この記事は100% AIによって出力したものであり,使用したモデルはGPT-5.5である.
序
このサイトの背景には,薄い透明水彩のような紙と滲みを置いている. 見た目はただの装飾だが,実装は一枚画像ではない. WebGPUで紙を描き,WebGL2で濡れた顔料を少しだけ動かし,Canvas 2Dのfallbackを持ち,mobileでは静的な背景へ落とす.
最初に大事なのは,これは「Web上で正確な水彩シミュレーションを作る」話ではない. 文章サイトの背景に,正確な流体シミュレーションはいらない. むしろ正確にやろうとした瞬間に,背景が本文より偉くなる.
背景に必要なのは,次のくらいでよい.
紙に凹凸と繊維がある
顔料が均一な半透明色ではない
濡れている間だけ少し動く
乾いたら紙に固定される
動かせない環境では静かに止まる
本文,リンク,スクロールを邪魔しない
実装を読み直すと,水彩らしさはshaderの派手さよりも,この分割で作っていることが分かる. 紙,濡れ,乾き,入力,fallbackを別々にして,それぞれの失敗範囲を小さくする. この設計がほぼすべてである.
背景は強くならない
画面上の層はだいたいこうなっている.
paper fallback span
paper fallback canvas
paper WebGPU canvas
brush canvas
page content
controls
Vue componentのtemplateは,layerを置くだけに近い.
<div class="watercolor-bg-container" aria-hidden="true">
<span ref="fallbackLayerElement" class="paper-fallback" />
<canvas ref="fallbackCanvasElement" class="paper-fallback-canvas" aria-hidden="true" />
<canvas ref="paperCanvasElement" class="paper-canvas" aria-hidden="true" />
<canvas ref="brushCanvasElement" class="brush-canvas" aria-hidden="true" />
</div>
aria-hidden="true"なのは,これは内容ではなく素材だからだ.
screen readerにcanvasの存在を知らせる必要はない.
CSSでも,container自体は巨大なfixed overlayにしない. 固定されるのは個々のcanvasで,containerは本文の上に覆いかぶさらない.
.watercolor-bg-container {
position: static;
pointer-events: auto;
background: transparent;
}
.paper-fallback-canvas,
.paper-canvas,
.brush-canvas {
position: fixed;
inset: 0;
pointer-events: none;
}
pointer-events: noneがかなり重要である.
背景は画面全体にあるが,リンクもスクロールも奪わない.
見た目は広いが,DOM上の権限は弱い.
文章サイトの装飾は,このくらい弱い方がよい.
islandも常に起こさない
この背景はVuerendのislandとして登録している. ただし,すべてのviewportでhydrateするわけではない.
const desktopIslandMedia = "(min-width: 769px)";
export const WatercolorBackgroundIsland = defineIsland("watercolor-background", {
component: WatercolorBackgroundIslandView,
load: () => import("./features/ShellWatercolorBackgroundIslandLoader"),
hydrate: "media",
media: desktopIslandMedia,
});
desktop幅のときだけclient codeを読む.
mobileではCSSでcanvasを隠し,bg-sp.pngを使う.
@media (max-width: 768px) {
html {
background: #f1eedf url("/bg-sp.png") center center / cover no-repeat;
}
.paper-fallback {
opacity: 1 !important;
background: #f1eedf url("/bg-sp.png") center center / cover no-repeat;
}
.paper-fallback-canvas,
.paper-canvas,
.brush-canvas {
display: none;
}
}
これは妥協というより,背景としての仕様だと思う. touch-firstな環境で,水彩brushを有効にしても嬉しさより事故の方が大きい. mobileでは静的な紙を出す. それで十分なところでは,十分なことだけをする.
pointer入力も同じで,fine pointerの環境だけ受ける.
export function canUseFinePointerPaint(): boolean {
return window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
背景の実装は,「何をできるか」だけでなく,「どこではやらないか」を先に決めると安定する.
紙は先に出す
WebGPUの紙shaderは非同期で初期化される. だから,その前にCanvas 2Dでfallback紙を描く.
renderPaperFallback(fallbackCanvasElement.value, maxDpr);
brushLayer.resize();
removeViewportListeners = addShellViewportListeners(queueResize);
fallback紙は単色ではない. 薄いradial wash,shadow patch,highlight patch,fiber,grain tileを重ねる.
context.fillStyle = "#f1eedf";
context.fillRect(0, 0, width, height);
drawRadialWash(context, width, height);
context.globalCompositeOperation = "multiply";
drawPaperPatches(context, width, height, random, Math.round(820 * areaScale), "shadow");
context.globalCompositeOperation = "screen";
drawPaperPatches(context, width, height, random, Math.round(520 * areaScale), "highlight");
WebGPUが成功したら,fallbackのopacityを下げる. 完全には消さない.
target.fallbackLayer?.style.setProperty("opacity", "0.1");
target.fallbackCanvas?.style.setProperty("opacity", "0.16");
この順番が大事である. WebGPUが速ければshaderの紙が見える. 遅ければfallback紙が見える. 失敗しても,ただ紙が残る. 背景の失敗としては,それでよい.
紙はWebGPUで一枚だけ焼く
WebGPU側は,ShellSitePaper.wgslをraw importして使っている.
import sitePaperShaderSource from "./ShellSitePaper.wgsl?raw";
export const sitePaperShader = sitePaperShaderSource;
adapterはlow-powerで要求する.
const adapter = await gpu.requestAdapter({ powerPreference: "low-power" });
背景の紙を描くために,高性能GPUを起こす必要はない. 描画もfullscreen triangle一枚で済ませる.
var positions = array<vec2f, 3>(
vec2f(-1.0, -1.0),
vec2f(3.0, -1.0),
vec2f(-1.0, 3.0)
);
DPRは1.25で切っている.
const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
紙のざらつきは,native DPRまで上げなくても読める. 背景に必要な解像度と,画面の物理解像度は同じではない. ここで欲しいのは精密な紙の顕微鏡画像ではなく,本文の後ろで紙だと分かることだ.
紙は動かさない
紙shaderにはtimeを渡していない. 紙の座標はviewportとseedから決まる.
let aspect = uniforms.resolution.x / max(uniforms.resolution.y, 1.0);
let centered = (input.uv - 0.5) * vec2f(aspect * 1.72, 1.72);
let point =
rotate(centered * vec2f(1.0, 1.04), -0.06)
+ vec2f(uniforms.seed * 6.4, uniforms.seed * 3.2);
紙が読んでいる最中に動くと,水彩紙ではなく演出になる. 背景に置く紙は,動かない方が強い.
shaderの中では,単なる色noiseではなく,高さ場を作っている.
embossField,microGrainField,fiberField,celluloseNetworkField,coldPressToothField,crumpleField,wrinkleLineFieldのような場がある.
それらをpaperHeightへ混ぜる.
return macroRelief * 0.34
+ detail.x * 2.16
+ micro * (0.76 + uniforms.fiberDensity * 0.16 + uniforms.grainScale * 0.1)
+ (emboss - 0.18) * (0.18 + uniforms.embossDepth * 0.24)
+ (tooth.y - 0.28) * (0.46 + uniforms.embossDepth * 0.34)
- (tooth.x - 0.18) * (0.34 + uniforms.shadowStrength * 0.22)
+ (grain - 0.5) * 0.018
+ (pulp - 0.3) * 0.018;
tooth.yは凸部,tooth.xは凹部として効く.
この符号の違いが,紙の上に顔料が乗ったときの説得力になる.
紙は白い背景ではなく,顔料が沈む地形である.
紙shaderの処理を手順にすると,こうなる.
1. uvを紙座標へ変換する
2. 低周波のmacro reliefを作る
3. fiber / fibril / cellulose / tooth / pulpの場を別々に作る
4. それらを高さfieldへ合成する
5. 高さfieldを近傍sampleして法線を作る
6. 法線,局所relief,toothからcavity / ridge maskを作る
7. 暖かい白にcavity shadowとridge highlightを混ぜる
法線は解析的に出していない.
paperHeightを少しずらしてsampleし,差分から作っている.
fn paperNormal(point: vec2f) -> vec3f {
let epsilon = 0.00076;
let center = paperHeight(point);
let dx = paperHeight(point + vec2f(epsilon, 0.0)) - center;
let dy = paperHeight(point + vec2f(0.0, epsilon)) - center;
let strength = 3.1 + uniforms.embossDepth * 2.2 + uniforms.grainScale * 1.1;
return normalize(vec3f(-dx * strength, -dy * strength, 1.0));
}
その法線と紙目から,凹みと山を別々に見る.
let cavityMask = saturate(
max(0.0, -localRelief * 1.84)
+ tooth.x * 0.68
+ (1.0 - normal.z) * 0.4
+ max(0.0, -diffuse) * 0.16
+ crumple.x * 0.08
+ wrinkleLines.x * 0.05
);
let ridgeMask = saturate(
max(0.0, localRelief * 1.42)
+ tooth.y * 0.62
+ detail.y * 0.06
+ max(0.0, diffuse) * 0.18
+ crumple.y * 0.06
+ wrinkleLines.y * 0.04
);
ここでcavityを暗くしすぎないのも大事である.
背景紙なので,凹凸が見えても本文のcontrastを奪ってはいけない.
最後はclamp(tone, vec3f(0.72), vec3f(1.0))で沈みすぎを止める.
ブラシはstampではなく濡れた状態
最初の発想として,Canvas 2Dで有機的な形を重ねれば水彩っぽく見える. 実際,fallback brushはそうしている. ただ,それだけだと「半透明の画像を押している」感じが残る. 水彩らしさには,描いたあとに少しだけ状態が変わる時間がいる.
そこで通常pathではWebGL2の小さなsolverを使う. ただし,これも最初から起動しない.
const fallback = createBrushLayer(options);
let engine: FluidBrushEngine | undefined;
let triedEngine = false;
function ensureEngine(): FluidBrushEngine | undefined {
if (engine || triedEngine) {
return engine;
}
triedEngine = true;
engine = FluidBrushEngine.create();
return engine;
}
WebGL2やfloat framebufferが使えなければ,Canvas 2D brushへ戻る.
if (!gl || !gl.getExtension("EXT_color_buffer_float")) {
return undefined;
}
solverは画面全体の解像度では動かさない.
base sizeは560で,aspectに合わせて小さなtextureを作る.
const simulationBaseSize = 560;
背景の滲みは,細かすぎるsimulationよりも少し鈍い方がよい. 解像度を下げることが,見た目にも性能にも効く.
流体の章は,まずこの1行だけで読める.
筆先が wet を置く -> velocity が wet を少し運ぶ -> pressure が暴れを抑える -> 古い wet が dry になる
目で追う対象は,青いwetのしみだけでよい.
矢印のvelocityはそのしみを動かす力で,赤いpressureは破裂しそうな流れをならすためのブレーキで,橙のdryは紙に固定された跡である.
この見方は,three.jsのX投稿を見て,水彩背景の実装を自分のコードに引き寄せて読み直したものである.
brush solverの1 frameは,ざっくり次のpipelineになっている.
curl
vorticity confinement
advect velocity
boundary velocity
advect wet
boundary wet
dry wet into dry
remove dried wet
divergence
pressure solve
subtract pressure gradient
boundary velocity
display
完全なNavier-Stokes solverではない. ただ,速度場に渦を少し戻し,semi-Lagrangian advectionで濡れた顔料を運び,pressure projectionで発散を抑える. 背景としては,このくらいのstable fluidsで十分だった.
まず粒子を追っていない
流体と聞くと,水の粒をたくさん置いて,その粒を追いかける絵を想像しやすい. でもこの実装は粒子を追っていない. 画面より小さいtexture gridを用意して,各pixelに「ここでは水がどちらへ流れているか」と「ここにはどれくらい濡れた顔料があるか」を持たせている.
つまり,考える対象は粒ではなく場である.
各pixel:
velocity = その場所の水の向き
wet = その場所の濡れた顔料
dry = その場所の乾いた顔料
1つのstrokeは,だいたい次のように変わる.
この図で言うと,流体solverが毎frame動かすのは主にvelocityとwetである.
dryはそこから外される.
だから顔料は,最初だけ水に乗って動き,だんだん紙に固定されていく.
流体の場はtextureとして持つ
solverはCPU上の配列ではなく,WebGL2のRGBA16F textureとして場を持つ.
各passはfullscreen triangleを一枚描き,fragment shaderで次のtextureを書き込む.
読み書きが同じtextureになると壊れるので,velocity,wet,dry,pressureはread/writeを持つping-pong bufferである.
velocity.xy 速度
velocity.zw 未使用 / alphaの余白
wet.rgb 濡れた顔料の吸収量
wet.a 濡れ量
dry.rgb 乾いた顔料の吸収量
dry.a 乾いた量
pressure.x 圧力
divergence.x 速度場の発散
curl.x 2D速度場の渦度
流体として見ると,毎frameでやりたいことはこうである.
u = velocity
w = wet pigment
d = dry pigment
u <- add vorticity(u)
u <- advect(u, u)
w <- advect(w, u)
d, w <- dry(w, d)
u <- project(u)
ここでdryは移流しない.
紙に固定された顔料だからだ.
水彩っぽさは,動く場を増やすことより,動かない場を決めることで出ている.
移流は戻ってsampleする
移流はsemi-Lagrangian advectionである.
現在のpixelに来た量は,ひとつ前の時刻では速度のぶんだけ上流にあった,とみなす.
だから現在位置vUvから逆向きに戻ってsampleする.
vec2 vel = texture(uVelocity, vUv).xy;
vec2 coord = vUv - dt * vel * uTexelSize;
outColor = dissipation * texture(uSource, coord);
これをvelocity自身にも,wetにも使う.
this.run("advect", this.velocity.write, {
dissipation: 0.986,
dt,
uSource: this.velocity.readTexture,
uVelocity: this.velocity.readTexture,
});
this.run("advect", this.wet.write, {
dissipation: 0.998,
dt,
uSource: this.wet.readTexture,
uVelocity: this.velocity.readTexture,
});
velocityのdissipationは0.986で,wetは0.998である.
速度は早めに弱まり,顔料は少し長く残る.
semi-Lagrangianは数値的に安定しやすいが,拡散したようにぼやける.
このぼやけは,背景の滲みでは欠点になりにくい.
渦度を少し戻す
semi-Lagrangian移流は安定だが,細かい回転を失いやすい. そこで先にcurlを計算し,vorticity confinementで少し渦を戻す.
outColor = vec4(
0.5 * (right.y - left.y - (top.x - bottom.x)),
0.0,
0.0,
1.0
);
2Dではcurlはscalarとして持てる. 次のpassでは,curlの絶対値の勾配を見て,渦の中心へ押す方向を作る.
vec2 force = 0.5 * vec2(abs(top.x) - abs(bottom.x), abs(right.x) - abs(left.x));
force /= length(force) + 0.0001;
force *= strength * center * vec2(1.0, -1.0);
vec2 vel = texture(uVelocity, vUv).xy;
outColor = vec4(vel + force * dt, 0.0, 1.0);
strengthは18にしている.
ここを上げると流れは派手になるが,背景としてはうるさくなる.
水彩の滲みで欲しいのは水面の渦ではなく,顔料が少し不均一に逃げることだ.
境界は場ごとに扱いを変える
各passのあとにboundary passを挟む. edge付近では隣の値を参照し,scaleを掛ける.
if (uv.x < uTexelSize.x) data = scale * texture(uTarget, uv + vec2(uTexelSize.x, 0.0));
if (uv.x > 1.0 - uTexelSize.x) data = scale * texture(uTarget, uv - vec2(uTexelSize.x, 0.0));
if (uv.y < uTexelSize.y) data = scale * texture(uTarget, uv + vec2(0.0, uTexelSize.y));
if (uv.y > 1.0 - uTexelSize.y) data = scale * texture(uTarget, uv - vec2(0.0, uTexelSize.y));
velocityではscale = -1にして壁で反転させる.
wetではscale = 0にして外へ流れた濡れを消す.
pressureではscale = 1で圧力を続ける.
同じboundary shaderでも,場の意味によって符号が違う.
pressure projectionで発散を抑える
速度場はsplatやvorticityで簡単に膨らむ. そこで発散を計算する.
outColor = vec4(
0.5 * (right.x - left.x + top.y - bottom.y),
0.0,
0.0,
1.0
);
発散がある場所は,流れが湧いているか吸い込んでいる場所である. これを抑えるために,Jacobi iterationで圧力を解く.
float div = texture(uDivergence, vUv).x;
outColor = vec4(
(left.x + right.x + bottom.x + top.x - div) * 0.25,
0.0,
0.0,
1.0
);
そのあと,速度から圧力勾配を引く.
vec2 vel = texture(uVelocity, vUv).xy;
outColor = vec4(
vel - 0.5 * vec2(right.x - left.x, top.x - bottom.x),
0.0,
1.0
);
これで完全に非圧縮になるわけではない.
iterationは8回だけなので,かなり粗い.
ただ,水彩背景では厳密な体積保存より,「strokeの周囲が破裂して見えない」ことの方が大事である.
wetとdryを分ける
WebGL2 solverが持つ中心的な状態は,velocity,wet,dry,pressureである.
velocity 水の流れ
wet まだ動く顔料
dry 紙に固定された顔料
pressure 速度場を整えるための圧力
pointerから入ってきた色は,いきなりdryへ描かない.
まずwetへ入れる.
strokeでは,まず前回座標との差分から方向と速度を作る.
const speed = Math.hypot(stroke.deltaX, stroke.deltaY);
const directionX = speed > 0.00001 ? stroke.deltaX / speed : 1;
const directionY = speed > 0.00001 ? stroke.deltaY / speed : 0;
const radius = 0.00018 + stroke.pressure * 0.00034;
const force = Math.min(46, 8 + speed * 2200);
const opacity = Math.min(0.42, 0.18 + stroke.alpha * 3.8 + stroke.pressure * 0.045);
同じstrokeから,速度場と濡れ顔料の二つを更新する. 速度場には動きだけを入れ,濡れ顔料には吸収量を入れる.
this.splatBuffer(
this.velocity,
stroke.x,
stroke.y,
directionX * force,
directionY * force,
0,
1,
radius * 1.05,
0,
directionX,
directionY,
);
this.splatBuffer(
this.wet,
stroke.x,
stroke.y,
(1 - stroke.red) * opacity,
(1 - stroke.green) * opacity,
(1 - stroke.blue) * opacity,
opacity,
radius,
speed > 0.0009 ? 1 : 0,
directionX,
directionY,
);
RGBをそのまま色として足すのではなく,1 - rgbとして持つ.
顔料を「紙から光を引く量」として扱うためである.
さらに中心だけでなく周辺6方向へ小さな速度を入れる.
これは物理的に正確な毛細管現象ではないが,筆先の外側へ水が逃げる感じを作る.
for (let index = 0; index < 6; index += 1) {
const angle = (index / 6) * Math.PI * 2;
const offset = Math.sqrt(radius) * 0.42;
const bloomX = clamp01(stroke.x + Math.cos(angle) * offset);
const bloomY = clamp01(stroke.y + Math.sin(angle) * offset);
this.splatBuffer(
this.velocity,
bloomX,
bloomY,
Math.cos(angle) * 28,
Math.sin(angle) * 28,
0,
1,
radius * 0.62,
0,
Math.cos(angle),
Math.sin(angle),
);
}
毎frameでは,velocityとwetだけを移流する.
dryは動かさない.
vec2 vel = texture(uVelocity, vUv).xy;
vec2 coord = vUv - dt * vel * uTexelSize;
outColor = dissipation * texture(uSource, coord);
この分離がかなり効く. 濡れた顔料は動く. 乾いた顔料は動かない. それだけで,「透明な画像が画面上を広がる」から「紙に染みている」に近づく.
速度場はそのままだと膨らんだり縮んだりしすぎるので,発散を計算し,pressureを反復で解く.
this.run("divergence", this.divergence, {
uVelocity: this.velocity.readTexture,
});
this.clearTarget(this.pressure.read);
for (let index = 0; index < pressureIterations; index += 1) {
this.run("pressure", this.pressure.write, {
uDivergence: this.divergenceTexture,
uPressure: this.pressure.readTexture,
});
this.swap(this.pressure);
this.applyBoundary(this.pressure, 1);
}
iterationは8回で止めている.
流体としては雑だが,背景のにじみとしては過不足が少ない.
むしろ少し緩い方が,紙の不均一さに見える.
乾燥はdryAccumとdryWetの二つのpassで進む.
float density = max(max(w.r, w.g), w.b);
float thicknessFactor = 1.0 / (1.0 + density * thicknessK);
float wet1 = max(wet0 - dryRate * thicknessFactor * dt, 0.0);
濃い顔料ほど乾きにくい. 薄い水は先に消え,溜まった顔料は外縁やムラとして残る. 正確な物理ではないが,知覚としてはここが水彩に近い.
dryAccum側では,乾いた顔料へ単純加算するだけではなく,濃度に応じて置換も混ぜる.
vec3 transfer = w.rgb * frac * 1.015;
float coverage = 1.0 - exp(-density * 1.8);
vec3 headroom = max(vec3(dryCap) - d.rgb, vec3(0.0));
vec3 added = d.rgb + min(transfer, headroom);
vec3 replaced = mix(d.rgb, w.rgb, frac);
outColor = vec4(mix(added, replaced, coverage), min(d.a + dried, 4.0));
全部を加算すると重ね塗りで黒く沈みすぎる. 全部を置換すると透明水彩のglazeが消える. 薄いところは加算寄り,濃いところは置換寄りにすることで,薄い層と厚い溜まりの両方を残す.
表示は透明canvasへ戻す
solver内では,仮の紙色にwet/dryを重ねて色を作る.
vec3 paper = vec3(0.96, 0.945, 0.92);
vec3 afterDry = paper * exp(-dry.rgb * 2.2);
vec3 wetColor = paper * exp(-wet.rgb * 2.2);
vec3 glazed = afterDry * exp(-wet.rgb * 2.2);
でも実際のページには別のWebGPU紙がある. だから最終出力は不透明な絵ではなく,透明度を持ったbrush canvasへ戻す.
float density = max(wetDensity, dryDensity);
float edge = smoothstep(0.002, 0.018, length(vec2(dFdx(density), dFdy(density))));
float alpha = clamp(density * 1.15 + wetAmt * 0.18 + edge * 0.12, 0.0, 0.62);
outColor = vec4(color, alpha);
最後はWebGL canvasから2D canvasへcopyする.
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(this.canvas, 0, 0, canvas.width, canvas.height);
DOM側ではmix-blend-mode: multiplyで紙に沈ませる.
.brush-canvas {
mix-blend-mode: multiply;
}
WebGPU紙とWebGL2 brushを直接一枚のrendererにまとめないのは,故障範囲を分けたいからだ. 紙だけ生きる,brushだけ消す,2D fallbackにする,mobileでは静止画にする. それぞれを別の層にした方が,背景として扱いやすい.
Canvas 2D fallbackも同じ考え方にする
WebGL2がない環境では,createBrushLayerが使われる.
これは流体ではないが,wet/dryの考え方は残している.
dryLayer
wetLayer
wetBlooms
wetDryFrames
strokeはwetLayerへ描かれ,遅れてdryLayerへ移る.
dryContext.globalCompositeOperation = "multiply";
dryContext.globalAlpha = 0.026;
dryContext.drawImage(wetLayer, 0, 0);
wetContext.globalCompositeOperation = "destination-out";
wetContext.fillStyle = "rgba(0, 0, 0, 0.018)";
wetContext.fillRect(0, 0, wetLayer.width, wetLayer.height);
水彩らしさを作っているのは,WebGL2そのものではない. 「濡れている層」と「乾いた層」があり,その間に時間差があることだ. Canvas 2D fallbackでも,この契約だけは残す.
色はすぐ変えない
brush colorは36 drawごとに変える.
const brushColorHoldDraws = 36;
pointermoveごとに色を変えると,水彩ではなく虹色のペンになる.
同じ顔料が水で薄まりながら広がる時間が必要なので,色はしばらく保持する.
paletteのalphaも0.04から0.05付近に抑えている.
派手な色替えではなく,薄い顔料が重なって濃くなる方を選ぶ. 透明水彩の背景としては,その方が読みやすい.
Vueにrenderer stateを載せない
Vueが持つstateは少ない.
const fallbackLayerElement = ref<HTMLElement>();
const fallbackCanvasElement = ref<HTMLCanvasElement>();
const paperCanvasElement = ref<HTMLCanvasElement>();
const brushCanvasElement = ref<HTMLCanvasElement>();
const isDrawingEnabled = ref(false);
WebGPU device,WebGL context,texture,framebuffer,wet/dry buffer,last pointer positionはVue reactivityに載せない. これらはUI stateではなくrenderer stateである.
Vueから見ると,brush layerはdraw,resize,clearを持つimperative objectでよい.
const brushLayer = createFluidBrushLayer({
canvas: () => brushCanvasElement.value,
isDrawingEnabled: () => isDrawingEnabled.value,
maxDpr,
});
UI frameworkの中でgraphicsを動かすとき,全部をreactiveにしたくなる. でも,templateを更新しない値をreactiveにしても,追跡コストと混乱が増えるだけである. 描画器の内部状態は,描画器の中に閉じ込める.
後片付けを雑にしない
背景はwindowにlistenerを張る. だから,mountよりunmountを雑にすると後で効いてくる.
onUnmounted(() => {
if (resizeFrame !== 0) {
cancelAnimationFrame(resizeFrame);
}
removeViewportListeners?.();
removeBrushListeners?.();
paperCleanup?.();
});
WebGPU側もunconfigure()まで呼ぶ.
return () => {
disposed = true;
if (paperResizeFrame !== 0) {
cancelAnimationFrame(paperResizeFrame);
}
removeViewportListeners();
gpuContext.unconfigure();
};
背景は地味だが,ページ全体へ触る. だから,止め方も実装の一部である.
テストするのは美しさではない
水彩が「良い感じか」を自動テストするのは難しい. でも,壊したくない性質には分解できる.
このサイトのPlaywrightでは,たとえば次を見る.
fallback canvasがblankではない
.watercolor-bg-containerが本文を覆わないWebGPUがある環境ではpaper canvasが初期化される
mobileでは
bg-sp.pngに落ち,canvasが消えるtooltipがhover外で残らない
brushの色が一stroke中に急に変わらない
滲みの外縁が遅れて増える,または濃くなる
pointer eventのburstが一定時間内に終わる
pixel perfectではなく,性質を見る. これは背景表現には向いている. 絵の完全一致を守るより,本文を邪魔しないこと,blankにならないこと,滲みが時間差を持つことの方が大事だからだ.
まとめ
Webで透明水彩をやる技術は,流体をどこまで正確に解くかではなかった. このサイトでは,次の判断が効いている.
紙とbrushを別レイヤにする
WebGPUの前にCanvas 2D fallback紙を出す
紙はWebGPUで一度焼き,timeで動かさない
DPRを制限し,背景に不要なfragmentを払わない
brushはfine pointerのdesktopだけで有効にする
WebGL2 solverは遅延初期化し,失敗したらCanvas 2Dへ落ちる
顔料を
wetとdryに分け,乾いた顔料は動かさない色をすぐ変えず,同じ顔料が薄まる時間を残す
renderer stateをVue reactivityに載せない
mobileでは静的背景にする
テストではpixelではなく壊したくない性質を見る
透明水彩らしさは,水と紙と顔料を全部正しく解くことではない. 読者が見ている時間の中で,紙があり,濡れがあり,乾きがあるように感じられればよい. そして,それができない環境では何もしない. 背景としては,その静かな失敗まで含めて実装である.