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

水彩 エフェクト 9種 — にじみ・顔料だまり・紙の目を計算で描く

水彩 エフェクトは、水を含んだ絵の具が紙に染みて広がる様子を、画面の上で一画素ずつ計算して描く表現です。絵の具のにじみは筆ではなく、水が乾く順番が作ります。03番は濡れたマスにだけ顔料を広げ、02番は乾くあいだに縁へ色を集め、01番は辺を割って揺らした多角形を薄く何層も重ねます。

筆で塗ったように見えるわけ — 水が乾く順番と紙の目が作るしみ

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

画面の水彩らしさは、色が紙に吸われていくように見えるかどうかで決まります。手がかりは四つで、輪郭がまっすぐ切れずに揺れること、乾いたしみの縁だけが濃く残ること、濡れた二色が触れた所でだけ混ざること、紙のくぼみに粒がたまることです。CSS が受け持つのは額縁だけで、.wc が板の幅を min(92vw, calc(160vh - 12.8vw))、aspect-ratio を 480 / 300 に決め、角の丸みと浮いた影と overflow: hidden を付けます。中の絵の具は一筆残らずキャンバス 2D の画素演算で描き、ライブラリも WebGL も画像ファイルも使いません。06 が水彩風に変える風景も、同じページの中で画素ごとに計算した絵です。

並び順にも意味があります。01〜04 は四つの振る舞いを一つずつ取り出し、05〜08 はそれをかすれ筆・画像の変換・水滴の花・重ね塗りという技法として使い、09 はそれまでの計算を一枚のファーストビューにまとめます。どの場面も 3.6〜4.6 秒かけて描き、完成した絵で 2.0〜2.6 秒止まり、左から水で洗い流して同じシードからまた描き始めます。

カフェのメニューやギャラリーのトップに置くなら

カフェのメニュー表なら、見出しの下に 05 の線を一本引くだけで十分です。線の終わりが毛先の筋に割れるので、まっすぐな下線より手書きらしく読め、一本は1秒ほどで描き終わるのでメニューの文字を邪魔しません。ギャラリーや展示のページには 09 が合います。空のにじみ、遠い山、手前の丘という順番が絵のできていく途中を見せ、描き終えたあとの 2.2 秒の静止で題名を読む時間が残ります。

招待状やブランドの背景に置くなら

スマホ向けの招待状のヘッダーには 02 が向いています。しみは中が淡く縁だけが濃く仕上がるので、真ん中に置いた名前や日付が読みやすいままです。大きな色の面がいくつかあればよいブランドのランディングページなら 01 の重ねたしみか 04 の粒立つ帯、もう少し飾りがほしい季節の企画バナーなら、色面の上に 07 の花びらの縁を浮かべる使い方が考えられます。描き終えると次の洗い流しまで絵が止まるので、その間は見出しや本文を落ち着いて読めます。

水彩 エフェクト 9種 — 重ねるしみ・乾いた縁・濡れたマスのにじみ・紙の目の顔料だまり・割れるかすれ筆・画像 水彩風 加工・塩の花にじみ・重ね塗りの混色・風景が描かれるファーストビュー

2秒の撮影で板のどれだけが変わったか

撮影ツールは 480×300 の板を2秒のあいだに24枚撮り、隣り合う23組を比べています。板の大部分を順に塗り替える 09 と 06 はどちらも面積の50%を超え、筆の線が4本横切るだけの 05 が20.8%でいちばん狭くなりました。

場面 累積面積 動いたフレーム 強度の平均
01 重ねるしみ 40.5% 17/23 15.9
02 乾いた縁 28.0% 20/23 21.0
03 濡れたマスのにじみ 29.6% 11/23 46.4
04 紙の目の顔料だまり 49.2% 10/23 78.4
05 かすれ筆 20.8% 17/23 76.3
06 画像の水彩風加工 52.0% 12/23 26.1
07 水滴の花にじみ 23.3% 19/23 18.3
08 重ね塗りの混色 28.8% 13/23 44.2
09 風景のファーストビュー 54.1% 18/23 36.6

