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

磁性流体 シミュレーション 9種 — 立つトゲから時計まで

磁性流体 シミュレーションを、実物ではなく画面の中の計算で組み直したキャンバス 2D の場面 9種です。磁石が近づいても水たまりは平らなまま耐え、強さがしきい値を超えた瞬間にトゲがいっせいに立ちます。そのしきい値はどう計算するのでしょうか。

磁石が強まっても水たまりはしばらく耐え、ある強さでいっせいに立ちます — 磁性流体の仕組みはしきい値ひとつから始まります

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

磁性流体とは、磁石に反応して表面にトゲを立てる液体のことです。この 9場面が共有する決まりは短く、磁石がつくる強さを点ごとに測り、その強さがしきい値を超えたぶんだけ液体を動かします。01 は強さの向きを線で、02〜04 は高さ・光沢・傾きで、05・06 は水たまりとしずくの移動で、07・08 は時間で変わる強さで見せ、09 はその五つの流れを一枚の皿に重ねます。

いっせいに立つ瞬間を数字でほどくと

02 で磁石の極は深さ 130px から 42px まで 0.9秒かけて近づきます。極ひとつの強さは距離 60px で 1 になるように決めたので、真上の強さが 0.45 になる深さは式で 60 ÷ √0.45 = 89.4px、フレーム単位で測ると深さ 88.4px の 0.63秒に初めてしきい値を超えます。それまでは高さの目標がどのマスも 0 で、水たまりはふちの縁だけが残った平らな板です。超えたあとは中央からトゲが立ち、磁石がいちばん近づくとトゲの立つ半径は √(60² ÷ 0.45 − 42²) = 79px まで広がります。

実物の実験ではなく画面の計算で答える理由

この記事は実物の磁性流体の扱い方を説明しません。画面の強さは FIELD_REF_PX 60 の距離で 1 になるよう決めた相対値なので実際の単位には直せず、液体の色 FLUID [13,12,17] もほぼ黒に固定した絵の値です。そのかわり、しきい値・傾き・鎖のように目で確かめたい性質を数式数行で組み、数字を変えると画面がどう変わるかをすぐ見られるようにしました。

となりのキャンバス物理の記事とちがう点

物理シミュレーションの作り方の磁石パーティクルは点ひとつが引かれる力だけを扱い、シャボン玉のエフェクトは膜の厚さで色をつける計算が中心です。この記事は強さの場を画面全体に敷き、その上に高さの格子を立てる点がちがいます。計算で描く場面エフェクトの基準は水滴とブラックホールのキャンバスエフェクトの記事にあります。

双極子の式で場を測り、三つの波を重ねてトゲを立て、高さの傾きで光沢をのせます — 三段の計算です

9場面は共通の関数三つを、場面ごとに選んで組み合わせます。各段で何が決まるかをまず表に置き、下で一つずつ開きます。

段階 関数 決まること 使う場面
1 場を測る fieldAt 極ごとに足した強さと向き、距離の下限 soften 01〜05・07〜09(06 は選んだ場の向き一つ)
2 トゲを立てる hexWave 間隔 spacing の六角形に並ぶ模様 0〜1 02・03・07・08・09
3 光沢をのせる shadeOne・shadeGrid 傾きから求めた反射ハイライトの色 02・03・07・08・09

極ごとに足す強さと 18px の距離の下限

極ひとつは符号つきの点 {x, y, q} で、測る点までのベクトルに三つ目の成分として soften を入れ、極の真上でも距離がそれ以下に縮まらないようにします。01 ではこの値が 18px なので、極ひとつだけ見ると真上の強さは (60 ÷ 18)² = 11.11 で止まります。三つの成分はグローバルの field 配列に残すので、04 のように向きが必要な場面は計算し直さずに読みます。

    const FIELD_REF_PX = 60;
    const field = [0, 0, 0];
    function fieldAt(poles, x, y, soften) {
        let bx = 0;
        let by = 0;
        let bz = 0;
        const ref2 = FIELD_REF_PX * FIELD_REF_PX;
        for (const p of poles) {
            const dx = x - p.x;
            const dy = y - p.y;
            const r2 = dx * dx + dy * dy + soften * soften;
            const k = p.q * ref2 / (r2 * Math.sqrt(r2));
            bx += dx * k;
            by += dy * k;
            bz += soften * k;
        }
        field[0] = bx;
        field[1] = by;
        field[2] = bz;
        return Math.sqrt(bx * bx + by * by + bz * bz);
    }

