flowoodz.
← blog
22분 읽기글 flowoodz

주사위 눈을 난수로 정하지 않았습니다: Jelly Dice가 XPBD로 멈춘 윗면을 읽는 방식

Playground의 Jelly Dice는 결과를 난수로 뽑지 않고, 물리 시뮬레이션이 멈춘 뒤 회전 행렬에서 위를 향한 면을 읽습니다. 그 구조를 코드로 다시 읽고 1,000번 굴려 보니 18번은 주사위가 기운 채 멈췄고, 그중 10번은 보이지 않는 울타리에 기대 있었습니다.

목차
  1. 난수는 결과가 아니라 던지는 동작에 씁니다
  2. 1/120초를 여섯 번 쪼개는 small-step XPBD
  3. 회전 행렬 한 줄로 윗면을 고릅니다
  4. 1,000번 굴려 봤습니다
  5. WebGPU가 없을 때
  6. 잰 숫자와 남은 일

Playground(play.flowoodz.com)는 설치 없이 브라우저에서 바로 만지는 실시간 인터랙티브 실험을 모아 둔 곳입니다. 첫 실험이 Jelly Dice입니다.

반투명한 젤리 주사위를 1개에서 5개까지 굴리고, 모서리를 잡아당기면 늘어났다가 놓으면 출렁이며 돌아옵니다.

아래에서 열면 이 글 안에서 바로 굴려 볼 수 있습니다. WebGPU가 없는 브라우저에서는 주사위 대신 안내문이 뜹니다.

Playground의 Jelly Dice

이 주사위는 결과를 난수로 뽑지 않습니다. 시뮬레이션이 멈추면 그때 위를 향한 면을 읽어 결과 칸에 적습니다. 이 글은 그 구조를 코드에서 다시 읽고, 라이브 페이지에서 직접 굴려 잰 기록입니다.

Jelly Dice는 2026년 10월 1일 커밋 하나(6f1df38)로 저장소에 완성본째 들어왔습니다. 만드는 동안 무엇을 고쳤는지는 커밋에 남아 있지 않아서, 지금 코드와 지금 라이브 페이지에서 확인한 것만 씁니다.

난수는 결과가 아니라 던지는 동작에 씁니다#

JellyWorld는 생성자에서 난수 함수 rng를 받고, 기본값은 Math.random입니다. 이 함수는 던지는 동작에 쓰입니다. 솟구치는 속도, 회전 축과 회전 속도, 떨어질 목표 위치를 여기서 정합니다.

const vy = 6.0 + rng() * 2.0;
// ...
const w = 7 + rng() * 8;

손을 떠난 뒤로는 물리만 돕니다. 결과는 각 주사위의 회전 행렬 d.R을 topFace에 넣어 얻습니다.

results() { return this.dice.map((d) => ({ die: d, ...topFace(d.R) })); }

왜 이렇게 만들었는지는 커밋 어디에도 적혀 있지 않습니다. 코드를 다시 읽으면 이유가 보입니다. 이 주사위는 손으로 만질 수 있습니다.

주사위를 잡으면 결과 칸이 흐려지고(markStale), 굴리는 중이 아닐 때 주사위가 바닥이나 다른 주사위에 세게 부딪혀도 흐려집니다. 다시 멈추면 윗면을 새로 읽습니다. 손으로 뒤집어 놓으면 결과도 따라 바뀝니다.

숫자를 먼저 뽑았다면 둘 중 하나를 골라야 했을 겁니다. 물리를 억지로 그 면에 세우거나, 누가 주사위를 건드리는 순간 화면과 숫자가 어긋나는 걸 받아들이거나. 만질 수 있는 주사위에서는 둘 다 티가 납니다.

덤으로 재현이 됩니다. 무작위가 던지는 동작에 몰려 있으니 시드를 고정한 rng를 넣으면 같은 판이 나옵니다. 코드에 들어 있는 mulberry32로 시드 1과 2를 세 번씩 굴리는 실행을 두 번 했더니, 주사위 18개의 눈이 두 번 다 같았습니다.

1/120초를 여섯 번 쪼개는 small-step XPBD#

주사위 하나는 4×4×4 격자, 입자 64개입니다. 입자 하나의 역질량 W_INV가 64라서 주사위 전체 질량이 1입니다. 이 64개가 그대로 삼차 베지어 볼륨의 제어점이 되고, GPU 컴퓨트 셰이더가 이 볼륨으로 화면의 둥근 메시를 변형합니다.

입자 사이에는 세 종류의 제약이 걸립니다. 이웃 입자끼리의 길이 제약(한 칸 건너뛴 굽힘 방지 링크 포함) 564개, 격자 칸마다 사면체 5개로 나눈 부피 제약 135개, 그리고 전체 모양을 원래 정육면체로 끌어당기는 약한 형상 맞춤입니다.

