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

javascript 3dアニメーション 9種 — マウスに向く立体模型

3D のウェブサイトはライブラリなしでも点の座標と投影の式ひとつで作れます — 02番は結び目の曲線を管で包んでマウスの方へねじり、05番は押すと箱のふたが順にばねのように開き、08番は小さな立方体の模型が散ってまた集まります。

マウスに振り向く九つの立体模型をこの画面で回してみる

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

javascript 3dアニメーション 9種 — 多面体・結び目・点の球・歯車・箱・らせん・輪・ボクセル・変身する点

並びは線だけの多面体(01)から始まり、面を塗る管(02)、面を持たず点だけを打つ球(03)と描き方を変えていき、二つの物がかみ合う歯車(04)と蝶番で開く箱(05)で部品どうしの関係を扱います。06と07は回る速さそのものをポインターが決める模型で、08と09は模型をつくるかけらが散ったり別の形に乗り換えたりする場面です。九つとも<canvas>の2Dに多角形・円・線を塗っているだけで、画素を読み戻すgetImageDataは一度も呼んでいません。

自前で計算していることと、あえて手を出していないこと

この模型たちが受け持つ仕事は五つにまとまります。点ごとに回転行列を掛け、奥行きで割って画面の座標を出し、遠いものから塗る順に並べ替え、法線が画面に背を向けた面をふるい落とし、法線と光の向きの内積で明るさを決めることです。画素ごとに奥行きを比べる深度バッファ、影の計算、表面への模様の貼り付けはしていないので、汎用の3Dエンジンの代わりにはなりません。そのかわり、ファーストビューで模型をひとつ回すだけならこの五つで足りることを、九つの模型が示しています。

480×300の舞台を2秒撮影して数えた動き

撮影は480×300の舞台で2秒のあいだに24枚を撮り、隣り合う2枚の違いを数えているので、フレーム欄の分母は23です。

模型 累積面積 動いたフレーム 強度の平均
01 遅れて向くワイヤーフレーム 21.1% 23/23 83.3
02 トーラス結び目の管 20.7% 23/23 62.2
03 へこむ点群の球 24.1% 23/23 85.4
04 かみ合う歯車 10.6% 23/23 93.6
05 ばねで開く箱のふた 20.7% 23/23 62.7
06 二重らせんのねじれ 18.1% 23/23 81.3
07 投げて回す三重の輪 23.7% 23/23 84.6
08 散って戻るボクセル 11.9% 23/23 58.2
09 形を変える点群 20.5% 23/23 84.7

九つすべてが23枚のあいだずっと動き、10%を下回った模型もありません。ただし累積面積はいちばん大きい03でも24.1%で、キャンバスの特殊効果の編が場面に求める30%には届いていません。模型は舞台のまんなかの一部を占めるだけで残りは何もない背景なので、変わる画素が模型の輪郭の内側にとどまるからです。04と08がとくに低いのは、04はその場で回る歯車の歯の縁だけが変わり、08はボクセルが散る瞬間以外はハートがほぼ定位置にいるためです。

どのファーストビューにどの模型を置くか

ブランドを象徴する物をひとつ浮かべたいランディングなら01の多面体がいちばん軽く、ポートフォリオのように形そのものを見せたい場所には02の結び目が合います。データやAIサービスのヒーローには03の点群、「こうやって動きます」のセクションには04の歯車がなじみます。商品のお披露目やプレゼント企画の画面は05の箱、バイオやヘルスケアのトップは06のらせんが題材とそのままつながります。07は手で投げてみる楽しさがあるので待ち画面の飾りに、08はゲーム紹介やイベントページに、09はサービスの段階をひとつずつ切り替えて見せる背景に入ります。

ロゴや商品の形をした javascript 3d モデルをトップに一つだけ置くときの見本になるよう、この九つも重い場面の代わりに舞台の右側に模型をひとつだけ置いています。模型の中心は(300, 150)なので、左上のボタンと左下の状態表示を避けられます。

平らな画面の点は、どうして奥行きのある模型に見えるのか?

どの模型も同じ座標の取り方をしています。画面の右が+x、下が+y、画面の奥が+zで、そのため画面のほうを向いた面は法線のzが負になります。点を回すのはrotX・rotY・rotZの三つの回転行列で、視点の行列viewMatrix(yaw, pitch)は縦軸で先に回してから横軸で傾けます。

奥行きで割ると遠い点は小さくなり、中心へ寄っていく

回した点を画面に移す仕事は、下のprojectという関数ひとつが受け持ちます。倍率 s = focal ÷ (focal + z) なのでzが大きいほどsが小さくなり、画面の座標は模型の中心のほうへ引き寄せられます。01の既定値FOCAL_PX 600で外接球の半径110pxの多面体を写すと、いちばん近い頂点は600/490で1.224倍、いちばん遠い頂点は600/710で0.845倍になり、手前と奥の大きさに1.45倍の差がつきます。

    // 遠近の割り算 — 画面の倍率 s = focal / (focal + z)。奥行き z が大きいほど(奥ほど)小さくなり中心へ寄る
    function project(p, focal) {
        const s = focal / (focal + p[2]);
        return { x: CX + p[0] * s, y: CY + p[1] * s, s: s, z: p[2] };
    }
    // 光 — 左上の手前から照らす
    const LIGHT = vnorm([-0.45, -0.62, -0.64]);
    function lambert(n) { return Math.max(0, dot(n, LIGHT)); }

