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

シャボン玉 アニメーション 9種 — 膜の虹色と割れる瞬間

シャボン玉の虹色は塗った色ではなく、膜の厚さが生む光のずれです — 01番は厚さから色を計算し、04番は突いた所から縁がめくれて割れ、06番はくっついた二つの玉の間に曲がった膜ができます。

九つのシャボン玉を吹いて、突いて、くっつけてみる

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

九つの場面は、膜が一枚できてから消えるまでを順に追って並べてあります。01・02は膜の厚さとその厚さが生む色、03は吹き終えた玉が空気の中を漂う道筋、04・05は突いたりはじいたりしたときの反応です。06・07は玉どうしがくっつく形と輪から離れていく形、08は厚さの代わりに見る角度が決める反射で、09はそれまでの計算を十四個の玉にまとめて載せた野原です。

シャボン玉 アニメーション 9種 — 膜の厚さの色・流れ落ち・風・割れ方・二つの玉で作る場面

キャンバス一枚と、玉の大きさに合わせた計算板

どの場面も<canvas>が一枚で、内部座標は480×300に固定されています。01・02・04・06・08・09は別に用意した板に画素の値を直接書き込み、それをdrawImageで舞台に載せていて、板の大きさは01・02が250×250、04が238×238、06が340×172、08が242×254、09は舞台全体の480×300です。putImageDataはキャンバスにかけた変換を無視するので板を一度経由させています。板は場面の座標の大きさのまま計算し、drawImageが端末の画素倍率(2で打ち切り)の分だけ引き伸ばして描くので、倍率の高い画面でも計算する画素の数は増えません。03・05・07は画素を埋めず、線とグラデーションだけで描きます。

1nm刻みの表がsinの呼び出しを肩代わりします

reflectRGBは起動時に0〜1600nmを1nm間隔で一度ずつ呼ばれ、4,803マスのfilmLutを埋めます。塗るときはfilmAtがその表を1nmのあいだで線形補間して読むので、01は毎フレーム62,500画素を塗り直してもMath.sinを呼びません。画面の画素を読み出すgetImageDataは九つの場面のどこにもなく、計算した値を書き込むだけです。

16msの固定ステップで刻む時間

時間はrequestAnimationFrameが渡す時刻の差をためておき、16msの固定ステップ(FIXED_DT)に切り分けて使います。一フレームにためられる時間は100ms、ステップは最大6回で、画素の計算はステップ数に関係なく一フレームに一回です。スタイルシートに@keyframesはなく、CSSで動くのはボタンの背景色のtransitionひとつだけで、円を@keyframesで上へ流すだけのcss アニメーション シャボン玉とはここで道が分かれます。

撮影窓で数えた玉の動き

測定はシャボン玉の場面ごとに480×300の窓で2秒間に24枚を写し、前後の二枚を見比べる方式です。動いたフレームは、その23回の比較のうち画面に差が出た回数を数えています。

場面 画面が動いた累積面積 強度平均 動いたフレーム
01 厚さから計算した虹色 27.3% 32.6 23/23
02 流れ落ちと黒い膜 29.1% 48.9 23/23
03 風に乗る玉の群れ 42.6% 88.4 23/23
04 突いた所からの割れ方 27.0% 88.6 23/23
05 ゆれのばね 13.5% 125.0 23/23
06 双子の玉と間の膜 23.9% 64.4 14/23
07 輪から吹く玉 20.7% 96.6 23/23
08 空と窓の反射 11.6% 41.7 23/23
09 野原の十四個の玉 33.7% 32.7 23/23

05と08の面積が13.5%・11.6%にとどまるのは、変わる場所が狭いからです。05で揺れるのは輪郭の線一本だけで、08は空が11.4秒で一周するため2秒では約63°しか回らず、反射の濃い場所も縁の輪に集まっています。06の14/23は、二つの玉がくっつききったあと1.1秒(HOLD_MS)止まっている区間が2秒の撮影の後ろ側にかかるためで、03の42.6%は玉26個と32px間隔の流線が画面全体に広がっているためと見られます。

このシャボン玉を置く画面

夏のイベントLPや子ども向けサービスのトップのように、澄んだ軽い印象を最初に出したい場所なら、09をヒーローの背景に敷いて見出しを重ねる構成が出発点になります。ボタンやいいねのアイコンに泡 が 弾ける アニメーションを付けたいなら、画面全体を使う場面より04の割れ方や05の揺れを小さく切り出すほうが計算も軽く済みます。アップロードの進行バーの位置に07の輪を置けば処理が終わる瞬間が玉の離れていく形で見え、02は残り時間をてっぺんの色で知らせる待ち画面に合います。

シャボン玉の虹色はなぜ膜が薄くなるほど変わるのか?

シャボン玉の膜に当たった光は、一部が外側の面ですぐにはね返り、一部は膜の中へ入って内側の面ではね返ってから戻ってきます。戻ってきた光は膜を一往復した分、つまり屈折率1.33×厚さ×2だけ遅れて出てくるため、その遅れが波長の何倍にあたるかで二つの光は強め合ったり打ち消し合ったりします。この記事では赤・緑・青の波長を650・530・450nmに置いているので、同じ遅れでもチャンネルごとに残る割合が違い、その差が色として見えます。何も塗らなくても膜の厚さが変わるだけで色が移るのはこのためです。