23組すべてで動いた場面がないのは、絵の具が絶え間なくではなく時刻表どおりに置かれるためと考えられます。03 の16回の筆置きは0.16秒おきに来て途中で終わり、04 は後半を顔料が沈む強さのゆっくりした上昇に使うので、二枚の撮影のあいだの差がしきい値に届かない組が出ると考えられます。01 と 07 の強度が低いのは一回ごとの変化が小さいからで、01 の一層が足す濃さは0.042、07 の水滴が一歩で押し出す割合は BLOOM_PUSH 0.2 です。

01辺を割って揺らした多角形を薄く何十層も重ねる絵の具のしみ

四つのしみはそれぞれ10頂点の輪郭を一度揺らして20頂点の下地にし、40層のそれぞれがその下地を deformPolygon にさらに5回かけた640頂点の形として LAYER_ALPHA 0.042 ずつ足すので、全層が重なる中心は濃さ1.68まで育ち、縁は薄いまま残ります。下の関数は各辺の中点を、辺の長さ × BLOT_JITTER(0.45) × 0.5 × 釣り鐘形の乱数だけ辺の法線方向へ押し出し、短い辺ほど動きが小さいので大きなうねりの上に細かな揺れが乗って、花屋のランディングページで文字の後ろに大きな色の面を置く用途に向きます。描き終わりの640頂点の外周は873px、頂点一つの平均の折れは0.755ラジアン、縁で濃さが最大の10%から90%へ上がる幅は板の上で4.5pxでした。

辺を割って揺らした多角形を薄く何十層も重ねる絵の具のしみ
新しいタブで開く

01 · 辺を割って揺らした多角形を薄く何十層も重ねる絵の具のしみ — 計測 累積面積 40.474% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BLOT_JITTERLAYER_ALPHALAYERS_PER_BLOT
    function deformPolygon(pts, depth, jitter, rng) {
        let cur = pts;
        for (let d = 0; d < depth; d += 1) {
            const next = [];
            const n = cur.length / 2;
            for (let i = 0; i < n; i += 1) {
                const j = (i + 1) % n;
                const ax = cur[2 * i];
                const ay = cur[2 * i + 1];
                const bx = cur[2 * j];
                const by = cur[2 * j + 1];
                const len = Math.hypot(bx - ax, by - ay) || 1;
                const push = normalRandom(rng) * jitter * len * 0.5;
                next.push(ax, ay, (ax + bx) / 2 + (by - ay) / len * push, (ay + by) / 2 - (bx - ax) / len * push);
            }
            cur = next;
        }
        return cur;
    }

02乾くあいだに顔料が縁へ寄って濃い輪が残るしみ

しみが広がり終えると distanceToEdge がマスごとにいちばん近い乾いたマスまでの距離を求め、flowToRim は一歩ごとに濡れたマスの顔料を、境界までの距離がより短い隣へ距離の差の割合で分けて送り、水位が下がるほど送る量を増やします。境界のマス(距離1以下)は送り先がないのでそこに顔料がたまり、スマホ向け招待状のヘッダーに使っても中央は淡いまま保たれます。乾ききったあとの縁の帯は内側より4.95〜5.19倍濃いのに、三つのしみの顔料の総量は1714.8・1346.4・885.6のまま減っておらず、色は増えたのではなく移っただけです。

乾くあいだに顔料が縁へ寄って濃い輪が残るしみ
新しいタブで開く

02 · 乾くあいだに顔料が縁へ寄って濃い輪が残るしみ — 計測 累積面積 27.987% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RIM_FLOWDRY_TIMERIM_SMOOTH
                if (dist[i] <= 1) {
                    continue;
                }
                const wl = downhill(dist, mask, i, l);
                const wr = downhill(dist, mask, i, r);
                const wu = downhill(dist, mask, i, u);
                const wd = downhill(dist, mask, i, d);
                const sum = wl + wr + wu + wd;
                if (sum <= 0) {
                    continue;
                }
                const move = f[i] * frac;
                flowTmp[i] -= move;
                flowTmp[l] += move * wl / sum;
                flowTmp[r] += move * wr / sum;
                flowTmp[u] += move * wu / sum;
                flowTmp[d] += move * wd / sum;

03濡れたマスにだけ広がり、二色が出会って混ざるにじみ

