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

주사위 굴리기 9가지 — 회전 행렬로 윗면을 읽어 내는 3D 장면

던지고, 튕기고, 멈춘다 — 주사위 한 번은 세 동사뿐이고, 03번은 마지막 동사에서 윗면 숫자를 계산으로 읽어 낸다. 아래 아홉 개는 주사위 굴리기를 캔버스 2d 로 걸음마다 새로 계산해 그린 장면이고, 압축 파일을 풀면 별도 설치 없이 열린다.

주사위 아홉 장면을 화면에서 바로 굴려 본다

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

주사위 굴리기 9가지 — 회전 행렬 하나가 윗면 숫자까지 정한다

주사위 하나의 자세는 3×3 회전 행렬 R 한 개로 정해지고, 08 을 뺀 여덟 장면이 모두 이 행렬 위에서 돈다. 01 은 행렬을 허공에서 계속 돌리며 보이는 면을 거르고, 02 는 같은 행렬에 중력과 바닥 충돌을 붙이며, 03 은 멈추는 동안 행렬을 윗면 쪽으로 서서히 세운다. 04 는 던지는 값을 시드 수열에서 뽑고, 05 는 주사위 두 개의 눈을 더해 쌓는다.

06 은 바닥에 닿은 자리마다 먼지와 자국을 남기고, 07 은 컵 속에서 행렬에 노이즈 오프셋을 더해 덜그럭거리게 한다. 08 만 3D 계산 없이 CSS 로 면 한 장을 납작하게 눌러 굴러가는 척하고, 09 가 세 주사위를 한 판에 모은 장면이다. 주사위 굴리는 애니메이션이 필요한 화면이 대부분 이 아홉 가운데 하나에서 시작할 수 있도록 순서를 잡았다.

던지는 순간부터 눈이 확정되는 순간까지 한 번의 굴리기를 계산으로 이어 붙여 놓았으니, 주사위 굴리기 시뮬레이션을 만들 때도 01 의 투영과 03 의 윗면 읽기는 그대로 가져다 쓸 수 있다. 비슷한 물리 장면이 궁금하면 바운싱 볼 애니메이션 9가지 — 충돌·중력 물리 계산과 팽이 돌리기 9가지 — 각속도와 세차로 계산하는 회전 장면을 이어서 보면 된다.

기법별 코드로 보는 주사위 회전 계산

코드는 각 장면 index.html 에서 이어진 줄을 그대로 잘라 실었고, 그리기 함수보다 장면마다 다른 계산이 있는 곳을 골랐다. 완결본은 압축 파일 안에 있다.

01회전 행렬로 돌리는 3D 주사위

rotAxis() 로 만든 회전 행렬을 matVec() 로 여덟 꼭짓점에 곱해 원근 투영하고, 법선과 시선의 내적이 0 보다 작은 면만 칠해 카메라를 등진 뒷면을 거른다. 허공을 도는 주사위는 DEMO_KICK_MS 1500ms 마다 새 축으로 걷어차이며 윗면은 매 걸음 법선 y 가 가장 큰 면으로 읽어 상태 줄에 적는다. 이 장면은 누적면적 13.8%·평균 강도 72.4·23/23 프레임이다.

회전 행렬로 돌리는 3D 주사위
새 창에서 열기

01 · 회전 행렬로 돌리는 3D 주사위 — 측정 누적면적 13.776% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

rotAxismatVectopIndexSPIN_BASEDEMO_KICK_MS
  function matVec(m, v) {
    return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
  }
  // 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
  function rotAxis(ax, ang) {
    var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
    return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
      t * x * y + s * z, t * y * y + c, t * y * z - s * x,
      t * x * z - s * y, t * y * z + s * x, t * z * z + c];
  }

02포물선으로 던져 바닥에서 튕기는 주사위

GRAVITY 22 가 속도를 걸음마다 깎아 포물선을 만들고, floorStep() 은 가장 낮은 꼭짓점이 바닥을 뚫은 만큼 들어 올린 뒤 접촉점 속도가 아래를 향할 때만 DICE_RESTITUTION 0.45 가 들어간 충격량을 준다. 충격 속도가 1.2 를 넘는 접촉만 튕김 횟수로 세고 벽에서는 wallStep() 이 속도를 반으로 줄여 되돌린다. 이 장면은 누적면적 23.3%·평균 강도 61.8·21/23 프레임이다.

포물선으로 던져 바닥에서 튕기는 주사위
새 창에서 열기

