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

ジェネラティブアート 作り方 9種 — 育つ模様をコードで

シードの数がひとつ同じなら、何度描き直しても同じ絵になります。02番は散らばった引き寄せ点へ葉脈を伸ばし、03番は線が伸びるたびに節を足してしわを折り、06番は隣のマスと辺が合うタイルだけを残します。

ひとつのシードから九枚の絵が育っていくキャンバスをそのまま眺める

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

各マスは空の盤で200ms休んでから育ち始め、育ち終えると900ms(09は1,400ms)とどまって次のシードへ移ります。キャンバスやボタンをポインターで押すか、キャンバスでEnter・R・Spaceのキーを押すと、01〜08は今のシードが最後まで育った所で止まり、09だけは手を触れても次のシードへ続けて進みます。

ジェネラティブアート 作り方 9種 — 流れ線・葉脈・しわの線・円詰め・直角のひび・タイル合わせ・年輪・苔の広がり・育つヒーロー

01〜03は線が育つ規則を扱います。ほかの線との間隔が線を止め(01)、引き寄せ点が枝の向きを決め(02)、増えていく節が線を折りたたみます(03)。04〜06は面を埋める規則で、膨らむ円・直角に分かれるひび・辺を合わせるタイルが並び、07・08は年輪と苔のように時間が層になって積もる自然物、09は01・02・04の計算をひとつのシードで重ねたトップ画面向けの場面です。九枚とも480×300の座標を持つ<canvas>の2Dに描かれ、動きはrequestAnimationFrameの時刻をためて16msの固定ティックを1フレームに最大6回進めるループが受け持ちます。スタイルシートの@keyframesは0個で、transitionはボタンの背景色ひとつだけです。

九つの場面がレンダーのキャプチャで動いた量

ヘッドレスブラウザで2秒間に24枚を撮り、隣り合う2枚のあいだに起きた変化23回のうち、実際に画素が変わった回数を「動いたフレーム」に記しました。

場面 累積面積 動いたフレーム 強度の平均
01 間隔を守る流れ線 28.0% 17/23 97.0
02 引き寄せ点の葉脈 9.8% 16/23 60.2
03 節を挟むしわの線 15.5% 23/23 81.9
04 膨らむ円詰め 44.2% 23/23 92.8
05 直角のひび 42.4% 17/23 56.6
06 辺を合わせるタイル 50.6% 23/23 83.0
07 年輪 35.8% 18/23 138.6
08 縁から広がる苔 75.5% 21/23 112.3
09 三層のヒーロー 39.0% 23/23 73.4

10%を下回ったのは02だけです。2.6pxの細い節が葉の形の中だけで、しかもティックごとに一度(ITER_PER_FRAMEが1)しか伸びないので、変わる画素が少ないと考えられます。08が75.5%で一番広いのは、4pxのマスをティックあたり60マスずつ盤全体の9,000マスに塗っていくためと読めます。05は100ティック、つまり1,600msで育ち終わり、とどまる区間がキャプチャに入るため17/23にとどまったと考えられます。

育つ絵を敷きやすい画面

ランディングページの最初の画面の背景なら09をそのまま使えばよく、文字の多い画面の後ろには筋のそろった01のほうが目立ちすぎません。同じシードは同じ絵を出すので、記事番号や商品番号をシードに入れれば、ページごとに違っていて開き直しても変わらない見出し画像を、画像ファイルなしで作れます。

ジェネラティブアート 作品を額に入れて眺めるのとは違い、待ち時間の画面には進み具合が目に見える場面が向いています。08は群れがにじむように盤を埋め、06は霧が晴れるようにマスが決まって、ときどき一度崩れてから埋め直されます。植物や環境に配慮したブランドなら02の葉脈と07の年輪、紙や石膏のような質感がほしいポートフォリオや404画面なら05のひび、イベントのバナーのようにすき間なく飾りたいときは04の円が合います。

ジェネラティブアート コードでは、偶然にどこまで任せて、どこから規則で押さえるのか?

乱数の点を画面に均一にまくだけでは、どこを見ても密度が同じなので、目で追える筋やかたまりが生まれません。この編の九枚で乱数が受け持つのは、出発点を選ぶことと候補の中からひとつをつまむことまでで、形を決めているのはいつ止まるかを書いた規則のほうです。01の線はほかの線の点が4px以内に入ると止まり、04の円は隣とのすき間が2pxまで縮むと固まり、05のひびは角度の違うひびに触れた瞬間に終わります。

規則が形を作っていることは、捨てられた側を見るとよくわかります。04は場所の抽選30,588回のうち29,021回(94.9%)を捨て、残った場所にだけ円を植えました。01では育ち終えた絵の中で、違う線どうしの一番近い距離がちょうど4.00px、つまりLINE_SEPARATIONの8にSEP_TESTの0.5をかけた値でした。乱数がどれだけ揺さぶっても、規則が決めた距離より下には一点も入らなかったということです。