水の量とコーラル・空色の二つの顔料を格子で持ち、wetDiffuse は右と下の隣との差を、自分の水と隣の水の小さいほう(Math.min(wi, water[j]))で縛ってやり取りするので、水が0の隣とは何も動かず、絵本紹介ページの表紙のように二色を真ん中で出会わせる絵に向きます。一歩ごとにこの拡散を DIFFUSE_SUBSTEPS 14回 × 二つの顔料、DIFFUSE_RATE 0.18 で回し、やり取りなので顔料の総量は保たれます。二つの顔料がどちらも0.05を超えて入ったマスは1.5秒の0から描き終わりには760マスまで増えましたが、最初から乾いていたマスへ漏れた顔料は0でした。

濡れたマスにだけ広がり、二色が出会って混ざるにじみ
新しいタブで開く

03 · 濡れたマスにだけ広がり、二色が出会って混ざるにじみ — 計測 累積面積 29.567% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DIFFUSE_RATEDIFFUSE_SUBSTEPSEVAPORATION
                const i = y * gw + x;
                const wi = water[i];
                if (wi <= 0) {
                    continue;
                }
                if (x + 1 < gw) {
                    const j = i + 1;
                    const g = Math.min(wi, water[j]);
                    if (g > 0) {
                        const flow = k * g * (before[j] - before[i]);
                        p[i] += flow;
                        p[j] -= flow;
                    }
                }
                if (y + 1 < gh) {
                    const j = i + gw;
                    const g = Math.min(wi, water[j]);
                    if (g > 0) {
                        const flow = k * g * (before[j] - before[i]);
                        p[i] += flow;
                        p[j] -= flow;
                    }
                }

04紙の目のくぼみに顔料の粒がたまる下地

なめらかなノイズを二層重ねた紙の高さを持ち、compose が顔料の濃さに Math.max(0, 1 + gran[k] * (1 - 2 * h)) を掛けるので、沈みきると谷(高さ0)は1.8倍、山(高さ1)は0.2倍の濃さになり、手作り文具店の商品詳細の背景のような紙の手ざわりが出ます。沈む強さは GRANULATION 0.8 を上限に、塗ってから0.45秒たったあと SETTLE_TIME 1.3秒かけて上がり、紙の凹凸の陰影は PAPER_RELIEF 1.8 で左上からの光を受けます。空色の帯の中央の行は、平均の明るさが178.5から179.5とほとんど変わらないまま、ばらつき(標準偏差)が塗った直後の7.07から沈みきったあとの15.17へ広がり、粒の明暗だけが2.1倍になりました。

紙の目のくぼみに顔料の粒がたまる下地
新しいタブで開く

04 · 紙の目のくぼみに顔料の粒がたまる下地 — 計測 累積面積 49.166% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GRANULATIONSETTLE_TIMEPAPER_RELIEF
                const h = PAPER_HEIGHT[p];
                let lr = 0;
                let lg = 0;
                let lb = 0;
                for (let k = 0; k < n; k += 1) {
                    const f = fields[k];
                    let d = (f[i00] * (1 - tx) + f[i10] * tx) * (1 - ty) + (f[i01] * (1 - tx) + f[i11] * tx) * ty;
                    if (d <= 0) {
                        continue;
                    }
                    if (gran[k] !== 0) {
                        d *= Math.max(0, 1 + gran[k] * (1 - 2 * h));
                    }
                    const pk = pigs[k];
                    lr += pk.r * d;
                    lg += pk.g * d;
                    lb += pk.b * d;
                }

05筆が速くなるほど水が減って毛先が割れるかすれ筆の線

決めておいた3次曲線に沿って筆を動かし、update は進み具合を STROKE_EASE 1.8 乗して終わりほど速くしながら、残りの水を (DRY_BASE + SPEED_DRAIN * speed) * dt ずつ減らすので、速く走る区間ほど水が早く尽きます。16本の毛はそれぞれ0.04〜0.62のあいだの別々のしきい値を持ち、そのしきい値はノイズで±0.08揺れます。残りの水がその値を下回ると、毛全体が消えるのではなく、その位置の点だけが打たれずに飛ばされるので、カフェのメニュー表の見出しの下線にすると線の終わりが筋に割れて手書きらしく見えます。4本の線で最初の毛が途切れたのは曲線の37〜48%の地点で、終わりの速さが740px/秒を超えた2本は16本の毛がすべて途切れました。