120° ずれた三つの波が六角形の並びになる理由

向きが 0°・120°・240° の cos の波を三つ足すと、合計は −1.5 から 3 のあいだを行き来し、三つの山が重なる点だけが 3 に近づきます。波数を 4π ÷ (√3 × 間隔) にすると、その重なる点のあいだがちょうど間隔になり、蜂の巣のように並びます。合計を 3 で割った値のうち 0.3 より下は 0 に切ってトゲのあいだを平らにし、残った上側を 0〜1 に引きのばして、先へ行くほどまっすぐ細くなる円すいの形にします。

高さの差からハイライトまでの道すじ

面の向きは、となりのマスの高さの差で求めた (−∂z/∂x, −∂z/∂y, 1) を長さ 1 に縮めたものです。光の向きと正面上の目の向きのまん中の向きをこの面の向きと内積し、SHINE 乗で鋭くして、ピンク [255,190,210] と水色 [172,212,255] の二つの光を順に足します。傾いたぶんは黒い下地に紫がかった FLUID_EDGE [86,80,112] を半分まで混ぜるので、ハイライトのない斜面にも輪郭が見えます。格子の絵は、マスひとつを画素ひとつとして隠しキャンバスに putImageData で書いてから、drawImage で板の大きさまでなめらかに引きのばして貼ります。

磁性流体 シミュレーション 9種 — 双極子の式でたどる磁力線・臨界の強さを超えると六角形に並んで立つトゲ・高さの傾きからのせる黒い光沢・磁力線の向きに傾くトゲ・磁石に引かれながら丸く踏ん張る水たまり・磁場の向きに並ぶしずくの鎖・拍に合わせて立っては沈むトゲ・数字の形の磁石の並びで時刻を立てる磁性流体の時計・ガラス皿の上の磁性流体インテリアの場面

01〜04 は一つの場から線・トゲ・光沢・傾きを一つずつ取り出す場面で、05・06 は液体が位置を動かす場面、07・08 は強さを時間割に変える場面です。09 は前の五つの計算を皿の上の一場面に集めます。九つとも一つのキャンバス 2D に、ライブラリ・WebGL・画像ファイルなしで描き、乱数はシード固定の makeRng なので、開くたびに同じデモが流れます。

480×300 の画面を 2秒撮って測った動き

場面 動いた面積 動いたコマ 強さの平均
01 双極子の磁力線 43.6% 23/23 97.6
02 しきい値を超えて立つ六角形のトゲ 9.8% 23/23 63.3
03 高さの傾きの光沢 14.4% 23/23 38.3
04 場にそって傾くトゲ 16.9% 23/23 95.5
05 引かれて踏ん張る水たまり 30.5% 23/23 113.5
06 しずくの鎖 63.1% 23/23 57.1
07 拍に跳ねるトゲ 6.0% 23/23 64.4
08 磁石格子の時計 14.2% 23/23 29.6
09 ガラス皿の場面 22.1% 23/23 79.4

しずく 140個が板全体で集まっては散る 06 が 63.1% でいちばん広くなります。07 は磁石が固定で中央のトゲの群れだけが上下するため 6.0% にとどまったようで、それでも九場面すべてが 23/23 のコマで画面が変わりました。

01双極子の式でたどる磁力線

理科学習サイトの磁場の単元の導入図のように、棒磁石が横 44px・縦 30.8px の楕円を 6秒で一周するあいだ、N 極のまわり 14点から出た線を 4px の歩ごとにその場の磁場の向きへつなぎ、フレームごとに 17〜18本を引き直します。一歩は半歩先の向きを読み直してから進む二段階の積分で、S 極の 9px 以内に届くか画面の外 24px を越えると止まります。線の上の点線は強さに比例して流れ、極のそばでは 205px/s、140px 離れた所では 2.36px/s と 87倍ちがいます。

双極子の式でたどる磁力線
新しいタブで開く

01 · 双極子の式でたどる磁力線 — 計測 累積面積 43.612% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FIELD_SOFTEN_PXSTEP_PXFLOW_SPEED
        for (let s = 0; s < MAX_STEPS; s += 1) {
            dirAt(x, y, sign, d1);
            const mx = x + d1[0] * STEP_PX / 2;
            const my = y + d1[1] * STEP_PX / 2;
            const bm = dirAt(mx, my, sign, d2);
            x += d2[0] * STEP_PX;
            y += d2[1] * STEP_PX;
            // 流れる時間 = 歩の長さ ÷ 速さ(FLOW_SPEED × 強さ)。速い所ほど同じ時間に点線が遠くへ進む
            t += STEP_PX / (FLOW_SPEED * Math.max(1e-3, bm));
            L.x.push(x);
            L.y.push(y);
            L.t.push(t);
            L.b.push(bm);
            if (Math.hypot(x - target.x, y - target.y) < SEED_RADIUS_PX) {
                reached = true;
                break;
            }
            if (x < -EDGE_PX || x > W + EDGE_PX || y < -EDGE_PX || y > H + EDGE_PX) {
                break;
            }
        }

