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

マジックアイ9種 — 深さマップを走査線シフトで織るステレオグラム

ピクセルの明るさを深さとして読み、同じ行の中で数ピクセルずらすと、平らな模様の中に立体が隠れます。ライブラリなしのコピペ隠れ絵エンジンです。マジックアイ(ステレオグラム)は、深さマップを決めて各行を左へ数ピクセル引き込んで複製するだけで、球・文字・山脈を粒の中に浮かべる仕組みです。

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

デモ

各盤は index.html と style.css の2ファイル単独で動き、追加の読み込みは一切ありません。24フレームの録画の間、九つのすべてが23フレームで動いていました(23/24)。05はホイールと横ドラッグで、07は左右のドラッグで操作でき、ほかの7つは開いた瞬間からひとりでに動きます。

盤 何が浮かぶか 累積面積(%) フレーム最大(%) 強度平均 フレーム
01 ランダムドット基本 2秒周期で息をする半球の浮き上がり 31.427 20.921 69.7 23/24
02 パターンタイル 4段の階段視差と幅100pxのタイル 52.806 12.343 155.2 23/24
03 文字隠し fillText の明るさが浮かぶ単語 31.934 19.192 85.8 23/24
04 写真レリーフ 明るさを高さに読んだ丘の彫刻 50.087 31.937 71.8 23/24
05 スクロール地形 旅する3オクターブの山脈と谷 51.918 36.326 71.8 23/24
06 見方ガイド 2点の練習と解答マップの装置 52.87 52.87 66.0 23/24
07 ポインタ彫刻 左右のドラッグで刻む粘土の跡 34.71 13.015 76.0 23/24
08 生きる隆起 ドームを横切る波長130pxのサイン 31.176 22.983 71.1 23/24
09 ロゴヒーローシーン 波とビネットに包まれたワードマーク 52.539 31.517 69.9 23/24

この回で累積面積がどの盤も31%を超え、02・06・09・05・04が50%台に達するのは性質であって欠陥ではありません。マジックアイはノイズ(またはタイル)が画面全面を覆う深さのある画面なので、覆う面積は最初から大きく、盤の差はフレーム最大(同じフレームの中でどれだけ編み直されるか)と強度で開きます。強度平均は02の155.2が最も明るく、06は解答マップが点灯する瞬間にフレーム最大52.87%の頂点を迎えます。

半球の数式からポインター彫刻まで、マジックアイの奥行きを広げた順序

並び順の基準は、深さマップの源をどこまで遠くへ連れて行けるかです。01は座標ハッシュのノイズの上で息する半球という最小構成、02はノイズをタイルに置き換えて、離散視差とパターン周期の噛み合わせを模様として読みます。03で初めて絵(文字のラスタ)を源にし、04はその絵を合成写真へ、05は数式の地形と旅へ引き伸ばします。06は一度計算から離れて「見えない人」への案内そのものを装置にし、07で初めて手で深さを刻み、08が時間による波で静止画だったマジックアイを動く場面へ変えます。09が文字・遠景・ビネットを重ねてサービスの表紙へ組み立てて締めくくります。3行の速度式を積分して蝶を巻き上げるローレンツの9盤や複素平面を反復で掘り下げるマンデルブロの9盤と違うのは、この9種がすべて「深さを決めて行をずらす」1本のパイプラインを共有する点です。

項目別コード

ここからは各盤の定数と、動いている関数をそのまま引きます。行はどれもその盤のソースからの引用で、定数も名前も変えていません(03のdrawは関数の頭を省いて途中から、06の最後のブロックは解答オーバーレイの手前で切れます)。コメントだけこの記事の言葉に置き換えてあり、変数名と関数名は zip の中身と同じ英語のままです。

01ランダムドット基本 — 黙って浮かび上がる球の最初の画面

盤全面に座標ハッシュのノイズを敷き、中央の半球の深さだけが2秒周期で膨らんだり沈んだりします。目を平行に緩めた瞬間、円が球として浮かびます。累積面積31.427%・強度平均69.7は、粒1pxのノイズが全面を覆う画面として妥当な値です。

