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

css 背景 マーブル 9パターン — 墨流し・櫛・石目の流れる背景

水面のインクは、表面張力が支える薄い膜です — 04番はその膜を一方向へ押して波の帯に固め、05番はずらした二つの櫛で目を羽のように割り、08番は層ごとの密度を値で押して石の帯に刻みます。

インクを落とし、櫛でかき、石を切る九つのマーブリングの場面

下の格子のカードを押すと、その項目の節へ移ります。各デモの中でポインターを置いたりキーを押したりすれば、しずくが落ちる、櫛が通る、層が流れるといった変化をその場で起こせます。

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

css 背景 マーブル 9パターン — インクの広がり・櫛の目・石の目で描く流れる背景

並べ方は、色が水の上でたどる順番に合わせました。01から03は浮かんだインクが広がり、割れ、折れる段階、04と05は熊手と櫛でその膜をかく段階、06と07はしずくと点が新しく落ちる瞬間、08と09は層になって固まった石の断面と、輪になって広がる墨です。九つの場面はどれも <canvas> 一枚の上に描かれ、スタイルシートが受け持つのは舞台の縦横比や角の丸み、影、ボタンとフォーカスの枠といった外側の枠です。同じ計算でも定数ひとつで模様の表情が大きく変わるので、その数字は後ろの値の節にまとめました。

2秒・24枚のキャプチャで数えた動き

場面ごとに2秒間で24枚を撮り、直前の1枚と比べて変わったピクセルを数えました。右端の列は、23回の比較のうち画面に変化があった回数です。

場面 累積面積 強度の平均 動いたフレーム
01 しずくの広がり 71.4% 36.3 16/23
02 割れて枝分かれする目 30.3% 29.0 23/23
03 扇のひだ 72.9% 26.3 23/23
04 熊手の波の帯 71.1% 20.5 23/23
05 ずれた櫛の羽 73.8% 33.6 16/23
06 王冠の跳ね 14.2% 36.4 23/23
07 まいた点 68.5% 26.9 16/23
08 瑪瑙の層 60.4% 31.9 23/23
09 墨の輪 72.0% 56.9 23/23

累積面積が30%を超えた場面は八つで、10%を下回った場面はありません。いちばん小さい06の14.2%は、変わるのが衝撃の輪の線と小さな粒だけというベクター描画の場面だからです。01・05・07が16/23にとどまったのは、実演のスケジュールの合間に模様が止まったキャプチャが混じっているためで、この三つも累積面積は68%を超えています。

この模様を置くのに向いた場所

しずくの広がり(01)と扇のひだ(03)は視線が画面の中央に集まる模様なので、ファーストビューや招待状の見出しのように文字の少ない場所に向きます。熊手の波(04)は横に長い帯なので、フッターやバナーの下の飾り帯として切り出しやすく、ずれた櫛の羽(05)は目が均一に敷かれるので購読フォームの背後に置けます。ただし入力欄を載せるときは模様の上に直接文字を置かず、不透明な板を一枚敷いておくと、文字のコントラストが模様の色に左右されません。

王冠の跳ね(06)は押した瞬間に反応する場面なので、クリックの手応えやヒーローが現れる一拍に合い、まいた点(07)はラベルの下地のように小さく繰り返す面に合います。瑪瑙の層(08)は一場面を止めただけでも石の断面に見えるので、名刺やアクセサリーのバナーに静止画として使ってもよく、輪がゆっくり広がる墨流し(09)はブランド紹介のように落ち着きが要る背景に近い場面です。

マーブリング やり方 をコードに移すと何から計算するか?

最初に計算するのは「このピクセルにいま見えるインクは、もともとどこにあったのか」です。01の inkAt はしずくを新しい順にさかのぼって画面の点の元の位置を探し、05の sourceY は二回の櫛の動きを y で戻して最初に敷いた帯の高さを探し、08の warp は座標そのものをノイズでねじって層を探す番地を変えます。インクの塊を押して運ぶのではなくピクセルごとに出発点を尋ねるので、模様がどれほど絡んでも、穴や二度塗りの場所なしに画面全体が一度で埋まります。手で マーブリング 絵の具 を水に落とすときは色が押し合って目ができますが、コードではその押し合いを逆向きにたどる式に置き換えているわけです。

小さなバッファに塗って大きく引き伸ばす

01〜05と07〜09の八つの場面は、画面より小さいフィールド(01は160×100、02は96×60、03・07・09は192×120、04・05・08は240×150)にピクセルの色を計算して createImageData のバッファに書き、putImageData で隠れたキャンバスに載せてから、drawImage で480×300の舞台に引き伸ばして貼ります。引き伸ばすときは imageSmoothingQuality を 'high' にして、マスの境界をなめらかにします。getImageData は九つのファイルのどこにもなく、画面を読み返さずに書くだけで、06だけはこのバッファを使わずに楕円と線を直接描きます。

