この記事は100% AIによって出力したものであり,使用したモデルはGPT-5.4である.
序
このサイトの背景には,薄い透明水彩のような紙と滲みを置いている. 見た目としては静かな装飾だが,中身は一枚画像ではない. WebGPUで描いた紙,Canvas 2Dで焼き込むブラシ,そしてDOMの本文を別々のレイヤとして重ねている.
先に断っておくと,これは物理的に正しい水彩シミュレーションではない. 水彩を真面目に解くなら,少なくとも次のような場を持つことになる.
water(x, y, t) 水分量
pigment(x, y, t) 顔料濃度
paper(x, y) 紙の凹凸,繊維,吸水性
velocity(x, y, t) 水の流速
dryness(x, y, t) 乾燥度
そこから拡散,移流,吸着,沈降を毎frame更新する. たとえば顔料だけ見ても,かなり雑に書けばこういう形になる.
pigment_next =
pigment
+ diffusion_rate * laplacian(pigment)
- absorption_rate * paper_absorbency * pigment
- sedimentation_rate * pigment
これは面白いが,文章を読むための背景としては過剰である. このサイトで欲しいのは物理の完全性ではなく,読者が「紙の上の薄い顔料」と感じるだけの手がかりだ.
だから実装では,透明水彩を次の知覚要素に分けている.
紙に細かい凹凸と繊維がある
薄い顔料が完全には均一に乗らない
境界が円ではなく,少し歪む
顔料が一部に溜まり,一部は水で抜ける
滲みは描画と同時ではなく,少し遅れて広がる
それでも本文,スクロール,リンク操作を邪魔しない
この分解がほぼ設計のすべてである. 巨大な流体shaderを書くのではなく,失敗してもページが壊れない小さな層に分ける.
レイヤを分ける
画面上の重なりはこうなっている.
paper fallback canvas
paper WebGPU canvas
brush 2D canvas
page content
controls
紙は常に固定背景として存在する.
WebGPUが使える環境ではpaper WebGPU canvasが紙の質感を描く.
WebGPUが使えない環境では,先に描いておいたpaper fallback canvasが見える.
ブラシはユーザーが有効化したときだけbrush 2D canvasに焼き込む.
本文は普通のDOMのまま上に置く.
この構成にしている理由は,表現と故障範囲を分けたいからだ.
WebGPU初期化に失敗しても紙は残る.
ブラシが重い端末ではユーザーがオフにできる.
背景canvasはpointer-events: noneなのでリンクやスクロールを奪わない.
装飾表現は,成功時の綺麗さだけではなく,失敗時の静けさまで設計した方がよい. 背景が一瞬でも本文より偉そうに振る舞うと,文章サイトとしては負けである.
WebGPUで紙を一枚焼く
WebGPUは紙の層にだけ使っている. 理由は単純で,紙は画面全体に必要だが,時間方向にはほとんど変化しないからだ. 初期化時とresize時だけ描けばよいので,GPUに投げる価値がある.
初期化は低消費電力寄りのadapterを要求している.
const adapter = await gpu.requestAdapter({ powerPreference: "low-power" });
const device = await adapter.requestDevice();
const format = gpu.getPreferredCanvasFormat();
ここでhigh-performanceを要求していないのは意図的である.
この背景は主役ではない.
読むための紙を描くのに,可能な限り強いGPUを起こす必要はない.
描画はfullscreen triangleで行う.
vertex bufferは持たず,vertex_indexから3頂点を作る.
var positions = array<vec2f, 3>(
vec2f(-1.0, -1.0),
vec2f(3.0, -1.0),
vec2f(-1.0, 3.0)
);
この三角形はclip space全体を覆う. 矩形を2 trianglesで描くより頂点数が少なく,対角線の継ぎ目もない. 紙のような全画面procedural textureにはちょうどよい.
JavaScript側ではalignmentとpaddingの余裕を含めたuniform bufferを80 bytesで作っている.
中身はFloat32Arrayで,解像度,seed,紙目の強さ,繊維密度,光の向きなどを詰めている.
return new Float32Array([
width,
height,
seed,
0.88, // embossDepth
0.32, // grainScale
0.56, // fiberDensity
0.24, // fiberLength
0.42, // pulpDensity
0.06, // warmTint
0,
0,
0.72, // shadowStrength
0,
-0.56, // lightYaw
-0.26, // lightPitch
0,
0,
0,
0,
0,
]);
WGSL側のstructはこういう意味を持つ.
struct Uniforms {
resolution: vec2f,
seed: f32,
embossDepth: f32,
grainScale: f32,
fiberDensity: f32,
fiberLength: f32,
pulpDensity: f32,
warmTint: f32,
coolTint: f32,
bloomStrength: f32,
shadowStrength: f32,
vignette: f32,
lightYaw: f32,
lightPitch: f32,
_padding: vec2f,
};
ここで重要なのは,shaderに時間を渡していないことだ. 紙のパラメータはviewportとseedから決まり,読んでいる最中に動かない. 紙が動くと,水彩紙ではなくスクリーンセーバーに見える.
紙の座標を作る
fragment shaderでは,まずuvを紙の座標に変換する.
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);
やっていることは地味だが,ここが効く.
aspectで縦横比の違いを吸収する1.72で画面に対する紙目の密度を決めるvec2f(1.0, 1.04)でわずかに縦方向の伸びを入れるrotate(..., -0.06)で紙目が画面軸に揃いすぎるのを避けるseedでviewportごとに同じ模様になりすぎるのを避ける
紙のようなtextureは,完全に画面座標に沿うと急にCGっぽくなる. 特に繊維や皺が水平垂直に揃うと,布地やグリッドに見えてしまう. だから座標を少しだけ傾けている.
noiseは役割ごとに分ける
紙shaderの基本部品はhash21,noise2,fbm,ridgedFbmである.
noise2は格子点のhashを補間するvalue noiseで,fbmはそれを4 octave重ねる.
fn fbm(seed: vec2f) -> f32 {
var value = 0.0;
var amplitude = 0.5;
var point = seed;
for (var index = 0; index < 4; index = index + 1) {
value = value + noise2(point) * amplitude;
point = point * 2.02 + vec2f(11.7, 5.9);
amplitude = amplitude * 0.5;
}
return value;
}
普通のfbmは紙のゆるいムラやwarpに使う.
一方でridgedFbmは1.0 - abs(noise * 2.0 - 1.0)を使って稜線っぽい値を作る.
これは広い皺や凹凸に向いている.
このshaderではnoiseを一種類の「ランダム模様」として使っていない. 役割ごとに別の場として扱っている.
embossField: broadな凹凸とpitsmicroGrainField: 細かい粒状感fiberField: 流れのある長い繊維fibrilDetailField: セルごとの短い線分繊維celluloseNetworkField: Voronoi風の孔とridgecoldPressToothField: cold press paperの歯crumpleField: 大きな折れと歪みwrinkleLineField: 細い皺
透明水彩では顔料が紙の凸部と凹部で違って見える. だから紙を単なる色noiseではなく,高さ場として作る必要がある.
高さ場から法線を作る
最終的な紙の高さはpaperHeight(point)で作る.
中ではmacro,detail,micro,emboss,tooth,grain,pulpを重み付きで足している.
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は孔や凹部として扱っている.
凹凸を同じ符号で足すのではなく,凸部は明るいridge,凹部は後からmultiplyされた顔料が沈んで見えるcavityとして分ける.
法線はanalyticに出していない.
paperHeightを近傍で2回サンプルして,中心差分に近い形で作る.
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));
}
epsilonは小さすぎると数値的な差が出にくく,大きすぎると細かい繊維が潰れる.
ここでは紙目の見た目に合わせて0.00076にしている.
正確な物理単位ではなく,画面上で「紙の毛羽立ちが見えるが,ざらざらしすぎない」ための単位だ.
光はyaw / pitchから作る.
let xy = cos(pitch);
return normalize(vec3f(cos(yaw) * xy, sin(yaw) * xy, sin(-pitch)));
uniformの値はlightYaw = -0.56,lightPitch = -0.26なので,斜め上から弱く当たる.
正面光にすると凹凸が死ぬし,強い斜光にすると紙が主張しすぎる.
cavityとridgeで紙色を決める
fragmentの最後は,法線と高さから紙色を作る. 基本色は少し暖かい白である.
var tone = vec3f(0.952, 0.947, 0.927);
そこへcavityとridgeのmaskを作って混ぜる.
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は少し沈ませる.
tone = mix(
tone,
vec3f(0.824, 0.813, 0.782),
cavityMask * (0.18 + uniforms.shadowStrength * 0.12)
);
ridgeは少し持ち上げる.
tone = mix(
tone,
vec3f(0.975, 0.97, 0.949),
ridgeMask * (0.082 + uniforms.embossDepth * 0.06)
);
最後にclamp(tone, vec3f(0.72), vec3f(1.0))で潰れすぎを防ぐ.
紙は背景なので,黒い影を作らない.
「凹んでいる」と読める程度に暗くするが,本文のコントラストを奪うほど濃くしない.
紙は動かさない
WebGPUの描画はrender() で一回command encoderを作り,draw(3)してsubmitする.
renderPass.setPipeline(pipeline);
renderPass.setBindGroup(0, bindGroup);
renderPass.draw(3);
renderPass.end();
device.queue.submit([commandEncoder.finish()]);
その後はresize / orientationchangeまで描き直さない. 紙は「時間変化する絵」ではなく「画面の素材」だからだ.
DPRも1.25で切っている.
const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
const width = Math.max(1, Math.round(window.innerWidth * dpr));
const height = Math.max(1, Math.round(window.innerHeight * dpr));
背景紙を端末のnative DPRまで上げても,読者が得る情報量はほとんど増えない. 一方でfragment数は増える. たとえば1440 x 900のviewportならDPR 1.25で約2.0M pixelsだが,DPR 2なら約5.2M pixelsになる. 紙のざらつきのために2.5倍以上のfragmentを払うのは釣り合わない.
fallback canvasは保険として描く
WebGPUがない場合でも紙が消えないように,2D canvasのfallbackも先に描く. ここではbase color,radial wash,patch,fiber,grain tileを使っている.
base fill #f1eedf
shadow patches 820 * areaScale
highlight 520 * areaScale
fibers 260 * areaScale
grain tile 384 x 384
fallbackはWebGPU shaderより単純だが,完全な単色背景よりずっとよい. WebGPU layerが初期化できたらfallback側のopacityを下げる. つまりfallbackは「古い絵」ではなく,WebGPU layerの下にある安全網である.
Vue islandとして接続する
この背景はVue componentではあるが,ページ全体をclient appとして動かしているわけではない. Vuerendのislandとして登録し,必要な背景部分だけをclientでhydrateしている.
export const WatercolorBackgroundIsland = defineIsland("watercolor-background", {
component: WatercolorBackgroundIslandView,
load: () => import("./features/ShellWatercolorBackgroundIslandLoader"),
hydrate: "load",
});
各route側では,薄いwrapperからこのislandを置くだけにしている.
<script setup lang="ts">
import { WatercolorBackgroundIsland } from "../Islands";
</script>
<template>
<WatercolorBackgroundIsland />
</template>
背景はrouteの本文とは別の関心なので,route componentにWebGPUやbrushの状態を持ち込まない. どのページでも同じ背景を置けるし,背景の実装を変えても記事や一覧のcomponentには波及しない.
Vue componentのtemplateは,見た目のlayerとcontrolsだけを宣言する.
<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が3枚あります」と読ませる必要はない.
CSS側ではそれぞれfixed layerにしている.
.paper-fallback z-index: -4
.paper-fallback-canvas z-index: -3
.paper-canvas z-index: -2
.brush-canvas z-index: -1
canvas自体はpointer-events: noneで,ユーザーの操作を奪わない.
ブラシの描画はcanvasに直接pointer eventを付けるのではなく,windowのpointer eventをlistenして行う.
こうするとcanvas layerがDOMの上にあるかどうかに依存せず,背景として振る舞える.
script setup側でVueが持つstateはかなり少ない.
const fallbackLayerElement = ref<HTMLElement>();
const fallbackCanvasElement = ref<HTMLCanvasElement>();
const paperCanvasElement = ref<HTMLCanvasElement>();
const brushCanvasElement = ref<HTMLCanvasElement>();
const isDrawingEnabled = ref(false);
ここでreactiveなのはDOM refと,ユーザーに見えるisDrawingEnabledだけである.
WebGPU device,CanvasRenderingContext2D,wet bloomの配列などはVueのstateにしない.
ブラシ描画器には,canvasとenabled flagを関数として渡している.
const brushLayer = createBrushLayer({
canvas: () => brushCanvasElement.value,
isDrawingEnabled: () => isDrawingEnabled.value,
maxDpr,
});
ここがVueと描画器の細い接続点である.
brushLayerは必要な瞬間にbrushCanvasElement.valueとisDrawingEnabled.valueを読むが,描画履歴そのものはVueに持たせない.
Vueは「今描いてよいか」を答えるだけで,strokeの内部状態には関与しない.
起動処理はonMountedに寄せている.
localStorage,window,navigator.gpu,canvas contextはserver render中には触れないからだ.
onMounted(() => {
isDrawingEnabled.value = localStorage.getItem(brushStorageKey) === "true";
renderPaperFallback(fallbackCanvasElement.value, maxDpr);
brushLayer.resize();
window.addEventListener("resize", queueResize, { passive: true });
window.addEventListener("orientationchange", queueResize);
window.addEventListener("pointerdown", brushLayer.draw, { passive: true });
window.addEventListener("pointermove", brushLayer.draw, { passive: true });
if (paperCanvasElement.value) {
void initializePaperShader({
fallbackCanvas: fallbackCanvasElement.value,
fallbackLayer: fallbackLayerElement.value,
maxDpr,
paperCanvas: paperCanvasElement.value,
}).then((cleanup) => {
paperCleanup = cleanup;
});
}
});
順番も意図がある.
まずlocalStorageからブラシのon/offを戻す.
次にfallback紙を2D canvasに描く.
その後でbrush canvasをviewportに合わせる.
最後にWebGPU紙を非同期で初期化する.
WebGPUが遅れても,その間fallback紙が見えている.
resizeは直接処理せず,1 frameにまとめる.
function queueResize() {
if (resizeFrame !== 0) {
return;
}
resizeFrame = requestAnimationFrame(() => {
resizeFrame = 0;
renderPaperFallback(fallbackCanvasElement.value, maxDpr);
brushLayer.resize();
});
}
canvasのresizeはbitmapを作り直すので,連続resize中に毎eventでやると重い.
requestAnimationFrameでまとめるだけでも,体感の引っかかりをかなり減らせる.
controlsが触るのもVue stateとbrush layerの公開APIだけである.
function toggleDrawing() {
isDrawingEnabled.value = !isDrawingEnabled.value;
localStorage.setItem(brushStorageKey, String(isDrawingEnabled.value));
}
function clearBrushCanvas() {
brushLayer.clear();
}
clearはbrush canvasだけを消す. paper fallbackやWebGPU paperはresetしない. 紙は画面の素材で,ブラシはユーザーが上から置いた顔料だから,消す単位も分ける.
後片付けも明示的に行う.
onUnmounted(() => {
if (resizeFrame !== 0) {
cancelAnimationFrame(resizeFrame);
}
window.removeEventListener("resize", queueResize);
window.removeEventListener("orientationchange", queueResize);
window.removeEventListener("pointerdown", brushLayer.draw);
window.removeEventListener("pointermove", brushLayer.draw);
paperCleanup?.();
});
paperCleanupの中ではWebGPU contextのunconfigure() も呼ぶ.
背景のようにページ全体へevent listenerを張るcomponentでは,mountよりunmountの方を雑にしないことが大事だと思っている.
ブラシをWebGPUにしなかった理由
紙はWebGPUだが,ブラシはCanvas 2Dで描いている. ここは意外と大事な判断だった.
ブラシはpointer eventに強く結びついた局所的な描画である.
必要な状態はsequence,最後の座標,最後の描画時刻,短命なbloom配列くらいしかない.
これをWebGPUのtexture ping-pongやstorage bufferに持ち込むと,背景装飾としては実装の重さが勝ってしまう.
このサイトでのブラシは,次の順でCanvas 2Dに焼き込む.
pointer event
-> pressure から size を決める
-> seed 付き乱数を作る
-> organic Path2D を塗る
-> pigment pool を足す
-> granulation を足す
-> destination-out で water lift を抜く
-> 数 frame だけ capillary bloom を足す
つまりbrush layerはsimulation bufferではなく,履歴を焼き込む画材として使っている. 毎frame全strokeを再計算しない. これがかなり効いている.
strokeの大きさとseed
ブラシの座標はcanvasの実bufferに合わせてDPRを掛ける.
const rect = canvas.getBoundingClientRect();
const dpr = canvas.width / Math.max(1, rect.width);
const x = (event.clientX - rect.left) * dpr;
const y = (event.clientY - rect.top) * dpr;
筆圧はpointerのpressureを使い,0の場合は0.6にする.
mouseではpressureが取れないことがあるので,何もしないと筆が細くなりすぎる.
const pressure = event.pressure > 0 ? event.pressure : 0.6;
const size = (48 + pressure * 84) * dpr;
この式だと,pressure 0.6で98.4 * dpr,pressure 1.0で132 * dprになる.
水彩の背景ブラシとしては少し大きめだが,alphaが低いのでこれくらいの面積がないと「塗り」ではなく点に見える.
乱数seedはsequenceと座標から作る.
const seed = ((sequence + 1) * 2654435761 + Math.round(x * 13) + Math.round(y * 17)) >>> 0;
const random = mulberry32(seed);
2654435761は32-bitの範囲で値を散らすための定数として使っている.
同じsequenceでも座標が違えば形が変わり,同じ座標でもsequenceが進めば別の顔料溜まりになる.
ただし完全な暗号乱数はいらない.
必要なのは,strokeの中で「手で作った円」感が消える程度の安定した乱数である.
円ではなく有機的なpatchを作る
水彩っぽさを壊す一番の敵は,綺麗すぎる円である. Canvasのradial gradientだけだと,どうしても中心と外周が見える.
そこで塗る範囲はPath2Dで歪ませている.
18個の点を円周上に置き,2,3,5倍角のsineとjitterで半径を揺らす.
const ripple =
Math.sin(angle * 2 + phaseA) * irregularity * 0.54 +
Math.sin(angle * 3 + phaseB) * irregularity * 0.34 +
Math.sin(angle * 5 + phaseC) * irregularity * 0.18;
const jitter = (random() - 0.5) * irregularity * 0.28;
const scale = Math.max(0.58, 1 + ripple + jitter);
2,3,5は互いに周期がずれるので,単純な花びら形になりにくい.
Math.max(0.58, ...) で潰れすぎも防いでいる.
stroke本体はこのpatchにradial gradientを流す.
gradient.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a * 1.72})`);
gradient.addColorStop(0.3, `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a * 1.18})`);
gradient.addColorStop(0.62, `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a * 0.42})`);
gradient.addColorStop(0.88, `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a * 0.08})`);
gradient.addColorStop(1, `rgba(${color.r}, ${color.g}, ${color.b}, 0)`);
base alphaはだいたい0.04から0.05である.
中心でも0.05 * 1.72 = 0.086程度なので,一回では強く出ない.
透明水彩は「一発で塗った色」より「重なって見えてくる色」が大事だからだ.
compositeはmultiplyにしている.
紙の白を完全に覆うのではなく,紙の明暗と掛け合わさって沈む感じにするためだ.
顔料溜まり,粒状化,水抜き
stroke本体のあとに,顔料が一部に濃く溜まるlayerを4回足す.
for (let index = 0; index < 4; index += 1) {
drawBrushPool(context, random, color, size, 0.82 + random() * 0.3);
}
drawBrushPoolは中心からsize * 0.42以内に小さなradial gradientを置く.
半径はsize * (0.16 + random() * 0.24),alphaはcolor.a * (0.72 + random() * 0.48) * intensityである.
これで「同じ水の中でも顔料が少し寄る」感じを作る.
粒状化はdotsと短いcurveで足している. dotsは10個,curveは5本だけだ.
context.arc(
Math.cos(angle) * distance,
Math.sin(angle) * distance,
size * (0.006 + random() * 0.016),
0,
Math.PI * 2,
);
粒を大量に置くと紙のtextureではなくノイズになる. 10個程度に抑えることで,近くで見ると顔料の粒があるが,本文の後ろではうるさくならない.
さらにdestination-outで水抜きを入れる.
context.globalCompositeOperation = "destination-out";
これは描いた顔料を少し消す処理である.
水彩では濡れたところが一様に濃くなるだけでなく,水で顔料が押し返されたような明るい穴ができる.
destination-outは物理的な水ではないが,視覚的にはかなり安くこの「抜け」を作れる.
滲みは短く遅らせる
最初の実装で一番ダメだったのは,滲みが二重丸に見えたことだった. 円形gradientを時間で拡大すると,中心,リング,外周がはっきり分かれてしまう. これは水彩というよりUIのripple effectに近い.
今の実装では,strokeを置いたあとwetBloomsに短命なbloomを積む.
wetBlooms.push({
age: 0,
maxAge: 6 + Math.floor(random() * 3),
driftX: (random() - 0.5) * size * 0.16,
driftY: (random() - 0.5) * size * 0.16,
size: size * (0.94 + random() * 0.14),
});
寿命は6から8 framesだけである. 60 fpsなら約100から133 msくらいの短い錯覚になる. 長く動かすと「濡れている」ではなく「アニメーションしている」に見える.
bloomの進行はease-outにしている.
const progress = bloom.age / bloom.maxAge;
const eased = 1 - (1 - progress) ** 3;
半径は進行に合わせて広げる.
const radiusX = bloom.size * (1.04 + eased * 0.76);
const radiusY = bloom.size * (0.84 + random() * 0.2 + eased * 0.52);
const alpha = bloom.color.a * (1 - progress * 0.58) * 0.84;
alpha自体は少しずつ下げる. ただし半径が広がるので,外側のpixelは遅れて色を受ける. 見た目としては「中心が濃くなり続ける」のではなく,「外縁に水が届く」ように見える.
さらにbloomも円ではなくcreateOrganicPatchPathで描く.
中心もfocusX / focusYで少しずらす.
これで同心円のリングを避ける.
const focusX = (random() - 0.5) * bloom.size * 0.18;
const focusY = (random() - 0.5) * bloom.size * 0.18;
const path = createOrganicPatchPath(radiusX, radiusY, random, 0.5);
保持するbloom数には上限を置いている.
const maxWetBlooms = 96;
pointerを激しく動かしても,古いbloomは切る. 背景ブラシは描画履歴の完全性より,ページの応答性を優先する.
色はstrokeの中ですぐ変えない
ブラシ色は36 drawごとに変えている.
const brushColorHoldDraws = 36;
const colorIndex = Math.floor(sequence / brushColorHoldDraws) % brushColors.length;
pointermoveごとに色を変えると,紙の上を虹色のmarkerが走る. 透明水彩では,同じ顔料が水で薄まりながら広がる時間が必要である.
36という数は,入力の間引きと一緒に効く.
後述のthrottleは最短で18 msなので,連続して描いても36 drawは少なくとも約648 ms続く.
実際には距離条件もあるので,ひとつの色はもう少し長く残る.
色配列自体も,強い原色ではなく低alphaの淡い色だけにしている.
青,赤,黄,緑,紫を入れているが,どれもa = 0.04から0.05付近である.
背景に置く水彩は,派手な色相変化よりも重なった濃度の方が大事だ.
入力を間引く
pointermoveを全部描くと,すぐ重くなる. それだけでなく,線が密になりすぎてmarkerのように見える.
そこで時間と距離の両方で間引いている.
if (
event.type === "pointermove" &&
now - lastDrawTime < 18 &&
Math.hypot(x - lastDrawX, y - lastDrawY) < size * 0.08
) {
return;
}
18 ms未満で,かつ移動距離がbrush sizeの8%未満なら描かない.
時間だけで間引くと,速いpointerで隙間が空きすぎる.
距離だけで間引くと,遅いpointerでイベントが増える.
両方を見ることで,見た目と負荷のバランスを取る.
これは単なる最適化ではない. 水彩らしい薄いムラは,描きすぎないことで生まれる. 「パフォーマンスの制約」がそのまま「画材の制約」になるのが面白いところだ.
mutable stateはVueに載せない
Vue islandとして接続していても,ブラシの内部状態はVue reactivityに載せていない.
createBrushLayerの中では,描画器の状態をclosureに閉じ込めている.
sequence
lastDrawX
lastDrawY
lastDrawTime
wetBlooms
wetBloomFrame
これらはUI stateではなくrenderer stateである.
templateを更新しない値をreactiveにしても,trackingのコストと見通しの悪さだけが増える.
Vueから見ると,brush layerはdraw,resize,clearを持つ小さなimperative objectでよい.
インタラクティブなgraphicsをUI frameworkの中に置くときは,「ユーザーに見える状態」と「描画器だけが知っていればよい状態」を分けるのが大事だと思う.
背景は読解を邪魔しない
背景canvasはpage contentより下にある. controlsは上にあるが,ブラシはユーザーが有効化したときだけpointer eventから描く. tooltipもhover / focusのときだけ見える.
ここは見た目以上に重要である. 水彩表現がどれだけうまくても,本文のcontrastを落としたら失敗だ. このサイトではhomeの上に記事一覧が重なる場面でoverlayとblurを入れている. 水彩背景の実装には,綺麗なpigmentだけでなく,文字を読める状態に保つlayerまで含まれる.
何をテストするか
この手の表現はunit testだけでは足りない.
canvas要素があることと,絵が正しく出ていることは別だからだ.
このサイトではPlaywrightで,少なくとも次の性質を見る.
fallback canvasが非blankである
WebGPU canvasが使える環境で初期化される
Graph viewや記事UIが背景で壊れていない
tooltipが残り続けない
code blockやMarkdown SVGがthemeから外れていない
brushの色が一strokeの中ですぐ変わらない
滲みの外縁が遅れて増える
pointer burstが一定時間内に終わる
「二重丸に見えない」を直接assertするのは難しい. しかし,外側のalphaが遅れて変わること,hueが暴れないこと,canvasがblankではないことは測れる. 視覚表現でも,守りたい性質に分解すればregression testにできる.
まとめ
Webで透明水彩をやるとき,最初から正確な流体シミュレーションを目指す必要はない. 少なくともこのサイトでは,次の判断が効いた.
紙とブラシを別レイヤにする
紙はWebGPUのfullscreen triangleで一度焼く
紙shaderでは色noiseではなく,高さ場,法線,cavity,ridgeを作る
time uniformを入れず,紙を動かさない
DPRを
1.25に制限して,背景に不要なfragmentを払わないブラシはCanvas 2Dに焼き込み,WebGPUのstateful simulationにしない
strokeは有機的な
Path2D,低alpha,multiplyで作る顔料溜まり,粒状化,
destination-outの水抜きを別々に足す滲みは6から8 framesの短いbloomとして遅らせる
色は36 draw保持して,虹色markerにならないようにする
pointermoveは時間と距離で間引く
rendererのmutable stateはVue reactivityに載せない
Playwrightではpixel perfectではなく,壊したくない性質を測る
リアルに見せることと,重くすることは同じではない. 透明水彩らしさは,水,紙,顔料,時間を全部正しく解くことではなく,読者が知覚する部分を軽く,短く,壊れにくく重ねることで作れる.