ランダムドット基本 — 黙って浮かび上がる球の最初の画面
新しいタブで開く

01 · ランダムドット基本 — 黙って浮かび上がる球の最初の画面 — 計測 累積面積 31.427% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ImageDataUint8ClampedArrayputImageData

baseIdx — 時間を受け取らない決定論的ハッシュ

両目がパターンを覚えてこそ立体は浮かびます。乱数の代わりに座標だけを受け取るハッシュで、同じ (x, y) には毎フレーム同じ粒が返ります。

  function baseIdx(x, y) {
    var h = (Math.imul(x + 11, 374761393) + Math.imul(y + 7, 668265263)) >>> 0;
    h ^= h >>> 13;
    h = Math.imul(h, 1274126177) >>> 0;
    h ^= h >>> 16;
    return ((h % PAL.length) + PAL.length) % PAL.length;   // % は ToInt32 で負数になり得るので折る
  }

行ごとの複製シフト — この回の唯一の前提

描画は各行を左から右へなぞり、深さ0の画素は下地のまま、深さのある画素は Math.round(depth × MAXSHIFT) だけ左から引き込んで複製します。画面の外へ出る画素は内側の下地へ巻き戻して取ります。

    for (y = 0; y < H; y++) {
      for (x = 0; x < W; x++) {
        var s = Math.round(depthAt(x, y, t) * MAXSHIFT);
        if (s <= 0) {
          col[x] = baseIdx(x, y);                    // 背景 — 下塗りの粒そのまま
        } else {
          var from = x - s;
          col[x] = from >= 0 ? col[from] : baseIdx(from + W, y);  // 左から複製(画面外は巻き戻し)
        }
      }
    }

02パターンタイル — 繰り返し幅が目の寄せる角度

ノイズの代わりに幅100pxのタイル(斜めストライプ)を敷き、深さを4・8・12・16pxの4段の階段に置きます。シフトがタイル周期の倍数のときだけ継ぎ目が噛み合い、そうでなければずれて浮き上がるのが模様として読めます。タイル自体は毎秒28px(整数ピクセル)で上へ流れ、どの段が浮くかは2秒周期で入れ替わります。強度平均155.2は九つの中で最も明るく、累積面積は52.806%です。

パターンタイル — 繰り返し幅が目の寄せる角度
新しいタブで開く

02 · パターンタイル — 繰り返し幅が目の寄せる角度 — 計測 累積面積 52.806% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ImageDatarequestAnimationFrame

tileColor — タイル座標だけで決まる模様

キャンバス座標をタイルの繰り返し座標へ畳み、斜めの太い帯・中央の点・細い補助線を返します。負の座標も TILE で割って巻き戻すので、どこへずれても同じタイルが現れます。

  function tileColor(px, py) {
    var tx = ((px % TILE) + TILE) % TILE, ty = ((py % TILE) + TILE) % TILE;
    var diag = (tx + ty) % TILE;                       // 斜め座標
    if (diag < 14 || diag > TILE - 14) return C2;      // 太い斜めの帯
    var dx = tx - TILE * 0.5, dy = ty - TILE * 0.5;
    var rr = Math.sqrt(dx * dx + dy * dy);
    if (rr < 9) return C2;                             // 中央の点
    if (rr < 15) return C0;                            // 点の縁
    return (diag % 50 < 4) ? C1 : C0;                  // 地 + 細い補助線
  }

shiftFor — 段ごとの離散視差

深さは帯ごとに時間差で持ち上がり、シフト量は段の値 STEPS[band] × 深さです。draw は各行を rowY = y + Math.floor(t × 28) でサンプルするので、タイルの模様が毎秒28pxで上へ流れます。

  function depthAt(x, y, t) {
    var band = (y * 4 + Math.floor(x / (W / 4)));      // 対角の階段インデックス
    var scan = (t / CYCLE) % 1;                        // 0..1 走査位相
    var appear = ((band % 8) + 8) % 8 / 8;             // 段ごとに現れる時刻をずらす
    var lift = Math.max(0, Math.sin((scan - appear) * Math.PI * 2 - Math.PI / 2) * .5 + .5);
    return lift * lift;
  }

  function shiftFor(x, y, t) {
    var band = Math.floor(x / (W / 4)) % 4;
    var d = depthAt(x, y, t);
    return Math.round(STEPS[band] * d);
  }

