GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

サイコロ アニメーション 9パターン — 回転行列で上の目を読む3D場面

投げる、跳ねる、止まる — 一回の転がりは三つの動詞だけで、03番は最後の動詞で上の目の数を計算で読み取る。以下の九つは、サイコロ アニメーションをキャンバスの2Dで一歩ずつ計算し直して描いた場面で、zipを開けば追加の設定なしでそのまま動きます。

九つのサイコロの場面をこのページで転がして確かめる

自動再生 · タイルを押すとその項目へ · すべて1つのzip

サイコロ アニメーション 9パターン — 回転行列ひとつが上の目まで決める

サイコロ一つの向きは3×3の回転行列 R 一枚で決まり、08番を除く八つの場面はすべてこの行列の上で動いています。01番は行列を宙で回し続けて見える面を選び、02番は同じ行列に重力と床の衝突を足し、03番は止まるあいだに行列を上の目の側へゆっくり起こします。04番は投げる値をシードの数列から引き、05番は二つのサイコロの目を足して積みます。

06番は床に当たった場所ごとにほこりと跡を残し、07番はカップの中で行列にノイズのずれを足してがたがた鳴らします。08番だけは3D計算を使わず、CSSで面を一枚平らに潰して転がったように見せ、09番が三つのサイコロを一つの盤にまとめた場面です。サイコロ 転がる アニメーションが必要な画面のほとんどは、この九つのどれかから始められるように順番を組みました。

手を離れた瞬間から目が決まる瞬間までの一回の転がりを計算でつないであるので、サイコロの振りを試す画面を作るときも、01番の投影と03番の上の目の読み取りはそのまま持っていけます。近い物理の場面を知りたいときは、跳ねるボール アニメーション 9選 — 衝突と重力の物理計算とコマ 回転 9パターン — 角速度と歳差で描く回転シーンを続けて見てください。

技法別に見るサイコロ回転のコード

コードは各場面の index.html からつながった行をそのまま切り出したもので、描画関数よりも場面ごとに違う計算がある場所を選びました。完全版はzipの中にあります。

01回転行列で回す3Dサイコロ

rotAxis() で作った回転行列を matVec() で八つの頂点に掛けて遠近投影し、法線と視線の内積が0より小さい面だけを塗ってカメラに背を向けた裏側を除きます。宙を漂うサイコロは DEMO_KICK_MS の1500msごとに新しい軸で蹴られ、上の目は一歩ごとに法線yが最大の面として読まれて状態行に出ます。計測では累積面積13.8%・平均強度72.4で、23/23フレームが動きました。

回転行列で回す3Dサイコロ
新しいタブで開く

01 · 回転行列で回す3Dサイコロ — 計測 累積面積 13.776% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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番は一つのサイコロが弱く投げられるので最も狭くなりますが、10%を下回る場面はありません。

項目 累積面積 平均強度 動いたフレーム
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回になるため、抽選や賭けの根拠には使わず演出にとどめてください。値を自分で変えながら確かめたいときは、zipをパスワード ky895umc で開いて使ってください。

変形3つとアクセシビリティ

変形 値 感じ方
反発係数を調整する 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だけで作る道は【一行でコピペOK】CSS 3D アニメーション9選に、ピクセルまで計算するキャンバスの場面はhtml5 canvas アニメーション9種 — 水滴とブラックホールに、奥行きの層を重ねる背景はcss パララックス 背景 9選 — 奥行き3Dシーンにあります。

サイコロの場面を使う前によくある質問

サイコロを振るゲームにするには、何を足せばいいですか?

各場面は、止まったら目の数を result に確定して状態行に書くところまでしか入っていません。得点・順番・勝ち負けのルールは自分で足す必要があり、05番の合計計算と09番の結果チップの列が、そのルールをのせるのにいちばん向いた場所です。

サイコロ アニメーション 素材として画像や外部ライブラリは必要ですか?

必要ありません。08番はDOMとCSS、残りの八つはキャンバス2Dと一つのファイルのスクリプトだけで動き、外部のアドレスを呼ぶ行は一つもないので、zipを開けばそのまま動きます。

サイコロが長く跳ねすぎたり、すぐ寝てしまったりするときはどこを直しますか?

跳ね方は02番の DICE_RESTITUTION、回転が減る速さは03番の SPIN_DAMPING で決まり、止まる段階へ移る時点は SETTLE_AFTER_MS が決めます。同じ投げをもう一度見たいときは04番の DICE_SEED を固定すれば、毎回同じ並びが出ます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。