筆が速くなるほど水が減って毛先が割れるかすれ筆の線
新しいタブで開く

05 · 筆が速くなるほど水が減って毛先が割れるかすれ筆の線 — 計測 累積面積 20.835% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BRISTLE_COUNTSPEED_DRAINSTROKE_EASE
    function update(dt, t) {
        for (let k = 0; k < STROKES.length; k += 1) {
            const s = STROKES[k];
            if (t < s.start || strokeU[k] >= 1) {
                continue;
            }
            const u = Math.pow(phaseOf(t, s.start, s.duration), STROKE_EASE);
            const speed = (u - strokeU[k]) * strokeLen[k] / dt;
            strokeWater[k] = Math.max(0, strokeWater[k] - (DRY_BASE + SPEED_DRAIN * speed) * dt);
            const w = strokeWater[k];
            brushSegment(strokeFields[k], OW, OH, s.curve, strokeU[k], u, w, bristleSets[k], BRUSH_HALF, lerp(DOT_DRY, DOT_WET, clamp(w, 0, 1)), BRISTLE_INK * (0.5 + 0.5 * w), BRISTLE_WOBBLE);
            strokeU[k] = u;
        }
    }

06風景の絵を明るさの段階と揺れた境目の絵の具の絵に変える画像の水彩風加工

元画像は同じページで計算した風景の絵(空のグラデーション・太陽・丘・家・木)で、loadPhoto がそれを見えないキャンバスに置いて getImageData で読み直し、buildPainted が境目を揺らしてから明るさを COLOR_LEVELS 5段階に丸めます。丸めるときは三つのチャンネルに同じ比率を掛けるので色相は変わらず、隣との明るさの差が大きい所を最大 EDGE_DARKEN 0.38 の割合で暗くして紙の目を掛け、旅行写真ブログのアイキャッチ画像に使う想定の一枚が左から掃くように塗り替わります。8ビットの色の数は元の995から段階化と揺らしのあとで858に減り、縁と紙の目を足すと画素ごとの陰影の違いで9,764に増え、縁で15%以上暗くなった画素は3.0%でした。

風景の絵を明るさの段階と揺れた境目の絵の具の絵に変える画像の水彩風加工
新しいタブで開く

06 · 風景の絵を明るさの段階と揺れた境目の絵の具の絵に変える画像の水彩風加工 — 計測 累積面積 51.997% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

COLOR_LEVELSEDGE_JITTEREDGE_DARKEN
            for (let x = 0; x < OW; x += 1) {
                // ① 境目を揺らす — 読む位置をノイズの分だけずらし、色面の縁が筆の跡のようにでこぼこになる
                const sx = clamp(Math.round(x + (noise2(x / 7, y / 7, SEED + 11) - 0.5) * 2 * EDGE_JITTER), 0, OW - 1);
                const sy = clamp(Math.round(y + (noise2(x / 7 + 40, y / 7, SEED + 12) - 0.5) * 2 * EDGE_JITTER), 0, OH - 1);
                const o = (y * OW + x) * 3;
                // 細かなむらをならす — ずらした位置とその四つの隣の平均を読む
                let r = 0;
                let g = 0;
                let b = 0;
                for (let q = 0; q < 5; q += 1) {
                    const qx = clamp(sx + (q === 1 ? -1 : q === 2 ? 1 : 0), 0, OW - 1);
                    const qy = clamp(sy + (q === 3 ? -1 : q === 4 ? 1 : 0), 0, OH - 1);
                    const s = (qy * OW + qx) * 3;
                    r += photo[s] * 0.2;
                    g += photo[s + 1] * 0.2;
                    b += photo[s + 2] * 0.2;
                }
                // ② 色の段階を減らす — 明るさを COLOR_LEVELS 段階に丸め、三つのチャンネルに同じ比率を掛けて色相は守る。続いて紙の色のほうへ PAPER_MIX の分だけ
                const l = r * 0.299 + g * 0.587 + b * 0.114;
                const ratio = Math.round(clamp(l, 0, 1) * top) / top / Math.max(l, 0.02);
                stepped[o] = lerp(clamp(r * ratio, 0, 1), pr, PAPER_MIX);
                stepped[o + 1] = lerp(clamp(g * ratio, 0, 1), pg, PAPER_MIX);
                stepped[o + 2] = lerp(clamp(b * ratio, 0, 1), pb, PAPER_MIX);
            }