03文字隠し — fillTextで打った文字を深さマップに使う

オフスクリーンの canvas に 'SOLID'(220px)を白と黒で打ち、getImageData で読んだ明るさを 0..1 の深さへ渡します。以後は01と同じ行ごとの複製シフトで、単語が2秒周期でノイズの中に浮かんでは沈みます。深さの源は絵1枚なら何でもよい、という拡張の扉を開く項目で、累積面積31.934%・強度平均85.8です。

文字隠し — fillTextで打った文字を深さマップに使う
新しいタブで開く

03 · 文字隠し — fillTextで打った文字を深さマップに使う — 計測 累積面積 31.934% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillTextgetImageDataImageData

オフスクリーンの文字ラスタ — 明るさを深さへ

白い文字を黒地に打ち、画素値を左右1pxずつ平均しながら深さバッファへ溜めます。1pxの平均は文字の縁の段差を丸めて、シフトの後に形が折れるのを防ぎます。

    srcCv.width = W; srcCv.height = H;
    srcCtx.fillStyle = '#000';
    srcCtx.fillRect(0, 0, W, H);
    srcCtx.fillStyle = '#fff';
    srcCtx.textAlign = 'center';
    srcCtx.textBaseline = 'middle';
    var fs = Math.min(FSIZE, W * 0.46);
    srcCtx.font = '900 ' + fs + 'px "Arial Black", "Segoe UI", sans-serif';
    srcCtx.fillText(WORD, W * 0.5, H * 0.58);
    var sd = srcCtx.getImageData(0, 0, W, H).data;

    // 明るさ → 深さ。左右1pxの平均で縁の段差を丸める(折れて見えないように)
    depthBuf = new Float32Array(W * H);
    var x, y, v, sum, n;
    for (y = 0; y < H; y++) {
      for (x = 0; x < W; x++) {
        v = sd[(y * W + x) * 4] / 255;
        sum = v; n = 1;
        if (x > 0)     { sum += sd[(y * W + x - 1) * 4] / 255; n++; }
        if (x < W - 1) { sum += sd[(y * W + x + 1) * 4] / 255; n++; }
        depthBuf[y * W + x] = sum / n;
      }
    }

depthBuf を読む draw — 浮上と上下の流れ

出現の振幅は最低12%を保つので、周期の切れ目に単語が完全に消えることはありません。bob は行ごとにサンプル行をずらし、単語をそっくり上下へ流します。

    var ph = t / CYCLE * Math.PI * 2;
    var appear = Math.sin(ph - Math.PI / 2) * .5 + .5;
    var amp = 0.12 + 0.88 * appear;                // 最低12%を保つ — ループがつながる
    var bob = Math.round(Math.sin(ph) * H * 0.09); // 単語が上下へ流れる
    var col = new Int32Array(W);
    for (y = 0; y < H; y++) {
      var sy = y - bob;
      var rowOff = (sy >= 0 && sy < H ? sy : y) * W;
      for (x = 0; x < W; x++) {
        var s = Math.round(depthBuf[rowOff + x] * amp * MAXSHIFT);
        if (s <= 0) {
          col[x] = baseIdx(x, y);
        } else {
          var from = x - s;
          col[x] = from >= 0 ? col[from] : baseIdx(from + W, y);
        }
      }
    }

04写真レリーフ — 明るさを地形の高さとして読み上げる

外部画像なしで canvas に「写真」(曇り空のグラデーション・丘3つ・丘の上のシルエット)を合成し、その明るさをそのまま高さに使います。光源が2秒周期で左右に往復すると丘の陰影が変わり、陰影こそ高さなのでレリーフ全体が生まれ変わります。累積面積50.087%・フレーム最大31.937%です。

写真レリーフ — 明るさを地形の高さとして読み上げる
新しいタブで開く

04 · 写真レリーフ — 明るさを地形の高さとして読み上げる — 計測 累積面積 50.087% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ImagegetImageDataImageData