表の面だけ位相が裏返ると見れば、薄い膜は黒くなります

reflectRGBは外側の面ではね返った光だけ位相が半周ずれると見なし、チャンネルごとにsin²(2π·1.33·d/λ)をそのチャンネルの割合にします。厚さdが0に近づくと二つの光がほぼ逆向きに重なって三つの値がそろって0へ落ち(30nmで0.14・0.21・0.28)、流れ落ちてごく薄くなったてっぺんは色ではなく暗く映ります。02が30nm(BLACK_FILM_NM)をもうすぐ割れる膜の目安にしているのもこのためです。

斜めから見ると、厚さが同じでも色がずれます

玉の縁のほうでは視線が膜を斜めに通るので、膜の中で曲がった光のずれが、曲がった角のコサイン倍に縮みます。insideCosがこの倍率を√(1 − (1 − cos²)/1.33²)として出し、01の380nmの膜は正面の赤紫から縁へ向かうほど橙を経て緑、さらに青へと移ります。厚さを均一にした玉でも縁に中央と違う色の輪ができるのは、この一行の働きです。

0〜1で出る値は反射率ではなく比率です

三つの値は0〜1にそろえた比率で、光が何%戻ってくるかを表すものではありません。実際の膜が返す光は数%程度ですが、08を除く八つの場面はこの比率に235(09は200)を掛けた明るさを背景の色の上に足すので、画面の玉は実際の膜よりずっと明るく濃く見えます。チャンネル一つを波長一つで代表させたのも近似で、波長の全範囲を足し合わせてできる実際の色より帯の境目がくっきり分かれます。

01膜の厚さひとつから虹色を計算して塗るシャボン玉

玉の中の250×250の板にある62,500画素それぞれで、thicknessAtがてっぺん0.68倍・底1.32倍の傾きと時間とともに流れるノイズのむらから厚さを決め、reflectRGBがその厚さで赤650・緑530・青450nmの三つの波長が残る割合を求めて色にします。厚さが同じ380nmでも法線の正面成分zが1→0.5→0.25と傾けばinsideCosがずれを縮め、0.97・0.08・0.49の赤紫が0.29・0.97・0.64の緑を経て0.04・0.68・0.98の青に変わります。ブランドのヒーロー背景や製品パッケージのモックアップのように模様より質感を先に見せたい場面に合い、指す点をドラッグすると左の欄にその場所の厚さと三つの値が数字で出ます。

膜の厚さひとつから虹色を計算して塗るシャボン玉
新しいタブで開く

01 · 膜の厚さひとつから虹色を計算して塗るシャボン玉 — 計測 累積面積 27.32% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FILM_NFILM_D_NMWAVE_RGB_NM
    // 膜の表と裏ではね返った二つの光が厚さの分だけずれ、波長ごとに残る割合 — sin²(2π·n·d/λ)。
    // 表の面ではね返った光だけ位相が裏返るので、厚さ0なら三つのチャンネルがすべて0(黒い膜)。値は0〜1にそろえた比率で、実際の反射率(何%)ではない
    function reflectRGB(dNm) {
        const out = [0, 0, 0];
        for (let c = 0; c < 3; c += 1) {
            const s = Math.sin(2 * Math.PI * FILM_N * dNm / WAVE_RGB_NM[c]);
            out[c] = s * s;
        }
        return out;
    }
    for (let d = 0; d <= LUT_D_MAX; d += 1) {
        const r = reflectRGB(d);
        filmLut[d * 3] = r[0];
        filmLut[d * 3 + 1] = r[1];
        filmLut[d * 3 + 2] = r[2];
    }
    // 斜めから見ると光が膜の中で曲がって通るので、ずれが cos(曲がった角) 倍に縮む — cosView は視線と法線のあいだの角の cos
    function insideCos(cosView) {
        const s2 = (1 - cosView * cosView) / (FILM_N * FILM_N);
        return Math.sqrt(1 - s2);
    }

02膜が下へ流れ落ち、てっぺんは黒い膜へ薄くなり、底の帯は上へせり上がる玉

てっぺんから底までを72本の帯に分けた膜で、drainStepが境目ごとにDRAIN_K × sin(境目の角) × 上の帯の厚さに時間を掛けた量を下の帯へ渡し、厚さ×面積の合計は変えないので、上が薄くなった分だけ下が厚くなります。最初から流すとてっぺんは364nmから1秒で209、2秒で120、3秒で69nmと減って4.46秒で黒い膜の30nmに届き、そのあいだに500nmの帯はてっぺんから測った角で38.8°から51.3°へ下がり、900nmの帯は156°から141°へ上がってきます。ローディングの待ち画面や残り時間の表示のように終わりが近づくことを色で知らせる場所に合い、実演は3.9秒流しておいた玉から始まって0.56秒後にてっぺんが黒くなり、その0.7秒後に割れてから新しい玉を吹きます。