02 · 포물선으로 던져 바닥에서 튕기는 주사위 — 측정 누적면적 23.341% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GRAVITYDICE_RESTITUTIONfloorStepwallStepINERTIA
    var low = d.p[1] + my;
    if (low >= 0) return;
    d.p[1] -= low;
    var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
    var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
    var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
    if (cvy >= 0) return;
    var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
    d.v[1] += j;
    d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;

03멈추며 윗면 숫자를 읽어 내는 주사위

settleStep() 은 여섯 면 법선 가운데 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 위를 볼 때까지 남은 각도의 SETTLE_RATE 0.16 만큼씩 걸음마다 돌린다. 던진 지 SETTLE_AFTER_MS 2000ms 가 지나거나 세 번 튕긴 뒤 속도가 충분히 줄면 멈추기로 넘어가며, 그 전에도 readNormals() 가 여섯 눈의 법선 y 를 막대로 보여 준다. 이 장면은 누적면적 13.4%·평균 강도 65.7·18/23 프레임이다.

멈추며 윗면 숫자를 읽어 내는 주사위
새 창에서 열기

03 · 멈추며 윗면 숫자를 읽어 내는 주사위 — 측정 누적면적 13.378% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

settleStepSETTLE_RATESETTLE_AFTER_MSreadNormalstopIndex
  // 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
  function settleStep(d) {
    var k = topIndex(d);
    d.top = FACE_VAL[k];
    var nw = matVec(d.R, FACE_N[k]);
    var ang = Math.acos(clamp(nw[1], -1, 1));
    if (ang > 1e-4) {
      var ax = unit(cross(nw, [0, 1, 0]));
      d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
    }

04시드로 같은 순서를 되풀이하는 주사위

seedRand() 의 선형 합동 수열이 던질 때마다의 위치·속도·회전을 정해진 순서로 뽑는다. 시작할 때 보이지 않게 SEQ_LEN 6 번을 미리 굴려 윗줄에 깔아 두고, 지금 굴리는 아랫줄과 눈이 같으면 칩 테두리를 민트로 바꿔 같은 시드가 같은 순서를 낸다는 것을 눈으로 확인하게 한다. 이 장면은 누적면적 18.2%·평균 강도 66.1·19/23 프레임이다.

시드로 같은 순서를 되풀이하는 주사위
새 창에서 열기

04 · 시드로 같은 순서를 되풀이하는 주사위 — 측정 누적면적 18.185% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

seedRandDICE_SEEDSEQ_LENseqThrowrunUntilHold
  function seqThrow(d, rnd) {
    launch(d, rnd, -2.8, rnd() * 2.4 - 0.8, 3.2, 0.1, 3.0, 14);
  }
  function sceneInit() {
    var r2 = seedRand(DICE_SEED);
    var tmp = newDie(2, -3.0, 3.0, -1.6, 2.4);
    for (var k = 0; k < SEQ_LEN; k += 1) { seqThrow(tmp, r2); prevRun.push(runUntilHold(tmp)); }
    die.phase = PH_IDLE;
  }
  function sceneRoll(manual) {
    if (throwNo % SEQ_LEN === 0) { rng = seedRand(DICE_SEED); curRun = []; }
    throwNo += 1;
    seqThrow(die, rng);
    setStatus(T_ROLLING, '', true);
  }

05두 주사위 합을 막대로 쌓는 장면

throwBoth() 가 두 주사위를 양쪽에서 마주 던지고 눈의 합을 counts 에 쌓아 막대로 보여 주며, 시작할 때 PRELOAD_ROLLS 24 번을 미리 굴려 막대가 비어 있지 않게 한다. 두 주사위 사이에는 충돌 검사가 없어서 구역을 좌우로 갈라 서로 지나치지 않게 했다. 이 장면은 누적면적 20.5%·평균 강도 69.3·20/23 프레임이다.

두 주사위 합을 막대로 쌓는 장면
새 창에서 열기

05 · 두 주사위 합을 막대로 쌓는 장면 — 측정 누적면적 20.543% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PRELOAD_ROLLSthrowBothcountsbarHrunUntilHold
  function throwBoth(a, b, rnd) {
    launch(a, rnd, -3.0, rnd() * 2.2 - 0.6, 3.0, 0.2, 3.0, 14);
    launch(b, rnd, 3.0, rnd() * 2.2 - 0.6, -3.0, 0.2, 3.0, 14);
  }
  function sceneInit() {
    var ta = newDie(0, -3.4, -0.6, -1.6, 2.4);
    var tb = newDie(3, 0.6, 3.4, -1.6, 2.4);
    for (var k = 0; k < PRELOAD_ROLLS; k += 1) {
      throwBoth(ta, tb, rng);
      counts[runUntilHold(ta) + runUntilHold(tb)] += 1;
    }
    for (var s = 2; s <= 12; s += 1) barH[s] = counts[s];
    dieA.phase = PH_IDLE; dieB.phase = PH_IDLE;
  }

06구른 자국과 먼지가 바닥에 남는 주사위

spawnDust() 가 충격 속도에 비례해 입자 7~17 개를 흩뿌리고 충격점을 바닥 얼룩으로 남기며, 그림자 점이 지나간 경로는 DUST_FADE_MS 3600ms 동안 옅어지는 선으로 그린다. 자국은 다음 던지기까지 남아 서서히 사라진다. 이 장면은 누적면적 18.8%·평균 강도 52.5·23/23 프레임이다.

구른 자국과 먼지가 바닥에 남는 주사위
새 창에서 열기

06 · 구른 자국과 먼지가 바닥에 남는 주사위 — 측정 누적면적 18.76% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DUST_FADE_MSspawnDustdustRngFLOOR_FRICTION
  function spawnDust(x, z, speed) {
    var n = 7 + Math.min(10, Math.round(speed * 1.1));
    for (var i = 0; i < n; i += 1) {
      var an = dustRng() * 6.2832, sp = 0.6 + dustRng() * 1.8;
      dust.push({ x: x, y: 0.05, z: z, vx: Math.cos(an) * sp, vy: 0.8 + dustRng() * 1.8, vz: Math.sin(an) * sp, born: elapsed, life: 900 + dustRng() * 700, size: 0.07 + dustRng() * 0.11 });
    }
    marks.push([x, z, elapsed, 0.3 + Math.min(0.5, speed * 0.04)]);
    if (marks.length > 24) marks.shift();
  }

07컵 안에서 흔들리는 주사위

noise1() 은 시드 난수 256 개 표를 부드럽게 이어 읽는 값 노이즈이고, shakePose() 가 컵과 주사위의 위치·높이·기울기에 서로 다른 오프셋을 더한다. 진폭은 SHAKE_MS 1300ms 동안 흔든 시간 비율의 제곱으로 커지다가 주사위를 컵 밖으로 쏘아 올리고, 그 뒤로는 02 와 같은 물리가 이어받는다. 이 장면은 누적면적 24.3%·평균 강도 68.4·23/23 프레임이다.

컵 안에서 흔들리는 주사위
새 창에서 열기

07 · 컵 안에서 흔들리는 주사위 — 측정 누적면적 24.347% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NOISE_FREQnoise1SHAKE_MSshakePosedrawCupFront
  function shakePose() {
    var t = elapsed / 1000 * NOISE_FREQ;
    var I = clamp(shakeT / SHAKE_MS, 0, 1);
    ampNow = 0.1 + 0.9 * I * I;
    cupDX = noise1(t + 31) * ampNow * 0.5;
    cupLift = Math.abs(noise1(t * 1.2 + 53)) * ampNow * 0.35;
    var cx = CUP_X + cupDX;
    die.p = [cx + noise1(t * 1.3 + 70) * ampNow * 0.7, 1.3 + cupLift + Math.abs(noise1(t * 1.1 + 110)) * ampNow * 0.9, CUP_Z + noise1(t * 1.2 + 150) * ampNow * 0.4];
    die.R = matMul(rotAxis([0, 1, 0], noise1(t * 1.4 + 190) * ampNow), matMul(rotAxis([1, 0, 0], noise1(t * 1.5 + 230) * ampNow * 0.6), baseR));
  }

08납작한 면 전환으로 굴러가는 2D 주사위

이 장면만 캔버스가 아니라 DOM 과 CSS 다. 자동 시연은 키프레임으로 바닥 줄무늬를 128px 씩 밀고 주사위를 scaleX 로 납작하게 눌러 눈 레이어를 갈아 끼우며, 버튼을 누르면 pickFaces() 가 직전 면과 겹치지 않게 HOP_COUNT 4 홉 치 면을 뽑아 Web Animations 로 재생한다. 이 장면은 누적면적 15.7%·평균 강도 48.1·23/23 프레임이다.

납작한 면 전환으로 굴러가는 2D 주사위
새 창에서 열기

08 · 납작한 면 전환으로 굴러가는 2D 주사위 — 측정 누적면적 15.748% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HOP_MSHOP_COUNTTILE_PXpickFacesseedRand
  function pickFaces() {
    var seq = [];
    var prev = Number(dieEl.getAttribute('data-face')) || 2;
    for (var k = 0; k < HOP_COUNT; k += 1) {
      var f = 1 + Math.floor(rng() * 6);
      if (f === prev) f = (f % 6) + 1;
      seq.push(f);
      prev = f;
    }
    return seq;
  }

09히어로 배경 주사위 테이블

세 주사위가 LANES 로 갈라진 가로 레인 안에서 각자 다른 시드로 시차를 두고 던져지고, 멈추면 결과가 판 아래 칩 줄에 한 칸씩 채워진다. 칩 줄은 처음에 비어 있고 충돌 계산이 없는 대신 레인으로 서로 피하며 깊이순으로만 그린다. 이 장면은 누적면적 23.0%·평균 강도 69.4·23/23 프레임이다.

히어로 배경 주사위 테이블
새 창에서 열기

09 · 히어로 배경 주사위 테이블 — 측정 누적면적 23.037% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DIE_COUNTLANESthrowOneboardDICE_SEED
  function throwOne(k) {
    var d = dice[k];
    var lane = LANES[k];
    launch(d, rngs[k], (lane[0] + lane[1]) / 2 - 0.4, rngs[k]() * 2.2 - 1.2, 0.6, 0.2, 2.8, 14);
    tossCount[k] += 1;
  }
  function sceneInit() {
    for (var k = 0; k < DIE_COUNT; k += 1) {
      dice.push(newDie(k, LANES[k][0], LANES[k][1], -1.6, 2.2));
      rngs.push(seedRand(DICE_SEED + 100 * (k + 1)));
    }
  }

값이 장면을 바꾸는 곳 — 반발 계수·회전 감쇠·시드

인상을 크게 바꾸는 값은 셋으로 좁혀진다. 02 의 DICE_RESTITUTION 은 바닥에서 속도가 얼마나 남는지를, 03 의 SPIN_DAMPING 은 걸음마다 회전이 얼마나 깎이는지를, 04 의 DICE_SEED 는 던지는 순서 전체를 바꾼다. 반발 계수를 올리면 높이가 오래 남지만 2초 안에 센 튕김이 일어나는 횟수는 13번에서 7번으로 오히려 줄 수 있어서, 이 값은 횟수가 아니라 높이로 읽어야 한다.

아홉 장면의 측정값을 한 표에 모았다. 07 은 컵과 주사위가 함께 크게 흔들려 면적이 가장 넓고, 03 은 주사위 하나가 약하게 던져져 가장 좁으며 열 퍼센트 밑으로 내려가는 장면은 없다.

항목 누적면적 평균 강도 움직인 프레임
01 회전 행렬 3D 주사위 13.8% 72.4 23/23
02 포물선 튕김 23.3% 61.8 21/23
03 윗면 읽기 13.4% 65.7 18/23
04 시드 수열 18.2% 66.1 19/23
05 두 주사위 합 막대 20.5% 69.3 20/23
06 먼지·자국 18.8% 52.5 23/23
07 컵 흔들기 노이즈 24.3% 68.4 23/23
08 평면 주사위 15.7% 48.1 23/23
09 히어로 테이블 23.0% 69.4 23/23

어디서 깨지는가 — 모서리로 서는 순간·좁은 화면·두 주사위가 겹칠 때

가장 위험한 곳은 모서리로 서는 순간이다. 윗면 읽기가 법선 y 의 최댓값이라 두 면의 y 가 거의 같은 대각 자세에서는 선택이 한 걸음 사이에도 바뀌고, 코드는 SETTLE_AFTER_MS 나 튕김 수 조건으로 억지로 멈추기에 들어가므로 이때 보정이 향하는 면이 걸음 사이에 바뀌어 눕는 방향이 흔들려 보일 수 있다. 던진 200번 가운데 멈추지 못한 것은 없었고 평균 110걸음, 가장 오래 걸린 것은 157걸음이었다.

좁은 화면에서는 캔버스가 폭에 비례해 줄고 340px 이하에서 상태 줄·버튼·캡션 글자만 작아진다. 두 주사위가 겹칠 때는 05 와 09 가 충돌을 풀지 않고 레인으로 피하게 했기 때문에 경계에서 눈 모양이 서로 가릴 수 있고, 먼 주사위부터 그리는 깊이순이 전부다. 눈은 고르게 나오지 않으니(시드 7 로 200번 던지면 1~6 이 51·25·31·29·26·38번) 추첨이나 내기의 근거로 쓰지 말고 연출로만 쓴다. 값을 직접 바꿔 가며 확인하고 싶다면 압축 파일의 비밀번호 ky895umc 로 열어서 쓰면 된다.

변형 세 가지와 접근성

이름 값 느낌
반발 계수 조정 02 DICE_RESTITUTION 0.45 → 0.7 값을 올리면 바닥에 닿을 때 속도가 덜 깎여 튕김 높이가 오래 높게 남는다(같은 던지기에서 첫째·둘째 튕김 정점이 0.82·0.15 에서 1.17·0.89 로, 주사위 반변 단위). 내리면 한두 번 튀고 곧 눕는다.
회전 감쇠 조정 03 SPIN_DAMPING 0.985 → 0.96 값을 내리면 걸음마다 회전이 더 깎여, 같은 20번 던지기 평균으로 윗면 보정이 시작되는 걸음이 78 에서 61 로 앞당겨지고 1초 시점 각속도도 3.8 에서 2.7 rad/s 로 낮아진다. 올리면 그만큼 오래 돈다.
시드 바꾸기 04 DICE_SEED 7 → 19 시드를 바꾸면 던지는 각도·속도·회전의 순서가 달라져 첫 여섯 번의 눈이 2·6·4·4·3·1 에서 5·6·2·1·5·1 로 바뀐다. 같은 값이면 언제나 같은 순서다.

prefers-reduced-motion 이 켜져 있으면 캔버스 장면은 requestAnimationFrame 루프를 만들지 않고, 시작할 때와 버튼을 누를 때마다 같은 계산을 한 번에 돌려 결과가 확정된 장면만 그린 뒤 상태 줄에 결과를 적는다. 08 은 모든 애니메이션을 끄고 버튼이 면을 즉시 바꾸며, 01 은 누를 때마다 회전 행렬에 새 회전을 곧바로 곱한다. 굴리기 버튼은 네이티브 <button> 이라 Tab 으로 초점을 받고 Enter·Space 가 그대로 클릭을 일으키며, 결과는 <output aria-live="polite"> 안에 적혀 화면 낭독기가 읽는다.

코드에서 주사위 윗면은 무엇으로 알아내는가?

윗면은 면 법선을 월드 좌표로 돌렸을 때 y 성분이 가장 큰 면이다. topIndex() 는 여섯 법선에 회전 행렬의 y 행을 곱해 가장 큰 값을 찾고, 눈은 위 +y=1·아래 -y=6·+x=2·-x=5·+z=3·-z=4 로 마주 보는 면의 합이 7 이 되게 배치했다. 같은 계산이 01 에서는 실시간 표시, 03 에서는 멈추기 전 보정 방향, 02 이후에서는 결과 확정에 쓰인다.

캔버스 그리기의 근거는 Canvas API 문서와 requestAnimationFrame 문서에 있고, 3D 를 캔버스 없이 CSS 로 만드는 길은 CSS 3D 큐브 캐러셀 틸트 9종 — 한 줄 복붙으로에, 픽셀까지 계산하는 캔버스 장면은 html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과에, 깊이 층으로 쌓는 배경은 css 배경 애니메이션 효과 9가지 — 깊이 층 3D 장면에 있다.

주사위 장면을 쓰기 전에 자주 묻는 질문

주사위 굴리기 게임으로 만들려면 무엇을 더해야 하나?

각 장면은 멈추면 눈 수를 result 에 확정해 상태 줄에 적는 데까지만 들어 있다. 점수·턴·승패 규칙은 직접 더해야 하고, 05 의 합 계산과 09 의 결과 칩 줄이 그 규칙을 얹기 가장 쉬운 자리다.

외부 라이브러리나 이미지가 필요한가?

필요 없다. 08 은 DOM 과 CSS, 나머지 여덟은 캔버스 2d 와 한 파일의 스크립트로만 돌고 외부 주소를 부르는 줄이 하나도 없어서 복사해 붙이면 바로 동작한다.

주사위가 너무 오래 튀거나 금방 눕는다면 어디를 고치나?

튕김은 02 의 DICE_RESTITUTION, 회전이 줄어드는 속도는 03 의 SPIN_DAMPING 이 정하고 멈추기로 넘어가는 시점은 SETTLE_AFTER_MS 가 정한다. 같은 던지기를 다시 보고 싶다면 04 의 DICE_SEED 를 고정하면 매번 같은 배열이 나온다.

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

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