수채화 효과 9종 — 번짐·안료 고임·종이 결을 계산으로 그리는 캔버스
수채화 효과는 물감이 종이에 스며 번지고, 마르며 테두리를 남기고, 종이 골에 고이는 모습을 화면 위에서 다시 그리는 기법이다. 물감 번짐은 붓이 아니라 물이 마르는 순서가 만든다.
붓으로 칠한 것처럼 보이는 이유 — 물이 마르는 순서와 종이 결이 만드는 얼룩
01 watercolor-blot-jitter-layer-stack
02 watercolor-dry-rim-pigment-pool
03 watercolor-wet-cell-bleed-mix
04 watercolor-paper-grain-granulation
05 watercolor-dry-brush-bristle-split
06 watercolor-photo-quantize-edge-wash
07 watercolor-salt-bloom-backrun
08 watercolor-glaze-absorb-mix
09 watercolor-hero-landscape-paint-in
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 변을 쪼개 흔든 다각형을 옅게 수십 겹 쌓는 물감 얼룩
- 02 마르는 동안 안료가 테두리로 몰려 진한 고리가 남는 얼룩
- 03 젖은 칸으로만 퍼지다 두 색이 만나 섞이는 번짐
- 04 종이 결의 낮은 곳에 안료 알갱이가 고이는 바탕
- 05 붓이 빨라질수록 물이 줄어 털끝이 갈라지는 마른 붓 획
- 06 풍경 그림을 밝기 단계와 흔들린 경계의 물감 그림으로 바꾸는 이미지 수채화 효과
- 07 젖은 색면에 맑은 물방울이 떨어져 꽃잎 테두리로 밀려나는 번짐
- 08 투명한 층을 덧칠할수록 빛을 곱으로 거르는 겹칠 혼색
- 09 하늘 번짐부터 마른 붓 언덕까지 차례로 그려지는 수채 풍경 첫 화면
종이 위 물감이 그림처럼 보이는 까닭은 색 자체보다 색이 놓인 방식에 있다. 가장자리가 고르게 잘리지 않고, 마른 자리에 진한 고리가 남고, 젖은 두 색이 만난 곳만 섞이고, 종이 골에 알갱이가 가라앉는다. 01~04 는 이 네 가지 현상을 하나씩 떼어 보여 주고, 05~08 은 마른 붓·그림 바꾸기·물방울 꽃·겹칠처럼 그 현상을 쓰는 기법을, 09 는 앞의 계산을 한 장면에 모은 첫 화면을 보여 준다. 아홉 장면은 캔버스 2D 에 화소를 직접 계산해 칠하고 라이브러리·WebGL·이미지 파일을 하나도 부르지 않는다. 06 이 바꾸는 원본 풍경도 같은 페이지에서 화소마다 계산한 그림이다.
카페 메뉴와 전시 안내 첫 화면
카페 메뉴 페이지라면 05 의 마른 붓 획을 메뉴 제목 밑에 한 번 긋는 정도가 알맞다. 획 끝이 갈라지며 사라지기 때문에 직선 밑줄보다 손으로 쓴 느낌이 살고, 획 하나가 1초 안팎으로 끝나 읽기를 방해하지 않는다. 전시 안내나 작품 판매 페이지의 첫 화면에는 09 가 맞는다. 하늘이 번지고 산이 겹쳐 쌓이고 언덕이 그려지는 순서 자체가 「지금 그림이 그려지고 있다」 는 인상을 주고, 다 그린 뒤 2.2초 머물러 제목을 읽을 틈을 준다.
초대장과 브랜드 첫 화면 배경
모바일 초대장의 머리 그림에는 가운데가 옅고 테두리만 진하게 남는 02 의 얼룩이 잘 어울린다. 옅은 안쪽에 이름과 날짜를 얹어도 글자가 묻히지 않는다. 브랜드 첫 화면 배경으로 큰 색 덩어리 몇 개만 깔고 싶다면 01 의 겹친 얼룩이나 04 의 종이 결 띠가 낫고, 계절 기획전 배너처럼 장식이 더 필요하면 07 의 물방울 꽃을 색면 위에 띄운다. 이런 수채화 애니메이션은 한 번 그려지고 멈추는 쪽이 본문을 덜 방해하는데, 아홉 장면 모두 3.6~4.6초 동안 그린 뒤 다음 씻기까지 2.0~2.6초 동안 그림이 멈춰 있다.
수채화 효과 9종 — 겹쳐 쌓는 얼룩·마른 테두리·젖은 칸 번짐·종이 결 고임·갈라지는 마른 붓·이미지 수채화 효과·소금 꽃 번짐·겹칠 혼색·풍경이 그려지는 첫 화면
2초 동안 24장을 찍어 센 그림의 움직임
480×300 판을 2초 동안 24장 찍고 이웃한 두 장의 차이 23개를 센 값이다. 화면 대부분을 차례로 칠하는 09 와 06 이 50% 를 넘고, 붓 네 획만 지나가는 05 가 20.8% 로 가장 좁다.
| 장면 | 누적면적 | 움직인 프레임 | 강도 평균 |
|---|---|---|---|
| 01 겹쳐 쌓는 얼룩 | 40.5% | 17/23 | 15.9 |
| 02 마른 테두리 | 28.0% | 20/23 | 21.0 |
| 03 젖은 칸 번짐 | 29.6% | 11/23 | 46.4 |
| 04 종이 결 고임 | 49.2% | 10/23 | 78.4 |
| 05 마른 붓 | 20.8% | 17/23 | 76.3 |
| 06 이미지 수채화 | 52.0% | 12/23 | 26.1 |
| 07 물방울 꽃 번짐 | 23.3% | 19/23 | 18.3 |
| 08 겹칠 혼색 | 28.8% | 13/23 | 44.2 |
| 09 수채 풍경 첫 화면 | 54.1% | 18/23 | 36.6 |
움직인 프레임이 23 에 못 미치는 것은 물감이 놓이는 사건이 시각표대로 띄엄띄엄 일어나기 때문으로 보인다. 03 은 붓 대기 16번이 0.16초 간격으로 이어지다 끝나고, 04 는 띠 세 줄을 다 칠한 뒤 고임이 천천히 올라가는 구간이라 이웃 두 장의 차이가 문턱 밑으로 작아졌을 가능성이 크다. 강도 평균이 가장 낮은 01·07 은 한 번에 바뀌는 진하기가 작은 장면이다 — 01 은 한 겹이 0.042, 07 은 방울 하나가 안료를 한 걸음에 20% 씩만 민다.
01변을 쪼개 흔든 다각형을 옅게 수십 겹 쌓는 물감 얼룩
얼룩 하나는 꼭짓점 10개짜리 바탕을 한 번 흔든 20각형에서 출발하고, 겹마다 그 바탕을 deformPolygon 으로 다섯 번 더 쪼개 흔든 640각형을 LAYER_ALPHA 0.042 씩 40겹 쌓아 가운데는 진하고 가장자리는 풀린 얼룩이 된다. 변 가운데 점을 변 길이 × BLOT_JITTER(0.45) × 0.5 × 종 모양 난수만큼 법선 쪽으로 미는 아래 함수가 모양을 정하는데, 짧아진 변일수록 덜 밀려 굵은 굴곡 위에 잔 굴곡이 얹히므로 꽃집 랜딩 페이지 배경처럼 큰 색 덩어리 몇 개만 필요한 자리에 맞는다. 그리기가 끝났을 때 640 꼭짓점 둘레는 873px, 꼭짓점 하나의 평균 꺾임은 0.755 라디안이고, 가장자리에서 진하기가 최대의 10% 에서 90% 로 오르는 폭은 판 4.5px 이다.

<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);
}
// 바탕 다각형 — 가운데 (cx, cy), 반지름 rx·ry 근처에 꼭짓점 sides 개를 둘레로 고르게 놓되 반지름을 ±15% 흔든다
function basePolygon(cx, cy, rx, ry, sides, rng) {
const pts = [];
for (let i = 0; i < sides; i += 1) {
const a = TAU * i / sides;
const k = 0.85 + 0.3 * rng();
pts.push(cx + Math.cos(a) * rx * k, cy + Math.sin(a) * ry * k);
}
return pts;
}
// 변 쪼개 흔들기 — 각 변의 가운데 점을 (변 길이 × jitter × 0.5 × 종 모양 난수) 만큼 변의 법선 방향으로 밀어 꼭짓점 사이에 끼운다
// depth 번 되풀이하면 꼭짓점이 2^depth 배가 되고, 짧아진 변일수록 덜 밀려 굵은 굴곡 위에 잔 굴곡이 얹힌다
function deformPolygon(pts, depth, jitter, rng) {
let cur = pts;
for (let d = 0; d < depth; d += 1) {
const next = [];
const n = cur.length / 2;
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
const ax = cur[2 * i];
const ay = cur[2 * i + 1];
const bx = cur[2 * j];
const by = cur[2 * j + 1];
const len = Math.hypot(bx - ax, by - ay) || 1;
const push = normalRandom(rng) * jitter * len * 0.5;
next.push(ax, ay, (ax + bx) / 2 + (by - ay) / len * push, (ay + by) / 2 - (bx - ax) / len * push);
}
cur = next;
}
return cur;
}
// 스캔라인 채우기의 교차점 자리 — 한 줄에서 다각형 변과 만나는 x 를 모아 정렬한다 (다시 쓰는 버퍼)
const CROSS = new Float64Array(4096);
// 다각형 안쪽 칸(칸 가운데 기준)에 amount 를 더한다. 좌표는 판(480×300), 격자는 gw×gh
function fillPolygon(field, gw, gh, pts, amount) {
const n = pts.length / 2;
const kx = gw / W;
const ky = gh / H;
let top = gh;
let bottom = 0;
for (let i = 0; i < n; i += 1) {
top = Math.min(top, pts[2 * i + 1] * ky);
bottom = Math.max(bottom, pts[2 * i + 1] * ky);
}
const r0 = Math.max(0, Math.floor(top));
const r1 = Math.min(gh - 1, Math.ceil(bottom));
for (let row = r0; row <= r1; row += 1) {
const py = (row + 0.5) / ky;
let c = 0;
for (let i = 0; i < n && c < CROSS.length; i += 1) {
const j = (i + 1) % n;
const ay = pts[2 * i + 1];
const by = pts[2 * j + 1];
if ((ay <= py && by > py) || (by <= py && ay > py)) {
const ax = pts[2 * i];
const bx = pts[2 * j];
CROSS[c] = (ax + (py - ay) / (by - ay) * (bx - ax)) * kx;
c += 1;
}
}
// 삽입 정렬 — 한 줄의 교차점은 많아야 수십 개
for (let a = 1; a < c; a += 1) {
const v = CROSS[a];
let b = a - 1;
while (b >= 0 && CROSS[b] > v) {
CROSS[b + 1] = CROSS[b];
b -= 1;
}
CROSS[b + 1] = v;
}
for (let a = 0; a + 1 < c; a += 2) {
const x0 = Math.max(0, Math.ceil(CROSS[a] - 0.5));
const x1 = Math.min(gw - 1, Math.floor(CROSS[a + 1] - 0.5));
for (let x = x0; x <= x1; x += 1) {
field[row * gw + x] += amount;
}
}
}
}
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);
}
// 01 겹쳐 쌓는 얼룩 — 바탕 다각형 하나에서 변 쪼개 흔들기로 변형본을 수십 장 만들고, 아주 옅게 한 장씩 쌓는다
// 얼룩마다 바탕 다각형의 꼭짓점 수
const BLOT_SIDES = 10;
// 바탕 모양 흔들기 횟수 — 얼룩마다 한 번, 큰 굴곡을 정한다
const BASE_DEPTH = 1;
// 겹마다 다시 흔드는 횟수 — 10 × 2^1 × 2^5 = 640 꼭짓점
const LAYER_DEPTH = 5;
// 변 가운데 점을 미는 폭 — 변 길이에 곱하는 비율
const BLOT_JITTER = 0.45;
// 한 겹의 진하기 — 겹마다 이만큼 진하기를 더한다(알파 약 4%). 가운데는 거의 모든 겹이 덮어 진하고 가장자리는 몇 겹만 닿아 옅다
const LAYER_ALPHA = 0.042;
// 얼룩 하나에 쌓는 겹 수 — 진하기는 Float32 격자에 더하므로 8비트 반올림 없이 쌓인다
const LAYERS_PER_BLOT = 40;
// 얼룩 하나의 겹이 다 쌓이는 시간(초)
const BLOT_TIME = 1.4;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.0;
const PAINT_TIME = 4.2;
const HOLD_TIME = 2.2;
const WASH_TIME = 1.4;
const SEED = 4821;
const BLOTS = [
{ cx: 150, cy: 118, rx: 92, ry: 74, color: CORAL, start: 0.2 },
{ cx: 312, cy: 178, rx: 96, ry: 78, color: LAVENDER, start: 0.85 },
{ cx: 128, cy: 228, rx: 84, ry: 56, color: BUTTER, start: 1.5 },
{ cx: 392, cy: 92, rx: 72, ry: 60, color: SKY, start: 2.15 },
];
const blotFields = BLOTS.map(function () { return new Float32Array(OW * OH); });
const blotPigments = BLOTS.map(function (b) { return pigment(b.color); });
const noGran = BLOTS.map(function () { return 0; });
let baseShapes = [];
const layersDone = BLOTS.map(function () { return 0; });
buildPaper(SEED, 1);
function reset() {
const rng = makeRng(SEED);
baseShapes = BLOTS.map(function (b) {
return deformPolygon(basePolygon(b.cx, b.cy, b.rx, b.ry, BLOT_SIDES, rng), BASE_DEPTH, BLOT_JITTER, rng);
});
for (let k = 0; k < BLOTS.length; k += 1) {
blotFields[k].fill(0);
layersDone[k] = 0;
}
}
// 겹 하나 — 같은 바탕 모양을 겹마다 다른 시드로 다시 흔들어 진하기 격자에 칠한다
function addLayer(k) {
const layer = layersDone[k];
const shape = deformPolygon(baseShapes[k], LAYER_DEPTH, BLOT_JITTER, makeRng(SEED * 100 + k * 1000 + layer));
fillPolygon(blotFields[k], OW, OH, shape, LAYER_ALPHA);
layersDone[k] = layer + 1;
}
function update(dt, t) {
for (let k = 0; k < BLOTS.length; k += 1) {
const want = Math.floor(LAYERS_PER_BLOT * phaseOf(t, BLOTS[k].start, BLOT_TIME));
while (layersDone[k] < want) {
addLayer(k);
}
}
}
function render(t) {
compose(blotFields, OW, OH, blotPigments, noGran);
washAt(t, SEED);
}
// 01 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
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();
};
})();// BlotJitterLayerStack - vanilla 판(01-watercolor-blot-jitter-layer-stack) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./BlotJitterLayerStack.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BlotJitterLayerStack() {
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);
}
// 바탕 다각형 — 가운데 (cx, cy), 반지름 rx·ry 근처에 꼭짓점 sides 개를 둘레로 고르게 놓되 반지름을 ±15% 흔든다
function basePolygon(cx: number, cy: number, rx: number, ry: number, sides: number, rng: () => number): number[] {
const pts: number[] = [];
for (let i = 0; i < sides; i += 1) {
const a = TAU * i / sides;
const k = 0.85 + 0.3 * rng();
pts.push(cx + Math.cos(a) * rx * k, cy + Math.sin(a) * ry * k);
}
return pts;
}
// 변 쪼개 흔들기 — 각 변의 가운데 점을 (변 길이 × jitter × 0.5 × 종 모양 난수) 만큼 변의 법선 방향으로 밀어 꼭짓점 사이에 끼운다
// depth 번 되풀이하면 꼭짓점이 2^depth 배가 되고, 짧아진 변일수록 덜 밀려 굵은 굴곡 위에 잔 굴곡이 얹힌다
function deformPolygon(pts: number[], depth: number, jitter: number, rng: () => number): number[] {
let cur = pts;
for (let d = 0; d < depth; d += 1) {
const next: number[] = [];
const n = cur.length / 2;
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
const ax = cur[2 * i];
const ay = cur[2 * i + 1];
const bx = cur[2 * j];
const by = cur[2 * j + 1];
const len = Math.hypot(bx - ax, by - ay) || 1;
const push = normalRandom(rng) * jitter * len * 0.5;
next.push(ax, ay, (ax + bx) / 2 + (by - ay) / len * push, (ay + by) / 2 - (bx - ax) / len * push);
}
cur = next;
}
return cur;
}
// 스캔라인 채우기의 교차점 자리 — 한 줄에서 다각형 변과 만나는 x 를 모아 정렬한다 (다시 쓰는 버퍼)
const CROSS = new Float64Array(4096);
// 다각형 안쪽 칸(칸 가운데 기준)에 amount 를 더한다. 좌표는 판(480×300), 격자는 gw×gh
function fillPolygon(field: Float32Array, gw: number, gh: number, pts: number[], amount: number): void {
const n = pts.length / 2;
const kx = gw / W;
const ky = gh / H;
let top = gh;
let bottom = 0;
for (let i = 0; i < n; i += 1) {
top = Math.min(top, pts[2 * i + 1] * ky);
bottom = Math.max(bottom, pts[2 * i + 1] * ky);
}
const r0 = Math.max(0, Math.floor(top));
const r1 = Math.min(gh - 1, Math.ceil(bottom));
for (let row = r0; row <= r1; row += 1) {
const py = (row + 0.5) / ky;
let c = 0;
for (let i = 0; i < n && c < CROSS.length; i += 1) {
const j = (i + 1) % n;
const ay = pts[2 * i + 1];
const by = pts[2 * j + 1];
if ((ay <= py && by > py) || (by <= py && ay > py)) {
const ax = pts[2 * i];
const bx = pts[2 * j];
CROSS[c] = (ax + (py - ay) / (by - ay) * (bx - ax)) * kx;
c += 1;
}
}
// 삽입 정렬 — 한 줄의 교차점은 많아야 수십 개
for (let a = 1; a < c; a += 1) {
const v = CROSS[a];
let b = a - 1;
while (b >= 0 && CROSS[b] > v) { CROSS[b + 1] = CROSS[b]; b -= 1; }
CROSS[b + 1] = v;
}
for (let a = 0; a + 1 < c; a += 2) {
const x0 = Math.max(0, Math.ceil(CROSS[a] - 0.5));
const x1 = Math.min(gw - 1, Math.floor(CROSS[a + 1] - 0.5));
for (let x = x0; x <= x1; x += 1) { field[row * gw + x] += amount; }
}
}
}
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);
}
// 01 겹쳐 쌓는 얼룩 — 바탕 다각형 하나에서 변 쪼개 흔들기로 변형본을 수십 장 만들고, 아주 옅게 한 장씩 쌓는다
// 얼룩마다 바탕 다각형의 꼭짓점 수
const BLOT_SIDES = 10;
// 바탕 모양 흔들기 횟수 — 얼룩마다 한 번, 큰 굴곡을 정한다
const BASE_DEPTH = 1;
// 겹마다 다시 흔드는 횟수 — 10 × 2^1 × 2^5 = 640 꼭짓점
const LAYER_DEPTH = 5;
// 변 가운데 점을 미는 폭 — 변 길이에 곱하는 비율
const BLOT_JITTER = 0.45;
// 한 겹의 진하기 — 겹마다 이만큼 진하기를 더한다(알파 약 4%). 가운데는 거의 모든 겹이 덮어 진하고 가장자리는 몇 겹만 닿아 옅다
const LAYER_ALPHA = 0.042;
// 얼룩 하나에 쌓는 겹 수 — 진하기는 Float32 격자에 더하므로 8비트 반올림 없이 쌓인다
const LAYERS_PER_BLOT = 40;
// 얼룩 하나의 겹이 다 쌓이는 시간(초)
const BLOT_TIME = 1.4;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.0;
const PAINT_TIME = 4.2;
const HOLD_TIME = 2.2;
const WASH_TIME = 1.4;
const SEED = 4821;
type Blot = { cx: number; cy: number; rx: number; ry: number; color: RGB; start: number };
const BLOTS: Blot[] = [
{ cx: 150, cy: 118, rx: 92, ry: 74, color: CORAL, start: 0.2 },
{ cx: 312, cy: 178, rx: 96, ry: 78, color: LAVENDER, start: 0.85 },
{ cx: 128, cy: 228, rx: 84, ry: 56, color: BUTTER, start: 1.5 },
{ cx: 392, cy: 92, rx: 72, ry: 60, color: SKY, start: 2.15 },
];
const blotFields: Float32Array[] = BLOTS.map(function (): Float32Array { return new Float32Array(OW * OH); });
const blotPigments: Pigment[] = BLOTS.map(function (b: Blot): Pigment { return pigment(b.color); });
const noGran: number[] = BLOTS.map(function (): number { return 0; });
let baseShapes: number[][] = [];
const layersDone: number[] = BLOTS.map(function (): number { return 0; });
buildPaper(SEED, 1);
function reset(): void {
const rng = makeRng(SEED);
baseShapes = BLOTS.map(function (b: Blot): number[] {
return deformPolygon(basePolygon(b.cx, b.cy, b.rx, b.ry, BLOT_SIDES, rng), BASE_DEPTH, BLOT_JITTER, rng);
});
for (let k = 0; k < BLOTS.length; k += 1) {
blotFields[k].fill(0);
layersDone[k] = 0;
}
}
// 겹 하나 — 같은 바탕 모양을 겹마다 다른 시드로 다시 흔들어 진하기 격자에 칠한다
function addLayer(k: number): void {
const layer = layersDone[k];
const shape = deformPolygon(baseShapes[k], LAYER_DEPTH, BLOT_JITTER, makeRng(SEED * 100 + k * 1000 + layer));
fillPolygon(blotFields[k], OW, OH, shape, LAYER_ALPHA);
layersDone[k] = layer + 1;
}
function update(dt: number, t: number): void {
for (let k = 0; k < BLOTS.length; k += 1) {
const want = Math.floor(LAYERS_PER_BLOT * phaseOf(t, BLOTS[k].start, BLOT_TIME));
while (layersDone[k] < want) { addLayer(k); }
}
}
function render(t: number): void {
compose(blotFields, OW, OH, blotPigments, noGran);
washAt(t, SEED);
}
// 01 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
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>
);
}01 · 변을 쪼개 흔든 다각형을 옅게 수십 겹 쌓는 물감 얼룩 — 측정 누적면적 40.474% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function deformPolygon(pts, depth, jitter, rng) {
let cur = pts;
for (let d = 0; d < depth; d += 1) {
const next = [];
const n = cur.length / 2;
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
const ax = cur[2 * i];
const ay = cur[2 * i + 1];
const bx = cur[2 * j];
const by = cur[2 * j + 1];
const len = Math.hypot(bx - ax, by - ay) || 1;
const push = normalRandom(rng) * jitter * len * 0.5;
next.push(ax, ay, (ax + bx) / 2 + (by - ay) / len * push, (ay + by) / 2 - (bx - ax) / len * push);
}
cur = next;
}
return cur;
}
02마르는 동안 안료가 테두리로 몰려 진한 고리가 남는 얼룩
얼룩이 다 번지면 distanceToEdge 가 위아래로 두 번 훑어 칸마다 가장 가까운 마른 칸까지 거리를 구해 두고, 물높이가 2.2초에 걸쳐 1 에서 0 으로 내려가는 동안 flowToRim 이 젖은 칸의 안료를 경계까지 거리가 더 짧은 이웃에게 거리 차 비율대로 나눠 보내며, 보내는 몫은 RIM_FLOW 2.2 × (0.3 + 0.7 × (1 − 물높이)) 라 마를수록 커진다. 아래 토막에서 거리 1 이하인 경계 칸은 continue 로 빠져 보낼 곳이 없으니 받은 안료가 그대로 쌓이고, 그래서 모바일 청첩장 머리 그림처럼 가운데를 비워 글자를 얹을 얼룩에 쓸 만하다. 다 마른 뒤 테두리 띠의 평균 진하기는 안쪽의 4.95·5.16·5.19배인데 세 얼룩의 안료 총량(1714.8·1346.4·885.6)은 그대로라, 색이 늘어난 것이 아니라 자리를 옮겼다.

