02확산 번짐
셀마다 이웃 넷과 값을 조금씩 섞는 확산 한 단계가 잉크가 물에 번지는 스모딩입니다. 확산 계수를 크게 하면 데칼코마니처럼 부드럽게 퍼지고, 0 에 가까우면 선명한 경계를 유지합니다.
쓰임물감 배경·수채 질감 섹션
Float32ArrayputImageData

<main class="stage stage--paper">
<div class="fa" id="fa">
<p class="fa__hint">
<svg class="fa__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></g></svg>
<span>매 걸음 이웃 넷과 값을 섞는 확산이 잉크를 종이에 번지게 합니다 — 눌러서 방울을 떨어뜨려 보세요</span>
</p>
<div class="fa__field" id="field" role="application" aria-label="확산 번짐 실험판" tabindex="0">
<canvas class="fa__cv" id="cv"></canvas>
<b class="fa__badge" aria-hidden="true">6<span class="fa__badgeUnit">완화</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 확산 번짐 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 하나도 두지 않는다 — 렌더러가 CSS 시계를 고정하지 않고 실시간으로 24장을 찍는다(01·012·077 선례).
// 종이(paper) 무대 위 염료는 밝은 배경에서 보이는 어두운·낮은 채도 파스텔 세 층으로 잡는다 —
// 회보라(짙음) → 청회색(중간) → 연회색(옅음). 채도 전부 0.21 이하로 BD26 파스텔 게이트를 처음부터 지킨다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.fa {
// 염료 세 층 — 짙은 회보라 → 청회색 → 연회색. 흰 종이 위 대비 6.0~9.9
--dye-0: #8b7f95;
--dye-1: #5c4f66;
--dye-2: #2d2836;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fa__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.fa__ico { width: 16px; height: 16px; flex: 0 0 auto; color: #5c4f66; }
.fa__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .12);
cursor: crosshair; touch-action: none;
}
.fa__field:focus-visible { outline: 2px solid #5c4f66; outline-offset: 2px; }
.fa__cv { display: block; width: 100%; height: 100%; }
.fa__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($stage-paper, .82);
color: $subject-ink; font-weight: 700; font-size: 11px;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .1);
}
.fa__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.fa { gap: $sp-1; }
.fa__field { height: 124px; }
.fa__badge { font-size: 10px; padding: 0 $sp-1; }
}
// reduce — 루프가 멈춘 한 장이 남으므로 커서 신호만 바꾼다.
@media (prefers-reduced-motion: reduce) {
.fa.is-still .fa__field { cursor: default !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);
}
.fa {
--dye-0: #8b7f95;
--dye-1: #5c4f66;
--dye-2: #2d2836;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fa__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.fa__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #5c4f66;
}
.fa__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.12);
cursor: crosshair;
touch-action: none;
}
.fa__field:focus-visible {
outline: 2px solid #5c4f66;
outline-offset: 2px;
}
.fa__cv {
display: block;
width: 100%;
height: 100%;
}
.fa__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
color: #17141a;
font-weight: 700;
font-size: 11px;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.1);
}
.fa__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.fa {
gap: 4px;
}
.fa__field {
height: 124px;
}
.fa__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.fa.is-still .fa__field {
cursor: default !important;
}
}// 확산(diffuse) — 이류처럼 값을 옮기지 않는다. 셀 하나가 이웃 네 칸과 값을 조금씩 섞는 일을
// 매 걸음 반복하면(가우스-사이델 완화), 진한 점 하나가 몇 걸음 만에 부드러운 얼룩으로 퍼진다.
// 속도장 없이 밀도(d) 하나만으로 돌아간다 — 이류 비중을 없애 확산 자체가 움직임의 전부가 되게 한다.
var root = document.getElementById('fa');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d', { willReadFrequently: true });
var css = getComputedStyle(root);
var PAL0 = css.getPropertyValue('--dye-0').trim();
var PAL1 = css.getPropertyValue('--dye-1').trim();
var PAL2 = css.getPropertyValue('--dye-2').trim();
// N — 격자 한 변 칸 수. 480px 무대에 32칸이면 한 칸 15px, 완화 6회를 60fps 예산 안에 담는다
var N = 32;
var SIZE = (N + 2) * (N + 2);
var IX = function (x, y) { return x + (N + 2) * y; };
var d = new Float32Array(SIZE), d0 = new Float32Array(SIZE);
var ITER = 6; // 가우스-사이델 완화 반복 횟수 — 4~10회 권장, 6회면 한 걸음에 충분히 번진다
var DIFF = 0.02; // 확산 계수 — 클수록 데칼코마니처럼 빨리 퍼지고, 0 에 가까우면 선명한 경계가 남는다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, raf = 0, seedT = 0;
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
// 경계 — 종이 가장자리에서 잉크가 반사돼 옅게 남는다
function bounds() {
for (var i = 1; i <= N; i++) {
d[IX(i, 0)] = d[IX(i, 1)] * 0.5; d[IX(i, N + 1)] = d[IX(i, N)] * 0.5;
}
for (var j = 1; j <= N; j++) {
d[IX(0, j)] = d[IX(1, j)] * 0.5; d[IX(N + 1, j)] = d[IX(N, j)] * 0.5;
}
}
// 확산 한 걸음 — x0(방금 주입한 소스)를 목표로, x(이전 값)를 이웃 네 칸과 섞어가며 완화한다.
// a 가 클수록 이웃의 영향이 커져 한 걸음에 더 멀리 번진다. 경계는 반복마다 다시 잡는다.
function diffuse(dt) {
var a = dt * DIFF * N * N;
for (var k = 0; k < ITER; k++) {
for (var j = 1; j <= N; j++) {
for (var i = 1; i <= N; i++) {
d[IX(i, j)] = (d0[IX(i, j)] + a * (d[IX(i - 1, j)] + d[IX(i + 1, j)] + d[IX(i, j - 1)] + d[IX(i, j + 1)])) / (1 + 4 * a);
}
}
bounds();
}
}
// 미리보기 주입원 — 시드 고정. 종이 위를 천천히 오가는 붓끝이 방울을 떨어뜨린다.
// seedT 로 결정적: 같은 편의 gif·poster 는 언제나 같은 번짐이 담긴다.
function inject(t) {
var cx = N * 0.5 + Math.sin(t * 0.55) * N * 0.28;
var cy = N * 0.5 + Math.cos(t * 0.4) * N * 0.24;
var r = 2.2;
var amt = 0.85 + Math.sin(t * 2.3) * 0.15;
for (var j = -r; j <= r; j++) {
for (var i = -r; i <= r; i++) {
var q = (i * i + j * j) / (r * r);
if (q > 1) continue;
var f = Math.exp(-q * 3) * amt;
var idx = IX(Math.round(cx + i), Math.round(cy + j));
d0[idx] = Math.min(2, d0[idx] + f);
}
}
}
function step(dt) {
d0.set(d);
inject(seedT);
diffuse(dt);
for (var k = 0; k < SIZE; k++) d[k] *= 0.992; // 아주 천천히 마른다 — 화면이 꽉 찬 채 굳지 않게
seedT += dt;
}
function render() {
var img = ctx.createImageData(cv.width, cv.height);
var px = img.data;
var cellW = cv.width / N, cellH = cv.height / N;
var pal = [PAL0, PAL1, PAL2];
for (var py = 0; py < cv.height; py++) {
var gy = Math.min(N - 1, (py / cellH) | 0);
for (var pxx = 0; pxx < cv.width; pxx++) {
var gx = Math.min(N - 1, (pxx / cellW) | 0);
var q = d[IX(gx + 1, gy + 1)];
var o = (py * cv.width + pxx) * 4;
if (q <= 0.002) { px[o] = 255; px[o + 1] = 255; px[o + 2] = 255; px[o + 3] = 255; continue; }
var band = q > 0.62 ? 2 : (q > 0.28 ? 1 : 0);
var c = pal[band];
var rr = parseInt(c.slice(1, 3), 16), gg = parseInt(c.slice(3, 5), 16), bb = parseInt(c.slice(5, 7), 16);
var a = Math.min(1, q);
px[o] = 255 + (rr - 255) * a; px[o + 1] = 255 + (gg - 255) * a; px[o + 2] = 255 + (bb - 255) * a; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame() {
step(1 / 30);
render();
raf = requestAnimationFrame(frame);
}
// 사람이 찍기 — 눌러 둔 자리에 방울을 계속 떨어뜨린다. 확산은 다음 걸음부터 저절로 번진다.
function splat(gx, gy, amt) {
var r = 3;
for (var j = -r; j <= r; j++) {
for (var i = -r; i <= r; i++) {
var q = (i * i + j * j) / (r * r);
if (q > 1) continue;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
var f = Math.exp(-q * 3) * amt;
var idx = IX(gx + i, gy + j);
d[idx] = Math.min(2.2, d[idx] + f);
}
}
}
var down = false;
function pointAt(e) {
var r = field.getBoundingClientRect();
var gx = 1 + Math.round((e.clientX - r.left) / r.width * (N - 1));
var gy = 1 + Math.round((e.clientY - r.top) / r.height * (N - 1));
splat(gx, gy, 0.9);
if (reduce) render();
}
field.addEventListener('pointerdown', function (e) { down = true; pointAt(e); });
field.addEventListener('pointermove', function (e) { if (down) pointAt(e); });
window.addEventListener('pointerup', function () { down = false; });
field.addEventListener('pointerleave', function () { down = false; });
field.addEventListener('keydown', function (e) {
var dir = { ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] }[e.key];
if (!dir) return;
e.preventDefault();
splat((N >> 1) + dir[0], (N >> 1) + dir[1], 1.1);
if (reduce) render();
});
window.addEventListener('resize', function () { fit(); if (reduce) render(); });
fit();
if (reduce) {
// reduce — 시뮬레이션을 40 걸음만 앞으로 감아 번짐이 자리잡은 한 장을 그린다(빈 화면이 아니다)
for (var k = 0; k < 40; k++) step(1 / 30);
render();
} else {
raf = requestAnimationFrame(frame);
}// DiffuseBlur — vanilla 판(run/308/demo/02-fluid-diffuse-blur/index.html) 의 React 포트.
// 속도장 없이 밀도(d) 하나만으로 가우스-사이델 완화 6회를 매 걸음 돌려 확산 자체가 움직임의 전부가 되게 한다.
// 격자 필드는 ref 상자 안에서만 산다 — React 상태로 두면 리렌더가 매 프레임 일어나 60fps 예산을 못 맞춘다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./DiffuseBlur.module.scss";
export type DiffuseBlurProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number; buttons?: number };
const N = 32;
const SIZE = (N + 2) * (N + 2);
function IX(x: number, y: number): number { return x + (N + 2) * y; }
const ITER = 6;
const DIFF = 0.02;
export default function DiffuseBlur({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: DiffuseBlurProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const palRef = useRef(["#8b7f95", "#5c4f66", "#2d2836"]) as Box<string[]>;
const d = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const d0 = useRef(new Float32Array(SIZE)) as Box<Float32Array>;
const seedTRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
}
function bounds(): void {
const dd = d.current;
for (let i = 1; i <= N; i++) {
dd[IX(i, 0)] = dd[IX(i, 1)] * 0.5; dd[IX(i, N + 1)] = dd[IX(i, N)] * 0.5;
}
for (let j = 1; j <= N; j++) {
dd[IX(0, j)] = dd[IX(1, j)] * 0.5; dd[IX(N + 1, j)] = dd[IX(N, j)] * 0.5;
}
}
function diffuse(dt: number): void {
const dd = d.current, dd0 = d0.current;
const a = dt * DIFF * N * N;
for (let k = 0; k < ITER; k++) {
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
dd[IX(i, j)] = (dd0[IX(i, j)] + a * (dd[IX(i - 1, j)] + dd[IX(i + 1, j)] + dd[IX(i, j - 1)] + dd[IX(i, j + 1)])) / (1 + 4 * a);
}
}
bounds();
}
}
function inject(t: number): void {
const cx = N * 0.5 + Math.sin(t * 0.55) * N * 0.28;
const cy = N * 0.5 + Math.cos(t * 0.4) * N * 0.24;
const r = 2.2;
const amt = 0.85 + Math.sin(t * 2.3) * 0.15;
const dd0 = d0.current;
for (let j = -r; j <= r; j++) {
for (let i = -r; i <= r; i++) {
const q = (i * i + j * j) / (r * r);
if (q > 1) continue;
const f = Math.exp(-q * 3) * amt;
dd0[IX(Math.round(cx + i), Math.round(cy + j))] = Math.min(2, dd0[IX(Math.round(cx + i), Math.round(cy + j))] + f);
}
}
}
function step(dt: number): void {
d0.current.set(d.current);
inject(seedTRef.current);
diffuse(dt);
const dd = d.current;
for (let k = 0; k < SIZE; k++) dd[k] *= 0.992;
seedTRef.current += dt;
}
function render(): void {
const ctx = ctxRef.current, cv = cvRef.current;
if (!ctx || !cv) return;
const img = ctx.createImageData(cv.width, cv.height);
const px = img.data;
const cellW = cv.width / N, cellH = cv.height / N;
const pal = palRef.current;
const dd = d.current;
for (let py = 0; py < cv.height; py++) {
const gy = Math.min(N - 1, (py / cellH) | 0);
for (let pxx = 0; pxx < cv.width; pxx++) {
const gx = Math.min(N - 1, (pxx / cellW) | 0);
const q = dd[IX(gx + 1, gy + 1)];
const o = (py * cv.width + pxx) * 4;
if (q <= 0.002) { px[o] = 255; px[o + 1] = 255; px[o + 2] = 255; px[o + 3] = 255; continue; }
const band = q > 0.62 ? 2 : q > 0.28 ? 1 : 0;
const c = pal[band];
const rr = parseInt(c.slice(1, 3), 16), gg = parseInt(c.slice(3, 5), 16), bb = parseInt(c.slice(5, 7), 16);
const a = Math.min(1, q);
px[o] = 255 + (rr - 255) * a; px[o + 1] = 255 + (gg - 255) * a; px[o + 2] = 255 + (bb - 255) * a; px[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame(): void {
step(1 / 30);
render();
rafRef.current = requestAnimationFrame(frame);
}
function splat(gx: number, gy: number, amt: number): void {
const r = 3;
const dd = d.current;
for (let j = -r; j <= r; j++) {
for (let i = -r; i <= r; i++) {
const q = (i * i + j * j) / (r * r);
if (q > 1) continue;
if (gx + i < 1 || gx + i > N || gy + j < 1 || gy + j > N) continue;
const f = Math.exp(-q * 3) * amt;
const idx = IX(gx + i, gy + j);
dd[idx] = Math.min(2.2, dd[idx] + f);
}
}
}
function pointAt(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const gx = 1 + Math.round((e.clientX - r.left) / r.width * (N - 1));
const gy = 1 + Math.round((e.clientY - r.top) / r.height * (N - 1));
splat(gx, gy, 0.9);
if (reduced()) render();
}
function onKey(e: KeyEvt): void {
const dir = ({ ArrowLeft: [-2, 0], ArrowRight: [2, 0], ArrowUp: [0, -2], ArrowDown: [0, 2] } as Record<string, number[]>)[e.key];
if (!dir) return;
e.preventDefault();
splat((N >> 1) + dir[0], (N >> 1) + dir[1], 1.1);
if (reduced()) render();
}
function onResize(): void { fit(); if (reduced()) render(); }
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d", { willReadFrequently: true });
if (!ctxRef.current) return;
const root = rootRef.current;
if (root) {
const css = getComputedStyle(root);
const p0 = css.getPropertyValue("--dye-0").trim();
const p1 = css.getPropertyValue("--dye-1").trim();
const p2 = css.getPropertyValue("--dye-2").trim();
if (p0 && p1 && p2) palRef.current = [p0, p1, p2];
}
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
if (root) root.classList.add("is-still");
for (let k = 0; k < 40; k++) step(1 / 30);
render();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></g></svg>
<span>매 걸음 이웃 넷과 값을 섞는 확산이 잉크를 종이에 번지게 합니다 — 눌러서 방울을 떨어뜨려 보세요</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label="확산 번짐 실험판"
tabIndex={0}
onPointerDown={(e: PtrEvt) => pointAt(e)}
onPointerMove={(e: PtrEvt) => { if ((e.buttons ?? 0) !== 0) pointAt(e); }}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">6<span className={styles.badgeUnit}>완화</span></b>
</div>
</div>
</main>
);
}02 확산 번짐 — 측정 누적면적 45.362% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 유체 시뮬레이션 9종 — 캔버스 연기·잉크 격자 계산 (WebGL 없이)
이 데모가 속한 글
유체 시뮬레이션 9종 — 캔버스 연기·잉크 격자 계산 (WebGL 없이)유체 시뮬레이션은 물이나 연기의 실제 나비에-스토크스 방정식을 그대로 푸는 게 아니라, 격자 위에 놓인 밀도·속도 배열을 매 프레임 이류하고 확산시키고 감쇠시켜 흐름을 흉내 내는 계산이다. 이 편은 라이브러리 없이 이 글의 데모 전부 보기


