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

万華鏡 アプリ — 鏡の角度で描く対称お絵かき9種

1本の線を描くと反射行列が残りの扇形をすべて埋めます。鏡の角度・対称次数・色相サイクルを自分で変える万華鏡描画ツール — ライブラリなしのキャンバス1枚、コードはパスワード付きzipでお渡しします。

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

デモ

項目 名前 累積面積% 強度平均 動いたフレーム
01 基本の万華鏡ブラシ 5.295 125.5 23/24
02 対称次数スライダー 0.975 129.1 23/24
03 色相サイクルブラシ 0.556 148.4 23/24
04 極座標スタンプ 19.003 80.6 22/24
05 写真を流し込む帯ミラー 13.488 66.9 23/24
06 6つ折り雪の結晶 0.714 122.9 23/24
07 ランゴリグリッド 2.192 116.7 23/24
08 録画と再生 5.417 147.0 23/24
09 反復タイルの壁紙 25.892 26.3 23/24

お絵かきツールという性質上、02・03・06・07の累積面積が0.556〜2.192%と狭いのは線描工具の構造的な特性です。5px前後の細いブラシで対称模様を1本ずつ積み上げるツールは面を塗らないため、フレームごとの変化は本質的に狭くなります。今回の判定軸は強度と動きで、4項目とも強度平均116.7〜148.4と下限の15に対して十分な余裕があります。逆に面を塗る軸は広く、04の極座標スタンプが19.003%、05の写真の帯が13.488%、09の反復タイルが25.892%です。

9ツールの段階別コード

9つの順序は依存関係です。01のmirroredSegがすべての土台で、02がその角度を外から変え、03が色を、04と07が位置のスナップを、05が絵の入力を、06が境界の切り取りを、08が時間の記録を、09が結果の持ち出しを担います。この記事のコードフェンスはdemo/の実ファイルからそのまま抜き出したもので、万華鏡の完全なコードはzipにはvanilla 9種・React 9種が入っています。

対称がなぜ描いた瞬間の計算なのか — 反射行列

万華鏡の心臓部は、鏡を並べることではなく2×2の反射行列を線分1本に掛けることにあります。k番目の角度 a = k·2π/n のコサイン c・サイン s に対して setTransform(c, s, s, -c, tx, ty) の6引数を1回呼ぶと、回転とy軸反転が同時にかかった座標系の中で同じパスが打てます。この行列 [[c, s], [s, −c]] の行列式は −1 で、純粋な回転行列 [[c, −s], [s, c]] とは鏡像の有無が違います。回転だけでn回打つと隣り合う扇形の境目が噛み合わず、本物の万華鏡の折りにはなりません。k を 0 から n−1 まで回せば反射の対まで含めて1本が2n本に増え、これを描いた瞬間ごとに計算するからこそ、次数を変えれば模様が組み直されます。

行列式が −1 であることが鏡の証明

回転だけの行列は行列式が +1 で、紙を回転盤に載せて回すのと同じです。行列式が −1 の行列は向きを裏返すので、打ち直された線は元の線の鏡写しになります。隣の扇形と継ぎ目なく噛み合うのはこの裏返しのおかげです。

打った後に単位行列へ戻す1行

反射座標系の中で次の描画を始めると画面全体がねじれます。2n個のループが終わったら setTransform(dpr, 0, 0, dpr, 0, 0) で戻すのが、9種すべてが守る作法です。

9枚の盤は実際どんな画面で使うか

写真加工アプリのプレビュー画面には05の写真流し込みが向いています — アップロードした画像をそのまま鏡に流すので、ユーザーが自分の写真を万華鏡にした結果をその場で見られます。結婚式や季節行事の招待状サイトの装飾には07のランゴリグリッドが合います — 点と曲線だけで織る花輪は写真を使わないぶん軽く、招待状の余白に敷いても本文を邪魔しません。子ども向け学習アプリの操作体験には02の対称次数スライダーが向いています — スライダーを動かすと模様がその場で組み直る様子は、数を変えると形が変わるという算数の感覚をそのまま画面にします。