<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);
}
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;
}
}
// 동작 줄이기 — 루프를 돌리지 않고, 같은 시드로 그리기 끝까지 한 번에 계산한 완성 그림 한 장만 보여 준다
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();
};
})();// 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 · 마르는 동안 안료가 테두리로 몰려 진한 고리가 남는 얼룩 — 측정 누적면적 27.987% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
if (dist[i] <= 1) {
continue;
}
const wl = downhill(dist, mask, i, l);
const wr = downhill(dist, mask, i, r);
const wu = downhill(dist, mask, i, u);
const wd = downhill(dist, mask, i, d);
const sum = wl + wr + wu + wd;
if (sum <= 0) {
continue;
}
const move = f[i] * frac;
flowTmp[i] -= move;
flowTmp[l] += move * wl / sum;
flowTmp[r] += move * wr / sum;
flowTmp[u] += move * wu / sum;
flowTmp[d] += move * wd / sum;
03젖은 칸으로만 퍼지다 두 색이 만나 섞이는 번짐
물 격자 하나와 산호·하늘색 안료 격자 둘을 두고, 맑은 물을 바른 두 자리에 붓을 16번 번갈아 대며 매 걸음 wetDiffuse 를 14번씩 두 안료에 돌려 그림책 소개 페이지 표지처럼 두 색이 가운데서 만나 보랏빛으로 섞이는 화면을 만든다. 경계를 만드는 것은 const g = Math.min(wi, water[j]); 한 줄로, 주고받는 양에 두 칸 가운데 적은 물을 곱하니 마른 이웃과는 0 이 되고, 아래 토막처럼 오른쪽 이웃과 아래 이웃(i + gw)에 같은 모양으로 한 번씩 주고받는다. 두 안료가 함께 0.05 넘게 든 칸은 1.5초 0 에서 3.5초 579, 그리기 끝 760칸으로 늘었고 처음부터 마른 칸으로 샌 안료는 0 이다.

<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);
}
}
}
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);
}
// 03 젖은 칸 번짐 — 물 격자 하나와 안료 격자 둘. 안료는 젖은 칸끼리만 이웃 평균 쪽으로 퍼지고, 두 색이 같은 젖은 칸에 닿으면 곱으로 섞인다
// 번짐 빠르기 — 한 번에 이웃 평균 쪽으로 다가가는 비율(물이 가득한 칸 기준)
const DIFFUSE_RATE = 0.18;
// 한 걸음(1/30초) 안에서 번짐을 되풀이하는 횟수
const DIFFUSE_SUBSTEPS = 14;
// 물이 마르는 빠르기(1초에 줄어드는 물 양) — 가장자리는 처음 물이 적어 먼저 마른다
const EVAPORATION = 0.2;
// 종이를 맑은 물로 적시는 시간(초)
const WET_TIME = 0.35;
// 붓 한 번에 넣는 안료 양과 퍼짐 반지름(판 px)
const TOUCH_INK = 1.05;
const TOUCH_RADIUS = 19;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.0;
const PAINT_TIME = 4.6;
const HOLD_TIME = 2.0;
const WASH_TIME = 1.4;
const SEED = 4823;
// 붓 대기 차례 — 0.45초부터 TOUCH_GAP 초마다 한 번, 산호색(왼쪽)과 하늘색(오른쪽)을 번갈아 찍는다. 자리는 시드 난수로 정하고 뒤로 갈수록 가운데로 다가온다
const TOUCH_COUNT = 16;
const TOUCH_GAP = 0.16;
const TOUCHES = [];
const touchRng = makeRng(SEED);
for (let i = 0; i < TOUCH_COUNT; i += 1) {
const side = i % 2;
const inward = i / (TOUCH_COUNT - 1);
const x = (side === 0 ? lerp(96, 226, inward) : lerp(388, 254, inward)) + (touchRng() - 0.5) * 28;
// 가운데로 올수록 물이 많아 빨리 퍼져 옅어지므로 안료를 더 싣는다
TOUCHES.push({ x: x, y: 90 + touchRng() * 120, at: 0.45 + i * TOUCH_GAP, side: side, ink: TOUCH_INK * (0.6 + 1.2 * inward) });
}
// 맑은 물을 먼저 바르는 두 자리 — 가운데에서 겹쳐 한 젖은 판이 된다
const WET_AREAS = [
{ cx: 178, cy: 152, r: 112 },
{ cx: 306, cy: 148, r: 112 },
];
const waterInit = new Float32Array(GW * GH);
const water03 = new Float32Array(GW * GH);
const coralField = new Float32Array(GW * GH);
const skyField = new Float32Array(GW * GH);
const before03 = new Float32Array(GW * GH);
const sheen03 = new Float32Array(GW * GH);
const fields03 = [coralField, skyField, sheen03];
const pigs03 = [pigment(CORAL), pigment(SKY), pigment(WATER)];
const gran03 = [0, 0, 0];
let touched = 0;
buildPaper(SEED, 1);
// 처음 물 양 — 젖은 자리 가운데 1, 둘레로 갈수록 0. 둘레는 노이즈로 흔들린다
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
let w = 0;
for (let a = 0; a < WET_AREAS.length; a += 1) {
const c = WET_AREAS[a];
const dx = (x + 0.5) * W / GW - c.cx;
const dy = (y + 0.5) * W / GW - c.cy;
const edge = c.r * (0.82 + 0.36 * ringNoise(Math.atan2(dy, dx * 0.8), 1.5, SEED + a));
w = Math.max(w, clamp((1 - Math.hypot(dx * 0.8, dy * 1.1) / edge) * 5, 0, 1));
}
// 종이 결마다 물을 머금는 양이 달라 번짐이 고르지 않게 갈래진다
waterInit[y * GW + x] = w * (0.75 + 0.5 * noise2(x / 6, y / 6, SEED + 7));
}
}
function reset() {
water03.fill(0);
coralField.fill(0);
skyField.fill(0);
sheen03.fill(0);
touched = 0;
}
function update(dt, t) {
// 맑은 물 바르기 — 왼쪽부터 오른쪽으로 쓸며 적신다. 다 바른 뒤에는 칸마다 같은 빠르기로 마른다
const wetFront = phaseOf(t, 0.2, WET_TIME) * (GW + 20) - 10;
const dryBy = Math.max(0, t - 0.2 - WET_TIME) * EVAPORATION;
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
water03[i] = x < wetFront ? Math.max(0, waterInit[i] - dryBy) : 0;
// 젖은 물기 — 물이 0.2 밑으로 줄면 막이 옅어지기 시작해 0.1 에서 사라진다. 마르는 앞머리가 가장자리에서 안쪽으로 움직이는 것이 보인다
sheen03[i] = 0.5 * smooth((water03[i] - 0.1) / 0.1);
}
}
while (touched < TOUCHES.length && t >= TOUCHES[touched].at) {
const tc = TOUCHES[touched];
touchPigment(tc.side === 0 ? coralField : skyField, water03, GW, GH, tc.x, tc.y, TOUCH_RADIUS, tc.ink);
touched += 1;
}
for (let s = 0; s < DIFFUSE_SUBSTEPS; s += 1) {
wetDiffuse(coralField, water03, GW, GH, DIFFUSE_RATE, before03);
wetDiffuse(skyField, water03, GW, GH, DIFFUSE_RATE, before03);
}
}
function render(t) {
compose(fields03, GW, GH, pigs03, gran03);
washAt(t, SEED);
}
// 03 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
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();
};
})();// WetCellBleedMix - vanilla 판(03-watercolor-wet-cell-bleed-mix) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./WetCellBleedMix.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function WetCellBleedMix() {
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);
}
}
}
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);
}
// 03 젖은 칸 번짐 — 물 격자 하나와 안료 격자 둘. 안료는 젖은 칸끼리만 이웃 평균 쪽으로 퍼지고, 두 색이 같은 젖은 칸에 닿으면 곱으로 섞인다
// 번짐 빠르기 — 한 번에 이웃 평균 쪽으로 다가가는 비율(물이 가득한 칸 기준)
const DIFFUSE_RATE = 0.18;
// 한 걸음(1/30초) 안에서 번짐을 되풀이하는 횟수
const DIFFUSE_SUBSTEPS = 14;
// 물이 마르는 빠르기(1초에 줄어드는 물 양) — 가장자리는 처음 물이 적어 먼저 마른다
const EVAPORATION = 0.2;
// 종이를 맑은 물로 적시는 시간(초)
const WET_TIME = 0.35;
// 붓 한 번에 넣는 안료 양과 퍼짐 반지름(판 px)
const TOUCH_INK = 1.05;
const TOUCH_RADIUS = 19;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.0;
const PAINT_TIME = 4.6;
const HOLD_TIME = 2.0;
const WASH_TIME = 1.4;
const SEED = 4823;
type Touch = { x: number; y: number; at: number; side: number; ink: number };
// 붓 대기 차례 — 0.45초부터 TOUCH_GAP 초마다 한 번, 산호색(왼쪽)과 하늘색(오른쪽)을 번갈아 찍는다. 자리는 시드 난수로 정하고 뒤로 갈수록 가운데로 다가온다
const TOUCH_COUNT = 16;
const TOUCH_GAP = 0.16;
const TOUCHES: Touch[] = [];
const touchRng = makeRng(SEED);
for (let i = 0; i < TOUCH_COUNT; i += 1) {
const side = i % 2;
const inward = i / (TOUCH_COUNT - 1);
const x = (side === 0 ? lerp(96, 226, inward) : lerp(388, 254, inward)) + (touchRng() - 0.5) * 28;
// 가운데로 올수록 물이 많아 빨리 퍼져 옅어지므로 안료를 더 싣는다
TOUCHES.push({ x: x, y: 90 + touchRng() * 120, at: 0.45 + i * TOUCH_GAP, side: side, ink: TOUCH_INK * (0.6 + 1.2 * inward) });
}
type Wet = { cx: number; cy: number; r: number };
// 맑은 물을 먼저 바르는 두 자리 — 가운데에서 겹쳐 한 젖은 판이 된다
const WET_AREAS: Wet[] = [
{ cx: 178, cy: 152, r: 112 },
{ cx: 306, cy: 148, r: 112 },
];
const waterInit = new Float32Array(GW * GH);
const water03 = new Float32Array(GW * GH);
const coralField = new Float32Array(GW * GH);
const skyField = new Float32Array(GW * GH);
const before03 = new Float32Array(GW * GH);
const sheen03 = new Float32Array(GW * GH);
const fields03: Float32Array[] = [coralField, skyField, sheen03];
const pigs03: Pigment[] = [pigment(CORAL), pigment(SKY), pigment(WATER)];
const gran03: number[] = [0, 0, 0];
let touched = 0;
buildPaper(SEED, 1);
// 처음 물 양 — 젖은 자리 가운데 1, 둘레로 갈수록 0. 둘레는 노이즈로 흔들린다
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
let w = 0;
for (let a = 0; a < WET_AREAS.length; a += 1) {
const c = WET_AREAS[a];
const dx = (x + 0.5) * W / GW - c.cx;
const dy = (y + 0.5) * W / GW - c.cy;
const edge = c.r * (0.82 + 0.36 * ringNoise(Math.atan2(dy, dx * 0.8), 1.5, SEED + a));
w = Math.max(w, clamp((1 - Math.hypot(dx * 0.8, dy * 1.1) / edge) * 5, 0, 1));
}
// 종이 결마다 물을 머금는 양이 달라 번짐이 고르지 않게 갈래진다
waterInit[y * GW + x] = w * (0.75 + 0.5 * noise2(x / 6, y / 6, SEED + 7));
}
}
function reset(): void {
water03.fill(0);
coralField.fill(0);
skyField.fill(0);
sheen03.fill(0);
touched = 0;
}
function update(dt: number, t: number): void {
// 맑은 물 바르기 — 왼쪽부터 오른쪽으로 쓸며 적신다. 다 바른 뒤에는 칸마다 같은 빠르기로 마른다
const wetFront = phaseOf(t, 0.2, WET_TIME) * (GW + 20) - 10;
const dryBy = Math.max(0, t - 0.2 - WET_TIME) * EVAPORATION;
for (let y = 0; y < GH; y += 1) {
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
water03[i] = x < wetFront ? Math.max(0, waterInit[i] - dryBy) : 0;
// 젖은 물기 — 물이 0.2 밑으로 줄면 막이 옅어지기 시작해 0.1 에서 사라진다. 마르는 앞머리가 가장자리에서 안쪽으로 움직이는 것이 보인다
sheen03[i] = 0.5 * smooth((water03[i] - 0.1) / 0.1);
}
}
while (touched < TOUCHES.length && t >= TOUCHES[touched].at) {
const tc = TOUCHES[touched];
touchPigment(tc.side === 0 ? coralField : skyField, water03, GW, GH, tc.x, tc.y, TOUCH_RADIUS, tc.ink);
touched += 1;
}
for (let s = 0; s < DIFFUSE_SUBSTEPS; s += 1) {
wetDiffuse(coralField, water03, GW, GH, DIFFUSE_RATE, before03);
wetDiffuse(skyField, water03, GW, GH, DIFFUSE_RATE, before03);
}
}
function render(t: number): void {
compose(fields03, GW, GH, pigs03, gran03);
washAt(t, SEED);
}
// 03 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
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>
);
}03 · 젖은 칸으로만 퍼지다 두 색이 만나 섞이는 번짐 — 측정 누적면적 29.567% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
const i = y * gw + x;
const wi = water[i];
if (wi <= 0) {
continue;
}
if (x + 1 < gw) {
const j = i + 1;
const g = Math.min(wi, water[j]);
if (g > 0) {
const flow = k * g * (before[j] - before[i]);
p[i] += flow;
p[j] -= flow;
}
}
if (y + 1 < gh) {
const j = i + gw;
const g = Math.min(wi, water[j]);
if (g > 0) {
const flow = k * g * (before[j] - before[i]);
p[i] += flow;
p[j] -= flow;
}
}
04종이 결의 낮은 곳에 안료 알갱이가 고이는 바탕
버터·하늘색·세이지 띠 세 줄을 붓 앞머리가 0.9초에 왼쪽에서 오른쪽으로 칠하고, 띠를 칠하기 시작하고 0.45초 뒤부터 고임 세기가 1.3초에 걸쳐 0 에서 GRANULATION 0.8 까지 올라 종이 골(높이 0)은 1.8배, 마루(높이 1)는 0.2배 진하기가 된다. 그 곱셈이 들어간 곳이 공용 색 합성 compose 의 안쪽 고리이고, 종이 높이는 두 옥타브 값 노이즈를 0~1 로 다시 편 격자라서 수제 문구점 상품 상세 배경처럼 종이 질감 자체를 보여 줄 자리에 어울린다. 하늘색 띠 가운데 줄의 평균 밝기는 178.5 에서 179.5 로 거의 그대로인데 흩어짐(표준편차)은 7.07 에서 15.17 로 2.1배가 되어, 색은 두고 알갱이 대비만 키운다.

