08透明な層を重ね塗りするほど光を掛け算でこす重ね塗りの混色
色の層ごとに光をどれだけ返すかをチャンネル別の割合として扱い、重なった所は二つの割合を掛けて青の上の黄色を緑へ沈ませ、同じ所を単純な透明度の混ぜで塗った版と並べて比べます。
使いどころ美術の授業資料の混色ガイド
GLAZE_ALPHAGLAZE_SHEETSLAYER_TIME

<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 オリジナル · SCSS と React は zip に同梱 · 水彩 エフェクト 9種 — にじみ・顔料だまり・紙の目を計算で描く
このデモが属する記事
水彩 エフェクト 9種 — にじみ・顔料だまり・紙の目を計算で描く水彩 エフェクトは、水を含んだ絵の具が紙に染みて広がる様子を、画面の上で一画素ずつ計算して描く表現です。絵の具のにじみは筆ではなく、水が乾く順番が作ります。03番は濡れたマスにだけ顔料を広げ、02番は乾くあいだに縁へ色を集め、01番は辺を割 この記事のデモをすべて見る