07濡れた色面に澄んだ水滴が落ちて花びらの縁へ押し出されるにじみ

まだ濡れている色面に水滴が落ちると、pushBloom は角度ごとにノイズで変わる半径の内側を澄んだ水で濡らし直し、半径の外側の帯にあるマスだけ、顔料を水滴の中心から遠い隣へ押し出します。水が WET_MIN 0.12 に届かないマスでは押し出さないので、乾いた所に落ちた水滴は花を作らず、春の季節企画のバナーなら色面を塗ってすぐに水滴を落とす順番が大事になります。描き終わりの6つの花は縁の帯が花の内側より3.40〜3.94倍濃く、顔料の総量は7,272.94のまま変わりませんでした。

濡れた色面に澄んだ水滴が落ちて花びらの縁へ押し出されるにじみ
新しいタブで開く

07 · 濡れた色面に澄んだ水滴が落ちて花びらの縁へ押し出されるにじみ — 計測 累積面積 23.299% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PETAL_NOISEBLOOM_PUSHBLOOM_GROW
                const i = y * gw + x;
                if (water[i] < wetMin) {
                    continue;
                }
                const dx = x + 0.5 - cx;
                const dy = y + 0.5 - cy;
                const dist = Math.hypot(dx, dy);
                const rr = bloomRadius(d, grow, Math.atan2(dy, dx), petal) * k;
                if (dist >= rr) {
                    continue;
                }
                // しずくの内側は澄んだ水でもう一度濡らす。押し出しは前縁側の帯(半径の外側 band の割合)だけ — 中心には薄い顔料が残る
                water[i] = 1;
                if (dist < rr * (1 - band)) {
                    continue;
                }
                const j = (y + Math.round(dy / dist)) * gw + (x + Math.round(dx / dist));
                if (water[j] < wetMin) {
                    continue;
                }
                const move = field[i] * frac;
                delta[i] -= move;
                delta[j] += move;

08透明な層を重ね塗りするほど光を掛け算でこす重ね塗りの混色

層ごとに光をどれだけ返すかをチャンネル別の割合として持ち、左の板の glazeAbsorb は重なるたびに 1 - a * (1 - layers[k][0] / 255) のような係数を掛け、右の板の glazeAlpha は今の色を層の色へ量 a だけ引き寄せます。紙の上で青の層(GLAZE_BLUE)に黄色の層(GLAZE_YELLOW)を重ねた所は、「反射率の掛け算」が180,187,153と緑へ沈み、「透明度の混ぜ合わせ」は206,203,159にとどまるので、美術の授業資料の混色ガイドとして左右を並べると違いがすぐ分かります。重ねる順番を入れ替えると掛け算は[180,187,153]のままですが、透明度の混ぜ合わせだけが[181,193,183]へ変わり、上に乗せた色が勝ちます。

透明な層を重ね塗りするほど光を掛け算でこす重ね塗りの混色
新しいタブで開く

08 · 透明な層を重ね塗りするほど光を掛け算でこす重ね塗りの混色 — 計測 累積面積 28.826% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GLAZE_ALPHAGLAZE_SHEETSLAYER_TIME
    function glazeAbsorb(base, layers, amounts) {
        let r = base[0];
        let g = base[1];
        let b = base[2];
        for (let k = 0; k < layers.length; k += 1) {
            const a = amounts[k];
            r *= 1 - a * (1 - layers[k][0] / 255);
            g *= 1 - a * (1 - layers[k][1] / 255);
            b *= 1 - a * (1 - layers[k][2] / 255);
        }
        mixR = r;
        mixG = g;
        mixB = b;
    }

09空のにじみからかすれ筆の丘まで順に描かれる水彩風景のファーストビュー

