GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

자바스크립트 물리엔진 원리 9종 — Verlet 로프부터 래그돌까지

자바스크립트 물리엔진은 로프가 늘어나고 천이 찢어지며 젤리가 출렁이는 움직임을 계산으로 만들어 냅니다. 이 아홉 판은 라이브러리 없이 캔버스와 Verlet 적분만으로 관절 인형·부력·도미노 연쇄·액체 방울까지 움직이고, 전체 코드를 그대로 내려줍니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 재료가 자라나는 순서다. 01 로프는 점 열네 개를 거리 제약으로 잇는 가장 작은 몸체고, 02 천은 그 제약을 격자로 넓힌 것이며, 03 젤리는 그물을 감싸 안쪽 압력으로 부피를 준 것이다. 04·05는 여기에 회전과 관절이 붙어 강체와 인형이 되고, 06 부력과 07 도미노는 물과 연쇄라는 조건이 하나씩 얹힌다. 08 깃발은 격자에 시간에 따라 바뀌는 바람을 더하고, 09 액체는 점 자체를 입자로 흩뿌린다 — 물리엔진 개발에서 가장 나중에 도달하는 재료다. 판은 애니메이션 차트 9종 과 같은 캔버스를 쓰지만, 그림을 그리는 쪽이 아니라 물리가 계산하는 쪽에 서 있다.

물리엔진 시뮬레이션과 무엇이 다른가

먼저 나온 물리 시뮬레이션 만들기 9종 은 요소에 힘을 더해 위치를 밀어내는 방식이었다 — 스프링 오버슛, 자유낙하, 단진자, 역제곱 인력, 벽 반사. 이번 아홉 판은 속도를 변수로 두지 않는 Verlet 적분과, 늘어난 제약을 여러 번 되돌리는 반복 풀이로 같은 역할에 도달한다. 어느 쪽이 나은 게 아니라 계산의 출발점이 다르고, 그 차이가 말랑한 몸체를 다룰 수 있는지를 가른다.

속도 변수를 두지 않는 적분

Verlet 적분은 다음 위치를 "지금 위치 - 이전 위치"의 차이로 계산한다. 속도를 따로 저장하지 않으니 충돌이나 제약으로 위치를 강제로 되돌려도 다음 프레임이 저절로 일관되게 이어진다. 속도를 저장하는 적분에서는 위치를 강제로 옮기는 순간 속도와 어긋나지만, 위치만 있으면 어긋날 틈 자체가 없다.

제약을 반복해서 푸는 구조

거리 제약 하나가 하는 일은 두 점을 목표 거리로 되돌리는 것뿐이다. 로프·천·젤리·래그돌 전부 이 한 동작을 판마다 몇 번씩 반복해 전체가 수렴하게 만든다. 01의 슬라이더로 반복 수를 3에서 8로 올리면 로프가 눈에 보이게 팽팽해진다.

회전까지 붙는 강체 충돌

04는 여기에 충돌 법선으로 속도를 주고받는 충격량과, 접점이 중심에서 벗어난 만큼 생기는 회전 토크를 얹는다. 점에 각도를 함께 적분하면 도형이 구르면서 튕겨 나간다. 물리엔진 사이트들이 소개하는 라이브러리 안에서도 이 두 계산이 가장 무거운 자리를 차지한다.

01체인 로프 흔들기

무엇이 보이나

끝 점을 잡아 끌면 로프 전체가 거리 제약을 반복해 풀며 따라온다. 드래그로 당기는 놀이형 배너나 끌어서 여는 서랍 손잡이에 그대로 얹을 수 있다.

체인 로프 흔들기
새 창에서 열기

01 · 체인 로프 흔들기 — 측정 누적면적 10.067% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramepointerdownpointermovetranslateY

코드에서 볼 곳