<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);
}
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);
}
// 04 종이 결 고임 — 값 노이즈 두 옥타브로 만든 종이 높이맵 위에 물감 띠를 칠하고, 안료가 가라앉을수록 골(낮은 곳)에 더 많이 고이게 한다
// 고임 세기 — 다 가라앉으면 진하기 × (1 + GRANULATION × (1 − 2 × 높이)): 골은 1.8배, 마루는 0.2배
const GRANULATION = 0.8;
// 칠한 뒤 안료가 다 가라앉는 시간(초)
const SETTLE_TIME = 1.3;
// 종이 요철 음영 세기 — 공용 음영(1)보다 비스듬한 빛을 세게 받는다
const PAPER_RELIEF = 1.8;
// 물감 띠 진하기와 붓이 지나가는 시간(초)
const WASH_DENSITY = 0.95;
const SWEEP_TIME = 0.9;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 0.9;
const PAINT_TIME = 4.2;
const HOLD_TIME = 2.2;
const WASH_TIME = 1.4;
const SEED = 4824;
const BANDS = [
{ y: 74, half: 50, color: BUTTER, start: 0.2 },
{ y: 152, half: 50, color: SKY, start: 0.85 },
{ y: 230, half: 52, color: SAGE, start: 1.5 },
];
const bandFields = BANDS.map(function () { return new Float32Array(GW * GH); });
const bandPigs = BANDS.map(function (b) { return pigment(b.color); });
const bandGran = BANDS.map(function () { return 0; });
const sweptDone = BANDS.map(function () { return false; });
buildPaper(SEED, PAPER_RELIEF);
function reset() {
for (let k = 0; k < BANDS.length; k += 1) {
bandFields[k].fill(0);
bandGran[k] = 0;
sweptDone[k] = false;
}
}
// 띠 하나 칠하기 — 붓 앞머리가 왼쪽에서 오른쪽으로 지나간 자리만. 위아래 가장자리와 앞머리는 노이즈로 흔들리고, 붓 자국마다 물감 양이 조금씩 다르다
function paintBand(k, sweep) {
const b = BANDS[k];
const f = bandFields[k];
const front = sweep * (W + 80) - 40;
for (let y = 0; y < GH; y += 1) {
const py = (y + 0.5) * H / GH;
for (let x = 0; x < GW; x += 1) {
const px = (x + 0.5) * W / GW;
const edge = b.half * (0.8 + 0.4 * noise2(px / 40, k * 7.1, SEED + 3)) - Math.abs(py - b.y);
const along = front + (noise2(py / 30, k * 3.3, SEED + 5) - 0.5) * 50 - px;
const cover = smooth(edge / 10) * smooth(along / 16);
f[y * GW + x] = WASH_DENSITY * cover * (0.8 + 0.4 * noise2(px / 22, py / 22, SEED + 9 + k));
}
}
}
function update(dt, t) {
for (let k = 0; k < BANDS.length; k += 1) {
const b = BANDS[k];
if (t < b.start) {
continue;
}
const sweep = phaseOf(t, b.start, SWEEP_TIME);
if (!sweptDone[k]) {
paintBand(k, sweep);
sweptDone[k] = sweep >= 1;
}
// 가라앉기 — 붓이 지나간 뒤부터 고임 세기가 0 에서 GRANULATION 까지 오른다
bandGran[k] = GRANULATION * smooth(phaseOf(t, b.start + SWEEP_TIME * 0.5, SETTLE_TIME));
}
}
function render(t) {
compose(bandFields, GW, GH, bandPigs, bandGran);
washAt(t, SEED);
}
// 04 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
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();
};
})();// PaperGrainGranulation - vanilla 판(04-watercolor-paper-grain-granulation) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PaperGrainGranulation.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PaperGrainGranulation() {
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);
}
// 04 종이 결 고임 — 값 노이즈 두 옥타브로 만든 종이 높이맵 위에 물감 띠를 칠하고, 안료가 가라앉을수록 골(낮은 곳)에 더 많이 고이게 한다
// 고임 세기 — 다 가라앉으면 진하기 × (1 + GRANULATION × (1 − 2 × 높이)): 골은 1.8배, 마루는 0.2배
const GRANULATION = 0.8;
// 칠한 뒤 안료가 다 가라앉는 시간(초)
const SETTLE_TIME = 1.3;
// 종이 요철 음영 세기 — 공용 음영(1)보다 비스듬한 빛을 세게 받는다
const PAPER_RELIEF = 1.8;
// 물감 띠 진하기와 붓이 지나가는 시간(초)
const WASH_DENSITY = 0.95;
const SWEEP_TIME = 0.9;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 0.9;
const PAINT_TIME = 4.2;
const HOLD_TIME = 2.2;
const WASH_TIME = 1.4;
const SEED = 4824;
type Band = { y: number; half: number; color: RGB; start: number };
const BANDS: Band[] = [
{ y: 74, half: 50, color: BUTTER, start: 0.2 },
{ y: 152, half: 50, color: SKY, start: 0.85 },
{ y: 230, half: 52, color: SAGE, start: 1.5 },
];
const bandFields: Float32Array[] = BANDS.map(function (): Float32Array { return new Float32Array(GW * GH); });
const bandPigs: Pigment[] = BANDS.map(function (b: Band): Pigment { return pigment(b.color); });
const bandGran: number[] = BANDS.map(function (): number { return 0; });
const sweptDone: boolean[] = BANDS.map(function (): boolean { return false; });
buildPaper(SEED, PAPER_RELIEF);
function reset(): void {
for (let k = 0; k < BANDS.length; k += 1) {
bandFields[k].fill(0);
bandGran[k] = 0;
sweptDone[k] = false;
}
}
// 띠 하나 칠하기 — 붓 앞머리가 왼쪽에서 오른쪽으로 지나간 자리만. 위아래 가장자리와 앞머리는 노이즈로 흔들리고, 붓 자국마다 물감 양이 조금씩 다르다
function paintBand(k: number, sweep: number): void {
const b = BANDS[k];
const f = bandFields[k];
const front = sweep * (W + 80) - 40;
for (let y = 0; y < GH; y += 1) {
const py = (y + 0.5) * H / GH;
for (let x = 0; x < GW; x += 1) {
const px = (x + 0.5) * W / GW;
const edge = b.half * (0.8 + 0.4 * noise2(px / 40, k * 7.1, SEED + 3)) - Math.abs(py - b.y);
const along = front + (noise2(py / 30, k * 3.3, SEED + 5) - 0.5) * 50 - px;
const cover = smooth(edge / 10) * smooth(along / 16);
f[y * GW + x] = WASH_DENSITY * cover * (0.8 + 0.4 * noise2(px / 22, py / 22, SEED + 9 + k));
}
}
}
function update(dt: number, t: number): void {
for (let k = 0; k < BANDS.length; k += 1) {
const b = BANDS[k];
if (t < b.start) { continue; }
const sweep = phaseOf(t, b.start, SWEEP_TIME);
if (!sweptDone[k]) {
paintBand(k, sweep);
sweptDone[k] = sweep >= 1;
}
// 가라앉기 — 붓이 지나간 뒤부터 고임 세기가 0 에서 GRANULATION 까지 오른다
bandGran[k] = GRANULATION * smooth(phaseOf(t, b.start + SWEEP_TIME * 0.5, SETTLE_TIME));
}
}
function render(t: number): void {
compose(bandFields, GW, GH, bandPigs, bandGran);
washAt(t, SEED);
}
// 04 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
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>
);
}04 · 종이 결의 낮은 곳에 안료 알갱이가 고이는 바탕 — 측정 누적면적 49.166% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
const h = PAPER_HEIGHT[p];
let lr = 0;
let lg = 0;
let lb = 0;
for (let k = 0; k < n; k += 1) {
const f = fields[k];
let d = (f[i00] * (1 - tx) + f[i10] * tx) * (1 - ty) + (f[i01] * (1 - tx) + f[i11] * tx) * ty;
if (d <= 0) {
continue;
}
if (gran[k] !== 0) {
d *= Math.max(0, 1 + gran[k] * (1 - 2 * h));
}
const pk = pigs[k];
lr += pk.r * d;
lg += pk.g * d;
lb += pk.b * d;
}
05붓이 빨라질수록 물이 줄어 털끝이 갈라지는 마른 붓 획
곡선 위 자리를 진행의 1.8제곱으로 잡아 붓이 끝으로 갈수록 빨라지고, 아래 update 가 매 걸음 남은 물을 (0.22 + 0.0017 × 속도 px/초) × 시간만큼 깎는다. 붓털 16가닥은 저마다 0.04~0.62 사이 문턱을 갖고 brushSegment 는 남은 물이 문턱(노이즈로 ±0.08 흔들린다)보다 적어진 자리에서 그 가닥을 건너뛰므로, 카페 메뉴판 제목 밑줄처럼 끝이 결을 따라 갈라지며 사라지는 획이 된다. 네 획에서 첫 가닥이 끊긴 자리는 곡선의 40·44·37·48% 지점이고, 끝 속도가 740px/초를 넘은 두 획(741.5·766.3)은 16가닥이 모두 끊긴 채 끝난다.

<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);
}
// 곡선 위 한 점 — u 는 0(시작)~1(끝). 결과는 curveX·curveY 에 (새 배열을 만들지 않으려고)
let curveX = 0;
let curveY = 0;
function curveAt(c, u) {
const v = 1 - u;
const a = v * v * v;
const b = 3 * v * v * u;
const d = 3 * v * u * u;
const e = u * u * u;
curveX = a * c[0] + b * c[2] + d * c[4] + e * c[6];
curveY = a * c[1] + b * c[3] + d * c[5] + e * c[7];
}
function makeBristles(count, thirstMin, thirstMax, rng) {
const out = [];
for (let i = 0; i < count; i += 1) {
out.push({
offset: -1 + 2 * (i + 0.5) / count + (rng() - 0.5) * 0.6 / count,
thirst: thirstMin + (thirstMax - thirstMin) * rng(),
seed: Math.floor(rng() * 100000),
});
}
return out;
}
// 점 찍기 — 진하기 격자(gw×gh)에 판 좌표 (x, y) 둘레 반지름 radius(판 px) 의 부드러운 원으로 amount 를 더한다
function stampDot(field, gw, gh, x, y, radius, amount) {
const k = gw / W;
const gx = x * k;
const gy = y * k;
const r = Math.max(radius * k, 0.5);
const x0 = Math.max(0, Math.floor(gx - r - 1));
const x1 = Math.min(gw - 1, Math.ceil(gx + r + 1));
const y0 = Math.max(0, Math.floor(gy - r - 1));
const y1 = Math.min(gh - 1, Math.ceil(gy + r + 1));
for (let yy = y0; yy <= y1; yy += 1) {
for (let xx = x0; xx <= x1; xx += 1) {
const d = Math.hypot(xx + 0.5 - gx, yy + 0.5 - gy);
const cover = clamp(r + 0.5 - d, 0, 1);
if (cover > 0) {
field[yy * gw + xx] += amount * cover;
}
}
}
}
// 붓 한 토막 — 곡선의 u0 → u1 구간을 판 0.8px 걸음으로 따라가며 가닥마다 점을 찍는다
// 물(water)이 그 가닥의 문턱 + 노이즈 흔들림보다 적으면 그 자리에서는 찍지 않는다 → 문턱이 높은 가닥부터 끊긴다
function brushSegment(field, gw, gh, c, u0, u1, water, bristles, halfWidth, dotRadius, ink, wobble) {
curveAt(c, u0);
let px = curveX;
let py = curveY;
curveAt(c, u1);
const len = Math.hypot(curveX - px, curveY - py);
const steps = Math.max(1, Math.ceil(len / 0.8));
let drawn = 0;
for (let s = 1; s <= steps; s += 1) {
const u = lerp(u0, u1, s / steps);
curveAt(c, u);
const x = curveX;
const y = curveY;
const tl = Math.hypot(x - px, y - py) || 1;
const nx = -(y - py) / tl;
const ny = (x - px) / tl;
px = x;
py = y;
for (let b = 0; b < bristles.length; b += 1) {
const br = bristles[b];
const flicker = (valueNoise(u * 40, b * 3.7, br.seed) - 0.5) * 0.16;
if (water < br.thirst + flicker) {
continue;
}
const off = br.offset * halfWidth + (valueNoise(u * 9, b * 1.3, br.seed + 1) - 0.5) * wobble;
stampDot(field, gw, gh, x + nx * off, y + ny * off, dotRadius, ink);
drawn += 1;
}
}
return drawn;
}
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);
}
// 05 마른 붓 — 붓이 제어점 두 개로 휘는 3차 곡선을 따라 점점 빨라지고, 빠를수록 물이 더 줄어 문턱이 높은 붓털부터 끊긴다
// 붓털 가닥 수
const BRISTLE_COUNT = 16;
// 가닥이 그리려면 남아 있어야 하는 물의 문턱 — 가닥마다 이 사이 값 하나
const THIRST_MIN = 0.04;
const THIRST_MAX = 0.62;
// 물이 줄어드는 빠르기 — 1초에 DRY_BASE + SPEED_DRAIN × 속도(판 px/초)
const DRY_BASE = 0.22;
const SPEED_DRAIN = 0.0017;
// 붓 반폭(판 px)과 가닥 하나의 점 반지름 — 물이 많으면 점이 커져 가닥끼리 붙고, 마르면 가늘어져 갈라진다
const BRUSH_HALF = 22;
const DOT_WET = 3.6;
const DOT_DRY = 0.9;
// 가닥 하나가 한 점에 남기는 진하기와 가닥 흔들림 폭(판 px)
const BRISTLE_INK = 0.11;
const BRISTLE_WOBBLE = 3;
// 붓이 빨라지는 정도 — 곡선 위 자리 u = 진행률^STROKE_EASE (1 보다 크면 끝으로 갈수록 빠르다)
const STROKE_EASE = 1.8;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.0;
const PAINT_TIME = 4.0;
const HOLD_TIME = 2.2;
const WASH_TIME = 1.4;
const SEED = 4825;
const STROKES = [
{ curve: [36, 64, 160, 14, 300, 128, 446, 58], start: 0.2, duration: 1.0, color: INDIGO },
{ curve: [44, 142, 170, 206, 300, 82, 436, 150], start: 1.0, duration: 0.95, color: CORAL },
{ curve: [40, 214, 182, 158, 318, 282, 442, 222], start: 1.75, duration: 0.95, color: SAGE },
{ curve: [70, 268, 200, 238, 300, 296, 414, 264], start: 2.5, duration: 0.85, color: LAVENDER },
];
const strokeFields = STROKES.map(function () { return new Float32Array(OW * OH); });
const strokePigs = STROKES.map(function (s) { return pigment(s.color); });
const strokeGran = STROKES.map(function () { return 0.25; });
const strokeU = STROKES.map(function () { return 0; });
const strokeWater = STROKES.map(function () { return 1; });
let bristleSets = [];
buildPaper(SEED, 1.2);
function reset() {
const rng = makeRng(SEED);
bristleSets = STROKES.map(function () { return makeBristles(BRISTLE_COUNT, THIRST_MIN, THIRST_MAX, rng); });
for (let k = 0; k < STROKES.length; k += 1) {
strokeFields[k].fill(0);
strokeU[k] = 0;
strokeWater[k] = 1;
}
}
// 곡선 길이(판 px) — 속도를 재려고 u 한 칸이 몇 px 인지 어림한다
function curveLength(c) {
let s = 0;
curveAt(c, 0);
let x = curveX;
let y = curveY;
for (let i = 1; i <= 200; i += 1) {
curveAt(c, i / 200);
s += Math.hypot(curveX - x, curveY - y);
x = curveX;
y = curveY;
}
return s;
}
const strokeLen = STROKES.map(function (s) { return curveLength(s.curve); });
function update(dt, t) {
for (let k = 0; k < STROKES.length; k += 1) {
const s = STROKES[k];
if (t < s.start || strokeU[k] >= 1) {
continue;
}
const u = Math.pow(phaseOf(t, s.start, s.duration), STROKE_EASE);
const speed = (u - strokeU[k]) * strokeLen[k] / dt;
strokeWater[k] = Math.max(0, strokeWater[k] - (DRY_BASE + SPEED_DRAIN * speed) * dt);
const w = strokeWater[k];
brushSegment(strokeFields[k], OW, OH, s.curve, strokeU[k], u, w, bristleSets[k], BRUSH_HALF, lerp(DOT_DRY, DOT_WET, clamp(w, 0, 1)), BRISTLE_INK * (0.5 + 0.5 * w), BRISTLE_WOBBLE);
strokeU[k] = u;
}
}
function render(t) {
compose(strokeFields, OW, OH, strokePigs, strokeGran);
washAt(t, SEED);
}
// 05 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
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();
};
})();// DryBrushBristleSplit - vanilla 판(05-watercolor-dry-brush-bristle-split) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./DryBrushBristleSplit.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DryBrushBristleSplit() {
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);
}
// 제어점 두 개로 휘는 3차 곡선 — 시작점·제어점 1·제어점 2·끝점의 x, y 여덟 개. 곡선은 두 제어점 쪽으로 끌려가며 휜다
type Curve = number[];
// 곡선 위 한 점 — u 는 0(시작)~1(끝). 결과는 curveX·curveY 에 (새 배열을 만들지 않으려고)
let curveX = 0;
let curveY = 0;
function curveAt(c: Curve, u: number): void {
const v = 1 - u;
const a = v * v * v;
const b = 3 * v * v * u;
const d = 3 * v * u * u;
const e = u * u * u;
curveX = a * c[0] + b * c[2] + d * c[4] + e * c[6];
curveY = a * c[1] + b * c[3] + d * c[5] + e * c[7];
}
// 붓털 하나 — 붓 가운데에서 떨어진 자리(−1~1), 물을 얼마나 머금어야 그리는지(문턱), 흔들림 노이즈 시드
type Bristle = { offset: number; thirst: number; seed: number };
function makeBristles(count: number, thirstMin: number, thirstMax: number, rng: () => number): Bristle[] {
const out: Bristle[] = [];
for (let i = 0; i < count; i += 1) {
out.push({
offset: -1 + 2 * (i + 0.5) / count + (rng() - 0.5) * 0.6 / count,
thirst: thirstMin + (thirstMax - thirstMin) * rng(),
seed: Math.floor(rng() * 100000),
});
}
return out;
}
// 점 찍기 — 진하기 격자(gw×gh)에 판 좌표 (x, y) 둘레 반지름 radius(판 px) 의 부드러운 원으로 amount 를 더한다
function stampDot(field: Float32Array, gw: number, gh: number, x: number, y: number, radius: number, amount: number): void {
const k = gw / W;
const gx = x * k;
const gy = y * k;
const r = Math.max(radius * k, 0.5);
const x0 = Math.max(0, Math.floor(gx - r - 1));
const x1 = Math.min(gw - 1, Math.ceil(gx + r + 1));
const y0 = Math.max(0, Math.floor(gy - r - 1));
const y1 = Math.min(gh - 1, Math.ceil(gy + r + 1));
for (let yy = y0; yy <= y1; yy += 1) {
for (let xx = x0; xx <= x1; xx += 1) {
const d = Math.hypot(xx + 0.5 - gx, yy + 0.5 - gy);
const cover = clamp(r + 0.5 - d, 0, 1);
if (cover > 0) { field[yy * gw + xx] += amount * cover; }
}
}
}
// 붓 한 토막 — 곡선의 u0 → u1 구간을 판 0.8px 걸음으로 따라가며 가닥마다 점을 찍는다
// 물(water)이 그 가닥의 문턱 + 노이즈 흔들림보다 적으면 그 자리에서는 찍지 않는다 → 문턱이 높은 가닥부터 끊긴다
function brushSegment(field: Float32Array, gw: number, gh: number, c: Curve, u0: number, u1: number, water: number,
bristles: Bristle[], halfWidth: number, dotRadius: number, ink: number, wobble: number): number {
curveAt(c, u0);
let px = curveX;
let py = curveY;
curveAt(c, u1);
const len = Math.hypot(curveX - px, curveY - py);
const steps = Math.max(1, Math.ceil(len / 0.8));
let drawn = 0;
for (let s = 1; s <= steps; s += 1) {
const u = lerp(u0, u1, s / steps);
curveAt(c, u);
const x = curveX;
const y = curveY;
const tl = Math.hypot(x - px, y - py) || 1;
const nx = -(y - py) / tl;
const ny = (x - px) / tl;
px = x;
py = y;
for (let b = 0; b < bristles.length; b += 1) {
const br = bristles[b];
const flicker = (valueNoise(u * 40, b * 3.7, br.seed) - 0.5) * 0.16;
if (water < br.thirst + flicker) { continue; }
const off = br.offset * halfWidth + (valueNoise(u * 9, b * 1.3, br.seed + 1) - 0.5) * wobble;
stampDot(field, gw, gh, x + nx * off, y + ny * off, dotRadius, ink);
drawn += 1;
}
}
return drawn;
}
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);
}
// 05 마른 붓 — 붓이 제어점 두 개로 휘는 3차 곡선을 따라 점점 빨라지고, 빠를수록 물이 더 줄어 문턱이 높은 붓털부터 끊긴다
// 붓털 가닥 수
const BRISTLE_COUNT = 16;
// 가닥이 그리려면 남아 있어야 하는 물의 문턱 — 가닥마다 이 사이 값 하나
const THIRST_MIN = 0.04;
const THIRST_MAX = 0.62;
// 물이 줄어드는 빠르기 — 1초에 DRY_BASE + SPEED_DRAIN × 속도(판 px/초)
const DRY_BASE = 0.22;
const SPEED_DRAIN = 0.0017;
// 붓 반폭(판 px)과 가닥 하나의 점 반지름 — 물이 많으면 점이 커져 가닥끼리 붙고, 마르면 가늘어져 갈라진다
const BRUSH_HALF = 22;
const DOT_WET = 3.6;
const DOT_DRY = 0.9;
// 가닥 하나가 한 점에 남기는 진하기와 가닥 흔들림 폭(판 px)
const BRISTLE_INK = 0.11;
const BRISTLE_WOBBLE = 3;
// 붓이 빨라지는 정도 — 곡선 위 자리 u = 진행률^STROKE_EASE (1 보다 크면 끝으로 갈수록 빠르다)
const STROKE_EASE = 1.8;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.0;
const PAINT_TIME = 4.0;
const HOLD_TIME = 2.2;
const WASH_TIME = 1.4;
const SEED = 4825;
type Stroke = { curve: Curve; start: number; duration: number; color: RGB };
const STROKES: Stroke[] = [
{ curve: [36, 64, 160, 14, 300, 128, 446, 58], start: 0.2, duration: 1.0, color: INDIGO },
{ curve: [44, 142, 170, 206, 300, 82, 436, 150], start: 1.0, duration: 0.95, color: CORAL },
{ curve: [40, 214, 182, 158, 318, 282, 442, 222], start: 1.75, duration: 0.95, color: SAGE },
{ curve: [70, 268, 200, 238, 300, 296, 414, 264], start: 2.5, duration: 0.85, color: LAVENDER },
];
const strokeFields: Float32Array[] = STROKES.map(function (): Float32Array { return new Float32Array(OW * OH); });
const strokePigs: Pigment[] = STROKES.map(function (s: Stroke): Pigment { return pigment(s.color); });
const strokeGran: number[] = STROKES.map(function (): number { return 0.25; });
const strokeU: number[] = STROKES.map(function (): number { return 0; });
const strokeWater: number[] = STROKES.map(function (): number { return 1; });
let bristleSets: Bristle[][] = [];
buildPaper(SEED, 1.2);
function reset(): void {
const rng = makeRng(SEED);
bristleSets = STROKES.map(function (): Bristle[] { return makeBristles(BRISTLE_COUNT, THIRST_MIN, THIRST_MAX, rng); });
for (let k = 0; k < STROKES.length; k += 1) {
strokeFields[k].fill(0);
strokeU[k] = 0;
strokeWater[k] = 1;
}
}
// 곡선 길이(판 px) — 속도를 재려고 u 한 칸이 몇 px 인지 어림한다
function curveLength(c: Curve): number {
let s = 0;
curveAt(c, 0);
let x = curveX;
let y = curveY;
for (let i = 1; i <= 200; i += 1) {
curveAt(c, i / 200);
s += Math.hypot(curveX - x, curveY - y);
x = curveX;
y = curveY;
}
return s;
}
const strokeLen: number[] = STROKES.map(function (s: Stroke): number { return curveLength(s.curve); });
function update(dt: number, t: number): void {
for (let k = 0; k < STROKES.length; k += 1) {
const s = STROKES[k];
if (t < s.start || strokeU[k] >= 1) { continue; }
const u = Math.pow(phaseOf(t, s.start, s.duration), STROKE_EASE);
const speed = (u - strokeU[k]) * strokeLen[k] / dt;
strokeWater[k] = Math.max(0, strokeWater[k] - (DRY_BASE + SPEED_DRAIN * speed) * dt);
const w = strokeWater[k];
brushSegment(strokeFields[k], OW, OH, s.curve, strokeU[k], u, w, bristleSets[k], BRUSH_HALF,
lerp(DOT_DRY, DOT_WET, clamp(w, 0, 1)), BRISTLE_INK * (0.5 + 0.5 * w), BRISTLE_WOBBLE);
strokeU[k] = u;
}
}
function render(t: number): void {
compose(strokeFields, OW, OH, strokePigs, strokeGran);
washAt(t, SEED);
}
// 05 그리기 — 진하기 격자를 색으로 합성해 화면에 놓는다
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>
);
}05 · 붓이 빨라질수록 물이 줄어 털끝이 갈라지는 마른 붓 획 — 측정 누적면적 20.835% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function update(dt, t) {
for (let k = 0; k < STROKES.length; k += 1) {
const s = STROKES[k];
if (t < s.start || strokeU[k] >= 1) {
continue;
}
const u = Math.pow(phaseOf(t, s.start, s.duration), STROKE_EASE);
const speed = (u - strokeU[k]) * strokeLen[k] / dt;
strokeWater[k] = Math.max(0, strokeWater[k] - (DRY_BASE + SPEED_DRAIN * speed) * dt);
const w = strokeWater[k];
brushSegment(strokeFields[k], OW, OH, s.curve, strokeU[k], u, w, bristleSets[k], BRUSH_HALF, lerp(DOT_DRY, DOT_WET, clamp(w, 0, 1)), BRISTLE_INK * (0.5 + 0.5 * w), BRISTLE_WOBBLE);
strokeU[k] = u;
}
}
06풍경 그림을 밝기 단계와 흔들린 경계의 물감 그림으로 바꾸는 이미지 수채화 효과
원본은 외부 사진이 아니라 makeLandscape 가 화소마다 하늘·해·언덕·집·나무를 계산한 320×200 풍경 그림이고, buildPainted 가 읽을 자리를 노이즈로 ±3.2 화소 옮겨 네 이웃과 평균 낸 뒤 밝기를 5단계로 반올림하되 세 채널에 같은 비율을 곱해 색상은 지킨다. 그다음 이웃 밝기 차가 큰 화소를 최대 38% 어둡게 해 테두리를 긋고 종이 결을 곱하며, 여행 블로그 대표 그림처럼 원본 위를 왼쪽에서 오른쪽으로 3.0초에 쓸어 바꾸는 모습 자체가 볼거리가 된다. 8비트 색 수는 원본 995 에서 단계·흔들기 뒤 858 로 줄었다가 테두리와 종이 결까지 얹으면 결 음영이 화소마다 달라 9,764 로 늘고, 테두리로 15% 넘게 어두워진 화소는 3.0% 다.