紙の目を先に敷き、0.2秒から空のにじみ、1.4秒から遠い山の重ね塗り、2.5秒から手前の丘のかすれ筆、3.5秒から水滴の花を順に重ね、4.6秒で完成させて6.8秒に水で洗い流します。下の部分は一歩ごとに空の三つの顔料へ水滴の押し出しを一回、SKY_DIFFUSE 0.3 の拡散を SKY_SUBSTEPS 8回かけ、稜線の多角形を一枚ごとに揺らし直して RIDGE_ALPHA 0.075 ずつ RIDGE_SHEETS 9枚まで重ねる箇所で、ギャラリーサイトのファーストビューに置くと同じシードから毎回同じ風景が描かれます。水滴が落ちる3.5秒の時点で空にはまだ濡れたマスが7,776あり、にじみと水滴を通しても空の顔料の総量は6,591.07のままでした。

空のにじみからかすれ筆の丘まで順に描かれる水彩風景のファーストビュー
新しいタブで開く

09 · 空のにじみからかすれ筆の丘まで順に描かれる水彩風景のファーストビュー — 計測 累積面積 54.138% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SKY_DIFFUSERIDGE_ALPHAHERO_PETAL
        // ⑤ 水滴の花 — まだ濡れた空に澄んだしずくを落とし、顔料を花びらの縁へ押す
        for (let k = 0; k < HERO_DROPS.length; k += 1) {
            const d = HERO_DROPS[k];
            if (t < d.at) {
                continue;
            }
            const grow = 1 - Math.exp(-(t - d.at) / 0.3);
            for (let f = 0; f < 3; f += 1) {
                pushBloom(skyFields[f], skyWater, GW, GH, d, grow, HERO_PETAL, HERO_PUSH, 0.4, 0.1, skyTmp);
            }
        }
        for (let s = 0; s < SKY_SUBSTEPS; s += 1) {
            for (let f = 0; f < 3; f += 1) {
                wetDiffuse(skyFields[f], skyWater, GW, GH, SKY_DIFFUSE, skyTmp);
            }
        }
        // ② 遠い山の重ね塗り — 稜線の多角形を一枚ごとに揺らし直して薄く重ねる
        for (let k = 0; k < RIDGES.length; k += 1) {
            const want = Math.floor(RIDGE_SHEETS * phaseOf(t, RIDGES[k].start, 0.8));
            while (ridgeSheets[k] < want) {
                const sheet = deformPolygon(ridgeShapes[k], 4, RIDGE_JITTER, makeRng(SEED * 10 + k * 100 + ridgeSheets[k]));
                fillPolygon(RIDGES[k].field, GW, GH, sheet, RIDGE_ALPHA);
                ridgeSheets[k] += 1;
            }
        }

水彩 にじみ やり方を画面で再現するには、何を計算すればよいのか?

色を置くのは簡単な半分です。しみが濡れて見えるかどうかは、置いた色がそのあとどこへ行くかで決まり、それは塗りつぶしを一回呼ぶだけでは持てない量に左右されます。

水・顔料・紙の高さという三つの量

どの場面もマスごとに三つを追います。そこにある水の量、顔料の量、その位置の紙の高さです。水は顔料が移れる場所を決め(03・07)、下がっていく水位は顔料が縁へ向かう時を決め(02)、値ノイズ二層の高さは粒のたまる所と左上からの光の当たり方を決めます(04)。9場面とも Math.random を呼ばずにシード付きの乱数から引くので、同じシードなら一巡りごとに一筆違わず同じ絵になります。

色は足さずに掛ける

compose は顔料ごとに、白い紙に一層塗ったとき返ってくる光をチャンネル別の対数の割合で持ち、足し合わせた濃さを「紙 × 割合^濃さ」として光に戻します。層は掛け算で深まるので、色の濃い所は灰色に濁らず、その顔料自身の色相へ沈みます。水彩 アニメーション 作り方を調べ始めた人がつまずきやすいのは混ぜると色が白っぽく浅くなる点で、半透明の重ね描きだけで作ると 08 の右の板の色に近づくと考えられます。

一様なぼかしとの違い

画像に水彩 にじみ ぼかしを付けたいとき、全体を一様にぼかすと乾いているはずの所まで色が流れます。03 は水のあるマスでしか顔料を動かさないので、にじみは濡れた範囲の内側で止まり、その外側の紙は白いまま残ります。

近い記事との違い

マーブル効果の記事は水に浮いたインクの膜を押したり櫛で引いたりするので、紙に染みこむ動きはありません。流体グリッドの記事は煙や染料のために速度の場を解きますが、03 には速度がなく、水を持つマス同士の拡散だけです。紙の高さに使う値ノイズはノイズで作る地形の記事と根が同じで、ピクセルアートへの変換が画素をブロックにまとめるのに対し、06 は明るさを段階に切るだけで色相を残します。

