07젖은 색면에 맑은 물방울이 떨어져 꽃잎 테두리로 밀려나는 번짐
아직 젖은 색면 위에 맑은 물방울이 떨어지면 방울 둘레의 안료를 바깥으로 밀어내고, 각도마다 노이즈로 다른 반지름을 주어 꽃잎처럼 울퉁불퉁한 테두리를 만들며, 마른 뒤에는 그 테두리가 진하게 남는다.
쓰임봄 시즌 기획전 배너
PETAL_NOISEBLOOM_PUSHBLOOM_GROW

<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="젖은 라벤더·분홍 색면에 맑은 물방울이 떨어져 안료를 밀어내고 꽃잎 모양의 진한 테두리를 남기는 그림."></canvas>
<button class="wc__pause" data-role="pause" type="button" aria-pressed="false">일시정지</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);
}
// 젖은 칸 번짐 한 걸음 — 칸마다 이웃 네 칸의 평균 쪽으로 rate 만큼 다가가되, 이웃과 주고받는 몫에 두 칸의 물 가운데 적은 쪽을 곱한다
// 물이 0 인 칸과는 아무것도 주고받지 않으므로 마른 칸에서 번짐이 멈춘다. 주고받기라 안료 총량은 그대로이고, rate ≤ 1 이면 값이 튀지 않는다
function wetDiffuse(p, water, gw, gh, rate, before) {
before.set(p);
const k = rate * 0.25;
for (let y = 0; y < gh; y += 1) {
for (let x = 0; x < gw; x += 1) {
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;
}
}
}
}
}
// 붓 대기 — (cx, cy)(판 좌표) 둘레에 종 모양으로 안료를 넣는다. 젖은 칸에만 들어간다
function touchPigment(p, water, gw, gh, cx, cy, radius, amount) {
const kx = gw / W;
const gx = cx * kx;
const gy = cy * kx;
const rr = radius * kx;
const x0 = Math.max(0, Math.floor(gx - rr * 2.5));
const x1 = Math.min(gw - 1, Math.ceil(gx + rr * 2.5));
const y0 = Math.max(0, Math.floor(gy - rr * 2.5));
const y1 = Math.min(gh - 1, Math.ceil(gy + rr * 2.5));
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
const i = y * gw + x;
if (water[i] <= 0) {
continue;
}
const d2 = ((x + 0.5 - gx) * (x + 0.5 - gx) + (y + 0.5 - gy) * (y + 0.5 - gy)) / (rr * rr);
p[i] += amount * Math.exp(-d2 * 0.5);
}
}
}
// 꽃잎 테두리 반지름 r(θ) — 기본 반지름 × 퍼진 정도 × (1 ± petal). 각마다 둘레 노이즈가 달라 꽃잎처럼 울퉁불퉁하다
function bloomRadius(d, grow, angle, petal) {
return d.r * grow * (1 + petal * (ringNoise(angle, 2.4, d.seed) - 0.5) * 2);
}
// 물방울 밀기 — 젖은 칸 가운데 지금 테두리 r(θ) 바로 안쪽 띠에 든 칸의 안료를 frac 만큼, 방울 가운데에서 먼 쪽 이웃 칸으로 보낸다
// 테두리가 넓어지는 동안 앞머리 바로 바깥에 안료가 쌓이고, 방울 안은 맑은 물로 옅어진다. 방울 안 칸은 물을 1 로 다시 채운다
function pushBloom(field, water, gw, gh, d, grow, petal, frac, band, wetMin, delta) {
const k = gw / W;
const reach = d.r * (1 + petal) * grow * k + 2;
const cx = d.x * k;
const cy = d.y * k;
const x0 = Math.max(1, Math.floor(cx - reach));
const x1 = Math.min(gw - 2, Math.ceil(cx + reach));
const y0 = Math.max(1, Math.floor(cy - reach));
const y1 = Math.min(gh - 2, Math.ceil(cy + reach));
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
delta[y * gw + x] = 0;
}
}
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
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;
}
}
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
field[y * gw + x] += delta[y * gw + x];
}
}
}
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);
}
// 07 물방울 꽃 번짐 — 젖은 색면에 맑은 물방울이 떨어지면 방울 테두리 r(θ) 안의 안료를 바깥으로 밀어, 꽃잎 모양으로 진한 테두리가 남는다
// 꽃잎 울퉁불퉁함 — 반지름에 곱하는 노이즈 폭(±)
const PETAL_NOISE = 0.4;
// 한 걸음(1/30초)에 테두리 안쪽 칸이 바깥 이웃으로 보내는 안료 비율
const BLOOM_PUSH = 0.2;
// 밀기 띠 — 테두리 반지름의 바깥 이 비율 안에서만 민다
const BLOOM_BAND = 0.4;
// 젖은 색면 안에서 안료가 고르게 퍼지는 빠르기 — 쌓인 테두리가 한두 칸 폭으로 부드러워진다
const WASH_DIFFUSE = 0.3;
// 방울이 퍼지는 시간 상수(초) — 퍼진 정도 = 1 − e^(−지난 시간 / BLOOM_GROW)
const BLOOM_GROW = 0.32;
// 이만큼 물이 남은 칸에서만 안료가 밀린다 — 마른 자리에 떨어진 방울은 꽃을 만들지 못한다
const WET_MIN = 0.12;
// 색면이 마르는 빠르기(1초에 줄어드는 물 양)
const EVAPORATION = 0.16;
// 바탕 색면 진하기와 칠하는 시간(초)
const WASH_INK = 0.62;
const LAY_TIME = 0.5;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 0.9;
const PAINT_TIME = 4.0;
const HOLD_TIME = 2.4;
const WASH_TIME = 1.4;
const SEED = 4827;
const DROPS = [
{ x: 132, y: 104, r: 44, at: 0.75, seed: SEED + 1 },
{ x: 322, y: 172, r: 50, at: 1.0, seed: SEED + 2 },
{ x: 226, y: 222, r: 36, at: 1.3, seed: SEED + 3 },
{ x: 392, y: 84, r: 34, at: 1.6, seed: SEED + 4 },
{ x: 80, y: 222, r: 32, at: 1.9, seed: SEED + 5 },
{ x: 240, y: 98, r: 30, at: 2.2, seed: SEED + 6 },
];
const lavenderField = new Float32Array(GW * GH);
const pinkField = new Float32Array(GW * GH);
const water07 = new Float32Array(GW * GH);
const sheen07 = new Float32Array(GW * GH);
const delta07 = new Float32Array(GW * GH);
const fields07 = [lavenderField, pinkField, sheen07];
const pigs07 = [pigment(LAVENDER), pigment(PINK), pigment(WATER)];
const gran07 = [0.2, 0.2, 0];
let laid = false;
buildPaper(SEED, 1);
function reset() {
lavenderField.fill(0);
pinkField.fill(0);
water07.fill(0);
sheen07.fill(0);
laid = false;
}
// 바탕 색면 — 왼쪽 위에서 오른쪽 아래로 쓸며 칠한다. 왼쪽은 라벤더, 오른쪽은 분홍, 가운데에서 겹친다. 가장자리는 노이즈로 흔들린다
function layWash(p) {
const front = p * 1.5 - 0.25;
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
const u = x / GW;
const v = y / GH;
const inside = Math.min(u - 0.05, 0.95 - u, (v - 0.07) * 1.6, (0.93 - v) * 1.6) + (noise2(x / 9, y / 9, SEED) - 0.5) * 0.08;
const cover = smooth(inside / 0.03) * smooth((front - (u + v) * 0.5) / 0.06);
if (cover <= 0) {
continue;
}
const ink = WASH_INK * cover * (0.85 + 0.3 * noise2(x / 14, y / 14, SEED + 4));
const mix = smooth((u - 0.35) / 0.3);
lavenderField[i] = ink * (1 - mix);
pinkField[i] = ink * mix;
water07[i] = Math.max(water07[i], cover);
}
}
}
function update(dt, t) {
if (!laid && t >= 0.2) {
const p = phaseOf(t, 0.2, LAY_TIME);
layWash(p);
laid = p >= 1;
}
if (t > 0.2 + LAY_TIME) {
for (let i = 0; i < GW * GH; i += 1) {
water07[i] = Math.max(0, water07[i] - EVAPORATION * dt);
}
}
for (let k = 0; k < DROPS.length; k += 1) {
const d = DROPS[k];
if (t < d.at) {
continue;
}
const grow = 1 - Math.exp(-(t - d.at) / BLOOM_GROW);
pushBloom(lavenderField, water07, GW, GH, d, grow, PETAL_NOISE, BLOOM_PUSH, BLOOM_BAND, WET_MIN, delta07);
pushBloom(pinkField, water07, GW, GH, d, grow, PETAL_NOISE, BLOOM_PUSH, BLOOM_BAND, WET_MIN, delta07);
}
wetDiffuse(lavenderField, water07, GW, GH, WASH_DIFFUSE, delta07);
wetDiffuse(pinkField, water07, GW, GH, WASH_DIFFUSE, delta07);
for (let i = 0; i < GW * GH; i += 1) {
sheen07[i] = water07[i] * 0.35;
}
}
function render(t) {
compose(fields07, GW, GH, pigs07, gran07);
washAt(t, SEED);
}
// 07 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
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;
}
}
// 동작 줄이기 — 루프를 돌리지 않고, 같은 시드로 그리기 끝까지 한 번에 계산한 완성 그림 한 장만 보여 준다
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 });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 완성 그림으로 멈추고, 꺼지면 처음부터 다시 그린다
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();
};
})();// SaltBloomBackrun - vanilla 판(07-watercolor-salt-bloom-backrun) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SaltBloomBackrun.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SaltBloomBackrun() {
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);
}
// 젖은 칸 번짐 한 걸음 — 칸마다 이웃 네 칸의 평균 쪽으로 rate 만큼 다가가되, 이웃과 주고받는 몫에 두 칸의 물 가운데 적은 쪽을 곱한다
// 물이 0 인 칸과는 아무것도 주고받지 않으므로 마른 칸에서 번짐이 멈춘다. 주고받기라 안료 총량은 그대로이고, rate ≤ 1 이면 값이 튀지 않는다
function wetDiffuse(p: Float32Array, water: Float32Array, gw: number, gh: number, rate: number, before: Float32Array): void {
before.set(p);
const k = rate * 0.25;
for (let y = 0; y < gh; y += 1) {
for (let x = 0; x < gw; x += 1) {
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;
}
}
}
}
}
// 붓 대기 — (cx, cy)(판 좌표) 둘레에 종 모양으로 안료를 넣는다. 젖은 칸에만 들어간다
function touchPigment(p: Float32Array, water: Float32Array, gw: number, gh: number, cx: number, cy: number, radius: number, amount: number): void {
const kx = gw / W;
const gx = cx * kx;
const gy = cy * kx;
const rr = radius * kx;
const x0 = Math.max(0, Math.floor(gx - rr * 2.5));
const x1 = Math.min(gw - 1, Math.ceil(gx + rr * 2.5));
const y0 = Math.max(0, Math.floor(gy - rr * 2.5));
const y1 = Math.min(gh - 1, Math.ceil(gy + rr * 2.5));
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
const i = y * gw + x;
if (water[i] <= 0) { continue; }
const d2 = ((x + 0.5 - gx) * (x + 0.5 - gx) + (y + 0.5 - gy) * (y + 0.5 - gy)) / (rr * rr);
p[i] += amount * Math.exp(-d2 * 0.5);
}
}
}
// 물방울 하나 — 떨어지는 자리(판 px), 다 퍼졌을 때 반지름, 떨어지는 시각(초), 꽃잎 노이즈 시드
type Drop = { x: number; y: number; r: number; at: number; seed: number };
// 꽃잎 테두리 반지름 r(θ) — 기본 반지름 × 퍼진 정도 × (1 ± petal). 각마다 둘레 노이즈가 달라 꽃잎처럼 울퉁불퉁하다
function bloomRadius(d: Drop, grow: number, angle: number, petal: number): number {
return d.r * grow * (1 + petal * (ringNoise(angle, 2.4, d.seed) - 0.5) * 2);
}
// 물방울 밀기 — 젖은 칸 가운데 지금 테두리 r(θ) 바로 안쪽 띠에 든 칸의 안료를 frac 만큼, 방울 가운데에서 먼 쪽 이웃 칸으로 보낸다
// 테두리가 넓어지는 동안 앞머리 바로 바깥에 안료가 쌓이고, 방울 안은 맑은 물로 옅어진다. 방울 안 칸은 물을 1 로 다시 채운다
function pushBloom(field: Float32Array, water: Float32Array, gw: number, gh: number, d: Drop, grow: number, petal: number,
frac: number, band: number, wetMin: number, delta: Float32Array): void {
const k = gw / W;
const reach = d.r * (1 + petal) * grow * k + 2;
const cx = d.x * k;
const cy = d.y * k;
const x0 = Math.max(1, Math.floor(cx - reach));
const x1 = Math.min(gw - 2, Math.ceil(cx + reach));
const y0 = Math.max(1, Math.floor(cy - reach));
const y1 = Math.min(gh - 2, Math.ceil(cy + reach));
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) { delta[y * gw + x] = 0; }
}
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) {
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;
}
}
for (let y = y0; y <= y1; y += 1) {
for (let x = x0; x <= x1; x += 1) { field[y * gw + x] += delta[y * gw + x]; }
}
}
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);
}
// 07 물방울 꽃 번짐 — 젖은 색면에 맑은 물방울이 떨어지면 방울 테두리 r(θ) 안의 안료를 바깥으로 밀어, 꽃잎 모양으로 진한 테두리가 남는다
// 꽃잎 울퉁불퉁함 — 반지름에 곱하는 노이즈 폭(±)
const PETAL_NOISE = 0.4;
// 한 걸음(1/30초)에 테두리 안쪽 칸이 바깥 이웃으로 보내는 안료 비율
const BLOOM_PUSH = 0.2;
// 밀기 띠 — 테두리 반지름의 바깥 이 비율 안에서만 민다
const BLOOM_BAND = 0.4;
// 젖은 색면 안에서 안료가 고르게 퍼지는 빠르기 — 쌓인 테두리가 한두 칸 폭으로 부드러워진다
const WASH_DIFFUSE = 0.3;
// 방울이 퍼지는 시간 상수(초) — 퍼진 정도 = 1 − e^(−지난 시간 / BLOOM_GROW)
const BLOOM_GROW = 0.32;
// 이만큼 물이 남은 칸에서만 안료가 밀린다 — 마른 자리에 떨어진 방울은 꽃을 만들지 못한다
const WET_MIN = 0.12;
// 색면이 마르는 빠르기(1초에 줄어드는 물 양)
const EVAPORATION = 0.16;
// 바탕 색면 진하기와 칠하는 시간(초)
const WASH_INK = 0.62;
const LAY_TIME = 0.5;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 0.9;
const PAINT_TIME = 4.0;
const HOLD_TIME = 2.4;
const WASH_TIME = 1.4;
const SEED = 4827;
const DROPS: Drop[] = [
{ x: 132, y: 104, r: 44, at: 0.75, seed: SEED + 1 },
{ x: 322, y: 172, r: 50, at: 1.0, seed: SEED + 2 },
{ x: 226, y: 222, r: 36, at: 1.3, seed: SEED + 3 },
{ x: 392, y: 84, r: 34, at: 1.6, seed: SEED + 4 },
{ x: 80, y: 222, r: 32, at: 1.9, seed: SEED + 5 },
{ x: 240, y: 98, r: 30, at: 2.2, seed: SEED + 6 },
];
const lavenderField = new Float32Array(GW * GH);
const pinkField = new Float32Array(GW * GH);
const water07 = new Float32Array(GW * GH);
const sheen07 = new Float32Array(GW * GH);
const delta07 = new Float32Array(GW * GH);
const fields07: Float32Array[] = [lavenderField, pinkField, sheen07];
const pigs07: Pigment[] = [pigment(LAVENDER), pigment(PINK), pigment(WATER)];
const gran07: number[] = [0.2, 0.2, 0];
let laid = false;
buildPaper(SEED, 1);
function reset(): void {
lavenderField.fill(0);
pinkField.fill(0);
water07.fill(0);
sheen07.fill(0);
laid = false;
}
// 바탕 색면 — 왼쪽 위에서 오른쪽 아래로 쓸며 칠한다. 왼쪽은 라벤더, 오른쪽은 분홍, 가운데에서 겹친다. 가장자리는 노이즈로 흔들린다
function layWash(p: number): void {
const front = p * 1.5 - 0.25;
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
const u = x / GW;
const v = y / GH;
const inside = Math.min(u - 0.05, 0.95 - u, (v - 0.07) * 1.6, (0.93 - v) * 1.6) + (noise2(x / 9, y / 9, SEED) - 0.5) * 0.08;
const cover = smooth(inside / 0.03) * smooth((front - (u + v) * 0.5) / 0.06);
if (cover <= 0) { continue; }
const ink = WASH_INK * cover * (0.85 + 0.3 * noise2(x / 14, y / 14, SEED + 4));
const mix = smooth((u - 0.35) / 0.3);
lavenderField[i] = ink * (1 - mix);
pinkField[i] = ink * mix;
water07[i] = Math.max(water07[i], cover);
}
}
}
function update(dt: number, t: number): void {
if (!laid && t >= 0.2) {
const p = phaseOf(t, 0.2, LAY_TIME);
layWash(p);
laid = p >= 1;
}
if (t > 0.2 + LAY_TIME) {
for (let i = 0; i < GW * GH; i += 1) { water07[i] = Math.max(0, water07[i] - EVAPORATION * dt); }
}
for (let k = 0; k < DROPS.length; k += 1) {
const d = DROPS[k];
if (t < d.at) { continue; }
const grow = 1 - Math.exp(-(t - d.at) / BLOOM_GROW);
pushBloom(lavenderField, water07, GW, GH, d, grow, PETAL_NOISE, BLOOM_PUSH, BLOOM_BAND, WET_MIN, delta07);
pushBloom(pinkField, water07, GW, GH, d, grow, PETAL_NOISE, BLOOM_PUSH, BLOOM_BAND, WET_MIN, delta07);
}
wetDiffuse(lavenderField, water07, GW, GH, WASH_DIFFUSE, delta07);
wetDiffuse(pinkField, water07, GW, GH, WASH_DIFFUSE, delta07);
for (let i = 0; i < GW * GH; i += 1) { sheen07[i] = water07[i] * 0.35; }
}
function render(t: number): void {
compose(fields07, GW, GH, pigs07, gran07);
washAt(t, SEED);
}
// 07 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
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>
);
}07 젖은 색면에 맑은 물방울이 떨어져 꽃잎 테두리로 밀려나는 번짐 — 측정 누적면적 23.299% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 수채화 효과 9종 — 번짐·안료 고임·종이 결을 계산으로 그리는 캔버스
이 데모가 속한 글
수채화 효과 9종 — 번짐·안료 고임·종이 결을 계산으로 그리는 캔버스수채화 효과는 물감이 종이에 스며 번지고, 마르며 테두리를 남기고, 종이 골에 고이는 모습을 화면 위에서 다시 그리는 기법이다. 물감 번짐은 붓이 아니라 물이 마르는 순서가 만든다. 03번은 젖은 칸으로만 안료를 퍼 이 글의 데모 전부 보기