膜が下へ流れ落ち、てっぺんは黒い膜へ薄くなり、底の帯は上へせり上がる玉
新しいタブで開く

02 · 膜が下へ流れ落ち、てっぺんは黒い膜へ薄くなり、底の帯は上へせり上がる玉 — 計測 累積面積 29.149% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DRAIN_KBLACK_FILM_NMSWIRL_AMP
    // 帯 i と i+1 の境目から下へ渡す量 — 上の帯の厚さで決める。二つの帯の(厚さ×面積)の合計は変わらないので膜の量が保たれる
    function drainStep(dt) {
        const h = dt / 4;
        for (let s = 0; s < 4; s += 1) {
            for (let i = 0; i < BAND_N - 1; i += 1) {
                const e = Math.sin((i + 1) / BAND_N * Math.PI);
                const q = DRAIN_K * e * thick[i] * h;
                const moved = Math.min(q, thick[i] * bandArea[i] * 0.5);
                thick[i] -= moved / bandArea[i];
                thick[i + 1] += moved / bandArea[i + 1];
            }
            if (thick[BAND_N - 1] > D_DRIP) {
                thick[BAND_N - 1] = D_DRIP;
            }
        }
        age += dt;
    }

03浮力と空気抵抗、ノイズの風の中を漂う玉の群れ

26個の玉はステップごとにdriftStepで、下へ引く9px/s²(NET_SINK)と、風との速さの差に1.6/s(DRAG_K)を掛けた抵抗を受け、風はwindAtが2Dノイズを流れ関数として、その傾きを90度回した渦の場に右向きの下地の風16px/sを足した値です。沈む終端速度は9÷1.6=5.63px/sとごく遅く、速さの差が半分に縮むまでがln2÷1.6=0.433秒なので、玉は自分の重さより風に従い、90px以内の隣どうしは速度の向きのコサイン平均が0.95でほぼ同じ方を向きます。ランディングの背景やイベントページの飾りで玉を群れで流したいときに使え、画面を押すと半径120pxの中の玉が中心140px/sの突風で外へ押し出されます。

浮力と空気抵抗、ノイズの風の中を漂う玉の群れ
新しいタブで開く

03 · 浮力と空気抵抗、ノイズの風の中を漂う玉の群れ — 計測 累積面積 42.649% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

NET_SINKDRAG_KWIND_SCALE
    function windAt(x, y, tSec, scale) {
        const z = tSec * WIND_DRIFT;
        const gx = x * scale;
        const gy = y * scale;
        const e = 0.01;
        // 流れ関数 ψ の傾きを格子単位で — (∂ψ/∂y, −∂ψ/∂x) が渦の風
        const dy = (noise2(gx + z, gy + e) - noise2(gx + z, gy - e)) / (2 * e);
        const dx = (noise2(gx + z + e, gy) - noise2(gx + z - e, gy)) / (2 * e);
        return [WIND_BASE_X + dy * WIND_SPEED, -dx * WIND_SPEED];
    }
    // 重さから浮力を引いたごく弱い沈み(px/s²)— 下(+y)へ引く
    const NET_SINK = 9;
    // 空気抵抗(1/s)— 玉の速さと風の速さの差に比例して、その差を縮める
    const DRAG_K = 1.6;
    // 1ステップの積分 — 加速 = 沈み + 抵抗×(風 − 速さ)。先に速さを変え、その速さで位置を動かす
    function driftStep(b, dt, tSec, scale) {
        const w = windAt(b.x, b.y, tSec, scale);
        b.vx += DRAG_K * (w[0] - b.vx) * dt;
        b.vy += (NET_SINK + DRAG_K * (w[1] - b.vy)) * dt;
        b.x += b.vx * dt;
        b.y += b.vy * dt;
    }

04突いた所から穴の縁がめくれて広がり、しずくが飛ぶ割れ方

押した場所に穴を開けるとstepPopが周り120方向ごとに縁をrimSpeedで押し広げ、その速さがRIM_V_K × 560 × √(400 ÷ その場所の厚さ)なので、膜の薄いてっぺん(264nm)では速く厚い底(622nm)では遅く、玉の中で447〜716px/sに分かれます。縁が28個のしずくの出発距離を過ぎると、しずくはその場所の速さの0.35倍で外へ向かい、上へ60px/s跳ねてから900px/s²(SPRAY_G)で落ち、真ん中を突くと割れきるまで247msかかります。クリックのフィードバックやいいねの反応のように押した所から割れが広がってほしい場面に合い、実演が順に突く四か所は334〜395msで割れきります。

突いた所から穴の縁がめくれて広がり、しずくが飛ぶ割れ方
新しいタブで開く