揺らした多角形は層で、顔料は濡れたマスの拡散で、縁は乾くまでの距離で — 絵の具一滴を画面に置く計算

重い計算は160列、色は320列で

水・拡散・距離は GW×GH = 160×100 の格子(1マスが板の3px)で、色の合成と紙の目は 320×200 の画素の格子で計算します。結果は Float32 のバッファに入り、present() が createImageData で一度だけ作った ImageData に8ビットで写して見えないキャンバスへ置き、imageSmoothingQuality を 'high' にして 480×300 の板へ引き伸ばして描きます。デバイスピクセル比の上限は2で、フレームごとに新しい配列や ImageData を作りません。

1/30秒の固定の歩みと自動デモの先回り

二つの requestAnimationFrame の時刻の差は DT_MAX 0.05秒で切られ、STEP 1/30秒ずつの歩みとして消費されるので、フレームレートが違っても同じシードなら同じ絵になります。各場面は時計が PAINT_TIME の内側にあるあいだだけ更新し、CYCLE を過ぎると時計を0に戻して reset() から描き直します。自動デモは描き始める前に DEMO_START(0.9〜1.4秒)まで先に計算しておくので、最初に見える画面は白紙ではなく、すでに塗りかけのしみです。

縁までの距離は上下二回のなぞりで

02 には、濡れたマスごとにいちばん近い乾いたマスまでの距離が要ります。distanceToEdge は上から下へ一回、下から上へ一回なぞり、各マスは隣の値に縦横なら1、斜めなら1.414を足したうちの最小を取り、そのあと softenDistance が隣の平均を二回混ぜて階段状の段差をならします。

別のドメインの写真で止まるピクセル読み取り・数百層で濁る8ビットの色・広い画面で重くなる拡散の格子 — 絵の具が壊れやすい場面

他のドメインの写真の画素を読むとき

06 の loadPhoto は計算した風景を見えないキャンバスに置いて getImageData で読み直しますが、この呼び出しを try と catch で包み、読めなかったときは計算しておいた元画像をそのまま使います。デモは元画像を同じページで描くので、キャンバスは汚染されません。許可のない別ドメインの写真を描くとキャンバスが汚染され、まさにこの呼び出しでセキュリティエラーが投げられるため、実際の写真には画像側の crossOrigin 属性と、それを許すサーバーの応答の両方が必要です(MDN のオリジン間の画像の解説)。この部分を書き替えるなら、パスワード 5tkwg3wj で zip を開き、06 のフォルダにある loadPhoto から手を付けてください。

薄い色を何百回も重ねるとき

キャンバスの画素は0〜255の整数なので、混ぜるたびに丸められます。残りの差 × 透明度が0.5を下回った瞬間に値は動かなくなり、目標の 0.5 ÷ 透明度 段手前で止まります。紙の色[250,245,232]にコーラル[240,148,132]を透明度0.042で400回重ねると、8ビットでは[250,159,143]で止まり、Float32 では[240,148,132]に届きますが、赤のチャンネルは差10 × 0.042 = 0.42 なので一度も動きません。透明度を1%まで薄くすると8ビットの結果は[250,198,182]で止まり、薄く何度も重ねるほど遠くで止まって色があせ、チャンネルごとに止まる位置が違うので色相までずれます。01 は濃さを Float32 の格子に足し、しみ一つの層を LAYERS_PER_BLOT 40 にまとめることで、この問題を避けています。

拡散を画面の解像度のまま回すとき

開発時に開発機の Node.js で測った値では、wetDiffuse 一回が 160×100 の格子(16,000マス)で0.081ms、960×600(480×300 をピクセル比2で、576,000マス、36倍)で3.01msでした。03 は一歩に14回 × 二つの顔料 = 28回呼ぶので、合計は2.26ms対84.2msになり、大きいほうは1/30秒の一歩の予算33msの2.5倍です。時間は機械ごとに違いますが、効くのは36倍という比のほうで、そのため拡散は160列で回し、色の合成だけを320列で行います。