大きさだけでは足りないので、塗る順番と明るさを足す

倍率を変えるだけだと、重なったところで奥が手前を覆ってしまうことがあります。そこで01から08の八つは描く前に奥行きで並べ替えて遠いものから塗り(04は側面と歯車の順で二回)、01・02・04・05・08は法線が画面に背を向けた面を描かずに飛ばします。07は輪の内側の面を飛ばさず、暗く塗り分けます。09の並べ替えは描く順のためではなく、形どうしの点を高さ順に組にするためのもので、前後は透明度と大きさだけで見せています。面の明るさは、01・02・04・05・07・08では左上の手前から差す光LIGHTと法線の内積を0で切った拡散 max(0, n·L) で決めるので、同じ色でも光に向き合う面は明るく、斜めの面は暗くなります。

ポインターの位置が回転角になり、模型がその角度になめらかに追いつくまで

ポインターの座標はgetBoundingClientRectでキャンバスの中の割合を求め、480×300の場面の座標に直します。01・02・04・05・08・09では、その点が模型の中心から横にどれだけずれたか(−1から1)にYAW_RANGE 1.1 radを、縦のずれにPITCH_RANGE 0.8 radを掛けたものが目標の角度で、04はずれに0.45を、05と08は0.4を先に掛けて回りすぎないようにしています。03はポインターをへこむ場所に、06はねじれの速さに、07はドラッグに使うので、この目標の角度をポインターからは取りません。06と07を除く七つの模型は目標へ一気に跳ばず、viewFollowが16msのステップごとに残りの差へ比率kを掛けた分だけ近づきます。

    function viewFollow(v, k) {
        v.yaw += (v.yawTo - v.yaw) * k;
        v.pitch += (v.pitchTo - v.pitch) * k;
    }
    function viewSnap(v) { v.yaw = v.yawTo; v.pitch = v.pitchTo; }
    function viewHome(v) { v.yawTo = v.yaw0; v.pitchTo = v.pitch0; }
    function viewGap(v) { return Math.hypot(v.yawTo - v.yaw, v.pitchTo - v.pitch); }
    // ポインター → 目標の角度。模型の中心から横にずれた分を yaw、縦にずれた分を pitch に (右を指すと正面が右へ回る)
    const YAW_RANGE = 1.1;
    const PITCH_RANGE = 0.8;
    function aimAt(v, px, py) {
        v.yawTo = v.yaw0 - clamp((px - CX) / (W / 2), -1, 1) * YAW_RANGE;
        v.pitchTo = v.pitch0 + clamp((py - CY) / (H / 2), -1, 1) * PITCH_RANGE;
    }

画面の更新ではなく16msの固定ステップで数える

requestAnimationFrameが渡す時刻の差をためておき(1フレームあたり100msまで)、そこから16msを取り出せるたびにstep()を一回、1フレームで最大六回回してからdraw()を呼びます。だから追いつく速さや摩擦がディスプレイのリフレッシュレートで変わりません。時刻がこの16msの刻みに乗るので、05のふたの動き出しもFLAP_STAGGERの110msどおりではなく16・112・224・336msという刻みの上の時刻になります。方向キーも同じ目標の角度を動かし、01から05と08・09は一回押すごとにKEY_STEP 0.3 radずつ回し、06は方向キーをねじれの速さと傾きに、07は←/→を投げる操作に使います。

01マウスの方へ遅れて向きを変え、奥ほど薄くなるワイヤーフレームの多面体

頂点12個を(0, ±1, ±φ)の三組で置くと、元の座標で距離が2になる30組が辺、互いに隣り合う3点の20組が面になり、模型はポインターが決めた目標の角度との差を16msごとに8%ずつ縮めて、差が10%になるまで28ステップ、448msかけて向きを変えます。辺は遠いものから描きながら奥行きに応じて太さと透明度を削るので、奥行きがいちばん奥まで行った辺では太さと透明度がどちらも25%だけ残り、太さにはさらに遠近の倍率がかかります。ランディングのファーストビューに置くブランドオブジェクトに向き、レンダーでは累積面積21.1%・強度83.3・23/23フレームでした。

マウスの方へ遅れて向きを変え、奥ほど薄くなるワイヤーフレームの多面体
新しいタブで開く

01 · マウスの方へ遅れて向きを変え、奥ほど薄くなるワイヤーフレームの多面体 — 計測 累積面積 21.065% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ROT_LERPFOCAL_PXDEPTH_FADE
    // 頂点12個 — (0, ±1, ±φ) を x・y・z の位置へ回して三組作る
    function icosaRaw() {
        const out = [];
        for (let i = 0; i < 4; i += 1) {
            const a = i & 1 ? 1 : -1;
            const b = i & 2 ? PHI : -PHI;
            out.push([0, a, b]);
            out.push([a, b, 0]);
            out.push([b, 0, a]);
        }
        return out;
    }
    const RAW = icosaRaw();
    const VERTS = RAW.map(function (p) { return vscale(vnorm(p), R); });
    // 辺30本 — 元の座標で距離が 2 になる頂点の組
    const EDGES = [];
    for (let i = 0; i < RAW.length; i += 1) {
        for (let j = i + 1; j < RAW.length; j += 1) {
            const d = vsub(RAW[i], RAW[j]);
            if (Math.abs(dot(d, d) - 4) < 1e-6) {
                EDGES.push([i, j]);
            }
        }
    }