렌더 실측은 누적면적 10.067% · 강도(픽셀 변화량 평균) 135.4 · 움직인 프레임 23/23 이다.

  for (var k = 0; k < iters; k++) {
    for (var j = 0; j < N - 1; j++) {
      var a = pts[j], b = pts[j + 1];
      var dx = b.x - a.x, dy = b.y - a.y;
      var d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
      var off = (d - SEG) / d * 0.5;
      var ax = dx * off, ay = dy * off;
      if (!a.pinned && j !== dragging || k === 0 && j !== dragging && !a.pinned) { /* no-op */ }
      var aFree = !a.pinned && j !== dragging;
      var bFree = !b.pinned && j + 1 !== dragging;
      if (aFree && bFree) { a.x += ax; a.y += ay; b.x -= ax; b.y -= ay; }
      else if (aFree) { a.x += ax * 2; a.y += ay * 2; }
      else if (bFree) { b.x -= ax * 2; b.y -= ay * 2; }
    }
  }
  if (dragging >= 0) { pts[dragging].x = dragX; pts[dragging].y = dragY; }

02천 찢어지는 그물

무엇이 보이나

그물코를 잡아 세게 당기면 목표 거리의 배수만큼 늘어난 제약만 끊어져 구멍이 남는다. 끊긴 제약은 다시 이어지지 않고, 새 천 버튼으로 처음부터 볼 수 있다.

천 찢어지는 그물
새 창에서 열기

02 · 천 찢어지는 그물 — 측정 누적면적 11.325% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramepointerdownpointermovetranslateY

코드에서 볼 곳

측정 기록은 누적면적 11.325% · 강도 70.5 · 움직인 프레임 23/23 이다.

  for (var k = 0; k < 3; k++) {
    for (var j = 0; j < links.length; j++) {
      var L = links[j];
      if (L.dead) continue;
      var a = pts[L.a], b = pts[L.b];
      var dx = b.x - a.x, dy = b.y - a.y;
      var d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
      // 찢어짐 판정 — 목표 거리의 tear 배만큼 늘어난 제약은 영구히 끊는다
      if (d > L.len * tear) {
        L.dead = true; torn++;
        ripVal.textContent = String(torn);
        continue;
      }
      var off = (d - L.len) / d * 0.5;
      var ax = dx * off, ay = dy * off;
      var aFree = !a.pin && L.a !== dragging;
      var bFree = !b.pin && L.b !== dragging;
      if (aFree && bFree) { a.x += ax; a.y += ay; b.x -= ax; b.y -= ay; }
      else if (aFree) { a.x += ax * 2; a.y += ay * 2; }
      else if (bFree) { b.x -= ax * 2; b.y -= ay * 2; }
    }

03말랑 젤리 압력

무엇이 보이나

젤리를 누르면 줄어든 면적만큼 밀어내는 압력이 커지고, 손을 떼면 본래 부피를 찾아 출렁인다. 좋아요 버튼처럼 눌렀다 떼는 말랑한 피드백에 어울린다.

말랑 젤리 압력
새 창에서 열기

03 · 말랑 젤리 압력 — 측정 누적면적 6.381% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramepointerdowntranslateY

코드에서 볼 곳

프레임 실측은 누적면적 6.381% · 강도 136.5 · 움직인 프레임 23/23 이다.

  // (2) 압력 — 면적 부족분을 법선 방향 힘으로. 법선은 이웃 점의 차분으로 계산한다
  var A = area();
  var press = (AREA0 - A) / AREA0 * k * 7200 * dt * dt;
  for (var v = 0; v < N; v++) {
    var q = pts[v];
    var pr = pts[(v + N - 1) % N], nx2 = pts[(v + 1) % N];
    var tx = nx2.x - pr.x, ty = nx2.y - pr.y;
    var tl = Math.hypot(tx, ty) || 0.0001;
    // 점은 반시계로 배치했다(슈루스호크 양수). 이 순회에서 바깥 법선은 (ty, -tx) —
    // 부호를 반대로 쓰면 부족한 면적을 안쪽으로 밀어 젤리가 스스로 접혀버린다
    q.nx = ty / tl; q.ny = -tx / tl;
    q.x += q.nx * press;
    q.y += q.ny * press;
  }

04구르며 부딪히는 도형

무엇이 보이나

도형을 잡아 던지면 충돌 법선 방향으로 속도를 주고받고 접점이 치우친 만큼 굴러간다. 반발 슬라이더로 튕기는 세기를 정한다.

구르며 부딪히는 도형
새 창에서 열기

04 · 구르며 부딪히는 도형 — 측정 누적면적 6.933% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFrametranslaterotate

코드에서 볼 곳

실측은 누적면적 6.933% · 강도 157.5 · 움직인 프레임 23/23 — 아홉 판 중 강도가 가장 높다.

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;
}