多角形の揺れを小さく、拡散を速く、重ね塗りを濃く — 値一つで変わる絵の水気

どの行もソースの定数を一つだけ変え、同じ関数をもう一度呼んで確かめた結果です。

バリエーション 変える値 変わること
多角形の揺れを小さく 01 BLOT_JITTER 0.45 → 0.2(343行) 頂点一つの平均の折れが0.755 → 0.37ラジアン、640頂点の外周が873 → 573px、縁の10%→90%の幅が4.5 → 1.5pxに狭まり、中心の濃さは1.68のままで、しみより切り紙に近く見えます
拡散を速く 03 DIFFUSE_RATE 0.18 → 0.3(312行) 二つの顔料がどちらも0.05を超えるマスが描き終わりで760 → 1,096(+44%)、3.5秒で579 → 799に増えて紫の帯が太くなり、乾いたマスへの漏れはどちらの値でも0です
重ね塗りを濃く 08 GLAZE_ALPHA 0.35 → 0.6(337行) 掛け算で青の上に黄色を重ねた所が[180,187,153] → [130,149,101]、色相72° → 84°、明るさ181.0 → 137.8に深まり、透明度の混ぜ合わせは204,191,108へ動き、青を二層重ねただけでも明るさが159.8 → 111.5に沈みます

一行目は形の輪郭、二行目は混ざる範囲、三行目は色の深さだけが動き、ほかの値はソースのままです。

止まっても読めるように — 動きを減らす設定で完成した一枚に止める・一時停止ボタン・絵の説明文のアクセシビリティ

動きを減らす設定での完成した一枚

9場面とも matchMedia('(prefers-reduced-motion: reduce)') とその change イベントを聞き、設定がオンなら showFinished() を呼びます。同じシードで PAINT_TIME まで一気に計算した完成の一枚を、繰り返しも洗い流しもなしで見せるので(MDN の prefers-reduced-motion)、動きを止めた人にも、動く版がたどり着くのと同じ絵が届きます。

一時停止ボタンとキーボード

どの場面にも右下に「一時停止」の丸いボタンがあり、type="button" と aria-pressed を持つふつうの button なので、Enter と Space は追加のコードなしで効きます。押すとインクの地にクリームの文字から、クリームの地にインクの文字へ反転します。キーボードのフォーカスは :focus-visible の3pxのインク色の外枠で見え、タブが隠れているあいだは描画を止め、戻ると一時停止中や動きを減らす設定のとき以外は続きから描きます。

絵の説明文と09の状態の行

各キャンバスは role="img" と、絵を説明する aria-label の文を持ちます。01 なら「コーラル・ラベンダー・バター・空色のしみが薄い層で一枚ずつ重なり、中心は濃く縁はにじむ絵。」です。09 には今の段階を「空のにじみ」「遠い山の重ね塗り」のように示す状態の行もあり、その aria-live は自動デモのあいだ off、最初の入力のあと polite に切り替わるので、誰も始めていないデモに読み上げが割りこみません。

サイトに水彩の背景を自前で描く前のよくある質問

絵の具をブランドの色に替えるにはどうすればよいですか?

色はスクリプトの上のほうに RGB の配列で並んでいて、PAPER [250,245,232]、SKY [146,192,230]、CORAL [240,148,132] などがあり、08 だけは GLAZE_BLUE・GLAZE_YELLOW・GLAZE_ROSE を別に持ちます。共有パレットの値はどれも白い紙に一層塗ったときの色で、compose が対数の割合に変えるため、暗い値ほど層が重なると急に深まります。ブランドの色より一段明るい値から試すのが無難です。

スマホで動かすとカクつきませんか?

重い計算は160×100のマスで回し、ピクセル比の上限は2、ImageData は一枚を使い回し、タブが隠れているあいだは描画を止めます。端末が追いつかないときは一フレーム分の時間を0.05秒で切るので、歩みを飛ばすより絵がゆっくり進むほうになると考えられます。

06 の画像 水彩風 加工に自分の写真を使えますか?

読み戻せる画像なら使えます。loadPhoto の中で 320×200 の元画像用キャンバスに描き、別ドメインから来る画像なら crossOrigin とサーバー側の許可をそろえます。そろわなければ getImageData が例外を投げ、catch が計算した風景をそのまま残します。

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

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