02臨界の強さを超えると六角形に並んで立つトゲ

スピーカー製品紹介ページのファーストビューの背景として、磁石の極が近づくあいだ水たまりは平らで、中央の強さがしきい値 0.45 を超える 0.63秒からトゲが立ちはじめます。マスごとの高さの目標は強さではなくしきい値を超えたぶんに 18 を掛けた値なので、いちばん近づいたときの中央は 18 × (2.04 − 0.45) = 28.6px です。高さは硬さ 80・減衰 8 のばねで追い、1.17秒に初めて目標の ±3% の帯に入り、中央のマスが最高の 30.3px に届く 1.25秒には磁石が横へすべりはじめて目標が 21.7px へ下がっているところです。

臨界の強さを超えると六角形に並んで立つトゲ
新しいタブで開く

02 · 臨界の強さを超えると六角形に並んで立つトゲ — 計測 累積面積 9.821% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CRITICAL_FIELDSPIKE_SPACING_PXSPIKE_STIFFNESS
    // マスごとに強さを測って高さの目標を決める — しきい値より下は 0、上は超えたぶん
    function measure() {
        poles[0].x = magX;
        poles[0].y = magY;
        peak = fieldAt(poles, magX, magY, depth);
        for (let j = 0; j < grid.gh; j += 1) {
            for (let i = 0; i < grid.gw; i += 1) {
                const k = j * grid.gw + i;
                const b = fieldAt(poles, i * CELL_PX, j * CELL_PX, depth);
                grid.target[k] = grid.wet[k] > 0.5 ? SPIKE_GAIN_PX * Math.max(0, b - CRITICAL_FIELD) : 0;
            }
        }
        simStatus = peak < CRITICAL_FIELD
            ? fill(STATUS_FLAT, { b: peak.toFixed(2), c: CRITICAL_FIELD })
            : fill(STATUS_SPIKE, { b: peak.toFixed(2), c: CRITICAL_FIELD, r: Math.round(spikeRadius()) });
    }
    // 高さ = ドーム + マスのばねの高さ × 三つの波
    function compose() {
        const n = grid.gw * grid.gh;
        for (let k = 0; k < n; k += 1) {
            grid.z[k] = dome[k] + grid.amp[k] * hex[k];
        }
    }

03高さの傾きからのせる黒い光沢

ダークモードの製品ランディングの光沢のある背景として、二つの磁石が立てたトゲの板の上を、ピンクと水色の光が 4秒で一周しながらトゲの先ごとにハイライトを映します。同じ場面を二つに分け、左は画面の画素ごとにとなりの高さを四回測り直し、右は 3px 格子の 8,181マスをマスごとに一回測って引きのばします。1フレームの予算が 9,000画素なので、左は倍率 1 で 8フレーム、倍率 2 で 32フレームに一枚しか変わらず、右は毎フレーム変わります。

高さの傾きからのせる黒い光沢
新しいタブで開く

03 · 高さの傾きからのせる黒い光沢 — 計測 累積面積 14.402% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SHINECELL_PXFRAME_BUDGET_PX
    function pixelPass(budget) {
        const hv = halfVectors(passLights);
        const step = 1 / passScale;
        const total = passW * passH;
        let done = 0;
        while (done < budget && passAt < total) {
            const px = passAt % passW;
            const py = Math.floor(passAt / passW);
            const x = (px + 0.5) * step;
            const y = (py + 0.5) * step;
            const gx = (heightAt(passPoles, x + step, y) - heightAt(passPoles, x - step, y)) / (2 * step);
            const gy = (heightAt(passPoles, x, y + step) - heightAt(passPoles, x, y - step)) / (2 * step);
            shadeOne(gx, gy, wetAt(x, y), DISH, hv, SHINE, work, passAt * 4);
            passAt += 1;
            done += 1;
        }
        passFrames += 1;
        if (passAt >= total) {
            shown.set(work);
            lastPassFrames = passFrames;
            startPass();
        }
        return done;
    }