ですからジェネラティブデザインを始めるときに最初に決めるのは、どの乱数を使うかより、何が成長を止めるかです。九枚の止まる条件は距離(01・04)、消えていく引き寄せ点(02)、節の数の上限(03)、違う角度(05)、決めるマスが残らないこと(06)、半径の上限(07)、空きマスが残らないこと(08)とそれぞれ違い、その違いがそのまま絵の違いになります。

同じシードが同じ絵を出す疑似乱数と、育つ線が隣をすばやく探すマス目

九つのソースのどこにもMath.random・Date.now・new Dateはありません。乱数はすべて下のmakeRngひとつから出てきます。32ビットの整数をMath.imulと>>>で混ぜて0以上1未満の数を返す列なので、同じシードを入れれば同じ順番の数がもう一度出てきます。

    // シード固定の乱数 — 同じシードなら同じ数列、つまり同じ絵がもう一度出る(32ビット整数の混ぜ合わせ)
    function makeRng(seedValue) {
        let s = seedValue >>> 0;
        return function () {
            s = (s + 0x6d2b79f5) >>> 0;
            let t = s;
            t = Math.imul(t ^ (t >>> 15), t | 1);
            t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
            return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
        };
    }
    // 次のシード — 描き直すを押すたびに整数ひとつだけが変わる(10007〜99995 の5桁)
    function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }

「描き直す」ボタンとEnterはnextSeedで次の5桁のシードを作り、「同じシード」ボタンとRキーは今のシードで盤を最初から育て直します。01を47101 → 13161 → 47101の順に育てて点の座標全体のハッシュを取ると、1433911356 → 239204140 → 1433911356となり、同じシードは同じ絵を、違うシードは違う絵を出すことが数字で確かめられます。成長はフレームではなく16msのティックで区切られ、ティックごとにする仕事の量(01のSTEPS_PER_FRAMEが70、06のCOLLAPSE_PER_FRAMEが2など)が定数で決まっているので、リフレッシュレートの違う画面でも同じ順番で育つと考えられます。

隣を探す仕事は格子のバケットが受け持ちます。画面を決まった大きさのマス(01は8px、02は60px、03は12px、04は34px)に分けて点をそれぞれのマスに入れておき、距離の検査はまわりのマスに入った点どうしだけで行います。01は育ち終えたとき点が8,386個あるので、一歩ごとにすべての点と比べていたら、一歩で8千回を超える計算をしていたはずです。

01ほかの線と4px以内に近づかない、間隔を守る流れ線

12pxのマスの頂点ごとにノイズで向きを一度だけ決めておき、線は半歩先の向きを見て2pxずつ前後に歩き、ほかの線の点が4px以内に入ると止まります。終わった線の両脇8pxの位置が次の線の出発候補として列に並ぶので、画面がそろった間隔の筋で満ちていき、等高線や風の流れのような筋を敷きたい紹介ページの背景に合います。シード47101は135ティックで線155本・合計16,462pxまで育ち、キャプチャでは累積面積28.0%・強度97.0・17/23フレームでした。

間隔を保ちながら流れ場に沿って伸びる線
新しいタブで開く

01 · 間隔を保ちながら流れ場に沿って伸びる線 — 計測 累積面積 28.044% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLOW_NOISE_SCALELINE_SEPARATIONSTEP_LENGTH
    // (x, y) の半径 d の中にほかの点があるか — 同じ線のすぐ前後の点(間隔の2倍の長さ以内)は数えない
    function flowTooClose(f, x, y, d, line, ord) {
        const skip = Math.ceil((f.o.sep * 2) / f.o.step);
        const d2 = d * d;
        return bucketNear(f.grid, x, y, d, function (id) {
            if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) {
                return false;
            }
            const dx = f.px[id] - x;
            const dy = f.py[id] - y;
            return dx * dx + dy * dy < d2;
        });
    }
            // 一歩 — 半歩先の向きへ歩く中点法(曲線の外へずれる誤差を減らす)
            b -= 1;
            const h = f.o.step * f.dir;
            const d1 = flowDir(f, f.x, f.y);
            const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
            const nx = f.x + d2[0] * h;
            const ny = f.y + d2[1] * h;
            const nord = f.ord + f.dir;
            if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
                // 前がふさがれば出発点に戻って反対側へ、反対側もふさがれば線ひとつ終わり
                if (f.dir === 1) {
                    f.dir = -1;
                    f.ord = 0;
                    f.x = f.sx;
                    f.y = f.sy;
                }
                else {
                    flowEndLine(f);
                }
                continue;
            }

02散らばった900個の引き寄せ点へ枝を伸ばす葉脈

葉の形の中に引き寄せ点900個をシード付きの乱数でまき、点ごとに半径60pxの中で一番近い節をひとつ選んで、その節に引き寄せ点たちの平均の向きへ2.6pxの新しい節を出させ、新しい節から6px以内に入った点は消します。枝の太さは節ごとに数えた子孫の数の平方根に従って幹から葉の縁へ向かうほど細くなり、育つ様子そのものが物語になる植物や環境系ブランドの紹介画面に向いています。シード47102は103ティックで引き寄せ点をすべて消して節794個・枝先71本で止まり、累積面積9.8%・強度60.2・16/23フレームと、九枚のうちただひとつ10%を下回りました。