05래그돌 관절 인형

무엇이 보이나

아무 관절이나 잡아 끌면 길이 제약이 연쇄로 풀리며 인형이 접히고 늘어진다. 클릭하면 쓰러지는 캐릭터 이스터에그에 쓸 수 있다.

래그돌 관절 인형
새 창에서 열기

05 · 래그돌 관절 인형 — 측정 누적면적 11.719% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramepointerdownatan2

코드에서 볼 곳

실측은 누적면적 11.719% · 강도 138.7 · 움직인 프레임 23/23 이다.

function solveLink(lk, strength) {
  var a = pts[lk.a], b = pts[lk.b];
  var dx = b.x - a.x, dy = b.y - a.y;
  var d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
  var target = d;
  if (d > lk.len) target = lk.len;
  else if (d < lk.min) target = lk.min;
  else return;                               // 구간 안 — 그대로
  var off = (d - target) / d * 0.5 * strength;
  var ox = dx * off, oy = dy * off;
  var aFree = lk.a !== dragging, bFree = lk.b !== dragging;
  if (aFree && bFree) { a.x += ox; a.y += oy; b.x -= ox; b.y -= oy; }
  else if (aFree) { a.x += ox * 2; a.y += oy * 2; }
  else if (bFree) { b.x -= ox * 2; b.y -= oy * 2; }
}

06물에 뜨는 부력

무엇이 보이나

물체를 던져 넣으면 잠긴 면적만큼 부력이 커져 중력과 평형을 이루는 깊이에서 뜬다. 물속에서는 공기보다 큰 유체 저항이 속도를 깎는다.

물에 뜨는 부력
새 창에서 열기

06 · 물에 뜨는 부력 — 측정 누적면적 9.128% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramepointermovetranslateY

코드에서 볼 곳