<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);
}
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);
}
// 06 이미지 수채화 — 같은 페이지에서 계산으로 그린 풍경을 원본으로 삼아 ① 경계 흔들기 ② 색 단계 줄이기 ③ 이웃 밝기 차로 테두리 어둡게 ④ 종이 결 곱 을 차례로 하고, 왼쪽부터 쓸어 바꾼다
// 밝기를 남길 단계 수 — 하늘 그라디언트도 이 수만큼의 띠로 끊긴다
const COLOR_LEVELS = 5;
// 색면 경계를 흔드는 폭(화소) — 노이즈로 읽을 자리를 이만큼 옮긴다
const EDGE_JITTER = 3.2;
// 이웃과의 밝기 차가 이 값을 넘으면 테두리로 친다(0~1)
const EDGE_THRESHOLD = 0.035;
// 테두리를 어둡게 하는 정도 — 밝기에 (1 − EDGE_DARKEN) 까지 곱한다
const EDGE_DARKEN = 0.38;
// 종이 색 섞기 — 물감은 종이를 비쳐 보이므로 단계 색을 종이 쪽으로 이만큼 옅게
const PAPER_MIX = 0.16;
// 원본 → 물감 그림으로 쓸어 바꾸는 시간(초)
const SWEEP_TIME = 3.0;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 0.9;
const PAINT_TIME = 3.6;
const HOLD_TIME = 2.6;
const WASH_TIME = 1.6;
const SEED = 4826;
// 원본 풍경(화소 격자 RGB 0~1) — 브라우저에서는 그린 뒤 getImageData 로 다시 읽은 값으로 바뀐다
const photo = new Float32Array(OW * OH * 3);
// 물감 그림 결과와 단계만 줄인 중간 결과
const painted = new Float32Array(OW * OH * 3);
const stepped = new Float32Array(OW * OH * 3);
let paintedReady = false;
buildPaper(SEED, 1);
function putPhoto(x, y, c, a) {
const i = (y * OW + x) * 3;
photo[i] = lerp(photo[i], c[0] / 255, a);
photo[i + 1] = lerp(photo[i + 1], c[1] / 255, a);
photo[i + 2] = lerp(photo[i + 2], c[2] / 255, a);
}
// 원본 풍경 — 하늘 그라디언트·해·먼 언덕·가까운 언덕·집·나무를 화소마다 계산한다(외부 사진 없음)
function makeLandscape() {
for (let y = 0; y < OH; y += 1) {
for (let x = 0; x < OW; x += 1) {
const k = y / 120;
const i = (y * OW + x) * 3;
photo[i] = lerp(112, 248, k) / 255;
photo[i + 1] = lerp(170, 222, k) / 255;
photo[i + 2] = lerp(232, 196, k) / 255;
const sun = Math.hypot(x - 236, y - 46);
putPhoto(x, y, [255, 232, 150], clamp(19 - sun, 0, 1));
const far = 104 + 22 * (noise2(x / 46, 3.1, SEED) - 0.5) * 2;
if (y > far) {
putPhoto(x, y, [104, 122, 178], clamp((y - far) / 2, 0, 1) * 0.9);
}
const near = 132 + 24 * (noise2(x / 34, 8.7, SEED + 1) - 0.5) * 2;
if (y > near) {
putPhoto(x, y, [lerp(110, 60, (y - near) / 70), lerp(176, 120, (y - near) / 70), 96], clamp((y - near) / 2, 0, 1));
}
// 집 — 벽·지붕·문·창
if (x >= 186 && x <= 232 && y >= 120 && y <= 154) {
putPhoto(x, y, [238, 226, 204], 1);
}
if (y >= 98 && y <= 120 && Math.abs(x - 209) <= (y - 98) * 1.25 + 2) {
putPhoto(x, y, [206, 92, 80], 1);
}
if (x >= 204 && x <= 214 && y >= 136 && y <= 154) {
putPhoto(x, y, [112, 78, 66], 1);
}
if (x >= 192 && x <= 200 && y >= 126 && y <= 133) {
putPhoto(x, y, [118, 160, 206], 1);
}
// 나무 — 둥근 잎과 줄기
if (x >= 262 && x <= 267 && y >= 128 && y <= 156) {
putPhoto(x, y, [108, 82, 60], 1);
}
putPhoto(x, y, [64, 132, 82], clamp(15 - Math.hypot(x - 265, y - 120), 0, 1));
}
}
}
makeLandscape();
function luma(buf, i) {
return buf[i * 3] * 0.299 + buf[i * 3 + 1] * 0.587 + buf[i * 3 + 2] * 0.114;
}
// 물감 그림 만들기 — 원본이 바뀌면 한 번만 다시 계산한다
function buildPainted() {
const pr = PAPER[0] / 255;
const pg = PAPER[1] / 255;
const pb = PAPER[2] / 255;
const top = COLOR_LEVELS - 1;
for (let y = 0; y < OH; y += 1) {
for (let x = 0; x < OW; x += 1) {
// ① 경계 흔들기 — 읽을 자리를 노이즈만큼 옮겨 색면 가장자리가 붓 자국처럼 울퉁불퉁해진다
const sx = clamp(Math.round(x + (noise2(x / 7, y / 7, SEED + 11) - 0.5) * 2 * EDGE_JITTER), 0, OW - 1);
const sy = clamp(Math.round(y + (noise2(x / 7 + 40, y / 7, SEED + 12) - 0.5) * 2 * EDGE_JITTER), 0, OH - 1);
const o = (y * OW + x) * 3;
// 잔 얼룩 고르기 — 옮긴 자리와 그 네 이웃의 평균을 읽는다
let r = 0;
let g = 0;
let b = 0;
for (let q = 0; q < 5; q += 1) {
const qx = clamp(sx + (q === 1 ? -1 : q === 2 ? 1 : 0), 0, OW - 1);
const qy = clamp(sy + (q === 3 ? -1 : q === 4 ? 1 : 0), 0, OH - 1);
const s = (qy * OW + qx) * 3;
r += photo[s] * 0.2;
g += photo[s + 1] * 0.2;
b += photo[s + 2] * 0.2;
}
// ② 색 단계 줄이기 — 밝기를 COLOR_LEVELS 단계로 반올림하고 세 채널에 같은 비율을 곱해 색상은 지킨다. 그다음 종이 색 쪽으로 PAPER_MIX 만큼
const l = r * 0.299 + g * 0.587 + b * 0.114;
const ratio = Math.round(clamp(l, 0, 1) * top) / top / Math.max(l, 0.02);
stepped[o] = lerp(clamp(r * ratio, 0, 1), pr, PAPER_MIX);
stepped[o + 1] = lerp(clamp(g * ratio, 0, 1), pg, PAPER_MIX);
stepped[o + 2] = lerp(clamp(b * ratio, 0, 1), pb, PAPER_MIX);
}
}
for (let y = 0; y < OH; y += 1) {
for (let x = 0; x < OW; x += 1) {
const i = y * OW + x;
const l = luma(stepped, i);
// ③ 이웃 밝기 차 — 위아래 왼오른 네 이웃과의 밝기 차 가운데 가장 큰 값
let e = 0;
if (x > 0) {
e = Math.max(e, Math.abs(l - luma(stepped, i - 1)));
}
if (x < OW - 1) {
e = Math.max(e, Math.abs(l - luma(stepped, i + 1)));
}
if (y > 0) {
e = Math.max(e, Math.abs(l - luma(stepped, i - OW)));
}
if (y < OH - 1) {
e = Math.max(e, Math.abs(l - luma(stepped, i + OW)));
}
const dark = 1 - EDGE_DARKEN * smooth((e - EDGE_THRESHOLD) / 0.12);
// ④ 종이 결 곱 — 요철 음영과, 골에 조금 더 고이는 안료(높이가 낮을수록 6% 진하게)
const grain = PAPER_SHADE[i] * (1 - 0.12 * (0.5 - PAPER_HEIGHT[i]));
painted[i * 3] = stepped[i * 3] * dark * grain;
painted[i * 3 + 1] = stepped[i * 3 + 1] * dark * grain;
painted[i * 3 + 2] = stepped[i * 3 + 2] * dark * grain;
}
}
paintedReady = true;
}
function reset() {
if (!paintedReady) {
buildPainted();
}
}
function update(dt, t) {
// 이 장면은 시간에 따라 쌓이는 계산이 없다 — 쓸기 자리만 render 가 시간으로 정한다
}
// 쓸기 앞머리(화소) — 0 이면 왼쪽 바깥, 1 이면 오른쪽 바깥. 줄마다 노이즈로 흔들려 젖은 붓 자국처럼 들쭉날쭉하다
function sweepFront(p, y) {
return p * (OW + 70) - 35 + (noise2(y / 18, 2.5, SEED + 21) - 0.5) * 40;
}
function render(t) {
const on = smooth(phaseOf(t, 0.2, SWEEP_TIME));
const back = smooth(phaseOf(t, PAINT_TIME + HOLD_TIME, WASH_TIME));
for (let y = 0; y < OH; y += 1) {
const f1 = sweepFront(on, y);
const f2 = sweepFront(back, y);
for (let x = 0; x < OW; x += 1) {
// 그림으로 바뀐 정도 — 첫 쓸기 앞머리 뒤쪽이면 1, 되돌리는 쓸기가 지나간 자리는 다시 0
const k = smooth((f1 - x) / 12) * (1 - smooth((f2 - x) / 12));
const i = (y * OW + x) * 3;
OUT[i] = clamp(lerp(photo[i], painted[i], k), 0, 1);
OUT[i + 1] = clamp(lerp(photo[i + 1], painted[i + 1], k), 0, 1);
OUT[i + 2] = clamp(lerp(photo[i + 2], painted[i + 2], k), 0, 1);
}
}
}
// 06 원본 읽기 — 계산한 풍경을 바깥에 안 보이는 캔버스에 놓고 getImageData 로 다시 읽어 그 화소를 원본으로 쓴다
// 같은 페이지에서 그린 그림이라 캔버스가 오염되지 않아 읽기가 열린다. 다른 출처의 사진을 crossOrigin 허락 없이 그렸다면 여기서 SecurityError 가 난다
const photoEl = document.createElement('canvas');
photoEl.width = OW;
photoEl.height = OH;
const photoCtx = photoEl.getContext('2d', { willReadFrequently: true });
function loadPhoto() {
if (!photoCtx) {
return;
}
const img = photoCtx.createImageData(OW, OH);
for (let i = 0; i < OW * OH; i += 1) {
img.data[i * 4] = photo[i * 3] * 255;
img.data[i * 4 + 1] = photo[i * 3 + 1] * 255;
img.data[i * 4 + 2] = photo[i * 3 + 2] * 255;
img.data[i * 4 + 3] = 255;
}
photoCtx.putImageData(img, 0, 0);
try {
const px = photoCtx.getImageData(0, 0, OW, OH).data;
for (let i = 0; i < OW * OH; i += 1) {
photo[i * 3] = px[i * 4] / 255;
photo[i * 3 + 1] = px[i * 4 + 1] / 255;
photo[i * 3 + 2] = px[i * 4 + 2] / 255;
}
paintedReady = false;
}
catch (err) {
// 오염된 캔버스 — 픽셀을 못 읽으면 계산해 둔 원본 그대로 쓴다
}
}
loadPhoto();
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();
};
})();// PhotoQuantizeEdgeWash - vanilla 판(06-watercolor-photo-quantize-edge-wash) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PhotoQuantizeEdgeWash.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PhotoQuantizeEdgeWash() {
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);
}
// 06 이미지 수채화 — 같은 페이지에서 계산으로 그린 풍경을 원본으로 삼아 ① 경계 흔들기 ② 색 단계 줄이기 ③ 이웃 밝기 차로 테두리 어둡게 ④ 종이 결 곱 을 차례로 하고, 왼쪽부터 쓸어 바꾼다
// 밝기를 남길 단계 수 — 하늘 그라디언트도 이 수만큼의 띠로 끊긴다
const COLOR_LEVELS = 5;
// 색면 경계를 흔드는 폭(화소) — 노이즈로 읽을 자리를 이만큼 옮긴다
const EDGE_JITTER = 3.2;
// 이웃과의 밝기 차가 이 값을 넘으면 테두리로 친다(0~1)
const EDGE_THRESHOLD = 0.035;
// 테두리를 어둡게 하는 정도 — 밝기에 (1 − EDGE_DARKEN) 까지 곱한다
const EDGE_DARKEN = 0.38;
// 종이 색 섞기 — 물감은 종이를 비쳐 보이므로 단계 색을 종이 쪽으로 이만큼 옅게
const PAPER_MIX = 0.16;
// 원본 → 물감 그림으로 쓸어 바꾸는 시간(초)
const SWEEP_TIME = 3.0;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 0.9;
const PAINT_TIME = 3.6;
const HOLD_TIME = 2.6;
const WASH_TIME = 1.6;
const SEED = 4826;
// 원본 풍경(화소 격자 RGB 0~1) — 브라우저에서는 그린 뒤 getImageData 로 다시 읽은 값으로 바뀐다
const photo = new Float32Array(OW * OH * 3);
// 물감 그림 결과와 단계만 줄인 중간 결과
const painted = new Float32Array(OW * OH * 3);
const stepped = new Float32Array(OW * OH * 3);
let paintedReady = false;
buildPaper(SEED, 1);
function putPhoto(x: number, y: number, c: RGB, a: number): void {
const i = (y * OW + x) * 3;
photo[i] = lerp(photo[i], c[0] / 255, a);
photo[i + 1] = lerp(photo[i + 1], c[1] / 255, a);
photo[i + 2] = lerp(photo[i + 2], c[2] / 255, a);
}
// 원본 풍경 — 하늘 그라디언트·해·먼 언덕·가까운 언덕·집·나무를 화소마다 계산한다(외부 사진 없음)
function makeLandscape(): void {
for (let y = 0; y < OH; y += 1) {
for (let x = 0; x < OW; x += 1) {
const k = y / 120;
const i = (y * OW + x) * 3;
photo[i] = lerp(112, 248, k) / 255;
photo[i + 1] = lerp(170, 222, k) / 255;
photo[i + 2] = lerp(232, 196, k) / 255;
const sun = Math.hypot(x - 236, y - 46);
putPhoto(x, y, [255, 232, 150], clamp(19 - sun, 0, 1));
const far = 104 + 22 * (noise2(x / 46, 3.1, SEED) - 0.5) * 2;
if (y > far) { putPhoto(x, y, [104, 122, 178], clamp((y - far) / 2, 0, 1) * 0.9); }
const near = 132 + 24 * (noise2(x / 34, 8.7, SEED + 1) - 0.5) * 2;
if (y > near) { putPhoto(x, y, [lerp(110, 60, (y - near) / 70), lerp(176, 120, (y - near) / 70), 96], clamp((y - near) / 2, 0, 1)); }
// 집 — 벽·지붕·문·창
if (x >= 186 && x <= 232 && y >= 120 && y <= 154) { putPhoto(x, y, [238, 226, 204], 1); }
if (y >= 98 && y <= 120 && Math.abs(x - 209) <= (y - 98) * 1.25 + 2) { putPhoto(x, y, [206, 92, 80], 1); }
if (x >= 204 && x <= 214 && y >= 136 && y <= 154) { putPhoto(x, y, [112, 78, 66], 1); }
if (x >= 192 && x <= 200 && y >= 126 && y <= 133) { putPhoto(x, y, [118, 160, 206], 1); }
// 나무 — 둥근 잎과 줄기
if (x >= 262 && x <= 267 && y >= 128 && y <= 156) { putPhoto(x, y, [108, 82, 60], 1); }
putPhoto(x, y, [64, 132, 82], clamp(15 - Math.hypot(x - 265, y - 120), 0, 1));
}
}
}
makeLandscape();
function luma(buf: Float32Array, i: number): number {
return buf[i * 3] * 0.299 + buf[i * 3 + 1] * 0.587 + buf[i * 3 + 2] * 0.114;
}
// 물감 그림 만들기 — 원본이 바뀌면 한 번만 다시 계산한다
function buildPainted(): void {
const pr = PAPER[0] / 255;
const pg = PAPER[1] / 255;
const pb = PAPER[2] / 255;
const top = COLOR_LEVELS - 1;
for (let y = 0; y < OH; y += 1) {
for (let x = 0; x < OW; x += 1) {
// ① 경계 흔들기 — 읽을 자리를 노이즈만큼 옮겨 색면 가장자리가 붓 자국처럼 울퉁불퉁해진다
const sx = clamp(Math.round(x + (noise2(x / 7, y / 7, SEED + 11) - 0.5) * 2 * EDGE_JITTER), 0, OW - 1);
const sy = clamp(Math.round(y + (noise2(x / 7 + 40, y / 7, SEED + 12) - 0.5) * 2 * EDGE_JITTER), 0, OH - 1);
const o = (y * OW + x) * 3;
// 잔 얼룩 고르기 — 옮긴 자리와 그 네 이웃의 평균을 읽는다
let r = 0;
let g = 0;
let b = 0;
for (let q = 0; q < 5; q += 1) {
const qx = clamp(sx + (q === 1 ? -1 : q === 2 ? 1 : 0), 0, OW - 1);
const qy = clamp(sy + (q === 3 ? -1 : q === 4 ? 1 : 0), 0, OH - 1);
const s = (qy * OW + qx) * 3;
r += photo[s] * 0.2;
g += photo[s + 1] * 0.2;
b += photo[s + 2] * 0.2;
}
// ② 색 단계 줄이기 — 밝기를 COLOR_LEVELS 단계로 반올림하고 세 채널에 같은 비율을 곱해 색상은 지킨다. 그다음 종이 색 쪽으로 PAPER_MIX 만큼
const l = r * 0.299 + g * 0.587 + b * 0.114;
const ratio = Math.round(clamp(l, 0, 1) * top) / top / Math.max(l, 0.02);
stepped[o] = lerp(clamp(r * ratio, 0, 1), pr, PAPER_MIX);
stepped[o + 1] = lerp(clamp(g * ratio, 0, 1), pg, PAPER_MIX);
stepped[o + 2] = lerp(clamp(b * ratio, 0, 1), pb, PAPER_MIX);
}
}
for (let y = 0; y < OH; y += 1) {
for (let x = 0; x < OW; x += 1) {
const i = y * OW + x;
const l = luma(stepped, i);
// ③ 이웃 밝기 차 — 위아래 왼오른 네 이웃과의 밝기 차 가운데 가장 큰 값
let e = 0;
if (x > 0) { e = Math.max(e, Math.abs(l - luma(stepped, i - 1))); }
if (x < OW - 1) { e = Math.max(e, Math.abs(l - luma(stepped, i + 1))); }
if (y > 0) { e = Math.max(e, Math.abs(l - luma(stepped, i - OW))); }
if (y < OH - 1) { e = Math.max(e, Math.abs(l - luma(stepped, i + OW))); }
const dark = 1 - EDGE_DARKEN * smooth((e - EDGE_THRESHOLD) / 0.12);
// ④ 종이 결 곱 — 요철 음영과, 골에 조금 더 고이는 안료(높이가 낮을수록 6% 진하게)
const grain = PAPER_SHADE[i] * (1 - 0.12 * (0.5 - PAPER_HEIGHT[i]));
painted[i * 3] = stepped[i * 3] * dark * grain;
painted[i * 3 + 1] = stepped[i * 3 + 1] * dark * grain;
painted[i * 3 + 2] = stepped[i * 3 + 2] * dark * grain;
}
}
paintedReady = true;
}
function reset(): void {
if (!paintedReady) { buildPainted(); }
}
function update(dt: number, t: number): void {
// 이 장면은 시간에 따라 쌓이는 계산이 없다 — 쓸기 자리만 render 가 시간으로 정한다
}
// 쓸기 앞머리(화소) — 0 이면 왼쪽 바깥, 1 이면 오른쪽 바깥. 줄마다 노이즈로 흔들려 젖은 붓 자국처럼 들쭉날쭉하다
function sweepFront(p: number, y: number): number {
return p * (OW + 70) - 35 + (noise2(y / 18, 2.5, SEED + 21) - 0.5) * 40;
}
function render(t: number): void {
const on = smooth(phaseOf(t, 0.2, SWEEP_TIME));
const back = smooth(phaseOf(t, PAINT_TIME + HOLD_TIME, WASH_TIME));
for (let y = 0; y < OH; y += 1) {
const f1 = sweepFront(on, y);
const f2 = sweepFront(back, y);
for (let x = 0; x < OW; x += 1) {
// 그림으로 바뀐 정도 — 첫 쓸기 앞머리 뒤쪽이면 1, 되돌리는 쓸기가 지나간 자리는 다시 0
const k = smooth((f1 - x) / 12) * (1 - smooth((f2 - x) / 12));
const i = (y * OW + x) * 3;
OUT[i] = clamp(lerp(photo[i], painted[i], k), 0, 1);
OUT[i + 1] = clamp(lerp(photo[i + 1], painted[i + 1], k), 0, 1);
OUT[i + 2] = clamp(lerp(photo[i + 2], painted[i + 2], k), 0, 1);
}
}
}
// 06 원본 읽기 — 계산한 풍경을 바깥에 안 보이는 캔버스에 놓고 getImageData 로 다시 읽어 그 화소를 원본으로 쓴다
// 같은 페이지에서 그린 그림이라 캔버스가 오염되지 않아 읽기가 열린다. 다른 출처의 사진을 crossOrigin 허락 없이 그렸다면 여기서 SecurityError 가 난다
const photoEl = document.createElement('canvas');
photoEl.width = OW;
photoEl.height = OH;
const photoCtx = photoEl.getContext('2d', { willReadFrequently: true });
function loadPhoto(): void {
if (!photoCtx) { return; }
const img = photoCtx.createImageData(OW, OH);
for (let i = 0; i < OW * OH; i += 1) {
img.data[i * 4] = photo[i * 3] * 255;
img.data[i * 4 + 1] = photo[i * 3 + 1] * 255;
img.data[i * 4 + 2] = photo[i * 3 + 2] * 255;
img.data[i * 4 + 3] = 255;
}
photoCtx.putImageData(img, 0, 0);
try {
const px = photoCtx.getImageData(0, 0, OW, OH).data;
for (let i = 0; i < OW * OH; i += 1) {
photo[i * 3] = px[i * 4] / 255;
photo[i * 3 + 1] = px[i * 4 + 1] / 255;
photo[i * 3 + 2] = px[i * 4 + 2] / 255;
}
paintedReady = false;
} catch (err) {
// 오염된 캔버스 — 픽셀을 못 읽으면 계산해 둔 원본 그대로 쓴다
}
}
loadPhoto();
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>
);
}06 · 풍경 그림을 밝기 단계와 흔들린 경계의 물감 그림으로 바꾸는 이미지 수채화 효과 — 측정 누적면적 51.997% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
for (let x = 0; x < OW; x += 1) {
// ① 경계 흔들기 — 읽을 자리를 노이즈만큼 옮겨 색면 가장자리가 붓 자국처럼 울퉁불퉁해진다
const sx = clamp(Math.round(x + (noise2(x / 7, y / 7, SEED + 11) - 0.5) * 2 * EDGE_JITTER), 0, OW - 1);
const sy = clamp(Math.round(y + (noise2(x / 7 + 40, y / 7, SEED + 12) - 0.5) * 2 * EDGE_JITTER), 0, OH - 1);
const o = (y * OW + x) * 3;
// 잔 얼룩 고르기 — 옮긴 자리와 그 네 이웃의 평균을 읽는다
let r = 0;
let g = 0;
let b = 0;
for (let q = 0; q < 5; q += 1) {
const qx = clamp(sx + (q === 1 ? -1 : q === 2 ? 1 : 0), 0, OW - 1);
const qy = clamp(sy + (q === 3 ? -1 : q === 4 ? 1 : 0), 0, OH - 1);
const s = (qy * OW + qx) * 3;
r += photo[s] * 0.2;
g += photo[s + 1] * 0.2;
b += photo[s + 2] * 0.2;
}
// ② 색 단계 줄이기 — 밝기를 COLOR_LEVELS 단계로 반올림하고 세 채널에 같은 비율을 곱해 색상은 지킨다. 그다음 종이 색 쪽으로 PAPER_MIX 만큼
const l = r * 0.299 + g * 0.587 + b * 0.114;
const ratio = Math.round(clamp(l, 0, 1) * top) / top / Math.max(l, 0.02);
stepped[o] = lerp(clamp(r * ratio, 0, 1), pr, PAPER_MIX);
stepped[o + 1] = lerp(clamp(g * ratio, 0, 1), pg, PAPER_MIX);
stepped[o + 2] = lerp(clamp(b * ratio, 0, 1), pb, PAPER_MIX);
}
07젖은 색면에 맑은 물방울이 떨어져 꽃잎 테두리로 밀려나는 번짐
라벤더에서 분홍으로 넘어가는 바탕 색면을 0.5초에 칠한 뒤 아직 젖은 자리에 맑은 물방울 6개가 떨어지면, pushBloom 이 각도마다 노이즈로 다른 반지름 r(θ) 를 구하고 그 바깥 40% 띠 안의 안료를 방울 가운데에서 먼 쪽 이웃 칸으로 민다. 물이 0.12 미만인 칸에서는 밀지 않아 마른 자리의 방울은 꽃을 못 만들고, 봄 시즌 기획전 배너처럼 색면 위에 꽃잎 모양 얼룩을 몇 개 띄울 때 쓴다. 그리기 끝에 꽃 테두리 띠의 평균 진하기는 꽃 안의 3.4~3.9배이고, 안료 총량은 7,272.94 그대로다.

