01속도를 서로 주고받는 탄성 충돌 공
공과 공이 부딪히는 순간 두 공의 질량과 속도를 계산해 그 값을 서로 맞바꿔, 무거운 공은 적게 밀리고 가벼운 공은 크게 튕겨 나간다.
쓰임당구공처럼 부딪히는 인터랙티브 배경 장면
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">공과 공이 부딪히는 순간 두 공의 질량과 속도를 계산해 그 값을 서로 맞바꿔, 무거운 공은 적게 밀리고 가벼운 공은 크게 튕겨 나간다</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 속도를 서로 주고받는 탄성 충돌 공 — 측정 누적면적 40.801% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 바운싱 볼 애니메이션 9가지 — 충돌·중력 물리 계산
이 데모가 속한 글
바운싱 볼 애니메이션 9가지 — 충돌·중력 물리 계산01번 공은 반발계수 0.82로 서로 속도를 맞바꾸고, 04번 공은 같은 반발계수를 받고도 질량 차이 때문에 튀는 높이가 달라지며, 05번 공은 포인터가 가까워지면 그 계수 대신 반발력을 받아 흩어진다. 이 아홉 가 이 글의 데모 전부 보기