시간은 이렇게 나눕니다.

const STEP = 1 / 120;                // fixed simulation step
const SUBSTEPS = 6;
const h = STEP / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s++) this.substep(h);

서브스텝 하나는 1/720초입니다. 그 안에서 제약마다 딱 한 번씩만 풉니다. 같은 제약을 여러 번 반복해 맞추는 루프가 없고, 대신 시간을 잘게 쪼갭니다. 코드 주석은 이걸 small-step XPBD라고 부릅니다.

서브스텝 안의 순서는 중력을 반영한 위치 예측, 길이, 부피, 형상 맞춤, 주사위끼리와 바닥·울타리 충돌, 속도 갱신입니다.

단단함은 compliance로 정합니다. Settings의 Firmness 슬라이더(0~100)가 길이 제약의 compliance를 바꾸고, 부피 제약은 고정입니다.

this.edgeCompliance = 0.25 * Math.pow(0.12, f);   // soft 0.25 .. firm 0.03
this.volCompliance = 1e-8;
const ae = this.edgeCompliance / (h * h), av = this.volCompliance / (h * h);

XPBD는 compliance(강성의 역수)를 시간 간격의 제곱으로 나눠 쓰고, 이 코드도 그대로 따릅니다. 그래서 슬라이더 값은 서브스텝 길이와 무관한 물성 값으로 남습니다. SUBSTEPS를 바꿔 가며 확인하지는 않았습니다.

기본값 55에서 계산하면 길이 제약의 compliance는 0.0779이고, h²로 나누면 약 40,379입니다. 분모의 질량 항이 128이라서 서브스텝 한 번에 길이 오차의 0.3%만 줄입니다.

부피 제약은 1e-8을 h²로 나눠도 0.005184로, 길이 제약 값의 약 780만분의 1입니다. 길이는 무르게, 부피는 거의 고정. 주석은 이 조합을 near-incompressible, like gummy라고 적어 두었습니다.

무르기만 하면 끝없이 늘어나니 상한도 있습니다. 쉴 때 길이의 1.9배를 넘거나 0.45배보다 짧아지면(STRETCH_MAX, STRETCH_MIN) compliance 없이 즉시 한계 길이로 되돌립니다.

회전 행렬 한 줄로 윗면을 고릅니다#

젤리는 찌그러지니 입자 위치만 보고는 어느 면이 위인지 바로 말할 수 없습니다. 그래서 형상 맞춤 단계가 서브스텝마다 찌그러진 격자에 가장 잘 맞는 회전을 구해 d.R에 넣어 둡니다.

주석에 따르면 Müller 외(2016)의 회전 추출을 직전 서브스텝의 쿼터니언에서 출발해 최대 4번 반복합니다. 윗면 판정은 이 회전 행렬만 봅니다.

function topFace(R) {
  let best = -2, face = 2;
  for (let a = 0; a < 3; a++) {
    const up = R[3 + a];
    if (up > best) { best = up; face = 2 * a; }
    if (-up > best) { best = -up; face = 2 * a + 1; }
  }
  return { value: FACE_VALUE[face], confidence: best };
}

R은 행 우선 3×3 행렬이고, 열 하나가 주사위의 로컬 축 하나를 월드 좌표로 나타냅니다. 그래서 R[3 + a]는 로컬 축 a의 월드 y 성분, 곧 그 축이 얼마나 위를 향하는지입니다.

축마다 +와 -를 비교해 여섯 면 중 가장 위를 향한 면을 고르고, 그 값이 그대로 confidence가 됩니다. 1이면 그 면이 정확히 위를 향하고, 0.86이면 약 30.7° 기운 상태입니다.

면 번호는 FACE_VALUE = [3, 4, 1, 6, 2, 5]로 +X부터 -Z까지 순서대로이고, 마주 보는 면의 합이 7입니다.

언제 읽을지도 정해 둡니다. 모든 입자 속도가 0.06보다 느린 상태가 0.35초 이어지면 멈춘 것으로 봅니다. 그때 신뢰도가 0.86 미만인 주사위가 있으면 위로 툭 쳐서 다시 기다립니다. 최대 세 번이고, 치는 방향에는 Math.random이 조금 섞입니다.