paintPhoto — 光源の左右往復と空のグラデーション

光源角は t / CYCLE から作り、cos で横に・sin でわずかに縦へ動きます。空は上ほど明るい縦のグラデーションで、その明るさがそのまま手前の高さとして読まれます。

    var ang = t / CYCLE * Math.PI * 2;
    var lx = W * 0.5 + Math.cos(ang) * W * 0.4;   // 光源が左右へ往復する
    var ly = H * 0.28 + Math.sin(ang) * H * 0.1;

    // 空 — 縦グラデーション(明るさが上から下へ下がる = 奥へ下がる地形)
    var sky = phCtx.createLinearGradient(0, 0, 0, H);
    sky.addColorStop(0, '#e8e2d4');
    sky.addColorStop(0.7, '#c9c0ae');
    sky.addColorStop(1, '#b3a894');
    phCtx.fillStyle = sky;
    phCtx.fillRect(0, 0, W, H);

draw — 合成写真の明るさをシフトへ

もう1枚の canvas から読み取った赤チャンネルを明るさとしてシフト量に渡します。実サービスではドラッグ&ドロップされた任意の写真を、この1行に同じように通せばレリーフになります。

  function draw(t) {
    paintPhoto(t);
    var ph = phCtx.getImageData(0, 0, W, H).data;
    var d = img.data, x, y, di;
    var col = new Int32Array(W);
    for (y = 0; y < H; y++) {
      var rowOff = y * W;
      for (x = 0; x < W; x++) {
        var lum = ph[(rowOff + x) * 4];   // 明るさ 0..255
        var s = Math.round(lum / 255 * MAXSHIFT);
        if (s <= 0) {
          col[x] = baseIdx(x, y);
        } else {
          var from = x - s;
          col[x] = from >= 0 ? col[from] : baseIdx(from + W, y);
        }
      }
    }
    ctx.putImageData(img, 0, 0);
  }

05スクロール地形 — すすった分だけ山脈が現れる

地形の高さは波長の違う3つのサイン(重み0.45・0.30・0.18)とゆるいコサイン1つの和で、旅のオフセット travel を足した世界座標でサンプルします。ホイール(0.6倍)と横ドラッグ(1.2倍)で自分でも旅でき、自動走行の90px/秒は毎フレーム積み増されるので手を離しても途切れません。累積面積51.918%・フレーム最大36.326%です。

スクロール地形 — すすった分だけ山脈が現れる
新しいタブで開く

05 · スクロール地形 — すすった分だけ山脈が現れる — 計測 累積面積 51.918% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scrollImageDatarequestAnimationFrame

terrain — 3オクターブのサインの和

波長の違う3つのサインと1つのコサインを足し、画面の下端(手前)ほど高く持ち上げます。travel は x に足すだけなので、地形はどこまでも続きます。

  function terrain(wx, y) {
    var v = 0;
    v += Math.sin(wx * 0.011 + y * 0.004) * 0.45;
    v += Math.sin(wx * 0.031 + 1.7 + y * 0.010) * 0.30;
    v += Math.sin(wx * 0.074 + 4.2) * 0.18;
    v += (Math.cos(wx * 0.005 + y * 0.006) * 0.5 + 0.5) * 0.35;
    var front = y / H;                       // 画面下端(手前)がより高くそびえる
    var h01 = Math.max(0, Math.min(1, v * 0.5 + 0.5)) * (0.35 + 0.65 * front * front);
    return h01;
  }

自動走行は毎フレーム — 手を離しても積み続ける

travel への加算はホイール・ドラッグ・自動走行の3つが並列に効きます。盤に一度触れるまで、下端には「ホイールやドラッグで地形を通り過ぎてください」の1行が出ています。

  function frame(now) {
    if (t0 < 0) { t0 = now; lastNow = now; }
    var dt = Math.min(0.05, (now - lastNow) / 1000 || 0.016);
    lastNow = now;
    travel += dt * SPEED;   // 自動走行 — 毎フレーム積む(手があれば入力が上乗せされる)
    draw(travel, hover);
    requestAnimationFrame(frame);
  }