<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 포함 · 비밀번호 보기
const i = y * gw + x;
if (water[i] < wetMin) {
continue;
}
const dx = x + 0.5 - cx;
const dy = y + 0.5 - cy;
const dist = Math.hypot(dx, dy);
const rr = bloomRadius(d, grow, Math.atan2(dy, dx), petal) * k;
if (dist >= rr) {
continue;
}
// 방울 안은 맑은 물로 다시 적신다. 밀기는 앞머리 쪽 띠(반지름의 바깥 band 비율)에서만 — 가운데에는 옅은 안료가 남는다
water[i] = 1;
if (dist < rr * (1 - band)) {
continue;
}
const j = (y + Math.round(dy / dist)) * gw + (x + Math.round(dx / dist));
if (water[j] < wetMin) {
continue;
}
const move = field[i] * frac;
delta[i] -= move;
delta[j] += move;
08투명한 층을 덧칠할수록 빛을 곱으로 거르는 겹칠 혼색
판을 둘로 나눠 같은 덮임으로 파랑·노랑·장미 세 층을 칠하되, 왼쪽은 아래 glazeAbsorb 가 채널마다 × (1 − 양 × (1 − 반사율)) 로 빛을 거르고 오른쪽은 glazeAlpha 가 지금 색을 층 색 쪽으로 양만큼 끌어온다. 파랑 층 한 겹([180,201,221]) 위에 노랑 층을 얹으면 흡수 쪽은 180,187,153, 투명도 섞기 쪽은 206,203,159인데, 두 층 순서를 바꾸면 곱셈인 흡수는 그대로이고 투명도 섞기만 [181,193,183] 으로 달라진다. 미술 수업 자료의 색 섞기 안내처럼 두 방식을 나란히 보여 줄 자리에 맞고, 파랑 층만 1·2·3겹 흡수로 쌓으면 밝기가 197.0 → 159.8 → 130.4 로 내려간다.

<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);
}
// 바탕 다각형 — 가운데 (cx, cy), 반지름 rx·ry 근처에 꼭짓점 sides 개를 둘레로 고르게 놓되 반지름을 ±15% 흔든다
function basePolygon(cx, cy, rx, ry, sides, rng) {
const pts = [];
for (let i = 0; i < sides; i += 1) {
const a = TAU * i / sides;
const k = 0.85 + 0.3 * rng();
pts.push(cx + Math.cos(a) * rx * k, cy + Math.sin(a) * ry * k);
}
return pts;
}
// 변 쪼개 흔들기 — 각 변의 가운데 점을 (변 길이 × jitter × 0.5 × 종 모양 난수) 만큼 변의 법선 방향으로 밀어 꼭짓점 사이에 끼운다
// depth 번 되풀이하면 꼭짓점이 2^depth 배가 되고, 짧아진 변일수록 덜 밀려 굵은 굴곡 위에 잔 굴곡이 얹힌다
function deformPolygon(pts, depth, jitter, rng) {
let cur = pts;
for (let d = 0; d < depth; d += 1) {
const next = [];
const n = cur.length / 2;
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
const ax = cur[2 * i];
const ay = cur[2 * i + 1];
const bx = cur[2 * j];
const by = cur[2 * j + 1];
const len = Math.hypot(bx - ax, by - ay) || 1;
const push = normalRandom(rng) * jitter * len * 0.5;
next.push(ax, ay, (ax + bx) / 2 + (by - ay) / len * push, (ay + by) / 2 - (bx - ax) / len * push);
}
cur = next;
}
return cur;
}
// 스캔라인 채우기의 교차점 자리 — 한 줄에서 다각형 변과 만나는 x 를 모아 정렬한다 (다시 쓰는 버퍼)
const CROSS = new Float64Array(4096);
// 다각형 안쪽 칸(칸 가운데 기준)에 amount 를 더한다. 좌표는 판(480×300), 격자는 gw×gh
function fillPolygon(field, gw, gh, pts, amount) {
const n = pts.length / 2;
const kx = gw / W;
const ky = gh / H;
let top = gh;
let bottom = 0;
for (let i = 0; i < n; i += 1) {
top = Math.min(top, pts[2 * i + 1] * ky);
bottom = Math.max(bottom, pts[2 * i + 1] * ky);
}
const r0 = Math.max(0, Math.floor(top));
const r1 = Math.min(gh - 1, Math.ceil(bottom));
for (let row = r0; row <= r1; row += 1) {
const py = (row + 0.5) / ky;
let c = 0;
for (let i = 0; i < n && c < CROSS.length; i += 1) {
const j = (i + 1) % n;
const ay = pts[2 * i + 1];
const by = pts[2 * j + 1];
if ((ay <= py && by > py) || (by <= py && ay > py)) {
const ax = pts[2 * i];
const bx = pts[2 * j];
CROSS[c] = (ax + (py - ay) / (by - ay) * (bx - ax)) * kx;
c += 1;
}
}
// 삽입 정렬 — 한 줄의 교차점은 많아야 수십 개
for (let a = 1; a < c; a += 1) {
const v = CROSS[a];
let b = a - 1;
while (b >= 0 && CROSS[b] > v) {
CROSS[b + 1] = CROSS[b];
b -= 1;
}
CROSS[b + 1] = v;
}
for (let a = 0; a + 1 < c; a += 2) {
const x0 = Math.max(0, Math.ceil(CROSS[a] - 0.5));
const x1 = Math.min(gw - 1, Math.floor(CROSS[a + 1] - 0.5));
for (let x = x0; x <= x1; x += 1) {
field[row * gw + x] += amount;
}
}
}
}
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);
}
// 08 겹칠 혼색 — 같은 세 층(하늘색·버터색·분홍)을 왼쪽 판은 흡수(채널마다 반사율을 곱하기), 오른쪽 판은 단순 투명도 섞기로 쌓아 나란히 비교한다
// 한 층의 진하기 — 층이 덮은 자리에서 빛을 얼마나 거르는가(0~1)
const GLAZE_ALPHA = 0.35;
// 층 하나를 이루는 흔든 다각형 장 수 — 장마다 1/GLAZE_SHEETS 씩 덮어 가장자리가 옅은 얼룩 모양이 된다
const GLAZE_SHEETS = 8;
// 층 얼룩을 흔드는 폭과 횟수 — 01 과 같은 변 쪼개 흔들기
const GLAZE_JITTER = 0.4;
const GLAZE_DEPTH = 5;
// 붓이 층 하나를 왼쪽에서 오른쪽으로 다 칠하는 시간(초)
const LAYER_TIME = 1.0;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.0;
const PAINT_TIME = 3.6;
const HOLD_TIME = 2.4;
const WASH_TIME = 1.4;
const SEED = 4828;
// 판 하나의 폭(판 px)과 화소 폭 — 왼쪽 판 좌표로 층 모양을 한 번 칠하고 오른쪽 판은 같은 덮임을 옆으로 옮겨 읽는다
const PANEL_W = W / 2;
const PANEL_OW = OW / 2;
// 겹칠용 안료 — 한 층이 GLAZE_ALPHA 만큼만 거르므로 안료 자체는 진하게 두고, 화면에는 옅은 파스텔로 보인다
const GLAZE_BLUE = [52, 124, 222];
const GLAZE_YELLOW = [252, 204, 30];
const GLAZE_ROSE = [234, 96, 150];
const LAYERS = [
{ cx: 92, cy: 110, rx: 80, ry: 74, color: GLAZE_BLUE, start: 0.2 },
{ cx: 150, cy: 150, rx: 76, ry: 74, color: GLAZE_YELLOW, start: 1.3 },
{ cx: 104, cy: 212, rx: 74, ry: 62, color: GLAZE_ROSE, start: 2.4 },
];
const coverFields = LAYERS.map(function () { return new Float32Array(OW * OH); });
const sheetsDone = LAYERS.map(function () { return 0; });
let layerBases = [];
buildPaper(SEED, 1);
function reset() {
const rng = makeRng(SEED);
layerBases = LAYERS.map(function (l) {
return deformPolygon(basePolygon(l.cx, l.cy, l.rx, l.ry, 9, rng), 1, GLAZE_JITTER, rng);
});
for (let k = 0; k < LAYERS.length; k += 1) {
coverFields[k].fill(0);
sheetsDone[k] = 0;
}
}
function update(dt, t) {
for (let k = 0; k < LAYERS.length; k += 1) {
// 층 모양은 칠하기 시작할 때 한꺼번에 — 화면에는 붓 앞머리(render)가 지나간 자리만 보인다
const want = t >= LAYERS[k].start ? GLAZE_SHEETS : 0;
while (sheetsDone[k] < want) {
const sheet = deformPolygon(layerBases[k], GLAZE_DEPTH, GLAZE_JITTER, makeRng(SEED * 10 + k * 100 + sheetsDone[k]));
fillPolygon(coverFields[k], OW, OH, sheet, 1 / GLAZE_SHEETS);
sheetsDone[k] += 1;
}
}
}
// 흡수 — 층마다 채널별로 (1 − 진하기 × 덮임 × (1 − 반사율)) 을 곱한다. 파랑(빨강 빛을 거름) 위 노랑(파랑 빛을 거름)은 초록 빛만 남는다
// 결과는 mixR·mixG·mixB 에 — 화소마다 새 배열을 만들지 않으려고
let mixR = 0;
let mixG = 0;
let mixB = 0;
function glazeAbsorb(base, layers, amounts) {
let r = base[0];
let g = base[1];
let b = base[2];
for (let k = 0; k < layers.length; k += 1) {
const a = amounts[k];
r *= 1 - a * (1 - layers[k][0] / 255);
g *= 1 - a * (1 - layers[k][1] / 255);
b *= 1 - a * (1 - layers[k][2] / 255);
}
mixR = r;
mixG = g;
mixB = b;
}
// 단순 투명도 섞기 — 층마다 지금 색을 층 색 쪽으로 (진하기 × 덮임) 만큼 끌어온다. 위에 얹은 색이 아래 색을 덮어 회색 쪽으로 섞인다
function glazeAlpha(base, layers, amounts) {
let r = base[0];
let g = base[1];
let b = base[2];
for (let k = 0; k < layers.length; k += 1) {
const a = amounts[k];
r = lerp(r, layers[k][0], a);
g = lerp(g, layers[k][1], a);
b = lerp(b, layers[k][2], a);
}
mixR = r;
mixG = g;
mixB = b;
}
const layerColors = LAYERS.map(function (l) { return l.color; });
const amounts08 = LAYERS.map(function () { return 0; });
// 층마다 이번 줄의 붓 앞머리(판 px, 판 하나 기준)
const fronts08 = LAYERS.map(function () { return 0; });
// 붓 앞머리 — 층 얼룩의 왼쪽 끝에서 오른쪽 끝까지 LAYER_TIME 동안. 줄마다 노이즈로 흔들린다
function brushFront(k, t, py) {
const l = LAYERS[k];
const p = phaseOf(t, l.start, LAYER_TIME);
return lerp(l.cx - l.rx * 1.4, l.cx + l.rx * 1.4, p) + (noise2(py / 26, k * 5.3, SEED + 40) - 0.5) * 36 * (p > 0 && p < 1 ? 1 : 0);
}
function render(t) {
for (let y = 0; y < OH; y += 1) {
for (let k = 0; k < LAYERS.length; k += 1) {
fronts08[k] = brushFront(k, t, (y + 0.5) * H / OH);
}
for (let x = 0; x < OW; x += 1) {
const right = x >= PANEL_OW;
const px = ((right ? x - PANEL_OW : x) + 0.5) * W / OW;
const src = y * OW + (right ? x - PANEL_OW : x);
for (let k = 0; k < LAYERS.length; k += 1) {
amounts08[k] = GLAZE_ALPHA * Math.min(1, coverFields[k][src]) * smooth((fronts08[k] - px) / 10);
}
if (right) {
glazeAlpha(PAPER, layerColors, amounts08);
}
else {
glazeAbsorb(PAPER, layerColors, amounts08);
}
const i = y * OW + x;
const s = PAPER_SHADE[i] / 255;
OUT[i * 3] = mixR * s;
OUT[i * 3 + 1] = mixG * s;
OUT[i * 3 + 2] = mixB * s;
}
}
washAt(t, SEED);
}
// 08 그리기 — 두 판을 합성한 뒤 가운데 칸막이와 판 이름표를 얹는다
const LABEL_ABSORB = '반사율 곱하기';
const LABEL_ALPHA = '투명도 섞기';
function drawLabel(text, cx) {
ctx.font = '700 12px sans-serif';
const w = ctx.measureText(text).width + 18;
ctx.fillStyle = rgbaCss(INK, 0.9);
ctx.beginPath();
ctx.roundRect(cx - w / 2, 12, w, 22, 11);
ctx.fill();
ctx.fillStyle = rgbaCss(PAPER, 1);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, cx, 23);
}
function rgbaCss(c, a) {
return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')';
}
function draw() {
render(simTime);
present();
ctx.fillStyle = rgbaCss(INK, 0.55);
ctx.fillRect(PANEL_W - 1, 0, 2, H);
drawLabel(LABEL_ABSORB, PANEL_W / 2);
drawLabel(LABEL_ALPHA, PANEL_W + PANEL_W / 2);
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 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();
};
})();// GlazeAbsorbMix - vanilla 판(08-watercolor-glaze-absorb-mix) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./GlazeAbsorbMix.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function GlazeAbsorbMix() {
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);
}
// 바탕 다각형 — 가운데 (cx, cy), 반지름 rx·ry 근처에 꼭짓점 sides 개를 둘레로 고르게 놓되 반지름을 ±15% 흔든다
function basePolygon(cx: number, cy: number, rx: number, ry: number, sides: number, rng: () => number): number[] {
const pts: number[] = [];
for (let i = 0; i < sides; i += 1) {
const a = TAU * i / sides;
const k = 0.85 + 0.3 * rng();
pts.push(cx + Math.cos(a) * rx * k, cy + Math.sin(a) * ry * k);
}
return pts;
}
// 변 쪼개 흔들기 — 각 변의 가운데 점을 (변 길이 × jitter × 0.5 × 종 모양 난수) 만큼 변의 법선 방향으로 밀어 꼭짓점 사이에 끼운다
// depth 번 되풀이하면 꼭짓점이 2^depth 배가 되고, 짧아진 변일수록 덜 밀려 굵은 굴곡 위에 잔 굴곡이 얹힌다
function deformPolygon(pts: number[], depth: number, jitter: number, rng: () => number): number[] {
let cur = pts;
for (let d = 0; d < depth; d += 1) {
const next: number[] = [];
const n = cur.length / 2;
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
const ax = cur[2 * i];
const ay = cur[2 * i + 1];
const bx = cur[2 * j];
const by = cur[2 * j + 1];
const len = Math.hypot(bx - ax, by - ay) || 1;
const push = normalRandom(rng) * jitter * len * 0.5;
next.push(ax, ay, (ax + bx) / 2 + (by - ay) / len * push, (ay + by) / 2 - (bx - ax) / len * push);
}
cur = next;
}
return cur;
}
// 스캔라인 채우기의 교차점 자리 — 한 줄에서 다각형 변과 만나는 x 를 모아 정렬한다 (다시 쓰는 버퍼)
const CROSS = new Float64Array(4096);
// 다각형 안쪽 칸(칸 가운데 기준)에 amount 를 더한다. 좌표는 판(480×300), 격자는 gw×gh
function fillPolygon(field: Float32Array, gw: number, gh: number, pts: number[], amount: number): void {
const n = pts.length / 2;
const kx = gw / W;
const ky = gh / H;
let top = gh;
let bottom = 0;
for (let i = 0; i < n; i += 1) {
top = Math.min(top, pts[2 * i + 1] * ky);
bottom = Math.max(bottom, pts[2 * i + 1] * ky);
}
const r0 = Math.max(0, Math.floor(top));
const r1 = Math.min(gh - 1, Math.ceil(bottom));
for (let row = r0; row <= r1; row += 1) {
const py = (row + 0.5) / ky;
let c = 0;
for (let i = 0; i < n && c < CROSS.length; i += 1) {
const j = (i + 1) % n;
const ay = pts[2 * i + 1];
const by = pts[2 * j + 1];
if ((ay <= py && by > py) || (by <= py && ay > py)) {
const ax = pts[2 * i];
const bx = pts[2 * j];
CROSS[c] = (ax + (py - ay) / (by - ay) * (bx - ax)) * kx;
c += 1;
}
}
// 삽입 정렬 — 한 줄의 교차점은 많아야 수십 개
for (let a = 1; a < c; a += 1) {
const v = CROSS[a];
let b = a - 1;
while (b >= 0 && CROSS[b] > v) { CROSS[b + 1] = CROSS[b]; b -= 1; }
CROSS[b + 1] = v;
}
for (let a = 0; a + 1 < c; a += 2) {
const x0 = Math.max(0, Math.ceil(CROSS[a] - 0.5));
const x1 = Math.min(gw - 1, Math.floor(CROSS[a + 1] - 0.5));
for (let x = x0; x <= x1; x += 1) { field[row * gw + x] += amount; }
}
}
}
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);
}
// 08 겹칠 혼색 — 같은 세 층(하늘색·버터색·분홍)을 왼쪽 판은 흡수(채널마다 반사율을 곱하기), 오른쪽 판은 단순 투명도 섞기로 쌓아 나란히 비교한다
// 한 층의 진하기 — 층이 덮은 자리에서 빛을 얼마나 거르는가(0~1)
const GLAZE_ALPHA = 0.35;
// 층 하나를 이루는 흔든 다각형 장 수 — 장마다 1/GLAZE_SHEETS 씩 덮어 가장자리가 옅은 얼룩 모양이 된다
const GLAZE_SHEETS = 8;
// 층 얼룩을 흔드는 폭과 횟수 — 01 과 같은 변 쪼개 흔들기
const GLAZE_JITTER = 0.4;
const GLAZE_DEPTH = 5;
// 붓이 층 하나를 왼쪽에서 오른쪽으로 다 칠하는 시간(초)
const LAYER_TIME = 1.0;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.0;
const PAINT_TIME = 3.6;
const HOLD_TIME = 2.4;
const WASH_TIME = 1.4;
const SEED = 4828;
// 판 하나의 폭(판 px)과 화소 폭 — 왼쪽 판 좌표로 층 모양을 한 번 칠하고 오른쪽 판은 같은 덮임을 옆으로 옮겨 읽는다
const PANEL_W = W / 2;
const PANEL_OW = OW / 2;
// 겹칠용 안료 — 한 층이 GLAZE_ALPHA 만큼만 거르므로 안료 자체는 진하게 두고, 화면에는 옅은 파스텔로 보인다
const GLAZE_BLUE: RGB = [52, 124, 222];
const GLAZE_YELLOW: RGB = [252, 204, 30];
const GLAZE_ROSE: RGB = [234, 96, 150];
type Layer = { cx: number; cy: number; rx: number; ry: number; color: RGB; start: number };
const LAYERS: Layer[] = [
{ cx: 92, cy: 110, rx: 80, ry: 74, color: GLAZE_BLUE, start: 0.2 },
{ cx: 150, cy: 150, rx: 76, ry: 74, color: GLAZE_YELLOW, start: 1.3 },
{ cx: 104, cy: 212, rx: 74, ry: 62, color: GLAZE_ROSE, start: 2.4 },
];
const coverFields: Float32Array[] = LAYERS.map(function (): Float32Array { return new Float32Array(OW * OH); });
const sheetsDone: number[] = LAYERS.map(function (): number { return 0; });
let layerBases: number[][] = [];
buildPaper(SEED, 1);
function reset(): void {
const rng = makeRng(SEED);
layerBases = LAYERS.map(function (l: Layer): number[] {
return deformPolygon(basePolygon(l.cx, l.cy, l.rx, l.ry, 9, rng), 1, GLAZE_JITTER, rng);
});
for (let k = 0; k < LAYERS.length; k += 1) {
coverFields[k].fill(0);
sheetsDone[k] = 0;
}
}
function update(dt: number, t: number): void {
for (let k = 0; k < LAYERS.length; k += 1) {
// 층 모양은 칠하기 시작할 때 한꺼번에 — 화면에는 붓 앞머리(render)가 지나간 자리만 보인다
const want = t >= LAYERS[k].start ? GLAZE_SHEETS : 0;
while (sheetsDone[k] < want) {
const sheet = deformPolygon(layerBases[k], GLAZE_DEPTH, GLAZE_JITTER, makeRng(SEED * 10 + k * 100 + sheetsDone[k]));
fillPolygon(coverFields[k], OW, OH, sheet, 1 / GLAZE_SHEETS);
sheetsDone[k] += 1;
}
}
}
// 흡수 — 층마다 채널별로 (1 − 진하기 × 덮임 × (1 − 반사율)) 을 곱한다. 파랑(빨강 빛을 거름) 위 노랑(파랑 빛을 거름)은 초록 빛만 남는다
// 결과는 mixR·mixG·mixB 에 — 화소마다 새 배열을 만들지 않으려고
let mixR = 0;
let mixG = 0;
let mixB = 0;
function glazeAbsorb(base: RGB, layers: RGB[], amounts: number[]): void {
let r = base[0];
let g = base[1];
let b = base[2];
for (let k = 0; k < layers.length; k += 1) {
const a = amounts[k];
r *= 1 - a * (1 - layers[k][0] / 255);
g *= 1 - a * (1 - layers[k][1] / 255);
b *= 1 - a * (1 - layers[k][2] / 255);
}
mixR = r;
mixG = g;
mixB = b;
}
// 단순 투명도 섞기 — 층마다 지금 색을 층 색 쪽으로 (진하기 × 덮임) 만큼 끌어온다. 위에 얹은 색이 아래 색을 덮어 회색 쪽으로 섞인다
function glazeAlpha(base: RGB, layers: RGB[], amounts: number[]): void {
let r = base[0];
let g = base[1];
let b = base[2];
for (let k = 0; k < layers.length; k += 1) {
const a = amounts[k];
r = lerp(r, layers[k][0], a);
g = lerp(g, layers[k][1], a);
b = lerp(b, layers[k][2], a);
}
mixR = r;
mixG = g;
mixB = b;
}
const layerColors: RGB[] = LAYERS.map(function (l: Layer): RGB { return l.color; });
const amounts08: number[] = LAYERS.map(function (): number { return 0; });
// 층마다 이번 줄의 붓 앞머리(판 px, 판 하나 기준)
const fronts08: number[] = LAYERS.map(function (): number { return 0; });
// 붓 앞머리 — 층 얼룩의 왼쪽 끝에서 오른쪽 끝까지 LAYER_TIME 동안. 줄마다 노이즈로 흔들린다
function brushFront(k: number, t: number, py: number): number {
const l = LAYERS[k];
const p = phaseOf(t, l.start, LAYER_TIME);
return lerp(l.cx - l.rx * 1.4, l.cx + l.rx * 1.4, p) + (noise2(py / 26, k * 5.3, SEED + 40) - 0.5) * 36 * (p > 0 && p < 1 ? 1 : 0);
}
function render(t: number): void {
for (let y = 0; y < OH; y += 1) {
for (let k = 0; k < LAYERS.length; k += 1) { fronts08[k] = brushFront(k, t, (y + 0.5) * H / OH); }
for (let x = 0; x < OW; x += 1) {
const right = x >= PANEL_OW;
const px = ((right ? x - PANEL_OW : x) + 0.5) * W / OW;
const src = y * OW + (right ? x - PANEL_OW : x);
for (let k = 0; k < LAYERS.length; k += 1) { amounts08[k] = GLAZE_ALPHA * Math.min(1, coverFields[k][src]) * smooth((fronts08[k] - px) / 10); }
if (right) { glazeAlpha(PAPER, layerColors, amounts08); } else { glazeAbsorb(PAPER, layerColors, amounts08); }
const i = y * OW + x;
const s = PAPER_SHADE[i] / 255;
OUT[i * 3] = mixR * s;
OUT[i * 3 + 1] = mixG * s;
OUT[i * 3 + 2] = mixB * s;
}
}
washAt(t, SEED);
}
// 08 그리기 — 두 판을 합성한 뒤 가운데 칸막이와 판 이름표를 얹는다
const LABEL_ABSORB = '반사율 곱하기';
const LABEL_ALPHA = '투명도 섞기';
function drawLabel(text: string, cx: number): void {
ctx.font = '700 12px sans-serif';
const w = ctx.measureText(text).width + 18;
ctx.fillStyle = rgbaCss(INK, 0.9);
ctx.beginPath();
ctx.roundRect(cx - w / 2, 12, w, 22, 11);
ctx.fill();
ctx.fillStyle = rgbaCss(PAPER, 1);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, cx, 23);
}
function rgbaCss(c: RGB, a: number): string {
return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')';
}
function draw(): void {
render(simTime);
present();
ctx.fillStyle = rgbaCss(INK, 0.55);
ctx.fillRect(PANEL_W - 1, 0, 2, H);
drawLabel(LABEL_ABSORB, PANEL_W / 2);
drawLabel(LABEL_ALPHA, PANEL_W + PANEL_W / 2);
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 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>
);
}08 · 투명한 층을 덧칠할수록 빛을 곱으로 거르는 겹칠 혼색 — 측정 누적면적 28.826% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function glazeAbsorb(base, layers, amounts) {
let r = base[0];
let g = base[1];
let b = base[2];
for (let k = 0; k < layers.length; k += 1) {
const a = amounts[k];
r *= 1 - a * (1 - layers[k][0] / 255);
g *= 1 - a * (1 - layers[k][1] / 255);
b *= 1 - a * (1 - layers[k][2] / 255);
}
mixR = r;
mixG = g;
mixB = b;
}
09하늘 번짐부터 마른 붓 언덕까지 차례로 그려지는 수채 풍경 첫 화면
종이 결 위에 0.2초 하늘 번짐, 1.4초 먼 산 겹칠, 2.5초 마른 붓 언덕, 3.5초 물방울 꽃이 차례로 얹히고, 아래 update 가운데 토막처럼 07 의 pushBloom·03 의 wetDiffuse·01 의 deformPolygon 을 같은 하늘 격자와 능선에 다시 부른다. 왼쪽 위 상태 줄이 지금 단계 이름을 보여 주고, 다 그린 뒤 2.2초 머물렀다가 1.3초에 씻겨 같은 시드의 같은 풍경으로 다시 그려지므로 갤러리 웹사이트 첫 화면에 그대로 얹을 수 있다. 하늘 안료 총량은 번짐과 물방울을 다 거친 뒤에도 6,591.07 그대로이고, 언덕 세 획은 끝에 남은 물이 0 이라 붓털이 모두 끊긴 채 끝난다.