散らばった引き寄せ点へ伸びていく葉脈
新しいタブで開く

02 · 散らばった引き寄せ点へ伸びていく葉脈 — 計測 累積面積 9.836% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ATTRACTOR_COUNTINFLUENCE_RADIUSKILL_DISTANCE
            for (let i = 0; i < n; i += 1) {
                if (!cnt[i]) {
                    continue;
                }
                let dx = sx[i];
                let dy = sy[i];
                let L = Math.hypot(dx, dy);
                // 引き寄せ点どうしが反対側から引っぱり合って平均がほぼ 0 なら、その節は今回は伸びない
                if (L < 0.2) {
                    continue;
                }
                // ごく小さな揺れ — 同じ場所に節が重なって積もらないよう対称を崩す
                dx = dx / L + (v.rng() - 0.5) * 0.12;
                dy = dy / L + (v.rng() - 0.5) * 0.12;
                L = Math.hypot(dx, dy) || 1;
                veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
            }
        }
        const leftBefore = v.left;
        veinKill(v, n);
        // 消える引き寄せ点がないまま VEIN_STALL_LIMIT 回続けて伸びるだけなら止める — 届かない点を追って回り続けないように
        v.stall = v.left === leftBefore ? v.stall + 1 : 0;
        if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) {
            v.done = true;
        }

03伸びるたびに節を挟み込んで折れていくしわの線

閉じた線の節はひとつひとつが両隣の真ん中へ引かれ、隣の節と4pxを保とうとするばねにつながれ、両隣以外の節が半径12px以内に来ると押し返します。5pxより長くなった辺とシード付きの乱数で選んだ辺にティックごとに節を挟むと、楕円の囲いの中で伸びる場所のない線が折れて珊瑚や脳のしわのような模様になり、有機的な質感がほしい科学や医療のコンテンツの見出し画像に使えます。シード47103は214ティックで節の上限2,600個に届いて周長は5,611pxになり、レンダーの値は累積面積15.5%・強度81.9・23/23フレームです。

節を挟み込みながらしわを寄せて育つ線
新しいタブで開く

03 · 節を挟み込みながらしわを寄せて育つ線 — 計測 累積面積 15.466% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

REPEL_RADIUSMAX_EDGE_LENGTHALIGN_FORCE
            for (let k = 0; k < 2; k += 1) {
                const j = nbs[k];
                const dx = xs[j] - x;
                const dy = ys[j] - y;
                const d = Math.hypot(dx, dy) || 1e-6;
                const pull = ((d - SPRING_REST) / d) * SPRING_FORCE * 0.5;
                fx += dx * pull;
                fy += dy * pull;
            }
            bucketNear(grid, x, y, R, function (j) {
                if (j === i || j === pv || j === nt) {
                    return false;
                }
                const dx = x - xs[j];
                const dy = y - ys[j];
                const d2 = dx * dx + dy * dy;
                if (d2 >= R * R || d2 < 1e-9) {
                    return false;
                }
                const d = Math.sqrt(d2);
                const push = ((R - d) / R) * REPEL_FORCE;
                fx += (dx / d) * push;
                fy += (dy / d) * push;
                return false;
            });
    // 長い辺の分割 — 始める位置をティックごとにずらしながら、MAX_EDGE_LENGTH より長い辺の真ん中に節を挟む
    function splitEdges() {
        let made = 0;
        const n0 = xs.length;
        let i = splitFrom % n0;
        for (let k = 0; k < n0 && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; k += 1) {
            const j = (i + 1) % xs.length;
            const dx = xs[j] - xs[i];
            const dy = ys[j] - ys[i];
            if (dx * dx + dy * dy > MAX_EDGE_LENGTH * MAX_EDGE_LENGTH) {
                // 真ん中からほんの少し横へ — 対称が崩れてはじめてしわが折れる
                const jit = (lrng() - 0.5) * 0.4;
                xs.splice(i + 1, 0, (xs[i] + xs[j]) * 0.5 - dy * jit * 0.1);
                ys.splice(i + 1, 0, (ys[i] + ys[j]) * 0.5 + dx * jit * 0.1);
                made += 1;
                i += 1;
            }
            i = (i + 1) % xs.length;
        }
        splitFrom = i + 37;

04すき間2pxに届くまで膨らむ円詰め

ティックごとにシード付きの乱数で場所を60か所抽選し、ほかの円や縁とのあいだに2pxのすき間が残る所にだけ半径1pxの円を植え、植えた円はティックごとに0.8pxずつ大きくして、場所がふさがった瞬間にその円だけを描画板に固めます。大きな円のすき間を小さな円が埋めていく過程がそのまま見えるので、イベントのポスターやキャンペーンのバナーのように画面をすき間なく埋める飾りに向いています。シード47104は565ティックのあいだに円1,567個で画面の46.1%を覆い、キャプチャの値は累積面積44.2%・強度92.8・23/23フレームでした。

触れ合うまで膨らむ円詰め
新しいタブで開く

04 · 触れ合うまで膨らむ円詰め — 計測 累積面積 44.177% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GROW_RATESPAWN_ATTEMPTSCIRCLE_GAP
    // 半径 r の円を (x, y) に置けるか — 縁、格子バケット内の隣の円(中心距離 < r + 隣の r + gap)、ふさがった場所の順に見る
    function packFree(p, x, y, r, self) {
        const g = p.o.gap;
        if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) {
            return false;
        }
        const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j) {
            if (j === self) {
                return false;
            }
            const dx = p.cx[j] - x;
            const dy = p.cy[j] - y;
            const lim = r + p.cr[j] + g;
            return dx * dx + dy * dy < lim * lim;
        });
        if (hit) {
            return false;
        }
        return !p.o.blocked(x, y, r + g);
    }
        // 育てる — 一段大きくした半径が場所に収まれば大きくし、収まらなければその円はここで止まる
        const next = [];
        for (let k = 0; k < p.growing.length; k += 1) {
            const i = p.growing[k];
            const nr = p.cr[i] + p.o.grow;
            if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
                p.cr[i] = nr;
                next.push(i);
            }
            else {
                onStop(i);
            }
        }
        p.growing = next;
        if (p.fails >= p.o.failTicks && p.growing.length === 0) {
            p.done = true;
        }
        return p.done;