실측은 누적면적 9.128% · 강도 88.1 · 움직인 프레임 23/23 이다.

  var sinkSum = 0;
  for (var i = 0; i < bodies.length; i++) {
    var b = bodies[i];
    if (i === dragging) { b.x = dragX; b.y = dragY; continue; } // 잡은 물체는 손이 든다(px 그대로 → 놓는 순간 관성)
    var bh = b.shape === 'c' ? b.r * 2 : b.h;
    var wl = surfaceY(b.x, t);
    var d = Math.max(0, Math.min(bh, b.y + half(b) - wl)); // 침수 깊이
    var full = b.shape === 'c' ? Math.PI * b.r * b.r : b.w * b.h;
    var sub = b.shape === 'c' ? capArea(b.r, d) : b.w * d;
    var ratio = strength * RHO * sub / (b.dens * full);     // 부력/중력 비 — 1 이면 평형
    var inW = d > 0.5;
    var damp = inW ? FLUID : AIR;
    var slope = (surfaceY(b.x + 3, t) - surfaceY(b.x - 3, t)) / 6; // 파도 기울기 → 수평 밀림
    var vx = (b.x - b.px) * damp, vy = (b.y - b.py) * damp;
    b.px = b.x; b.py = b.y;
    b.x += vx + G * Math.min(ratio, 1.4) * slope * 0.16 * dt * dt;
    b.y += vy + G * (1 - ratio) * dt * dt;

07연쇄 도미노

무엇이 보이나

첫 조각을 오른쪽으로 끌면 넘어지는 회전이 다음 조각에 각속도를 넘기며 연쇄가 달린다. 간격 슬라이더로 조각 사이를 벌릴 수 있다.

연쇄 도미노
새 창에서 열기

07 · 연쇄 도미노 — 측정 누적면적 12.262% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramepointerdownrotate

코드에서 볼 곳

실측은 누적면적 12.262% · 강도 93.4 · 움직인 프레임 11/23 — 연쇄가 일찍 끝나 멈춘 프레임이 그대로 남는다.

  var crit = Math.atan2(HW, H2); // 무게중심이 받침점 위로 오는 임계각
  var rest = 1.485;              // 바닥에 거의 눕는 각도
  var fell = 0;
  for (var i = 0; i < N; i++) {
    var t = tiles[i];
    var angacc;
    if (t.a >= crit) {
      t.down = true;
      angacc = 2200 / H2 * Math.sin(t.a + 0.18); // 넘어가는 모멘트 — 갈수록 커진다
    } else {
      angacc = -2600 / H2 * Math.sin(crit - t.a); // 복원 모멘트 — 다시 세워지려는 쪽
    }
    // 각도 Verlet: a += a - pa + angacc·dt²
    var aa = t.a;
    t.a = t.a + (t.a - t.pa) * 0.996 + angacc * dt * dt;
    t.pa = aa;
    if (t.a < 0) { t.a = 0; t.pa = t.a; }            // 바닥 반발 흡수
    if (t.a > rest) { t.a = rest; t.pa = t.a - (t.pa - t.a) * 0.1; } // 눕고 살짝 튀다 멈춘다

08바람에 흔들리는 깃발

무엇이 보이나

깃대 쪽 첫 세로줄만 고정된 점 격자가 위상이 다른 사인파 바람에 출렁인다. 커서를 판 위에 올리면 그 자리가 거리 감쇠 돌풍의 원이 된다.

바람에 흔들리는 깃발
새 창에서 열기

08 · 바람에 흔들리는 깃발 — 측정 누적면적 25.599% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFrametranslateY

코드에서 볼 곳

실측은 누적면적 25.599% · 강도 109.2 · 움직인 프레임 23/23 — 아홉 판 중 화면이 가장 많이 움직인다.

  // (1) 적분 — 사인파 바람 + 커서 돌풍 + 중력
  for (var r = 0; r < ROWS; r++) {
    for (var c = 0; c < COLS; c++) {
      var p = pt(r, c);
      if (p.pin) continue;
      var fall = 0.35 + 0.65 * (c / (COLS - 1)); // 깃대에서 멀수록 바람을 많이 받는다
      var fx = Math.sin(t * 2.2 + r * 0.9 + c * 0.35) * 520 * base * fall + 90 * base; // 위상 변조 사인파 + 약한 정상시류
      var fy = Math.sin(t * 1.6 + c * 0.5) * 140 * base * fall;
      if (gustOn) {
        var dx = p.x - gustX, dy = p.y - gustY;
        var d2 = Math.hypot(dx, dy);
        var g = 3200 * Math.exp(-d2 / 90); // 거리 감쇠 돌풍
        if (d2 > 0.5) { fx += dx / d2 * g; fy += dy / d2 * g * 0.35; }
      }
      var vx = (p.x - p.px) * DAMP, vy = (p.y - p.py) * DAMP;
      p.px = p.x; p.py = p.y;
      p.x += vx + fx * dt * dt;
      p.y += vy + (fy + GRAV) * dt * dt;
    }
  }

09튀는 액체 방울

무엇이 보이나

입자마다 이웃 밀도를 계산해 밀면 밀어내는 압력이 작동한다. 방울이 뭉쳤다 갈라졌다 다시 뭉치는 모습이 표면장력처럼 보인다.

튀는 액체 방울
새 창에서 열기

09 · 튀는 액체 방울 — 측정 누적면적 21.528% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramepointerdowntranslate

코드에서 볼 곳

실측은 누적면적 21.528% · 강도 44.5 · 움직인 프레임 23/23 — 입자가 고르게 흩어져 강도는 낮게 나온다.

  // (2) 지역 밀도 — 이웃 반경 h 안 입자의 커널 w = 1 - r/h 합
  //     O(N²) 이지만 N=120 이라 프레임당 14,400 쌍 — 충분히 가볍다
  var dens = new Float64Array(N);
  var nbr = [];               // 이웃 목록(압력·점성·표면장력에서 다시 쓴다)
  for (var a = 0; a < N; a++) nbr.push([]);
  for (var a2 = 0; a2 < N; a2++) {
    for (var b = a2 + 1; b < N; b++) {
      var dx = ps[b].x - ps[a2].x, dy = ps[b].y - ps[a2].y;
      var d2 = dx * dx + dy * dy;
      if (d2 >= h2) continue;
      var d = Math.sqrt(d2) || 0.0001;
      var w = 1 - d / H;
      dens[a2] += w; dens[b] += w;
      nbr[a2].push(b); nbr[b].push(a2);
    }
  }
  var RHO0 = 5.5;             // 목표 밀도 — 이 커널에서 안정 방울이 갖는 밀도(실측 튜닝값)

변형 — 값 하나만 바꾸기

값 하나만 바꾸면 판의 성격이 바뀐다 — 아홉 판에서 세 가지를 뽑았다.

이름 바꾼 값 느낌
제약 반복 늘림 01 제약 반복 3 → 8 로프가 덜 늘어나고 더 팽팽해진다
반발계수 높임 04 반발계수 0.5 → 0.85 부딪힌 도형이 더 오래 굴러다닌다
바람 세기 높임 08 풍력 계수 0.6 → 1.4 깃발 끝이 더 크게 출렁인다

어디서 깨지는가 — 함정

젤리 법선의 부호

03의 압력은 면적 부족분을 바깥 법선으로 밀어내는데, 점을 반시계로 배치한 이 그물에서 바깥 법선은 (ty, -tx)다. 부호를 반대로 쓰면 부족한 면적을 안쪽으로 밀어 젤리가 스스로 접혀버린다. 렌더링도 같은 문제를 겪었다 — 광택을 화면 고정 위치에 그리면 가라앉은 젤리와 어긋나서, 지금은 몸통 중심을 따라가게 그린 뒤 테두리를 입힌다.

도미노는 연쇄가 끝나면 멈춘다

07의 움직인 프레임이 11/23에 그친 건 측정이 잘못된 것이 아니라, 각 조각이 임계각을 넘어야 넘어가는 모멘트를 받는 구조라 연쇄가 끝나면 물리적으로 정지하기 때문이다. 간격 배율을 넓히면 넘어오는 각속도가 닿기 전에 소멸해 도중에 멈춘다. 누적면적 12.262% · 강도 93.4 기록도 이 멈춤을 포함한 값이다.

토크 계수 하나로 도형이 팽이가 된다

04의 회전은 접점 팔이 중심에서 벗어난 만큼 각속도로 바꾸는 항인데 계수가 0.00013으로 아주 작다. 이 값을 크게 하면 사각형이 맞을 때마다 스핀이 누적되어 당구가 아니라 팽이가 된다. 반발계수와 토크 계수는 반드시 함께 움직여야 손이 맞는다.

아홉 판의 전체 소스를 담은 압축 파일의 비밀번호는 zxsbjc6j 이고, 풀어 보면 각 판의 index.html 이 그대로 들어 있다.

접근성

아홉 판 모두 조작 영역에 role="application" 과 tabindex="0" 을 두었다. 애니메이션을 스스로 멈추지 못하는 사용자를 위해 prefers-reduced-motion: reduce 환경에서는 드래그로 움직이는 01·02·03·05는 잡은 점만 옮기고 관성 없이 제약이 즉시 따라오게 하며, 06·09는 자동 시연을 시작하지 않는다 — 09는 설정이 바뀌는 순간(change)까지 듣는다. 08은 커서가 없을 때 돌던 가상 돌풍을 끈다. 04와 07은 자동 시연이 주기적으로 도는 판이라 reduce 에서도 멈추지 않는데, 07은 드래그로 시작할 때 임계각을 넘는 순간 관성을 생략하고 연쇄 시작 속도만 넣는다. 사람이 판에 손을 대는 순간 자동 시연은 끊기고 진짜 포인터를 따라간다. 움직임을 줄이는 기준은 WCAG 애니메이션 권고 를 따랐다.

FAQ

라이브러리 없이도 이 물리엔진 원리 데모가 그대로 돌아가나요?

네. 아홉 판 전부 Canvas 2D 와 requestAnimationFrame, Verlet 적분, 제약 반복만으로 움직인다. CDN 이나 물리엔진 라이브러리는 한 줄도 없어서 파일을 열면 바로 실행된다(requestAnimationFrame 문서).

프레임레이트가 다른 기기에서도 결과가 같나요?

적분은 시간 간격 dt 의 제곱으로 계산하고 dt 는 0.033 초를 넘지 못하게 묶는다. 그래서 60Hz 와 120Hz 화면에서 로프와 천이 같은 속도로 늘어난다.

터치 화면에서도 당기고 던질 수 있나요?

Pointer Events 로 마우스와 터치를 한 이벤트로 처리한다(pointer 지원 표). 손가락으로 로프를 잡거나 도형을 던지는 동작이 그대로 된다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.