紙の目は一度だけ焼き、時間は16msで刻む

背景の紙の目(paperGrain、3オクターブの値ノイズ)は時間がたっても変わらないので、initField で grainF 配列に一度だけ焼いておき、ピクセルごとに取り出して使います。最初の版はこれを毎フレーム計算し直していて、キャプチャで動いたフレームが空いたため、いまの作りに変えました。時間は requestAnimationFrame が渡す時刻をためて16ms(FIXED_DT)ずつ区切って使い、一度にためる量は100ms、回すステップは6回が上限です。キャンバス内部の解像度は480×300に画面の倍率を掛けますが、Math.min(window.devicePixelRatio || 1, 2) で2倍に抑えます。

乱数ではなく整数ハッシュなので模様が毎回同じ

Math.random は一度も呼びません。Math.imul で混ぜる整数ハッシュ hash1・hash2 と、その上の値ノイズ vnoise2、オクターブを重ねる fbm2 が模様の不規則さをすべて受け持ち、シードの数字は呼び出しごとに直接渡します(02の利得マップは211、07の点の位置は811、08のねじれは101・313、09の半径の揺れは401)。ファイルの先頭には const SEED = 463 も書かれていますが、どの計算もこの定数を使っていません。そのため再読み込みしても、同じ場所に同じ目が立ちます。

このあとの各項目のコードは、場面ごとの index.html のスクリプトから途中の行を抜かずにひと続きの区間を写したものです。マーブル 模様 作り方 絵の具 の手順を探してここへ来た人も、受け取ったファイルで関数名を検索すれば、前後の文脈ごと同じ箇所にたどり着けます。

01落ちたインクが輪を作りながら外へ広がるしずくの広がり

半径 R のしずくが落ちると外側の点は中心から √(1 + R²/d²) 倍遠ざかるので、inkAt はその逆の √(1 − R²/d²) 倍を新しいしずくから順に掛け、画面の点がしずくより前にいた場所をさかのぼります。この変位は面積を保つので、BLOOM_RADIUS 96のしずく二つを同じ場所に置いて中心から108.2pxの点をさかのぼると、最初のしずくの中で半径比0.521(50/96)が出ます — 50pxにあったインクが、ちょうど108.2pxまで押し出されたということです。しずくの中は R/RING_N = 19.2px幅の帯5重で塗り、EDGE_PUSH 0.35の位置に押し戻されたインクを一本重ねて二重の帯を作っていて、レンダーでは累積面積71.4%・強度36.3・16/23フレームでした。

落ちたインクが輪を作りながら外へ広がるしずくの広がり
新しいタブで開く

01 · 落ちたインクが輪を作りながら外へ広がるしずくの広がり — 計測 累積面積 71.384% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BLOOM_RADIUSRING_NEDGE_PUSH
    function inkAt(x, y, now) {
        let qx = x, qy = y;
        hitI = -1;
        for (let i = blooms.length - 1; i >= 0; i -= 1) {
            const b = blooms[i];
            const R = radiusOf(b, now);
            if (R <= 0) {
                continue;
            }
            const dx = qx - b.x, dy = qy - b.y;
            const d2 = dx * dx + dy * dy;
            if (d2 < R * R) {
                hitI = i;
                hitQ = Math.sqrt(d2) / R;
                return;
            }
            const s = Math.sqrt(1 - (R * R) / d2);
            qx = b.x + dx * s;
            qy = b.y + dy * s;
        }
    }

02押されたインクの面が細いすき間に割れて枝分かれする断片の目

96×60の格子のセルごとに圧力を「密度 × (1.15 − 利得)」と置き、利得を fbm2 の3オクターブ(SPLIT_DEPTH 3)で敷いて大きな谷の中に小さな谷を掘ったうえで、右と下の隣どうしで圧力差 × DENSITY_DIFF_K 0.16だけインクを渡し、一回の量はセルの密度の24%で打ち切ります。交換だけだとインクはぼやけたしみのように均一に広がるので、tradeDens の最後の反応項 d + EDGE_KEEP·d(1−d)(d−½)·4 が0と1のあいだの値を両端へ押し戻し、谷が空く場所を鋭いすき間として立てます。ソースの関数を Node で回すと、すき間(密度0.1未満のセル)は10.6%から160ステップ(2.56秒)後に22.7%まで広がり、レンダーの測定は累積面積30.3%・強度29.0・23/23です。

押されたインクの面が細いすき間に割れて枝分かれする断片の目
新しいタブで開く