01基本の万華鏡ブラシ — 1本の線が2n本に増える反射

pointermoveで受けた線分1本を、k·2π/n の反射行列で n か所に打ちます。反射の対まで含めて2n=16枝になり、手を離すと1.6秒後に自動デモが再開します。描き心地の基準になる盤で、累積面積5.295%・面積最大0.699%・強度平均125.5・動いたフレーム23/24です。

基本の万華鏡ブラシ — 1本の線が2n本に増える反射
新しいタブで開く

01 · 基本の万華鏡ブラシ — 1本の線が2n本に増える反射 — 計測 累積面積 5.295% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

setTransformlineTo

線分1本を2n位置に打つmirroredSeg

6引数のsetTransformが回転×y反転を1回で担い、ループの外で単位行列に戻します。

  // 反射行列ストローク — 線分1本を2N位置に打つ。setTransform の6引数は
  // (a, b, c, d, e, f) = 行列 2×2 + 平行移動。ここで掛ける行列が回転×反射になる
  function mirroredSeg(x0, y0, x1, y1, ci) {
    for (var k = 0; k < N; k++) {
      var a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
      ctx.setTransform(dpr * c, dpr * s, dpr * s, -dpr * c, cx * dpr, cy * dpr);
      ctx.strokeStyle = COLS[ci % 3];
      ctx.lineWidth = 6;
      ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

乱数を使わないブラシの経路pathAt

自動デモの3本のブラシは位相を 2π/3 ずつずらした同じパラメータ曲線を回るので、デモは毎回まったく同じ模様を再現します。

  // シード固定の自動ストローク — 3本のブラシが位相をずらして一緒に回る
  function pathAt(t, ph) {
    var ang = t * 1.55 + ph + 0.9 * Math.sin(t * 0.31 + ph);
    var r = R * (0.30 + 0.34 * Math.sin(t * 0.47 + ph * 2) + 0.20 * Math.sin(t * 0.13 + ph));
    if (r < R * 0.07) r = R * 0.07;
    return [cx + r * Math.cos(ang), cy + r * Math.sin(ang)];
  }

02対称次数スライダー — nを変えると模様が組み直される

次数nを3から24までのスライダーで変えると、扇形の角度 360/n 度が狭まり、同じ線がより密な花に組み直されます。描いた線分の配列を保存してあるので、changeイベントで指を離した後に全パスを新しい角度で1回だけ再計算します。累積面積0.975%・強度平均129.1・動いたフレーム23/24です。

対称次数スライダー — nを変えると模様が組み直される
新しいタブで開く

02 · 対称次数スライダー — nを変えると模様が組み直される — 計測 累積面積 0.975% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

setTransformchange

保存した線分の配列を全 angle で打ち直す

ビットマップの積み残しをそのまま角度だけ回すと模様がずれます。clearRectからの再描画が正しい組み直しです。

  function drawSeg(s) {
    for (var k = 0; k < N; k++) {
      var a = k * 2 * Math.PI / N, c = Math.cos(a), sn = Math.sin(a);
      ctx.setTransform(dpr * c, dpr * sn, dpr * sn, -dpr * c, cx * dpr, cy * dpr);
      ctx.strokeStyle = s[4] ? C2 : C1;
      ctx.lineWidth = 5;
      ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(s[0], s[1]); ctx.lineTo(s[2], s[3]); ctx.stroke();
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  function redraw() {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, W, H);
    for (var i = 0; i < strokes.length; i++) drawSeg(strokes[i]);
  }

inputではなくchangeで1回だけ再計算する

ドラッグ中に毎フレーム再計算すると画面が揺れます。指を離した瞬間の1回に抑えるのがスライダーの作法です。

  // change イベント — 手を離した後に全体を再計算。input ではなく change を使うと
  // スライダーを引っ張っている間は模様が揺れず、離した瞬間に1回で組み直る
  order.addEventListener('change', function () { setN(parseInt(order.value, 10) || 6); });

03色相サイクルブラシ — 描いた距離で色が巡る

進んだ経路長を累積して hsl(pathLen·0.35, 34%, 74%) で線の色を決めると、線が伸びるほど色相軸が虹に巡ります。彩度34%・明度74%のパステル域に抑えてあるので、万華鏡特有の強い色が柔らかく収まります。累積面積0.556%・強度平均148.4・動いたフレーム23/24で、9種の中で最も明るい部類です。

色相サイクルブラシ — 描いた距離で色が巡る
新しいタブで開く

03 · 色相サイクルブラシ — 描いた距離で色が巡る — 計測 累積面積 0.556% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

hsllineTo

経路長から色相角への1行の変換

距離の累積に0.35を掛けて360で割った余りがhue角になる、色を距離に縛るただの1行です。

  function brushColor() {
    var h = (pathLen * 0.35) % 360;
    return 'hsl(' + h.toFixed(0) + ', 34%, 74%)';
  }

残像減衰は半透明の上塗りで作る

毎フレームキャンバス全体を8%の舞台色で覆うと、古い筆致が尾を引いて流れます。等価減衰0.92の効果をglobalAlphaの上塗りで実現します。

  function fade() {
    // 残像の減衰 — alpha 0.92 ではなく、毎フレーム8%ずつ舞台色を重ねて古いストロークを溶かす
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.globalAlpha = 0.08;
    ctx.fillStyle = STAGE;
    ctx.fillRect(0, 0, W, H);
    ctx.globalAlpha = 1;
  }

04極座標スタンプ — 半径・角度グリッドに吸着するスタンプ

カーソル位置を半径と角度に変えて最も近い格子点に吸着させてスタンプを打つと、ランゴリやドットマンダラのできあがりです。リング1〜4・スポーク0〜11の格子へ丸め、スタンプ1個がスポーク全体に反射複製されます。自動デモは0.12秒ごとに格子点1つ、3周の144個で消してやり直します。累積面積19.003%・面積最大5.593%・強度平均80.6・動いたフレーム22/24です。

極座標スタンプ — 半径・角度グリッドに吸着するスタンプ
新しいタブで開く

04 · 極座標スタンプ — 半径・角度グリッドに吸着するスタンプ — 計測 累積面積 19.003% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

arclineTo

極座標をリング番号とスポーク番号に丸めるsnap

半径はリング幅に、角度は 2π/n に丸めて座標へ戻す写像が、落書きをグリッドの装飾に変えます。

  // スナップ — 極座標をリング・スポーク番号に丸めた後、座標に戻す
  function snap(px, py) {
    var dx = px - cx, dy = py - cy;
    var r = Math.min(RINGS, Math.max(1, Math.round(Math.hypot(dx, dy) / (R / RINGS))));
    var k = Math.round(Math.atan2(dy, dx) / (2 * Math.PI / N));
    k = ((k % N) + N) % N;
    var a = k * 2 * Math.PI / N;
    return [cx + r * (R / RINGS) * Math.cos(a), cy + r * (R / RINGS) * Math.sin(a), r, k];
  }

3つのスタンプの形は円と直線の配列で定義する

花びら5つ・8角の星・点3つを選べます。形は画像ではなくarcとlineToの組み合わせなので、色はトークンパレットから自由に差し替えられます。

      // 花びら5枚 — 5つの円が1点を共有する
      for (var p = 0; p < 5; p++) {
        var a = p * 2 * Math.PI / 5;
        ctx.beginPath();
        ctx.arc(s * 0.62 * Math.cos(a), s * 0.62 * Math.sin(a), s * 0.42, 0, 2 * Math.PI);
        ctx.fill();
      }

05写真を流し込む — 画像断片を鏡に送る

ソースキャンバスにパラメータ曲線46本で織った絹の目を描いておき、その細い帯1本をk番目の扇形にdrawImageで巻きます。偶数扇形はそのまま、奇数扇形はflip=−1で左右に反転して隣同士が噛み合い、帯の位置 p は 0.5+0.48·sin(0.21t) でゆっくり前後に流れます。本物の万華鏡のように風景が模様になる盤で、ドラッグで直接動かせます。累積面積13.488%・面積最大9.228%・強度平均66.9・動いたフレーム23/24です。

写真を流し込む — 画像断片を鏡に送る
新しいタブで開く

05 · 写真を流し込む — 画像断片を鏡に送る — 計測 累積面積 13.488% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

drawImagesetTransform

帯を扇形に巻くdrawImageの1行

帯幅は SW/n で、ソースのx位置をkごとに少しずつずらすことで扇形ごとに違う断片が見えます。

  // 断片の配置 — k番目の扇形に帯1本。帯の位置 p(0〜1) は k と時間で少しずつ変える
  function render(p) {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.fillStyle = STAGE;
    ctx.fillRect(0, 0, W, H);
    var band = SW / N;                        // 各扇形が受け持つ帯の幅
    for (var k = 0; k < N; k++) {
      var a = k * 2 * Math.PI / N, c = Math.cos(a), s = Math.sin(a);
      // 扇形の裏返し(反射) — 同じ帯を左右に反転して隣同士を噛み合わせる
      var flip = (k % 2 === 0) ? 1 : -1;
      ctx.setTransform(dpr * c, dpr * s * flip, dpr * s, -dpr * c * flip, cx * dpr, cy * dpr);
      var sx = ((p * (SW - band) + k * band * 0.13) % SW + SW) % SW;
      // 帯を円錐に巻くように: 縦を半径に寝かせ、横を弧の方向に伸ばす
      ctx.drawImage(src, sx, 0, band, SH, -R * 0.18, -R, R * 0.36, R * 2);
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

066つ折り雪の結晶 — 紙を折らずに折る方法

扇形1つを60度に狭め、筆致の座標をfoldPointでその中に折り込んでから、k番目の折りの変換で6回打ちます。扇形クリッピングをかけるので境界を越えた線は切り取られたまま反射され、紙を6回折って切った雪の結晶と同じ規則になります。累積面積0.714%・強度平均122.9・動いたフレーム23/24です。

6つ折り雪の結晶 — 紙を折らずに折る方法
新しいタブで開く

06 · 6つ折り雪の結晶 — 紙を折らずに折る方法 — 計測 累積面積 0.714% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

setTransformlineTo

どの角度で描いても第一の折りに写像するfoldPoint

角度を [A0, A0+WEDGE] に折り込む写像があるから、画面のどこを描いても雪の結晶の1スパンに収まります。

  // 扇形の中へ座標を折り込む描画座標 — ユーザーがどこを描いても最初の折りに写像する
  function foldPoint(px, py) {
    var dx = px - cx, dy = py - cy;
    var a = Math.atan2(dy, dx);
    var r = Math.min(R, Math.hypot(dx, dy));
    // 角度を [A0, A0+WEDGE] に折り込む — 六角対称なのでどの角度もこの扇形に写せる
    var span = 2 * Math.PI / N;
    var rel = ((a - A0) % (2 * span) + 2 * span) % (2 * span);
    if (rel > span) rel = 2 * span - rel;         // 広い側なら反射して折り畳む
    var a2 = A0 + rel;
    return [cx + r * Math.cos(a2), cy + r * Math.sin(a2)];
  }

beginPathとclipがはさみの役割を担う

境界の外に出た線は切り取られたまま6回反射されます。折り紙の雪の結晶の切断規則をそのままコードにした形です。

  // 反射複製 — 線分を6つ折りに打つ。扇形のクリッピングは beginPath-clip で
  function mirroredSeg(x0, y0, x1, y1, ci) {
    for (var k = 0; k < N; k++) {
      var a = A0 + k * WEDGE, c = Math.cos(a), s = Math.sin(a);
      ctx.setTransform(dpr * c, dpr * s, dpr * s, -dpr * c, cx * dpr, cy * dpr);
      // 扇形(0 <= 角 <= WEDGE、ローカル座標)のクリッピング — 境界の外のストロークが切れる規則
      ctx.save();
      ctx.beginPath();
      ctx.moveTo(0, 0);
      ctx.arc(0, 0, R + 2, 0, WEDGE);
      ctx.closePath();
      ctx.clip();
      ctx.strokeStyle = ci ? C2 : C1;
      ctx.lineWidth = 4.5;
      ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(x0 - cx, y0 - cy); ctx.lineTo(x1 - cx, y1 - cy); ctx.stroke();
      ctx.restore();
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

07ランゴリグリッド — 点から織る対称の花輪

極座標グリッド(リング4・スポーク16)の上に点を打つと、対称全体に同じ点が巡る配置が計算されます。自動デモは0.045秒ごとに点を1つ打って64個を埋め、続いて0.06秒ごとに隣接する点を結ぶ曲線48本を重ね、1周終わると2秒休んで次の模様へ移ります。累積面積2.192%・強度平均116.7・動いたフレーム23/24です。

ランゴリグリッド — 点から織る対称の花輪
新しいタブで開く

07 · ランゴリグリッド — 点から織る対称の花輪 — 計測 累積面積 2.192% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

arcchange

リング番号とスポーク番号から座標を出すpt

点の位置はリングとスポークの2つの整数だけで決まります。ジグザグ模様は奇数リングを半スポークずらす1行で生まれます。

  function pt(ring, k) {
    var off = (pattern === 1 && ring % 2 === 1) ? 0.5 : 0;   // ジグザグはリングごとに半スポークずらす
    var r = R * (ring + 1) / (RINGS + 1);
    var a = (k + off) * 2 * Math.PI / N;
    return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
  }

点を結ぶ曲線の速さも秒で積分する

点64個を0.045秒ごと、曲線48本を0.06秒ごとという速度は、フレーム数ではなく経過時間から割り出されます。

    if (!phase2) {
      var want = Math.min(total, Math.floor(t / 0.045));  // 点を打つ速度
      while (fired < want) { placeDot(fired); fired += 1; }
      dots.textContent = String(fired);
      if (fired >= total) phase2 = true;
    } else {
      var want2 = Math.floor((t - total * 0.045) / 0.06);  // 曲線を結ぶ速度
      while (linked < Math.min(want2, (RINGS - 1) * N)) { placeLink(linked); linked += 1; }
    }

08録画と再生 — 描いた過程を巻き戻すループ

座標配列を [u, x, y] の形で毎フレーム積んでおくと、消した後に同じ速度で順番どおり再描画できます。ブラシの経路は終点が始点につながるループの螺旋なので再生は途切れず、ループごとに色が交互に変わります。緑の点カーソルが再生位置を追い、録画1周は3.0秒です。累積面積5.417%・強度平均147.0・動いたフレーム23/24です。

録画と再生 — 描いた過程を巻き戻すループ
新しいタブで開く

08 · 録画と再生 — 描いた過程を巻き戻すループ — 計測 累積面積 5.417% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramesetTransformarc(

記録は進行度と座標の3つ組で積む

u は 0〜1 のループ進行度で、これがあるから再生側で速度を自由に変えられます。

  // 録画 — ブラシが通った場所だけ即座に反射して打たれる(残像の蓄積)
  function recStep(du) {
    recU += du;
    var u = Math.min(1, recU / REC_DUR);
    var prev = record.length ? [record[record.length - 1][1], record[record.length - 1][2]] : null;
    var p = pathAt(u);
    record.push([u, p[0], p[1]]);
    pts.textContent = String(record.length);
    if (prev) mirroredSeg(prev[0], prev[1], p[0], p[1], C1);
    fill.style.width = String(Math.round(u * 100)) + '%';
    if (u >= 1) { phase = 1; playU = 0; playPrev = null; clear(); }
  }

再生は近傍2点の線形補間で滑らかに

記録点の間を補間して打つので、録画時より速くても遅くても線は途切れません。

    // 記録の補間 — u に最も近い2点
    var i = Math.min(record.length - 1, Math.floor(u * (record.length - 1)));
    var a = record[i], b = record[Math.min(record.length - 1, i + 1)];
    var f = (u * (record.length - 1)) - i;
    var x = a[1] + (b[1] - a[1]) * f, y = a[2] + (b[2] - a[2]) * f;
    if (playPrev) mirroredSeg(playPrev[0], playPrev[1], x, y, col);

09壁紙エクスポート — 描いた模様を反復タイルに

四分円キャンバスにパラメータ曲線の目7本を描き、組立キャンバスに0度・90度・180度・270度の回転setTransformで4回配置すると、4隅がつながる反復タイルになります。完成タイルはtoDataURL('image/png')で書き出してプレビュー背景に埋め込むので、描いた結果がそのまま使える壁紙です。3×3のタイルで画面全体に模様が敷かれるため累積面積25.892%と9種で最も広く、薄い舞台色の上を淡い曲線が流れるため、強度平均26.3と最も暗いです。動いたフレームは23/24です。

壁紙エクスポート — 描いた模様を反復タイルに
新しいタブで開く

09 · 壁紙エクスポート — 描いた模様を反復タイルに — 計測 累積面積 25.892% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

toDataURLfillRect

4分冊の回転配置が継ぎ目のないタイルを作る

四分円を4つの回転行列で配置するだけで、反復の境目で曲線がつながります。回転を反射に変えるとまた別の継ぎ方になります。

  // 4分割の回転配置 — 左上(0度)・右上(90)・右下(180)・左下(270)。変換行列がそのまま回転
  function assembleTile() {
    var qd = q.width;                  // 四分円の device px — 正方形
    tc.setTransform(1, 0, 0, 1, 0, 0);
    tc.clearRect(0, 0, tw.width, tw.height);
    tc.drawImage(q, 0, 0);                                    // 左上 0度
    tc.setTransform(0, 1, -1, 0, qd * 2, 0); tc.drawImage(q, 0, 0);   // 右上 90度
    tc.setTransform(-1, 0, 0, -1, qd * 2, qd * 2); tc.drawImage(q, 0, 0); // 右下 180度
    tc.setTransform(0, -1, 1, 0, 0, qd * 2); tc.drawImage(q, 0, 0);   // 左下 270度
    tc.setTransform(1, 0, 0, 1, 0, 0);
  }

toDataURLで書き出して背景に実際に敷く

書き出した1枚をdata URLのままプレビューのbackgroundImageに敷くので、エクスポート結果をその場で確認できます。

    // エクスポート — 完成タイルをデータURLに組み立ててプレビュー背景に埋め込む
    var url = tw.toDataURL('image/png');
    tile.style.backgroundImage = 'url(' + url + ')';

対称次数を変えると模様が崩れ再構築される理由

次数nを倍にすると、角度の刻み 2π/n が半分になり、同じ筆致が二重の密度で打たれます。画面に残っている古いビットマップは古い角度のままなので、ここで再計算を挟まないと新旧の扇形の境目がずれて模様が壊れたように見えます。崩れて再構築される一瞬は、万華鏡の計算が見える瞬間です。

n=8からn=16に上げると帯の幅が半分になる

同じブラシの幅は変わらないのに、1つの扇形が受け持つ範囲が半分になるので、余白が減って密な花になります。逆にn=3では広い扇形に線が散って、隙間の大きい模様になります。

崩れたように見える瞬間は古い角度とのずれ

スライダーを動かした直後に一見乱れた画面が出るのは、積もったビットマップが前のnで打たれたものだからです。配列から全パスを打ち直せば、新しい角度で整列した模様に組み直ります。

y反転を抜くと万華鏡が扇形パッチワークになる落とし穴

1枝だけ描いてCSS transformで複製したように見せかける

複製された写しは角度や次数が変わった瞬間にずれます。setTransformの行列で毎回実際に2n回打ってはじめて、次数変更・スナップ・再計算が生きます。

反射を純粋な回転行列で打つ

回転だけでは鏡像がなく、隣り合う扇形が噛み合いません。行列式が −1 の回転×y反転でなければ本当の万華鏡の折りではないのです。

打った後にsetTransformを単位行列に戻さない

次の描画が反射座標系の中で始まり、画面全体がねじれます。1つの線分の2nループが終わったら (dpr, 0, 0, dpr, 0, 0) に戻すのが9種共通の作法です。

スライダーをinputイベントで毎フレーム再計算する

ドラッグ中に画面が揺れます。changeで指を離した後に1回だけ再計算するのが正しい受け方です。9種の完全なコード(vanilla 9種・React 9種)はパスワード付きzipに入っていて、mqv4cpdtを入力すると解凍できます。

値1つで変わる3つのバリエーションとreduced-motionアクセシビリティ

バリエーション 変える値 効果
対称次数 02の次数 8 → 16 次数が2倍になると同じ筆致が華やかになる代わりに1つの帯の幅が半分になり、余白の美しさが消える
ブラシの揺らぎ 01のジッター 0px → 3px 手の震えを重ねると幾何学模様に手描きの温かみが宿るが、過ぎると反射対称がぼやけて万華鏡がスケッチになる
線の減衰 03の残像 1.0 → 0.92 毎フレームごく薄く消すと筆致が尾を引いて流れ、減衰を1.0に置けばすべての瞬間がそのまま積もる

対称次数を変える

02のスライダーを8から16に動かすだけで、同じ線分の配列が二重の密度で組み直されます。コードはsetNの1呼び出しで済みます。

ブラシの揺らぎを足す

01の描画点に半径3pxの揺らぎを足すと、鏡面の模様に手の温かみが入ります。揺らぎは反射の前に入れるので、2n枝すべてが同じ揺れ方で増えます。

線の減衰を変える

03のfadeの上塗り率0.08を0にすれば減衰1.0と同じで、すべての瞬間がそのまま積もります。0.92の残像を保てば筆致が尾を引いて流れます。

prefers-reduced-motionでは完成した模様を1枚見せる

OSの「視差効果を減らす」設定が有効なユーザーにはループを止め、時間を先に進めて完成した模様1枚を固定して見せます。動きを減らす要求は情報を消せという意味ではないので、空のキャンバスには戻しません。各盤には分かりやすいaria-labelを与え、ポインター入力はsetPointerCaptureでキャンバスの外に出ても失われないようtry/catchで囲んでいます。

9枚の盤を開く前によくあるFAQ

万華鏡 作り方 簡単というのは、この9種のどれが一番簡単ですか

01の基本ブラシです。mirroredSegの10行とpointermoveの受けだけが本体で、zipを展開すれば30行足らずで動きます。残り8種はすべてこの1関数の上に成り立っています。

万華鏡 アプリ 無料でこの盤は使えますか

このページの9つのデモはすべてブラウザの中でそのまま動きます。コード一式(vanilla 9種・React 9種)はパスワード付きzipで提供していて、ペーストすれば自分のページに同じ模様が立ち上がります。

デモが毎回同じ模様を描くのはなぜですか

自動デモに乱数を一切使っていないためです。ブラシの経路は位相をずらしたパラメータ曲線pathAtで定義してあり、毎回同じ模様が再現されます。人が描いている間は自動デモが止まり、手を離すと1.6秒後に再開します。

もっと読む

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

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