04 · 突いた所から穴の縁がめくれて広がり、しずくが飛ぶ割れ方 — 計測 累積面積 27.03% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RIM_V_KDROPLET_NSPRAY_G
    // 膜の厚さ(nm)— 上が薄く下が厚く、ノイズでむらが出る。nx・ny は玉の中心を基準に −1〜1
    function thicknessAt(nx, ny) {
        return Math.max(420 * (1 + 0.45 * ny) * (1 + 0.18 * noise2(nx * 1.8 + filmT * 0.2, ny * 1.8 + 4.1)), 20);
    }
    function rimSpeed(x, y) {
        const d = thicknessAt((x - BCX) / BR, (y - BCY) / BR);
        return RIM_V_K * RIM_V0 * Math.sqrt(D_RIM_REF / d);
    }
    function stepPop(dt) {
        let open = 0;
        for (let k = 0; k < RIM_N; k += 1) {
            if (rimR[k] >= rimMax[k]) {
                open += 1;
                continue;
            }
            const a = k / RIM_N * Math.PI * 2;
            const v = rimSpeed(hx + Math.cos(a) * rimR[k], hy + Math.sin(a) * rimR[k]);
            rimR[k] = Math.min(rimR[k] + v * dt, rimMax[k]);
        }
        for (const d of drops) {
            if (d.live || d.done || rimR[d.k] < d.at) {
                continue;
            }
            const a = d.k / RIM_N * Math.PI * 2;
            const v = rimSpeed(hx + Math.cos(a) * d.at, hy + Math.sin(a) * d.at);
            d.x = hx + Math.cos(a) * d.at;
            d.y = hy + Math.sin(a) * d.at;
            d.vx = Math.cos(a) * v * 0.35;
            d.vy = Math.sin(a) * v * 0.35 - 60;
            d.live = true;
            spawned += 1;
        }

05輪郭が何層ものゆれのばねで揺れて落ち着く玉

輪郭の半径を2・3・4番目のゆれの和とし、springStepがゆれごとに−ω²·振幅 − 2γ·速さで加速を決めます。ωはomegaの式でnが大きいほど大きく8.50・16.46・25.50rad/sになり、減衰γはn²に比例して0.55・1.24・2.20と増えるので、一度はじいたあと振幅0.004を最後に超えた時刻は4番目が928ms、3番目が2,016ms、2番目が5,760msと高いゆれから収まって遅い楕円の揺れが残り、伸びた輪郭は厚さが(R0/r)²倍に薄くなって線の色が変わります。ボタンのホバー反応やマスコットのインタラクションに合い、全体の振幅が初めて0.004を下回るのは1,856msですが、楕円がまた超えたり戻ったりするので最後まで落ち着いたままになるのは5,776msからです。

輪郭が何層ものゆれのばねで揺れて落ち着く玉
新しいタブで開く

05 · 輪郭が何層ものゆれのばねで揺れて落ち着く玉 — 計測 累積面積 13.506% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MODE_MAXMODE_DAMPPOKE_IMPULSE
    function omega(n) { return OMEGA_2 * Math.sqrt((n - 1) * n * (n + 2) / 8); }
    function gamma(n) { return MODE_DAMP * n * n / 4; }
    // 減衰ばねの1ステップ — 加速 = −ω²·振幅 − 2γ·速さ。先に速さを変え、その速さで振幅を動かす
    function springStep(dt) {
        for (let n = MODE_MIN; n <= MODE_MAX; n += 1) {
            const w = omega(n);
            const g = gamma(n);
            velC[n] += (-w * w * ampC[n] - 2 * g * velC[n]) * dt;
            velS[n] += (-w * w * ampS[n] - 2 * g * velS[n]) * dt;
            ampC[n] = clamp(ampC[n] + velC[n] * dt, -AMP_CAP, AMP_CAP);
            ampS[n] = clamp(ampS[n] + velS[n] * dt, -AMP_CAP, AMP_CAP);
        }
    }

06二つの玉がくっつき、間に曲がった膜を一枚分け合う双子の玉

二つの玉が触れるとgeometryが二つの円の交点を求めて間の膜の曲がりをp × (1/rs − 1/rl)で決め、くっつききった半径52・84pxなら間の膜は半径rs·rl/(rl − rs)の136.5pxの円弧になり、その中心が小さい玉の側にあるため膜は大きい玉の側へふくらみます。中心距離は二つの半径が60度に開いた三角形の第三辺√(rs² + rl² − rs·rl)として先に置き、junctionAnglesが上の交点で三枚の膜の接線どうしの角を測ると、くっつききったあとは実演の四つの大きさすべてで120.00°ですが、くっつく途中(p=0.5)では82°・142°・136°とまだ傾いています。比較カードや二つの要素をつなぐ場面に合い、同じ大きさ(84・84px)にすると曲がりが0になって間の膜はまっすぐな線で立ちます。

二つの玉がくっつき、間に曲がった膜を一枚分け合う双子の玉
新しいタブで開く

06 · 二つの玉がくっつき、間に曲がった膜を一枚分け合う双子の玉 — 計測 累積面積 23.855% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

R_SMALLR_LARGEJOIN_MS
        const x1 = MIDX + (rs - rl - s) / 2;
        const g = { x1: x1, x2: x1 + s, s: s, touching: s < touch - 0.01, hj: 0, xc: 0, flat: true, rw: 0, xw: 0, p: p };
        if (g.touching) {
            // 二つの円の交点 — 小さい円の中心から共通の弦までの距離 a、弦の半分の長さ hj
            const a = (s * s + rs * rs - rl * rl) / (2 * s);
            g.hj = Math.sqrt(Math.max(rs * rs - a * a, 0));
            g.xc = x1 + a;
            // 間の膜の曲がり = p × (1/小さい半径 − 1/大きい半径)。くっつききると(p=1)二つの玉の圧力差に合う値
            const kappa = p * (1 / rs - 1 / rl);
            if (kappa > 1e-6) {
                g.flat = false;
                g.rw = 1 / kappa;
                // 間の膜の円の中心は小さい玉の側 — だから膜は大きい玉の側へふくらむ
                g.xw = g.xc - Math.sqrt(Math.max(g.rw * g.rw - g.hj * g.hj, 0));
            }
        }
        return g;
    }
    function junctionAngles(g) {
        const jx = g.xc;
        const jy = MIDY - g.hj;
        const t1 = tangentAway(g.x1, jx, jy, g.x2, rl);
        const t2 = tangentAway(g.x2, jx, jy, g.x1, rs);
        let t3 = [0, 1];
        if (!g.flat) {
            const len = Math.hypot(jx - g.xw, jy - MIDY);
            t3 = [-(jy - MIDY) / len, (jx - g.xw) / len];
            if (t3[1] < 0) {
                t3 = [-t3[0], -t3[1]];
            }
        }
        const ang = function (a, b) {
            return Math.acos(clamp(a[0] * b[0] + a[1] * b[1], -1, 1)) * 180 / Math.PI;
        };
        return [ang(t1, t2), ang(t2, t3), ang(t3, t1)];
    }