02 · 押されたインクの面が細いすき間に割れて枝分かれする断片の目 — 計測 累積面積 30.344% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CELL_NDENSITY_DIFF_KSPLIT_DEPTH
    // 交換一回 — 右・下の隣の組だけを回り、同じ組を二度数えない。谷が空いてすき間になり、
    // 反応項 d(1−d)(d−½) が0と1のあいだの値を両端へ押し戻し、すき間の縁がぼやけない
    function tradeDens(dx, dy) {
        const out = new Float32Array(dens);
        for (let y = 0; y < GY; y += 1) {
            for (let x = 0; x < GX; x += 1) {
                const i = y * GX + x;
                if (x + 1 < GX) {
                    trade(out, i, i + 1, dx);
                }
                if (y + 1 < GY) {
                    trade(out, i, i + GX, dy);
                }
            }
        }
        for (let i = 0; i < out.length; i += 1) {
            const d = out[i];
            out[i] = clamp(d + EDGE_KEEP * d * (1 - d) * (d - 0.5) * 4, 0, 1);
        }
        dens = out;
        trades += 1;
    }

03一点から伸びるひだが扇のように折れるブーケの目

扇の中心(フィールドの幅の0.44、高さの0.94)から測った角に、pleatWidth が RADIANT_K × PLEAT_FREQ × sin(角) だけ幅を足して折り座標 (角 × 15 + 幅 × 0.5)/π を作るので、一回りに帯が30本立ち、画面に見える上半分の円には約15本が並びます。その座標の小数部が0.44を超える後ろ半分は0.12の幅をかけて暗くなり、FOLD_CONTRAST 0.34が明るさを明るい面0.66と折れた面0.474の二段に分けて、布を畳んだような面を作ります。実演の最初の回転は角速度0.0021rad/msからステップごとに0.965倍に落ちて約55°回って止まり、レンダーは累積面積72.9%・強度26.3・23/23でした。

一点から伸びるひだが扇のように折れるブーケの目
新しいタブで開く

03 · 一点から伸びるひだが扇のように折れるブーケの目 — 計測 累積面積 72.876% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RADIANT_KPLEAT_FREQFOLD_CONTRAST
                    let ang = Math.atan2(dx, -dy) + fanA;
                    const w = pleatWidth(r, ang);
                    // 折り座標 — ひだの幅だけ角を押し、布が折れたような密度を作る
                    const fold = (ang * PLEAT_FREQ + w * 0.5) / Math.PI;
                    const fr = fold - Math.floor(fold);
                    // 折れた面(後ろ半分)は暗く — 境界は0.12の幅でなめらかに移り、段がギザギザに崩れない
                    const shade = 1 - 0.58 * smooth((fr - 0.44) / 0.12) * (1 - smooth((fr - 0.92) / 0.08));
                    const tint = TINTS[Math.abs(Math.floor(fold)) % TINTS.length];
                    const edge = smooth(1 - Math.abs(fr - 0.5) * 2);
                    let k = smooth(clamp(1 - r / (FH * 0.98), 0, 1)) * 0.8;
                    // 目ごとの明るさの段 — shade と FOLD_CONTRAST で折れた面とのあいだの段
                    const stepB = shade * (1 - FOLD_CONTRAST) + (1 - shade) * FOLD_CONTRAST;
                    k = k * stepB + edge * 0.06;

04一方向へ押された波の帯が斜めに固まる櫛の目

bandAt は帯の番号を (y + 傾き + 波 + さざ波) ÷ BAND_PX 13で決め、傾きの CHEVRON_SKEW 0.34は帯を18.8°傾け、波は行ごとに sin(y × 0.028) × 1.15 だけ位相のずれた振幅13(WAVE_AMP 26の半分)の正弦波です。ステップごとに位相 drift へ向き × PHASE_DRIFT 0.022radがたまって毎秒1.375rad、波の頂でいえば横に125px/sで流れ、向きを変えない限り同じ目が片側へ押され続けて斜めの帯に固まります。実演は200msに右、1900msに左へ櫛の向きを変え、レンダーの数値は累積面積71.1%・強度20.5・23/23です。

一方向へ押された波の帯が斜めに固まる櫛の目
新しいタブで開く

04 · 一方向へ押された波の帯が斜めに固まる櫛の目 — 計測 累積面積 71.056% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WAVE_AMPPHASE_DRIFTCHEVRON_SKEW
    function bandAt(x, y) {
        // ずれた位相の正弦波の合成 — 行ごとに位相が違って波になり、横の傾きがシェブロンを作る
        const tilt = CHEVRON_SKEW * (x - W / 2);
        const wave = WAVE_AMP * 0.5 * Math.sin(x * 0.011 + Math.sin(y * 0.028) * 1.15 + drift);
        const fine = WAVE_AMP * 0.22 * Math.sin(x * 0.0031 + y * 0.052 - drift * 0.6);
        return (y + tilt + wave + fine) / BAND_PX;
    }