// A die leaning on another is re-nudged a couple of times, like a real table would.
const cocked = res.find((r) => r.confidence < 0.86);
if (cocked && nudges < 3) {

세 번을 넘기면 기운 그대로 결과를 보여 줍니다. 표시 순서는 화면 왼편부터입니다.

한 스텝을 1/720초 서브스텝 6번으로 나눠 제약을 한 번씩 풀고, 형상 맞춤에서 나온 회전 행렬로 윗면과 신뢰도를 정하는 흐름

아래는 라이브 페이지에서 5개를 굴린 화면입니다. 결과 칸의 1, 4, 5, 4, 6이 화면 왼편부터 보이는 윗면과 맞습니다.

라이브 페이지에서 멈춘 젤리 주사위 5개와, 결과 칸의 1, 4, 5, 4, 6, 합계 20

1,000번 굴려 봤습니다#

헤드리스 Chromium에서는 이 페이지가 GPU 어댑터를 얻지 못해 프레임 루프가 아예 시작하지 않습니다. 그래도 물리 코드는 이미 로드돼 있어서, 페이지 안에서 new JellyWorld(mulberry32(seed))를 직접 만들어 돌렸습니다.

시드 1~100마다 주사위 3개로 10번씩 연달아 굴렸습니다. 멈출 때까지 step()을 돌리고, 툭 치는 규칙도 페이지와 같게 넣되 방향만 시드 난수에서 뽑았습니다. 모두 1,000번, 주사위 3,000개입니다.

눈 1 2 3 4 5 6
횟수 500 531 479 498 503 489

카이제곱 값은 3.07입니다. 자유도 5, 유의수준 0.05의 기준값이 11.07이니 이 표본에서는 특정 눈으로 치우친 흔적을 찾지 못했습니다.

굴린 뒤 처음 멈출 때까지는 시뮬레이션 시간으로 중앙값 3.25초, 95번째 백분위 4.59초, 가장 길게 7.31초였습니다.

걸린 건 기운 주사위였습니다. 1,000번 중 18번은 주사위 하나가 신뢰도 0.86 미만으로 멈췄고, 가장 많이 기운 것은 0.7189(약 44°)였습니다. 툭 치기를 모두 51번 했는데, 18번 중 16번은 세 번을 다 쳐도 여전히 0.86 미만이었습니다.

시드를 고정했으니 그 18번을 그대로 다시 재생해 주사위가 어디 있었는지 봤습니다.

  • 8번은 다른 주사위에 바짝 붙어 있었습니다. 가장 가까운 주사위 중심까지 1.22~1.44였습니다(주사위 한 변이 1.0). 주석이 예상한 경우입니다.
  • 10번은 다른 주사위와 닿지 않았습니다. 가장 가까운 주사위까지 2.15~3.84 떨어져 있었고, 입자 하나가 원점에서 정확히 3.000 거리에 있었습니다.

3.0은 ARENA_R입니다. 주사위가 화면 오버레이 밑으로 들어가지 않게 둘러 둔 보이지 않는 원형 울타리의 반지름입니다. 주사위가 이 울타리에 기대 비스듬히 서 있었던 겁니다.

툭 치기는 거의 힘을 쓰지 못했습니다. 칠 때마다 주사위 중심이 0.0040.007(한 변의 0.40.7%) 떠올랐다가 내려앉았습니다. 울타리에 기댄 10건은 하나도 풀리지 않았고, 풀리지 않은 16건 모두 신뢰도 변화가 0.0213을 넘지 않았습니다.

라이브 페이지에서도 확인했습니다. headed Chromium에서 window.jellyDice.world의 난수를 시드 1로 바꾸고 일곱 번째 롤까지 같은 순서로 돌린 뒤, 페이지에 넘겨 페이지가 직접 툭 치고 결과를 내게 했습니다.

기운 주사위의 신뢰도는 헤드리스에서 잰 값과 같은 0.8083이었습니다.

시드 1 재현 화면. 오른편 아래 주사위가 허공에 기대 눈 4개 면을 보이는데 결과 칸은 2, 3, 2

오른편 아래 주사위가 아무것도 없는 곳에 기대 서 있고, 결과 칸에는 2가 찍혔습니다. 2의 면은 위 방향 성분이 0.808이라 판정에서 이기지만, 카메라를 향한 정도는 0.105뿐입니다.

4의 면은 위 방향 0.589, 카메라를 향한 정도 0.922입니다. 판정 코드는 틀리지 않았는데, 화면에서 4로 보이는 주사위가 2로 집계됐습니다.

WebGPU가 없을 때#

물리는 CPU에서 도는 JavaScript(Float64Array)입니다. GPU는 베지어 볼륨으로 메시를 변형하는 컴퓨트 셰이더와 2048×2048 그림자 맵, 4× MSAA 렌더링을 맡습니다.

createRenderer는 navigator.gpu가 없거나 어댑터를 얻지 못하면 예외를 던지고, 디바이스를 잃어도 같은 처리로 갑니다. 상단 점이 빨갛게 바뀌고 "These dice need a WebGPU browser." 안내와 이유가 뜨며, 패널 버튼과 Reset view까지 컨트롤 15개가 비활성화됩니다.

Playwright 1.64.1의 Chromium 156으로 세 조건을 열어 봤습니다.

조건 어댑터 페이지에 뜬 이유
헤드리스 기본 없음(null) No compatible GPU adapter was found.
헤드리스 + --enable-unsafe-webgpu SwiftShader The GPU device was lost (A valid external Instance reference no longer exists.)
headed + --enable-unsafe-webgpu Apple metal-3 정상 동작

SwiftShader 조건은 처음 열었을 때 시뮬레이션 시간 0.88초를 돈 뒤 끊겼고, 다시 열었을 때는 0초에서 끊겼습니다. navigator.gpu 자체가 없는 브라우저는 시험하지 못했습니다.

WebGPU 어댑터를 얻지 못해 안내문이 뜨고 패널 컨트롤이 꺼진 화면

실패한 화면에서 world.time은 0이었고, 주사위 3개는 생성된 높이(1.78~2.21)에 그대로 떠 있었습니다. world.step()을 부르는 프레임 루프가 렌더러가 만들어진 뒤에만 시작되기 때문입니다.

그런데 1,000번 굴리기는 바로 이 실패한 페이지에서 돌렸습니다. 물리는 GPU 없이도 멀쩡히 돕니다.

Playground 첫 화면 코드에는 WebGPU가 없을 때 정지 화면과 "이 브라우저에서는 정지 화면으로 보여 드립니다." 문구를 띄우는 처리가 있는데, Jelly Dice에는 그런 대체 화면이 없습니다.

보이지 않는 젤리 주사위가 숫자만 내는 게 의미가 있을지는 아직 판단하지 못했습니다. 만지는 감각이 이 실험의 전부라서, 안내문만 띄우는 지금 방식이 맞을 수도 있습니다.

잰 숫자와 남은 일#

프레임은 headed Chromium 156(--enable-unsafe-webgpu), MacBook Pro M2 Max(32GB), 어댑터 Apple metal-3, 창 1400×900에서 쟀습니다. 5초씩 requestAnimationFrame 횟수를 세고, world.step을 감싸 스텝 한 번의 시간을 쟀습니다.

회차 조건 fps 스텝 중앙값(3개 / 5개)
1 dpr 1 52~54 0.50.6ms / 0.91.0ms
2 dpr 1 22~36 0.80.9ms / 1.31.6ms
3 dpr 2, 캔버스 2231×1434 49~55 0.6~0.7ms / 1.2ms

같은 조건인 1회차와 2회차가 크게 갈렸습니다. 이 기기에서는 다른 작업이 같이 돌고 있었고, 2·3회차를 잰 직후 1분 부하 평균이 99.9였습니다(12코어).

회차마다 부하를 따로 기록하지 않아서 2회차만 느린 이유는 확정하지 못했습니다. 이 fps는 기기 실력이 아니라 부하가 걸린 공용 기기에서의 기록으로 봐야 합니다.

1회차와 3회차도 프레임 시간 95번째 백분위가 27~42ms였습니다. 원인이 CPU인지 GPU인지는 가르지 못했습니다. 스텝 한 번의 예산은 1/120초, 약 8.3ms인데 부하가 걸린 2회차에서도 스텝 중앙값은 1.6ms를 넘지 않았습니다.

2회차에서는 굴린 뒤 결과가 나오기까지 8.1초와 6.5초가 걸렸고, 다른 회차는 3.2~4.5초였습니다. 프레임 루프는 한 프레임에 스텝을 최대 4번만 돌리고 밀린 시간은 버립니다.

while (acc >= STEP && steps < 4) { world.step(); acc -= STEP; steps++; }
if (steps === 4) acc = 0;

프레임이 약 33ms보다 길어지면 시뮬레이션은 따라잡지 않고 느리게 재생됩니다. 2회차가 오래 걸린 것도 이 때문으로 봅니다. 주사위가 순간이동하는 대신 느린 화면을 고른 셈입니다.

남은 일은 셋입니다.

  • 보이지 않는 울타리에 기댄 주사위. 3,000개 중 10개로 드물지만, 툭 치기로는 풀리지 않고 기운 채 결과가 찍힙니다. 재현한 한 건은 보이는 면과 집계된 눈이 달랐습니다. 중심을 향해 치는 방법은 아직 시험하지 않았습니다.
  • 툭 치기의 세기. 다른 주사위 옆에서 기운 8건 중 풀린 건 2건이고, 그중 하나는 0.8551에서 0.8602로 기준을 겨우 넘겼습니다.
  • 물리 블록 주석은 check.mjs가 이 코드를 Node에서 불러 쓴다고 적고 있는데, 그 파일이 이 저장소에 없습니다. 이번에 쓴 시드 고정 굴리기를 거기 넣어 두면, 울타리 문제를 고친 뒤 같은 시드로 바로 비교할 수 있습니다.