04磁力線の向きに傾くトゲ

ポートフォリオのファーストビューの動く背景として、横から見たトゲ 76本(三列の 31・25・20本)の下を棒磁石が 4.4秒で一往復すると、トゲはその場の場の軸にそって寝ます。傾きの目標は軸の角に 0.9 を掛けて ±60.2° で切り、高さの目標は縦成分がしきい値 0.35 を超えたぶんなので、場が横になる二つの極の中央の上(縦 0・横 2.33)にはトゲが立ちません。角度のばね(60・9)が遅れて追うため、一周で最も大きく寝たトゲは上限を行き過ぎた 70.7° です。

磁力線の向きに傾くトゲ
新しいタブで開く

04 · 磁力線の向きに傾くトゲ — 計測 累積面積 16.903% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LEAN_GAINLEAN_MAXLEAN_STIFFNESS
    // 場の軸の角 — 縦の上(0)から時計回りが正。トゲは矢印ではなく線の軸にそうので、±π/2 の外なら半回転して折りたたむ
    function fieldAxisAngle() {
        let a = Math.atan2(field[0], -field[1]);
        if (a > Math.PI / 2) {
            a -= Math.PI;
        }
        if (a < -Math.PI / 2) {
            a += Math.PI;
        }
        return a;
    }
    function aim() {
        for (const s of spikes) {
            const r = ROWS[s.row];
            fieldAt(poles, s.x, r.y, Math.hypot(LEAN_SOFTEN_PX, r.depth));
            s.lean.to = clamp(fieldAxisAngle() * LEAN_GAIN, -LEAN_MAX, LEAN_MAX);
            // 高さは表面をまっすぐ貫く縦成分 |by| で決める — 二つの極のあいだのように場が横になる所ではトゲが立たない
            const normal = Math.abs(field[1]);
            s.h.to = Math.min(SPIKE_MAX_PX, SPIKE_GAIN_PX * Math.max(0, normal - CRITICAL_FIELD)) * r.scale;
        }
    }

05磁石に引かれながら丸く踏ん張る水たまり

体験型展示の紹介ページの引き寄せる演出として、半径 46px の水たまりのふちの点 40個を、磁石が横に寝た 8の字を 3秒で描きながら引いていきます。点ごとに強さの二乗の傾きで引く力・となりの二点のまん中へ戻る力・最初の広さを守る圧力・粘りの減衰を足すので、広さは 0.93〜1.04倍の中にとどまります。磁石が先へ進むと水たまりはしずくの形に伸びてついていき、磁石のうしろに残る尾は 0.75秒の 62.8px がいちばん長くなります。

磁石に引かれながら丸く踏ん張る水たまり
新しいタブで開く

05 · 磁石に引かれながら丸く踏ん張る水たまり — 計測 累積面積 30.456% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SURFACE_TENSIONAREA_PRESSUREPUDDLE_DT_MAX
    function ringAccel(r, poles, p) {
        const n = r.n;
        const area = ringArea(r);
        const squeeze = p.pressure * (r.area0 - area) / Math.max(1, Math.abs(r.area0));
        for (let i = 0; i < n; i += 1) {
            const a = (i + n - 1) % n;
            const b = (i + 1) % n;
            const mx = (r.x[a] + r.x[b]) / 2;
            const my = (r.y[a] + r.y[b]) / 2;
            // 外向き — 前後の点を結ぶ向き (tx, ty) を (ty, −tx) に回した側。面積が正になる順に並んだ輪では、こちらが外
            const tx = r.x[b] - r.x[a];
            const ty = r.y[b] - r.y[a];
            const tl = Math.hypot(tx, ty) || 1;
            const g = strength2Grad(poles, r.x[i], r.y[i], p.soften);
            ringAx[i] = p.drag * g[0] + p.tension * TENSION_RATE * (mx - r.x[i]) + squeeze * (ty / tl) - p.viscosity * r.vx[i];
            ringAy[i] = p.drag * g[1] + p.tension * TENSION_RATE * (my - r.y[i]) + squeeze * (-tx / tl) - p.viscosity * r.vy[i];
        }
    }

06磁場の向きに並ぶしずくの鎖