05逆方向の二つの櫛がずれて出会い、羽に割れる目

tineShift はいちばん近い歯の列までの横の距離 d から引かれる量を amount × λ/(d + λ) で返し、λ が 14 × 0.55 ÷ FEATHER_SPLIT_K 1.3 = 5.92pxなので、歯の列の上で64pxある引きが半マス(7px)横では29.3pxまで減ります。sourceY は下へ引いた一つ目の櫛と、歯を COMB_TOOTH_GAP × STAGGER_PHASE = 7pxずらして上へ引いた二つ目の櫛を y で順に戻し、最初に敷いた16pxの横帯の色を取ってくるので、二つの引きが食い違う場所ごとに帯が羽の形に割れます。480pxの幅を歯34.3本が通り、一つ目の櫛の64pxには1138ms、二つ目の51.2pxには910msかかり、レンダーは累積面積73.8%・強度33.6・16/23フレームでした。

逆方向の二つの櫛がずれて出会い、羽に割れる目
新しいタブで開く

05 · 逆方向の二つの櫛がずれて出会い、羽に割れる目 — 計測 累積面積 73.832% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

COMB_TOOTH_GAPSTAGGER_PHASEFEATHER_SPLIT_K
    // 櫛の歯一列がインクを引く量 — いちばん近い歯の列までの横の距離 d で amount·λ/(d+λ)
    function tineShift(x, offset, amount) {
        const g = COMB_TOOTH_GAP;
        let u = (x - offset) / g;
        u -= Math.round(u);
        const d = Math.abs(u) * g;
        const lam = (g * 0.55) / FEATHER_SPLIT_K;
        return amount * lam / (d + lam);
    }
    // 画面の点は櫛を通す前どの高さにあったインクか — 二つの櫛はどちらも縦にだけ引くので y だけ戻せばよい
    function sourceY(x, y) {
        return y - tineShift(x, 0, pull1) + tineShift(x, COMB_TOOTH_GAP * STAGGER_PHASE, pull2);
    }

06触れた瞬間にインクが輪の上へ上がり、落ちて戻る王冠の跳ね

06はピクセルのバッファを使わず楕円と線を描くベクターの場面で、しずくが触れると CROWN_N 28個の粒が衝撃の輪と一緒にステップごとに RING_PULSE 1.1pxずつ外へ運ばれ、高さの方向には初速 TANGENT_EJECT × 0.6〜1.1で上がり、その上向きの速度からステップごとに GRAVITY_FALL 0.36が引かれます。高さが0を下回った瞬間に stepBead がその位置を落点として固め、Node で測ると最も遅い粒は320msで17.4px上がって輪の中心から28pxの位置に、最も速い粒は592msで61.2px上がって46.7pxの位置に着地します。水面の円は縦を TILT 0.46倍に縮めた斜めの視点の楕円で描き、画面の上側の粒から塗って前後を合わせていて、レンダーは累積面積14.2%・強度36.4・23/23でした。

触れた瞬間にインクが輪の上へ上がり、落ちて戻る王冠の跳ね
新しいタブで開く

06 · 触れた瞬間にインクが輪の上へ上がり、落ちて戻る王冠の跳ね — 計測 累積面積 14.168% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RING_PULSETANGENT_EJECTGRAVITY_FALL
    // 粒の1ステップ — 輪と一緒に外へ運ばれ(RING_PULSE)、上へは重力で減速する。高さが0を下回ると落点
    function stepBead(b) {
        if (b.land > 0) {
            b.land = Math.min(1, b.land + 0.025);
            return;
        }
        b.r += RING_PULSE;
        b.vh -= GRAVITY_FALL;
        b.h += b.vh;
        if (b.h <= 0 && b.vh < 0) {
            b.h = 0;
            b.land = 0.01;
        }
    }

07まいた点がにじみ、伸びて、まだらになる点模様の目

seedDots は5px格子の内側814か所ごとにハッシュ値ひとつを SCATTER_P 0.55と比べ、小さいときだけ点を置いて435個(53.4%)を立て、位置と大きさ(1.3〜2.8)と色も場所のハッシュで決めるので、呼び直しても同じ点が同じ場所に出ます。splatDots は点ごとに自分の半径の箱だけを回り、(1 − r²)² × 0.55 の重みとその重みぶんの色を一緒に積むので、重なった場所は隣の点の色の加重平均で混ざり、画面基準で半径12.5〜26.9pxまでにじみます。続いてカーネルの横の半径と x 位置を最大 STRETCH_ALONG_COMB 1.9倍まで伸ばして点を櫛の向きへ引き、レンダーでは累積面積68.5%・強度26.9・16/23フレームでした。

