04Rolling collisions
Circles and boxes with different masses swap velocity along the collision normal by the restitution value, and an off-center hit adds torque so the shape spins away as it bounces.
Best forA billiard-like loading scene, tumbling shapes on a 404 page
requestAnimationFrametranslaterotate

<main class="stage">
<div class="rb" id="rb">
<p class="rb__hint">
<svg class="rb__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224,128a96,96,0,0,1-96,96c-11.4,0-20-8.6-20-20s8.9-20,20-20a56,56,0,0,0,0-112c-11.1,0-20-8.9-20-20s8.6-20,20-20A96,96,0,0,1,224,128Z" opacity=".2"/><path d="M128,24A104,104,0,0,0,128,232c-15.8,0-28-12.3-28-28s12.4-28,28-28a48,48,0,0,0,0-96c-15.6,0-28-12.4-28-28S112.2,24,128,24Zm0,16c-6.9,0-12,5.2-12,12s5.3,12,12,12a64,64,0,0,1,0,128c-6.7,0-12,5.4-12,12s5.1,12,12,12a88,88,0,0,0,0-176Z"/></g></svg>
<span>Throw a shape — velocities swap along the collision normal and the off-center hit makes it roll away</span>
</p>
<div class="rb__field" id="field" role="application" aria-label="Rigid body collision board" tabindex="0">
<canvas class="rb__cv" id="cv"></canvas>
<b class="rb__badge" aria-hidden="true"><span class="rb__badgeNum" id="hitVal">0</span><span class="rb__badgeUnit">collisions</span></b>
</div>
<div class="rb__bar">
<label class="rb__dial">restitution
<input type="range" id="e" min="30" max="85" step="5" value="60" aria-label="restitution coefficient (%)">
</label>
<button class="rb__reset" id="reset" type="button">Reset</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 구르며 부딪히는 도형 — 움직임은 전부 캔버스 rAF 루프 안의 충격량·토크 계산에 있다.
// @keyframes 를 하나도 두지 않는다 — CSS 애니메이션이 0개여야 렌더러가 실시간으로 24장을 찍어
// 도형이 실제로 구르고 튕기는 모습이 담긴다(도구/demo.py 렌더(), 01·077 선례 "애니메이션": 0).
// 캔버스가 쓰는 색은 여기서 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
.rb {
--circ: #{$subject-sky};
--boxf: #{$subject-lilac};
--ring: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.rb__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.rb__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.rb__field {
position: relative; height: 160px;
border-radius: $r-card; overflow: hidden;
background: #211d26;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: grab; touch-action: none;
}
.rb__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rb__field:active { cursor: grabbing; }
.rb__cv { display: block; width: 100%; height: 100%; }
// 충돌 횟수 — 시뮬에서 갱신된다
.rb__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-cream, .88);
color: $subject-ink; font-weight: 700; font-size: 11px;
}
.rb__badgeNum { font-size: 13px; color: $subject-blue-deep; }
.rb__badgeUnit { font-weight: 700; }
.rb__bar {
display: flex; align-items: center; gap: $sp-3;
}
.rb__dial {
display: flex; align-items: center; gap: $sp-2; flex: 1 1 auto;
font-weight: 700; font-size: 12px; color: $subject-cream;
}
.rb__dial input[type="range"] { flex: 1 1 auto; accent-color: $color; }
.rb__reset {
flex: 0 0 auto; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; font-weight: 700; font-size: 12px;
background: $color; color: $subject-ink; cursor: pointer;
}
.rb__reset:active { transform: translateY(1px); }
@media (max-width: 340px) {
.rb { gap: $sp-1; }
.rb__field { height: 114px; }
.rb__badge { font-size: 10px; padding: 0 $sp-1; }
.rb__badgeNum { font-size: 11px; }
.rb__bar { gap: $sp-2; }
.rb__dial { font-size: 11px; }
.rb__reset { font-size: 11px; padding: $sp-1 $sp-2; }
}
// reduce — 자동 시연 루프는 JS 가 끈다(가상 던지기가 멈춘다).
// 집어 던지는 조작은 살아 있고 정지 장면만 그린다.
@media (prefers-reduced-motion: reduce) {
.rb.is-still .rb__field { cursor: default !important; }
.rb.is-still .rb__badge { opacity: .92 !important; }
}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);
}
.rb {
--circ: #6ec8ff;
--boxf: #b9a0ff;
--ring: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.rb__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.rb__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b9a0ff;
}
.rb__field {
position: relative;
height: 160px;
border-radius: 16px;
overflow: hidden;
background: #211d26;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: grab;
touch-action: none;
}
.rb__field:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.rb__field:active {
cursor: grabbing;
}
.rb__cv {
display: block;
width: 100%;
height: 100%;
}
.rb__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.88);
color: #17141a;
font-weight: 700;
font-size: 11px;
}
.rb__badgeNum {
font-size: 13px;
color: #0a4fe8;
}
.rb__badgeUnit {
font-weight: 700;
}
.rb__bar {
display: flex;
align-items: center;
gap: 12px;
}
.rb__dial {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
font-weight: 700;
font-size: 12px;
color: #fff7e6;
}
.rb__dial input[type=range] {
flex: 1 1 auto;
accent-color: #b9a0ff;
}
.rb__reset {
flex: 0 0 auto;
border: 0;
border-radius: 999px;
padding: 4px 12px;
font-weight: 700;
font-size: 12px;
background: #b9a0ff;
color: #17141a;
cursor: pointer;
}
.rb__reset:active {
transform: translateY(1px);
}
@media (max-width: 340px) {
.rb {
gap: 4px;
}
.rb__field {
height: 114px;
}
.rb__badge {
font-size: 10px;
padding: 0 4px;
}
.rb__badgeNum {
font-size: 11px;
}
.rb__bar {
gap: 8px;
}
.rb__dial {
font-size: 11px;
}
.rb__reset {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rb.is-still .rb__field {
cursor: default !important;
}
.rb.is-still .rb__badge {
opacity: 0.92 !important;
}
}// 구르며 부딪히는 도형 — 강체 충돌의 핵심 두 가지를 베어낸 미니 물리엔진.
// ① 충격량: 충돌 법선 방향 상대속도에 반발계수 e 를 곱해 운동량 보존(j = -(1+e)·vRel·n / (1/m1 + 1/m2))
// 으로 두 몸체에 나눠 준다. Verlet 에서는 "이전 위치를 충격량만큼 되돌리는 것"이 곧 속도 변화다.
// ② 토크: 충돌 접점이 중심에서 벗어난 팔(r × n)만큼 각속도를 얻는다 — 구르며 튕기는 이유.
// OBB(회전하는 사각형)와 원의 충돌은 원심을 사각 로컬 좌표로 옮겨 최근접점을 찍는 클램프 근사로 푼다.
var root = document.getElementById('rb');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var eIn = document.getElementById('e');
var hitVal = document.getElementById('hitVal');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var CIRC = css.getPropertyValue('--circ').trim();
var BOXF = css.getPropertyValue('--boxf').trim();
var RING = css.getPropertyValue('--ring').trim();
var W = 0, H = 0, dpr = 1;
var t0 = performance.now();
var GRAV = 1400; // 중력(px/s^2)
var DAMP = 0.9985; // 속도 보존률(공기 저항 아주 작게 — 굴러다녀야 한다)
var WALL_E = 0.72; // 벽·바닥 restitution(도형끼리 반발과 별개)
var FRICTION = 0.985; // 바닥 접점 접선 감쇠(미끄러짐)
var LOOP = 2; // 자동 시연 한 바퀴(초)
// 몸체: {kind:'c'|'b', x, y, px, py, r(반지름 또는 절반변), a(회전각), pa(이전 각), m(질량), im(역질량), col}
var bodies = [];
var hits = 0;
var hold = -1; // 잡고 있는 몸체 번호
var holdX = 0, holdY = 0, holdPX = 0, holdPY = 0; // 잡은 위치와 직전 위치(던지기 속도용)
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
build();
}
function build() {
bodies = []; hits = 0; hitVal.textContent = '0'; hold = -1;
var colIn = H * 0.34, gap = W / 6;
// 원 3 + 사각 2 + 원 1 — 질량은 반지름 세제곱 비례(면적 밀도 같은 셈)
var spec = [
{ k: 'c', r: 17, fx: 0.5, fy: 0.20, c: CIRC },
{ k: 'c', r: 13, fx: 0.30, fy: 0.46, c: CIRC },
{ k: 'c', r: 21, fx: 0.72, fy: 0.40, c: CIRC },
{ k: 'b', r: 15, fx: 0.42, fy: 0.62, c: BOXF, sq: 0.78 },
{ k: 'b', r: 18, fx: 0.62, fy: 0.30, c: BOXF, sq: 1.25 },
{ k: 'c', r: 11, fx: 0.86, fy: 0.66, c: CIRC }
];
for (var i = 0; i < spec.length; i++) {
var s = spec[i];
var m = s.k === 'c' ? s.r * s.r * s.r : s.r * s.r * s.r * (s.sq || 1) * 0.9;
var x = W * s.fx, y = H - colIn * (1 - s.fy) * 0.9;
bodies.push({
kind: s.k, x: x, y: y, px: x, py: y,
r: s.r, hw: s.r, hh: s.r * (s.sq || 1),
a: s.k === 'b' ? (i * 0.9 - 1.2) : 0, pa: s.k === 'b' ? (i * 0.9 - 1.2) : 0,
m: m, im: 1 / m, col: s.c
});
}
}
// 몸체 반지름(충돌 판정용 대략 크기)
function rad(b) { return b.kind === 'c' ? b.r : Math.max(b.hw, b.hh); }
// 원-원 충돌 → {nx, ny, pen, cx, cy}
function hitCC(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
var d = Math.hypot(dx, dy);
var rr = a.r + b.r;
if (d >= rr || d === 0) return null;
return { nx: dx / d, ny: dy / d, pen: rr - d, cx: a.x + dx / d * a.r, cy: a.y + dy / d * a.r };
}
// 점이 회전사각(OBB) 안/가장자리에서 사각 중심 기준 로컬 좌표로 변환
function toLocal(b, px2, py2) {
var c = Math.cos(b.a), s = Math.sin(b.a); // 월드→로컬은 역회전
var dx = px2 - b.x, dy = py2 - b.y;
return { x: dx * c + dy * s, y: -dx * s + dy * c };
}
// 원-OBB 충돌 — 원심을 로컬로 옮겨 클램프한 최근접점으로 판정
function hitCB(c, b) {
var l = toLocal(b, c.x, c.y);
var qx = Math.max(-b.hw, Math.min(b.hw, l.x));
var qy = Math.max(-b.hh, Math.min(b.hh, l.y));
var dx = l.x - qx, dy = l.y - qy;
var d = Math.hypot(dx, dy);
if (d >= c.r) return null;
var nx, ny, pen;
if (d > 0.0001) { nx = dx / d; ny = dy / d; pen = c.r - d; }
else {
// 원심이 사각 안 — 가장 얕은 축으로 밀어낸다
var ox = b.hw - Math.abs(l.x), oy = b.hh - Math.abs(l.y);
if (ox < oy) { nx = l.x >= 0 ? 1 : -1; ny = 0; pen = ox + c.r; }
else { nx = 0; ny = l.y >= 0 ? 1 : -1; pen = oy + c.r; }
}
var co = Math.cos(b.a), si = Math.sin(b.a); // 로컬 법선 → 월드로 회전
var wnx = nx * co - ny * si, wny = nx * si + ny * co;
return { nx: wnx, ny: wny, pen: pen, cx: qx, cy: qy };
}
function collide(a, b) {
if (a.kind === 'c' && b.kind === 'c') return hitCC(a, b);
if (a.kind === 'c' && b.kind === 'b') { var h = hitCB(a, b); if (h) { h.nx = -h.nx; h.ny = -h.ny; } return h; }
if (a.kind === 'b' && b.kind === 'c') return hitCB(b, a);
// 사각-사각은 원 근사(반지름 = 평균 반변)로만 밀어낸다 — 6개 몸체 미니엔진의 절충
var ra = (a.hw + a.hh) * 0.5, rb2 = (b.hw + b.hh) * 0.5;
var dx = b.x - a.x, dy = b.y - a.y;
var d = Math.hypot(dx, dy);
if (d >= (ra + rb2) * 0.9 || d === 0) return null;
return { nx: dx / d, ny: dy / d, pen: (ra + rb2) * 0.9 - d, cx: 0, cy: 0 };
}
// 충격량 적용 — Verlet 은 이전 위치를 되돌리는 것으로 속도를 바꾼다
function impulse(a, b, n, cx2, cy2, e) {
var avx = a.x - a.px, avy = a.y - a.py;
var bvx = b.x - b.px, bvy = b.y - b.py;
var vRel = (avx - bvx) * n.x + (avy - bvy) * n.y;
if (vRel > 0) return false; // 이미 떨어지는 중
var j = -(1 + e) * vRel / (a.im + b.im);
var jx = n.x * j, jy = n.y * j;
a.px -= jx * a.im; a.py -= jy * a.im; // 정방향 충격량 = 속도 증가
b.px += jx * b.im; b.py += jy * b.im;
// 토크 — 접점 팔이 중심에서 벗어났으면 각속도로 돌린다(사각만 받는다)
if (a.kind === 'b') {
var la = toLocal(a, cx2, cy2);
a.pa -= (la.x * n.y - la.y * n.x) * j * a.im * 0.00013;
}
if (b.kind === 'b') {
var lb = toLocal(b, cx2, cy2);
b.pa += (lb.x * n.y - lb.y * n.x) * j * b.im * 0.00013;
}
return true;
}
function step(now) {
var dt = Math.min((now - t0) / 1000, 0.033);
t0 = now;
var e = Number(eIn.value) / 100;
var i, j2, bd;
// (1) 관성 + 중력 + 회전 감쇠
for (i = 0; i < bodies.length; i++) {
bd = bodies[i];
if (i === hold) continue;
var vx = bd.x - bd.px;
var vy = bd.y - bd.py + GRAV * dt * dt;
bd.px = bd.x; bd.py = bd.y;
bd.x += vx * DAMP;
bd.y += vy * DAMP;
var na = bd.a + (bd.a - bd.pa) * 0.995;
bd.pa = bd.a; bd.a = na;
}
if (hold >= 0) {
var hb = bodies[hold];
hb.px = holdPX; hb.py = holdPY; // 직전 손 위치 → 이전 위치. 놓으면 이 차이가 던지기 속도
hb.x = holdX; hb.y = holdY;
hb.pa += (hb.a - hb.pa) * 0.8;
}
// (2) 짝 충돌 — 위치 보정(침투 밀어내기) + 충격량
for (i = 0; i < bodies.length; i++) {
for (j2 = i + 1; j2 < bodies.length; j2++) {
var A = bodies[i], B = bodies[j2];
if (i === hold && j2 === hold) continue;
var h = collide(A, B);
if (!h) continue;
var cxW, cyW;
if (A.kind === 'b' && B.kind === 'b') { cxW = (A.x + B.x) * 0.5; cyW = (A.y + B.y) * 0.5; }
else if (A.kind === 'b') {
var co = Math.cos(A.a), si = Math.sin(A.a);
cxW = A.x + h.cx * co - h.cy * si; cyW = A.y + h.cx * si + h.cy * co;
} else if (B.kind === 'b') {
var co2 = Math.cos(B.a), si2 = Math.sin(B.a);
cxW = B.x + h.cx * co2 - h.cy * si2; cyW = B.y + h.cx * si2 + h.cy * co2;
} else { cxW = h.cx; cyW = h.cy; }
// 침투 보정 — 질량 역수 비율로 나눠 밀어낸다(잡은 몸체는 움직이지 않는다)
var tot = (i === hold ? 0 : A.im) + (j2 === hold ? 0 : B.im);
if (tot > 0) {
var cor = Math.min(h.pen, 6) / tot * 0.55;
if (i !== hold) { A.x -= h.nx * cor * A.im; A.y -= h.ny * cor * A.im; }
if (j2 !== hold) { B.x += h.nx * cor * B.im; B.y += h.ny * cor * B.im; }
}
if (impulse(A, B, h, cxW, cyW, e)) { hits++; hitVal.textContent = hits; }
}
}
// (3) 벽·바닥 — 반사 + 바닥 접점 토크(구르기)
for (i = 0; i < bodies.length; i++) {
bd = bodies[i];
if (i === hold) continue;
var rr = rad(bd);
if (bd.x < rr) { bd.x = rr; bd.px = bd.x + (bd.x - bd.px) * WALL_E; bd.pa += (bd.py - bd.y) * 0.02; }
if (bd.x > W - rr) { bd.x = W - rr; bd.px = bd.x + (bd.x - bd.px) * WALL_E; bd.pa -= (bd.py - bd.y) * 0.02; }
if (bd.y > H - rr) {
var vy = bd.y - bd.py;
bd.y = H - rr;
bd.py = bd.y + vy * WALL_E; // restitution
var vx = bd.x - bd.px;
bd.px = bd.x - vx * FRICTION; // 마찰
bd.pa -= vx * 0.02 / bd.r; // 구르기 토크(접선 속도 → 회전)
}
if (bd.y < rr) { bd.y = rr; bd.py = bd.y + (bd.y - bd.py) * WALL_E; }
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
for (var i = 0; i < bodies.length; i++) {
var b = bodies[i];
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.a);
ctx.lineJoin = 'round';
ctx.lineWidth = 2.5;
ctx.strokeStyle = RING;
if (b.kind === 'c') {
ctx.fillStyle = b.col;
ctx.beginPath();
ctx.arc(0, 0, b.r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 회전이 보이는 표시 — 바깥 점 하나
ctx.fillStyle = RING;
ctx.beginPath();
ctx.arc(b.r * 0.55, 0, Math.max(2.5, b.r * 0.16), 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillStyle = b.col;
ctx.beginPath();
ctx.roundRect(-b.hw, -b.hh, b.hw * 2, b.hh * 2, 4);
ctx.fill();
ctx.stroke();
// 비대칭 마킹 — 어디가 위인지(회전) 알려준다
ctx.fillStyle = RING;
ctx.beginPath();
ctx.arc(b.hw * 0.45, -b.hh * 0.45, Math.max(2.5, b.hw * 0.14), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (hold >= 0) {
ctx.strokeStyle = RING;
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(bodies[hold].x, bodies[hold].y, rad(bodies[hold]) + 7, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
}
// 사람이 안 만졌을 때 도는 자동 시연 — 결정적 가상 "던지기".
// 한 주기마다 도형 하나를 사인파 경로로 끌어 올려 놓는다. Math.random 금지.
var manual = false;
var demoPhase = 0;
function demoThrow(dtSec) {
demoPhase += dtSec / LOOP;
var idx = Math.floor(demoPhase) % bodies.length;
var frac = demoPhase - Math.floor(demoPhase);
var b = bodies[idx];
if (!b) return;
var ph = demoPhase * Math.PI * 2;
if (frac < 0.62) {
// 잡는 구간 — 목표(들어 올리는 경로)를 따라간다. 이전 프레임 손 위치가 곧 이전 위치
var tx = W * 0.5 + Math.sin(ph) * W * 0.28;
var ty = H * 0.30 + Math.sin(ph * 2) * H * 0.08;
hold = bodies.indexOf(b);
holdPX = holdX; holdPY = holdY;
holdX = tx; holdY = ty;
} else if (hold >= 0) {
// 놓기 — 마지막 두 프레임 이동을 살짝 증폭해 던지기 속도로 만든다
holdPX = holdX - (holdX - holdPX) * 1.6;
holdPY = holdY - (holdY - holdPY) * 1.6;
hold = -1;
}
}
function frame(now) {
var dt = Math.min((now - t0) / 1000, 0.033);
if (!manual) demoThrow(dt);
step(now);
draw();
requestAnimationFrame(frame);
}
// 조작 — 도형을 집어 던진다. 놓는 순간의 이동 속도가 그대로 던지기 속도
function pick(x, y) {
var best = -1, bd2 = 30;
for (var i = 0; i < bodies.length; i++) {
var b = bodies[i];
var d = Math.hypot(b.x - x, b.y - y);
if (d < rad(b) + 12 && d < bd2 + rad(b)) { bd2 = d; best = i; }
}
return best;
}
field.addEventListener('pointerdown', function (e) {
var r = field.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
var idx = pick(x, y);
if (idx < 0) return;
manual = true;
hold = idx;
var b = bodies[idx];
holdX = b.x; holdY = b.y; holdPX = b.x; holdPY = b.y;
field.setPointerCapture(e.pointerId);
if (reduce.matches) { draw(); }
});
field.addEventListener('pointermove', function (e) {
if (hold < 0) return;
var r = field.getBoundingClientRect();
holdPX = holdX; holdPY = holdY;
holdX = Math.max(6, Math.min(W - 6, e.clientX - r.left));
holdY = Math.max(6, Math.min(H - 6, e.clientY - r.top));
});
window.addEventListener('pointerup', function () { hold = -1; });
document.getElementById('reset').addEventListener('click', function () {
manual = false; demoPhase = 0; build();
});
window.addEventListener('resize', fit);
fit();
requestAnimationFrame(frame);// RigidBodyCollide — vanilla 판(run/306/demo/04-rigid-body-collide-rotate/index.html) 의 React 포트.
// ① 충격량: 충돌 법선 방향 상대속도에 반발계수 e 를 곱한 운동량 보존으로 두 몸체에 나눠 준다.
// ② 토크: 충돌 접점이 중심에서 벗어난 팔만큼 각속도를 얻는다. OBB×원은 로컬 좌표 클램프 근사.
// Verlet 에서는 "이전 위치를 되돌리는 것"이 곧 속도 변화다. 값·주석 의미는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./RigidBodyCollide.module.scss";
export type RigidBodyCollideProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다 (292 선례)
type Box<T> = { current: T };
type InputEvt = { target: { value: string } };
type Body = {
kind: "c" | "b";
x: number; y: number; px: number; py: number;
r: number; hw: number; hh: number;
a: number; pa: number;
m: number; im: number;
};
type Hit = { nx: number; ny: number; pen: number; cx: number; cy: number };
const GRAV = 1400; // 중력(px/s^2)
const DAMP = 0.9985; // 속도 보존률
const WALL_E = 0.72; // 벽·바닥 반발
const FRICTION = 0.985; // 바닥 접선 감쇠
const LOOP = 2; // 자동 시연 한 바퀴(초)
const FIELD_LABEL = "강체 충돌 실험판";
export default function RigidBodyCollide({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
}: RigidBodyCollideProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const hitValRef = useRef<HTMLSpanElement | null>(null);
const eInRef = useRef<HTMLInputElement | null>(null);
const bodiesRef = useRef<Body[]>([]) as Box<Body[]>;
const sizeRef = useRef({ W: 0, H: 0 }) as Box<{ W: number; H: number }>;
const holdRef = useRef(-1) as Box<number>;
const holdPtRef = useRef({ x: 0, y: 0, px: 0, py: 0 }) as Box<{ x: number; y: number; px: number; py: number }>;
const hitsRef = useRef(0) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const demoPhaseRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(0) as Box<number>;
const [rest, setRest] = useState(60);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function ctx2d(): CanvasRenderingContext2D | null {
const cv = cvRef.current;
if (!cv) return null;
return cv.getContext("2d");
}
function build(): void {
const { W, H } = sizeRef.current;
const bodies: Body[] = [];
const colIn = H * 0.34;
// 원 4 + 사각 2 — 질량은 반지름 세제곱 비례(면적 밀도 같은 셈)
const spec: { k: "c" | "b"; r: number; fx: number; fy: number; sq?: number }[] = [
{ k: "c", r: 17, fx: 0.5, fy: 0.2 },
{ k: "c", r: 13, fx: 0.3, fy: 0.46 },
{ k: "c", r: 21, fx: 0.72, fy: 0.4 },
{ k: "b", r: 15, fx: 0.42, fy: 0.62, sq: 0.78 },
{ k: "b", r: 18, fx: 0.62, fy: 0.3, sq: 1.25 },
{ k: "c", r: 11, fx: 0.86, fy: 0.66 }
];
for (let i = 0; i < spec.length; i++) {
const s = spec[i];
const m = s.k === "c" ? s.r * s.r * s.r : s.r * s.r * s.r * (s.sq || 1) * 0.9;
const x = W * s.fx, y = H - colIn * (1 - s.fy) * 0.9;
const a0 = s.k === "b" ? i * 0.9 - 1.2 : 0;
bodies.push({
kind: s.k, x, y, px: x, py: y,
r: s.r, hw: s.r, hh: s.r * (s.sq || 1),
a: a0, pa: a0, m, im: 1 / m
});
}
bodiesRef.current = bodies;
hitsRef.current = 0;
if (hitValRef.current) hitValRef.current.textContent = "0";
holdRef.current = -1;
demoPhaseRef.current = 0;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctx2d();
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.W = r.width;
sizeRef.current.H = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
build();
}
// 몸체 반지름(충돌 판정용 대략 크기)
function rad(b: Body): number {
return b.kind === "c" ? b.r : Math.max(b.hw, b.hh);
}
// 원-원 충돌 → {nx, ny, pen, cx, cy}
function hitCC(a: Body, b: Body): Hit | null {
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const rr = a.r + b.r;
if (d >= rr || d === 0) return null;
return { nx: dx / d, ny: dy / d, pen: rr - d, cx: a.x + (dx / d) * a.r, cy: a.y + (dy / d) * a.r };
}
// 점을 회전사각(OBB) 중심 기준 로컬 좌표로 변환(월드→로컬은 역회전)
function toLocal(b: Body, px2: number, py2: number): { x: number; y: number } {
const c = Math.cos(b.a), s = Math.sin(b.a);
const dx = px2 - b.x, dy = py2 - b.y;
return { x: dx * c + dy * s, y: -dx * s + dy * c };
}
// 원-OBB 충돌 — 원심을 로컬로 옮겨 클램프한 최근접점으로 판정
function hitCB(c: Body, b: Body): Hit | null {
const l = toLocal(b, c.x, c.y);
const qx = Math.max(-b.hw, Math.min(b.hw, l.x));
const qy = Math.max(-b.hh, Math.min(b.hh, l.y));
const dx = l.x - qx, dy = l.y - qy;
const d = Math.hypot(dx, dy);
if (d >= c.r) return null;
let nx: number, ny: number, pen: number;
if (d > 0.0001) { nx = dx / d; ny = dy / d; pen = c.r - d; }
else {
// 원심이 사각 안 — 가장 얕은 축으로 밀어낸다
const ox = b.hw - Math.abs(l.x), oy = b.hh - Math.abs(l.y);
if (ox < oy) { nx = l.x >= 0 ? 1 : -1; ny = 0; pen = ox + c.r; }
else { nx = 0; ny = l.y >= 0 ? 1 : -1; pen = oy + c.r; }
}
const co = Math.cos(b.a), si = Math.sin(b.a); // 로컬 법선 → 월드로 회전
return { nx: nx * co - ny * si, ny: nx * si + ny * co, pen, cx: qx, cy: qy };
}
function collide(a: Body, b: Body): Hit | null {
if (a.kind === "c" && b.kind === "c") return hitCC(a, b);
if (a.kind === "c" && b.kind === "b") {
const h = hitCB(a, b);
if (h) { h.nx = -h.nx; h.ny = -h.ny; }
return h;
}
if (a.kind === "b" && b.kind === "c") return hitCB(b, a);
// 사각-사각은 원 근사로만 밀어낸다 — 6개 몸체 미니엔진의 절충
const ra = (a.hw + a.hh) * 0.5, rb2 = (b.hw + b.hh) * 0.5;
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.hypot(dx, dy);
if (d >= (ra + rb2) * 0.9 || d === 0) return null;
return { nx: dx / d, ny: dy / d, pen: (ra + rb2) * 0.9 - d, cx: 0, cy: 0 };
}
// 충격량 적용 — Verlet 은 이전 위치를 되돌리는 것으로 속도를 바꾼다
function impulse(a: Body, b: Body, n: { x: number; y: number }, cx2: number, cy2: number, e: number): boolean {
const avx = a.x - a.px, avy = a.y - a.py;
const bvx = b.x - b.px, bvy = b.y - b.py;
const vRel = (avx - bvx) * n.x + (avy - bvy) * n.y;
if (vRel > 0) return false; // 이미 떨어지는 중
const j = (-(1 + e) * vRel) / (a.im + b.im);
const jx = n.x * j, jy = n.y * j;
a.px -= jx * a.im; a.py -= jy * a.im;
b.px += jx * b.im; b.py += jy * b.im;
// 토크 — 접점 팔이 중심에서 벗어났으면 각속도로 돌린다(사각만 받는다)
if (a.kind === "b") {
const la = toLocal(a, cx2, cy2);
a.pa -= (la.x * n.y - la.y * n.x) * j * a.im * 0.00013;
}
if (b.kind === "b") {
const lb = toLocal(b, cx2, cy2);
b.pa += (lb.x * n.y - lb.y * n.x) * j * b.im * 0.00013;
}
return true;
}
function step(now: number): void {
const { W, H } = sizeRef.current;
const bodies = bodiesRef.current;
const dt = Math.min((now - t0Ref.current) / 1000, 0.033);
t0Ref.current = now;
const e = eInRef.current ? Number(eInRef.current.value) / 100 : 0.6;
const hold = holdRef.current;
// (1) 관성 + 중력 + 회전 감쇠
for (let i = 0; i < bodies.length; i++) {
const bd = bodies[i];
if (i === hold) continue;
const vx = bd.x - bd.px;
const vy = bd.y - bd.py + GRAV * dt * dt;
bd.px = bd.x; bd.py = bd.y;
bd.x += vx * DAMP;
bd.y += vy * DAMP;
const na = bd.a + (bd.a - bd.pa) * 0.995;
bd.pa = bd.a; bd.a = na;
}
if (hold >= 0) {
const hb = bodies[hold];
hb.px = holdPtRef.current.px; hb.py = holdPtRef.current.py; // 직전 손 위치 → 놓으면 던지기 속도
hb.x = holdPtRef.current.x; hb.y = holdPtRef.current.y;
hb.pa += (hb.a - hb.pa) * 0.8;
}
// (2) 짝 충돌 — 위치 보정 + 충격량
for (let i = 0; i < bodies.length; i++) {
for (let j2 = i + 1; j2 < bodies.length; j2++) {
const A = bodies[i], B = bodies[j2];
const h = collide(A, B);
if (!h) continue;
let cxW: number, cyW: number;
if (A.kind === "b" && B.kind === "b") { cxW = (A.x + B.x) * 0.5; cyW = (A.y + B.y) * 0.5; }
else if (A.kind === "b") {
const co = Math.cos(A.a), si = Math.sin(A.a);
cxW = A.x + h.cx * co - h.cy * si; cyW = A.y + h.cx * si + h.cy * co;
} else if (B.kind === "b") {
const co2 = Math.cos(B.a), si2 = Math.sin(B.a);
cxW = B.x + h.cx * co2 - h.cy * si2; cyW = B.y + h.cx * si2 + h.cy * co2;
} else { cxW = h.cx; cyW = h.cy; }
// 침투 보정 — 질량 역수 비율로 나눠 밀어낸다(잡은 몸체는 안 움직인다)
const tot = (i === hold ? 0 : A.im) + (j2 === hold ? 0 : B.im);
if (tot > 0) {
const cor = (Math.min(h.pen, 6) / tot) * 0.55;
if (i !== hold) { A.x -= h.nx * cor * A.im; A.y -= h.ny * cor * A.im; }
if (j2 !== hold) { B.x += h.nx * cor * B.im; B.y += h.ny * cor * B.im; }
}
if (impulse(A, B, { x: h.nx, y: h.ny }, cxW, cyW, e)) {
hitsRef.current++;
if (hitValRef.current) hitValRef.current.textContent = String(hitsRef.current);
}
}
}
// (3) 벽·바닥 — 반사 + 바닥 접점 토크(구르기)
for (let i = 0; i < bodies.length; i++) {
const bd = bodies[i];
if (i === hold) continue;
const rr = rad(bd);
if (bd.x < rr) { bd.x = rr; bd.px = bd.x + (bd.x - bd.px) * WALL_E; bd.pa += (bd.py - bd.y) * 0.02; }
if (bd.x > W - rr) { bd.x = W - rr; bd.px = bd.x + (bd.x - bd.px) * WALL_E; bd.pa -= (bd.py - bd.y) * 0.02; }
if (bd.y > H - rr) {
const vy = bd.y - bd.py;
bd.y = H - rr;
bd.py = bd.y + vy * WALL_E; // 반발
const vx = bd.x - bd.px;
bd.px = bd.x - vx * FRICTION; // 마찰
bd.pa -= (vx * 0.02) / bd.r; // 구르기 토크(접선 속도 → 회전)
}
if (bd.y < rr) { bd.y = rr; bd.py = bd.y + (bd.y - bd.py) * WALL_E; }
}
}
function draw(circ: string, boxf: string, ring: string): void {
const ctx = ctx2d();
if (!ctx) return;
const bodies = bodiesRef.current;
ctx.clearRect(0, 0, sizeRef.current.W, sizeRef.current.H);
for (let i = 0; i < bodies.length; i++) {
const b = bodies[i];
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.a);
ctx.lineJoin = "round";
ctx.lineWidth = 2.5;
ctx.strokeStyle = ring;
if (b.kind === "c") {
ctx.fillStyle = circ;
ctx.beginPath();
ctx.arc(0, 0, b.r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 회전이 보이는 표시 — 바깥 점 하나
ctx.fillStyle = ring;
ctx.beginPath();
ctx.arc(b.r * 0.55, 0, Math.max(2.5, b.r * 0.16), 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillStyle = boxf;
ctx.beginPath();
ctx.roundRect(-b.hw, -b.hh, b.hw * 2, b.hh * 2, 4);
ctx.fill();
ctx.stroke();
// 비대칭 마킹 — 어디가 위인지(회전) 알려준다
ctx.fillStyle = ring;
ctx.beginPath();
ctx.arc(b.hw * 0.45, -b.hh * 0.45, Math.max(2.5, b.hw * 0.14), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (holdRef.current >= 0) {
ctx.strokeStyle = ring;
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(bodies[holdRef.current].x, bodies[holdRef.current].y, rad(bodies[holdRef.current]) + 7, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
}
// 사람이 안 만졌을 때 도는 자동 시연 — 결정적 가상 "던지기"(사인파, 난수 없음)
function demoThrow(dtSec: number): void {
const { W, H } = sizeRef.current;
const bodies = bodiesRef.current;
demoPhaseRef.current += dtSec / LOOP;
const idx = Math.floor(demoPhaseRef.current) % bodies.length;
const frac = demoPhaseRef.current - Math.floor(demoPhaseRef.current);
const b = bodies[idx];
if (!b) return;
const ph = demoPhaseRef.current * Math.PI * 2;
const hp = holdPtRef.current;
if (frac < 0.62) {
// 잡는 구간 — 목표(들어 올리는 경로)를 따라간다
hp.px = hp.x; hp.py = hp.y;
hp.x = W * 0.5 + Math.sin(ph) * W * 0.28;
hp.y = H * 0.3 + Math.sin(ph * 2) * H * 0.08;
holdRef.current = idx;
} else if (holdRef.current >= 0) {
// 놓기 — 마지막 두 프레임 이동을 증폭해 던지기 속도로
hp.px = hp.x - (hp.x - hp.px) * 1.6;
hp.py = hp.y - (hp.y - hp.py) * 1.6;
holdRef.current = -1;
}
}
useEffect(() => {
fit();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
// 캔버스가 쓰는 색은 module.scss 의 커스텀 프로퍼티에서 읽는다 — 값은 스타일 한 곳에만 둔다
const css = getComputedStyle(rootRef.current as Element);
const circ = css.getPropertyValue("--circ").trim() || "#6ec8ff";
const boxf = css.getPropertyValue("--boxf").trim() || "#b9a0ff";
const ring = css.getPropertyValue("--ring").trim() || "#fff7e6";
t0Ref.current = performance.now();
function frame(now: number): void {
const dt = Math.min((now - t0Ref.current) / 1000, 0.033);
if (!manualRef.current) demoThrow(dt);
step(now);
draw(circ, boxf, ring);
rafRef.current = requestAnimationFrame(frame);
}
if (!reduce.matches) rafRef.current = requestAnimationFrame(frame);
else { step(performance.now()); draw(circ, boxf, ring); }
function onResize(): void { fit(); }
function onReduceChange(): void {
if (reduce.matches) {
manualRef.current = true;
holdRef.current = -1;
cancelAnimationFrame(rafRef.current);
} else if (!manualRef.current) {
t0Ref.current = performance.now();
rafRef.current = requestAnimationFrame(frame);
}
}
window.addEventListener("resize", onResize);
reduce.addEventListener("change", onReduceChange);
return () => {
window.removeEventListener("resize", onResize);
reduce.removeEventListener("change", onReduceChange);
cancelAnimationFrame(rafRef.current);
};
}, []);
// 조작 — 도형을 집어 던진다. 놓는 순간의 이동 속도가 그대로 던지기 속도
function pick(x: number, y: number): number {
const bodies = bodiesRef.current;
let best = -1, bd2 = Infinity;
for (let i = 0; i < bodies.length; i++) {
const b = bodies[i];
const d = Math.hypot(b.x - x, b.y - y);
if (d < rad(b) + 12 && d - rad(b) < bd2) { bd2 = d - rad(b); best = i; }
}
return best;
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const x = event.clientX - r.left, y = event.clientY - r.top;
const idx = pick(x, y);
if (idx < 0) return;
manualRef.current = true;
holdRef.current = idx;
const b = bodiesRef.current[idx];
holdPtRef.current = { x: b.x, y: b.y, px: b.x, py: b.y };
field.setPointerCapture(event.pointerId);
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
if (holdRef.current < 0) return;
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const hp = holdPtRef.current;
hp.px = hp.x; hp.py = hp.y;
hp.x = Math.max(6, Math.min(sizeRef.current.W - 6, event.clientX - r.left));
hp.y = Math.max(6, Math.min(sizeRef.current.H - 6, event.clientY - r.top));
}
function onPointerUp(): void {
holdRef.current = -1;
}
function onReset(): void {
manualRef.current = false;
build();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reduce.matches) return; // 줄인 모드에서는 정지 장면만 둔다
t0Ref.current = performance.now();
cancelAnimationFrame(rafRef.current);
const css = getComputedStyle(rootRef.current as Element);
const circ = css.getPropertyValue("--circ").trim() || "#6ec8ff";
const boxf = css.getPropertyValue("--boxf").trim() || "#b9a0ff";
const ring = css.getPropertyValue("--ring").trim() || "#fff7e6";
rafRef.current = requestAnimationFrame(function loop(now: number): void {
const dt = Math.min((now - t0Ref.current) / 1000, 0.033);
if (!manualRef.current) demoThrow(dt);
step(now);
draw(circ, boxf, ring);
rafRef.current = requestAnimationFrame(loop);
});
}
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224,128a96,96,0,0,1-96,96c-11.4,0-20-8.6-20-20s8.9-20,20-20a56,56,0,0,0,0-112c-11.1,0-20-8.9-20-20s8.6-20,20-20A96,96,0,0,1,224,128Z" opacity=".2"/><path d="M128,24A104,104,0,0,0,128,232c-15.8,0-28-12.3-28-28s12.4-28,28-28a48,48,0,0,0,0-96c-15.6,0-28-12.4-28-28S112.2,24,128,24Zm0,16c-6.9,0-12,5.2-12,12s5.3,12,12,12a64,64,0,0,1,0,128c-6.7,0-12,5.4-12,12s5.1,12,12,12a88,88,0,0,0,0-176Z"/></g></svg>
<span>도형을 잡아 던지면 충돌 법선으로 속도를 주고받고 접점이 치우친 만큼 굴러갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={hitValRef}>0</span><span className={styles.badgeUnit}>충돌 횟수</span></b>
</div>
<div className={styles.bar}>
<label className={styles.dial}>반발
<input
ref={eInRef}
type="range" min={30} max={85} step={5} value={rest} aria-label="반발계수(%)"
onChange={(event: InputEvt) => setRest(Number(event.target.value))}
/>
</label>
<button className={styles.reset} type="button" onClick={onReset}>처음으로</button>
</div>
</div>
</main>
);
}04 Rolling collisions — Measured area 6.933% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Verlet Physics 9 Demos: No Library, Just Canvas
The post this demo lives in
Verlet Physics 9 Demos: No Library, Just CanvasVerlet physics moves a point by the gap between where it is now and where it was last frame — no velocity stored. Nine c See all demos in this post
If you like this effect - similar demos
Creative UI01 Spring followJavaScript Physics Simulation: 9 Demos, No Library
Creative UI02 Bird silhouettes — feathers whose wingbeat phases drift apart9 Canvas Boids Algorithm Flocking Birds Simulation
Creative UI04 The six-fold mirror — fold by 60 degrees and grow six times fasterSnowflake Generator, 9 Ways (DLA, No Library)