物理の授業資料の磁場のデモとして、しずく 140個が磁場の入っている 2.6秒のあいだ鎖につながり、切れている 1.4秒のあいだ散らばります。二つのしずくを結ぶ線と磁場のなす角 θ から (3cos²θ − 1)/ρ⁴ に比例する力を与えるので、場の向きに並んだ組は引き合い、横に並んだ組は押し合い、sin 2θ の成分は結ぶ線を場のほうへ回します。入って 0.2秒で鎖が 52本でき、2.4秒につながった 99組の線は場の向きと平均 14.9° ずれています。

磁場の向きに並ぶしずくの鎖
新しいタブで開く

06 · 磁場の向きに並ぶしずくの鎖 — 計測 累積面積 63.08% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CHAIN_GAINCUTOFF_PXLINK_RATIO
                                const ux = dx / r;
                                const uy = dy / r;
                                let px = 0;
                                let py = 0;
                                if (fieldOn > 0) {
                                    const cos = ux * fx + uy * fy;
                                    const sin = fx * uy - fy * ux;
                                    const rho = Math.max(r, dia * 0.9) / dia;
                                    const k = CHAIN_GAIN * fieldOn / (rho * rho * rho * rho);
                                    const radial = -k * (3 * cos * cos - 1);
                                    const turn = -k * 2 * sin * cos;
                                    px += radial * ux + turn * -uy;
                                    py += radial * uy + turn * ux;
                                }
                                if (fieldOn < 1) {
                                    const s = SCATTER_GAIN * (1 - fieldOn) * (1 - r / CUTOFF_PX);
                                    px += s * ux;
                                    py += s * uy;
                                }
                                a.fx += px;
                                a.fy += py;
                                b.fx -= px;
                                b.fy -= py;

07拍に合わせて立っては沈むトゲ

音楽プレーヤーの再生中の表示として、シード 7 で選んだ 10拍(間隔 0.32・0.48・0.64秒のどれか)ごとに、速く上がってゆっくり下がる曲線を作り、磁場の強さの倍率を 1.00〜3.37倍に揺らします。強さ 1 の拍は 0.066秒で頂点に届いて 0.312秒で半分になり、拍がないときも中央の強さ 0.9 がしきい値の 2倍なのでトゲは少し立っています。マスのばね(140・9)が強い拍で目標を最大 15.7px 行き過ぎて、2.55秒に最も高い 56.4px のトゲを立て、音声ファイルもマイクも使いません。

拍に合わせて立っては沈むトゲ
新しいタブで開く

07 · 拍に合わせて立っては沈むトゲ — 計測 累積面積 5.995% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ATTACK_SDECAY_SENVELOPE_GAIN
    function makeBeats() {
        const rng = makeRng(BEAT_SEED);
        beats = [];
        for (let t = FIRST_BEAT_S; t < LOOP_S - 0.3; t += BEAT_GAPS[Math.floor(rng() * BEAT_GAPS.length)]) {
            beats.push({ t: t, s: 0.45 + rng() * 0.55 });
        }
    }
    function envelopeAt(t) {
        let e = 0;
        for (const b of beats) {
            const tau = t - b.t;
            if (tau < 0) {
                continue;
            }
            e += b.s * (1 - Math.exp(-tau / ATTACK_S)) * Math.exp(-tau / DECAY_S);
        }
        return e;
    }

08数字の形の磁石の並びで時刻を立てる磁性流体の時計

ラウンジの待合画面の時計ウィジェットとして、12px 間隔の 40×25 の磁石格子から、5×7 の点の文字四つとコロンの位置の磁石だけを入れ、時刻が変わるときだけ場を足し直します。仮想の時計はシードで 00:41 から始まって 1.1秒ごとに 7分ずつ進み、液体はより強いとなりのマスへ引かれながら、変わった直後に 0.25秒の時間定数でおさまるにじみに乗って新しい数字の場所へ移ります。マスから引いた量をそのままとなりに足すので全体の量 64 は一周の最大誤差 6.6×10⁻⁶ で保たれ、変わって 1秒たつと 98〜99% が新しい数字の場所に集まります。

数字の形の磁石の並びで時刻を立てる磁性流体の時計
新しいタブで開く

08 · 数字の形の磁石の並びで時刻を立てる磁性流体の時計 — 計測 累積面積 14.197% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

