01Ball Pair Exchanging Velocity Through an Elastic Collision
The moment two balls touch, their mass and speed are calculated and swapped between them, so the heavier ball barely moves while the lighter one shoots away.
Best forBilliard-like interactive background scene
BALL_RESTITUTIONWALL_RESTITUTIONresolvePair

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">The instant two balls collide, their mass and velocity are used to swap momentum between them, so the heavier ball is pushed less and the lighter one bounces off harder.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: 두 공이 겹치는 순간 접촉면 법선(n)을 구해 각 공의 속도를 법선·접선 성분으로
// 나눈다. 접선 성분은 그대로 두고 법선 성분에만 반발계수(BALL_RESTITUTION)를 적용한
// 충격량 공식을 풀어 질량이 다른 두 공이 속도를 주고받게 한다 — 질량이 큰 쪽은
// 적게 밀리고 작은 쪽은 크게 튕긴다. 벽은 에너지를 보존해(WALL_RESTITUTION = 1) 장면이
// 계속 살아 있게 한다.
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var BALL_RESTITUTION = 0.82;
var WALL_RESTITUTION = 1.0;
var STAGE_BG = '23,20,26';
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(427011);
var COLORS = ['#ff9ebb', '#4cd4a6', '#b9a0ff', '#6ec8ff'];
var balls = [];
function makeBalls() {
var radii = [12, 16, 20, 24];
balls = [];
for (var i = 0; i < radii.length; i += 1) {
var r = radii[i];
var ang = rand() * Math.PI * 2;
var spd = 90 + rand() * 90;
balls.push({
x: r + rand() * (CALC_W - r * 2),
y: r + rand() * (CALC_H - r * 2),
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
r: r,
mass: r,
color: COLORS[i % COLORS.length],
squash: 0
});
}
}
makeBalls();
var impacts = [];
function resolveWalls(b) {
var hit = 0;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = Math.max(hit, Math.abs(b.vx)); }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = Math.max(hit, Math.abs(b.vx)); }
if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = Math.max(hit, Math.abs(b.vy)); }
if (b.y + b.r > CALC_H) { b.y = CALC_H - b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = Math.max(hit, Math.abs(b.vy)); }
if (hit > 0) b.squash = Math.max(b.squash, Math.min(1, hit / 260));
}
function resolvePair(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.hypot(dx, dy);
var minDist = a.r + b.r;
if (dist <= 0 || dist >= minDist) return;
var nx = dx / dist, ny = dy / dist;
var overlap = minDist - dist;
var totalMass = a.mass + b.mass;
a.x -= nx * overlap * (b.mass / totalMass);
a.y -= ny * overlap * (b.mass / totalMass);
b.x += nx * overlap * (a.mass / totalMass);
b.y += ny * overlap * (a.mass / totalMass);
var rvx = b.vx - a.vx, rvy = b.vy - a.vy;
var vn = rvx * nx + rvy * ny;
if (vn >= 0) return;
var j = -(1 + BALL_RESTITUTION) * vn / (1 / a.mass + 1 / b.mass);
var ix = j * nx, iy = j * ny;
a.vx -= ix / a.mass; a.vy -= iy / a.mass;
b.vx += ix / b.mass; b.vy += iy / b.mass;
var strength = Math.min(1, Math.abs(vn) / 220);
a.squash = Math.max(a.squash, strength);
b.squash = Math.max(b.squash, strength);
impacts.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, born: elapsed, strength: strength });
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
b.x += b.vx * dtS;
b.y += b.vy * dtS;
resolveWalls(b);
b.squash = Math.max(0, b.squash - dtS * 3.2);
}
for (var p = 0; p < balls.length; p += 1) {
for (var q = p + 1; q < balls.length; q += 1) resolvePair(balls[p], balls[q]);
}
var live = [];
for (var k = 0; k < impacts.length; k += 1) {
if (nowMs - impacts[k].born < 340) live.push(impacts[k]);
}
impacts = live;
}
function drawBall(sx, sy, b) {
var px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
var stretchX = 1 + b.squash * 0.5;
var stretchY = 1 - b.squash * 0.35;
ctx.save();
ctx.translate(px, py);
ctx.scale(stretchX, stretchY);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
ctx.restore();
}
function draw(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.fillStyle = 'rgba(' + STAGE_BG + ',0.22)';
ctx.fillRect(0, 0, cw, ch);
for (var i = 0; i < impacts.length; i += 1) {
var im = impacts[i];
var f = (nowMs - im.born) / 340;
var px = im.x * sx, py = im.y * sy;
var pr = (10 + f * 26) * ((sx + sy) / 2);
ctx.beginPath();
ctx.arc(px, py, pr, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,' + (im.strength * (1 - f) * 0.6) + ')';
ctx.lineWidth = 2;
ctx.stroke();
}
for (var j = 0; j < balls.length; j += 1) drawBall(sx, sy, balls[j]);
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
ctx.fillStyle = 'rgb(' + STAGE_BG + ')';
ctx.fillRect(0, 0, cw, ch);
draw(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 70 : 60;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 16; stepSim(16, elapsed); }
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepSim(FIXED_DT, elapsed); acc -= FIXED_DT; }
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsElasticCollision.module.scss";
type Ball = { x: number; y: number; vx: number; vy: number; r: number; mass: number; color: string; squash: number };
type Impact = { x: number; y: number; born: number; strength: number };
export default function BallPhysicsElasticCollision() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 320, CALC_H = 200;
const BALL_RESTITUTION = 0.82;
const WALL_RESTITUTION = 1.0;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(427011);
const RADII = [12, 16, 20, 24];
const COLORS = ["#ff9ebb", "#4cd4a6", "#b9a0ff", "#6ec8ff"];
let balls: Ball[] = [];
function makeBalls() {
balls = [];
for (let i = 0; i < RADII.length; i += 1) {
const r = RADII[i];
balls.push({
x: 40 + i * ((CALC_W - 80) / (RADII.length - 1)),
y: 30 + rand() * (CALC_H - 60),
vx: (rand() - 0.5) * 160,
vy: (rand() - 0.5) * 160,
r, mass: r,
color: COLORS[i],
squash: 0
});
}
}
makeBalls();
let impacts: Impact[] = [];
function resolveWalls(b: Ball) {
let hit = false;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
if (b.y + b.r > CALC_H) { b.y = CALC_H - b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
if (hit) b.squash = Math.max(b.squash, Math.min(0.7, Math.hypot(b.vx, b.vy) / 340));
}
function resolvePair(a: Ball, b: Ball, nowMs: number) {
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.hypot(dx, dy);
const minDist = a.r + b.r;
if (dist === 0 || dist >= minDist) return;
const nx = dx / dist, ny = dy / dist;
const overlap = minDist - dist;
const totalMass = a.mass + b.mass;
a.x -= nx * overlap * (b.mass / totalMass);
a.y -= ny * overlap * (b.mass / totalMass);
b.x += nx * overlap * (a.mass / totalMass);
b.y += ny * overlap * (a.mass / totalMass);
const rvx = b.vx - a.vx, rvy = b.vy - a.vy;
const velAlongNormal = rvx * nx + rvy * ny;
if (velAlongNormal > 0) return;
const j = -(1 + BALL_RESTITUTION) * velAlongNormal / (1 / a.mass + 1 / b.mass);
const ix = j * nx, iy = j * ny;
a.vx -= ix / a.mass; a.vy -= iy / a.mass;
b.vx += ix / b.mass; b.vy += iy / b.mass;
const strength = Math.min(1, Math.abs(velAlongNormal) / 300);
a.squash = Math.max(a.squash, strength);
b.squash = Math.max(b.squash, strength);
impacts.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, born: nowMs, strength });
}
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
b.x += b.vx * dtS;
b.y += b.vy * dtS;
resolveWalls(b);
b.squash = Math.max(0, b.squash - dtS * 3.4);
}
for (let a = 0; a < balls.length; a += 1) {
for (let c = a + 1; c < balls.length; c += 1) resolvePair(balls[a], balls[c], nowMs);
}
impacts = impacts.filter((im) => nowMs - im.born < 240);
}
function drawBall(sx: number, sy: number, b: Ball) {
const px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
const ang = Math.atan2(b.vy, b.vx);
const stretch = 1 + Math.min(0.4, Math.hypot(b.vx, b.vy) / 700) + b.squash * 0.2;
ctx!.save();
ctx!.translate(px, py);
ctx!.rotate(ang);
ctx!.scale(stretch, 1 / stretch);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = b.color;
ctx!.fill();
ctx!.restore();
}
let cw = 480, ch = 300;
function draw(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.fillStyle = "rgba(23,20,26,0.22)";
ctx!.fillRect(0, 0, cw, ch);
for (const im of impacts) {
const f = (nowMs - im.born) / 240;
ctx!.beginPath();
ctx!.arc(im.x * sx, im.y * sy, (6 + f * 20) * ((sx + sy) / 2), 0, Math.PI * 2);
ctx!.strokeStyle = "rgba(255,255,255," + (im.strength * (1 - f) * 0.5) + ")";
ctx!.lineWidth = 2;
ctx!.stroke();
}
for (const b of balls) drawBall(sx, sy, b);
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = 60;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 16; stepSim(16, elapsed); }
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepSim(FIXED_DT, elapsed); acc -= FIXED_DT; }
draw(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>공과 공이 부딪히는 순간 두 공의 질량과 속도를 계산해 그 값을 서로 맞바꿔, 무거운 공은 적게 밀리고 가벼운 공은 크게 튕겨 나간다</p>
</div>
</main>
);
}01 Ball Pair Exchanging Velocity Through an Elastic Collision — Measured area 40.801% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Bouncing Ball Animation Patterns With Physics
The post this demo lives in
9 Bouncing Ball Animation Patterns With PhysicsThis bouncing ball animation set has nine builds below. Ball 01 trades velocity through a 0.82 restitution coefficient, See all demos in this post