07輪に張った膜を風でふくらませ、首がくびれて離れていく玉

吹いているあいだは膜の中の面積を毎秒7,200px²(BLOW_RATE)ずつ増やし、solveCがdomeAreaを50回半分ずつ絞り込んでその面積になる円を逆算し、膜の長さが輪の周251.3pxを超える面積8,295px²からsimulateが首の半径を縮め始めます。平らな膜から吹き続けると1.156秒で首がくびれ始め、首が輪の半径40pxの0.3倍より細くなる1.560秒に玉が離れ、膜の量を保つ近似(D_FLAT × 2R ÷ 長さ)なので離れた玉の膜は192nmまで薄くなっています。アップロードや送信の進行表示のように満ちてから一気に離れる瞬間が要る場所に合い、実演は7,600px²までふくらんだ膜から始めて0.504秒で最初の玉を送り出します。

輪に張った膜を風でふくらませ、首がくびれて離れていく玉
新しいタブで開く

07 · 輪に張った膜を風でふくらませ、首がくびれて離れていく玉 — 計測 累積面積 20.721% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BLOW_RATERING_RPINCH_RATIO
    // 輪の両端を通り、中心が輪から c だけ右にある円 — 輪の右側に入る面積
    function domeArea(c) {
        const rho = Math.hypot(RING_R, c);
        return rho * rho * Math.acos(-c / rho) + c * RING_R;
    }
    // 面積から c を逆算 — 面積は c とともに増える一方なので、半分ずつ絞り込んで探す
    function solveC(a) {
        let lo = -4000;
        let hi = 800;
        for (let k = 0; k < 50; k += 1) {
            const mid = (lo + hi) / 2;
            if (domeArea(mid) < a) {
                lo = mid;
            }
            else {
                hi = mid;
            }
        }
        return (lo + hi) / 2;
    }
    function simulate(dt, blow) {
        tSec += dt;
        if (blow) {
            area += BLOW_RATE * dt;
        }
        const s = shape();
        if (stage === 0 && s.len >= CIRC) {
            stage = 1;
        }
        if (stage === 1) {
            neck -= NECK_RATE * RING_R * ((s.len - CIRC) / CIRC + NECK_BASE) * dt;
            if (neck / RING_R < PINCH_RATIO) {
                release(shape());
            }
        }

08空と窓を映し、縁に近いほど反射が濃くなる玉

paintBubbleが画素ごとに球面の法線から反射の向き2z·n − (0,0,1)を求めて空のグラデーションと窓の四角形から色を読み、奥の膜の内側の面ではx・yを反転した向きでもう一度読んで、逆さに立つ二つ目の窓を足します。混ぜる割合reflectanceはEDGE_F0 + (1 − EDGE_F0)(1 − cosθ)⁵なので、中央は4%、半径0.9で9.5%、0.97で27.9%と縁の細い輪でだけ空が濃くなり、後ろの舞台は(1 − F)²倍でほぼそのまま透けます。ガラス質のアイコンやプレミアムカードのつやのように中央は透明で縁だけ光ってほしい質感に合い、ドラッグすると空が横に回り、回転がオンのあいだは11.4秒で一周ずつ自動で回ります。

空と窓を映し、縁に近いほど反射が濃くなる玉
新しいタブで開く

08 · 空と窓を映し、縁に近いほど反射が濃くなる玉 — 計測 累積面積 11.57% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

EDGE_F0SKY_TILTWINDOW_GLINT
                const z = Math.sqrt(1 - r2);
                // 入ってきた視線 (0,0,−1) を法線で折り返した向き = 2z·n − (0,0,1)
                sampleEnv(2 * z * nx, 2 * z * ny, 2 * z * z - 1);
                const f0 = env[0];
                const f1 = env[1];
                const f2 = env[2];
                // 奥の膜の内側の面でもう一度 — 法線が内向きなので反射の向きの x・y が反転する(逆さに立つ二つ目の窓)
                sampleEnv(-2 * z * nx, -2 * z * ny, 2 * z * z - 1);
                const f = reflectance(z);
                const t = (1 - f) * (1 - f);
                const bo = (clamp(Math.floor(y), 0, H - 1) * W + clamp(Math.floor(x), 0, W - 1)) * 4;
                px[o] = bg[bo] * t + f0 * f + env[0] * f * (1 - f);
                px[o + 1] = bg[bo + 1] * t + f1 * f + env[1] * f * (1 - f);
                px[o + 2] = bg[bo + 2] * t + f2 * f + env[2] * f * (1 - f);
                px[o + 3] = 255 * clamp((1 - Math.sqrt(r2)) * BR, 0, 1);

09野原の上を風に乗って流れる十四個のシャボン玉が、それぞれ色と反射を計算する場面

空・雲・丘・草地・70個の花の点を最初に一度だけ480×300の画素に焼いておき、毎フレームその配列を写した上で14個の玉(BUBBLE_N)だけをpaintBubbleで計算し直し、膜の厚さは年をとるほど上から薄くなる式ひとつで02の流れ落ちを縮めて入れてあります。寿命は玉ごとに6.5秒の0.7〜1.3倍、つまり4.55〜8.45秒で、最初の場面では0.35・0.77・1.19秒のように0.42秒おきに順番をずらしてあり、寿命の来た玉は上の縁の一点から420px/sで穴が広がって50〜160msで割れ、左の外から新しい玉が入ってきます。サービスのヒーローや夏のイベントLPの最初の画面に合い、空いた所を押すと、画面の下中央から見たその方向へ太陽(SUN_DEGの初期値38°)が移ってハイライトの位置が変わります。

野原の上を風に乗って流れる十四個のシャボン玉が、それぞれ色と反射を計算する場面
新しいタブで開く

09 · 野原の上を風に乗って流れる十四個のシャボン玉が、それぞれ色と反射を計算する場面 — 計測 累積面積 33.719% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BUBBLE_NSUN_DEGLIFE_S
                const z = Math.sqrt(1 - r2);
                // 膜の厚さ — 下が厚く、年をとるほど上から薄くなる(流れ落ち)、ノイズでむら
                const d = Math.max(b.d0 * (1 + 0.3 * ny) * Math.exp(-1.1 * drain * (1 - ny) * 0.5) + 50 * noise2(nx * 2 + b.ph + tSec * 0.3, ny * 2), 0);
                const dEff = d * insideCos(z);
                // 縁の反射 — 反射の向きが上なら空、下なら草の色
                const m = 1 - z;
                const f = 0.04 + 0.96 * m * m * m * m * m;
                const rx = 2 * z * nx;
                const ry = 2 * z * ny;
                const rz = 2 * z * z - 1;
                const up = -ry;
                const envC = up > 0 ? mix(SKY_LOW, SKY_TOP, Math.min(up, 1)) : GRASS;
                const spec = Math.pow(Math.max(rx * lx + ry * ly + rz * lz, 0), 140);
                const amp = 0.3 + 0.6 * Math.pow(m, 0.6);
                const edge = clamp((1 - Math.sqrt(r2)) * rr, 0, 1) * fade;
                for (let c = 0; c < 3; c += 1) {
                    const v = px[o + c] * (1 - f * 0.8) + envC[c] * f + filmAt(dEff, c) * 200 * amp + spec * 255;
                    px[o + c] = px[o + c] + (v - px[o + c]) * edge;
                }

膜の表と裏で二度はね返った光から、どの色が残るのか

正面から見た厚さごとの三つの値をreflectRGBで直接呼んで測ると、次のようになります。膜が流れて薄くなると、玉のその場所の色は表を上から下へたどります。

厚さ 赤 650nm 緑 530nm 青 450nm 見える色 この記事で現れる場所
500nm 0.02 1.00 0.02 緑 01の玉の底のあたり
380nm 0.97 0.08 0.49 赤紫 01の基準の厚さ
260nm 0.04 0.67 0.99 青 04のてっぺん(264nm)
120nm 1.00 0.90 0.63 淡い黄みの白 01の厚さの変形、02の2秒ごろのてっぺん
30nm 0.14 0.21 0.28 暗い灰青 02の黒い膜の基準
0nm 0 0 0 黒 割れる直前

02の上の帯と底の帯が逆向きに動くわけ

境目から渡す量が上の帯の厚さと境目の周(sin θ)の両方に比例するため、てっぺんは出した分を上から受け取れずに薄くなり、その薄さが下へ広がって500nmになる位置が38.8°から51.3°へ下がります。底の帯は受け取る一方であふれの上限1500nm(D_DRIP)に縛られているので、厚い領域が上へ広がって900nmの位置が156°から141°へ上がります。あふれて捨てた量は膜全体の0.25%(30,713→30,638)なので、玉が落とすしずくより上下に移る水のほうがずっと多いことになります。

04の縁は薄い所で急ぎます

速さが√(400 ÷ 厚さ)に比例するので、厚さが4分の1になると縁は2倍速くなります。04の膜はてっぺん264nm・真ん中455nm・底622nmと上が薄いため、下のほうを突くと穴は上へ先に駆け出し、底側の縁は遅れます。真ん中で測った速さは525px/sで、玉の中全体では447〜716px/sのあいだです。

08は厚さではなく角度で反射が決まります

08にはreflectRGBがなく、反射の強さを視線と法線の角度ひとつで決めます。反射率が10%を超えるのは半径0.905より外、50%を超えるのは0.991より外なので、空の色は縁の細い輪にだけ濃く集まります。窓は白い光に7倍(WINDOW_GLINT)を掛けてあるので、反射率4%の中央でも0.04×255×7≈71だけ明るく乗って見えます。09の縁の反射0.04 + 0.96·m⁵はこのreflectanceと同じ式を一行に書き写したもので、09はinsideCosも自分のpaintBubbleの中で直接呼んでいます。

数十個の画素計算・薄すぎる膜・止まったタブ — シャボン玉が壊れる三つの場所

崩れやすい場所は大きく三つで、ほかに近似や入力が持ちこたえられない境目が三つあります。

玉が数十個になると、画素の仕事が面積の分だけ増えます

01は半径124pxの玉ひとつのために250×250、62,500画素を毎フレーム塗ります。09が14個の玉を半径11.5〜32pxと小さくしているのはこのためで、十四個を合わせても玉の画素は約25,000と、01が毎フレーム塗る板62,500画素の半分に届きません。同じ大きさの玉を数十個浮かべたいならBUBBLE_Nを増やす前に半径から縮め、04のように画素ごとにatan2・hypotを呼ぶ割れ方は、いくつも同時に回さないほうがフレームを守れます。

膜が薄すぎると縁の速さが跳ね上がります

04のrimSpeedは厚さで割る式なので、厚さが0に近づくと速さは限りなく大きくなります。そこでthicknessAtが厚さを20nmより下げないように押さえていますが、その下限でも速さは560×√20≈2,504px/sと、真ん中の525px/sの5倍近くになります。実際の割れは数msで終わるところを、ここではRIM_V0を560px/sに抑えて数百msかけて見えるようにしてあり、この値を10倍にすると真ん中を突いたときの割れが247msの10分の1の約25ms(計算値)となり、16msのステップ2回のうちに終わって、縁がめくれていく形を見る間がほとんどありません。

タブが止まって戻っても、追いつくのは100ms分だけです

一フレームにためる時間を100msで打ち切るので、タブを隠して戻ってきても、たまった物理がまとめて計算されることはありません。その代わり09の寿命の時計もその分しか進まず、隠していたあいだに割れているはずだった玉が、戻った画面にそのまま浮いています。玉を実際の時刻で片付けたい画面なら、この上限とは別に経過時間を数える必要があります。

流れを厚さの3乗にすると、てっぺんが黒くなりません

02の流れる量は上の帯の厚さに比例させた近似で、重力が斜面に沿って引く成分も省いてあります。実際の膜は厚さの3乗に近い形で流れますが、このソースで3乗に変えて回すと4.7秒たってもてっぺんが約260nmにとどまり、数秒で黒い膜を見せる実演になりませんでした。斜面成分を入れた場合も、傾きのほとんどないてっぺんがほとんど流れなくなって同じ問題が起きるので、02の4.46秒は過程が画面で見えるように選んだ速さであり、実際の玉が薄くなる時間ではありません。底のあふれの上限を外すと底の帯の面積が0に近いため厚さが30万nmまで跳ね上がり、一度に帯の量の半分までしか移さない制限は、面積の小さいてっぺんの帯がマイナスに落ちるのを防いでいます。

120度は玉が二つのときの式です

06は三枚の膜が120度で出会う中心距離を式で置いているだけで、膜の引く力を解いてその距離を探しているわけではありません。そのため三つ以上くっついた泡のかたまりにはそのまま移せず、小さいほうが大きいほうより大きくなると曲がりの式の符号が反転するので、入力を30〜84px、大きい玉の半径以下に縛っています。圧力差も別に計算しておらず、曲がりの式がその関係を代わりに担っています。

指の縦ドラッグはページが持っていきます

九つのキャンバスはどれもtouch-action: pan-yなので、タッチ画面の縦のスワイプはページのスクロールに回ります。08の空の傾きはタッチの縦ドラッグでは変わらないため、その軸は↑・↓キーが受け持ちます。

膜の厚さや縁の速さを手元で書き換えて試すファイル一式は、パスワード xdxs3ayg で開きます。場面ごとのindex.htmlとstyle.scssに加えて、同じ式をReactへ移した版も入っています。

膜の厚さ・縁の速さ・風の大きさを動かす変形と、漂う玉のアクセシビリティ

名前 値 変わり方
膜の厚さを変える 01 FILM_D_NM 380 → 120 正面の三つの値が0.97・0.08・0.49(赤紫)から1.00・0.90・0.63(淡い黄みの白)に変わり、てっぺんは0.68倍の約82nmまで薄くなって黒い膜に近づきます。
縁がめくれる速さを変える 04 RIM_V_K 1.0 → 2.5 真ん中の速さが525から1,313px/sに上がり、真ん中を突いた割れは247から100ms、実演の四か所は334〜395から135〜159msに縮みます。しずく28個はそのままです。
風の細かさを変える 03 WIND_SCALE 0.004 → 0.012 渦ひとつが250pxから約83pxに縮み、平均の速さは34.8から34.5px/sとほぼ同じですが、90px以内の隣どうしの向きのそろい方は0.95から0.07に落ちて群れがばらけます。

三つ目の変形は03の「風を細かく」ボタン(FINE_Xの3倍)がすでに持っているので、ソースを書き換える前にEnterやボタンで比べてみられます。

キーボードで吹いて、突く

キャンバスごとにtabindex="0"と、何をする場面かを書いたaria-labelが付き、マウスなしで押せる本物の<button>も場面ごとに二つずつ置いてあります。Homeは九つの場面すべてで最初の状態に戻し、01は-・+で膜を40nmずつ、02は→で流れ落ちを0.6秒ずつ早送りします。04は矢印で狙ってEnterで突き、06は↑・↓で小さい玉の大きさを、07はSpaceで吹くのを切り替え、08は←・→で空を回し、09は←・→で太陽を10°ずつ動かします。オンとオフを切り替えるボタンは02・03・07・08・09にあり、aria-pressedで状態を伝えます。

実演中は黙っている状態表示

場面の下の状態表示は自動の実演中はaria-live="off"のままで、人が初めてポインターやキー、ボタンを使った瞬間にpoliteへ変わり、実演も止まります。そのため玉が割れるたびに読み上げが重なって流れることはありません。

動きを減らす設定で残る一枚

prefers-reduced-motionがオンになるとループを止めて一枚を残し、九つの場面すべてがchangeイベントを聞いているので、ページを開いたまま設定を変えてもすぐに従います。01は時間の止まった膜、02はてっぺんが黒い膜に届いた瞬間、06はくっついた二つの玉の姿で止まります。入力はこのときも受け付け、03は0.5秒分を、07は0.4秒分を一度に積分した結果を、04は割れたあとしずくが0.3秒落ちた位置を、05ははじいた場所がへこんだ形をすぐに描き、09は割った玉をその場で新しい玉に替えます。

シャボン玉 割れる アニメーションは画像ファイルなしのコードだけでできるか?

できます。九つの場面には画像ファイル・ライブラリ・CDN・WebGLがひとつもなく、画素を埋める六つの場面はcreateImageDataで作った空の板に計算した値を入れてputImageDataで載せます。08・09のように玉の後ろに透ける舞台が要る場合も、その舞台を最初に一度画素で計算して焼き、その配列を読んでいて、動きはrequestAnimationFrameのループひとつが回します。乱数を使う01・02・03・04・06・09はシード465で固定したmakeRngを呼び、05・07・08は乱数を使わないので、ページを開くたびに同じ並びから始まります。

後ろの画面を屈折させて映す水滴の計算はキャンバス特殊効果の水滴とブラックホールで、打ち上げた花火が水面に映る場面は花火の場面9種で続けて見られます。ばねと減衰で反応するほかの画面なら、物理シミュレーションの9場面が近い題材です。

泡 アニメーション css を自分のページに置く前によくある質問

css アニメーション シャボン玉の色を自分のブランドの色に合わせられますか?

膜の虹色はパレットではなく厚さの計算値なので、舞台やボタンのパステル系トークンを変えても膜の色はついてきません。色の中心を動かすなら01のFILM_D_NMを変えるのがいちばん単純で、上の表のとおり500nmなら緑、380nmなら赤紫、260nmなら青が中心になります。一色に固定したいなら、この計算ではなく単色で塗るほうが向いています。

玉の数を増やすと、何から重くなりますか?

画素を埋める場面なら、玉の画素の合計がそのまま毎フレームの仕事になります。09はBUBBLE_Nが14でその合計が約25,000なので、半径を変えずに数を2倍にすれば仕事もほぼ2倍になります。03は輪郭を12切れの線で描く群れなので、FLOCK_Nの26を増やしても画素の計算は生まれません。

React版も同じ場面を描きますか?

同じTypeScriptの元から素のindex.htmlとReactのコンポーネントを一緒に生成しているので、計算の関数と定数の値は同じです。この記事のコード片と数値は素のindex.htmlを基準にしており、React版でFILM_D_NMのような定数名を探すと同じ値が出てきます。

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

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