02結び目の曲線を管で包み、マウスに合わせてねじれるトーラス結び目

(2, 3)の結び目曲線の132か所ごとに接線・法線・従法線で枠を立て、周りに10点を半径13pxで巡らせて、面1,320枚の管を組み立てます。法線が画面に背を向けた面を飛ばし、残りだけを奥行きの平均の順に塗るので既定の視点で描かれるのは683枚で、押すか「別の結び目」を選ぶと(3, 5)・(2, 5)で管を作り直します。ポートフォリオのヒーローのように形そのものが見どころになる場所に合い、測定は累積面積20.7%・強度62.2・23/23フレームです。

結び目の曲線を管で包み、マウスに合わせてねじれるトーラス結び目
新しいタブで開く

02 · 結び目の曲線を管で包み、マウスに合わせてねじれるトーラス結び目 — 計測 累積面積 20.738% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

KNOT_PKNOT_QTUBE_R
    function knotAt(p, q, phi) {
        const r = 2 + Math.cos(q * phi);
        return [r * Math.cos(p * phi) * KNOT_SCALE, r * Math.sin(p * phi) * KNOT_SCALE, -Math.sin(q * phi) * KNOT_SCALE];
    }
    // 管を作る — 点ごとに接線 T、曲がる側の法線 N、両方に垂直な B で枠を立て、周りの点を輪にする
    function buildTube(p, q) {
        const verts = [];
        const ringDir = [];
        const h = TAU / SEG_U / 4;
        for (let i = 0; i < SEG_U; i += 1) {
            const phi = (i / SEG_U) * TAU;
            const c = knotAt(p, q, phi);
            const a = knotAt(p, q, phi - h);
            const b = knotAt(p, q, phi + h);
            const tng = vnorm(vsub(b, a));
            const accel = vadd(vsub(b, c), vsub(a, c));
            const nrm = vnorm(vsub(accel, vscale(tng, dot(accel, tng))));
            const bin = cross(tng, nrm);
            for (let j = 0; j < SEG_V; j += 1) {
                const v = (j / SEG_V) * TAU;
                const dir = vadd(vscale(nrm, Math.cos(v)), vscale(bin, Math.sin(v)));
                ringDir.push(dir);
                verts.push(vadd(c, vscale(dir, TUBE_R)));
            }
        }
        const m = matMul(viewMatrix(view.yaw, view.pitch), rotZ(spin));
        const rv = tube.verts.map(function (p) { return apply(m, p); });
        const pv = rv.map(function (p) { return project(p, FOCAL); });
        const faceN = [];
        const faceZ = [];
        const order = [];
        for (let i = 0; i < SEG_U; i += 1) {
            const i2 = (i + 1) % SEG_U;
            for (let j = 0; j < SEG_V; j += 1) {
                const j2 = (j + 1) % SEG_V;
                const f = i * SEG_V + j;
                const n = apply(m, tube.normals[f]);
                faceN.push(n);
                faceZ.push((rv[i * SEG_V + j][2] + rv[i * SEG_V + j2][2] + rv[i2 * SEG_V + j][2] + rv[i2 * SEG_V + j2][2]) / 4);
                // 画面に背を向けた面(法線 z > 0)は飛ばす — 管の内側の面が手前に透けて出ない
                if (n[2] < 0.05) {
                    order.push(f);
                }
            }
        }

03ポインターが触れたところがへこんで戻る点群の球

点720個を、高さは均等に分けたうえで点ごとに黄金角137.508°ずつ回して球の表面に広げ、画面側の点のうち投影した位置がポインターから66px以内に入ったものだけ半径を最大34%縮めます。戻す力は減衰比0.38のばねなので、いちばん深く沈んだ点は元の位置を26.1%越えて外へふくらみ、880msで落ち着きます。データやAIサービスのヒーロー場面に似合い、レンダーの値は累積面積24.1%・強度85.4・23/23フレームです。

ポインターが触れたところがへこんで戻る点群の球
新しいタブで開く

03 · ポインターが触れたところがへこんで戻る点群の球 — 計測 累積面積 24.134% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

POINT_COUNTDENT_RADIUSDENT_SPRING
    // 黄金角のらせん — 高さを均等に分け、点ごとに黄金角ずつ回して置く
    function goldenSphere(n) {
        const out = [];
        for (let i = 0; i < n; i += 1) {
            const y = 1 - (2 * (i + 0.5)) / n;
            const r = Math.sqrt(1 - y * y);
            const th = i * GOLDEN_ANGLE;
            out.push([r * Math.cos(th), y, r * Math.sin(th)]);
        }
        return out;
    }
    // ポインターとの近さ → 目標の半径。画面側(z < 0)の点だけ、近いほど深く
    function dentTarget(p, front, ax, ay) {
        if (ax < 0 || !front) {
            return 1;
        }
        const d = Math.hypot(p.x - ax, p.y - ay);
        if (d >= DENT_RADIUS) {
            return 1;
        }
        return 1 - DENT_DEPTH * smooth(1 - d / DENT_RADIUS);

04歯数の比でかみ合って回り、マウスで視点が変わる立体の歯車

歯18枚の大きい歯車と10枚の小さい歯車の輪郭を前後に9pxずつ押し出して厚さ18pxの立体にし、小さい歯車の角度は大きい歯車の角度に18/10を掛けて符号を反転させるので、大きい歯車が歯ひとつ分の20°回ると小さい歯車は−36°、つまり自分の歯ひとつ分だけ回ります。小さい歯車は歯半分の18°を足した位相から始まるので大きい歯車と向き合う位置が溝になり、ピッチ円の上の線速度は二つとも56.7px/sでそろいます。サービスの仕組みを紹介するセクションに合い、測定は累積面積10.6%・強度93.6・23/23フレームです。

歯数の比でかみ合って回り、マウスで視点が変わる立体の歯車
新しいタブで開く

04 · 歯数の比でかみ合って回り、マウスで視点が変わる立体の歯車 — 計測 累積面積 10.555% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TEETH_ATEETH_BTOOTH_DEPTH
    // 歯の輪郭 — 歯ひとつに四点: 根元左・先端左・先端右・根元右 (先端が根元より狭い台形)
    function gearOutline(teeth, r, phase) {
        const out = [];
        const per = TAU / teeth;
        const r0 = r - TOOTH_DEPTH / 2;
        const r1 = r + TOOTH_DEPTH / 2;
        for (let k = 0; k < teeth; k += 1) {
            const a = phase + k * per;
            const spots = [[-0.28, r0], [-0.15, r1], [0.15, r1], [0.28, r0]];
            for (let s = 0; s < spots.length; s += 1) {
                const th = a + spots[s][0] * per;
                out.push([Math.cos(th) * spots[s][1], Math.sin(th) * spots[s][1]]);
            }
        }
        return out;
    }
    const GEARS = [
        { teeth: TEETH_A, r: RA, cx: SHIFT, outline: gearOutline(TEETH_A, RA, 0), col: PAL[0] },
        { teeth: TEETH_B, r: RB, cx: SHIFT + RA + RB, outline: gearOutline(TEETH_B, RB, Math.PI + HALF_TOOTH), col: PAL[4] },
    ];
    // 小さい歯車の角度 — 大きい歯車の角度に歯数の比を掛けて符号を反転する
    function angleB(a) { return -a * TEETH_A / TEETH_B; }
    function report() { say(running ? TXT_RUN : TXT_STOP); }
    function step() {
        viewFollow(view, VIEW_LERP);
        if (running) {
            angA += dir * SPIN_A * FIXED_DT / 1000;
        }
    }

05押すとふた四枚が順にばねのように開く立体の箱

上面を頂点が真ん中に集まる三角形四枚に分けたふたを、それぞれ蝶番の軸のまわりに持ち上げ、開き角は減衰比0.527のばねで積分します。ふた一枚を開くと目標の210°を越えて368msに236.7°、12.7%の行き過ぎまで開いてから戻り、押して四枚を開くと16・112・224・336msに動き出したふたが880・976・1,088・1,200msから±2°の内にとどまり、落ち着く間隔は96・112・112msです。商品のお披露目やプレゼント企画の画面に使いやすく、レンダーの数値は累積面積20.7%・強度62.7・23/23フレームです。

押すとふた四枚が順にばねのように開く立体の箱
新しいタブで開く

05 · 押すとふた四枚が順にばねのように開く立体の箱 — 計測 累積面積 20.651% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LID_OPEN_DEGLID_SPRINGFLAP_STAGGER
    // ふたの先端 — 蝶番から内側へ S だけ伸びた頂点を、蝶番の軸のまわりに θ だけ持ち上げる
    function flapTip(f, th) {
        return vadd(f.hinge, vadd(vscale(f.inward, S * Math.cos(th)), [0, -S * Math.sin(th), 0]));
    }
    function setOpen(v) {
        open = v;
        for (let i = 0; i < flaps.length; i += 1) {
            // 閉じるときは最後に開いたふたから
            const order = open ? i : flaps.length - 1 - i;
            flaps[i].start = lidClock + order * FLAP_STAGGER;
        }
        if (lidBtn) {
            lidBtn.setAttribute('aria-pressed', open ? 'true' : 'false');
        }
    }
    function step() {
        viewFollow(view, VIEW_LERP);
        lidClock += FIXED_DT;
        gemSpin += 1.2 * FIXED_DT / 1000;
        for (let i = 0; i < flaps.length; i += 1) {
            const f = flaps[i];
            if (f.start >= 0 && lidClock >= f.start) {
                f.spring.to = open ? LID_OPEN_DEG * DEG : 0;
            }
            springStep(f.spring, LID_SPRING, LID_DAMP, FIXED_DT / 1000);
            // 閉じて行き過ぎたふたは箱の中へ入らず、上面で軽く跳ね返る
            if (f.spring.x < 0) {
                f.spring.x = 0;
                f.spring.v = -f.spring.v * 0.3;
            }
        }
    }

06マウスの横の位置でねじれの速さが変わる二重らせんのビーズ模型

二本の鎖のビーズ34組を同じ高さで180°ずらして2.5回巻き、3段ごとに横木を渡すので、横木は全部で12本になります。ねじれの速さはポインターの横の位置から決まって左端の−2.0、真ん中の0.6、右端の3.2 rad/sのあいだを行き来し、縦の位置は前後の傾きになり、ビーズ・背骨・横木をひとつのリストにまとめて奥行きの順に描きます。バイオやヘルスケアサービスのファーストビューに合い、累積面積18.1%・強度81.3・23/23フレームと測定されました。

マウスの横の位置でねじれの速さが変わる二重らせんのビーズ模型
新しいタブで開く

06 · マウスの横の位置でねじれの速さが変わる二重らせんのビーズ模型 — 計測 累積面積 18.058% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HELIX_TURNSRUNG_EVERYTWIST_GAIN
    // ポインター → ねじれの速さ・傾きの目標
    function aimTwist(x, y) {
        twistTo = BASE_TWIST + TWIST_GAIN * clamp((x - CX) / (W / 2), -1, 1);
        tiltTo = clamp((y - CY) / (H / 2), -1, 1) * TILT_MAX;
    }
    function step() {
        twist += (twistTo - twist) * TWIST_LERP;
        tilt += (tiltTo - tilt) * TWIST_LERP;
        if (!paused) {
            phase += twist * FIXED_DT / 1000;
        }
    }
    function settle() { twist = twistTo; tilt = tiltTo; }
        // 縦軸まわりのねじれは phase が、前後の傾きは横軸の回転が、少し横に寝た姿勢は奥行きの軸の回転が受け持つ
        const m = matMul(rotZ(-0.32), rotX(tilt));
        const bs = beads();
        const rv = bs.map(function (b) { return apply(m, b.p); });
        const pv = rv.map(function (p) { return project(p, FOCAL); });
        const cols = [PAL[1], PAL[2]];
        const items = [];
        let rungs = 0;
        for (let i = 0; i < bs.length; i += 1) {
            items.push({ z: rv[i][2], kind: 0, a: pv[i], b: pv[i], ca: cols[bs[i].strand], cb: cols[bs[i].strand] });
            // 同じ鎖の次のビーズとつなぐ背骨
            if (i + 2 < bs.length) {
                items.push({ z: (rv[i][2] + rv[i + 2][2]) / 2 + 0.5, kind: 1, a: pv[i], b: pv[i + 2], ca: cols[bs[i].strand], cb: cols[bs[i].strand] });
            }
        }
        for (let i = 0; i < BEADS; i += RUNG_EVERY) {
            const a = 2 * i, b = 2 * i + 1;
            items.push({ z: (rv[a][2] + rv[b][2]) / 2 + 1, kind: 2, a: pv[a], b: pv[b], ca: cols[0], cb: cols[1] });
            rungs += 1;
        }
        // 奥行きの順 — 遠いビーズ・横木から
        items.sort(function (p, q) { return q.z - p.z; });

07投げるようにドラッグすると惰性で回り、ゆっくり止まる三重の輪

ドラッグを離す瞬間の手の速さを角速度に変え、離したあとは16msごとに0.96を掛けて減らしながら、外の輪は縦軸、真ん中は横軸、内側は奥行きの軸と、同じ角度を別々の軸に割り振って回します。1ステップ0.1 radで投げると速度が5%に落ちるまでの74ステップ(1.18秒)で130.8°、止まるまでに136.8°(2.388 rad)回り、等比級数の閉じた式 w·F/(1−F) が与える2.4 radに近い値になります。触って遊べるファーストビューや待ち画面の飾りに向き、レンダーでは累積面積23.7%・強度84.6・23/23フレームが出ました。

投げるようにドラッグすると惰性で回り、ゆっくり止まる三重の輪
新しいタブで開く

07 · 投げるようにドラッグすると惰性で回り、ゆっくり止まる三重の輪 — 計測 累積面積 23.661% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLICK_GAINSPIN_FRICTIONRING_COUNT
    // ドラッグ1ステップの動き → 符号つきの速さ。横が大きければ横の向き、縦が大きければ縦の向きに従う
    function signedSpeed(dx, dy) { return Math.abs(dx) >= Math.abs(dy) ? dx : dy; }
    // 手を離したあとの1ステップ — 角速度に摩擦の係数を掛ける
    function coast(v) {
        const n = v * SPIN_FRICTION;
        return Math.abs(n) < STOP_W ? 0 : n;
    }
    function step() {
        if (dragging) {
            // ドラッグ中は手の速さをそのまま角速度に — 半分だけ混ぜて手の震えをこす
            const target = clamp(signedSpeed(px - lastX, py - lastY) * FLICK_GAIN, -MAX_SPIN, MAX_SPIN);
            w = lerp(w, target, 0.5);
            lastX = px;
            lastY = py;
        }
        else {
            w = coast(w);
        }
        angle += w;
        turned += Math.abs(w);
    }
    // 輪 i の姿勢 — 外の輪の中に真ん中、その中に内側が吊られたジンバル。同じ角度をそれぞれ別の軸に使う
    function axisRot(axis, a) { return axis === 'y' ? rotY(a) : (axis === 'x' ? rotX(a) : rotZ(a)); }
    function ringMatrix(i) {
        let m = axisRot(RING_AXIS[0], angle);
        for (let k = 1; k <= i; k += 1) {
            m = matMul(m, axisRot(RING_AXIS[k], angle));
        }
        return m;
    }

08マウスを当てると散らばって、また集まる小さな立方体の模型

9×8の文字表の#のマス46個を二層に積んでボクセル92個のハートを作り、投影した位置がポインターから64px以内に新しく入ったボクセルにだけ外向きの速度を与えてから、減衰比0.406のばねで元の場所へ引き戻します。一度押されたボクセルは192msにいちばん遠い38pxまで出て、1,520ms以降は1px以内にとどまり、立方体ごとに画面を向いた面だけを違う明るさで塗ります。イベントページやゲーム紹介のヒーローに合い、測定は累積面積11.9%・強度58.2・23/23フレームです。

マウスを当てると散らばって、また集まる小さな立方体の模型
新しいタブで開く

08 · マウスを当てると散らばって、また集まる小さな立方体の模型 — 計測 累積面積 11.915% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

VOXEL_SIZESCATTER_FORCEREFORM_K
    // ポインターの半径に新しく入ったボクセル — 画面でポインターの反対側へ押し、シードの値だけ前後にも弾く
    function scatter(m) {
        for (let i = 0; i < voxels.length; i += 1) {
            const v = voxels[i];
            const q = project(apply(m, v.pos), FOCAL);
            const d = Math.hypot(q.x - px, q.y - py);
            const inside = px >= 0 && d < SCATTER_RADIUS;
            if (inside && !v.inside) {
                const dx = d > 1e-3 ? (q.x - px) / d : 0;
                const dy = d > 1e-3 ? (q.y - py) / d : -1;
                // 画面の向きを模型の座標へ戻す — 回転行列の転置
                const dir = applyT(m, vnorm([dx, dy, -0.6 + 0.4 * v.kick]));
                const k = SCATTER_FORCE * (1 - 0.5 * d / SCATTER_RADIUS);
                v.vel = vadd(v.vel, vscale(dir, k));
            }
            v.inside = inside;
        }
    }
    function step() {
        viewFollow(view, VIEW_LERP);
        wobble += 0.9 * FIXED_DT / 1000;
        const m = matrix();
        scatter(m);
        const dt = FIXED_DT / 1000;
        for (let i = 0; i < voxels.length; i += 1) {
            const v = voxels[i];
            // ばねで戻す — 加速 = −k(位置 − 元の場所) − d・速度
            const a = vsub(vscale(vsub(v.pos, v.home), -REFORM_K), vscale(v.vel, REFORM_DAMP));
            v.vel = vadd(v.vel, vscale(a, dt));
            v.pos = vadd(v.pos, vscale(v.vel, dt));
        }
    }

09点群が立方体・球・輪の形へ順に変わりながら回る場面

立方体・球・輪の表面にシードで520点ずつ取り、高さの順に並べてから同じ順番の点どうしを組にして、900msのあいだに位置と色を補間します。そのあいだにのせる揺れの幅が14·sin(π·進行率)なので出発と到着では0、時間の25%の地点で2.73px、50%の地点で14pxになり、ポインターは模型全体の視点を回します。サービスの段階紹介やストーリーの背景に使いやすく、レンダーの値は累積面積20.5%・強度84.7・23/23フレームです。

点群が立方体・球・輪の形へ順に変わりながら回る場面
新しいタブで開く

09 · 点群が立方体・球・輪の形へ順に変わりながら回る場面 — 計測 累積面積 20.519% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MORPH_MSSHAPE_ORDERNOISE_AMP
    function sample(name, seed) {
        const rnd = makeRng(seed);
        const out = [];
        for (let i = 0; i < N; i += 1) {
            if (name === 'cube') {
                const f = Math.floor(rnd() * 6);
                const u = rnd() * 2 - 1, v = rnd() * 2 - 1, s = f % 2 ? 1 : -1;
                const a = SIZE * 0.78;
                out.push(f < 2 ? [u * a, v * a, s * a] : (f < 4 ? [s * a, u * a, v * a] : [u * a, s * a, v * a]));
            }
            else if (name === 'sphere') {
                const y = 1 - (2 * (i + 0.5)) / N;
                const r = Math.sqrt(1 - y * y);
                const th = i * Math.PI * (3 - Math.sqrt(5));
                out.push([r * Math.cos(th) * SIZE, y * SIZE, r * Math.sin(th) * SIZE]);
            }
            else {
                const u = rnd() * TAU, v = rnd() * TAU;
                const big = SIZE * 0.8, small = SIZE * 0.3;
                out.push([(big + small * Math.cos(v)) * Math.cos(u), small * Math.sin(v), (big + small * Math.cos(v)) * Math.sin(u)]);
            }
        }
        out.sort(function (a, b) { return a[1] - b[1]; });
        return out;
    }
    // 点ごとの整数ハッシュ → −1 ~ 1。同じ点・同じ節なら同じ値
    function hash(i, k) {
        let h = Math.imul(i, 374761393) ^ Math.imul(k, 668265263);
        h = Math.imul(h ^ (h >>> 13), 1274126177);
        return (((h ^ (h >>> 16)) >>> 0) / 4294967296) * 2 - 1;
    }
    // 値ノイズ — 節の値二つを smoothstep でつなぐ
    function noise(i, ms) {
        const x = ms / NOISE_STEP;
        const k = Math.floor(x);
        return lerp(hash(i, k), hash(i, k + 1), smooth(x - k));
    }

点を数千個に増やす・カメラを寄せすぎる・裏の面が手前に回る — 立体のふりが壊れるとき

ここに並べたのは、ソースの関数を直接呼んで確かめた値と、ソースの式から計算し直した値です。機種ごとの1フレームの処理時間は測っていません。

点を増やすと投影と並べ替えもその分ふくらむ

03は描くたびに720点をすべて投影して奥行きで並べ替え、09は520点を並べ替えずに透明度と大きさだけで前後を出しています。点を数千個に増やすと投影の計算と並べ替えの比較がいっしょに増えるので、数を多く使いたいなら09のように並べ替えを外す方法がまず候補になります。ただし並べ替えをやめると、重なった点の前後が混ざって見えます。

焦点距離を模型の半径に近づけると倍率が跳ね上がる

倍率の分母は focal + z で、いちばん近い頂点のzは−Rなので、FOCAL_PXを半径の110に向けて縮めると分母が0に近づき、手前側の倍率が手に負えないほど大きくなります。300にしただけでも近い頂点は1.579倍になり、600のときの1.224倍よりはっきり飛び出して見えます。

比率をフレームごとに掛けると速さがリフレッシュレートしだいになる

01の追従や07の摩擦をrequestAnimationFrameの1回ごとに掛けると、フレームが二倍細かい画面では二倍速く追いつき、投げた輪が止まるまでの距離も変わってしまいます。このソースは16msの固定ステップでだけ掛けるので、その差が生まれません。

平均の奥行きで並べると、食い込み合う瞬間に前後を取り違える

並べ替えの単位は面や物ひとつの平均の奥行きで、画素ごとには比べていません。04は歯車一枚をひとかたまりで並べるので、二つの軸が大きく傾いて前後が入れ違う視点ではかみ合う部分の前後を間違えることがあり、08は散ったボクセルどうしが突き抜け合う瞬間に1フレームだけ前後が入れ替わることがあります。02から並べ替えを外すと、結び目が自分の体をまたぐところで奥の管が手前の管を覆います。

裏の面を捨てるだけだと、開いた箱の中が空になる

05が外の壁だけを作って裏の面を捨てると、ふたが開いたときに箱の内側が抜けて後ろの背景が見えてしまいます。そこで0.6px内側へ寄せた内壁四枚と底を法線を内向きにして別に持たせ、既定の視点で開いた状態では26面のうち13面を描いています。06は逆に何も捨てない代わりに、横木の奥行きに+1、背骨に+0.5を足して、同じ奥行きのビーズと並べ替えが揺れてちらつくのを防いでいます。

動いたあとの位置で距離を測り直すと点が震える

03がへこんだあとの位置でポインターとの距離を測ると、沈んだ点がポインターから離れて飛び出し、また近づいて沈むのを繰り返します。そのため距離は、へこむ前の半径Rで投影した位置で測ります。08も半径の内にいるあいだ毎ステップ速度を与えると、ポインターを当てているだけでボクセルが際限なく速くなるので、半径に新しく入った瞬間にだけ押し、画面の向きはapplyTで回転を戻してから模型の座標に足します。

組み合わせを間違えると、かみ合わせも変身もほつれる

04から歯半分の位相を抜くと、歯と歯が正面からぶつかって互いにめり込みます。09で二つの形の点をでたらめな順に組にすると、点が画面を横切って絡まりひとかたまりの雲になるので、高さの順に並べてから組にしています。02の(p, q)も組み合わせが大事で、2と4のように互いに素でない値だと、曲線が一本につながらず複数の輪に分かれます。

九つの模型のバニラのindex.html・style.scssと、同じ計算をtsxに移したReact版はひとつの圧縮ファイルにまとめてあり、展開するときに聞かれるパスワードは ndnyxvuv です。立体 アニメーション 作り方を自分の画面で試すなら、模型の中心CX・CYと定数を数行合わせるところから始められます。

追いつく速さ・焦点距離・摩擦を変える試しと、マウスなしでも回せるアクセシビリティ

名前 値 手ざわり
追いつく速さを上げる 01 ROT_LERP 0.08 → 0.2 角度の差が最初の10%になるまで28ステップ(448ms)かかっていたのが11ステップ(176ms)に縮みます。模型がポインターにほぼ張りついて回り、そのぶん手の震えもこされずに画面へ出ます。
焦点距離を縮める 01 FOCAL_PX 600 → 300 半径110pxで近い頂点と遠い頂点の倍率が1.224倍・0.845倍から1.579倍・0.732倍に変わり、前後の差は1.45倍から2.16倍に広がります。遠近が強調され、近い辺が画面の外へ押し出されてくるように見えます。
摩擦を減らす 07 SPIN_FRICTION 0.96 → 0.99 1ステップ0.1 radで投げたとき、5%まで74ステップ(1.18秒)・130.8°だったのが299ステップ(4.78秒)・539°に伸び、止まるまでの角度は136.8°から564°になります。一度投げた輪が一周半を超えて回り続けます。

キーボードとスクリーンリーダーで操作する

九つのキャンバスにはどれもtabindex="0"とaria-labelがあってフォーカスが入り、ボタンも模型ごとに二つずつ本物の<button>で置いています。方向キーは01から05と08・09では視点を回し、06では←/→がねじれの速さを±0.8、↑/↓が傾きを±0.2変え、07では←/→が角速度に±0.12を足す投げる操作、↑/↓が視点です。01の「奥行きのぼかしを切る」、04の「逆に回す」、05の「ふたを開け閉め」、06の「ねじれを止める」はaria-pressedでオンとオフを伝えます。状態表示は自動デモのあいだaria-live="off"で黙っていて、人が初めてポインターやキー、ボタンに触れるとpoliteに切り替わり、それ以降の変化だけを読み上げます。

動きを減らす設定をオンにしたとき

prefers-reduced-motion は九つの模型すべてがmatchMediaで読み、そこにchangeのリスナーを一つずつ付けているので、ページを開いたまま設定を切り替えてもすぐに追従します。オンになった瞬間にonMotionPrefが回っていたフレームのループを止め、settle()で終わりの状態を作ってから一度描き、オフになるとループを再開します。減らす設定のあいだも入力は受け付け、押すたびにafter()が終わりの状態まで計算してすぐに描き直すので、視点を回した結果やふたを開け閉めした結果が、動きなしで画面に残ります。

ひとりでに回っていた02の結び目、03の球、04の歯車、06のねじれ、09の自転は、この状態では止まったままです。その代わり04のEnterは歯半分ひとこま、06の←/→は位相±0.4、08の「散らす」は視点のyaw +0.5に置き換わって入力が画面に見えるようにし、07はドラッグした分だけすぐに回り、離すと残りの回転を等比級数の和として一度に足します。

javascript 3d canvas だけで、ライブラリなしにどこまで描けるか?

模型をひとつポインターのほうへ回すくらいなら、入れなくてもかまいません。この編の九つの模型はライブラリ・CDN・WebGLを使わず、CanvasRenderingContext2Dのパスの塗り・円・線とrequestAnimationFrame、ポインターのイベントだけで動き、回転と投影は3×3の行列の掛け算と割り算一回です。この編のviewMatrixも縦軸の回転を先に、横軸の傾きを後に掛けていて、行列を掛ける順番はMDNのウェブのための行列計算でさらに確かめられます。

ライブラリが要るようになる境目は、先の節に書いた限界と重なります。面が数千枚あって互いに食い込む場面、画素ごとに奥行きを比べないと正しくならない場面、表面に写真を貼りたい場面は、この五つの計算の範囲を超えます。模型がファーストビューの物ひとつで面の数がこの編くらいなら、ソースを一ファイル貼るほうが受け取るファイルも少なく、直す場所も目で追えます。

CSSのtransformだけでカードや立方体を回す作りはポインターに合わせて回るCSSの3D場面、キャンバスの投影で観覧車や地形のような世界の場面を組むほうはマウスで回る立体の場面にあります。回転行列から上の面を読み取る例はサイコロを転がす九つの場面、奥行きの層を重ねた背景は奥行きの層で作る3Dの背景、画素ごとに距離を測って描く3Dはレイマーチングの九つの場面、この編が品質の基準にした計算の場面はキャンバスの特殊効果です。

立体模型をファーストビューに置く前によくある三つの確かめごと

javascript 3d モデルを自分のロゴの形にするには、どこを変えますか?

いちばん手間が少ないのは08です。文字表HEARTの#の位置を自分のロゴの輪郭に描き替えると、そのマスごとにボクセルが積まれ、散って戻る動きもそのままついてきます。線でできたロゴなら01のicosaRawが返す頂点の一覧と辺の組EDGESを自分の座標に替え、点で埋めるロゴなら09のsampleに形をひとつ足します。

スマートフォンのタッチでも模型は回りますか?

回ります。九つの模型はどれもマウスとタッチを同じように受け取るpointermoveとpointerdownで入力を読み、style.cssのtouch-action: pan-yが縦のドラッグをページのスクロールに残します。07はドラッグの途中で指がキャンバスの外へ出ても見失わないよう、setPointerCaptureでポインターをつかんでおきます。

ポインターをキャンバスの外へ出すと、模型はどうなりますか?

01・02・04・05・08・09はpointerleaveを受けるとviewHomeで目標の角度を既定の視点へ戻し、08は散らす位置もいっしょに消します。03はポインターの座標だけを消すので、へこみがばねでふくらんでから落ち着くだけで、視点はそのときの角度のまま残ります。06はgoHome()でねじれの速さを最初の0.6 rad/sへ、傾きを最初の0.25へ戻し、07は手を離したあとの惰性がこの模型の動きそのものなのでpointerleaveを受けず、投げた分だけ回って摩擦で止まります。

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

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