まいた点がにじみ、伸びて、まだらになる点模様の目
新しいタブで開く

07 · まいた点がにじみ、伸びて、まだらになる点模様の目 — 計測 累積面積 68.47% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SCATTER_PBLEND_RADIUSSTRETCH_ALONG_COMB
    function seedDots() {
        dots = [];
        let i = 0;
        for (let gy = 1; gy < FH / 5 - 1; gy += 1) {
            for (let gx = 1; gx < FW / 5 - 1; gx += 1) {
                const h = hash2(gx, gy, 811);
                // 格子の内側のマスにシードハッシュの確率で点を置く — 場所ごとにいつも同じ
                if (h < SCATTER_P) {
                    dots.push({
                        x: gx * 5 + (hash2(gx, gy, 977) - 0.5) * 3.6,
                        y: gy * 5 + (hash2(gx, gy, 341) - 0.5) * 3.6,
                        s: 1.3 + hash2(gx, gy, 553) * 1.5,
                        tint: Math.floor(hash2(gx, gy, 761) * TINTS.length) % TINTS.length,
                        k: 0.55 + hash2(gx, gy, 197) * 0.5,
                    });
                    i += 1;
                }
            }
        }
        scatterT = 0;
        stretchT = 0;
    }

08層ごとに密度が変わり帯の幅が変わる瑪瑙の目

warp は画面の座標を4オクターブの fbm2 二つでねじって渡すドメインワープなので、絵を揺らす代わりに層を探す番地が最大21.9px曲がり、そのねじれはステップごとに DOMAIN_SHIFT 0.0048ずつ流れます。layerAt は層の座標 (x + 0.22y) × 0.055 に低周波のノイズを足した値の小数部が 1 − AGATE_LAYER_DENSITY を超える部分だけを濃く塗るので、3px間隔の標本で濃い層の割合が32.0%と、密度の値0.35をほぼそのままなぞります。濃い層の頂(0.82付近)に薄い光沢を重ねて石の断面に見えるようにしていて、レンダーの数値は累積面積60.4%・強度31.9・23/23です。

層ごとに密度が変わり帯の幅が変わる瑪瑙の目
新しいタブで開く

08 · 層ごとに密度が変わり帯の幅が変わる瑪瑙の目 — 計測 累積面積 60.386% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

AGATE_LAYER_DENSITYWARP_AMPDOMAIN_SHIFT
    function warp(x, y) {
        // ドメインワープ — 座標そのものをノイズでねじり、層の境界を曲げる
        const a = (fbm2(x * 0.012 + warpT, y * 0.012, 4, 101) - 0.5) * 2 * WARP_AMP;
        const b = (fbm2(x * 0.012 + 519.3, y * 0.012 - warpT * 0.7, 4, 313) - 0.5) * 2 * WARP_AMP * 0.8;
        return [x + a, y + b];
    }
    function layerAt(wx, wy) {
        // 層の座標 — 瑪瑙の断面の帯の軸。密度の濃い層は薄く詰まっている
        const s = (wx + wy * 0.22) * 0.055 + fbm2(wx * 0.006, wy * 0.006, 3, 61) * 1.5;
        const fract = s - Math.floor(s);
        // 層の密度で帯の幅を調整 — AGATE_LAYER_DENSITY が帯の中の濃い部分の割合を決める
        const dense = smooth((fract - (1 - AGATE_LAYER_DENSITY)) / AGATE_LAYER_DENSITY);
        return dense;
    }

09中心ごとに広がる同心円が揺れ、重なる墨流しの目

ringField は中心ごとにステップあたり RING_SPEED 0.9px(毎秒56.25px)で育つ輪を持ち、角度ごとに流れるノイズで半径を最大±4.16px(RADIUS_NOISE 0.16 × RING_GAP 26)まで揺らして墨の線を震わせます。返す値は Σc + INTERFERE_K × ((Σc)² − Σc²)/2 で、同じ中心の輪どうしは26px間隔で離れていて積の項がほぼ0なので、二つの中心の輪が出会う節でだけ単純な和より濃い墨が立ちます。実演は200・480・760msに一つ目の中心、1000・1300msに二つ目の中心へ墨を落として前の輪を消さず、レンダーの測定は累積面積72.0%・強度56.9・23/23と、九つの場面の中で強度がいちばん高い値です。

中心ごとに広がる同心円が揺れ、重なる墨流しの目
新しいタブで開く

