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

html5 canvas アニメーション9種 — 水滴とブラックホール

html5 canvas アニメーションとは、写真のレタッチなしで画素そのものを動かす特殊効果です。水滴が下の文字を膨らませ、ブラックホールが画面全体を飲み込む。この9種を Three.js なし、canvas ひとつで作りました。下の格子がいま動いています。

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

並び順は派手さではなく 歪みが組み上がる順 です。まず画素を読み出して置き直す屈折が生まれ(01)、奥行きの錯覚が生まれ(02・03・04)、画面全体が流れるフィードバックが生まれ(05・06)、最後に光と霧がシーン全体を包みます(07・08・09)。粒を撒く キャンバスパーティクルエンジン とバネや重力を扱う 物理インタラクション がこの記事の隣人です。

01水滴レンズ

背景を一度だけ描き、水滴が覆う円の内側の画素だけを取り出して球面屈折の式で置き直す、商品写真の上を転がる水滴のヒーロー用パーツです。

水滴レンズ
新しいタブで開く

01 · 水滴レンズ — 計測 累積面積 32.968% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getImageDataputImageDatacreateRadialGradient

拡大とは標本座標を動かすことである

  for (var y = 0; y < sh; y++) {
    for (var x = 0; x < sw; x++) {
      var i = (y * sw + x) * 4;
      var dx = x - ox, dy = y - oy;
      var dist = Math.sqrt(dx * dx + dy * dy);
      var rn = dist / pr;
      var j = i;
      if (rn < 1 && dist > 0) {
        var inc = Math.asin(rn);                 // 入射角
        var ref = Math.asin(rn / IOR);           // 屈折角 (スネルの法則)
        var shift = depth * Math.tan(inc - ref); // 内側へ読み込む距離
        var k = (dist - shift) / dist;
        var mx = Math.round(ox + dx * k), my = Math.round(oy + dy * k);
        if (mx < 0) mx = 0; else if (mx >= sw) mx = sw - 1;
        if (my < 0) my = 0; else if (my >= sh) my = sh - 1;
        j = (my * sw + mx) * 4;
      }

描くものはそのままに、読み取る場所だけを水滴の中心へ引き寄せるので、格子と文字がレンズの下で膨らみます。水の屈折率 1.34 でスネルの法則をそのまま解き、ドラッグすると水滴がその位置へ滑らかに追従します(実測: ドラッグ位置へ水滴の中心が移動、アルファ画素 14,273 個が変化)。

02浮かび上がる3Dボール

四十個の球がそれぞれ z 値を持ち、焦点距離で一度割るだけで遠近が生まれる、料金セクションの背後に漂う背景用パーツです。

浮かび上がる3Dボール
新しいタブで開く

02 · 浮かび上がる3Dボール — 計測 累積面積 31.135% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createRadialGradientctx.filterglobalAlpha

遠近は割り算ひとつである

  // 画家のアルゴリズム — z の降順で奥から描く。深度バッファのない 2D で
  // 重なり順を作る唯一の方法。
  order.sort(function (a, b) { return b.z - a.z; });

  var blurNow = -1;
  for (var i = 0; i < COUNT; i++) {
    var o = order[i];
    var s = focal / (focal + o.z);                 // 透視投影 — この一行がすべて
    var sx = W * 0.5 + (o.x * spanX * 0.5 - camX) * s;
    var sy = H * 0.5 + (o.y * spanY * 0.5 - camY) * s;
    var r = o.r * s;
    if (r < 0.8) continue;
    // ぼかしは整数段階に切る — z でソート済みなので ctx.filter は
    // フレームあたり五回ほどの付け替えで済む。
    var blur = Math.round((o.z - Z_NEAR) / (Z_FAR - Z_NEAR) * MAX_BLUR * 1.2);

座標と半径にスケール s を掛けると、同じ大きさの球が遠くは小さく、近くは大きく描かれます。遠い球は ctx.filter のぼかしと低いアルファでさらに遠くに見え、ポインタを動かすとカメラがわずかに視差をつけて空間ごと傾きます。

03ノイズシェーダーの再現

160×100 の小さなバッファに値ノイズを四層重ねて画素単位で書き込み、拡大して張る、セクションの間を埋める流れる雲の背景用パーツです。

ノイズシェーダーの再現
新しいタブで開く

03 · ノイズシェーダーの再現 — 計測 累積面積 44.889% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createImageDataimageSmoothingEnableddrawImage

ノイズは周波数を倍にして重ねる

// fbm — 同じノイズを周波数2倍・振幅半分で重ねて積む。層数が粒の細かさになる。
function fbm(x, y, z, oct) {
  var sum = 0, amp = 0.5, norm = 0, f = 1;
  for (var i = 0; i < oct; i++) {
    sum += vnoise(x * f, y * f, z * f) * amp;
    norm += amp;
    amp *= 0.5;
    f *= 2;
  }
  return sum / norm;
}

フラグメントシェーダーのループを模しつつ、解像度を 160×100 に落として毎フレームの画素計算を画面の百分の一に減らしました。時間を第三の値として渡すので雲が流れ、層数を 4 から 2 に減らすと粒がごつくなります(実測: スライダー層 2、アルファ画素 69,182 個が変化)。

04揺れる3Dテキスト

文字を見えないキャンバスに一度だけ描き、横一行ずつ切り出して正弦波でずらして貼り直す、イベントページのうねる見出し用パーツです。

揺れる3Dテキスト
新しいタブで開く

04 · 揺れる3Dテキスト — 計測 累積面積 13.245% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillTextmeasureTextdrawImage

行ごとに正弦波の位相だけずらす

function pass(img, drop, t) {
  for (var y = 0; y < H; y += ROW) {
    var sx = 7 * Math.sin(0.055 * y + 2.4 * t) + 3 * Math.sin(0.021 * y - 1.5 * t + 1.4);
    var w = 1 + 0.05 * Math.sin(0.026 * y - 1.9 * t + 0.7);
    ctx.drawImage(img, 0, y * dpr, img.width, ROW * dpr, (W - W * w) * 0.5 + sx, y + drop, W * w, ROW);
  }
}

二つの正弦が逆向きに走る進行波なので行ごとに位相差が生まれ、文字が旗のようにうねります。下に敷いた暗い複製層が厚みを作り、入力欄に自分の語句を入れると measureText が中央を取り直します(実測: 語句の入れ替えでアルファ画素 14,779 個が変化)。

05ブラックホールのトンネル

直前のフレームを中心基準で少しだけ拡大して自分の上に描き直す、そのフィードバック拡大ひとつで永遠に吸い込まれ続けるトンネルを作る、ページ遷移直前の没入背景用パーツです。

ブラックホールのトンネル
新しいタブで開く

05 · ブラックホールのトンネル — 計測 累積面積 52.193% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

drawImageglobalCompositeOperationcreateRadialGradient

フィードバック拡大一回がトンネルのすべてである

function frame(now) {
  var w = cv.width, h = cv.height;
  // 1. 直前フレームを補助キャンバスに複写
  actx.clearRect(0, 0, w, h);
  actx.drawImage(cv, 0, 0);
  // 2. 中心基準で拡大して張り直す — この一回の drawImage がトンネルのすべて
  var dw = w * zoom, dh = h * zoom;
  ctx.globalCompositeOperation = 'source-over';
  ctx.drawImage(aux, 0, 0, w, h, (w - dw) * 0.5, (h - dh) * 0.5, dw, dh);
  // 3. 新しいまだら模様を足す — 重なる場所が明るくなる lighter 合成
  ctx.globalCompositeOperation = 'lighter';
  for (var i = 0; i < SEEDS; i++) speck();
  // 4. 地平面と光子球
  horizon(now);
  raf = requestAnimationFrame(frame);
}

直前のフレームを 1.06 倍に拡大して重ねていくと、まだら模様が外へ流れ出し深さが無限のトンネルになります。拡大率を 1.02 に下げると吸い込まれる速さがはっきり遅くなります(実測: 同じ 0.4 秒の変化画素が 63,768 個から 57,853 個へ)。

06銀河の渦

六百個の星の半径と初期角度を一度だけ決め、以降の位置を式で計算する、ダークモードのダッシュボードの静かな背景用パーツです。

銀河の渦
新しいタブで開く

06 · 銀河の渦 — 計測 累積面積 30.838% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

globalCompositeOperationdestination-outMath.cos

位置は保存せず毎フレーム式で出す

function draw(t) {
  // 尾 — 完全に消さず、低いアルファで塗る
  ctx.globalCompositeOperation = 'destination-out';
  ctx.fillStyle = 'rgba(0, 0, 0, ' + FADE + ')';
  ctx.fillRect(0, 0, W, H);
  // 星とハブの光は重なると明るくなる lighter で載せる
  ctx.globalCompositeOperation = 'lighter';
  for (var i = 0; i < COUNT; i++) {
    var st = stars[i];
    var R = st.r * rmax + 8;                       // 底の半径 8 で中心への集中を防ぐ
    var a = st.a0 + t * K / (Math.pow(R, 0.8) + 20);
    var ex = Math.cos(a) * R;
    var ey = Math.sin(a) * R * FLAT;
    // ポインタ項 — 座標全体が (px, py) へ inf だけ傾く(密度の中心が一瞬そこへ借りる)
    var x = cx + ex * cosT - ey * sinT + (px - cx) * inf;
    var y = cy + ex * sinT + ey * cosT + (py - cy) * inf;

角速度は外側ほど小さくなるので内側は速く、外縁はゆっくり回り、差動回転が積もって渦巻きの腕がひとりでに生まれます。速度も寿命も保存する配列がないので同じ時刻にはいつも同じ絵が出て、destination-out の低アルファ消しが軌跡を残します。

07流体の波紋の表面

高さの格子を二枚交互に使って波動式を解き、隣接マスとの高さの差で下の絵の画素を引き寄せる、触れると反応するバナーの水面用パーツです。

流体の波紋の表面
新しいタブで開く

07 · 流体の波紋の表面 — 計測 累積面積 57.639% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Float32ArraygetImageDataputImageData

隣四マスの平均の二倍から直前の値を引く

// 波動式 — 隣接四マスの平均の二倍から直前の値を引くと次の高さになる。
// 二枚のバッファを交換しながら解く。
function sim(steps) {
  for (var s = 0; s < steps; s++) {
    for (var y = 1; y < ROWS - 1; y++) {
      var row = y * COLS;
      for (var x = 1; x < COLS - 1; x++) {
        var i = row + x;
        prev[i] = ((curr[i - 1] + curr[i + 1] + curr[i - COLS] + curr[i + COLS]) * 0.5 - prev[i]) * DAMP;
      }
    }
    swap = prev; prev = curr; curr = swap;
  }
}
    for (var x = 0; x < RW; x++) {
      var cx = Math.round(x * kx);
      var cl = Math.max(0, cx - 1), cr = Math.min(COLS - 1, cx + 1);
      var hL = curr[rc + cl], hR = curr[rc + cr];
      var hU = curr[ru + cx], hD = curr[rd + cx];
      var sxp = x + (hL - hR) * REFR;

波は物理現象ですが、見える理由は屈折です — 左右と上下の高さ差をオフセットにして元の画素を引き寄せます。盤を押したりなでたりすると余弦の落ち込みで水がへこみ、丸く広がって壁で跳ね返ります(実測: ドラッグでアルファ画素 79,762 個が変化)。

08光の散乱光線

シルエットを描いた絵を光源基準で少しずつ拡大しながら十二回重ねて足す、森や窓の写真に重ねる光の筋用パーツです。

光の散乱光線
新しいタブで開く

08 · 光の散乱光線 — 計測 累積面積 46.398% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

globalCompositeOperationlightercreateLinearGradient

重なるほど明るくなる合成で光線が育つ

  ctx.globalAlpha = 1;
  ctx.drawImage(scene, 0, 0, W, H);
  // 2. 光源基準で 1.03 倍ずつ大きくなる十二枚 — 重なるほど明るくなる lighter 合成で
  // 光線が育つ
  var s = 1;
  for (var i = 1; i <= STACK; i++) {
    s *= GROW;
    ctx.globalCompositeOperation = 'lighter';
    ctx.globalAlpha = 0.55 * (1 - i / (STACK + 1));
    ctx.drawImage(veil, lx * (1 - s), ly * (1 - s), W * s, H * s);
  }

暗いベールを光源から離れる方向へ十二枚重ねると、木々の間の隙間だけが明るく積もって扇状の光線になります。光源はポインタをゆっくり追いかけ(実測: 移動でアルファ画素 77,145 個が変化)、矢印キーでも動かせます。

09奥行きの霧のシーン

尾根を六層、距離順に並べ、距離ごとに霧の色を指数で混ぜる、旅行ランディングの静かな遠景背景用パーツです。

奥行きの霧のシーン
新しいタブで開く

09 · 奥行きの霧のシーン — 計測 累積面積 1.434% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createLinearGradientfillRectglobalAlpha

残るコントラストは指数で溶ける

  // 尾根 — 奥から手前へ。距離ごとに霧の色を指数で混ぜる: t = exp(-距離/密度)
  for (var i = 0; i < LAYERS; i++) {
    var d = LAYERS - i;              // 距離 — 手前の層が 1
    var t = Math.exp(-d / density);  // その層が残すコントラスト
    var off = (sec * meta[i].speed) % W;
    ctx.fillStyle = css3(mix3(SKY1C, INK, t));
    ctx.save();
    ctx.translate(-off, 0);
    ctx.fill(paths[i]);
    ctx.translate(W, 0);
    ctx.fill(paths[i]);

指数の混色は遠い層ほど空の色へ急激に溶け込むので、同じインク色の尾根六つが段階的な遠景として読まれます。各層がそれぞれの速さで横に流れる視差が奥行きを二重に確定し、密度を ×2.4 から ×5.0 へ上げると霧が濃くなって一番手前の尾根だけが残ります(実測: アルファ画素 46,650 個が変化)。

canvas アニメーション 作り方 — 歪みはどこで生まれるのか?

html5 canvas 2d が歪みを作る方法は描画ではなく 読み直し ひとつです。すでに描いた画素を getImageData で取り出し、標本座標だけ動かして戻せば(01)、隣の高さ差で引き寄せれば(07)、直前フレームを拡大すれば(05)、同じ絵がそれぞれレンズ・波紋・トンネルに見えます。描く順番と色だけを変える画家のアルゴリズムや指数の混色(02・09)も同じ系列です。

この9種はどれも requestAnimationFrame が渡す時刻を時計として使い、フレーム間隔を自分で測る 02 はその間隔が 0.05 秒を超えないように切り詰めています。実測では 60 フレームの平均間隔はどの項目も 16.4〜16.6 ミリ秒で 60fps に張り付き、動いたフレームは 23 山すべて、累積面積は 09 の 1.4 パーセントから 07 の 57.6 パーセントまで項目ごとに違います。html5 canvas api の三つの関数 — 読み・書き・合成 — が水滴とブラックホールのすべてです。

どこで壊れるか — ガラスが灰色の球になる話

最初の水滴はレンズではなく半透明の灰色の球に見えました。屈折を計算するときに水層の厚さを半径の 0.5 倍にしていたため拡大が弱すぎて背景がほとんど変わらず、上に重ねた縁の陰が主役になってしまったのです。厚さを 0.9 倍まで上げてようやく格子が目に見えて曲がり、1.0 を超えると縁の標本座標が負に突き抜けて像が裏返る境界もソースに書き残してあります。

測る側にも落とし穴がありました。屈折の検証を最初に青チャンネルで読んだのが間違いです — 青い格子とクリームの背景はそのチャンネルでは差が 5 しかなく、ちゃんと曲がっている格子が空の画面と誤認されました。赤チャンネルで測り直して、円の内側の格子間隔が外側の約 1.3 倍に開いているという事実が確認できました。9種の完成した特殊効果のソースと測定値は記事の下のパスワード付き zip に入っています。zip のパスワードは w2k393bp で、三つのロケール版のダウンロードボタンが同じパスワードを共有します。

3つのバリエーションとアクセシビリティ

名前 変更する値 感じ方の違い
フィードバック倍率 05 拡大 1.06 → 1.02 吸い込まれる速さが遅くなりトンネルが長くなる
ノイズの層数 03 octaves 4 → 2 雲がごつくなり粒が粗くなる
色 $subject-blue → $subject-mint 冷たい青から爽やかなミントへ

アクセシビリティでは、九つの盤が同じ規則を共有します。prefers-reduced-motion: reduce を設定した利用者には rAF ループを始めず、それぞれの盤が最もよく見える静止画を一枚だけ描きます — 水滴が背景に乗った状態(01)、トンネルが完成した状態(05)、鳥が空に浮いた瞬間(09)で、実測でも三項目とも静止画のアルファ画素数が 0 ではありません。01・02・03・06・07・08 の盤には tabindex と role があり、Enter・Space で波を起こし(07)、矢印キーで光源を動かせ(08)、08 のカーソルは細い十字なので画面を塞ぎません。

FAQ

javascript canvas アニメーションは CSS ではなく canvas で作るべきときはいつですか?

画素の値を実際に読む必要があるときです。CSS の変形は要素を丸ごと動かしますが、水滴の下の文字を膨らませる(01)・波紋の下の絵を屈折させる(07)ことは画素単位の再配置なので CSS では表現できません。逆に単純な移動やフェードなら CSS の方が電池と性能の面で有利です。

canvas アニメーション サンプルはスマートフォンで重くなりませんか?

計算する画素数を画面より小さく保つのがこの記事の定石です。ノイズは 160×100 のバッファで計算して拡大し(03)、水のシミュレーションは 96×47 の格子で解いて描画はその三倍で行います(07)。ブラックホールのトンネルは毎フレーム描くのが拡大一回といくつかのまだら模様だけなので九つの中で最も軽いです。

ソースコードをそのままコピーしてもいいですか?

構いません。記事の下のパスワード付き zip に九つの vanilla 版と React 版がまるごと入っていて、貼り付ければすぐ動きます。画面の文字は三言語に分かれており、格子の色や太さは各盤の先頭にある変数とトークンで変えられます。

さらに読む

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

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