<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>
<p class="wc__status" data-role="status" aria-live="off">종이 결 깔기</p>
<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);
}
// 바탕 다각형 — 가운데 (cx, cy), 반지름 rx·ry 근처에 꼭짓점 sides 개를 둘레로 고르게 놓되 반지름을 ±15% 흔든다
function basePolygon(cx, cy, rx, ry, sides, rng) {
const pts = [];
for (let i = 0; i < sides; i += 1) {
const a = TAU * i / sides;
const k = 0.85 + 0.3 * rng();
pts.push(cx + Math.cos(a) * rx * k, cy + Math.sin(a) * ry * k);
}
return pts;
}
// 변 쪼개 흔들기 — 각 변의 가운데 점을 (변 길이 × jitter × 0.5 × 종 모양 난수) 만큼 변의 법선 방향으로 밀어 꼭짓점 사이에 끼운다
// depth 번 되풀이하면 꼭짓점이 2^depth 배가 되고, 짧아진 변일수록 덜 밀려 굵은 굴곡 위에 잔 굴곡이 얹힌다
function deformPolygon(pts, depth, jitter, rng) {
let cur = pts;
for (let d = 0; d < depth; d += 1) {
const next = [];
const n = cur.length / 2;
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
const ax = cur[2 * i];
const ay = cur[2 * i + 1];
const bx = cur[2 * j];
const by = cur[2 * j + 1];
const len = Math.hypot(bx - ax, by - ay) || 1;
const push = normalRandom(rng) * jitter * len * 0.5;
next.push(ax, ay, (ax + bx) / 2 + (by - ay) / len * push, (ay + by) / 2 - (bx - ax) / len * push);
}
cur = next;
}
return cur;
}
// 스캔라인 채우기의 교차점 자리 — 한 줄에서 다각형 변과 만나는 x 를 모아 정렬한다 (다시 쓰는 버퍼)
const CROSS = new Float64Array(4096);
// 다각형 안쪽 칸(칸 가운데 기준)에 amount 를 더한다. 좌표는 판(480×300), 격자는 gw×gh
function fillPolygon(field, gw, gh, pts, amount) {
const n = pts.length / 2;
const kx = gw / W;
const ky = gh / H;
let top = gh;
let bottom = 0;
for (let i = 0; i < n; i += 1) {
top = Math.min(top, pts[2 * i + 1] * ky);
bottom = Math.max(bottom, pts[2 * i + 1] * ky);
}
const r0 = Math.max(0, Math.floor(top));
const r1 = Math.min(gh - 1, Math.ceil(bottom));
for (let row = r0; row <= r1; row += 1) {
const py = (row + 0.5) / ky;
let c = 0;
for (let i = 0; i < n && c < CROSS.length; i += 1) {
const j = (i + 1) % n;
const ay = pts[2 * i + 1];
const by = pts[2 * j + 1];
if ((ay <= py && by > py) || (by <= py && ay > py)) {
const ax = pts[2 * i];
const bx = pts[2 * j];
CROSS[c] = (ax + (py - ay) / (by - ay) * (bx - ax)) * kx;
c += 1;
}
}
// 삽입 정렬 — 한 줄의 교차점은 많아야 수십 개
for (let a = 1; a < c; a += 1) {
const v = CROSS[a];
let b = a - 1;
while (b >= 0 && CROSS[b] > v) {
CROSS[b + 1] = CROSS[b];
b -= 1;
}
CROSS[b + 1] = v;
}
for (let a = 0; a + 1 < c; a += 2) {
const x0 = Math.max(0, Math.ceil(CROSS[a] - 0.5));
const x1 = Math.min(gw - 1, Math.floor(CROSS[a + 1] - 0.5));
for (let x = x0; x <= x1; x += 1) {
field[row * gw + x] += amount;
}
}
}
}
// 젖은 칸 번짐 한 걸음 — 칸마다 이웃 네 칸의 평균 쪽으로 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);
}
}
}
// 곡선 위 한 점 — u 는 0(시작)~1(끝). 결과는 curveX·curveY 에 (새 배열을 만들지 않으려고)
let curveX = 0;
let curveY = 0;
function curveAt(c, u) {
const v = 1 - u;
const a = v * v * v;
const b = 3 * v * v * u;
const d = 3 * v * u * u;
const e = u * u * u;
curveX = a * c[0] + b * c[2] + d * c[4] + e * c[6];
curveY = a * c[1] + b * c[3] + d * c[5] + e * c[7];
}
function makeBristles(count, thirstMin, thirstMax, rng) {
const out = [];
for (let i = 0; i < count; i += 1) {
out.push({
offset: -1 + 2 * (i + 0.5) / count + (rng() - 0.5) * 0.6 / count,
thirst: thirstMin + (thirstMax - thirstMin) * rng(),
seed: Math.floor(rng() * 100000),
});
}
return out;
}
// 점 찍기 — 진하기 격자(gw×gh)에 판 좌표 (x, y) 둘레 반지름 radius(판 px) 의 부드러운 원으로 amount 를 더한다
function stampDot(field, gw, gh, x, y, radius, amount) {
const k = gw / W;
const gx = x * k;
const gy = y * k;
const r = Math.max(radius * k, 0.5);
const x0 = Math.max(0, Math.floor(gx - r - 1));
const x1 = Math.min(gw - 1, Math.ceil(gx + r + 1));
const y0 = Math.max(0, Math.floor(gy - r - 1));
const y1 = Math.min(gh - 1, Math.ceil(gy + r + 1));
for (let yy = y0; yy <= y1; yy += 1) {
for (let xx = x0; xx <= x1; xx += 1) {
const d = Math.hypot(xx + 0.5 - gx, yy + 0.5 - gy);
const cover = clamp(r + 0.5 - d, 0, 1);
if (cover > 0) {
field[yy * gw + xx] += amount * cover;
}
}
}
}
// 붓 한 토막 — 곡선의 u0 → u1 구간을 판 0.8px 걸음으로 따라가며 가닥마다 점을 찍는다
// 물(water)이 그 가닥의 문턱 + 노이즈 흔들림보다 적으면 그 자리에서는 찍지 않는다 → 문턱이 높은 가닥부터 끊긴다
function brushSegment(field, gw, gh, c, u0, u1, water, bristles, halfWidth, dotRadius, ink, wobble) {
curveAt(c, u0);
let px = curveX;
let py = curveY;
curveAt(c, u1);
const len = Math.hypot(curveX - px, curveY - py);
const steps = Math.max(1, Math.ceil(len / 0.8));
let drawn = 0;
for (let s = 1; s <= steps; s += 1) {
const u = lerp(u0, u1, s / steps);
curveAt(c, u);
const x = curveX;
const y = curveY;
const tl = Math.hypot(x - px, y - py) || 1;
const nx = -(y - py) / tl;
const ny = (x - px) / tl;
px = x;
py = y;
for (let b = 0; b < bristles.length; b += 1) {
const br = bristles[b];
const flicker = (valueNoise(u * 40, b * 3.7, br.seed) - 0.5) * 0.16;
if (water < br.thirst + flicker) {
continue;
}
const off = br.offset * halfWidth + (valueNoise(u * 9, b * 1.3, br.seed + 1) - 0.5) * wobble;
stampDot(field, gw, gh, x + nx * off, y + ny * off, dotRadius, ink);
drawn += 1;
}
}
return drawn;
}
// 꽃잎 테두리 반지름 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);
}
// 09 수채 풍경 첫 화면 — 종이 결 → 젖은 하늘 번짐(03) → 먼 산 겹칠(01·08) → 앞 언덕 마른 붓(05) → 하늘의 물방울 꽃(07) 순서로, 같은 시드에서 같은 풍경을 그린다
// 하늘 번짐 빠르기와 되풀이 횟수 — 03 과 같은 젖은 칸 번짐
const SKY_DIFFUSE = 0.3;
const SKY_SUBSTEPS = 8;
// 물을 바르며 함께 까는 하늘 바탕 진하기 — 위는 하늘색, 지평선 쪽은 분홍
const SKY_BASE = 0.42;
// 하늘 물이 마르는 빠르기 — 물방울 꽃이 피는 BLOOM_AT(3.5초) 뒤까지 젖어 있도록 느리게
const SKY_EVAPORATION = 0.09;
// 산 한 겹의 진하기와 겹 수 — 01 의 흔든 다각형을 옅게 쌓는다
const RIDGE_ALPHA = 0.075;
const RIDGE_SHEETS = 9;
const RIDGE_JITTER = 0.32;
// 언덕 붓질 — 05 와 같은 붓털 계산
const HILL_BRISTLES = 18;
const HILL_DRY = 0.0019;
// 물방울 꽃 — 07 과 같은 밀기
const HERO_PETAL = 0.36;
const HERO_PUSH = 0.18;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.4;
const PAINT_TIME = 4.6;
const HOLD_TIME = 2.2;
const WASH_TIME = 1.3;
const SEED = 4829;
// 장면 순서(초) — 상태 줄 글자도 이 시각으로 바뀐다
const SKY_AT = 0.2;
const RIDGE_AT = 1.4;
const HILL_AT = 2.5;
const BLOOM_AT = 3.5;
const STAGE_SKY = '하늘 번짐';
const STAGE_RIDGE = '먼 산 겹칠';
const STAGE_HILL = '마른 붓 언덕';
const STAGE_BLOOM = '물방울 꽃';
const STAGE_DONE = '완성';
const STAGE_WASH = '물로 씻어 내는 중';
const STAGE_PAPER = '종이 결 깔기';
function stageName(t) {
if (t >= PAINT_TIME + HOLD_TIME) {
return STAGE_WASH;
}
if (t >= PAINT_TIME) {
return STAGE_DONE;
}
if (t >= BLOOM_AT) {
return STAGE_BLOOM;
}
if (t >= HILL_AT) {
return STAGE_HILL;
}
if (t >= RIDGE_AT) {
return STAGE_RIDGE;
}
if (t >= SKY_AT) {
return STAGE_SKY;
}
return STAGE_PAPER;
}
// 하늘 — 물 격자 하나와 안료 셋(하늘·라벤더·분홍)
const skyWater = new Float32Array(GW * GH);
const skyBlue = new Float32Array(GW * GH);
const skyLilac = new Float32Array(GW * GH);
const skyRose = new Float32Array(GW * GH);
const skyTmp = new Float32Array(GW * GH);
const skyFields = [skyBlue, skyLilac, skyRose];
// 하늘 바탕을 이미 깐 칸 — 한 칸에 한 번만
const skyLaid = new Uint8Array(GW * GH);
// 산 두 줄(먼 쪽 인디고, 가까운 쪽 세이지)
const ridgeFar = new Float32Array(GW * GH);
const ridgeNear = new Float32Array(GW * GH);
// 언덕 붓질 — 가는 붓털이 보이게 화소 격자 크기로
const hillField = new Float32Array(OW * OH);
const hillDeep = new Float32Array(OW * OH);
const gridFields = [skyBlue, skyLilac, skyRose, ridgeFar, ridgeNear];
const gridPigs = [pigment(SKY), pigment(LAVENDER), pigment(PINK), pigment(INDIGO), pigment(SAGE)];
const gridGran = [0, 0, 0, 0.35, 0.3];
const brushFields = [hillField, hillDeep];
const brushPigs = [pigment(SAGE), pigment(SKY)];
const brushGran = [0.3, 0.2];
const SKY_DABS = [
{ x: 70, y: 40, at: 0.35, field: 0, amount: 0.9 },
{ x: 400, y: 36, at: 0.45, field: 0, amount: 0.9 },
{ x: 236, y: 30, at: 0.55, field: 0, amount: 0.8 },
{ x: 150, y: 86, at: 0.7, field: 1, amount: 1.1 },
{ x: 340, y: 92, at: 0.8, field: 1, amount: 1.1 },
{ x: 60, y: 128, at: 0.95, field: 2, amount: 1.0 },
{ x: 250, y: 126, at: 1.05, field: 2, amount: 1.0 },
{ x: 430, y: 120, at: 1.15, field: 2, amount: 1.0 },
];
const RIDGES = [
{ base: 116, amp: 40, field: ridgeFar, start: RIDGE_AT },
{ base: 180, amp: 30, field: ridgeNear, start: RIDGE_AT + 0.5 },
];
const HILL_STROKES = [
{ curve: [-10, 232, 140, 204, 320, 262, 490, 222], start: HILL_AT, duration: 0.6, field: 0 },
{ curve: [-10, 262, 160, 240, 300, 288, 490, 252], start: HILL_AT + 0.3, duration: 0.6, field: 0 },
{ curve: [-10, 290, 150, 270, 330, 304, 490, 280], start: HILL_AT + 0.6, duration: 0.55, field: 1 },
];
const HERO_DROPS = [
{ x: 118, y: 62, r: 30, at: BLOOM_AT, seed: SEED + 1 },
{ x: 346, y: 54, r: 26, at: BLOOM_AT + 0.35, seed: SEED + 2 },
];
const hillU = HILL_STROKES.map(function () { return 0; });
const hillWater = HILL_STROKES.map(function () { return 1; });
let hillBristles = [];
let dabsDone = 0;
const ridgeSheets = RIDGES.map(function () { return 0; });
let ridgeShapes = [];
const hillLen = HILL_STROKES.map(function (s) {
let len = 0;
curveAt(s.curve, 0);
let x = curveX;
let y = curveY;
for (let i = 1; i <= 100; i += 1) {
curveAt(s.curve, i / 100);
len += Math.hypot(curveX - x, curveY - y);
x = curveX;
y = curveY;
}
return len;
});
buildPaper(SEED, 1.3);
// 산등성이 다각형 — 판 왼쪽 바깥에서 오른쪽 바깥까지 노이즈로 오르내리는 능선, 아래는 판 바닥까지 닫는다
function ridgePolygon(r, k) {
const pts = [];
for (let i = 0; i <= 12; i += 1) {
const x = -20 + i * 520 / 12;
pts.push(x, r.base - r.amp * (noise2(x / 70, k * 4.2, SEED + 30) - 0.5) * 4);
}
pts.push(500, 310, -20, 310);
return pts;
}
function reset() {
const rng = makeRng(SEED);
skyWater.fill(0);
skyBlue.fill(0);
skyLilac.fill(0);
skyRose.fill(0);
skyLaid.fill(0);
ridgeFar.fill(0);
ridgeNear.fill(0);
hillField.fill(0);
hillDeep.fill(0);
dabsDone = 0;
ridgeShapes = RIDGES.map(function (r, k) { return ridgePolygon(r, k); });
for (let k = 0; k < RIDGES.length; k += 1) {
ridgeSheets[k] = 0;
}
hillBristles = HILL_STROKES.map(function () { return makeBristles(HILL_BRISTLES, 0.05, 0.6, rng); });
for (let k = 0; k < HILL_STROKES.length; k += 1) {
hillU[k] = 0;
hillWater[k] = 1;
}
}
function update(dt, t) {
// ① 하늘 — 위에서 아래로 맑은 물을 바르고(지평선 아래는 마른 채), 물감을 찍어 젖은 칸으로만 번지게 한다
const wetLine = phaseOf(t, SKY_AT, 0.3) * 170;
const dried = Math.max(0, t - SKY_AT - 0.3) * SKY_EVAPORATION;
for (let y = 0; y < GH; y += 1) {
const py = (y + 0.5) * H / GH;
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
const horizon = 150 + (noise2(x / 12, 1.7, SEED + 3) - 0.5) * 20;
const w = py < wetLine && py < horizon ? clamp((horizon - py) / 12, 0, 1) : 0;
skyWater[i] = Math.max(0, w - dried);
if (w > 0 && !skyLaid[i]) {
skyLaid[i] = 1;
const v = py / 150;
skyBlue[i] += SKY_BASE * (1 - v) * w;
skyRose[i] += SKY_BASE * 0.8 * v * v * w;
}
}
}
while (dabsDone < SKY_DABS.length && t >= SKY_DABS[dabsDone].at) {
const d = SKY_DABS[dabsDone];
touchPigment(skyFields[d.field], skyWater, GW, GH, d.x, d.y, 30, d.amount);
dabsDone += 1;
}
// ⑤ 물방울 꽃 — 아직 젖은 하늘에 맑은 방울을 떨어뜨려 안료를 꽃잎 테두리로 민다
for (let k = 0; k < HERO_DROPS.length; k += 1) {
const d = HERO_DROPS[k];
if (t < d.at) {
continue;
}
const grow = 1 - Math.exp(-(t - d.at) / 0.3);
for (let f = 0; f < 3; f += 1) {
pushBloom(skyFields[f], skyWater, GW, GH, d, grow, HERO_PETAL, HERO_PUSH, 0.4, 0.1, skyTmp);
}
}
for (let s = 0; s < SKY_SUBSTEPS; s += 1) {
for (let f = 0; f < 3; f += 1) {
wetDiffuse(skyFields[f], skyWater, GW, GH, SKY_DIFFUSE, skyTmp);
}
}
// ② 먼 산 겹칠 — 능선 다각형을 장마다 다시 흔들어 옅게 쌓는다
for (let k = 0; k < RIDGES.length; k += 1) {
const want = Math.floor(RIDGE_SHEETS * phaseOf(t, RIDGES[k].start, 0.8));
while (ridgeSheets[k] < want) {
const sheet = deformPolygon(ridgeShapes[k], 4, RIDGE_JITTER, makeRng(SEED * 10 + k * 100 + ridgeSheets[k]));
fillPolygon(RIDGES[k].field, GW, GH, sheet, RIDGE_ALPHA);
ridgeSheets[k] += 1;
}
}
// ③ 앞 언덕 마른 붓 — 빨라질수록 물이 줄어 붓털이 갈라진다
for (let k = 0; k < HILL_STROKES.length; k += 1) {
const s = HILL_STROKES[k];
if (t < s.start || hillU[k] >= 1) {
continue;
}
const u = Math.pow(phaseOf(t, s.start, s.duration), 1.6);
const speed = (u - hillU[k]) * hillLen[k] / dt;
hillWater[k] = Math.max(0, hillWater[k] - (0.2 + HILL_DRY * speed) * dt);
const w = hillWater[k];
brushSegment(brushFields[s.field], OW, OH, s.curve, hillU[k], u, w, hillBristles[k], 26, lerp(0.9, 4, w), 0.12 * (0.5 + 0.5 * w), 3);
hillU[k] = u;
}
}
function render(t) {
compose(gridFields, GW, GH, gridPigs, gridGran);
compose(brushFields, OW, OH, brushPigs, brushGran, true);
washAt(t, SEED);
}
// 09 그리기 — 풍경을 합성해 놓고, 지금 그리는 단계를 상태 줄 글자로 알린다(바뀔 때만 고친다)
let shownStage = '';
function draw() {
render(simTime);
present();
const stage = stageName(simTime);
if (statusEl && stage !== shownStage) {
statusEl.textContent = stage;
shownStage = stage;
}
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 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();
};
})();// HeroLandscapePaintIn - vanilla 판(09-watercolor-hero-landscape-paint-in) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./HeroLandscapePaintIn.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HeroLandscapePaintIn() {
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);
}
// 바탕 다각형 — 가운데 (cx, cy), 반지름 rx·ry 근처에 꼭짓점 sides 개를 둘레로 고르게 놓되 반지름을 ±15% 흔든다
function basePolygon(cx: number, cy: number, rx: number, ry: number, sides: number, rng: () => number): number[] {
const pts: number[] = [];
for (let i = 0; i < sides; i += 1) {
const a = TAU * i / sides;
const k = 0.85 + 0.3 * rng();
pts.push(cx + Math.cos(a) * rx * k, cy + Math.sin(a) * ry * k);
}
return pts;
}
// 변 쪼개 흔들기 — 각 변의 가운데 점을 (변 길이 × jitter × 0.5 × 종 모양 난수) 만큼 변의 법선 방향으로 밀어 꼭짓점 사이에 끼운다
// depth 번 되풀이하면 꼭짓점이 2^depth 배가 되고, 짧아진 변일수록 덜 밀려 굵은 굴곡 위에 잔 굴곡이 얹힌다
function deformPolygon(pts: number[], depth: number, jitter: number, rng: () => number): number[] {
let cur = pts;
for (let d = 0; d < depth; d += 1) {
const next: number[] = [];
const n = cur.length / 2;
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
const ax = cur[2 * i];
const ay = cur[2 * i + 1];
const bx = cur[2 * j];
const by = cur[2 * j + 1];
const len = Math.hypot(bx - ax, by - ay) || 1;
const push = normalRandom(rng) * jitter * len * 0.5;
next.push(ax, ay, (ax + bx) / 2 + (by - ay) / len * push, (ay + by) / 2 - (bx - ax) / len * push);
}
cur = next;
}
return cur;
}
// 스캔라인 채우기의 교차점 자리 — 한 줄에서 다각형 변과 만나는 x 를 모아 정렬한다 (다시 쓰는 버퍼)
const CROSS = new Float64Array(4096);
// 다각형 안쪽 칸(칸 가운데 기준)에 amount 를 더한다. 좌표는 판(480×300), 격자는 gw×gh
function fillPolygon(field: Float32Array, gw: number, gh: number, pts: number[], amount: number): void {
const n = pts.length / 2;
const kx = gw / W;
const ky = gh / H;
let top = gh;
let bottom = 0;
for (let i = 0; i < n; i += 1) {
top = Math.min(top, pts[2 * i + 1] * ky);
bottom = Math.max(bottom, pts[2 * i + 1] * ky);
}
const r0 = Math.max(0, Math.floor(top));
const r1 = Math.min(gh - 1, Math.ceil(bottom));
for (let row = r0; row <= r1; row += 1) {
const py = (row + 0.5) / ky;
let c = 0;
for (let i = 0; i < n && c < CROSS.length; i += 1) {
const j = (i + 1) % n;
const ay = pts[2 * i + 1];
const by = pts[2 * j + 1];
if ((ay <= py && by > py) || (by <= py && ay > py)) {
const ax = pts[2 * i];
const bx = pts[2 * j];
CROSS[c] = (ax + (py - ay) / (by - ay) * (bx - ax)) * kx;
c += 1;
}
}
// 삽입 정렬 — 한 줄의 교차점은 많아야 수십 개
for (let a = 1; a < c; a += 1) {
const v = CROSS[a];
let b = a - 1;
while (b >= 0 && CROSS[b] > v) { CROSS[b + 1] = CROSS[b]; b -= 1; }
CROSS[b + 1] = v;
}
for (let a = 0; a + 1 < c; a += 2) {
const x0 = Math.max(0, Math.ceil(CROSS[a] - 0.5));
const x1 = Math.min(gw - 1, Math.floor(CROSS[a + 1] - 0.5));
for (let x = x0; x <= x1; x += 1) { field[row * gw + x] += amount; }
}
}
}
// 젖은 칸 번짐 한 걸음 — 칸마다 이웃 네 칸의 평균 쪽으로 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);
}
}
}
// 제어점 두 개로 휘는 3차 곡선 — 시작점·제어점 1·제어점 2·끝점의 x, y 여덟 개. 곡선은 두 제어점 쪽으로 끌려가며 휜다
type Curve = number[];
// 곡선 위 한 점 — u 는 0(시작)~1(끝). 결과는 curveX·curveY 에 (새 배열을 만들지 않으려고)
let curveX = 0;
let curveY = 0;
function curveAt(c: Curve, u: number): void {
const v = 1 - u;
const a = v * v * v;
const b = 3 * v * v * u;
const d = 3 * v * u * u;
const e = u * u * u;
curveX = a * c[0] + b * c[2] + d * c[4] + e * c[6];
curveY = a * c[1] + b * c[3] + d * c[5] + e * c[7];
}
// 붓털 하나 — 붓 가운데에서 떨어진 자리(−1~1), 물을 얼마나 머금어야 그리는지(문턱), 흔들림 노이즈 시드
type Bristle = { offset: number; thirst: number; seed: number };
function makeBristles(count: number, thirstMin: number, thirstMax: number, rng: () => number): Bristle[] {
const out: Bristle[] = [];
for (let i = 0; i < count; i += 1) {
out.push({
offset: -1 + 2 * (i + 0.5) / count + (rng() - 0.5) * 0.6 / count,
thirst: thirstMin + (thirstMax - thirstMin) * rng(),
seed: Math.floor(rng() * 100000),
});
}
return out;
}
// 점 찍기 — 진하기 격자(gw×gh)에 판 좌표 (x, y) 둘레 반지름 radius(판 px) 의 부드러운 원으로 amount 를 더한다
function stampDot(field: Float32Array, gw: number, gh: number, x: number, y: number, radius: number, amount: number): void {
const k = gw / W;
const gx = x * k;
const gy = y * k;
const r = Math.max(radius * k, 0.5);
const x0 = Math.max(0, Math.floor(gx - r - 1));
const x1 = Math.min(gw - 1, Math.ceil(gx + r + 1));
const y0 = Math.max(0, Math.floor(gy - r - 1));
const y1 = Math.min(gh - 1, Math.ceil(gy + r + 1));
for (let yy = y0; yy <= y1; yy += 1) {
for (let xx = x0; xx <= x1; xx += 1) {
const d = Math.hypot(xx + 0.5 - gx, yy + 0.5 - gy);
const cover = clamp(r + 0.5 - d, 0, 1);
if (cover > 0) { field[yy * gw + xx] += amount * cover; }
}
}
}
// 붓 한 토막 — 곡선의 u0 → u1 구간을 판 0.8px 걸음으로 따라가며 가닥마다 점을 찍는다
// 물(water)이 그 가닥의 문턱 + 노이즈 흔들림보다 적으면 그 자리에서는 찍지 않는다 → 문턱이 높은 가닥부터 끊긴다
function brushSegment(field: Float32Array, gw: number, gh: number, c: Curve, u0: number, u1: number, water: number,
bristles: Bristle[], halfWidth: number, dotRadius: number, ink: number, wobble: number): number {
curveAt(c, u0);
let px = curveX;
let py = curveY;
curveAt(c, u1);
const len = Math.hypot(curveX - px, curveY - py);
const steps = Math.max(1, Math.ceil(len / 0.8));
let drawn = 0;
for (let s = 1; s <= steps; s += 1) {
const u = lerp(u0, u1, s / steps);
curveAt(c, u);
const x = curveX;
const y = curveY;
const tl = Math.hypot(x - px, y - py) || 1;
const nx = -(y - py) / tl;
const ny = (x - px) / tl;
px = x;
py = y;
for (let b = 0; b < bristles.length; b += 1) {
const br = bristles[b];
const flicker = (valueNoise(u * 40, b * 3.7, br.seed) - 0.5) * 0.16;
if (water < br.thirst + flicker) { continue; }
const off = br.offset * halfWidth + (valueNoise(u * 9, b * 1.3, br.seed + 1) - 0.5) * wobble;
stampDot(field, gw, gh, x + nx * off, y + ny * off, dotRadius, ink);
drawn += 1;
}
}
return drawn;
}
// 물방울 하나 — 떨어지는 자리(판 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);
}
// 09 수채 풍경 첫 화면 — 종이 결 → 젖은 하늘 번짐(03) → 먼 산 겹칠(01·08) → 앞 언덕 마른 붓(05) → 하늘의 물방울 꽃(07) 순서로, 같은 시드에서 같은 풍경을 그린다
// 하늘 번짐 빠르기와 되풀이 횟수 — 03 과 같은 젖은 칸 번짐
const SKY_DIFFUSE = 0.3;
const SKY_SUBSTEPS = 8;
// 물을 바르며 함께 까는 하늘 바탕 진하기 — 위는 하늘색, 지평선 쪽은 분홍
const SKY_BASE = 0.42;
// 하늘 물이 마르는 빠르기 — 물방울 꽃이 피는 BLOOM_AT(3.5초) 뒤까지 젖어 있도록 느리게
const SKY_EVAPORATION = 0.09;
// 산 한 겹의 진하기와 겹 수 — 01 의 흔든 다각형을 옅게 쌓는다
const RIDGE_ALPHA = 0.075;
const RIDGE_SHEETS = 9;
const RIDGE_JITTER = 0.32;
// 언덕 붓질 — 05 와 같은 붓털 계산
const HILL_BRISTLES = 18;
const HILL_DRY = 0.0019;
// 물방울 꽃 — 07 과 같은 밀기
const HERO_PETAL = 0.36;
const HERO_PUSH = 0.18;
// 자동 시연이 미리 그려 두고 시작하는 시각(초)
const DEMO_START = 1.4;
const PAINT_TIME = 4.6;
const HOLD_TIME = 2.2;
const WASH_TIME = 1.3;
const SEED = 4829;
// 장면 순서(초) — 상태 줄 글자도 이 시각으로 바뀐다
const SKY_AT = 0.2;
const RIDGE_AT = 1.4;
const HILL_AT = 2.5;
const BLOOM_AT = 3.5;
const STAGE_SKY = '하늘 번짐';
const STAGE_RIDGE = '먼 산 겹칠';
const STAGE_HILL = '마른 붓 언덕';
const STAGE_BLOOM = '물방울 꽃';
const STAGE_DONE = '완성';
const STAGE_WASH = '물로 씻어 내는 중';
const STAGE_PAPER = '종이 결 깔기';
function stageName(t: number): string {
if (t >= PAINT_TIME + HOLD_TIME) { return STAGE_WASH; }
if (t >= PAINT_TIME) { return STAGE_DONE; }
if (t >= BLOOM_AT) { return STAGE_BLOOM; }
if (t >= HILL_AT) { return STAGE_HILL; }
if (t >= RIDGE_AT) { return STAGE_RIDGE; }
if (t >= SKY_AT) { return STAGE_SKY; }
return STAGE_PAPER;
}
// 하늘 — 물 격자 하나와 안료 셋(하늘·라벤더·분홍)
const skyWater = new Float32Array(GW * GH);
const skyBlue = new Float32Array(GW * GH);
const skyLilac = new Float32Array(GW * GH);
const skyRose = new Float32Array(GW * GH);
const skyTmp = new Float32Array(GW * GH);
const skyFields: Float32Array[] = [skyBlue, skyLilac, skyRose];
// 하늘 바탕을 이미 깐 칸 — 한 칸에 한 번만
const skyLaid = new Uint8Array(GW * GH);
// 산 두 줄(먼 쪽 인디고, 가까운 쪽 세이지)
const ridgeFar = new Float32Array(GW * GH);
const ridgeNear = new Float32Array(GW * GH);
// 언덕 붓질 — 가는 붓털이 보이게 화소 격자 크기로
const hillField = new Float32Array(OW * OH);
const hillDeep = new Float32Array(OW * OH);
const gridFields: Float32Array[] = [skyBlue, skyLilac, skyRose, ridgeFar, ridgeNear];
const gridPigs: Pigment[] = [pigment(SKY), pigment(LAVENDER), pigment(PINK), pigment(INDIGO), pigment(SAGE)];
const gridGran: number[] = [0, 0, 0, 0.35, 0.3];
const brushFields: Float32Array[] = [hillField, hillDeep];
const brushPigs: Pigment[] = [pigment(SAGE), pigment(SKY)];
const brushGran: number[] = [0.3, 0.2];
type Dab = { x: number; y: number; at: number; field: number; amount: number };
const SKY_DABS: Dab[] = [
{ x: 70, y: 40, at: 0.35, field: 0, amount: 0.9 },
{ x: 400, y: 36, at: 0.45, field: 0, amount: 0.9 },
{ x: 236, y: 30, at: 0.55, field: 0, amount: 0.8 },
{ x: 150, y: 86, at: 0.7, field: 1, amount: 1.1 },
{ x: 340, y: 92, at: 0.8, field: 1, amount: 1.1 },
{ x: 60, y: 128, at: 0.95, field: 2, amount: 1.0 },
{ x: 250, y: 126, at: 1.05, field: 2, amount: 1.0 },
{ x: 430, y: 120, at: 1.15, field: 2, amount: 1.0 },
];
type Ridge = { base: number; amp: number; field: Float32Array; start: number };
const RIDGES: Ridge[] = [
{ base: 116, amp: 40, field: ridgeFar, start: RIDGE_AT },
{ base: 180, amp: 30, field: ridgeNear, start: RIDGE_AT + 0.5 },
];
type Stroke9 = { curve: Curve; start: number; duration: number; field: number };
const HILL_STROKES: Stroke9[] = [
{ curve: [-10, 232, 140, 204, 320, 262, 490, 222], start: HILL_AT, duration: 0.6, field: 0 },
{ curve: [-10, 262, 160, 240, 300, 288, 490, 252], start: HILL_AT + 0.3, duration: 0.6, field: 0 },
{ curve: [-10, 290, 150, 270, 330, 304, 490, 280], start: HILL_AT + 0.6, duration: 0.55, field: 1 },
];
const HERO_DROPS: Drop[] = [
{ x: 118, y: 62, r: 30, at: BLOOM_AT, seed: SEED + 1 },
{ x: 346, y: 54, r: 26, at: BLOOM_AT + 0.35, seed: SEED + 2 },
];
const hillU: number[] = HILL_STROKES.map(function (): number { return 0; });
const hillWater: number[] = HILL_STROKES.map(function (): number { return 1; });
let hillBristles: Bristle[][] = [];
let dabsDone = 0;
const ridgeSheets: number[] = RIDGES.map(function (): number { return 0; });
let ridgeShapes: number[][] = [];
const hillLen: number[] = HILL_STROKES.map(function (s: Stroke9): number {
let len = 0;
curveAt(s.curve, 0);
let x = curveX;
let y = curveY;
for (let i = 1; i <= 100; i += 1) {
curveAt(s.curve, i / 100);
len += Math.hypot(curveX - x, curveY - y);
x = curveX;
y = curveY;
}
return len;
});
buildPaper(SEED, 1.3);
// 산등성이 다각형 — 판 왼쪽 바깥에서 오른쪽 바깥까지 노이즈로 오르내리는 능선, 아래는 판 바닥까지 닫는다
function ridgePolygon(r: Ridge, k: number): number[] {
const pts: number[] = [];
for (let i = 0; i <= 12; i += 1) {
const x = -20 + i * 520 / 12;
pts.push(x, r.base - r.amp * (noise2(x / 70, k * 4.2, SEED + 30) - 0.5) * 4);
}
pts.push(500, 310, -20, 310);
return pts;
}
function reset(): void {
const rng = makeRng(SEED);
skyWater.fill(0);
skyBlue.fill(0);
skyLilac.fill(0);
skyRose.fill(0);
skyLaid.fill(0);
ridgeFar.fill(0);
ridgeNear.fill(0);
hillField.fill(0);
hillDeep.fill(0);
dabsDone = 0;
ridgeShapes = RIDGES.map(function (r: Ridge, k: number): number[] { return ridgePolygon(r, k); });
for (let k = 0; k < RIDGES.length; k += 1) { ridgeSheets[k] = 0; }
hillBristles = HILL_STROKES.map(function (): Bristle[] { return makeBristles(HILL_BRISTLES, 0.05, 0.6, rng); });
for (let k = 0; k < HILL_STROKES.length; k += 1) {
hillU[k] = 0;
hillWater[k] = 1;
}
}
function update(dt: number, t: number): void {
// ① 하늘 — 위에서 아래로 맑은 물을 바르고(지평선 아래는 마른 채), 물감을 찍어 젖은 칸으로만 번지게 한다
const wetLine = phaseOf(t, SKY_AT, 0.3) * 170;
const dried = Math.max(0, t - SKY_AT - 0.3) * SKY_EVAPORATION;
for (let y = 0; y < GH; y += 1) {
const py = (y + 0.5) * H / GH;
for (let x = 0; x < GW; x += 1) {
const i = y * GW + x;
const horizon = 150 + (noise2(x / 12, 1.7, SEED + 3) - 0.5) * 20;
const w = py < wetLine && py < horizon ? clamp((horizon - py) / 12, 0, 1) : 0;
skyWater[i] = Math.max(0, w - dried);
if (w > 0 && !skyLaid[i]) {
skyLaid[i] = 1;
const v = py / 150;
skyBlue[i] += SKY_BASE * (1 - v) * w;
skyRose[i] += SKY_BASE * 0.8 * v * v * w;
}
}
}
while (dabsDone < SKY_DABS.length && t >= SKY_DABS[dabsDone].at) {
const d = SKY_DABS[dabsDone];
touchPigment(skyFields[d.field], skyWater, GW, GH, d.x, d.y, 30, d.amount);
dabsDone += 1;
}
// ⑤ 물방울 꽃 — 아직 젖은 하늘에 맑은 방울을 떨어뜨려 안료를 꽃잎 테두리로 민다
for (let k = 0; k < HERO_DROPS.length; k += 1) {
const d = HERO_DROPS[k];
if (t < d.at) { continue; }
const grow = 1 - Math.exp(-(t - d.at) / 0.3);
for (let f = 0; f < 3; f += 1) { pushBloom(skyFields[f], skyWater, GW, GH, d, grow, HERO_PETAL, HERO_PUSH, 0.4, 0.1, skyTmp); }
}
for (let s = 0; s < SKY_SUBSTEPS; s += 1) {
for (let f = 0; f < 3; f += 1) { wetDiffuse(skyFields[f], skyWater, GW, GH, SKY_DIFFUSE, skyTmp); }
}
// ② 먼 산 겹칠 — 능선 다각형을 장마다 다시 흔들어 옅게 쌓는다
for (let k = 0; k < RIDGES.length; k += 1) {
const want = Math.floor(RIDGE_SHEETS * phaseOf(t, RIDGES[k].start, 0.8));
while (ridgeSheets[k] < want) {
const sheet = deformPolygon(ridgeShapes[k], 4, RIDGE_JITTER, makeRng(SEED * 10 + k * 100 + ridgeSheets[k]));
fillPolygon(RIDGES[k].field, GW, GH, sheet, RIDGE_ALPHA);
ridgeSheets[k] += 1;
}
}
// ③ 앞 언덕 마른 붓 — 빨라질수록 물이 줄어 붓털이 갈라진다
for (let k = 0; k < HILL_STROKES.length; k += 1) {
const s = HILL_STROKES[k];
if (t < s.start || hillU[k] >= 1) { continue; }
const u = Math.pow(phaseOf(t, s.start, s.duration), 1.6);
const speed = (u - hillU[k]) * hillLen[k] / dt;
hillWater[k] = Math.max(0, hillWater[k] - (0.2 + HILL_DRY * speed) * dt);
const w = hillWater[k];
brushSegment(brushFields[s.field], OW, OH, s.curve, hillU[k], u, w, hillBristles[k], 26, lerp(0.9, 4, w), 0.12 * (0.5 + 0.5 * w), 3);
hillU[k] = u;
}
}
function render(t: number): void {
compose(gridFields, GW, GH, gridPigs, gridGran);
compose(brushFields, OW, OH, brushPigs, brushGran, true);
washAt(t, SEED);
}
// 09 그리기 — 풍경을 합성해 놓고, 지금 그리는 단계를 상태 줄 글자로 알린다(바뀔 때만 고친다)
let shownStage = '';
function draw(): void {
render(simTime);
present();
const stage = stageName(simTime);
if (statusEl && stage !== shownStage) {
statusEl.textContent = stage;
shownStage = stage;
}
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 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>
<p className={cx("wc__status")} data-role="status" aria-live="off">{'종이 결 깔기'}</p>
<button className={cx("wc__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}09 · 하늘 번짐부터 마른 붓 언덕까지 차례로 그려지는 수채 풍경 첫 화면 — 측정 누적면적 54.138% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// ⑤ 물방울 꽃 — 아직 젖은 하늘에 맑은 방울을 떨어뜨려 안료를 꽃잎 테두리로 민다
for (let k = 0; k < HERO_DROPS.length; k += 1) {
const d = HERO_DROPS[k];
if (t < d.at) {
continue;
}
const grow = 1 - Math.exp(-(t - d.at) / 0.3);
for (let f = 0; f < 3; f += 1) {
pushBloom(skyFields[f], skyWater, GW, GH, d, grow, HERO_PETAL, HERO_PUSH, 0.4, 0.1, skyTmp);
}
}
for (let s = 0; s < SKY_SUBSTEPS; s += 1) {
for (let f = 0; f < 3; f += 1) {
wetDiffuse(skyFields[f], skyWater, GW, GH, SKY_DIFFUSE, skyTmp);
}
}
// ② 먼 산 겹칠 — 능선 다각형을 장마다 다시 흔들어 옅게 쌓는다
for (let k = 0; k < RIDGES.length; k += 1) {
const want = Math.floor(RIDGE_SHEETS * phaseOf(t, RIDGES[k].start, 0.8));
while (ridgeSheets[k] < want) {
const sheet = deformPolygon(ridgeShapes[k], 4, RIDGE_JITTER, makeRng(SEED * 10 + k * 100 + ridgeSheets[k]));
fillPolygon(RIDGES[k].field, GW, GH, sheet, RIDGE_ALPHA);
ridgeSheets[k] += 1;
}
}
수채화 효과 사이트처럼 웹 화면에서 물감 번짐을 만들려면 무엇을 계산해야 하는가?
물의 양, 안료의 양, 종이의 높이
색을 바로 칠하면 번짐이 생기지 않는다. 이 편이 칸마다 들고 있는 값은 세 가지다. 물이 얼마나 남았는지(번짐을 허락하는 값), 안료가 얼마나 들었는지(색의 진하기), 종이가 그 자리에서 얼마나 높은지(알갱이가 고일 곳)다. 03 은 물이 있는 칸끼리만 안료를 나누고, 02 는 물이 줄어드는 속도로 안료를 테두리에 모으고, 04 는 종이 높이로 진하기를 곱한다. 화면의 색은 맨 마지막에 이 값들에서 한 번 계산될 뿐이다.
색은 더하지 않고 곱한다
공용 함수 compose 는 안료마다 「흰 종이에 한 겹 칠했을 때 돌아오는 빛」 을 채널별 비율로 들고, 진하기만큼 그 비율을 거듭제곱해 종이 색에 곱한다. 로그로 바꿔 진하기 × 로그 비율을 더한 뒤 지수로 되돌리는 방식이라, 겹칠수록 색이 곱으로 깊어지고 흰색 쪽으로 바래지 않는다. 08 이 같은 두 층을 투명도 섞기와 나란히 놓는 까닭이 여기에 있다 — 곱은 순서를 따지지 않지만 섞기는 위에 얹은 색이 이긴다.
이웃 편과 무엇이 다른가
마블링 효과 편은 물 위에 뜬 잉크 막을 밀고 빗질해 결을 만드는 장면이라 안료가 종이에 스미는 일이 없다. 유체 격자 편은 연기와 잉크가 흐르는 속도장을 풀지만, 여기 03 은 속도 없이 물이 있는 칸끼리의 확산만 쓴다. 종이 높이를 만드는 값 노이즈는 노이즈 지형 편과 뿌리가 같고, 06 의 단계 줄이기는 픽셀 아트 변환 편처럼 화소를 블록으로 묶는 대신 밝기만 줄이고 색상은 남긴다는 점이 다르다.
흔들린 다각형은 겹으로, 안료는 젖은 칸의 확산으로, 테두리는 마르는 거리로 — 물감 한 방울을 화면에 앉히는 계산
무거운 계산은 가로 160칸, 색은 가로 320칸
물·안료 확산·거리처럼 이웃을 여러 번 읽는 계산은 160×100 격자(한 칸이 판 3px)에서 하고, 색 합성과 종이 결은 320×200 화소 격자에서 한다. 합성 결과는 Float32 버퍼 하나에 모았다가, 처음에 한 번 만든 ImageData 에 8비트로 옮겨 숨긴 캔버스에 놓고 480×300 판에 부드럽게 늘려 그린다. 프레임마다 새 배열도 새 ImageData 도 만들지 않으니 쓰레기 수거가 끼어들 틈이 줄어드는 것으로 보인다.
1/30초 고정 걸음과 미리 그려 둔 첫 화면
rAF 가 넘겨주는 시각 차는 0.05초에서 자르고, 모인 시간을 1/30초씩 꺼내 한 걸음씩 계산한다. 화면이 잠시 멈췄다 돌아와도 걸음 크기는 같아 같은 시드에서 같은 그림이 나온다. 자동 시연은 장면마다 DEMO_START(0.9~1.4초)까지 미리 계산한 채 시작해서 페이지를 열자마자 빈 종이만 보이지 않고, 사람이 다시 시작하면 0초부터 그린다.
마르는 거리는 두 번 훑어 구한다
02 의 distanceToEdge 는 위에서 아래로, 다시 아래에서 위로 격자를 훑으며 이웃 값에 곧은 이웃은 1, 대각 이웃은 1.414 를 더한 것 가운데 작은 값을 남긴다. 두 번이면 칸마다 가장 가까운 마른 칸까지의 거리가 정해지고, 이어 이웃 평균을 두 번 섞어 계단을 편다. 세 얼룩에서 가장 깊은 칸은 경계에서 27.28·24.44·19.42칸 떨어져 있다.
물감이 깨지는 곳 — 다른 주소의 사진에서 막히는 픽셀 읽기·수백 겹에서 탁해지는 8비트 색·넓은 화면에서 느려지는 확산 격자
다른 출처의 사진을 읽으려 할 때
06 의 loadPhoto 는 계산한 풍경을 숨긴 캔버스에 놓고 getImageData 로 다시 읽는데, 이 호출을 try·catch 로 감싸 읽기가 막히면 계산해 둔 원본을 그대로 쓴다. 이 데모는 원본을 같은 페이지에서 그리므로 캔버스가 오염되지 않는다. 다른 주소의 사진을 허락 없이 캔버스에 그리면 캔버스가 오염되어 바로 이 호출에서 보안 오류가 나므로, 실제 사진을 쓰려면 이미지의 crossOrigin 속성과 서버의 허용 응답이 함께 있어야 한다(교차 출처 이미지 안내). 받은 zip 의 06 폴더에서 이 부분을 직접 바꿔 보려면 압축 암호 5tkwg3wj 를 넣고 loadPhoto 를 열면 된다.
옅은 색을 수백 번 덮을 때
종이 [250,245,232] 위에 산호색 [240,148,132] 을 알파 a 로 거듭 덮으면서 칠할 때마다 0~255 정수로 반올림하면, 남은 차이 × a 가 0.5 밑으로 내려가는 순간 값이 더 움직이지 않는다. 목표에서 0.5/a 단계 앞에서 멈추는 셈이다. a 0.042 로 400겹을 덮으면 8비트는 [250,159,143] 에서 멈추고 Float32 는 [240,148,132] 에 닿는데, 빨강은 처음 차이가 10 이라 10 × 0.042 = 0.42 로 한 번도 움직이지 않는다. 알파 1% 로 400번 덮어도 빨강은 250 에서 그대로이고 목표 240 에 닿지 못한다. 채널마다 멈추는 자리가 달라서 색이 바래는 데 그치지 않고 색상까지 틀어진다. 01 이 진하기를 Float32 격자에 더하고 겹 수를 얼룩 하나에 40 으로 묶어 두는 이유다.
화면 화소 그대로 확산을 돌릴 때
같은 wetDiffuse 한 번이 160×100(16,000칸)에서는 0.081ms, 화면 화소 그대로인 960×600(480×300 × 배율 2, 36배 칸 수)에서는 3.01ms 걸렸다. 03 은 한 걸음에 14번 × 두 안료 = 28번을 돌리므로 걸음당 2.26ms 와 84.2ms 로 갈리고, 뒤쪽은 한 걸음 예산 33ms 를 2.5배 넘긴다. 이 시간은 개발 시점에 개발 기계의 Node.js 에서 잰 값이라 기기마다 다르지만 36배라는 칸 수 비율은 그대로 남는다. 그래서 확산은 가로 160 격자에서 끝내고 색 합성만 320×200 에서 한다. 03 의 DIFFUSE_SUBSTEPS 를 늘려 번짐을 빠르게 하고 싶을 때도 이 걸음당 예산을 먼저 계산해 보는 편이 안전하다.
다각형을 덜 흔들고, 확산을 빠르게, 겹칠을 진하게 — 값 하나로 바뀌는 그림의 물기
| 변형 | 바꾼 값 | 결과 |
|---|---|---|
| 다각형을 덜 흔들게 | 01 BLOT_JITTER 0.45 → 0.2 |
꼭짓점 평균 꺾임 0.755 → 0.37 라디안, 둘레 873 → 573px, 가장자리 폭 4.5 → 1.5px 로 오린 종이에 가까워진다 |
| 확산을 빠르게 | 03 DIFFUSE_RATE 0.18 → 0.3 |
두 안료가 섞인 칸 760 → 1,096(+44%), 마른 칸 누수는 두 값 모두 0 |
| 겹칠을 진하게 | 08 GLAZE_ALPHA 0.35 → 0.6 |
파랑 위 노랑(흡수) [180,187,153] → [130,149,101], 색상 72 → 84°, 파랑 2겹 밝기 159.8 → 111.5 |
세 값은 각 장면 스크립트 위쪽 상수에 한 줄로 있다. 흔들림을 줄이면 가운데 진하기(1.68)는 그대로 둔 채 가장자리만 단단해지고, 확산률을 올려도 마른 칸의 벽은 무너지지 않으며, 겹칠 양을 올리면 흡수 쪽 밝기는 181.0 → 137.8 로 내려가고 투명도 섞기 쪽은 [204,191,108] 이 된다.
그림이 멈춰도 읽히게 — 완성된 한 장으로 멈추는 움직임 줄이기·일시정지 버튼·그림 설명 문장의 접근성
움직임 줄이기에서 남는 완성 그림
prefers-reduced-motion: reduce 이면 아홉 장면은 루프를 세우지 않고, 같은 시드로 그리기 끝까지 한 번에 계산한 완성 그림 한 장만 보여 준다. 씻어 내기도 돌지 않으며, 설정이 바뀌면 change 리스너가 바로 따라간다.
일시정지 버튼과 키보드
오른쪽 아래 알약 버튼은 aria-pressed 를 단 네이티브 <button> 이라 Tab 으로 가서 Enter 나 Space 로 멈추고 다시 돌린다. 누른 상태에서는 바탕과 글자 색이 뒤집히고, 키보드 포커스에는 3px 외곽선이 생긴다.
그림 설명 문장과 09 의 상태 줄
캔버스마다 role="img" 와 장면을 문장으로 풀어 쓴 aria-label 이 붙어 있다. 상태 줄이 있는 09 는 자동 시연 동안 aria-live 를 꺼 두었다가 첫 입력이 들어온 뒤에 polite 로 바꿔, 단계 이름이 바뀔 때마다 화면 읽기 프로그램이 끼어들지 않게 한다.
웹 페이지에 수채화 배경 이미지를 직접 그려 넣기 전에 자주 묻는 질문
우리 브랜드 색으로 물감을 바꾸려면 어디를 고치나요?
스크립트 위쪽의 PAPER·SKY·CORAL·LAVENDER 같은 RGB 배열이 종이와 안료 색이다. 값을 바꾸면 compose 가 그 색에서 채널별 비율을 다시 구하므로 겹칠 때 깊어지는 방식은 그대로 따라온다.
수채화 애니메이션이 휴대폰에서도 버벅이지 않나요?
계산 격자는 화면 크기와 상관없이 160×100 과 320×200 으로 고정이고, 기기 배율은 최대 2 까지만 실제 화소에 반영된다. 좁은 화면이라고 계산이 늘지 않으며, 탭을 숨기면 루프가 멈췄다가 돌아오면 다시 돈다.
06 에 내가 고른 사진을 넣을 수 있나요?
같은 출처의 이미지이거나 서버가 교차 출처 읽기를 허락한 이미지라면 숨긴 캔버스에 그린 뒤 같은 getImageData 로 읽게 바꿀 수 있다. 허락이 없으면 읽기가 막히고, 그때는 catch 가 계산한 풍경을 그대로 쓰므로 화면이 깨지지는 않는다.