09 · 中心ごとに広がる同心円が揺れ、重なる墨流しの目 — 計測 累積面積 72.013% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RING_SPEEDRADIUS_NOISEINTERFERE_K
    function ringField(x, y, now) {
        // 時間で広がる同心円 — ステップごとに半径が伸び、隣の輪との間隔で波の模様を作る
        let v = 0;
        let v2 = 0;
        for (let i = 0; i < rings.length; i += 1) {
            const rg = rings[i];
            const dx = x - rg.x, dy = y - rg.y;
            const r = Math.sqrt(dx * dx + dy * dy);
            if (r > rg.r + 2) {
                continue;
            }
            // 半径のノイズ — 角度ごとに流れるノイズで目が揺れる
            const ang = Math.atan2(dy, dx);
            const sway = RADIUS_NOISE * RING_GAP * (fbm2(Math.cos(ang) * 2.4 + 7.1, Math.sin(ang) * 2.4 + now * 0.00021, 3, 401) - 0.5) * 2;
            const d = r - (rg.r + sway);
            const w = RING_GAP * 1.3;
            if (Math.abs(d) < w) {
                const c = Math.exp(-(d * d) / (w * w * 0.24)) * (1 - rg.r / RING_MAX_R);
                v += c;
                v2 += c * c;
            }
        }
        // 二中心の干渉 — 輪どうしが重なる場所に積の項 ((Σc)² − Σc²)/2 を INTERFERE_K だけ足す。
        // 同じ中心の輪どうしは間隔(RING_GAP)だけ離れてほとんど重ならないので、この項は二つの中心の輪が出会う節で立つ
        return v + INTERFERE_K * (v * v - v2) * 0.5;

広がりの半径・櫛の歯の間隔・層の密度 — 数字ひとつで模様の目が変わる場所

ここに書いた値はレンダーで測ったものではありません。inkAt・tineShift・layerAt・tradeDens を Node でそのまま呼び、定数だけを変えて計算した値なので、上の測定表とは分けて読んでください。

BLOOM_RADIUS を96から200に広げると、しずく一つが画面をほぼ覆う

しずく一つが覆う面積は、画面の20.1%から87.3%に広がります。輪の数 RING_N 5はそのままなので帯一本が19.2pxから40pxに太くなり、中心から50px離れていたインクは108.2pxではなく206.2pxの位置まで押し出されます。新しいしずくが落ちるたびに前の模様の大半が画面の外へ出ていくので、幾重にも重なった目の代わりに大きな輪が一組だけ残ります。

COMB_TOOTH_GAP を14から34に開くと、羽が2.4倍に広がる

480pxの幅を通る歯は34.3本から14.1本に減り、引きが弱まる幅 λ は5.92pxから14.38pxに広がります。それでも半マスの位置での引きは、どちらの値でも29.3pxで同じです。λ が歯の間隔に比例して一緒に伸びるので、羽の形の比率は保たれ、大きさだけが変わります。

AGATE_LAYER_DENSITY を0.35から0.72に上げると、細い帯が太い石の目にまとまる

濃い層に入る標本の割合が32.0%から65.9%に、平均の密度が0.174から0.359に増えます。WARP_AMP 26はそのままなので、境界が曲がる最大の幅21.9pxは変わりません。帯の太さと曲がり方が別々の定数に結びついているので、石の目の重さと波打ち方を分けて選べます。

SPLIT_DEPTH を3から1に下げると、すき間が少し早く開く

オクターブが一枚だけだと利得マップには大きな谷だけが残り、その中の小さな谷が消えます。同じ160ステップのあとのすき間の割合は22.7%ではなく24.5%になり、枝が細かく割れる代わりに、太いすき間が何本か先に開きます。

パレットの色が近いとき・模様が画面より大きいとき・動きを減らす設定で壊れるところ

03の折りのコントラストを0.5にすると、明るさの段はそろうが折れた面の陰は残る

明るい面の明るさの段は 1 − FOLD_CONTRAST、折れた面は 0.42 × (1 − コントラスト) + 0.58 × コントラスト なので、コントラストが0.5になった瞬間に二つの面の段がどちらも0.5にそろいます。名前からはコントラストを上げるほどひだがくっきりしそうに見えますが、0.5を越えると段の値だけは折れた面のほうが高くなりますが、折れた面に混ぜる墨の量も FOLD_CONTRAST に比例して増えるので、折れた面がそのまま明るく見えるとは限りません。ちょうど0.5では段の差が消えても、折れた面に墨の色 INK を混ぜる行が別にあるので、陰がひと重ね残ります。

07のにじみの半径を広げると、混ざった色が濁る

BLEND_RADIUS が大きくなると一か所に重なる隣の点が増え、加重平均で混ざった色はパステル五色の平均に近づき、灰色がかった濁った色に寄っていきます。SCATTER_P を1に上げても似た崩れ方をして、814か所がすべて埋まり、点の模様ではなく均一な下地になります。

01の大きなしずくは前の模様を一気に押し出す

しずくが DROP_MAX の7個を超えると、いちばん古いしずくを捨てます。もう画面の外へ押し出されたはずだという前提ですが、画面の中央に大きなしずくが長く残っている状況では、そのしずくがある瞬間にまるごと消えたように見えます。ピクセルごとにしずくの数だけさかのぼるので、上限を上げればそのぶん計算も増えます。

07の伸びは右の点から画面の外へ押し出す

伸ばす処理は x 座標そのものに倍率を掛ける方式なので、左端の点はほとんど動かず、右の点ほど遠くへ押されます。1.9倍まで伸ばすと右半分の点は舞台の外へ出てしまうので、広い画面全体に敷くなら、伸ばす基準点を中央へ移すほうが合います。

08のねじれが層の間隔を越えると、帯が島のように途切れる

層と層のあいだは約18pxですが、WARP_AMP を上げてねじれがこの間隔を越えると、同じ層が一本の線の上を二度通って折り返します。すると続いているはずの帯が島のように途切れて、石というよりしみのように見えます。

06の王冠は外へ開かない

粒は輪の壁に沿って垂直に上がって落ちるだけの近似なので、本物の王冠のように縁が外へ開きながら割れる形は出ません。開きまで欲しいなら、粒ごとに外向きの速度をもう一つ持たせ、空中にいるあいだその値を、粒が輪の中心から離れた距離 b.r に足していけば近づきます。GRAVITY_FALL を小さくしすぎると飛行が RING_LIFE 1500msより長くなって輪が消えたあとも粒が浮いたままになり、縁の近くに落とすと粒が舞台の外へ出るので、ポインターの座標は横40〜W−40、縦60〜H−40の範囲に切り詰めています。

02は反応項が抜けると0.75秒で止まる

最初の版は EDGE_KEEP が0で、インクはぼやけたしみのように均一に広がったまま0.75秒で収束し、それ以降のフレームは一枚も変わりませんでした。逆に DENSITY_DIFF_K を上げて交換量がセルの密度の24%という上限によく当たるようになると、流れが上限で切られて、目が格子の形に固まります。

05は二つの櫛が同じ列を通ると互いを消す

STAGGER_PHASE が0なら、二つ目の櫛が一つ目と同じ列を逆向きに引き、二つの引きが同じ量のときは模様が最初の帯へ完全に戻ります。240×150のフィールドでは歯の間隔14pxがフィールドの7マスにあたり、間隔を6pxより狭めると歯一本がフィールドの3マスにも満たず、羽の代わりに崩れた線が残ります。

09の干渉の係数を上げると、節がかたまりになる

INTERFERE_K を上げると、二つの中心の輪が出会う節が墨の色の上限0.92に張りついて、点ではなくかたまりになります。RING_MAX_R 320を越えた輪は見えなくなっても、直近24本の中にいるあいだは計算され続けます。

小さなフィールドを引き伸ばした場所には段が見える

01は160×100を3倍に、02は96×60を5倍に伸ばして貼るので、帯やセルの境界が近くで見ると段になります。フィールドを大きくすれば段は減りますが、計算は縦横の積で増え、なかでも08はピクセルごとに4オクターブのノイズを二回、3オクターブのノイズを二回、240×150つまり3万6千ピクセルに回すので、九つの場面でいちばん重い場面です。

動きを減らす設定では06の王冠が上がる瞬間が見えない

動きを減らす設定がオンだと、06は finishSplash で同じ stepBead を粒がすべて着地するまで一度に回した結果だけを描きます。粒が落ちた位置はアニメーションと同じですが、王冠が立ち上がる場面そのものは出ず、09も広がる途中を飛ばして、広がり終えた輪を計算し直して見せます。

定数を一つずつ書き換えながら模様が崩れる境目を自分の手で探したいときは、下の zip をダウンロードして 95rs6qwq を入れて開いてください。素の HTML 版(vanilla/)と React 版(react/)が同じ定数名を使っているので、どちらで探しても記事の名前がそのまま見つかります。

インク・櫛・石の目の値を変える三つとアクセシビリティ

名前 変えた値 感触
インクの広がる半径を変える 01 BLOOM_RADIUS 96 → 200 しずく一つが画面の87.3%を覆い、帯一本が40pxに太くなって、重なった目の代わりに大きな輪が一組残ります。
櫛の歯の間隔を変える 05 COMB_TOOTH_GAP 14 → 34 歯が14.1本に減って羽は2.4倍に広がりますが、半マスの引き29.3pxは同じなので形の比率は保たれます。
石の目の層の密度を変える 08 AGATE_LAYER_DENSITY 0.35 → 0.72 濃い層の割合が65.9%に増えて太い帯にまとまり、曲がる幅21.9pxはそのままです。

マウスがなくても、九つの場面をすべて動かせます。キャンバスごとに tabindex="0" と項目名を入れた aria-label があり、キーは場面ごとに違います — 01は矢印キーで円のカーソルを動かして Enter・Space でしずくを落とし、02は矢印キーでインクを押し、03は左右で扇を回し、04は左右で櫛の向きを決めます。05は ↓ で一つ目の櫛、↑ で二つ目の櫛を引き、06は矢印キーのカーソルと Enter・Space のしずく、07は Enter・Space でまき直し、08は左右か Enter で別の断面、09は Enter・Space で墨を落とします。preventDefault はその場面が使うキーでだけ呼ぶので Tab の移動は止まらず、場面ごとに <button> が一つずつあり、キャンバスとボタンのどちらにも :focus-visible の枠が出ます。タッチでは touch-action: pan-y なので、舞台の上で縦に払ってもページがスクロールします。

状態表示の行は実演のあいだ aria-live="off" で静かにしていて、人が初めてポインター・キー・ボタンに触れた瞬間に wake() が実演を終わらせ、polite に切り替えます。自動の実演が画面読み上げソフトをしゃべらせ続けないための順番です。

prefers-reduced-motion は、九つの場面すべてでスクリプトの matchMedia とスタイルシートの両方から確かめています。この設定がオンになると rAF のループをそもそも始めず、完成した状態にそろえた場面を一枚だけ描いておきます。入力があっても途中を飛ばして目標へ移ります — 01は広がりきったしずくを足し、02は交換24回を一度に、03は0.28radの回転を、04は位相0.9radをすぐに当て、05は引きの目標値へ、06は粒がすべて着地した状態へ、07は散布と伸び0.55へ、08は断面の入れ替えへ、09は広がりきった輪へまっすぐ移ってから draw() を一度呼びます。

マーブル 背景 作り方 はCSSだけでどこまでできるか?

この記事で CSS がする仕事は舞台の枠です。.hs が width: min(92vw, calc(160vh - 12.8vw)) と aspect-ratio: 480 / 300 で大きさを決め、16pxの角の丸みと overflow: hidden、影を付けます。九つの場面のスタイルシートに @keyframes は一つもなく、動きはすべてスクリプトから来ています。グラデーションを重ねれば縞や同心円のような決まった模様は描けますが、01のように新しいしずくがそれまでのしずくをすべて押し出したり、02のように隣のマスどうしがインクをやり取りしたりする処理は、次の画面が直前の画面に左右されるので、規則だけでは書き表せません。

決まった模様をシードで描き直す方向ならCSS 背景パターンの9場面、グラデーションの層だけで空の色を変える例なら夕焼けグラデーションの9場面が近い記事です。ピクセルの計算をもっと深く見たいなら、水滴の屈折を扱ったキャンバスの特殊効果9種と、格子の上のインクの流れを解いた流体シミュレーション9種へ進んでください。バッファを画面に載せる部分は、putImageData と requestAnimationFrame の資料を基準に組みました。

マーブリングアート を自分のページに移す前によくある質問

模様の色を自分のサイトの配色に合わせるには、どこを直しますか?

九つのファイルのどれも、スクリプトの前のほう27〜34行目に、背景の PAPER(252, 246, 234)、墨の INK(58, 48, 76)、インク五色を入れた TINTS が同じ位置にあります。この配列を変えるだけで、計算はそのままに色だけを入れ替えられます。模様の上に文字を載せる予定なら、いちばん濃い色といちばん淡い色の両方で文字のコントラストを確かめてください。

手作業のマーブリング やり方 のように、読み込むたびに違う模様を出せますか?

いまはハッシュの呼び出しに渡すシードの数字が固定なので、毎回同じ模様になります。07なら hash2(gx, gy, 811) の811を、08なら warp の中の fbm2 に渡す101・313を別の数に変えると目が変わります。ファイルごとに SEED 463という定数も宣言されていますが、いまの計算関数はこの定数ではなく、呼び出しごとに書かれた数字を使っています。

ページ全体の背景に広げて敷いても重くなりませんか?

コストは画面の大きさではなく、フィールドのピクセル数についてきます。舞台が大きくなってもフィールドはそのままで引き伸ばして貼るだけなので計算量は同じで、その代わり伸ばした倍率のぶん段が粗くなります。いちばん重い08はフィールドを小さくするか、止めた一場面として使っても石の断面として十分に読めるので、動きが本当に要る場所かどうかを先に考えてみてください。

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

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