06見方ガイド — 焦点誘導ドットと自己解答オーバーレイ

「見えない」人のための案内盤です。盤の上に、視差100px(SEP)だけ離れた同じ点を2つ浮かべ、2秒周期でゆっくり開いたり閉じたりする間に目を緩める練習をさせます。ボタンで同じ深さマップ(中央のドーナツ、内側の穴は深さ0.22)をグレースケールの濃淡で重ねて見せ、周期の後半1秒は解答マップが自動で点灯します。累積面積とフレーム最大がともに52.87%なのは、マップ点灯の瞬間がこの盤の頂点だからです。

見方ガイド — 焦点誘導ドットと自己解答オーバーレイ
新しいタブで開く

06 · 見方ガイド — 焦点誘導ドットと自己解答オーバーレイ — 計測 累積面積 52.87% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramefillRectMath.sin

depthAt — 解答マップと同じ式を共有するドーナツ

練習盤も本番も同じ depthAt を使い、内側の穴を0.22と低くして立体で読むとドーナツになります。ドーナツは2秒周期で浮かんでは沈み、深さも脈動します。

  function depthAt(x, y, t) {
    var ph = t / CYCLE * Math.PI * 2;
    var cy = H * 0.54 + Math.sin(ph) * H * 0.08;              // 垂直の bob
    var cx = W * 0.5;
    var rad = Math.min(W, H) * 0.36;
    var dx = (x - cx) / rad, dy = (y - cy) / rad;
    var d2 = dx * dx + dy * dy;
    if (d2 >= 1) return 0;
    var ring = Math.sqrt(d2);
    var hole = ring < 0.36 ? 0.22 : 1;   // 内側の穴は低く — 立体で読むとドーナツ
    var pulse = 0.55 + 0.45 * (Math.sin(ph - Math.PI / 2) * .5 + .5);   // 深さの脈動
    return Math.sqrt(1 - d2 * 0.25) * 0.92 * hole * pulse;
  }

手がかりの2点 — ゆっくり開いては閉じる

2点は固定間隔ではなく、2秒周期でゆっくり開いては閉じます。開き幅が目の緩めるべき視差(100px)に達した瞬間に盤が融合するので、動きはキャプチャ時計に合う rAF の canvas で描きます。「ステレオグラム 見方」で検索して来た人も、この2点で寄せる距離をつかめます。

      // 手がかりの2点 — ゆっくり開いては閉じる
      var open = Math.max(0, Math.sin(ph - Math.PI / 2) * .5 + .5);
      var px = open * SEP / 2;
      ctx.fillStyle = 'rgba(' + DOT[0] + ',' + DOT[1] + ',' + DOT[2] + ',0.92)';
      ctx.beginPath(); ctx.arc(W / 2 - px, 14, 3.5, 0, 6.2832); ctx.fill();
      ctx.beginPath(); ctx.arc(W / 2 + px, 14, 3.5, 0, 6.2832); ctx.fill();

07ポインタ彫刻 — 深さを押し込み引き上げる

sculpt バッファ(Float32Array)に、ポインタが通った場所の深さを足し引きします。左ドラッグは +0.8(引き上げ)・右ドラッグは −0.5(押し込み)で、ブラシの直径は26px。跡は exp(−dt/6) の指数減衰で6秒かけて静かに沈み込み、目には何の変化もなく、目を緩めた瞬間に粘土の上の彫刻が浮かびます。累積面積34.71%・強度平均76.0です。

ポインタ彫刻 — 深さを押し込み引き上げる
新しいタブで開く

07 · ポインタ彫刻 — 深さを押し込み引き上げる — 計測 累積面積 34.71% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermoveImageDatarequestAnimationFrame

brush — 円錐フォールオフで線分を刻む