05角度の違うひびに触れると止まる直角のひび

画素ごとに通ったひびの角度を書き込む盤を持ち、ひびは0.5pxずつ歩いて空いた画素か角度の差が5°以内の画素なら進み続け、違う角度に出会うと止まり、止まったひび1本ごとに、すでに引いたひびの上の一点から直角に4°前後の揺れを足した向きへ新しいひびが2本出ます。ひびの横には次のひびまでの距離を測ってその内側に淡い砂粒10個を重ねて打つのでひびのあいだに筋が生まれ、紙や石膏の質感が似合うポートフォリオの背景に合います。シード47105は100ティックでひびの上限320本を満たして角度が書き込まれた画素14,337個を残し、測定値は累積面積42.4%・強度56.6・17/23フレームです。

直角に枝分かれして広がるひび
新しいタブで開く

05 · 直角に枝分かれして広がるひび — 計測 累積面積 42.388% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CRACK_MAXBRANCH_ANGLE_JITTERSAND_GRAINS
    // 法線方向の砂 — ひびから横へ空きマスをたどってほかのひび(または縁)までの距離を測り、その内側に淡い粒を重ねて打つ
    function paintSand(c) {
        const nxv = Math.cos((c.t + 90) * DEG);
        const nyv = Math.sin((c.t + 90) * DEG);
        let d = 1;
        for (; d < SAND_REACH; d += 1) {
            const px = Math.floor(c.x + nxv * d);
            const py = Math.floor(c.y + nyv * d);
            if (px < 0 || px >= W || py < 0 || py >= H || cgrid[py * W + px] < EMPTY) {
                break;
            }
        }
        c.g = clamp(c.g + (crng() - 0.5) * 0.1, 0.05, 1);
        for (let i = 0; i < SAND_GRAINS; i += 1) {
            const k = (d * c.g * i) / SAND_GRAINS;
            actx.fillStyle = c.sand[i];
            actx.fillRect(c.x + nxv * k, c.y + nyv * k, 1, 1);
        }
    }
    function moveCrack(c) {
        c.x += CRACK_STEP * Math.cos(c.t * DEG);
        c.y += CRACK_STEP * Math.sin(c.t * DEG);
        c.steps += 1;
        const cx = Math.floor(c.x + (crng() - 0.5) * 0.66);
        const cy = Math.floor(c.y + (crng() - 0.5) * 0.66);
        if (cx < 0 || cx >= W || cy < 0 || cy >= H) {
            c.alive = false;
            return;
        }
        const i = cy * W + cx;
        const g = cgrid[i];
        if (g >= EMPTY || Math.abs(g - c.t) < ANGLE_TOLERANCE) {
            if (g >= EMPTY) {
                marks.push(i);
            }
            cgrid[i] = c.t;
            paintSand(c);
            actx.fillStyle = rgba(INK, 0.85);
            actx.fillRect(c.x, c.y, 1, 1);
        }
        else if (c.steps > START_GRACE) {
            // 角度の違うひびに当たった — ここで止まる
            c.alive = false;
        }

06隣のマスと辺が合うタイルだけを残すタイル合わせ

24×15のマスごとに草地・道・水路の辺を持つタイル18種をすべて候補として持ち、候補が一番少ないマスをひとつ重み付きの乱数で決めてから、その決定が隣のマスの候補を削る処理をキューで最後まで広げます。まだ決まっていないマスは残った候補の割合だけ濃い霧で描くので制約が広がる様子が見え、地図型のゲーム画面や道案内サービスの待ち画面に似合います。実演のシード66110は912msで候補が0のマスが出て盤を一度最初から埋め直し、3,664msで360マスを埋め終えて、レンダーの値は累積面積50.6%・強度83.0・23/23フレームでした。

隣のマスと合うものだけを残して埋まるタイル
新しいタブで開く

06 · 隣のマスと合うものだけを残して埋まるタイル — 計測 累積面積 50.553% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TILE_EDGESTILE_WEIGHTSCOLLAPSE_PER_FRAME
                const j = nr * COLS + nc;
                if (fixedTile[j] >= 0) {
                    continue;
                }
                // このマスの候補が向き d の側に出す辺の種類 — その種類と合うタイルだけが隣に残る
                let allow = 0;
                for (let t = 0; t < NT; t += 1) {
                    if (cand[i] & (1 << t)) {
                        allow |= FIT[d][edgeOf(t, d)];
                    }
                }
                const nm = cand[j] & allow;
                if (nm === cand[j]) {
                    continue;
                }
                if (nm === 0) {
                    return false;
                }
                cand[j] = nm;
                if (bits(nm) === 1) {
                    fix(j, 31 - Math.clz32(nm));
                }
                queue.push(j);
    // 候補が最も少ないマス(同じならシードの乱数で決めた順)ひとつを重み付きの乱数で選ぶ
    function collapseOne() {
        let best = -1;
        let bestScore = 1e9;
        for (let i = 0; i < cand.length; i += 1) {
            if (fixedTile[i] >= 0) {
                continue;
            }
            const s = bits(cand[i]) + tie[i];
            if (s < bestScore) {
                bestScore = s;
                best = i;
            }
        }
        if (best < 0) {
            return true;
        }

07一周しても継ぎ目が出ない、ノイズで揺らぐ年輪

年ごとに幅の違う輪を半径138pxまで重ね、輪の角度をノイズ空間の円周上の座標に変えて読んで半径を±8%の範囲で揺らし、輪ごとにその円を0.07ずつずらして隣り合う輪どうしを似せます。広い年は明るく、狭い年は濃く太い線で塗り、新しい輪は5ティックかけて膨らむので、木や家具のブランドや沿革の紹介画面によくなじみます。シード47107は120ティックで輪24本を描き終えて0と2πの地点の半径の差は0pxとなり、強度138.6は九枚の中で一番高く、累積面積35.8%・18/23フレームです。

ノイズで揺らぎながら一層ずつ増える年輪
新しいタブで開く

07 · ノイズで揺らぎながら一層ずつ増える年輪 — 計測 累積面積 35.837% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RING_SPACINGRING_NOISE_AMPRING_SEGMENTS
    // k 番目の輪の角度 a での半径 — 角度をノイズ空間の円周上の座標に変えて読み、一周すると同じ値で閉じる
    function ringR(k, a, base) {
        const nx = 7.3 + k * RING_DRIFT + Math.cos(a) * NOISE_LOOP_R;
        const ny = 3.1 + Math.sin(a) * NOISE_LOOP_R;
        return base * (1 + RING_NOISE_AMP * (fbm2(wnoise, nx, ny, 2) * 2 - 1));
    }
    function ringPoints(k, base) {
        const out = new Float64Array(RING_SEGMENTS * 2);
        for (let j = 0; j < RING_SEGMENTS; j += 1) {
            const a = (j / RING_SEGMENTS) * TAU;
            const r = ringR(k, a, base);
            out[j * 2] = TRUNK_CX + Math.cos(a) * r;
            out[j * 2 + 1] = TRUNK_CY + Math.sin(a) * r;
        }
        return out;
    }

08群れごとに縁のマスをひとつずつ取っていく苔の広がり

4pxのマス9,000個の盤に群れを7つ植え、群れを順番に回って自分の縁のリストからシード付きの乱数で1マスを取り出し、空いていれば自分のものにして、取り出した位置はリストの一番後ろのマスと入れ替えて抜きます。先に生まれたマスは濃く、遅く生まれたマスは明るく塗り、ほかの群れと接する辺には濃い1pxの線を引くので、地域ごとの区画やチームの担当を分けるダッシュボードの背景のような場所に合います。シード47108は150ティックで9,000マスを埋め終えて境目732か所を残し、累積面積75.5%は九枚の中で一番広く、強度112.3・21/23フレームです。

縁のマスがひとつずつ付いて広がる苔の群れ
新しいタブで開く

08 · 縁のマスがひとつずつ付いて広がる苔の群れ — 計測 累積面積 75.494% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

COLONY_COUNTCELLS_PER_FRAMEAGE_FADE
    function claim(i, c) {
        owner[i] = c;
        const x0 = (i % COLS) * CELL;
        const y0 = Math.floor(i / COLS) * CELL;
        // 生まれた順に色を淡く — 先に生まれた中心は濃く、遅く生まれた縁は明るい
        actx.fillStyle = rgba(mix(colonyDeep(c), colonyLight(c), (AGE_FADE * born) / TOTAL), 1);
        actx.fillRect(x0, y0, CELL, CELL);
        born += 1;
        fresh.push(i);
        const col = i % COLS;
        const row = Math.floor(i / COLS);
        const nb = [col > 0 ? i - 1 : -1, col < COLS - 1 ? i + 1 : -1, row > 0 ? i - COLS : -1, row < ROWS - 1 ? i + COLS : -1];
        for (let k = 0; k < 4; k += 1) {
            const j = nb[k];
            if (j < 0) {
                continue;
            }
            if (owner[j] < 0) {
                if (!(inFront[j] & (1 << c))) {
                    inFront[j] |= 1 << c;
                    fronts[c].push(j);
                }
            }

09ひとつのシードで流れ線・葉脈・円の三層を育てるヒーロー

ひとつのシードを枝番号と混ぜて流れ線・葉脈・円・ノイズがそれぞれ別の乱数列を受け取るようにし、流れ線は0ms、葉脈は480ms、円は960msから順に育ち、円は葉脈の節から2px以内を避けて場所を取ります。三つが育ち終えると1,400msとどまったあと今の場面を別に写し取り、次のシードの場面の上に700msかけて淡く重ねて切り替わるので、ランディングの最初の画面のヒーロー背景としてそのまま貼れるように作った一枚です。実演の一巡(47109 → 76513)は909ティック・14,544msで、キャプチャでは累積面積39.0%・強度73.4・23/23フレームでした。

ひとつのシードから三層が育つヒーロー背景
新しいタブで開く

09 · ひとつのシードから三層が育つヒーロー背景 — 計測 累積面積 39.016% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HERO_SEEDLAYER_STAGGER_MSHOLD_MS
    // 同じシードから枝(lane)ごとに別の乱数列 — 三層が同じ数を分け合わないので模様が重ならない
    function laneRng(base, lane) { return makeRng((base ^ Math.imul(lane + 1, 0x9e3779b1)) >>> 0); }
    const VEIN_CX = 316;
    const VEIN_CY = 124;
    function insideCrown(x, y) {
        const dx = (x - VEIN_CX) / 150;
        const dy = (y - VEIN_CY) / 100;
        return dx * dx + dy * dy < 1;
    }
    function buildFlow(s) {
        return makeFlow(laneRng(s, 0), makeNoise(laneRng(s, 3)), { sep: 12, test: 0.5, step: 2.5, scale: 0.004, cell: 14, maxPts: 500, margin: 4 });
    }
    function grow() {
        fadeLeft = Math.max(0, fadeLeft - FIXED_DT);
        if (!fl.done) {
            flowStep(fl, HERO_FLOW_STEPS, flowSeg);
        }
        if (age >= LAYER_STAGGER_MS && !vn.done) {
            veinStep(vn);
        }
        if (age >= LAYER_STAGGER_MS * 2 && !pk.done) {
            packStep(pk, function () { });
        }
        const all = fl.done && vn.done && pk.done;
        if (!all) {
            return false;
        }

点が数万個に増える・シードを変えても似た絵になる・画面の大きさが変わる — 育つ模様が壊れるところ

この節のティック数と個数は、vanillaのindex.htmlの関数をNodeでそのまま呼び出して測り直した値で、「最初の版」の数値は、作る途中で前の版を同じ方法で測ったものです。msで書いた計算時間はこのPCのNodeで測ったもので機器ごとに変わるため、比べるときはティック数で見るほうが確かです。

点が増えるほど、隣探しが仕事を食う

01は点8,386個、04は円1,500個前後、03は節2,600個まで積み上がります。格子なしですべての点と比べると、04はティックごとに場所の抽選60回と育っている円の数の合計に1,500をかけた回数だけ比べることになり、数が増えるほど画面がもたつくと考えられます。03は節の上限がなければティックあたりの仕事が節の数に比例して大きくなり、上限2,600個のときNodeで測った一番重いティックは2.0msでした。08は縁のリストの途中のマスをspliceで抜くとマスの数だけ後ろを詰める必要があるので、一番後ろのマスと入れ替えて抜く方法を選んでいます。

最初の一歩で自分の点にふさがれる流れ線

01が同じ線のすぐ前の点まで距離の検査に入れると、最初の一歩から自分の点が4px以内にあって線が止まってしまいます。そのためflowTooCloseは、同じ線で前後8歩(間隔の2倍の長さ)以内の点を数えません。渦の真ん中では線がいつまでも回り続けることがあるので、点700個(MAX_LINE_POINTS)で打ち切ります。

引っぱり合う引き寄せ点に捕まった葉脈

02を最初に作ったときは、平均の向きが0に近い節も正規化してそのまま伸ばしていましたが、シード47102と27672が引き寄せ点2個を最後まで消せないままMAX_ITERの400まで回り、節を1,102個まで増やしました。反対側にある引き寄せ点ふたつがひとつの節を引っぱり合い、その場で足踏みしたと考えられます。平均の長さが0.2未満の節はそのティックは休ませ、引き寄せ点がひとつも消えないティックが40回続いたら止めるようにすると、103ティックで終わりました。消す距離の6pxを節の長さ2.6pxより大きくしたのも、新しい節が引き寄せ点を飛び越えて行ったり来たりしないための値と考えられます。

押し返しと分割だけでは育たないしわの線

03の最初の版には長い辺の分割と押し返しだけがあり、8,000ティック回しても節は47個から増えませんでした。両隣の節を押す力が線に沿った向きなので、互いに打ち消し合ったと考えられます。隣と4pxを保とうとするばねと、シード付きの乱数で選んだ辺に挟む成長の節を足して、ようやく線が伸びるようになりました。spliceで挟むとその後ろの節の番号がひとつずつずれるので、挟んだ直後にiをもうひとつ飛ばします。

終わりの判定が遅いと、ほとんど同じ絵を長く待つ

04は、円をひとつも植えられないティックが何回続いたら終えるかをFAIL_TICKSで決めます。この値を24にしたときは1,070ティックかかり、8に減らすと565ティックで終わりました。そのあいだに残る空きは半径1〜2pxのものばかりなので、絵の違いはほとんどありません。ふたつの円が同じティックに育っても、後から見る円は先の円がすでに大きくなった半径で判定するので重なりません。

出発点を見つけられなかったひび

05の最初の版では、新しいひびの出発点をでたらめな画素を1,200回試して探させたところ、角度が書き込まれた画素が12個しかない始めのころに一度も当たらず、ひびが0本で終わりました。今は角度が書かれた画素のリストmarksから直接選びます。出発してから4歩は猶予を置き、親のひびのマスですぐに止まらないようにしました。角度の差は360°で折り返さないので、同じ向きでも−90°と270°は別のひびとして扱うという限界が、ソースにそのまま残っています。

橋のタイルを入れると崩れないタイルの盤

06には道と水路が交差する橋のタイルがありません。橋2種を入れていた最初の版はシード200個で矛盾が0回でしたが、外した今はシード400個のうち7個が一度ずつ崩れます。道と水路が出会う場所があれば行き止まりのマスが生まれないということで、実演のシード66110は、崩れる7個のうちのひとつを選んで埋め直す場面が見えるようにしたものです。巻き戻しをせずに盤全体を最初からやり直す方式なので、盤が大きくなるほどやり直しの手間も大きくなります。候補の数が同じマスを番号順に選ぶと左上から行をそろえて埋まっていくため、マスごとにシード付きの乱数で決めた同点崩しの値tieを足しています。

角度を直線で読むと出る7.40pxの段差

07のringRを、角度をそのままノイズのxとして読むように変えると、同じ振幅で0と2πの地点の半径が7.40pxずれて、輪ごとに段差ができます。円周上の座標で読めば一周の終わりが始まりと同じノイズの位置に戻るので、差は0pxです。振幅が半径に比例するため外側の138pxの輪では±11pxまで揺れ、幅が2.67pxの年と重なると隣の輪と交わることがあります。隣の輪のノイズの円を0.07だけずらして似せてあるので、実際の絵ではほとんど見えないと考えられます。

シードを変えても似て見える理由

シードを変えると配置は変わりますが、性格は変わりません。02はシード21080・27672でも103ティックで引き寄せ点を消し切り(枝先85本・103本)、04はシード36918・94117で棄却率が95.2%・94.2%と、47104の94.9%から1ポイントも離れず、08は9,000マスをティックあたり60マスずつ埋めるのでどのシードでも150ティックで終わります(境目732・821・975か所)。絵の印象を変えたいなら、シードより下の変形の表にある定数を動かすほうが早道です。

三層が互いを揺さぶるヒーロー

09が三層に同じ乱数列を分けて使うと、流れ線の設定をひとつ変えただけで後から引く葉脈と円の模様まで丸ごと変わると考えられるので、laneRngで枝を分けました。円は960msに始まるとき、それまでに育った葉脈の節だけを避けるので、葉脈がその後も伸びると円と重なりうる箇所がソースに残っています。前の場面を画面のキャンバスからそのまま写し取ると、右下の数値の箱まで淡く重なって残るため、snapshotはdrawSceneで場面だけを描き直します。

画面の大きさと画素の倍率が変わるとき

キャンバスの中の座標は480×300に固定で、外側の箱だけがwidth: min(92vw, calc(160vh - 12.8vw))とaspect-ratio: 480 / 300で伸び縮みします。そのためスマートフォンで開いても、同じシードは同じ絵になります。実際の画素数には機器の倍率をかけますが、Math.min(window.devicePixelRatio || 1, 2)で2倍に抑えています。キャンバスの大きさを変えると描いた内容が消えるので、倍率が変わるとfitCanvasが描画板を一時キャンバスへ移しておき、新しい大きさで描き戻します。

九つのフォルダにあるvanillaのソースを自分のシードと自分の色に替えて育ててみたい場合は、ダウンロードしたzipを開くときに9rhueet8を入力してください。同じ計算を移したReact版も、同じzipに入っています。

流れ線の間隔・引き寄せ半径・押し返す半径を変えると絵はどう変わるか

名前 変えた値 感じ
流れ線の間隔を広げる 01 LINE_SEPARATION 8 → 16 同じシード47101で線が155本から79本に、長さの合計が16,462pxから8,372px(0.51倍)に減り、135ティックだった成長が66ティックで終わります。間隔を2倍に開くと面積を間隔で割った分しか線が入らないので長さがほぼ半分になり、筋がまばらで涼しげになります。
引き寄せ半径を広げる 02 INFLUENCE_RADIUS 60 → 120 節794 → 815個、枝先71 → 81本で、成長は103ティックのままなので絵はほとんど同じです。Nodeで測った成長全体の計算は24.6msから40.0msに増えました。引き寄せ点900個が葉の中に密に詰まっていて、60px以内ですでに一番近い節が決まっているためと考えられます。
押し返す半径を大きくする 03 REPEL_RADIUS 12 → 22 同じ120ティックで平均の太さ(面積×2÷周長)が7.5pxから13.0pxへ、横幅が171pxから271pxへ、面積が12,036px²から29,297px²へ大きくなります。細かく折れたしわが、太くゆったりした曲がりに変わります。

三行のうち01と03は絵の筋目が目に見えて変わりますが、02の引き寄せ半径は絵をほとんどそのままにして計算だけを増やします。半径を大きくする前に、絵が本当に変わるかと計算がどれだけ増えるかをいっしょに測っておくほうがよいことが、02の行から見えてきます。

CSSのキーフレームで決まった絵を動かす方向はジェネラティブパターン背景、点から一番近い領域を分ける模様はボロノイ図のパターンで続きを読めます。一歩ずつさまよって付着していく結晶は雪の結晶の成長、規則の文字列を書き換えて育てる木はフラクタルの木、画素単位の計算で場面を作る効果はキャンバスの特殊効果9種が近い題材です。

育ち終えた一枚を静止画に — 動きを減らした画面と背景画のアクセシビリティ

OSで動きを減らす設定(prefers-reduced-motion)をオンにした画面では、九枚ともrequestAnimationFrameのループを回しません。代わりにfinishNow()がティックの上限6,000(GROW_GUARD)の中でそのシードを最後まで育て、育ち終えた絵を一枚すぐに描きます。Enter・R・ボタンでシードを替えたり育て直したりしても、その場で新しい絵の完成形だけが入れ替わります。09はクロスフェードも次のシードもなく、育ち終えた三層の一枚にとどまります。設定はmatchMediaのchangeリスナーが見張っているので、ページを開いたまま切り替えても反映されます。

キーボードでも九枚すべてを操作できます。キャンバスごとにtabindex="0"と、絵とキーを説明するaria-labelが付いていて、フォーカスが入るとEnterは新しいシード、Rは同じシードの育て直し、Spaceは一時停止と再開です。既定の動作を止めるのはこの三つのキーだけなので、Tabをはじめほかのキーはそのまま通ります。「描き直す」「同じシード」「一時停止」は本物の<button>で、一時停止ボタンはaria-pressedで押された状態を知らせ、キャンバスとボタンのどちらにも:focus-visibleの枠があります。

状態の行は実演が回っているあいだaria-live="off"で黙っていて、人が初めて操作した瞬間にpoliteへ切り替わり、文言も育っている途中・育ち終わり・一時停止のように段階が変わるときだけ書き換えます。06の状態の行は、育っている途中なら矛盾でやり直した回数が1以上のときだけその回数を添え、育ち終わりの文言には0回でも回数が付きます。線・節・円の数のようにティックごとに変わる数字は状態の行ではなくキャンバス右下の箱に描いているので、スクリーンリーダーが毎フレーム数字を読み上げることはありません。絵を文章の後ろに飾りとしてだけ敷くときは、ボタンとキーの案内をいっしょに外し、キャンバスを読み上げの対象から外しておくほうが、聞く人の負担が少なくなります。

ジェネラティブアートをサイトの背景に敷く前によくある質問

ジェネラティブアート コードを、開くたびに違う絵が出るように変えられますか?

01〜08はSEED0、09はHERO_SEEDの一行が最初のシードです。ソースは同じシードが同じ絵を出すように、時刻やMath.randomをわざと使っていません。訪問ごとに変えたいならこの一行の数をページ側で作って渡せばよく、その場合に同じ絵をもう一度見るにはその数もいっしょに残しておく必要があります。記事番号のようにページごとに決まった数を入れれば、ページごとに違っていながら、開き直すたびに同じ絵が出ます。

色を自分のブランドの色に替えるには、どこを直しますか?

九つのソースとも、色はスクリプト上部のINK・PAPER・PALの定数にRGBの配列でまとまっていて、三つとも描画に使われています。この配列の値だけを替えれば、計算と形はそのままで線・節・円の色が変わります。舞台の箱の角の丸み(border-radius)と影はstyle.scssのトークンに従います。

スマートフォンで動かしても重くなりませんか?

ティックごとの仕事には上限があります。01は歩数70、04は新しい円8個、08は60マスのように定数で縛られ、フレームあたりのティックも最大6回、画素の倍率も2倍で抑えています。03は節の上限2,600個のときNodeで測った一番重いティックが2.0msでしたが、この値はこのPCでのものなので、遅い機器ではMAX_NODESやティックあたりの上限を下げてみるほうが安心です。

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

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