POLE_QPULL_RATESTEP_MIN
                for (let q = 0; q < 4; q += 1) {
                    const j = nb[q];
                    if (j < 0) {
                        continue;
                    }
                    const bi = coarseField[i];
                    const bj = coarseField[j];
                    let f = liquid[i] * PULL_RATE * h * Math.max(0, bj - bi) / (bj + bi + PULL_BIAS);
                    // にじみは組ごとに一度だけ(i < j 側で)両側の差の半分を移す
                    if (j > i) {
                        f += spread * h * (liquid[i] - liquid[j]) / 2;
                    }
                    outs[q] = f;
                    outSum += Math.max(0, f);
                }
                const cap = outSum > liquid[i] * 0.5 ? liquid[i] * 0.5 / outSum : 1;
                for (let q = 0; q < 4; q += 1) {
                    const j = nb[q];
                    if (j < 0) {
                        continue;
                    }
                    const f = outs[q] > 0 ? outs[q] * cap : outs[q];
                    flowTmp[i] -= f;
                    flowTmp[j] += f;
                }

09ガラス皿の上の磁性流体インテリアの場面

インテリア雑貨の通販サイトの商品ファーストビューとして、半径 128px のガラス皿の下を棒磁石が 6秒で一周すると、05 の点の輪の水たまりが引かれ、その上に 02 のトゲ・04 の傾き・03 の光沢・01 の磁力線が重なります。傾きは波の値が高い所ほど 6px × 傾きのぶんずらして読むので、トゲの胴が片側へ曲がって見えます。トゲが立つマスは 36〜60% のあいだを行き来し、磁石と水たまりの重心の距離は 1秒の 2.7px から 5.5秒の 19.1px へ開きます。

ガラス皿の上の磁性流体インテリアの場面
新しいタブで開く

09 · ガラス皿の上の磁性流体インテリアの場面 — 計測 累積面積 22.147% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAG_ORBIT_RLEAN_SHIFT_PXDOME_PX
    // 高さ = 水たまりのドーム + トゲの高さ × 傾けてずらして読んだ波の値
    function compose() {
        for (let j = 0; j < grid.gh; j += 1) {
            for (let i = 0; i < grid.gw; i += 1) {
                const k = j * grid.gw + i;
                let p = 0;
                if (grid.amp[k] > 0.05) {
                    const x = grid.ox + i * CELL_PX - DISH_X;
                    const y = grid.oy + j * CELL_PX - DISH_Y;
                    const p1 = hexBase[k];
                    p = hexWave(x - leanX[k] * LEAN_SHIFT_PX * p1, y - leanY[k] * LEAN_SHIFT_PX * p1, SPIKE_SPACING_PX);
                }
                grid.z[k] = DOME_PX * wetRaw[k] + grid.amp[k] * p;
            }
        }
    }

磁性流体とは何かを画面で見せるなら、トゲはどちらへ傾くのでしょうか?

トゲは磁石のほうではなく、その場所を通る磁力線の軸の向きへ傾きます。04 の fieldAxisAngle は場のベクトルを矢印ではなく線として扱い、縦から ±90° を越えたら半回転して折り返します。そのため N 極の上と S 極の上では場の符号が逆でも同じ側へ立ち、二つの極のあいだのように場がほぼ横になる所では、傾きのかわりに高さが 0 になります。

傾きが裏返る一瞬

磁石が通りすぎて軸が水平を越える瞬間には、目標が +60° から −60° へそのまま裏返ります。一周ぶんを計算で測ると、目標と実際の傾きのいちばん大きな差は 129.7° で、角度のばねはその差を −π〜π に巻いて短いほうへ回ります。トゲの林が波のように順に寝て起きるのは、この遅れたばねのおかげのようです。

こんな画面に貼る

磁性流体 インテリアの雑貨を扱う通販サイトの商品ページには 09 をそのまま掲げ、商品写真のかわりに皿の上の液体がゆっくり引かれていく様子で雰囲気をつくれます。スピーカー製品の紹介のように音と組みたいページなら 02 のしきい値の場面か 07 の拍の場面が合い、07 は音声ファイルなしでシードの拍で跳ねるので、再生ボタンのとなりの小さな表示にも使えます。

理科の学習資料なら、用途を言葉で並べるより 01 の線と 06 の鎖を並べて出すほうが早く伝わります。ラウンジや店頭の待合画面には 08 の時計が合いますが、いまは仮想の時刻で動くため、本物の時刻を出すには時刻を進める関数に外の時刻を渡す一段を足す必要があります。

磁性流体の場面が壊れるのは、磁石の真上で跳ね上がりつづける場・画素ごとに測り直す傾き・隠れたタブから戻った長い一コマの三か所です

距離の下限を消すと極のそばが 14,401 に跳ね上がる