ドラッグの軌跡を線分として刻み、中心ほど深く縁ほど浅い円錐の落ち込みを置きます。深さは 0..1 に挟んであり、盛りすぎも掘りすぎも自動で頭打ちになります。

  function brush(x0, y0, x1, y1, amount, r) {
    r = r || BRUSH / 2;
    var dist = Math.hypot(x1 - x0, y1 - y0);
    var steps = Math.max(1, Math.ceil(dist / (r * 0.4)));
    for (var i = 0; i <= steps; i++) {
      var bx = x0 + (x1 - x0) * (i / steps);
      var by = y0 + (y1 - y0) * (i / steps);
      var x0i = Math.max(0, Math.floor(bx - r)), x1i = Math.min(W - 1, Math.ceil(bx + r));
      var y0i = Math.max(0, Math.floor(by - r)), y1i = Math.min(H - 1, Math.ceil(by + r));
      for (var yy = y0i; yy <= y1i; yy++) {
        for (var xx = x0i; xx <= x1i; xx++) {
          var dx = xx - bx, dy = yy - by;
          var dd = Math.sqrt(dx * dx + dy * dy);
          if (dd >= r) continue;
          var fall = 1 - dd / r;               // 円錐フォールオフ — 縁は浅く
          var idx = yy * W + xx;
          var v = sculpt[idx] + amount * fall;
          sculpt[idx] = v < 0 ? 0 : (v > 1 ? 1 : v);
        }
      }
    }
  }

左右のドラッグと静かな減衰

入力は左右のドラッグだけです(pointerdown の button で切り替え)。触れた場所が明滅すると平行眼が壊れるので、跡は毎フレーム k = exp(−dt / DECAY) を掛けて6秒かけて静かに沈みます。盤を開いた直後の .is-demo の間は、リサジュー軌道で盤を広く掃く5つのフックが自動で跡を描き、最初のドラッグでこの演出は外れて盤は自分の彫刻に明け渡されます。

  fieldEl.addEventListener('pointerdown', function (ev) {
    ev.preventDefault();
    root.classList.remove('is-demo');   // 最初のひと撫でで5フックの掃引演出を外す
    mode = ev.button === 2 ? -1 : 1;    // 右ボタン = 押し込み
    var p = localXY(ev); lastX = p[0]; lastY = p[1];
    fieldEl.setPointerCapture(ev.pointerId);
  });
  fieldEl.addEventListener('pointermove', function (ev) {
    if (!mode) return;
    var p = localXY(ev);
    if (lastX !== null) {
      brush(lastX, lastY, p[0], p[1], mode > 0 ? LIFT : -PRESS);
    }
    lastX = p[0]; lastY = p[1];
  });

08生きる隆起 — 深さの波で息をする表面

基底の中央ドームの上を、波長130pxのサイン波が対角線に横切ります。峰は振幅12pxの最大シフトを作り、谷は0まで平らになり、シフトのある画素とない画素が行ごとに交互に流れます。サインの周期は2秒で、全体の振幅もその半分の位相でゆっくり息をします。累積面積31.176%・フレーム最大22.983%です。

生きる隆起 — 深さの波で息をする表面
新しいタブで開く

08 · 生きる隆起 — 深さの波で息をする表面 — 計測 累積面積 31.176% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ImageDataMath.sinrequestAnimationFrame

depthAt — ドーム × 対角サイン × 息

深さは3つの層の掛け算です。中央ドーム(縁は0)×対角と直交の2つのサイン×ゆっくり息をする振幅で、山と穴が入れ替わりながら表面が動きます。

  function depthAt(x, y, t) {
    var ph = t / CYCLE * Math.PI * 2;
    var cx = W * 0.5, cy = H * 0.5 + Math.sin(ph) * H * 0.06;
    var rad = Math.min(W, H) * 0.74;
    var dx = (x - cx) / rad, dy = (y - cy) / rad;
    var d2 = dx * dx + dy * dy;
    if (d2 >= 1) return 0;
    var dome = Math.sqrt(1 - d2) * 0.60;
    var wpos = (x + y * 0.6) / WAVELEN * Math.PI * 2;
    var wpos2 = (x * 0.4 - y) / (WAVELEN * 1.4) * Math.PI * 2;
    var wave = Math.sin(wpos - ph) * 0.5 + 0.5;
    var wave2 = Math.sin(wpos2 - ph * 0.7 + 2.1) * 0.5 + 0.5;
    var v = dome * (0.28 + 0.44 * wave + 0.28 * wave2);
    var breathe = 0.68 + 0.32 * Math.sin(ph * 0.5);
    return v * breathe;
  }

