02A Stain Whose Pigment Drifts to the Rim as It Dries, Leaving a Dark Ring
Each cell stores its distance to the edge of the wet area, and as the water level drops over time the pigment inside moves toward the edge, so the dried stain is pale in the middle with a dark ring at the rim.
Best forHeader art for a mobile wedding invitation
RIM_FLOWDRY_TIMERIM_SMOOTH

<main class="stage">
<div class="wc is-demo" id="wc">
<canvas class="wc__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="Three wet stains dry while their pigment drifts to the rim, leaving dark rings."></canvas>
<button class="wc__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.wc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
.wc__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.wc__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.wc__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.wc__pause:hover {
transform: translateY(-2px);
}
.wc__pause:active {
transform: translateY(1px);
}
.wc__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 어떤 행동 중인지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.wc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
pointer-events: none;
}
@media (max-width: 340px) {
.wc__pause,
.wc__status { font-size: 11px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 쉬는 자세 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.wc__pause { transition: none !important; }
.wc__pause:hover,
.wc__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.wc__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.wc__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wc__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.wc__pause:hover {
transform: translateY(-2px);
}
.wc__pause:active {
transform: translateY(1px);
}
.wc__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.wc__status {
position: absolute;
left: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
pointer-events: none;
}
@media (max-width: 340px) {
.wc__pause,
.wc__status {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.wc__pause {
transition: none !important;
}
.wc__pause:hover,
.wc__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("wc");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 그리기 좌표는 이 판으로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
// 화소 격자 — 픽셀 연산(색 합성·종이 결)은 320×200 에서 하고 화면에는 늘려 그린다
const OW = 320;
const OH = 200;
// 계산 격자 — 물·안료 확산처럼 무거운 계산은 가로 160 칸 격자에서 (한 칸 = 판 3px)
const GW = 160;
const GH = 100;
// 고정 걸음(초) — 같은 시드면 몇 번을 다시 그려도 같은 그림이 나오도록 계산은 1/30초씩 나아간다
const STEP = 1 / 30;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 얼룩·물방울·붓털이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 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;
};
}
// 종 모양 난수 — 균일 난수 넷의 합을 평균 0·표준편차 1 로 맞춘 값. 대부분 ±1 안, 가끔 ±2 를 넘는다
function normalRandom(rng) {
return (rng() + rng() + rng() + rng() - 2) * 1.7320508;
}
// 격자 점마다 정해진 값(0~1) — 정수 좌표 두 개와 시드를 섞는다
function hash2(ix, iy, seed) {
let h = Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263) ^ Math.imul(seed, 1274126177);
h = Math.imul(h ^ (h >>> 13), 1103515245);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 2차원 값 노이즈 — 정수 격자 네 점의 값을 부드럽게 보간한 0~1. 종이 높이·경계 흔들기·꽃잎 반지름에 쓴다
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = hash2(ix, iy, seed);
const b = hash2(ix + 1, iy, seed);
const c = hash2(ix, iy + 1, seed);
const d = hash2(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 두 옥타브 — 굵은 결(0.65) 위에 두 배 촘촘한 결(0.35)을 겹친다
function noise2(x, y, seed) {
return valueNoise(x, y, seed) * 0.65 + valueNoise(x * 2 + 17.3, y * 2 + 5.1, seed + 101) * 0.35;
}
// 둘레를 따라 한 바퀴 이어지는 노이즈 — 각 θ 를 원 위의 점으로 바꿔 2차원 노이즈를 읽으므로 0 과 2π 에서 값이 같다
function ringNoise(theta, freq, seed) {
return noise2(Math.cos(theta) * freq + 50, Math.sin(theta) * freq + 50, seed);
}
// 파스텔 안료 — 흰 종이에 한 겹 칠했을 때 돌아오는 빛(0~255). 진하게 쌓이면 곱으로 깊어진다
const PAPER = [250, 245, 232];
const INK = [58, 50, 74];
const SKY = [146, 192, 230];
const BUTTER = [246, 214, 118];
const CORAL = [240, 148, 132];
const SAGE = [142, 192, 152];
const LAVENDER = [178, 158, 226];
const PINK = [240, 164, 194];
const INDIGO = [128, 136, 196];
// 순환 — 그리기 → 머묾 → 씻겨 지워짐 → 처음부터. 시간 t(초)가 어디쯤인지 0~1 로
function phaseOf(t, start, length) { return clamp((t - start) / length, 0, 1); }
function pigment(c) {
return { r: Math.log(Math.max(c[0], 1) / 255), g: Math.log(Math.max(c[1], 1) / 255), b: Math.log(Math.max(c[2], 1) / 255) };
}
// 젖은 물기 — 마르기 전의 색면을 살짝 어둡고 차갑게 보이게 하는 옅은 회청색 한 겹
const WATER = [214, 224, 236];
// 화면 버퍼 — 화소마다 RGB(0~1) 세 칸. 프레임마다 새 배열을 만들지 않고 이 한 벌을 다시 쓴다
const OUT = new Float32Array(OW * OH * 3);
// 종이 높이맵 — 0(골)~1(마루). 처음 한 번만 계산한다
const PAPER_HEIGHT = new Float32Array(OW * OH);
// 종이 요철 음영 — 비스듬한 빛이 결의 기울기에 닿아 생기는 밝기 배율
const PAPER_SHADE = new Float32Array(OW * OH);
// 종이 결 크기 — 노이즈 한 칸이 화소 몇 개인가 (작을수록 촘촘한 결)
const GRAIN_SIZE = 2.6;
// 결 음영 세기 — 왼쪽 위에서 든 빛 방향으로 오르막이면 밝게, 내리막이면 어둡게
const GRAIN_LIGHT = 0.36;
function buildPaper(seed, relief) {
let lo = 1;
let hi = 0;
for (let y = 0; y < OH; y += 1) {
for (let x = 0; x < OW; x += 1) {
const h = noise2(x / GRAIN_SIZE, y / GRAIN_SIZE, seed);
PAPER_HEIGHT[y * OW + x] = h;
lo = Math.min(lo, h);
hi = Math.max(hi, h);
}
}
// 높이를 0~1 로 다시 펴서 골과 마루가 끝까지 쓰이게 한다
const span = Math.max(hi - lo, 1e-6);
for (let i = 0; i < OW * OH; i += 1) {
PAPER_HEIGHT[i] = (PAPER_HEIGHT[i] - lo) / span;
}
for (let y = 0; y < OH; y += 1) {
for (let x = 0; x < OW; x += 1) {
const gx = PAPER_HEIGHT[y * OW + Math.min(x + 1, OW - 1)] - PAPER_HEIGHT[y * OW + Math.max(x - 1, 0)];
const gy = PAPER_HEIGHT[Math.min(y + 1, OH - 1) * OW + x] - PAPER_HEIGHT[Math.max(y - 1, 0) * OW + x];
// 빛은 왼쪽 위에서 — 기울기를 (−1, −1)/√2 방향에 내린 값이 양이면 빛을 마주 본다
const lit = -(gx + gy) * 0.7071;
PAPER_SHADE[y * OW + x] = clamp(1 + GRAIN_LIGHT * relief * lit * 0.25, 0.82, 1.08);
}
}
}
// 색 합성 — 진하기 격자 여러 장(gw×gh)을 화소 격자로 늘려 읽고, 안료마다 진하기 × 로그 비율을 더한 뒤 지수로 되돌려 종이 색에 곱한다
// gran 은 안료마다 종이 골에 고이는 정도: 진하기 × (1 + gran × (1 − 2 × 높이)) — 골(0)은 1 + gran 배, 마루(1)는 1 − gran 배
// over 가 true 면 종이부터 다시 칠하지 않고 지금 OUT 위에 곱으로 덧칠한다(격자 크기가 다른 층을 한 장면에 겹칠 때)
function compose(fields, gw, gh, pigs, gran, over) {
const sx = gw / OW;
const sy = gh / OH;
const n = fields.length;
const pr = PAPER[0] / 255;
const pg = PAPER[1] / 255;
const pb = PAPER[2] / 255;
for (let y = 0; y < OH; y += 1) {
const fy = clamp((y + 0.5) * sy - 0.5, 0, gh - 1);
const y0 = Math.min(Math.floor(fy), gh - 2 < 0 ? 0 : gh - 2);
const ty = fy - y0;
for (let x = 0; x < OW; x += 1) {
const fx = clamp((x + 0.5) * sx - 0.5, 0, gw - 1);
const x0 = Math.min(Math.floor(fx), gw - 2);
const tx = fx - x0;
const i00 = y0 * gw + x0;
const i10 = i00 + 1;
const i01 = i00 + gw;
const i11 = i01 + 1;
const p = y * OW + x;
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;
}
if (over) {
if (lr !== 0 || lg !== 0 || lb !== 0) {
OUT[p * 3] *= Math.exp(lr);
OUT[p * 3 + 1] *= Math.exp(lg);
OUT[p * 3 + 2] *= Math.exp(lb);
}
continue;
}
const s = PAPER_SHADE[p];
OUT[p * 3] = pr * s * Math.exp(lr);
OUT[p * 3 + 1] = pg * s * Math.exp(lg);
OUT[p * 3 + 2] = pb * s * Math.exp(lb);
}
}
}
// 씻어 내기 앞머리 띠 폭(화소) — 물이 지나가는 가장자리가 이만큼 부드럽게 번진다
const WASH_SOFT = 26;
// 씻어 내기 — 물이 왼쪽에서 오른쪽으로 쓸고 지나간 자리는 종이로 돌아간다. 앞머리는 줄마다 노이즈로 흔들리고 젖은 띠가 살짝 어둡다
function washOver(p, seed) {
if (p <= 0) {
return;
}
const pr = PAPER[0] / 255;
const pg = PAPER[1] / 255;
const pb = PAPER[2] / 255;
for (let y = 0; y < OH; y += 1) {
const front = lerp(-WASH_SOFT - 50, OW + WASH_SOFT + 50, p) + (noise2(y / 46, p * 1.5, seed) - 0.5) * 70;
for (let x = 0; x < OW; x += 1) {
const k = smooth((front - x) / WASH_SOFT);
if (k <= 0) {
continue;
}
const i = y * OW + x;
const s = PAPER_SHADE[i];
const tint = 1 - 0.07 * (1 - Math.abs(2 * k - 1));
OUT[i * 3] = lerp(OUT[i * 3], pr * s, k) * tint;
OUT[i * 3 + 1] = lerp(OUT[i * 3 + 1], pg * s, k) * tint;
OUT[i * 3 + 2] = lerp(OUT[i * 3 + 2], pb * s, k) * (1 - 0.03 * (1 - Math.abs(2 * k - 1)));
}
}
}
// 순환 끝의 씻어 내기 — 그리기(PAINT_TIME)와 머묾(HOLD_TIME)이 지난 뒤 WASH_TIME 동안
function washAt(t, seed) {
washOver(phaseOf(t, PAINT_TIME + HOLD_TIME, WASH_TIME), seed);
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 화소 격자를 담는 바깥에 안 보이는 캔버스 — ImageData 한 장을 만들어 두고 프레임마다 내용만 바꿔 쓴다
const bufEl = document.createElement('canvas');
bufEl.width = OW;
bufEl.height = OH;
const bufRaw = bufEl.getContext('2d');
if (!canvasEl || !ctxRaw || !pauseEl || !bufRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const bufCtx = bufRaw;
const frameImage = bufCtx.createImageData(OW, OH);
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 버퍼(OUT, 0~1)를 8비트로 옮겨 바깥 캔버스에 놓고, 판 크기로 부드럽게 늘려 그린다
function present() {
const px = frameImage.data;
for (let i = 0, j = 0; i < OW * OH; i += 1, j += 3) {
px[i * 4] = OUT[j] * 255;
px[i * 4 + 1] = OUT[j + 1] * 255;
px[i * 4 + 2] = OUT[j + 2] * 255;
px[i * 4 + 3] = 255;
}
bufCtx.putImageData(frameImage, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bufEl, 0, 0, W, H);
}
// 02 마른 테두리 — 젖은 자리마다 경계까지의 거리를 구해 두고, 물이 줄어드는 동안 안료를 거리가 짧아지는 쪽(테두리)으로 옮긴다
// 물이 번져 자리를 잡는 시간(초) — 이 사이에 젖은 자리가 가운데부터 넓어진다
const SPREAD_TIME = 0.45;
// 다 마르는 데 걸리는 시간(초) — 물높이가 1 에서 0 으로 내려간다
const DRY_TIME = 2.2;
// 처음 안료 진하기 — 젖은 자리 전체에 고르게
const STAIN_DENSITY = 0.6;
// 테두리로 옮기는 빠르기(1초에 옮기는 비율) — 마를수록 빨라진다: RIM_FLOW × (0.3 + 0.7 × (1 − 물높이))
const RIM_FLOW = 2.2;
// 얼룩 둘레 흔들기 — 반지름에 곱하는 노이즈 폭
const STAIN_WOBBLE = 0.24;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.0;
const PAINT_TIME = 4.4;
const HOLD_TIME = 2.4;
const WASH_TIME = 1.4;
const SEED = 4822;
// 판 1px = 격자 몇 칸인가
const CELL = GW / W;
const STAINS = [
{ cx: 138, cy: 128, r: 92, color: SKY, start: 0.2 },
{ cx: 336, cy: 104, r: 78, color: PINK, start: 0.9 },
{ cx: 282, cy: 226, r: 68, color: LAVENDER, start: 1.6 },
];
const wetMasks = STAINS.map(function () { return new Uint8Array(GW * GH); });
const rimDist = STAINS.map(function () { return new Float32Array(GW * GH); });
const stainFields = STAINS.map(function () { return new Float32Array(GW * GH); });
const distReady = STAINS.map(function () { return false; });
// 얼룩마다 가장 안쪽 칸의 거리 — 마르는 앞머리가 테두리에서 여기까지 들어온다
const maxDist = STAINS.map(function () { return 1; });
// 옮길 양을 모아 두는 격자 — 한 걸음의 이동을 다 계산한 뒤 한꺼번에 더한다(먼저 옮긴 칸이 같은 걸음에 또 옮겨지지 않게)
const flowTmp = new Float32Array(GW * GH);
const waterField = new Float32Array(GW * GH);
const fields02 = stainFields.concat([waterField]);
const pigs02 = STAINS.map(function (s) { return pigment(s.color); }).concat([pigment(WATER)]);
const gran02 = fields02.map(function () { return 0; });
buildPaper(SEED, 1);
function edgeRadius(k, angle) {
return STAINS[k].r * (1 + STAIN_WOBBLE * (ringNoise(angle, 1.7, SEED + k) - 0.5) * 2);
}
function reset() {
for (let k = 0; k < STAINS.length; k += 1) {
wetMasks[k].fill(0);
stainFields[k].fill(0);
rimDist[k].fill(0);
distReady[k] = false;
}
waterField.fill(0);
}
// 젖은 자리 넓히기 — 반지름 × grow 안쪽의 마른 칸을 적시고 안료를 넣는다
function spreadStain(k, grow) {
const s = STAINS[k];
const mask = wetMasks[k];
const f = stainFields[k];
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
if (mask[i]) {
continue;
}
const dx = (x + 0.5) / CELL - s.cx;
const dy = (y + 0.5) / CELL - s.cy;
if (Math.hypot(dx, dy) < edgeRadius(k, Math.atan2(dy, dx)) * grow) {
mask[i] = 1;
f[i] = STAIN_DENSITY;
}
}
}
}
// 거리 변환 — 젖은 칸마다 가장 가까운 마른 칸까지의 거리(칸). 위→아래로 한 번, 아래→위로 한 번 훑으며 이웃 값 + 1(곧은 이웃)·+1.414(대각 이웃) 중 작은 것을 고른다
function distanceToEdge(mask, out) {
const far = 1e6;
for (let i = 0; i < GW * GH; i += 1) {
out[i] = mask[i] ? far : 0;
}
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
if (!out[i]) {
continue;
}
let d = out[i];
if (x > 0) {
d = Math.min(d, out[i - 1] + 1);
}
else {
d = Math.min(d, 1);
}
if (y > 0) {
d = Math.min(d, out[i - GW] + 1);
if (x > 0) {
d = Math.min(d, out[i - GW - 1] + 1.414);
}
if (x < GW - 1) {
d = Math.min(d, out[i - GW + 1] + 1.414);
}
}
else {
d = Math.min(d, 1);
}
out[i] = d;
}
}
for (let y = GH - 1; y >= 0; y -= 1) {
for (let x = GW - 1; x >= 0; x -= 1) {
const i = y * GW + x;
if (!out[i]) {
continue;
}
let d = out[i];
if (x < GW - 1) {
d = Math.min(d, out[i + 1] + 1);
}
else {
d = Math.min(d, 1);
}
if (y < GH - 1) {
d = Math.min(d, out[i + GW] + 1);
if (x < GW - 1) {
d = Math.min(d, out[i + GW + 1] + 1.414);
}
if (x > 0) {
d = Math.min(d, out[i + GW - 1] + 1.414);
}
}
else {
d = Math.min(d, 1);
}
out[i] = d;
}
}
}
// 거리 다듬기 — 곧은·대각 걸음만으로 잰 거리는 방향마다 계단이 져서 흐름이 바큇살처럼 갈라진다. 젖은 칸끼리 이웃 평균을 두 번 섞어 매끈하게(경계 칸 1 은 그대로)
function softenDistance(mask, dist) {
for (let pass = 0; pass < 2; pass += 1) {
flowTmp.set(dist);
for (let y = 1; y < GH - 1; y += 1) {
for (let x = 1; x < GW - 1; x += 1) {
const i = y * GW + x;
if (!mask[i] || dist[i] <= 1) {
continue;
}
const avg = (flowTmp[i - 1] + flowTmp[i + 1] + flowTmp[i - GW] + flowTmp[i + GW]) * 0.25;
dist[i] = Math.max(1.01, (flowTmp[i] + avg) * 0.5);
}
}
}
}
// 안쪽 칸끼리 고르기 — 이웃 젖은 칸과의 차이의 RIM_SMOOTH/4 만큼 주고받는다(안료 총량은 그대로). 흐름 갈래가 줄무늬로 남지 않게
const RIM_SMOOTH = 0.8;
// 한 이웃으로 보내기 — c 가 젖었고 경계에 더 가까우면 거리 차에 비례한 몫을 받는다
function downhill(dist, mask, i, c) {
return mask[c] && dist[c] < dist[i] ? dist[i] - dist[c] : 0;
}
// 테두리로 옮기기 — 젖은 칸마다 안료의 frac 만큼을, 네 이웃 가운데 경계까지 거리가 더 짧은 젖은 칸들에 거리 차 비율로 나눠 보낸다
// 경계 칸(거리 1)은 더 갈 곳이 없어 받은 안료가 쌓인다 → 마른 뒤 진한 고리
function flowToRim(k, frac) {
const mask = wetMasks[k];
const dist = rimDist[k];
const f = stainFields[k];
flowTmp.fill(0);
for (let y = 1; y < GH - 1; y += 1) {
for (let x = 1; x < GW - 1; x += 1) {
const i = y * GW + x;
if (!mask[i]) {
continue;
}
const l = i - 1;
const r = i + 1;
const u = i - GW;
const d = i + GW;
// 고르기 — 테두리(거리 2 이하)에서 먼 젖은 칸끼리만. 테두리에 모인 안료는 안으로 되돌아가지 않는다
let ex = 0;
if (mask[l] && dist[l] > 2) {
ex += f[l] - f[i];
}
if (mask[r] && dist[r] > 2) {
ex += f[r] - f[i];
}
if (mask[u] && dist[u] > 2) {
ex += f[u] - f[i];
}
if (mask[d] && dist[d] > 2) {
ex += f[d] - f[i];
}
if (dist[i] > 2) {
flowTmp[i] += ex * RIM_SMOOTH * 0.25;
}
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;
}
}
for (let i = 0; i < GW * GH; i += 1) {
f[i] += flowTmp[i];
}
}
// 물높이 — 번진 뒤 DRY_TIME 동안 1 에서 0 으로
function waterLevel(k, t) {
return 1 - phaseOf(t, STAINS[k].start + SPREAD_TIME, DRY_TIME);
}
function update(dt, t) {
waterField.fill(0);
for (let k = 0; k < STAINS.length; k += 1) {
const s = STAINS[k];
if (t < s.start) {
continue;
}
const grow = phaseOf(t, s.start, SPREAD_TIME);
if (grow < 1) {
spreadStain(k, smooth(grow));
}
else if (!distReady[k]) {
spreadStain(k, 1);
distanceToEdge(wetMasks[k], rimDist[k]);
softenDistance(wetMasks[k], rimDist[k]);
maxDist[k] = 1;
for (let i = 0; i < GW * GH; i += 1) {
maxDist[k] = Math.max(maxDist[k], rimDist[k][i]);
}
distReady[k] = true;
}
const level = waterLevel(k, t);
if (distReady[k] && level > 0) {
flowToRim(k, RIM_FLOW * (0.3 + 0.7 * (1 - level)) * dt);
}
// 젖은 물기 — 옅은 회청색 막. 물높이가 내려갈수록 테두리 쪽부터 말라 막이 가운데로 줄어든다(테두리는 안료가 붙잡아 젖은 채 남는 것과 따로, 눈에 보이는 물기만)
const mask = wetMasks[k];
const dryFront = (1 - level) * maxDist[k];
for (let i = 0; i < GW * GH; i += 1) {
if (mask[i]) {
waterField[i] += distReady[k] ? 0.5 * smooth((rimDist[k][i] - dryFront) / 2.5) : 0.5;
}
}
}
}
function render(t) {
compose(fields02, GW, GH, pigs02, gran02);
washAt(t, SEED);
}
// 02 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
function draw() {
render(simTime);
present();
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 한 바퀴 — 그리기 → 머묾 → 씻겨 지워짐. 끝나면 같은 시드로 처음부터 다시 그린다
const CYCLE = PAINT_TIME + HOLD_TIME + WASH_TIME;
// 그림 시계(초)와 고정 걸음에 아직 못 넣은 나머지 시간
let simTime = 0;
let pending = 0;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 고정 걸음 하나 — 그리는 동안만 계산을 나아가게 하고, 한 바퀴가 끝나면 처음 상태로 되돌린다
function stepOnce() {
simTime += STEP;
if (simTime >= CYCLE) {
simTime = 0;
reset();
return;
}
if (simTime <= PAINT_TIME) {
update(STEP, simTime);
}
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
pending += Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
while (pending >= STEP) {
stepOnce();
pending -= STEP;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임에 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고, 같은 시드로 그리기 끝까지 한 번에 계산한 Finished 그림 한 장만 보여 준다
function showFinished() {
stopLoop();
reset();
simTime = 0;
while (simTime + STEP <= PAINT_TIME) {
simTime += STEP;
update(STEP, simTime);
}
simTime = PAINT_TIME;
draw();
}
function play() {
reset();
simTime = 0;
pending = 0;
// 자동 시연은 DEMO_START 초까지 미리 그려 둔 채 시작한다 — 페이지를 열자마자 빈 종이만 보이지 않게(사람이 다시 시작하면 0초부터)
if (demo) {
while (simTime + STEP <= DEMO_START) {
stepOnce();
}
}
draw();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 그리기를 멈추고, 돌아오면 시계를 다시 맞춰 이어 그린다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 Finished 그림으로 멈추고, 꺼지면 처음부터 다시 그린다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showFinished();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showFinished();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DryRimPigmentPool - vanilla 판(02-watercolor-dry-rim-pigment-pool) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./DryRimPigmentPool.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DryRimPigmentPool() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 그리기 좌표는 이 판으로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
// 화소 격자 — 픽셀 연산(색 합성·종이 결)은 320×200 에서 하고 화면에는 늘려 그린다
const OW = 320;
const OH = 200;
// 계산 격자 — 물·안료 확산처럼 무거운 계산은 가로 160 칸 격자에서 (한 칸 = 판 3px)
const GW = 160;
const GH = 100;
// 고정 걸음(초) — 같은 시드면 몇 번을 다시 그려도 같은 그림이 나오도록 계산은 1/30초씩 나아간다
const STEP = 1 / 30;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 얼룩·물방울·붓털이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
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;
};
}
// 종 모양 난수 — 균일 난수 넷의 합을 평균 0·표준편차 1 로 맞춘 값. 대부분 ±1 안, 가끔 ±2 를 넘는다
function normalRandom(rng: () => number): number {
return (rng() + rng() + rng() + rng() - 2) * 1.7320508;
}
// 격자 점마다 정해진 값(0~1) — 정수 좌표 두 개와 시드를 섞는다
function hash2(ix: number, iy: number, seed: number): number {
let h = Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263) ^ Math.imul(seed, 1274126177);
h = Math.imul(h ^ (h >>> 13), 1103515245);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 2차원 값 노이즈 — 정수 격자 네 점의 값을 부드럽게 보간한 0~1. 종이 높이·경계 흔들기·꽃잎 반지름에 쓴다
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = hash2(ix, iy, seed);
const b = hash2(ix + 1, iy, seed);
const c = hash2(ix, iy + 1, seed);
const d = hash2(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 두 옥타브 — 굵은 결(0.65) 위에 두 배 촘촘한 결(0.35)을 겹친다
function noise2(x: number, y: number, seed: number): number {
return valueNoise(x, y, seed) * 0.65 + valueNoise(x * 2 + 17.3, y * 2 + 5.1, seed + 101) * 0.35;
}
// 둘레를 따라 한 바퀴 이어지는 노이즈 — 각 θ 를 원 위의 점으로 바꿔 2차원 노이즈를 읽으므로 0 과 2π 에서 값이 같다
function ringNoise(theta: number, freq: number, seed: number): number {
return noise2(Math.cos(theta) * freq + 50, Math.sin(theta) * freq + 50, seed);
}
// 파스텔 안료 — 흰 종이에 한 겹 칠했을 때 돌아오는 빛(0~255). 진하게 쌓이면 곱으로 깊어진다
const PAPER: RGB = [250, 245, 232];
const INK: RGB = [58, 50, 74];
const SKY: RGB = [146, 192, 230];
const BUTTER: RGB = [246, 214, 118];
const CORAL: RGB = [240, 148, 132];
const SAGE: RGB = [142, 192, 152];
const LAVENDER: RGB = [178, 158, 226];
const PINK: RGB = [240, 164, 194];
const INDIGO: RGB = [128, 136, 196];
// 순환 — 그리기 → 머묾 → 씻겨 지워짐 → 처음부터. 시간 t(초)가 어디쯤인지 0~1 로
function phaseOf(t: number, start: number, length: number): number { return clamp((t - start) / length, 0, 1); }
// 안료 — 한 겹이 채널마다 돌려보내는 빛의 비율을 로그로 들고 있다. 진하기 d 만큼 쌓이면 빛 = 종이 × 비율^d (곱이라 겹칠수록 깊어진다)
type Pigment = { r: number; g: number; b: number };
function pigment(c: RGB): Pigment {
return { r: Math.log(Math.max(c[0], 1) / 255), g: Math.log(Math.max(c[1], 1) / 255), b: Math.log(Math.max(c[2], 1) / 255) };
}
// 젖은 물기 — 마르기 전의 색면을 살짝 어둡고 차갑게 보이게 하는 옅은 회청색 한 겹
const WATER: RGB = [214, 224, 236];
// 화면 버퍼 — 화소마다 RGB(0~1) 세 칸. 프레임마다 새 배열을 만들지 않고 이 한 벌을 다시 쓴다
const OUT = new Float32Array(OW * OH * 3);
// 종이 높이맵 — 0(골)~1(마루). 처음 한 번만 계산한다
const PAPER_HEIGHT = new Float32Array(OW * OH);
// 종이 요철 음영 — 비스듬한 빛이 결의 기울기에 닿아 생기는 밝기 배율
const PAPER_SHADE = new Float32Array(OW * OH);
// 종이 결 크기 — 노이즈 한 칸이 화소 몇 개인가 (작을수록 촘촘한 결)
const GRAIN_SIZE = 2.6;
// 결 음영 세기 — 왼쪽 위에서 든 빛 방향으로 오르막이면 밝게, 내리막이면 어둡게
const GRAIN_LIGHT = 0.36;
function buildPaper(seed: number, relief: number): void {
let lo = 1;
let hi = 0;
for (let y = 0; y < OH; y += 1) {
for (let x = 0; x < OW; x += 1) {
const h = noise2(x / GRAIN_SIZE, y / GRAIN_SIZE, seed);
PAPER_HEIGHT[y * OW + x] = h;
lo = Math.min(lo, h);
hi = Math.max(hi, h);
}
}
// 높이를 0~1 로 다시 펴서 골과 마루가 끝까지 쓰이게 한다
const span = Math.max(hi - lo, 1e-6);
for (let i = 0; i < OW * OH; i += 1) { PAPER_HEIGHT[i] = (PAPER_HEIGHT[i] - lo) / span; }
for (let y = 0; y < OH; y += 1) {
for (let x = 0; x < OW; x += 1) {
const gx = PAPER_HEIGHT[y * OW + Math.min(x + 1, OW - 1)] - PAPER_HEIGHT[y * OW + Math.max(x - 1, 0)];
const gy = PAPER_HEIGHT[Math.min(y + 1, OH - 1) * OW + x] - PAPER_HEIGHT[Math.max(y - 1, 0) * OW + x];
// 빛은 왼쪽 위에서 — 기울기를 (−1, −1)/√2 방향에 내린 값이 양이면 빛을 마주 본다
const lit = -(gx + gy) * 0.7071;
PAPER_SHADE[y * OW + x] = clamp(1 + GRAIN_LIGHT * relief * lit * 0.25, 0.82, 1.08);
}
}
}
// 색 합성 — 진하기 격자 여러 장(gw×gh)을 화소 격자로 늘려 읽고, 안료마다 진하기 × 로그 비율을 더한 뒤 지수로 되돌려 종이 색에 곱한다
// gran 은 안료마다 종이 골에 고이는 정도: 진하기 × (1 + gran × (1 − 2 × 높이)) — 골(0)은 1 + gran 배, 마루(1)는 1 − gran 배
// over 가 true 면 종이부터 다시 칠하지 않고 지금 OUT 위에 곱으로 덧칠한다(격자 크기가 다른 층을 한 장면에 겹칠 때)
function compose(fields: Float32Array[], gw: number, gh: number, pigs: Pigment[], gran: number[], over?: boolean): void {
const sx = gw / OW;
const sy = gh / OH;
const n = fields.length;
const pr = PAPER[0] / 255;
const pg = PAPER[1] / 255;
const pb = PAPER[2] / 255;
for (let y = 0; y < OH; y += 1) {
const fy = clamp((y + 0.5) * sy - 0.5, 0, gh - 1);
const y0 = Math.min(Math.floor(fy), gh - 2 < 0 ? 0 : gh - 2);
const ty = fy - y0;
for (let x = 0; x < OW; x += 1) {
const fx = clamp((x + 0.5) * sx - 0.5, 0, gw - 1);
const x0 = Math.min(Math.floor(fx), gw - 2);
const tx = fx - x0;
const i00 = y0 * gw + x0;
const i10 = i00 + 1;
const i01 = i00 + gw;
const i11 = i01 + 1;
const p = y * OW + x;
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;
}
if (over) {
if (lr !== 0 || lg !== 0 || lb !== 0) {
OUT[p * 3] *= Math.exp(lr);
OUT[p * 3 + 1] *= Math.exp(lg);
OUT[p * 3 + 2] *= Math.exp(lb);
}
continue;
}
const s = PAPER_SHADE[p];
OUT[p * 3] = pr * s * Math.exp(lr);
OUT[p * 3 + 1] = pg * s * Math.exp(lg);
OUT[p * 3 + 2] = pb * s * Math.exp(lb);
}
}
}
// 씻어 내기 앞머리 띠 폭(화소) — 물이 지나가는 가장자리가 이만큼 부드럽게 번진다
const WASH_SOFT = 26;
// 씻어 내기 — 물이 왼쪽에서 오른쪽으로 쓸고 지나간 자리는 종이로 돌아간다. 앞머리는 줄마다 노이즈로 흔들리고 젖은 띠가 살짝 어둡다
function washOver(p: number, seed: number): void {
if (p <= 0) { return; }
const pr = PAPER[0] / 255;
const pg = PAPER[1] / 255;
const pb = PAPER[2] / 255;
for (let y = 0; y < OH; y += 1) {
const front = lerp(-WASH_SOFT - 50, OW + WASH_SOFT + 50, p) + (noise2(y / 46, p * 1.5, seed) - 0.5) * 70;
for (let x = 0; x < OW; x += 1) {
const k = smooth((front - x) / WASH_SOFT);
if (k <= 0) { continue; }
const i = y * OW + x;
const s = PAPER_SHADE[i];
const tint = 1 - 0.07 * (1 - Math.abs(2 * k - 1));
OUT[i * 3] = lerp(OUT[i * 3], pr * s, k) * tint;
OUT[i * 3 + 1] = lerp(OUT[i * 3 + 1], pg * s, k) * tint;
OUT[i * 3 + 2] = lerp(OUT[i * 3 + 2], pb * s, k) * (1 - 0.03 * (1 - Math.abs(2 * k - 1)));
}
}
}
// 순환 끝의 씻어 내기 — 그리기(PAINT_TIME)와 머묾(HOLD_TIME)이 지난 뒤 WASH_TIME 동안
function washAt(t: number, seed: number): void {
washOver(phaseOf(t, PAINT_TIME + HOLD_TIME, WASH_TIME), seed);
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 화소 격자를 담는 바깥에 안 보이는 캔버스 — ImageData 한 장을 만들어 두고 프레임마다 내용만 바꿔 쓴다
const bufEl = document.createElement('canvas');
bufEl.width = OW;
bufEl.height = OH;
const bufRaw = bufEl.getContext('2d');
if (!canvasEl || !ctxRaw || !pauseEl || !bufRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const bufCtx: CanvasRenderingContext2D = bufRaw;
const frameImage: ImageData = bufCtx.createImageData(OW, OH);
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 버퍼(OUT, 0~1)를 8비트로 옮겨 바깥 캔버스에 놓고, 판 크기로 부드럽게 늘려 그린다
function present(): void {
const px = frameImage.data;
for (let i = 0, j = 0; i < OW * OH; i += 1, j += 3) {
px[i * 4] = OUT[j] * 255;
px[i * 4 + 1] = OUT[j + 1] * 255;
px[i * 4 + 2] = OUT[j + 2] * 255;
px[i * 4 + 3] = 255;
}
bufCtx.putImageData(frameImage, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bufEl, 0, 0, W, H);
}
// 02 마른 테두리 — 젖은 자리마다 경계까지의 거리를 구해 두고, 물이 줄어드는 동안 안료를 거리가 짧아지는 쪽(테두리)으로 옮긴다
// 물이 번져 자리를 잡는 시간(초) — 이 사이에 젖은 자리가 가운데부터 넓어진다
const SPREAD_TIME = 0.45;
// 다 마르는 데 걸리는 시간(초) — 물높이가 1 에서 0 으로 내려간다
const DRY_TIME = 2.2;
// 처음 안료 진하기 — 젖은 자리 전체에 고르게
const STAIN_DENSITY = 0.6;
// 테두리로 옮기는 빠르기(1초에 옮기는 비율) — 마를수록 빨라진다: RIM_FLOW × (0.3 + 0.7 × (1 − 물높이))
const RIM_FLOW = 2.2;
// 얼룩 둘레 흔들기 — 반지름에 곱하는 노이즈 폭
const STAIN_WOBBLE = 0.24;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.0;
const PAINT_TIME = 4.4;
const HOLD_TIME = 2.4;
const WASH_TIME = 1.4;
const SEED = 4822;
// 판 1px = 격자 몇 칸인가
const CELL = GW / W;
type Stain = { cx: number; cy: number; r: number; color: RGB; start: number };
const STAINS: Stain[] = [
{ cx: 138, cy: 128, r: 92, color: SKY, start: 0.2 },
{ cx: 336, cy: 104, r: 78, color: PINK, start: 0.9 },
{ cx: 282, cy: 226, r: 68, color: LAVENDER, start: 1.6 },
];
const wetMasks: Uint8Array[] = STAINS.map(function (): Uint8Array { return new Uint8Array(GW * GH); });
const rimDist: Float32Array[] = STAINS.map(function (): Float32Array { return new Float32Array(GW * GH); });
const stainFields: Float32Array[] = STAINS.map(function (): Float32Array { return new Float32Array(GW * GH); });
const distReady: boolean[] = STAINS.map(function (): boolean { return false; });
// 얼룩마다 가장 안쪽 칸의 거리 — 마르는 앞머리가 테두리에서 여기까지 들어온다
const maxDist: number[] = STAINS.map(function (): number { return 1; });
// 옮길 양을 모아 두는 격자 — 한 걸음의 이동을 다 계산한 뒤 한꺼번에 더한다(먼저 옮긴 칸이 같은 걸음에 또 옮겨지지 않게)
const flowTmp = new Float32Array(GW * GH);
const waterField = new Float32Array(GW * GH);
const fields02: Float32Array[] = stainFields.concat([waterField]);
const pigs02: Pigment[] = STAINS.map(function (s: Stain): Pigment { return pigment(s.color); }).concat([pigment(WATER)]);
const gran02: number[] = fields02.map(function (): number { return 0; });
buildPaper(SEED, 1);
function edgeRadius(k: number, angle: number): number {
return STAINS[k].r * (1 + STAIN_WOBBLE * (ringNoise(angle, 1.7, SEED + k) - 0.5) * 2);
}
function reset(): void {
for (let k = 0; k < STAINS.length; k += 1) {
wetMasks[k].fill(0);
stainFields[k].fill(0);
rimDist[k].fill(0);
distReady[k] = false;
}
waterField.fill(0);
}
// 젖은 자리 넓히기 — 반지름 × grow 안쪽의 마른 칸을 적시고 안료를 넣는다
function spreadStain(k: number, grow: number): void {
const s = STAINS[k];
const mask = wetMasks[k];
const f = stainFields[k];
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
if (mask[i]) { continue; }
const dx = (x + 0.5) / CELL - s.cx;
const dy = (y + 0.5) / CELL - s.cy;
if (Math.hypot(dx, dy) < edgeRadius(k, Math.atan2(dy, dx)) * grow) {
mask[i] = 1;
f[i] = STAIN_DENSITY;
}
}
}
}
// 거리 변환 — 젖은 칸마다 가장 가까운 마른 칸까지의 거리(칸). 위→아래로 한 번, 아래→위로 한 번 훑으며 이웃 값 + 1(곧은 이웃)·+1.414(대각 이웃) 중 작은 것을 고른다
function distanceToEdge(mask: Uint8Array, out: Float32Array): void {
const far = 1e6;
for (let i = 0; i < GW * GH; i += 1) { out[i] = mask[i] ? far : 0; }
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
if (!out[i]) { continue; }
let d = out[i];
if (x > 0) { d = Math.min(d, out[i - 1] + 1); } else { d = Math.min(d, 1); }
if (y > 0) {
d = Math.min(d, out[i - GW] + 1);
if (x > 0) { d = Math.min(d, out[i - GW - 1] + 1.414); }
if (x < GW - 1) { d = Math.min(d, out[i - GW + 1] + 1.414); }
} else { d = Math.min(d, 1); }
out[i] = d;
}
}
for (let y = GH - 1; y >= 0; y -= 1) {
for (let x = GW - 1; x >= 0; x -= 1) {
const i = y * GW + x;
if (!out[i]) { continue; }
let d = out[i];
if (x < GW - 1) { d = Math.min(d, out[i + 1] + 1); } else { d = Math.min(d, 1); }
if (y < GH - 1) {
d = Math.min(d, out[i + GW] + 1);
if (x < GW - 1) { d = Math.min(d, out[i + GW + 1] + 1.414); }
if (x > 0) { d = Math.min(d, out[i + GW - 1] + 1.414); }
} else { d = Math.min(d, 1); }
out[i] = d;
}
}
}
// 거리 다듬기 — 곧은·대각 걸음만으로 잰 거리는 방향마다 계단이 져서 흐름이 바큇살처럼 갈라진다. 젖은 칸끼리 이웃 평균을 두 번 섞어 매끈하게(경계 칸 1 은 그대로)
function softenDistance(mask: Uint8Array, dist: Float32Array): void {
for (let pass = 0; pass < 2; pass += 1) {
flowTmp.set(dist);
for (let y = 1; y < GH - 1; y += 1) {
for (let x = 1; x < GW - 1; x += 1) {
const i = y * GW + x;
if (!mask[i] || dist[i] <= 1) { continue; }
const avg = (flowTmp[i - 1] + flowTmp[i + 1] + flowTmp[i - GW] + flowTmp[i + GW]) * 0.25;
dist[i] = Math.max(1.01, (flowTmp[i] + avg) * 0.5);
}
}
}
}
// 안쪽 칸끼리 고르기 — 이웃 젖은 칸과의 차이의 RIM_SMOOTH/4 만큼 주고받는다(안료 총량은 그대로). 흐름 갈래가 줄무늬로 남지 않게
const RIM_SMOOTH = 0.8;
// 한 이웃으로 보내기 — c 가 젖었고 경계에 더 가까우면 거리 차에 비례한 몫을 받는다
function downhill(dist: Float32Array, mask: Uint8Array, i: number, c: number): number {
return mask[c] && dist[c] < dist[i] ? dist[i] - dist[c] : 0;
}
// 테두리로 옮기기 — 젖은 칸마다 안료의 frac 만큼을, 네 이웃 가운데 경계까지 거리가 더 짧은 젖은 칸들에 거리 차 비율로 나눠 보낸다
// 경계 칸(거리 1)은 더 갈 곳이 없어 받은 안료가 쌓인다 → 마른 뒤 진한 고리
function flowToRim(k: number, frac: number): void {
const mask = wetMasks[k];
const dist = rimDist[k];
const f = stainFields[k];
flowTmp.fill(0);
for (let y = 1; y < GH - 1; y += 1) {
for (let x = 1; x < GW - 1; x += 1) {
const i = y * GW + x;
if (!mask[i]) { continue; }
const l = i - 1;
const r = i + 1;
const u = i - GW;
const d = i + GW;
// 고르기 — 테두리(거리 2 이하)에서 먼 젖은 칸끼리만. 테두리에 모인 안료는 안으로 되돌아가지 않는다
let ex = 0;
if (mask[l] && dist[l] > 2) { ex += f[l] - f[i]; }
if (mask[r] && dist[r] > 2) { ex += f[r] - f[i]; }
if (mask[u] && dist[u] > 2) { ex += f[u] - f[i]; }
if (mask[d] && dist[d] > 2) { ex += f[d] - f[i]; }
if (dist[i] > 2) { flowTmp[i] += ex * RIM_SMOOTH * 0.25; }
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;
}
}
for (let i = 0; i < GW * GH; i += 1) { f[i] += flowTmp[i]; }
}
// 물높이 — 번진 뒤 DRY_TIME 동안 1 에서 0 으로
function waterLevel(k: number, t: number): number {
return 1 - phaseOf(t, STAINS[k].start + SPREAD_TIME, DRY_TIME);
}
function update(dt: number, t: number): void {
waterField.fill(0);
for (let k = 0; k < STAINS.length; k += 1) {
const s = STAINS[k];
if (t < s.start) { continue; }
const grow = phaseOf(t, s.start, SPREAD_TIME);
if (grow < 1) {
spreadStain(k, smooth(grow));
} else if (!distReady[k]) {
spreadStain(k, 1);
distanceToEdge(wetMasks[k], rimDist[k]);
softenDistance(wetMasks[k], rimDist[k]);
maxDist[k] = 1;
for (let i = 0; i < GW * GH; i += 1) { maxDist[k] = Math.max(maxDist[k], rimDist[k][i]); }
distReady[k] = true;
}
const level = waterLevel(k, t);
if (distReady[k] && level > 0) {
flowToRim(k, RIM_FLOW * (0.3 + 0.7 * (1 - level)) * dt);
}
// 젖은 물기 — 옅은 회청색 막. 물높이가 내려갈수록 테두리 쪽부터 말라 막이 가운데로 줄어든다(테두리는 안료가 붙잡아 젖은 채 남는 것과 따로, 눈에 보이는 물기만)
const mask = wetMasks[k];
const dryFront = (1 - level) * maxDist[k];
for (let i = 0; i < GW * GH; i += 1) {
if (mask[i]) { waterField[i] += distReady[k] ? 0.5 * smooth((rimDist[k][i] - dryFront) / 2.5) : 0.5; }
}
}
}
function render(t: number): void {
compose(fields02, GW, GH, pigs02, gran02);
washAt(t, SEED);
}
// 02 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
function draw(): void {
render(simTime);
present();
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 한 바퀴 — 그리기 → 머묾 → 씻겨 지워짐. 끝나면 같은 시드로 처음부터 다시 그린다
const CYCLE = PAINT_TIME + HOLD_TIME + WASH_TIME;
// 그림 시계(초)와 고정 걸음에 아직 못 넣은 나머지 시간
let simTime = 0;
let pending = 0;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 고정 걸음 하나 — 그리는 동안만 계산을 나아가게 하고, 한 바퀴가 끝나면 처음 상태로 되돌린다
function stepOnce(): void {
simTime += STEP;
if (simTime >= CYCLE) {
simTime = 0;
reset();
return;
}
if (simTime <= PAINT_TIME) { update(STEP, simTime); }
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
pending += Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
while (pending >= STEP) {
stepOnce();
pending -= STEP;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임에 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고, 같은 시드로 그리기 끝까지 한 번에 계산한 완성 그림 한 장만 보여 준다
function showFinished(): void {
stopLoop();
reset();
simTime = 0;
while (simTime + STEP <= PAINT_TIME) {
simTime += STEP;
update(STEP, simTime);
}
simTime = PAINT_TIME;
draw();
}
function play(): void {
reset();
simTime = 0;
pending = 0;
// 자동 시연은 DEMO_START 초까지 미리 그려 둔 채 시작한다 — 페이지를 열자마자 빈 종이만 보이지 않게(사람이 다시 시작하면 0초부터)
if (demo) {
while (simTime + STEP <= DEMO_START) { stepOnce(); }
}
draw();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 그리기를 멈추고, 돌아오면 시계를 다시 맞춰 이어 그린다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 완성 그림으로 멈추고, 꺼지면 처음부터 다시 그린다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showFinished(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showFinished(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("wc") + " is-demo"} ref={rootRef}>
<canvas className={cx("wc__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="물에 젖은 얼룩 세 개가 마르면서 안료가 테두리로 몰려 진한 고리가 남는 그림."></canvas>
<button className={cx("wc__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}02 A Stain Whose Pigment Drifts to the Rim as It Dries, Leaving a Dark Ring — Measured area 27.987% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · Watercolor Effect CSS: 9 Bleeding Paint Scenes
The post this demo lives in
Watercolor Effect CSS: 9 Bleeding Paint ScenesIn a watercolor effect CSS frame, watercolor bleeds come from how water dries, not from the brush: 03 spreads pigment on See all demos in this post
If you like this effect - similar demos
Galleries05 Reflection Pool Beneath the ImageImage Ripple Effect CSS: 9 Canvas Wave Patterns
Galleries03 Smoke Whose Thickness Changes With the Emission-Rate ValueCSS Smoke Effect: 9 Canvas Noise Patterns
Galleries01 Magnifier lens that follows the pointer9 Image Zoom Viewer Interactions — No Photo Files