01 の下の断面グラフは、二つの極を通る直線の ±72px を 1px ごとに測り、距離の下限 18 の版と下限 0 の版を重ねて描きます。下限 18 の版の最大は 10.96 なのに、下限 0 の版は 14,401 で 1,314倍あり、グラフの上へ突き抜けます。標本が画素の中心なので極にいちばん近い点が 0.5px になり、距離の二乗が 0.25 まで下がって 60² ÷ 0.25 = 14,400 が出ます。この値を 02 の式に入れると、そのマスの目標だけが 18 × 14,400 ほどになって画面の上へ立ちます。

    // 断面二本 — 距離の下限ありの強さと、下限 0 の強さ
    function measureSection() {
        sectionSoft = [];
        sectionHard = [];
        const ux = Math.cos(magA);
        const uy = Math.sin(magA);
        for (let k = -SECTION_HALF_PX; k < SECTION_HALF_PX; k += 1) {
            const u = k + 0.5;
            const x = magX + ux * u;
            const y = magY + uy * u;
            sectionSoft.push(fieldAt(poles, x, y, FIELD_SOFTEN_PX));
            sectionHard.push(fieldAt(poles, x, y, 0));
        }
    }

画素ごとに傾きを測り直すと 32フレームに一枚

03 の左の板は画素ひとつごとにとなりの高さを四回測り直すので、倍率 1 では 72,000画素・高さ測定 288,000回、倍率 2 では 288,000画素・1,152,000回が一枚です。フレーム予算 9,000画素で割ると、8フレームと 32フレームに一枚だけ変わり、そのあいだ画面の左上には「Nフレーム目まで同じ絵」と出ます。右の 3px 格子は倍率と関係なく 8,181マスなので毎フレーム塗り直せます。画面の倍率が高い端末ほど画素方式は遅くなるため、高さのようになめらかに変わる値は、格子で測って引きのばすほうが合います。

隠れたタブから戻った 0.8秒の一コマ

05 は 1.15秒に 0.8秒ぶんを一コマに押し込み、タブが隠れて戻った状況をまねます。受け取った時間をそのまま一歩に使った左の水たまりは、一つの点が 3,116px 跳び、面積が −566倍に裏返ってねじれた多角形になります。0.05秒で切って 1/240秒の小さな歩に分けた右は、いちばん大きく動いた点が 12.7px・面積 0.96倍にとどまります。張力だけで見ると、そのままの一歩が耐えられる限界は 0.101秒で、0.8秒はその 8倍近くです。

    // 一歩 — 受け取った dt をそのまま一度に(速度が先、位置があと)。切りも分けもしない
    function stepRingRaw(r, poles, p, dt) {
        ringAccel(r, poles, p);
        for (let i = 0; i < r.n; i += 1) {
            r.vx[i] += ringAx[i] * dt;
            r.vy[i] += ringAy[i] * dt;
            r.x[i] += r.vx[i] * dt;
            r.y[i] += r.vy[i] * dt;
        }
    }
    // 分けて歩く — dt を PUDDLE_DT_MAX で切り、それを PUDDLE_SUBSTEP 以下の小さな歩に割って上の歩を繰り返す
    const PUDDLE_DT_MAX = 0.05;
    const PUDDLE_SUBSTEP = 1 / 240;
    function stepRingSafe(r, poles, p, dt) {
        let left = Math.min(dt, PUDDLE_DT_MAX);
        while (left > 1e-9) {
            const h = Math.min(PUDDLE_SUBSTEP, left);
            stepRingRaw(r, poles, p, h);
            left -= h;
        }
    }

9場面は visibilitychange でタブが隠れると止まり、戻ると時計を合わせ直して、隠れていた時間を最初のコマの長さとして渡しません。ダウンロードした zip のパスワードは zm79qtgy で、05 のフォルダの index.html だけをブラウザで開いても、1.15秒に左の水たまりがねじれてはじける瞬間を一時停止ボタンでつかまえて見られます。はじけた形はその場に止めておくので、右の水たまりと並べて比べやすくなります。

しきい値を下げるとトゲはどれだけ早く立つのでしょうか — 臨界の強さ・表面張力・場をやわらげる距離を変えて比べます

名前 変えた値 感じ
トゲを早めに立たせる 02 CRITICAL_FIELD 0.45 → 0.3 始まり 0.63秒(深さ 88.4px)→ 0.48秒(109.3px)、トゲの立つ半径 79px → 101.2px、中央の目標 28.6px → 31.3px
水たまりのふちを締める 05 SURFACE_TENSION 0.22 → 0.5 広さ 0.93〜1.04 → 0.82〜1.03倍、丸さ 0.66 → 0.68、尾 62.8px → 65.4px、そのままの一歩が耐える限界 0.101秒 → 0.067秒
極の真上を鋭くする 01 FIELD_SOFTEN_PX 18 → 6 極の上の強さ 11.1 → 100(9倍)、極のそばの流れ 205 → 726px/s、線の数 17〜18 → 18