周期を縮めると脈打つようになる

3つ目の変形はこの CYCLE を2秒から1秒へ縮めます。眠るように息していた表面が脈打つように変わり、立体の輪郭の変わる速さが倍になります。

09ロゴヒーローシーン — ブランド文字を隠した表紙の場面

オフスクリーンに 'GODRICH'(96px)を打ち、明るさを深さへ渡したうえで、周期2秒・波長150pxのゆっくりしたサイン波を流します。文字は膨らんでは沈み、背後には1〜2pxの浅い遠景の地面が霞のように敷かれ、ビネット18%が縁を落として表紙として完成します。累積面積52.539%・フレーム最大31.517%です。

ロゴヒーローシーン — ブランド文字を隠した表紙の場面
新しいタブで開く

09 · ロゴヒーローシーン — ブランド文字を隠した表紙の場面 — 計測 累積面積 52.539% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createLinearGradientImageDatarequestAnimationFrame

renderMark — 幅に合わせて横に潰すワードマーク

ワードマークが盤幅の94%を超えるときは measureText で測って横方向だけ圧縮し、常に画面を埋めます。字間を開けたサブワードでブランドのロックアップを作ります。

    offCtx.font = '900 ' + fs + 'px "Segoe UI", "Arial Black", sans-serif';
    var tw = offCtx.measureText(MARK).width;
    var line1y = H * 0.42, line2y = H * 0.66;
    if (tw > W * 0.94) {
      offCtx.save();
      offCtx.translate(W / 2, line1y);
      offCtx.scale(W * 0.94 / tw, 1);
      offCtx.fillText(MARK, 0, 0);
      offCtx.restore();
    } else {
      offCtx.fillText(MARK, W / 2, line1y);
    }

ビネット — 計算画面を表紙へ締める1枚

深さの編み直しの後に、上下の縁だけを暗くする18%のグラデーションを重ねます。マークの深さは遠景の地面と独立に息をするので、文字の浮き沈みの下で背景もそっと動きます。

    // ビネット18% — ノイズ盤の上に暗い縁を重ねる(場面の完成)
    var vg = ctx.createLinearGradient(0, 0, 0, H);
    vg.addColorStop(0, 'rgba(12, 10, 18, ' + (VIG * 0.9).toFixed(3) + ')');
    vg.addColorStop(0.28, 'rgba(12, 10, 18, 0)');
    vg.addColorStop(0.72, 'rgba(12, 10, 18, 0)');
    vg.addColorStop(1, 'rgba(12, 10, 18, ' + (VIG * 0.9).toFixed(3) + ')');
    ctx.fillStyle = vg;
    ctx.fillRect(0, 0, W, H);

なぜ平らな模様から立体が浮かぶのか — 両眼視差と深さマップ

マジックアイの正体は、同じ粒を両目に違う横位置で見せることです。ステレオグラムとは、この行ごとの複製シフトで手前の粒を詰め、遠くの粒はそのままに並べ、平行に緩めた両目が粒の対応を立体として融合する仕組みを指します。

視差は水平だけ — 行ごとの一方向複製

ステレオグラムの視差は水平方向だけです。だからこそ各行は左から右へ独立になぞり、深さのある画素だけを左から引き込んで複製します。縦のシフトが1pxでも混ざると粒の対応が崩れ、目が合わなくなります。

深さマップ — 0..1 に正規化して同じシフトへ

9つの盤で源は違います(息する半球・文字の明るさ・写真のラスタ・サインの和・ポインタの跡)。共通する規則は 0..1 への正規化と「同じ深さには同じシフト」だけで、この1本のパイプラインに差し込む源を入れ替えるだけで9つの場面ができます。距離場をレイで歩いて球面を探すレイマーチングの9盤が距離を奥行きに使うのに対し、この回は明るさと数式を横シフトに使います。

3つの変形のひとつがそのままreduced-motionのアクセシビリティ対応になる