値を変える前に見たい数字

しきい値を 0.3 に下げると、同じ磁石の道すじで 0.15秒早く立ちはじめ、中央の目標は SPIKE_GAIN_PX × 0.15 = 2.7px 高くなります。表面張力を 0.5 に上げても尾はむしろ 2.6px 長くなりました。この力はとなりの点のまん中へ引いてふちをなめらかにするだけで、引かれて伸びる長さを直接は抑えないようです。かわりに硬さが 2 × 0.5 × 900 = 900 に大きくなり、そのままの一歩が耐える限界は 2 ÷ √900 = 0.067秒に縮むので、この値を上げるときは小さな歩の 1/240秒をそのままにしておくほうが安全です。

距離の下限を 6px に減らすと線の数は同じでも、極の上の強さが 9倍になり、極のそばの点線が 3.5倍速く流れます。02 のように高さを強さに比例させる場面へその値を移すと極の真上のマスだけが跳ね上がることがあり、前の節の一つ目の落とし穴がまさにその場合です。

トゲが見えなくても強さが伝わるようにします — 磁場の強さを知らせる文・一時停止ボタン・動きを減らす設定のアクセシビリティです

状態の行が読み上げる数字

キャンバスにはそれぞれ role="img" と場面を一文で説明する aria-label、tabindex="0" をつけました。キャンバスにフォーカスがあると矢印キーが調整点を 8px(Shift を押すと 24px)動かし、Enter・Space は中央へ戻し、08 だけは矢印・Enter・クリックが時刻を一回進めます。状態の行は aria-live="off" で始まり、最初の入力のあと polite に変わって、02 なら「強さ 2.04・しきい値 0.45 超え — トゲの立つ半径 79px」のように、文字が変わるときだけ一文を読み上げます。

動きを減らす設定と一時停止

prefers-reduced-motion: reduce ではループを回さず、決めた時刻の姿にばねを目標へすぐ座らせた一枚だけを描くので、時間が経っても画面は同じです。01 の点線の流れは 0 で止まり、05・06・08 はその時刻までを一度に計算した止まった姿を見せます。この状態で矢印を押すと、変わった位置で一枚を描き直し、状態の行も更新されます。一時停止ボタンは九場面すべてにあり、押すと aria-pressed="true" に変わります。読み上げ文の動作は MDN aria-liveに、設定のクエリは MDN prefers-reduced-motionにまとまっています。

磁性流体 シミュレーションのよくある質問 — 本物の液体との違い・スマートフォンで重いとき・黒い液体のコントラスト

磁性流体 作り方の動画で見る本物の液体と、画面の場面は何がちがいますか?

02・07・09 のトゲは、液体がどう流れるかを解いていません。強さの場から高さの目標を決め、マスごとにばねで追わせてから、決めた間隔の三つの波の模様を掛けてトゲの形にします。そのためトゲの間隔は強さで変わらず、02 では SPIKE_SPACING_PX の 28px に固定で、強さの数字も実際の単位ではなく画面の相対値です。

スマートフォンで重いときはどこを減らせばよいですか?

まず格子のマスの大きさです。02 は CELL_PX 2 で 36,391マスを毎フレーム測っていて、マスを大きくするとマスの数はその二乗に反比例して減ります。キャンバスの画素倍率は fitCanvas が 2 で止めてあり、06 は 44px のマスの空間格子で組の検査を最大 1,127回(すべての組なら 9,730回)まで減らしました。場の足し算を表にしておく方法は、07(強さ 1 の表に倍率だけを掛ける)と 08(時刻が変わるときだけ足す)が使っています。

黒い液体の上でハイライトや文字は見えますか?

液体の色 FLUID はほぼ黒の [13,12,17] にして、パステル変換の対象から外しました。この色が暗いからこそ、ピンクと水色のハイライトがトゲの先で映って見えるためです。状態の行とボタンは $subject-ink の地に $subject-cream の文字で、キャンバスの外に置きました。キャンバスへ絵を書く方法は MDN putImageDataに、タブが隠れたときのイベントは MDN visibilitychangeにあります。

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

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