名前 変更する値 変わる感触
隆起の深さ 01 MAXSHIFT 10px → 22px 球が目の前まで飛び出し、見方を知らない人にも見える。上げすぎると模様が破けて二重に見える
パターン幅 02 TILE 100px → 140px 幅が広いほど目をより遠くまで緩めなければならず、違和感が増す。狭めれば逆にとても容易になる
波の速度 08 CYCLE 2秒 → 1秒 眠るように息していた表面が脈打つように躍り、立体の輪郭の変わる速さが倍になる

定数ブロックの1行だけを書き換える

どれもソース頭の定数ブロックの1行だけです。深さは飛び出し方・幅は目の寄せる距離・周期は速さと、つまみの性格がそれぞれ違います。

reduce-motion で止まっても立体は残る

九つの盤すべてに prefers-reduced-motion: reduce の分岐があり、ループを回さず各盤の代表場面を1枚描いて .is-still クラスを付けます。静止していても深さの編み目は残るので、動きを減らした訪問者にも結論の場面が届きます。

手が届く2枚と自動の7枚

05はホイールと横ドラッグ(0.6倍・1.2倍)、07は左ドラッグで引き上げ・右ドラッグで押し込みを受け付けます。ほかの7つは手を触れなくても完結し、盤の下の行には深さpx・周期秒の計測メタが常に表示されています。

MAXSHIFTを18px以上に上げると、そこで壊れる落とし穴

粒を毎フレーム振り直す

両目がパターンを覚えてこそ立体は浮かびます。下地のハッシュが時間を受け取るとフレームごとに粒が入れ替わり、目が対応を追えなくなって平面に戻ります。baseIdx が座標だけを受け取るのはこのためです。

MAXSHIFT を18px以上に上げる

10〜16pxが安定域で、02の階段も4〜16pxに収まっています。それを超えると隣の粒との対応が崩れて模様が破け、二重に見えます。18px以上では隣の粒がもう1つの対応候補になり、22pxは01の変形としてのみ試す値です。

細い文字を隠す

オフスクリーンで文字の肉幅が1〜2pxまで細くなると、シフトの後に形が溶けます。太くて短い単語('SOLID'・'GODRICH')がよく浮かぶのはこのためです。

zip の中には、九つの盤のフォルダーにそれぞれ index.html と style.css が収まっています。展開のパスワードは9kzb6vjjです。記事に引いた関数はコメントごと収まっているので、手元で行を突き合わせて確かめられます。

この画面はどこで使う — ブランドのヒーロー部と商品の背景

09 は繰り返し粒の中にワードマークを隠す場面なので、ランディングの最初の画面にそのまま置けます。文字は目が寄るまで見えないので、説明の一文を添えなくても訪問者の視線を数秒引き止められます。

04 は写真の明るさを立体の高さに置き換えるので、商品ページや紹介ページの背景テクスチャに向きます。05 はスクロールした分だけ山脈が現れる作りなので、スクロールテリングの途中の切り替え区間に差し込みやすい形です。

3つとも入れ替えるのは深さマップのもとになる画像だけで、粒の下地とシフトのループはそのままで構いません。

マジックアイを試す前によくある質問 FAQ

「ステレオグラム 作り方」を検索しています。コードを貼るだけで自分のページに置けますか?

置けます。zip から好きな盤1つのフォルダーを取り出し、index.html と style.css をそのままアップロードすれば動きます。ライブラリー・WebGL・CDN・外部画像を一切使っていないので、読み込みの設定は要りません。

どうしても立体が見えない人はどうすればよいですか?

06のガイド盤から始めてください。上部の2点が重なるまで目をゆっくり緩める練習をし、周期の後半1秒に点灯するグレースケールの解答マップで正解の形を確認できます。慣れてくれば、01の球も同じ距離感で浮かびます。

ノイズの粒は毎フレーム変わるのですか?

変わりません。下地の baseIdx は座標だけを受け取る決定論的ハッシュで、同じ位置には常に同じ粒が返ります。変わるのは深さマップのほうで、深さが変わると複製距離が変わり、粒の並びが編み直されます。

もっと読む

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

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