마블링 효과 css 9가지 — 잉크 확산·빗 결·돌 결의 흐르는 배경
물 위의 잉크 한 방울은 표면장력이 붙잡아 매는 얇은 막이다 — 04번은 그 막을 한 방향으로 밀어 물결 띠로 굳히고, 05번은 어긋난 두 빗질로 결을 깃털로 갈라 놓으며, 08번은 층마다 밀도 값을 눌러 돌의 띠로 새긴다.
잉크를 떨어뜨리고 빗으로 긁고 돌을 자르는 아홉 장면
01 mbl-drop-bloom-radial-diffusion
02 mbl-fractal-mesh-branching-veins
03 mbl-bouquet-fan-fold-pleats
04 mbl-rake-wave-chevron-bands
05 mbl-stagger-comb-feather-grain
06 mbl-ink-drop-crown-splash
07 mbl-nonpareil-dot-grid-scatter
08 mbl-agate-layer-density-bands
09 mbl-suminagashi-ink-concentric-rings
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 떨어진 잉크가 고리를 만들며 바깥으로 번지는 방울 피짐
- 02 밀린 잉크 면이 좁은 틈으로 갈라져 가지치는 부분 결
- 03 한 점에서 뻗는 주름이 부채처럼 접히는 부케 결
- 04 한 방향으로 밀린 물결 띠가 비스듬히 굳는 빗질 결
- 05 반대 방향 두 빗질이 어긋나 만나 깃털로 갈라지는 결
- 06 닿는 순간 잉크가 링 위로 솟았다 되떨어지는 왕관 튐
- 07 흩뿌린 점이 번지고 늘어나 얼룩지는 점 무늬 결
- 08 층마다 밀도가 달라 띠 폭이 달라지는 마노 돌 결
- 09 중심마다 퍼지는 동심원이 흔들리며 겹치는 먹 흐름 결
마블링 효과 css 9가지 — 잉크 확산·빗 결·돌 결로 그리는 흐르는 배경
순서는 물감이 겪는 일을 따라간다. 01~03 은 물 위에 놓인 잉크가 번지고 갈라지고 접히는 단계, 04·05 는 갈퀴와 빗으로 그 막을 긁는 단계, 06·07 은 방울과 점이 새로 떨어지는 순간, 08·09 는 층으로 굳은 돌의 단면과 고리로 퍼지는 먹이다. 아홉 장면은 모두 <canvas> 한 장에 그려지고, 스타일시트는 무대의 비율과 모서리, 버튼과 포커스 테두리만 정한다. 같은 계산이라도 상수 하나에 마블링 패턴의 인상이 크게 달라지는데, 그 숫자들은 아래 값 절에 따로 모았다.
2초 캡처 스물네 장으로 센 움직임
장면마다 2초에 걸쳐 스물네 장을 캡처하고 바로 앞 장과 달라진 픽셀을 셌다. 그래서 오른쪽 열은 비교 23번 가운데 화면이 바뀐 횟수다.
| 장면 | 누적면적 | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 방울 피짐 | 71.4% | 36.3 | 16/23 |
| 02 갈라지는 부분 결 | 30.3% | 29.0 | 23/23 |
| 03 부채 주름 | 72.9% | 26.3 | 23/23 |
| 04 갈퀴 물결 띠 | 71.1% | 20.5 | 23/23 |
| 05 엇갈린 빗 깃털 | 73.8% | 33.6 | 16/23 |
| 06 왕관 튐 | 14.2% | 36.4 | 23/23 |
| 07 흩뿌린 점 | 68.5% | 26.9 | 16/23 |
| 08 마노 층 | 60.4% | 31.9 | 23/23 |
| 09 먹 고리 | 72.0% | 56.9 | 23/23 |
누적면적이 30% 를 넘는 장면이 여덟이고 10% 아래로 내려간 장면은 없다. 가장 작은 06 의 14.2% 는 화면에서 바뀌는 것이 충격 링 선과 작은 알갱이뿐인 벡터 장면이라서 나온 크기다. 01·05·07 이 16/23 에 머문 것은 시연 일정 사이에 무늬가 멈춰 선 캡처가 끼어 있다는 뜻이고, 그 셋도 누적면적은 68% 를 넘는다.
이 무늬를 붙일 만한 자리
방울 피짐(01)과 부채 주름(03)은 화면 가운데에 시선이 모이는 무늬라 첫 화면이나 초대장 머리처럼 글자가 적은 자리에 맞는다. 갈퀴 물결(04)은 가로로 긴 띠라 푸터나 배너 아래 장식 띠로 잘라 쓰기 쉽고, 엇갈린 빗 깃털(05)은 결이 고르게 깔려 구독 신청 칸 뒤에 둘 만하다. 다만 그 위에 입력란을 얹을 때는 무늬 위에 바로 글자를 놓지 말고 불투명한 판을 한 겹 깔아야 글자 대비가 무늬 색에 흔들리지 않는다.
왕관 튐(06)은 누르는 순간에 반응하는 장면이라 클릭 피드백이나 히어로가 들어오는 한 박자에 어울리고, 흩뿌린 점(07)은 문구나 라벨 바탕처럼 작게 반복되는 면에 맞는다. 마노 층(08)은 한 장면만 멈춰 세워도 돌 단면처럼 읽혀서 명함이나 주얼리 배너에 정지 화면으로 써도 되고, 고리가 천천히 퍼지는 먹 흐름(09)은 브랜드 소개처럼 차분해야 하는 마블링 배경에 가깝다.
마블링 기법은 코드로 옮기면 무엇부터 계산하는가?
먼저 계산하는 것은 "이 픽셀에 지금 보이는 잉크는 원래 어디에 있던 것인가" 다. 01 의 inkAt 은 방울을 새것부터 거꾸로 되짚어 화면 점의 옛 자리를 찾고, 05 의 sourceY 는 두 번의 빗질을 y 에서 되돌려 처음 깔린 띠의 높이를 찾으며, 08 의 warp 은 좌표 자체를 노이즈로 비틀어 층을 찾을 주소를 바꾼다. 잉크 덩어리를 밀어 옮기는 대신 픽셀마다 출발점을 묻기 때문에, 이 세 장면은 무늬가 아무리 얽혀도 빈 구멍이나 두 번 칠한 자리 없이 화면 전체가 한 번에 채워진다.
작은 버퍼에 칠하고 크게 늘려 붙인다
01~05·07~09 여덟 장면은 화면보다 작은 필드(01 160×100, 02 96×60, 03·07·09 192×120, 04·05·08 240×150)에 픽셀 색을 계산해 createImageData 버퍼에 쓰고, putImageData 로 숨은 캔버스에 올린 다음 drawImage 로 480×300 무대에 늘려 붙인다. 늘릴 때 imageSmoothingQuality 를 'high' 로 두어 칸 경계를 부드럽게 한다. getImageData 는 아홉 파일 어디에도 없어서 화면을 다시 읽는 일 없이 쓰기만 하고, 06 하나는 이 버퍼를 쓰지 않고 타원과 선을 직접 그린다.
종이 결은 한 번만 굽고 시간은 16ms 로 자른다
바탕의 종이 결(paperGrain, 3옥타브 값 노이즈)은 시간이 흘러도 변하지 않아 initField 에서 grainF 배열에 한 번 구워 두고 픽셀마다 꺼내 쓴다. 첫 판은 이걸 프레임마다 다시 계산하다가 캡처에서 움직인 프레임이 비어 지금 구조로 바꿨다. 시간은 requestAnimationFrame 이 준 시각을 쌓아 16ms(FIXED_DT) 단위로 끊어 쓰는데, 한 번에 쌓는 양은 100ms, 도는 스텝은 6번이 상한이다. 캔버스 내부 해상도는 480×300 에 화면 배율을 곱하되 Math.min(window.devicePixelRatio || 1, 2) 로 2배에서 멈춘다.
난수 대신 정수 해시라서 무늬가 매번 같다
Math.random 은 한 번도 부르지 않는다. Math.imul 로 섞는 정수 해시 hash1·hash2 와 그 위의 값 노이즈 vnoise2, 옥타브를 겹치는 fbm2 가 무늬의 불규칙함을 모두 맡고, 호출마다 시드 숫자를 따로 넘긴다(02 의 이득 지도 211, 07 의 점 자리 811, 08 의 비틂 101·313, 09 의 반경 흔들림 401). 그래서 새로고침해도 같은 자리에 같은 결이 다시 선다.
아래 항목의 코드는 장면별 index.html 스크립트에서 중간 줄을 빼지 않고 한 구간을 그대로 떠 온 것이다. 함수 이름이 소스와 같아서, 받은 파일에서 그 이름을 찾으면 앞뒤 맥락까지 바로 이어진다.
01떨어진 잉크가 고리를 만들며 바깥으로 번지는 방울 피짐
반경 R 인 방울이 떨어지면 바깥 점은 중심에서 √(1 + R²/d²) 배 멀어지는데, inkAt 은 그 역인 √(1 − R²/d²) 배를 새 방울부터 차례로 곱해 화면 점이 방울들 이전에 있던 자리를 되짚는다. 이 변위는 넓이를 보존해서, BLOOM_RADIUS 96 방울 두 개를 같은 자리에 놓고 중심에서 108.2px 인 점을 되짚으면 첫 방울 안 반경 비율 0.521(50/96)이 나온다 — 50px 에 있던 잉크가 정확히 108.2px 까지 밀려났다는 뜻이다. 방울 안은 R/RING_N = 19.2px 폭의 띠 다섯 겹으로 칠하고 EDGE_PUSH 0.35 자리에 되밀린 잉크 한 줄을 겹쳐 이중 띠를 만들며, 렌더에서는 누적면적 71.4%·강도 36.3·16/23 프레임이 나왔다.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="떨어진 잉크가 고리를 만들며 바깥으로 번지는 방울 피짐"></canvas>
<p class="hs__status" data-role="status" aria-live="off">고리가 바깥으로 번지는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">잉크 떨어뜨리기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.hs {
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-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: 2px;
}
// 포인터로는 잉크를 다루고, 쓸어 넘기는 손가락 제스처는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
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: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER = [252, 246, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx = null;
let buf = null;
let px = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF = new Float32Array(0);
function initField(nw, nh) {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) {
return;
}
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) {
grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh);
}
}
}
function blitField(nw, nh) {
if (!sctx || !buf) {
return;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n, seed) {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t) { return t * t * (3 - 2 * t); }
function vnoise2(x, y, seed) {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x, y, oct, seed) {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x, y) {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 01 방울 피짐 — 착탄점 거리별 확산량을 반경 띠에 누적, 농고리 안쪽 역류로 이중 띠
const BLOOM_RADIUS = 96; // 방울 하나가 번져 닿는 반경(px)
const RING_N = 5; // 방울 하나가 남기는 고리(띠) 수
const EDGE_PUSH = 0.35; // 고리 한 칸 폭 가운데 안쪽으로 되밀린 농띠의 자리(0~1) — 이중 띠의 안쪽 줄
const GROW_MS = 1100; // 방울이 다 번지는 데 걸리는 시간
const DROP_MAX = 7; // 화면에 남겨 두는 방울 수 — 더 오래된 방울은 이미 화면 밖으로 밀려나 있다
const DEMO_LOOP = 3200;
const FW = 160;
const FH = 100;
const SPOTS = [[214, 138], [292, 162], [236, 112], [170, 178], [322, 120], [252, 184]];
const TXT_BLOOM = '고리가 바깥으로 번지는 중';
const TXT_DOUBLE = '농고리 안쪽이 밀려 이중 띠';
const TXT_SETTLE = '번짐이 멎음';
const TXT_GUIDE = '화살표로 위치를 옮기고 엔터로 떨어뜨림';
let blooms = [];
let dropCount = 0;
let spotI = 0;
let cursorX = W / 2;
let cursorY = H / 2;
let showCursor = false;
function addBloom(x, y, t0) {
blooms.push({ x: x, y: y, tint: dropCount % TINTS.length, t0: t0 });
dropCount += 1;
if (blooms.length > DROP_MAX) {
blooms.shift();
}
}
function nextSpot(t0) {
const s = SPOTS[spotI % SPOTS.length];
spotI += 1;
addBloom(s[0], s[1], t0);
}
// 지금 반경 — 처음엔 빨리, 끝으로 갈수록 천천히 번진다(1−(1−a)² 의 제곱근)
function radiusOf(b, now) {
const a = clamp((now - b.t0) / GROW_MS, 0, 1);
return BLOOM_RADIUS * Math.sqrt(1 - (1 - a) * (1 - a));
}
// 화면 점이 어느 방울의 몇 번째 띠 위에 있나 — 새 방울부터 거꾸로 되짚는다.
// 반경 R 방울이 떨어지면 바깥 점 p 는 c + (p−c)·√(1 + R²/|p−c|²) 로 밀려난다(넓이 보존).
// 그러니 지금 화면 점이 방울 전에 있던 자리는 c + (p−c)·√(1 − R²/|p−c|²) 이다
let hitI = -1;
let hitQ = 0;
function inkAt(x, y, now) {
let qx = x, qy = y;
hitI = -1;
for (let i = blooms.length - 1; i >= 0; i -= 1) {
const b = blooms[i];
const R = radiusOf(b, now);
if (R <= 0) {
continue;
}
const dx = qx - b.x, dy = qy - b.y;
const d2 = dx * dx + dy * dy;
if (d2 < R * R) {
hitI = i;
hitQ = Math.sqrt(d2) / R;
return;
}
const s = Math.sqrt(1 - (R * R) / d2);
qx = b.x + dx * s;
qy = b.y + dy * s;
}
}
// 띠 하나의 색 — 안쪽 띠일수록 잉크가 많이 쌓여 진하고(거리별 확산량 누적), 띠 바깥 가장자리는 농고리,
// 농고리 바로 안쪽에서 EDGE_PUSH 자리로 되밀린 잉크가 한 줄 더 생겨 이중 띠가 된다
function bandColor(base, tint, q) {
const f = q * RING_N;
const band = Math.floor(f);
const fr = f - band;
const amount = 1 - (band / RING_N) * 0.62;
let col = band % 2 === 0 ? mix(base, tint, 0.34 + 0.5 * amount) : mix(base, mix(tint, PAPER, 0.55), 0.5 + 0.3 * amount);
const rim = smooth((fr - 0.84) / 0.12);
const back = 1 - smooth(Math.abs(fr - EDGE_PUSH) / 0.07);
col = mix(col, INK, rim * 0.34 * amount + back * 0.2 * amount);
return col;
}
function draw() {
if (!px || !sctx) {
return;
}
const now = clock;
const sx = W / FW, sy = H / FH;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
for (let x = 0; x < FW; x += 1) {
const g = grainF[y * FW + x];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
inkAt((x + 0.5) * sx, (y + 0.5) * sy, now);
const col = hitI < 0 ? base : bandColor(base, TINTS[blooms[hitI].tint], hitQ);
px[i4] = col[0];
px[i4 + 1] = col[1];
px[i4 + 2] = col[2];
px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
if (showCursor) {
ctx.beginPath();
ctx.arc(cursorX, cursorY, 7, 0, Math.PI * 2);
ctx.strokeStyle = rgba(INK, 0.8);
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
function seedBlooms() {
blooms = [];
for (let i = 0; i < 4; i += 1) {
nextSpot(-1e9);
}
}
function settle() {
seedBlooms();
say(TXT_SETTLE);
}
function step() {
if (!demo || !blooms.length) {
return;
}
const last = blooms[blooms.length - 1];
say(clock - last.t0 < GROW_MS ? TXT_BLOOM : TXT_DOUBLE);
}
function dropAt(x, y) {
addBloom(x, y, still ? -1e9 : clock);
say(still ? TXT_DOUBLE : TXT_BLOOM);
if (still) {
draw();
}
}
function demoTick(t) {
if (crossed(t, 200, DEMO_LOOP) || crossed(t, 700, DEMO_LOOP) || crossed(t, 1200, DEMO_LOOP)) {
nextSpot(clock);
}
}
seedBlooms();
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]');
if (btn) {
btn.addEventListener('click', function () {
wake();
const s = SPOTS[spotI % SPOTS.length];
spotI += 1;
dropAt(s[0], s[1]);
});
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
cursorX = p[0];
cursorY = p[1];
dropAt(p[0], p[1]);
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight' && k !== 'ArrowUp' && k !== 'ArrowDown' && k !== 'Enter' && k !== ' ') {
return;
}
ev.preventDefault();
wake();
showCursor = true;
if (k === 'Enter' || k === ' ') {
dropAt(cursorX, cursorY);
return;
}
cursorX = clamp(cursorX + (k === 'ArrowLeft' ? -16 : (k === 'ArrowRight' ? 16 : 0)), 8, W - 8);
cursorY = clamp(cursorY + (k === 'ArrowUp' ? -12 : (k === 'ArrowDown' ? 12 : 0)), 8, H - 8);
say(TXT_GUIDE);
if (still) {
draw();
}
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MblDropBloomRadialDiffusion - vanilla 판(01-mbl-drop-bloom-radial-diffusion) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MblDropBloomRadialDiffusion.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MblDropBloomRadialDiffusion() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER: RGB = [252, 246, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx: CanvasRenderingContext2D | null = null;
let buf: ImageData | null = null;
let px: Uint8ClampedArray | null = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF: Float32Array = new Float32Array(0);
function initField(nw: number, nh: number): void {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) { return; }
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) { grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh); }
}
}
function blitField(nw: number, nh: number): void {
if (!sctx || !buf) { return; }
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n: number, seed: number): number {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t: number): number { return t * t * (3 - 2 * t); }
function vnoise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x: number, y: number, oct: number, seed: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x: number, y: number): number {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 01 방울 피짐 — 착탄점 거리별 확산량을 반경 띠에 누적, 농고리 안쪽 역류로 이중 띠
const BLOOM_RADIUS = 96; // 방울 하나가 번져 닿는 반경(px)
const RING_N = 5; // 방울 하나가 남기는 고리(띠) 수
const EDGE_PUSH = 0.35; // 고리 한 칸 폭 가운데 안쪽으로 되밀린 농띠의 자리(0~1) — 이중 띠의 안쪽 줄
const GROW_MS = 1100; // 방울이 다 번지는 데 걸리는 시간
const DROP_MAX = 7; // 화면에 남겨 두는 방울 수 — 더 오래된 방울은 이미 화면 밖으로 밀려나 있다
const DEMO_LOOP = 3200;
const FW = 160;
const FH = 100;
const SPOTS: [number, number][] = [[214, 138], [292, 162], [236, 112], [170, 178], [322, 120], [252, 184]];
const TXT_BLOOM = '고리가 바깥으로 번지는 중';
const TXT_DOUBLE = '농고리 안쪽이 밀려 이중 띠';
const TXT_SETTLE = '번짐이 멎음';
const TXT_GUIDE = '화살표로 위치를 옮기고 엔터로 떨어뜨림';
type Bloom = { x: number; y: number; tint: number; t0: number };
let blooms: Bloom[] = [];
let dropCount = 0;
let spotI = 0;
let cursorX = W / 2;
let cursorY = H / 2;
let showCursor = false;
function addBloom(x: number, y: number, t0: number): void {
blooms.push({ x: x, y: y, tint: dropCount % TINTS.length, t0: t0 });
dropCount += 1;
if (blooms.length > DROP_MAX) { blooms.shift(); }
}
function nextSpot(t0: number): void {
const s = SPOTS[spotI % SPOTS.length];
spotI += 1;
addBloom(s[0], s[1], t0);
}
// 지금 반경 — 처음엔 빨리, 끝으로 갈수록 천천히 번진다(1−(1−a)² 의 제곱근)
function radiusOf(b: Bloom, now: number): number {
const a = clamp((now - b.t0) / GROW_MS, 0, 1);
return BLOOM_RADIUS * Math.sqrt(1 - (1 - a) * (1 - a));
}
// 화면 점이 어느 방울의 몇 번째 띠 위에 있나 — 새 방울부터 거꾸로 되짚는다.
// 반경 R 방울이 떨어지면 바깥 점 p 는 c + (p−c)·√(1 + R²/|p−c|²) 로 밀려난다(넓이 보존).
// 그러니 지금 화면 점이 방울 전에 있던 자리는 c + (p−c)·√(1 − R²/|p−c|²) 이다
let hitI = -1;
let hitQ = 0;
function inkAt(x: number, y: number, now: number): void {
let qx = x, qy = y;
hitI = -1;
for (let i = blooms.length - 1; i >= 0; i -= 1) {
const b = blooms[i];
const R = radiusOf(b, now);
if (R <= 0) { continue; }
const dx = qx - b.x, dy = qy - b.y;
const d2 = dx * dx + dy * dy;
if (d2 < R * R) {
hitI = i;
hitQ = Math.sqrt(d2) / R;
return;
}
const s = Math.sqrt(1 - (R * R) / d2);
qx = b.x + dx * s;
qy = b.y + dy * s;
}
}
// 띠 하나의 색 — 안쪽 띠일수록 잉크가 많이 쌓여 진하고(거리별 확산량 누적), 띠 바깥 가장자리는 농고리,
// 농고리 바로 안쪽에서 EDGE_PUSH 자리로 되밀린 잉크가 한 줄 더 생겨 이중 띠가 된다
function bandColor(base: RGB, tint: RGB, q: number): RGB {
const f = q * RING_N;
const band = Math.floor(f);
const fr = f - band;
const amount = 1 - (band / RING_N) * 0.62;
let col = band % 2 === 0 ? mix(base, tint, 0.34 + 0.5 * amount) : mix(base, mix(tint, PAPER, 0.55), 0.5 + 0.3 * amount);
const rim = smooth((fr - 0.84) / 0.12);
const back = 1 - smooth(Math.abs(fr - EDGE_PUSH) / 0.07);
col = mix(col, INK, rim * 0.34 * amount + back * 0.2 * amount);
return col;
}
function draw(): void {
if (!px || !sctx) { return; }
const now = clock;
const sx = W / FW, sy = H / FH;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
for (let x = 0; x < FW; x += 1) {
const g = grainF[y * FW + x];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
inkAt((x + 0.5) * sx, (y + 0.5) * sy, now);
const col = hitI < 0 ? base : bandColor(base, TINTS[blooms[hitI].tint], hitQ);
px[i4] = col[0]; px[i4 + 1] = col[1]; px[i4 + 2] = col[2]; px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
if (showCursor) {
ctx.beginPath();
ctx.arc(cursorX, cursorY, 7, 0, Math.PI * 2);
ctx.strokeStyle = rgba(INK, 0.8);
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
function seedBlooms(): void {
blooms = [];
for (let i = 0; i < 4; i += 1) { nextSpot(-1e9); }
}
function settle(): void {
seedBlooms();
say(TXT_SETTLE);
}
function step(): void {
if (!demo || !blooms.length) { return; }
const last = blooms[blooms.length - 1];
say(clock - last.t0 < GROW_MS ? TXT_BLOOM : TXT_DOUBLE);
}
function dropAt(x: number, y: number): void {
addBloom(x, y, still ? -1e9 : clock);
say(still ? TXT_DOUBLE : TXT_BLOOM);
if (still) { draw(); }
}
function demoTick(t: number): void {
if (crossed(t, 200, DEMO_LOOP) || crossed(t, 700, DEMO_LOOP) || crossed(t, 1200, DEMO_LOOP)) { nextSpot(clock); }
}
seedBlooms();
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (btn) {
btn.addEventListener('click', function (): void {
wake();
const s = SPOTS[spotI % SPOTS.length];
spotI += 1;
dropAt(s[0], s[1]);
});
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
cursorX = p[0];
cursorY = p[1];
dropAt(p[0], p[1]);
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight' && k !== 'ArrowUp' && k !== 'ArrowDown' && k !== 'Enter' && k !== ' ') { return; }
ev.preventDefault();
wake();
showCursor = true;
if (k === 'Enter' || k === ' ') {
dropAt(cursorX, cursorY);
return;
}
cursorX = clamp(cursorX + (k === 'ArrowLeft' ? -16 : (k === 'ArrowRight' ? 16 : 0)), 8, W - 8);
cursorY = clamp(cursorY + (k === 'ArrowUp' ? -12 : (k === 'ArrowDown' ? 12 : 0)), 8, H - 8);
say(TXT_GUIDE);
if (still) { draw(); }
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="떨어진 잉크가 고리를 만들며 바깥으로 번지는 방울 피짐"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">고리가 바깥으로 번지는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="btn" type="button">잉크 떨어뜨리기</button>
</div>
</div>
);
}01 · 떨어진 잉크가 고리를 만들며 바깥으로 번지는 방울 피짐 — 측정 누적면적 71.384% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function inkAt(x, y, now) {
let qx = x, qy = y;
hitI = -1;
for (let i = blooms.length - 1; i >= 0; i -= 1) {
const b = blooms[i];
const R = radiusOf(b, now);
if (R <= 0) {
continue;
}
const dx = qx - b.x, dy = qy - b.y;
const d2 = dx * dx + dy * dy;
if (d2 < R * R) {
hitI = i;
hitQ = Math.sqrt(d2) / R;
return;
}
const s = Math.sqrt(1 - (R * R) / d2);
qx = b.x + dx * s;
qy = b.y + dy * s;
}
}
02밀린 잉크 면이 좁은 틈으로 갈라져 가지치는 부분 결
96×60 격자의 셀마다 압력을 밀도 × (1.15 − 이득) 으로 두고, 이득을 fbm2 3옥타브(SPLIT_DEPTH 3)로 깔아 큰 골짜기 안에 작은 골짜기를 판 뒤, 오른쪽·아래 이웃끼리 압력 차 × DENSITY_DIFF_K 0.16 만큼 잉크를 넘기되 한 번에 셀 밀도의 24% 에서 자른다. 교환만으로는 잉크가 흐린 얼룩으로 고르게 퍼지기 때문에, tradeDens 끝의 반응항 d + EDGE_KEEP·d(1−d)(d−½)·4 가 0 과 1 사이의 값을 양 끝으로 되밀어 골짜기가 비는 자리를 날카로운 틈으로 세운다. 소스 함수를 노드에서 돌리면 틈(밀도 0.1 미만 셀)은 10.6% 에서 160스텝(2.56초) 뒤 22.7% 까지 넓어지고, 렌더 측정은 누적면적 30.3%·강도 29.0·23/23 이다.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="밀린 잉크 면이 좁은 틈으로 갈라져 가지치는 부분 결"></canvas>
<p class="hs__status" data-role="status" aria-live="off">밀린 가장자리가 틈으로 갈라진다</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">밀기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.hs {
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-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: 2px;
}
// 포인터로는 잉크를 다루고, 쓸어 넘기는 손가락 제스처는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
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: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER = [252, 246, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx = null;
let buf = null;
let px = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF = new Float32Array(0);
function initField(nw, nh) {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) {
return;
}
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) {
grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh);
}
}
}
function blitField(nw, nh) {
if (!sctx || !buf) {
return;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n, seed) {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t) { return t * t * (3 - 2 * t); }
function vnoise2(x, y, seed) {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x, y, oct, seed) {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x, y) {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 02 부분 결 — 격자 셀 간 밀도차 잉크 교환으로 가장자리 열화·분기 반복
const CELL_N = 60; // 격자 세로 셀 수(가로는 무대 비율로 96)
const DENSITY_DIFF_K = 0.16; // 이웃 셀과의 압력(밀도)차 가운데 한 번에 넘기는 비율
const SPLIT_DEPTH = 3; // 틈이 다시 두 갈래로 나뉘는 깊이(이득장 노이즈 옥타브 수)
const EDGE_KEEP = 0.12; // 찢어진 경계를 날카롭게 붙잡는 반응항 세기 — 0 이면 잉크가 흐려지며 퍼지기만 한다
const PUSH_K = 0.5; // 미는 방향으로 더 흘려 보내는 비율
const TRADES = 3; // 16ms 스텝 하나에 교환을 몇 번 돌리나
const GX = Math.round(CELL_N * W / H);
const GY = CELL_N;
const DEMO_LOOP = 3600;
const TXT_EDGE = '밀린 가장자리가 틈으로 갈라진다';
const TXT_FORK = '틈이 두 갈래로 나뉜다';
const TXT_SETTLE = '결이 멎음';
const TXT_GUIDE = '화살표로 잉크를 민다';
let dens = new Float32Array(GX * GY);
// 셀마다 잉크를 붙잡는 힘(이득)이 다르다 — 옥타브를 SPLIT_DEPTH 겹 쌓아 큰 골짜기 안에 작은 골짜기가 생기게 한다
const gain = new Float32Array(GX * GY);
for (let y = 0; y < GY; y += 1) {
for (let x = 0; x < GX; x += 1) {
gain[y * GX + x] = fbm2(x * 0.1, y * 0.1, SPLIT_DEPTH, 211);
}
}
let pushX = 0;
let pushY = 0;
let pushLeft = 0; // 남은 밀기 스텝(사람이 민 경우)
let pushCount = 0;
let trades = 0;
function seedDens() {
// 잉크 면 — 종이 거의 전체를 0.85 농도 잉크 한 장으로 덮고, 가장자리만 부드럽게 비운다
for (let y = 0; y < GY; y += 1) {
for (let x = 0; x < GX; x += 1) {
const ex = Math.min(x, GX - 1 - x) / 6, ey = Math.min(y, GY - 1 - y) / 6;
dens[y * GX + x] = 0.85 * smooth(Math.min(ex, ey));
}
}
trades = 0;
}
// 셀의 압력 = 밀도 × (1.15 − 이득) — 이득이 낮은 골짜기 셀은 같은 밀도에서도 압력이 높아 잉크를 밀어낸다
function pressure(i) { return dens[i] * (1.15 - gain[i]); }
// 이웃 한 쌍의 교환 — 압력차 × K 만큼 높은 쪽에서 낮은 쪽으로, 미는 방향이면 PUSH_K 만큼 더
function trade(out, i, j, along) {
let flow = (pressure(i) - pressure(j)) * DENSITY_DIFF_K;
if (along > 0) {
flow += dens[i] * DENSITY_DIFF_K * PUSH_K;
}
else if (along < 0) {
flow -= dens[j] * DENSITY_DIFF_K * PUSH_K;
}
flow = clamp(flow, -dens[j] * 0.24, dens[i] * 0.24);
out[i] -= flow;
out[j] += flow;
}
// 교환 한 번 — 오른쪽·아래 이웃 쌍만 돌아 한 쌍을 두 번 세지 않는다. 골짜기가 비며 틈이 되고,
// 반응항 d(1−d)(d−½) 이 0 과 1 사이 값을 양 끝으로 되밀어 틈의 가장자리가 흐려지지 않는다
function tradeDens(dx, dy) {
const out = new Float32Array(dens);
for (let y = 0; y < GY; y += 1) {
for (let x = 0; x < GX; x += 1) {
const i = y * GX + x;
if (x + 1 < GX) {
trade(out, i, i + 1, dx);
}
if (y + 1 < GY) {
trade(out, i, i + GX, dy);
}
}
}
for (let i = 0; i < out.length; i += 1) {
const d = out[i];
out[i] = clamp(d + EDGE_KEEP * d * (1 - d) * (d - 0.5) * 4, 0, 1);
}
dens = out;
trades += 1;
}
// 틈 비율 — 잉크가 0.1 아래로 빠진 셀의 몫(사실 측정·상태 줄용)
function gapShare() {
let n = 0;
for (let i = 0; i < dens.length; i += 1) {
if (dens[i] < 0.1) {
n += 1;
}
}
return n / dens.length;
}
function draw() {
if (!px || !sctx) {
return;
}
let i4 = 0;
for (let y = 0; y < GY; y += 1) {
for (let x = 0; x < GX; x += 1) {
const i = y * GX + x;
const g = grainF[i];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
const v = dens[i];
let col = base;
if (v > 0.06) {
// 잉크 섬마다 이득 높이로 색을 갈라 결을 만들고, 진한 곳은 잉크빛을 더한다
const tint = TINTS[Math.floor(gain[i] * 11) % TINTS.length];
col = mix(base, tint, smooth(v * 1.4) * 0.8);
if (v > 0.8) {
col = mix(col, INK, (v - 0.8) * 0.9);
}
}
px[i4] = col[0];
px[i4 + 1] = col[1];
px[i4 + 2] = col[2];
px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(GX, GY);
}
function settle() {
seedDens();
for (let n = 0; n < 240; n += 1) {
tradeDens(0, 0);
}
say(TXT_SETTLE);
}
function step() {
const active = demo || pushLeft > 0 || trades < 600;
if (!active) {
return;
}
for (let n = 0; n < TRADES; n += 1) {
tradeDens(pushX, pushY);
}
if (pushLeft > 0) {
pushLeft -= 1;
if (pushLeft === 0) {
pushX = 0;
pushY = 0;
say(TXT_SETTLE);
}
}
}
function pushInk(dx, dy) {
pushX = dx;
pushY = dy;
pushCount += 1;
if (still) {
for (let n = 0; n < 24; n += 1) {
tradeDens(dx, dy);
}
say(TXT_EDGE);
draw();
return;
}
pushLeft = 60;
say(pushCount % 2 === 0 ? TXT_FORK : TXT_EDGE);
}
// 시연 — 잉크 한 장이 깔린 채 시작해 저절로 갈라지고, 900ms 부터는 오른쪽 → 아래 → 왼쪽으로 민다.
// 다시 깔기는 캡처 창 밖(3500ms)에서만 한다
const DIRS = [[1, 0], [0, 1], [-1, 0], [0, -1]];
function demoTick(t) {
if (crossed(t, 200, DEMO_LOOP)) {
pushX = 0;
pushY = 0;
say(TXT_EDGE);
}
for (let k = 0; k < 5; k += 1) {
if (crossed(t, 900 + k * 500, DEMO_LOOP)) {
pushX = DIRS[k % 4][0];
pushY = DIRS[k % 4][1];
say(TXT_FORK);
}
}
if (crossed(t, 3500, DEMO_LOOP)) {
seedDens();
pushX = 0;
pushY = 0;
}
}
seedDens();
initField(GX, GY);
const btn = root.querySelector('[data-role="btn"]');
if (btn) {
btn.addEventListener('click', function () {
wake();
const d = DIRS[pushCount % 4];
pushInk(d[0], d[1]);
});
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
const dx = p[0] - W / 2, dy = p[1] - H / 2;
// 누른 쪽에서 반대쪽으로 민다 — 가로·세로 중 더 먼 축으로
if (Math.abs(dx) / W > Math.abs(dy) / H) {
pushInk(dx < 0 ? 1 : -1, 0);
}
else {
pushInk(0, dy < 0 ? 1 : -1);
}
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight' && k !== 'ArrowUp' && k !== 'ArrowDown') {
return;
}
ev.preventDefault();
wake();
say(TXT_GUIDE);
pushInk(k === 'ArrowRight' ? 1 : (k === 'ArrowLeft' ? -1 : 0), k === 'ArrowDown' ? 1 : (k === 'ArrowUp' ? -1 : 0));
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MblFractalMeshBranchingVeins - vanilla 판(02-mbl-fractal-mesh-branching-veins) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MblFractalMeshBranchingVeins.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MblFractalMeshBranchingVeins() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER: RGB = [252, 246, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx: CanvasRenderingContext2D | null = null;
let buf: ImageData | null = null;
let px: Uint8ClampedArray | null = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF: Float32Array = new Float32Array(0);
function initField(nw: number, nh: number): void {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) { return; }
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) { grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh); }
}
}
function blitField(nw: number, nh: number): void {
if (!sctx || !buf) { return; }
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n: number, seed: number): number {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t: number): number { return t * t * (3 - 2 * t); }
function vnoise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x: number, y: number, oct: number, seed: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x: number, y: number): number {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 02 부분 결 — 격자 셀 간 밀도차 잉크 교환으로 가장자리 열화·분기 반복
const CELL_N = 60; // 격자 세로 셀 수(가로는 무대 비율로 96)
const DENSITY_DIFF_K = 0.16; // 이웃 셀과의 압력(밀도)차 가운데 한 번에 넘기는 비율
const SPLIT_DEPTH = 3; // 틈이 다시 두 갈래로 나뉘는 깊이(이득장 노이즈 옥타브 수)
const EDGE_KEEP = 0.12; // 찢어진 경계를 날카롭게 붙잡는 반응항 세기 — 0 이면 잉크가 흐려지며 퍼지기만 한다
const PUSH_K = 0.5; // 미는 방향으로 더 흘려 보내는 비율
const TRADES = 3; // 16ms 스텝 하나에 교환을 몇 번 돌리나
const GX = Math.round(CELL_N * W / H);
const GY = CELL_N;
const DEMO_LOOP = 3600;
const TXT_EDGE = '밀린 가장자리가 틈으로 갈라진다';
const TXT_FORK = '틈이 두 갈래로 나뉜다';
const TXT_SETTLE = '결이 멎음';
const TXT_GUIDE = '화살표로 잉크를 민다';
let dens: Float32Array = new Float32Array(GX * GY);
// 셀마다 잉크를 붙잡는 힘(이득)이 다르다 — 옥타브를 SPLIT_DEPTH 겹 쌓아 큰 골짜기 안에 작은 골짜기가 생기게 한다
const gain: Float32Array = new Float32Array(GX * GY);
for (let y = 0; y < GY; y += 1) {
for (let x = 0; x < GX; x += 1) { gain[y * GX + x] = fbm2(x * 0.1, y * 0.1, SPLIT_DEPTH, 211); }
}
let pushX = 0;
let pushY = 0;
let pushLeft = 0; // 남은 밀기 스텝(사람이 민 경우)
let pushCount = 0;
let trades = 0;
function seedDens(): void {
// 잉크 면 — 종이 거의 전체를 0.85 농도 잉크 한 장으로 덮고, 가장자리만 부드럽게 비운다
for (let y = 0; y < GY; y += 1) {
for (let x = 0; x < GX; x += 1) {
const ex = Math.min(x, GX - 1 - x) / 6, ey = Math.min(y, GY - 1 - y) / 6;
dens[y * GX + x] = 0.85 * smooth(Math.min(ex, ey));
}
}
trades = 0;
}
// 셀의 압력 = 밀도 × (1.15 − 이득) — 이득이 낮은 골짜기 셀은 같은 밀도에서도 압력이 높아 잉크를 밀어낸다
function pressure(i: number): number { return dens[i] * (1.15 - gain[i]); }
// 이웃 한 쌍의 교환 — 압력차 × K 만큼 높은 쪽에서 낮은 쪽으로, 미는 방향이면 PUSH_K 만큼 더
function trade(out: Float32Array, i: number, j: number, along: number): void {
let flow = (pressure(i) - pressure(j)) * DENSITY_DIFF_K;
if (along > 0) { flow += dens[i] * DENSITY_DIFF_K * PUSH_K; }
else if (along < 0) { flow -= dens[j] * DENSITY_DIFF_K * PUSH_K; }
flow = clamp(flow, -dens[j] * 0.24, dens[i] * 0.24);
out[i] -= flow;
out[j] += flow;
}
// 교환 한 번 — 오른쪽·아래 이웃 쌍만 돌아 한 쌍을 두 번 세지 않는다. 골짜기가 비며 틈이 되고,
// 반응항 d(1−d)(d−½) 이 0 과 1 사이 값을 양 끝으로 되밀어 틈의 가장자리가 흐려지지 않는다
function tradeDens(dx: number, dy: number): void {
const out = new Float32Array(dens);
for (let y = 0; y < GY; y += 1) {
for (let x = 0; x < GX; x += 1) {
const i = y * GX + x;
if (x + 1 < GX) { trade(out, i, i + 1, dx); }
if (y + 1 < GY) { trade(out, i, i + GX, dy); }
}
}
for (let i = 0; i < out.length; i += 1) {
const d = out[i];
out[i] = clamp(d + EDGE_KEEP * d * (1 - d) * (d - 0.5) * 4, 0, 1);
}
dens = out;
trades += 1;
}
// 틈 비율 — 잉크가 0.1 아래로 빠진 셀의 몫(사실 측정·상태 줄용)
function gapShare(): number {
let n = 0;
for (let i = 0; i < dens.length; i += 1) { if (dens[i] < 0.1) { n += 1; } }
return n / dens.length;
}
function draw(): void {
if (!px || !sctx) { return; }
let i4 = 0;
for (let y = 0; y < GY; y += 1) {
for (let x = 0; x < GX; x += 1) {
const i = y * GX + x;
const g = grainF[i];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
const v = dens[i];
let col = base;
if (v > 0.06) {
// 잉크 섬마다 이득 높이로 색을 갈라 결을 만들고, 진한 곳은 잉크빛을 더한다
const tint = TINTS[Math.floor(gain[i] * 11) % TINTS.length];
col = mix(base, tint, smooth(v * 1.4) * 0.8);
if (v > 0.8) { col = mix(col, INK, (v - 0.8) * 0.9); }
}
px[i4] = col[0]; px[i4 + 1] = col[1]; px[i4 + 2] = col[2]; px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(GX, GY);
}
function settle(): void {
seedDens();
for (let n = 0; n < 240; n += 1) { tradeDens(0, 0); }
say(TXT_SETTLE);
}
function step(): void {
const active = demo || pushLeft > 0 || trades < 600;
if (!active) { return; }
for (let n = 0; n < TRADES; n += 1) { tradeDens(pushX, pushY); }
if (pushLeft > 0) {
pushLeft -= 1;
if (pushLeft === 0) { pushX = 0; pushY = 0; say(TXT_SETTLE); }
}
}
function pushInk(dx: number, dy: number): void {
pushX = dx;
pushY = dy;
pushCount += 1;
if (still) {
for (let n = 0; n < 24; n += 1) { tradeDens(dx, dy); }
say(TXT_EDGE);
draw();
return;
}
pushLeft = 60;
say(pushCount % 2 === 0 ? TXT_FORK : TXT_EDGE);
}
// 시연 — 잉크 한 장이 깔린 채 시작해 저절로 갈라지고, 900ms 부터는 오른쪽 → 아래 → 왼쪽으로 민다.
// 다시 깔기는 캡처 창 밖(3500ms)에서만 한다
const DIRS: [number, number][] = [[1, 0], [0, 1], [-1, 0], [0, -1]];
function demoTick(t: number): void {
if (crossed(t, 200, DEMO_LOOP)) { pushX = 0; pushY = 0; say(TXT_EDGE); }
for (let k = 0; k < 5; k += 1) {
if (crossed(t, 900 + k * 500, DEMO_LOOP)) {
pushX = DIRS[k % 4][0];
pushY = DIRS[k % 4][1];
say(TXT_FORK);
}
}
if (crossed(t, 3500, DEMO_LOOP)) { seedDens(); pushX = 0; pushY = 0; }
}
seedDens();
initField(GX, GY);
const btn = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (btn) {
btn.addEventListener('click', function (): void {
wake();
const d = DIRS[pushCount % 4];
pushInk(d[0], d[1]);
});
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
const dx = p[0] - W / 2, dy = p[1] - H / 2;
// 누른 쪽에서 반대쪽으로 민다 — 가로·세로 중 더 먼 축으로
if (Math.abs(dx) / W > Math.abs(dy) / H) { pushInk(dx < 0 ? 1 : -1, 0); } else { pushInk(0, dy < 0 ? 1 : -1); }
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight' && k !== 'ArrowUp' && k !== 'ArrowDown') { return; }
ev.preventDefault();
wake();
say(TXT_GUIDE);
pushInk(k === 'ArrowRight' ? 1 : (k === 'ArrowLeft' ? -1 : 0), k === 'ArrowDown' ? 1 : (k === 'ArrowUp' ? -1 : 0));
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="밀린 잉크 면이 좁은 틈으로 갈라져 가지치는 부분 결"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">밀린 가장자리가 틈으로 갈라진다</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="btn" type="button">밀기</button>
</div>
</div>
);
}02 · 밀린 잉크 면이 좁은 틈으로 갈라져 가지치는 부분 결 — 측정 누적면적 30.344% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 교환 한 번 — 오른쪽·아래 이웃 쌍만 돌아 한 쌍을 두 번 세지 않는다. 골짜기가 비며 틈이 되고,
// 반응항 d(1−d)(d−½) 이 0 과 1 사이 값을 양 끝으로 되밀어 틈의 가장자리가 흐려지지 않는다
function tradeDens(dx, dy) {
const out = new Float32Array(dens);
for (let y = 0; y < GY; y += 1) {
for (let x = 0; x < GX; x += 1) {
const i = y * GX + x;
if (x + 1 < GX) {
trade(out, i, i + 1, dx);
}
if (y + 1 < GY) {
trade(out, i, i + GX, dy);
}
}
}
for (let i = 0; i < out.length; i += 1) {
const d = out[i];
out[i] = clamp(d + EDGE_KEEP * d * (1 - d) * (d - 0.5) * 4, 0, 1);
}
dens = out;
trades += 1;
}
03한 점에서 뻗는 주름이 부채처럼 접히는 부케 결
부채 중심(필드 폭의 0.44, 높이의 0.94)에서 잰 각에 pleatWidth 가 RADIANT_K × PLEAT_FREQ × sin(각) 만큼 폭을 더해 접힘 좌표 (각 × 15 + 폭 × 0.5)/π 를 만들므로, 한 바퀴에 띠 30칸이 서고 화면에 보이는 위쪽 반원에는 약 15칸이 놓인다. 그 좌표의 소수부가 0.44 를 넘는 뒤 절반은 0.12 폭에 걸쳐 어두워지고, FOLD_CONTRAST 0.34 가 밝기를 밝은 면 0.66 과 접힌 면 0.474 두 계단으로 갈라 천을 접어 둔 듯한 면을 만든다. 시연의 첫 회전은 각속도 0.0021rad/ms 에서 스텝마다 0.965 배로 줄어 약 55° 돌고 멈추며, 렌더는 누적면적 72.9%·강도 26.3·23/23 이었다.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="한 점에서 뻗는 주름이 부채처럼 접히는 부케 결"></canvas>
<p class="hs__status" data-role="status" aria-live="off">부채 결이 접힌 채 돈다</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">부채 되돌리기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.hs {
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-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: 2px;
}
// 포인터로는 잉크를 다루고, 쓸어 넘기는 손가락 제스처는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
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: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER = [252, 246, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx = null;
let buf = null;
let px = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF = new Float32Array(0);
function initField(nw, nh) {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) {
return;
}
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) {
grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh);
}
}
}
function blitField(nw, nh) {
if (!sctx || !buf) {
return;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n, seed) {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t) { return t * t * (3 - 2 * t); }
function vnoise2(x, y, seed) {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x, y, oct, seed) {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x, y) {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 03 부케 — 방사각 사인 주름 접힘 폭 산정 + 결마다 밝기 계단
const RADIANT_K = 1.0; // 방사각 사인 주름 진폭 계수
const PLEAT_FREQ = 15; // 한 바퀴 안의 주름(접힘) 수
const FOLD_CONTRAST = 0.34; // 접힌 결마다 밝기 계단 크기
const DEMO_LOOP = 3600;
const FW = 192;
const FH = 120;
const TXT_TURN = '부채 결이 접힌 채 돈다';
const TXT_STEPB = '접힘마다 밝기가 계단진다';
const TXT_GUIDE = '화살표로 부채를 돌린다';
let fanA = 0.5; // 부채 회전각(rad)
let fanV = 0;
let saidStep = false;
function pleatWidth(r, ang) {
// 방사각 사인 주름 접힘 폭 산정 — 주름 계수 K(f·sin mθ) 가 접힘 폭을 정한다
return RADIANT_K * PLEAT_FREQ * Math.sin(ang);
}
function draw() {
if (!px || !sctx) {
return;
}
const ox = FW * 0.44, oy = FH * 0.94;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
for (let x = 0; x < FW; x += 1) {
const g = grainF[y * FW + x];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
const dx = x - ox, dy = y - oy;
const r = Math.sqrt(dx * dx + dy * dy);
let col = base;
if (r > 6) {
let ang = Math.atan2(dx, -dy) + fanA;
const w = pleatWidth(r, ang);
// 접힘 좌표 — 주름 폭만큼 각을 눌러 천이 접히는 듯한 밀도를 만든다
const fold = (ang * PLEAT_FREQ + w * 0.5) / Math.PI;
const fr = fold - Math.floor(fold);
// 접힌 면(뒤 절반)은 어둡게 — 경계는 0.12 폭으로 부드럽게 넘어가 계단이 톱니로 깨지지 않는다
const shade = 1 - 0.58 * smooth((fr - 0.44) / 0.12) * (1 - smooth((fr - 0.92) / 0.08));
const tint = TINTS[Math.abs(Math.floor(fold)) % TINTS.length];
const edge = smooth(1 - Math.abs(fr - 0.5) * 2);
let k = smooth(clamp(1 - r / (FH * 0.98), 0, 1)) * 0.8;
// 결마다 밝기 계단 — shade 와 FOLD_CONTRAST 로 접힌 면 사이 계단
const stepB = shade * (1 - FOLD_CONTRAST) + (1 - shade) * FOLD_CONTRAST;
k = k * stepB + edge * 0.06;
if (y > oy - 4) {
k *= smooth((oy - 4 - y) / 14);
}
col = mix(base, tint, clamp(k, 0, 1) * 0.8);
col = mix(col, INK, FOLD_CONTRAST * 0.5 * (1 - shade) / 0.58);
}
px[i4] = col[0];
px[i4 + 1] = col[1];
px[i4 + 2] = col[2];
px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
// 부챗살 결 — 접힘 폭이 커지는 바깥쪽 잉크 테두리
for (let s = 0; s < PLEAT_FREQ; s += 1) {
const a0 = fanA + (s / PLEAT_FREQ) * Math.PI * 2;
const w = pleatWidth(100, a0);
const a1 = a0 + (0.5 + w * 0.008) * (Math.PI * 2 / PLEAT_FREQ);
const r2 = FH * 0.95;
ctx.beginPath();
ctx.moveTo(ox * W / FW, oy * H / FH);
ctx.lineTo((ox + Math.sin(a1) * r2 * 0.96) * W / FW, (oy - Math.cos(a1) * r2 * 0.96) * H / FH);
ctx.strokeStyle = rgba(INK, 0.16);
ctx.lineWidth = 1;
ctx.stroke();
}
}
function settle() {
fanV = 0;
say(TXT_TURN);
}
function step() {
fanA += fanV * FIXED_DT;
fanV *= 0.965;
if (demo && Math.abs(fanV) > 0.0004) {
if (!saidStep && Math.abs(fanA) > 1.1) {
saidStep = true;
say(TXT_STEPB);
}
else if (!saidStep) {
say(TXT_TURN);
}
}
}
function spin(dir) {
fanV += dir * 0.0023;
if (still) {
fanA += dir * 0.28;
fanV = 0;
draw();
return;
}
say(TXT_TURN);
}
function demoTick(t) {
if (crossed(t, 200, DEMO_LOOP)) {
fanV = 0.0021;
saidStep = false;
}
if (crossed(t, 2000, DEMO_LOOP)) {
fanV = -0.0024;
}
}
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]');
if (btn) {
btn.addEventListener('click', function () {
wake();
fanA = 0.5;
fanV = 0;
saidStep = false;
if (still) {
draw();
}
say(TXT_TURN);
});
}
cv.addEventListener('pointerdown', function (ev) {
wake();
spin(toScene(ev)[0] < W / 2 ? -1 : 1);
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight') {
return;
}
ev.preventDefault();
wake();
spin(k === 'ArrowRight' ? 1 : -1);
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MblBouquetFanFoldPleats - vanilla 판(03-mbl-bouquet-fan-fold-pleats) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MblBouquetFanFoldPleats.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MblBouquetFanFoldPleats() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER: RGB = [252, 246, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx: CanvasRenderingContext2D | null = null;
let buf: ImageData | null = null;
let px: Uint8ClampedArray | null = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF: Float32Array = new Float32Array(0);
function initField(nw: number, nh: number): void {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) { return; }
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) { grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh); }
}
}
function blitField(nw: number, nh: number): void {
if (!sctx || !buf) { return; }
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n: number, seed: number): number {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t: number): number { return t * t * (3 - 2 * t); }
function vnoise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x: number, y: number, oct: number, seed: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x: number, y: number): number {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 03 부케 — 방사각 사인 주름 접힘 폭 산정 + 결마다 밝기 계단
const RADIANT_K = 1.0; // 방사각 사인 주름 진폭 계수
const PLEAT_FREQ = 15; // 한 바퀴 안의 주름(접힘) 수
const FOLD_CONTRAST = 0.34; // 접힌 결마다 밝기 계단 크기
const DEMO_LOOP = 3600;
const FW = 192;
const FH = 120;
const TXT_TURN = '부채 결이 접힌 채 돈다';
const TXT_STEPB = '접힘마다 밝기가 계단진다';
const TXT_GUIDE = '화살표로 부채를 돌린다';
let fanA = 0.5; // 부채 회전각(rad)
let fanV = 0;
let saidStep = false;
function pleatWidth(r: number, ang: number): number {
// 방사각 사인 주름 접힘 폭 산정 — 주름 계수 K(f·sin mθ) 가 접힘 폭을 정한다
return RADIANT_K * PLEAT_FREQ * Math.sin(ang);
}
function draw(): void {
if (!px || !sctx) { return; }
const ox = FW * 0.44, oy = FH * 0.94;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
for (let x = 0; x < FW; x += 1) {
const g = grainF[y * FW + x];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
const dx = x - ox, dy = y - oy;
const r = Math.sqrt(dx * dx + dy * dy);
let col = base;
if (r > 6) {
let ang = Math.atan2(dx, -dy) + fanA;
const w = pleatWidth(r, ang);
// 접힘 좌표 — 주름 폭만큼 각을 눌러 천이 접히는 듯한 밀도를 만든다
const fold = (ang * PLEAT_FREQ + w * 0.5) / Math.PI;
const fr = fold - Math.floor(fold);
// 접힌 면(뒤 절반)은 어둡게 — 경계는 0.12 폭으로 부드럽게 넘어가 계단이 톱니로 깨지지 않는다
const shade = 1 - 0.58 * smooth((fr - 0.44) / 0.12) * (1 - smooth((fr - 0.92) / 0.08));
const tint = TINTS[Math.abs(Math.floor(fold)) % TINTS.length];
const edge = smooth(1 - Math.abs(fr - 0.5) * 2);
let k = smooth(clamp(1 - r / (FH * 0.98), 0, 1)) * 0.8;
// 결마다 밝기 계단 — shade 와 FOLD_CONTRAST 로 접힌 면 사이 계단
const stepB = shade * (1 - FOLD_CONTRAST) + (1 - shade) * FOLD_CONTRAST;
k = k * stepB + edge * 0.06;
if (y > oy - 4) { k *= smooth((oy - 4 - y) / 14); }
col = mix(base, tint, clamp(k, 0, 1) * 0.8);
col = mix(col, INK, FOLD_CONTRAST * 0.5 * (1 - shade) / 0.58);
}
px[i4] = col[0]; px[i4 + 1] = col[1]; px[i4 + 2] = col[2]; px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
// 부챗살 결 — 접힘 폭이 커지는 바깥쪽 잉크 테두리
for (let s = 0; s < PLEAT_FREQ; s += 1) {
const a0 = fanA + (s / PLEAT_FREQ) * Math.PI * 2;
const w = pleatWidth(100, a0);
const a1 = a0 + (0.5 + w * 0.008) * (Math.PI * 2 / PLEAT_FREQ);
const r2 = FH * 0.95;
ctx.beginPath();
ctx.moveTo(ox * W / FW, oy * H / FH);
ctx.lineTo((ox + Math.sin(a1) * r2 * 0.96) * W / FW, (oy - Math.cos(a1) * r2 * 0.96) * H / FH);
ctx.strokeStyle = rgba(INK, 0.16);
ctx.lineWidth = 1;
ctx.stroke();
}
}
function settle(): void {
fanV = 0;
say(TXT_TURN);
}
function step(): void {
fanA += fanV * FIXED_DT;
fanV *= 0.965;
if (demo && Math.abs(fanV) > 0.0004) {
if (!saidStep && Math.abs(fanA) > 1.1) { saidStep = true; say(TXT_STEPB); }
else if (!saidStep) { say(TXT_TURN); }
}
}
function spin(dir: number): void {
fanV += dir * 0.0023;
if (still) {
fanA += dir * 0.28;
fanV = 0;
draw();
return;
}
say(TXT_TURN);
}
function demoTick(t: number): void {
if (crossed(t, 200, DEMO_LOOP)) { fanV = 0.0021; saidStep = false; }
if (crossed(t, 2000, DEMO_LOOP)) { fanV = -0.0024; }
}
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (btn) {
btn.addEventListener('click', function (): void {
wake();
fanA = 0.5;
fanV = 0;
saidStep = false;
if (still) { draw(); }
say(TXT_TURN);
});
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
spin(toScene(ev)[0] < W / 2 ? -1 : 1);
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight') { return; }
ev.preventDefault();
wake();
spin(k === 'ArrowRight' ? 1 : -1);
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="한 점에서 뻗는 주름이 부채처럼 접히는 부케 결"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">부채 결이 접힌 채 돈다</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="btn" type="button">부채 되돌리기</button>
</div>
</div>
);
}03 · 한 점에서 뻗는 주름이 부채처럼 접히는 부케 결 — 측정 누적면적 72.876% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
let ang = Math.atan2(dx, -dy) + fanA;
const w = pleatWidth(r, ang);
// 접힘 좌표 — 주름 폭만큼 각을 눌러 천이 접히는 듯한 밀도를 만든다
const fold = (ang * PLEAT_FREQ + w * 0.5) / Math.PI;
const fr = fold - Math.floor(fold);
// 접힌 면(뒤 절반)은 어둡게 — 경계는 0.12 폭으로 부드럽게 넘어가 계단이 톱니로 깨지지 않는다
const shade = 1 - 0.58 * smooth((fr - 0.44) / 0.12) * (1 - smooth((fr - 0.92) / 0.08));
const tint = TINTS[Math.abs(Math.floor(fold)) % TINTS.length];
const edge = smooth(1 - Math.abs(fr - 0.5) * 2);
let k = smooth(clamp(1 - r / (FH * 0.98), 0, 1)) * 0.8;
// 결마다 밝기 계단 — shade 와 FOLD_CONTRAST 로 접힌 면 사이 계단
const stepB = shade * (1 - FOLD_CONTRAST) + (1 - shade) * FOLD_CONTRAST;
k = k * stepB + edge * 0.06;
04한 방향으로 밀린 물결 띠가 비스듬히 굳는 빗질 결
bandAt 은 띠 번호를 (y + 기울기 + 물결 + 잔물결) ÷ BAND_PX 13 으로 매기는데, 기울기 CHEVRON_SKEW 0.34 는 띠를 18.8° 기울이고 물결은 줄마다 sin(y × 0.028) × 1.15 만큼 위상이 어긋난 진폭 13(WAVE_AMP 26 의 절반)의 사인파다. 스텝마다 위상 drift 에 방향 × PHASE_DRIFT 0.022rad 가 쌓여 초당 1.375rad, 물결 마루로 치면 가로 125px/s 로 흘러가고, 방향을 바꾸지 않는 한 같은 결이 한쪽으로 계속 밀려 비스듬한 띠로 굳는다. 시연은 200ms 에 오른쪽, 1900ms 에 왼쪽으로 빗질을 바꾸고, 렌더 수치는 누적면적 71.1%·강도 20.5·23/23 이다.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="한 방향으로 밀린 물결 띠가 비스듬히 굳는 빗질 결"></canvas>
<p class="hs__status" data-role="status" aria-live="off">오른쪽으로 빗질하는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">빗질 방향</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.hs {
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-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: 2px;
}
// 포인터로는 잉크를 다루고, 쓸어 넘기는 손가락 제스처는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
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: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER = [252, 246, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx = null;
let buf = null;
let px = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF = new Float32Array(0);
function initField(nw, nh) {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) {
return;
}
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) {
grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh);
}
}
}
function blitField(nw, nh) {
if (!sctx || !buf) {
return;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n, seed) {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t) { return t * t * (3 - 2 * t); }
function vnoise2(x, y, seed) {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x, y, oct, seed) {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x, y) {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 04 빗질 결 — 어긋난 위상 사인파 합성 + 민 만큼 위상 표류 누적
const WAVE_AMP = 26; // 사인파 진폭(px)
const PHASE_DRIFT = 0.022; // 16ms 스텝마다 쌓이는 위상 표류(빗이 민 만큼)
const CHEVRON_SKEW = 0.34; // 가로 위치에 비례해 띠를 비스듬히 누르는 기울기
const BAND_PX = 13; // 물결 띠 한 줄 폭(px)
const DEMO_LOOP = 3600;
const FW = 240;
const FH = 150;
const TXT_R = '오른쪽으로 빗질하는 중';
const TXT_L = '왼쪽으로 빗질하는 중';
const TXT_SET = '물결 띠가 비스듬히 굳는다';
const TXT_GUIDE = '화살표로 빗질 방향';
let drift = 0; // 누적 위상 표류
let dir = 1; // 빗질 방향
let saidSet = false;
let btnEl = null;
function bandAt(x, y) {
// 어긋난 위상 사인파 합성 — 줄마다 위상이 달라 물결, 가로 기울기가 셰브런을 만든다
const tilt = CHEVRON_SKEW * (x - W / 2);
const wave = WAVE_AMP * 0.5 * Math.sin(x * 0.011 + Math.sin(y * 0.028) * 1.15 + drift);
const fine = WAVE_AMP * 0.22 * Math.sin(x * 0.0031 + y * 0.052 - drift * 0.6);
return (y + tilt + wave + fine) / BAND_PX;
}
function draw() {
if (!px || !sctx) {
return;
}
const sx = FW / W, sy = FH / H;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
const wy = y / sy;
for (let x = 0; x < FW; x += 1) {
const wx = x / sx;
const g = grainF[y * FW + x];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
const b = bandAt(wx, wy);
const bi = Math.floor(b);
const fr = b - bi;
const tint = TINTS[Math.abs(bi + 40) % TINTS.length];
let col = base;
const k = smooth(1 - Math.abs(fr - 0.5) * 2);
if (bi % 2 === 0) {
col = mix(base, tint, k * 0.5 + 0.1);
if (fr < 0.16) {
col = mix(col, INK, (0.16 - fr) * 2.2);
}
}
else {
col = mix(base, INK, 0.06 + k * 0.05);
}
px[i4] = col[0];
px[i4 + 1] = col[1];
px[i4 + 2] = col[2];
px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle() {
drift = 3.2;
say(TXT_SET);
}
function step() {
drift += dir * PHASE_DRIFT;
if (demo) {
if (!saidSet && Math.abs(drift - 3.2) < 0.05) {
saidSet = true;
say(TXT_SET);
}
else if (!saidSet) {
say(dir > 0 ? TXT_R : TXT_L);
}
}
}
function setDir(d) {
dir = d;
saidSet = false;
if (still) {
drift += d * 0.9;
draw();
return;
}
say(d > 0 ? TXT_R : TXT_L);
if (btnEl) {
btnEl.textContent = d > 0 ? '왼쪽으로 빗질' : '오른쪽으로 빗질';
}
}
function demoTick(t) {
if (crossed(t, 200, DEMO_LOOP)) {
dir = 1;
saidSet = false;
}
if (crossed(t, 1900, DEMO_LOOP)) {
dir = -1;
}
}
initField(FW, FH);
btnEl = root.querySelector('[data-role="btn"]');
if (btnEl) {
btnEl.textContent = '왼쪽으로 빗질';
btnEl.addEventListener('click', function () {
wake();
setDir(-dir);
});
}
cv.addEventListener('pointerdown', function (ev) {
wake();
setDir(toScene(ev)[0] < W / 2 ? -1 : 1);
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight') {
return;
}
ev.preventDefault();
wake();
setDir(k === 'ArrowRight' ? 1 : -1);
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MblRakeWaveChevronBands - vanilla 판(04-mbl-rake-wave-chevron-bands) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MblRakeWaveChevronBands.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MblRakeWaveChevronBands() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER: RGB = [252, 246, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx: CanvasRenderingContext2D | null = null;
let buf: ImageData | null = null;
let px: Uint8ClampedArray | null = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF: Float32Array = new Float32Array(0);
function initField(nw: number, nh: number): void {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) { return; }
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) { grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh); }
}
}
function blitField(nw: number, nh: number): void {
if (!sctx || !buf) { return; }
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n: number, seed: number): number {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t: number): number { return t * t * (3 - 2 * t); }
function vnoise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x: number, y: number, oct: number, seed: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x: number, y: number): number {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 04 빗질 결 — 어긋난 위상 사인파 합성 + 민 만큼 위상 표류 누적
const WAVE_AMP = 26; // 사인파 진폭(px)
const PHASE_DRIFT = 0.022; // 16ms 스텝마다 쌓이는 위상 표류(빗이 민 만큼)
const CHEVRON_SKEW = 0.34; // 가로 위치에 비례해 띠를 비스듬히 누르는 기울기
const BAND_PX = 13; // 물결 띠 한 줄 폭(px)
const DEMO_LOOP = 3600;
const FW = 240;
const FH = 150;
const TXT_R = '오른쪽으로 빗질하는 중';
const TXT_L = '왼쪽으로 빗질하는 중';
const TXT_SET = '물결 띠가 비스듬히 굳는다';
const TXT_GUIDE = '화살표로 빗질 방향';
let drift = 0; // 누적 위상 표류
let dir = 1; // 빗질 방향
let saidSet = false;
let btnEl: HTMLButtonElement | null = null;
function bandAt(x: number, y: number): number {
// 어긋난 위상 사인파 합성 — 줄마다 위상이 달라 물결, 가로 기울기가 셰브런을 만든다
const tilt = CHEVRON_SKEW * (x - W / 2);
const wave = WAVE_AMP * 0.5 * Math.sin(x * 0.011 + Math.sin(y * 0.028) * 1.15 + drift);
const fine = WAVE_AMP * 0.22 * Math.sin(x * 0.0031 + y * 0.052 - drift * 0.6);
return (y + tilt + wave + fine) / BAND_PX;
}
function draw(): void {
if (!px || !sctx) { return; }
const sx = FW / W, sy = FH / H;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
const wy = y / sy;
for (let x = 0; x < FW; x += 1) {
const wx = x / sx;
const g = grainF[y * FW + x];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
const b = bandAt(wx, wy);
const bi = Math.floor(b);
const fr = b - bi;
const tint = TINTS[Math.abs(bi + 40) % TINTS.length];
let col = base;
const k = smooth(1 - Math.abs(fr - 0.5) * 2);
if (bi % 2 === 0) {
col = mix(base, tint, k * 0.5 + 0.1);
if (fr < 0.16) { col = mix(col, INK, (0.16 - fr) * 2.2); }
} else {
col = mix(base, INK, 0.06 + k * 0.05);
}
px[i4] = col[0]; px[i4 + 1] = col[1]; px[i4 + 2] = col[2]; px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle(): void {
drift = 3.2;
say(TXT_SET);
}
function step(): void {
drift += dir * PHASE_DRIFT;
if (demo) {
if (!saidSet && Math.abs(drift - 3.2) < 0.05) { saidSet = true; say(TXT_SET); }
else if (!saidSet) { say(dir > 0 ? TXT_R : TXT_L); }
}
}
function setDir(d: number): void {
dir = d;
saidSet = false;
if (still) { drift += d * 0.9; draw(); return; }
say(d > 0 ? TXT_R : TXT_L);
if (btnEl) { btnEl.textContent = d > 0 ? '왼쪽으로 빗질' : '오른쪽으로 빗질'; }
}
function demoTick(t: number): void {
if (crossed(t, 200, DEMO_LOOP)) { dir = 1; saidSet = false; }
if (crossed(t, 1900, DEMO_LOOP)) { dir = -1; }
}
initField(FW, FH);
btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (btnEl) {
btnEl.textContent = '왼쪽으로 빗질';
btnEl.addEventListener('click', function (): void {
wake();
setDir(-dir);
});
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
setDir(toScene(ev)[0] < W / 2 ? -1 : 1);
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight') { return; }
ev.preventDefault();
wake();
setDir(k === 'ArrowRight' ? 1 : -1);
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="한 방향으로 밀린 물결 띠가 비스듬히 굳는 빗질 결"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">오른쪽으로 빗질하는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="btn" type="button">빗질 방향</button>
</div>
</div>
);
}04 · 한 방향으로 밀린 물결 띠가 비스듬히 굳는 빗질 결 — 측정 누적면적 71.056% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function bandAt(x, y) {
// 어긋난 위상 사인파 합성 — 줄마다 위상이 달라 물결, 가로 기울기가 셰브런을 만든다
const tilt = CHEVRON_SKEW * (x - W / 2);
const wave = WAVE_AMP * 0.5 * Math.sin(x * 0.011 + Math.sin(y * 0.028) * 1.15 + drift);
const fine = WAVE_AMP * 0.22 * Math.sin(x * 0.0031 + y * 0.052 - drift * 0.6);
return (y + tilt + wave + fine) / BAND_PX;
}
05반대 방향 두 빗질이 어긋나 만나 깃털로 갈라지는 결
tineShift 는 가장 가까운 이빨 줄까지의 가로 거리 d 에서 끌린 양을 amount × λ/(d + λ) 로 주고, λ 가 14 × 0.55 ÷ FEATHER_SPLIT_K 1.3 = 5.92px 라서 이빨 줄 위 64px 끌림이 반 칸(7px) 옆에서는 29.3px 로 줄어든다. sourceY 는 아래로 끈 첫 빗과 이빨을 COMB_TOOTH_GAP × STAGGER_PHASE = 7px 옮겨 위로 끈 둘째 빗을 y 에서 차례로 되돌려 처음 깔린 16px 가로 띠의 색을 가져오므로, 두 끌림이 엇갈리는 자리마다 띠가 깃털 모양으로 갈라진다. 480px 폭에 이빨 34.3개가 지나가고 첫 빗 64px 는 1138ms, 둘째 빗 51.2px 는 910ms 걸리며, 렌더는 누적면적 73.8%·강도 33.6·16/23 프레임이었다.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="반대 방향 두 빗질이 어긋나 만나 깃털로 갈라지는 결"></canvas>
<p class="hs__status" data-role="status" aria-live="off">윗결을 빗는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">다시 빗질</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.hs {
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-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: 2px;
}
// 포인터로는 잉크를 다루고, 쓸어 넘기는 손가락 제스처는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
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: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER = [252, 246, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx = null;
let buf = null;
let px = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF = new Float32Array(0);
function initField(nw, nh) {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) {
return;
}
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) {
grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh);
}
}
}
function blitField(nw, nh) {
if (!sctx || !buf) {
return;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n, seed) {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t) { return t * t * (3 - 2 * t); }
function vnoise2(x, y, seed) {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x, y, oct, seed) {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x, y) {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 05 깃털 — 반 칸 위상 어긋난 두 빗질 간섭, 만나는 지점 결 분기
const COMB_TOOTH_GAP = 14; // 빗 이빨 간격(px)
const STAGGER_PHASE = 0.5; // 두 번째 빗의 이빨을 첫 빗 이빨 사이 몇 칸 지점에 두나(반 칸 = 0.5)
const FEATHER_SPLIT_K = 1.3; // 이빨 줄에서 멀어질 때 끌림이 줄어드는 빠르기 — 클수록 깃털 끝이 가늘게 갈라진다
const COMB_SPEED = 0.9; // 16ms 스텝마다 빗이 끌고 가는 거리(px)
const COMB_MAX = 64; // 빗 하나가 끝까지 끄는 거리(px)
const BAND_PX = 16; // 처음 깔린 가로 잉크 띠 한 줄 높이(px)
const DEMO_LOOP = 3600;
const FW = 240;
const FH = 150;
const TXT_UP = '윗결을 빗는 중';
const TXT_DN = '아랫결을 반대로 빗는 중';
const TXT_SPLIT = '만나는 지점에서 깃털로 갈라진다';
let pull1 = 0; // 첫 빗(아래로 끎) 누적 거리
let pull2 = 0; // 둘째 빗(위로 끎, 반 칸 어긋남) 누적 거리
let target1 = 0;
let target2 = 0;
// 빗 이빨 한 줄이 잉크를 끄는 양 — 가장 가까운 이빨 줄까지 가로 거리 d 에서 amount·λ/(d+λ)
function tineShift(x, offset, amount) {
const g = COMB_TOOTH_GAP;
let u = (x - offset) / g;
u -= Math.round(u);
const d = Math.abs(u) * g;
const lam = (g * 0.55) / FEATHER_SPLIT_K;
return amount * lam / (d + lam);
}
// 화면 점이 빗질 전 어느 높이에 있던 잉크인가 — 두 빗 모두 세로로만 끌므로 y 만 되돌리면 된다
function sourceY(x, y) {
return y - tineShift(x, 0, pull1) + tineShift(x, COMB_TOOTH_GAP * STAGGER_PHASE, pull2);
}
function draw() {
if (!px || !sctx) {
return;
}
const sx = W / FW, sy = H / FH;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
const wy = (y + 0.5) * sy;
for (let x = 0; x < FW; x += 1) {
const wx = (x + 0.5) * sx;
const g0 = grainF[y * FW + x];
const base = mix(PAPER, INK, g0 * 0.07 + 0.015);
// 처음 깔린 띠는 살짝 물결친다 — 완전한 직선이면 빗 자국이 기계처럼 보인다
const sy0 = sourceY(wx, wy) + Math.sin(wx * 0.013) * 4;
const f = sy0 / BAND_PX;
const band = Math.floor(f);
const fr = f - band;
const tint = TINTS[((band % 5) + 5) % 5];
let col = mix(base, tint, 0.62);
if (((band % 2) + 2) % 2 === 1) {
col = mix(base, tint, 0.26);
}
col = mix(col, INK, smooth((fr - 0.86) / 0.12) * 0.28);
px[i4] = col[0];
px[i4 + 1] = col[1];
px[i4 + 2] = col[2];
px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle() {
pull1 = target1 = COMB_MAX;
pull2 = target2 = COMB_MAX * 0.8;
say(TXT_SPLIT);
}
function step() {
if (pull1 < target1) {
pull1 = Math.min(target1, pull1 + COMB_SPEED);
}
else if (pull2 < target2) {
pull2 = Math.min(target2, pull2 + COMB_SPEED);
}
if (demo) {
say(pull2 <= 0 ? TXT_UP : (pull2 < COMB_MAX * 0.5 ? TXT_DN : TXT_SPLIT));
}
}
function comb(which) {
if (which === 1) {
target1 = Math.min(target1 + 16, COMB_MAX * 2);
}
else {
target2 = Math.min(target2 + 16, COMB_MAX * 2);
}
if (still) {
pull1 = target1;
pull2 = target2;
draw();
}
say(which === 1 ? TXT_UP : TXT_DN);
}
function demoTick(t) {
// 첫 빗은 200ms 부터, 둘째 빗은 첫 빗이 끝나는 대로 이어서 — 2초 안에 두 빗질이 다 들어온다
if (crossed(t, 200, DEMO_LOOP)) {
pull1 = 0;
pull2 = 0;
target1 = COMB_MAX;
target2 = COMB_MAX * 0.8;
}
}
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]');
if (btn) {
btn.addEventListener('click', function () {
wake();
pull1 = 0;
pull2 = 0;
target1 = COMB_MAX;
target2 = COMB_MAX * 0.8;
if (still) {
settle();
draw();
return;
}
say(TXT_UP);
});
}
cv.addEventListener('pointerdown', function (ev) {
wake();
comb(toScene(ev)[1] < H / 2 ? 1 : 2);
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k !== 'ArrowUp' && k !== 'ArrowDown') {
return;
}
ev.preventDefault();
wake();
comb(k === 'ArrowDown' ? 1 : 2);
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MblStaggerCombFeatherGrain - vanilla 판(05-mbl-stagger-comb-feather-grain) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MblStaggerCombFeatherGrain.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MblStaggerCombFeatherGrain() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER: RGB = [252, 246, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx: CanvasRenderingContext2D | null = null;
let buf: ImageData | null = null;
let px: Uint8ClampedArray | null = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF: Float32Array = new Float32Array(0);
function initField(nw: number, nh: number): void {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) { return; }
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) { grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh); }
}
}
function blitField(nw: number, nh: number): void {
if (!sctx || !buf) { return; }
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n: number, seed: number): number {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t: number): number { return t * t * (3 - 2 * t); }
function vnoise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x: number, y: number, oct: number, seed: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x: number, y: number): number {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 05 깃털 — 반 칸 위상 어긋난 두 빗질 간섭, 만나는 지점 결 분기
const COMB_TOOTH_GAP = 14; // 빗 이빨 간격(px)
const STAGGER_PHASE = 0.5; // 두 번째 빗의 이빨을 첫 빗 이빨 사이 몇 칸 지점에 두나(반 칸 = 0.5)
const FEATHER_SPLIT_K = 1.3; // 이빨 줄에서 멀어질 때 끌림이 줄어드는 빠르기 — 클수록 깃털 끝이 가늘게 갈라진다
const COMB_SPEED = 0.9; // 16ms 스텝마다 빗이 끌고 가는 거리(px)
const COMB_MAX = 64; // 빗 하나가 끝까지 끄는 거리(px)
const BAND_PX = 16; // 처음 깔린 가로 잉크 띠 한 줄 높이(px)
const DEMO_LOOP = 3600;
const FW = 240;
const FH = 150;
const TXT_UP = '윗결을 빗는 중';
const TXT_DN = '아랫결을 반대로 빗는 중';
const TXT_SPLIT = '만나는 지점에서 깃털로 갈라진다';
let pull1 = 0; // 첫 빗(아래로 끎) 누적 거리
let pull2 = 0; // 둘째 빗(위로 끎, 반 칸 어긋남) 누적 거리
let target1 = 0;
let target2 = 0;
// 빗 이빨 한 줄이 잉크를 끄는 양 — 가장 가까운 이빨 줄까지 가로 거리 d 에서 amount·λ/(d+λ)
function tineShift(x: number, offset: number, amount: number): number {
const g = COMB_TOOTH_GAP;
let u = (x - offset) / g;
u -= Math.round(u);
const d = Math.abs(u) * g;
const lam = (g * 0.55) / FEATHER_SPLIT_K;
return amount * lam / (d + lam);
}
// 화면 점이 빗질 전 어느 높이에 있던 잉크인가 — 두 빗 모두 세로로만 끌므로 y 만 되돌리면 된다
function sourceY(x: number, y: number): number {
return y - tineShift(x, 0, pull1) + tineShift(x, COMB_TOOTH_GAP * STAGGER_PHASE, pull2);
}
function draw(): void {
if (!px || !sctx) { return; }
const sx = W / FW, sy = H / FH;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
const wy = (y + 0.5) * sy;
for (let x = 0; x < FW; x += 1) {
const wx = (x + 0.5) * sx;
const g0 = grainF[y * FW + x];
const base = mix(PAPER, INK, g0 * 0.07 + 0.015);
// 처음 깔린 띠는 살짝 물결친다 — 완전한 직선이면 빗 자국이 기계처럼 보인다
const sy0 = sourceY(wx, wy) + Math.sin(wx * 0.013) * 4;
const f = sy0 / BAND_PX;
const band = Math.floor(f);
const fr = f - band;
const tint = TINTS[((band % 5) + 5) % 5];
let col = mix(base, tint, 0.62);
if (((band % 2) + 2) % 2 === 1) { col = mix(base, tint, 0.26); }
col = mix(col, INK, smooth((fr - 0.86) / 0.12) * 0.28);
px[i4] = col[0]; px[i4 + 1] = col[1]; px[i4 + 2] = col[2]; px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle(): void {
pull1 = target1 = COMB_MAX;
pull2 = target2 = COMB_MAX * 0.8;
say(TXT_SPLIT);
}
function step(): void {
if (pull1 < target1) { pull1 = Math.min(target1, pull1 + COMB_SPEED); }
else if (pull2 < target2) { pull2 = Math.min(target2, pull2 + COMB_SPEED); }
if (demo) {
say(pull2 <= 0 ? TXT_UP : (pull2 < COMB_MAX * 0.5 ? TXT_DN : TXT_SPLIT));
}
}
function comb(which: number): void {
if (which === 1) { target1 = Math.min(target1 + 16, COMB_MAX * 2); } else { target2 = Math.min(target2 + 16, COMB_MAX * 2); }
if (still) {
pull1 = target1;
pull2 = target2;
draw();
}
say(which === 1 ? TXT_UP : TXT_DN);
}
function demoTick(t: number): void {
// 첫 빗은 200ms 부터, 둘째 빗은 첫 빗이 끝나는 대로 이어서 — 2초 안에 두 빗질이 다 들어온다
if (crossed(t, 200, DEMO_LOOP)) { pull1 = 0; pull2 = 0; target1 = COMB_MAX; target2 = COMB_MAX * 0.8; }
}
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (btn) {
btn.addEventListener('click', function (): void {
wake();
pull1 = 0;
pull2 = 0;
target1 = COMB_MAX;
target2 = COMB_MAX * 0.8;
if (still) { settle(); draw(); return; }
say(TXT_UP);
});
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
comb(toScene(ev)[1] < H / 2 ? 1 : 2);
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k !== 'ArrowUp' && k !== 'ArrowDown') { return; }
ev.preventDefault();
wake();
comb(k === 'ArrowDown' ? 1 : 2);
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="반대 방향 두 빗질이 어긋나 만나 깃털로 갈라지는 결"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">윗결을 빗는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="btn" type="button">다시 빗질</button>
</div>
</div>
);
}05 · 반대 방향 두 빗질이 어긋나 만나 깃털로 갈라지는 결 — 측정 누적면적 73.832% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 빗 이빨 한 줄이 잉크를 끄는 양 — 가장 가까운 이빨 줄까지 가로 거리 d 에서 amount·λ/(d+λ)
function tineShift(x, offset, amount) {
const g = COMB_TOOTH_GAP;
let u = (x - offset) / g;
u -= Math.round(u);
const d = Math.abs(u) * g;
const lam = (g * 0.55) / FEATHER_SPLIT_K;
return amount * lam / (d + lam);
}
// 화면 점이 빗질 전 어느 높이에 있던 잉크인가 — 두 빗 모두 세로로만 끌므로 y 만 되돌리면 된다
function sourceY(x, y) {
return y - tineShift(x, 0, pull1) + tineShift(x, COMB_TOOTH_GAP * STAGGER_PHASE, pull2);
}
06닿는 순간 잉크가 링 위로 솟았다 되떨어지는 왕관 튐
06 은 픽셀 버퍼 없이 타원과 선을 그리는 벡터 장면이고, 방울이 닿으면 CROWN_N 28개 알갱이가 충격 링과 함께 스텝마다 RING_PULSE 1.1px 씩 바깥으로 실려 가면서 높이 쪽으로는 처음 속도 TANGENT_EJECT × 0.6~1.1 로 솟았다가 스텝마다 GRAVITY_FALL 0.36 씩 깎인다. 높이가 0 아래로 내려가는 순간 stepBead 가 그 자리를 낙점으로 굳히는데, 노드로 재 보면 가장 느린 알갱이는 320ms 동안 17.4px 솟아 반경 28px 에, 가장 빠른 알갱이는 592ms 동안 61.2px 솟아 46.7px 에 내려앉는다. 수면의 원은 세로를 TILT 0.46 배로 눌러 비스듬한 시점의 타원으로 그리고 화면 위쪽 알갱이부터 칠해 앞뒤를 맞추며, 렌더는 누적면적 14.2%·강도 36.4·23/23 이었다.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="닿는 순간 잉크가 링 위로 솟았다 되떨어지는 왕관 튐"></canvas>
<p class="hs__status" data-role="status" aria-live="off">링이 퍼지는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">방울 떨어뜨리기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.hs {
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-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: 2px;
}
// 포인터로는 잉크를 다루고, 쓸어 넘기는 손가락 제스처는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
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: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER = [252, 246, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx = null;
let buf = null;
let px = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF = new Float32Array(0);
function initField(nw, nh) {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) {
return;
}
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) {
grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh);
}
}
}
function blitField(nw, nh) {
if (!sctx || !buf) {
return;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n, seed) {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t) { return t * t * (3 - 2 * t); }
function vnoise2(x, y, seed) {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x, y, oct, seed) {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x, y) {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 06 왕관 튐 — 충격 링 펄스 + 접선 분사·중력 재낙하 낙점
const RING_PULSE = 1.1; // 충격 링 반경이 16ms 스텝마다 자라는 양(px)
const TANGENT_EJECT = 6.2; // 링 벽을 따라(위로) 튀어 오르는 처음 속도(px/스텝)
const GRAVITY_FALL = 0.36; // 중력 가속도(px/스텝²) — 되떨어져 낙점에 앉는다
const CROWN_N = 28; // 왕관 알갱이 수
const TILT = 0.46; // 비스듬히 내려다보는 시점 — 수면의 원이 이 비율로 눌린 타원이 된다
const RING_LIFE = 1500; // 링이 살아 있는 시간(ms)
const DEMO_LOOP = 3800;
const TXT_RING = '링이 퍼지는 중';
const TXT_RISE = '접선 방향으로 솟는 중';
const TXT_FALL = '중력으로 되떨어져 앉는다';
const TXT_GUIDE = '화살표로 위치, 엔터로 방울';
let splashes = [];
let splashCount = 0;
let cursorX = W / 2;
let cursorY = H * 0.55;
let showCursor = false;
function newSplash(x, y, t0) {
const beads = [];
for (let i = 0; i < CROWN_N; i += 1) {
const a = (i / CROWN_N) * Math.PI * 2 + (hash1(i, 55) - 0.5) * 0.2;
beads.push({ a: a, r: 6, h: 0, vh: TANGENT_EJECT * (0.6 + hash1(i + splashCount * 31, 77) * 0.5), land: 0, tint: (i + splashCount) % TINTS.length });
}
splashCount += 1;
return { x: x, y: y, t0: t0, beads: beads, tint: splashCount % TINTS.length };
}
// 알갱이 한 스텝 — 링과 함께 바깥으로 실려 가며(RING_PULSE) 위로는 중력에 감속한다. 높이가 0 아래로 내려가면 낙점
function stepBead(b) {
if (b.land > 0) {
b.land = Math.min(1, b.land + 0.025);
return;
}
b.r += RING_PULSE;
b.vh -= GRAVITY_FALL;
b.h += b.vh;
if (b.h <= 0 && b.vh < 0) {
b.h = 0;
b.land = 0.01;
}
}
function step() {
for (let s = 0; s < splashes.length; s += 1) {
const sp = splashes[s];
for (let i = 0; i < sp.beads.length; i += 1) {
stepBead(sp.beads[i]);
}
}
if (splashes.length > 3) {
splashes.shift();
}
}
function drawScene() {
const g0 = ctx.createLinearGradient(0, 0, 0, H);
g0.addColorStop(0, rgba(mix(PAPER, SKY, 0.16), 1));
g0.addColorStop(1, rgba(mix(PAPER, LILAC, 0.14), 1));
ctx.fillStyle = g0;
ctx.fillRect(0, 0, W, H);
for (let s = 0; s < splashes.length; s += 1) {
const sp = splashes[s];
const age = clock - sp.t0;
const fade = clamp(1 - age / (RING_LIFE + 400), 0, 1);
const tint = TINTS[sp.tint];
// 잉크 고임 — 착탄점에 남아 서서히 번지는 타원
const poolR = 14 + Math.min(Math.max(age, 0) * 0.02, 34);
ctx.fillStyle = rgba(mix(tint, INK, 0.3), 0.55);
ctx.beginPath();
ctx.ellipse(sp.x, sp.y, poolR, poolR * TILT, 0, 0, Math.PI * 2);
ctx.fill();
// 충격 링 — 알갱이와 같은 속도로 퍼지며 옅어지는 두 겹 펄스
const rr = 6 + Math.max(age, 0) * RING_PULSE / FIXED_DT;
if (fade > 0.02) {
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(mix(tint, INK, 0.4), 0.6 * fade);
ctx.beginPath();
ctx.ellipse(sp.x, sp.y, Math.min(rr, 330), Math.min(rr, 330) * TILT, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.3 * fade);
ctx.beginPath();
ctx.ellipse(sp.x, sp.y, rr * 0.7, rr * 0.7 * TILT, 0, 0, Math.PI * 2);
ctx.stroke();
}
// 뒤쪽 알갱이부터 그린다(화면 위쪽 = 먼 쪽)
const order = sp.beads.slice().sort(function (p, q) { return Math.sin(p.a) - Math.sin(q.a); });
for (let i = 0; i < order.length; i += 1) {
const b = order[i];
const bx = sp.x + Math.cos(b.a) * b.r;
const groundY = sp.y + Math.sin(b.a) * b.r * TILT;
if (b.land > 0) {
const st = 3 + b.land * 3;
ctx.fillStyle = rgba(mix(TINTS[b.tint], INK, 0.3), 0.85);
ctx.beginPath();
ctx.ellipse(bx, groundY, st * 1.5, st * 0.75, 0, 0, Math.PI * 2);
ctx.fill();
continue;
}
// 솟는 알갱이 — 링 벽에서 튀어 오른 줄기와 끝의 방울
const by = groundY - b.h;
ctx.strokeStyle = rgba(mix(TINTS[b.tint], INK, 0.2), 0.7);
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.moveTo(bx, groundY);
ctx.lineTo(bx, by);
ctx.stroke();
const stretch = 1 + clamp(Math.abs(b.vh) * 0.08, 0, 1.2);
ctx.fillStyle = rgba(mix(TINTS[b.tint], INK, 0.15), 0.95);
ctx.beginPath();
ctx.ellipse(bx, by, 3.4, 3.4 * stretch, 0, 0, Math.PI * 2);
ctx.fill();
}
}
}
// 키보드로 고른 자리 — 화살표를 쓰면 다음 방울이 떨어질 곳을 눌린 타원으로 보여 준다
function draw() {
drawScene();
if (!showCursor) {
return;
}
ctx.beginPath();
ctx.ellipse(cursorX, cursorY, 10, 10 * TILT, 0, 0, Math.PI * 2);
ctx.strokeStyle = rgba(INK, 0.8);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// reduce — 알갱이가 모두 앉은 뒤의 장면을 같은 식으로 끝까지 돌려 만든다
function finishSplash(sp) {
for (let i = 0; i < sp.beads.length; i += 1) {
const b = sp.beads[i];
for (let n = 0; n < 400 && b.land < 1; n += 1) {
stepBead(b);
}
}
}
function settle() {
splashes = [newSplash(W * 0.38, H * 0.5, -1e9), newSplash(W * 0.64, H * 0.6, -1e9)];
finishSplash(splashes[0]);
finishSplash(splashes[1]);
say(TXT_FALL);
}
function drop(x, y) {
const sp = newSplash(x, y, still ? -1e9 : clock);
splashes.push(sp);
if (splashes.length > 3) {
splashes.shift();
}
if (still) {
finishSplash(sp);
say(TXT_FALL);
draw();
return;
}
say(TXT_RING);
}
function demoTick(t) {
if (crossed(t, 220, DEMO_LOOP)) {
splashes.push(newSplash(W * 0.5, H * 0.55, clock));
say(TXT_RING);
}
if (crossed(t, 700, DEMO_LOOP)) {
say(TXT_RISE);
}
if (crossed(t, 1100, DEMO_LOOP)) {
splashes.push(newSplash(W * 0.27, H * 0.42, clock));
}
if (crossed(t, 1500, DEMO_LOOP)) {
say(TXT_FALL);
}
}
settle();
const btn = root.querySelector('[data-role="btn"]');
if (btn) {
btn.addEventListener('click', function () {
wake();
drop(W * (0.3 + hash1(splashCount, 21) * 0.4), H * (0.42 + hash1(splashCount, 5) * 0.2));
});
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
drop(clamp(p[0], 40, W - 40), clamp(p[1], 60, H - 40));
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight' && k !== 'ArrowUp' && k !== 'ArrowDown' && k !== 'Enter' && k !== ' ') {
return;
}
ev.preventDefault();
wake();
showCursor = true;
if (k === 'Enter' || k === ' ') {
drop(cursorX, cursorY);
return;
}
cursorX = clamp(cursorX + (k === 'ArrowLeft' ? -20 : (k === 'ArrowRight' ? 20 : 0)), 40, W - 40);
cursorY = clamp(cursorY + (k === 'ArrowUp' ? -14 : (k === 'ArrowDown' ? 14 : 0)), 60, H - 40);
say(TXT_GUIDE);
if (still) {
draw();
}
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MblInkDropCrownSplash - vanilla 판(06-mbl-ink-drop-crown-splash) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MblInkDropCrownSplash.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MblInkDropCrownSplash() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER: RGB = [252, 246, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx: CanvasRenderingContext2D | null = null;
let buf: ImageData | null = null;
let px: Uint8ClampedArray | null = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF: Float32Array = new Float32Array(0);
function initField(nw: number, nh: number): void {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) { return; }
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) { grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh); }
}
}
function blitField(nw: number, nh: number): void {
if (!sctx || !buf) { return; }
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n: number, seed: number): number {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t: number): number { return t * t * (3 - 2 * t); }
function vnoise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x: number, y: number, oct: number, seed: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x: number, y: number): number {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 06 왕관 튐 — 충격 링 펄스 + 접선 분사·중력 재낙하 낙점
const RING_PULSE = 1.1; // 충격 링 반경이 16ms 스텝마다 자라는 양(px)
const TANGENT_EJECT = 6.2; // 링 벽을 따라(위로) 튀어 오르는 처음 속도(px/스텝)
const GRAVITY_FALL = 0.36; // 중력 가속도(px/스텝²) — 되떨어져 낙점에 앉는다
const CROWN_N = 28; // 왕관 알갱이 수
const TILT = 0.46; // 비스듬히 내려다보는 시점 — 수면의 원이 이 비율로 눌린 타원이 된다
const RING_LIFE = 1500; // 링이 살아 있는 시간(ms)
const DEMO_LOOP = 3800;
const TXT_RING = '링이 퍼지는 중';
const TXT_RISE = '접선 방향으로 솟는 중';
const TXT_FALL = '중력으로 되떨어져 앉는다';
const TXT_GUIDE = '화살표로 위치, 엔터로 방울';
type Bead = { a: number; r: number; h: number; vh: number; land: number; tint: number };
type Splash = { x: number; y: number; t0: number; beads: Bead[]; tint: number };
let splashes: Splash[] = [];
let splashCount = 0;
let cursorX = W / 2;
let cursorY = H * 0.55;
let showCursor = false;
function newSplash(x: number, y: number, t0: number): Splash {
const beads: Bead[] = [];
for (let i = 0; i < CROWN_N; i += 1) {
const a = (i / CROWN_N) * Math.PI * 2 + (hash1(i, 55) - 0.5) * 0.2;
beads.push({ a: a, r: 6, h: 0, vh: TANGENT_EJECT * (0.6 + hash1(i + splashCount * 31, 77) * 0.5), land: 0, tint: (i + splashCount) % TINTS.length });
}
splashCount += 1;
return { x: x, y: y, t0: t0, beads: beads, tint: splashCount % TINTS.length };
}
// 알갱이 한 스텝 — 링과 함께 바깥으로 실려 가며(RING_PULSE) 위로는 중력에 감속한다. 높이가 0 아래로 내려가면 낙점
function stepBead(b: Bead): void {
if (b.land > 0) {
b.land = Math.min(1, b.land + 0.025);
return;
}
b.r += RING_PULSE;
b.vh -= GRAVITY_FALL;
b.h += b.vh;
if (b.h <= 0 && b.vh < 0) {
b.h = 0;
b.land = 0.01;
}
}
function step(): void {
for (let s = 0; s < splashes.length; s += 1) {
const sp = splashes[s];
for (let i = 0; i < sp.beads.length; i += 1) { stepBead(sp.beads[i]); }
}
if (splashes.length > 3) { splashes.shift(); }
}
function drawScene(): void {
const g0 = ctx.createLinearGradient(0, 0, 0, H);
g0.addColorStop(0, rgba(mix(PAPER, SKY, 0.16), 1));
g0.addColorStop(1, rgba(mix(PAPER, LILAC, 0.14), 1));
ctx.fillStyle = g0;
ctx.fillRect(0, 0, W, H);
for (let s = 0; s < splashes.length; s += 1) {
const sp = splashes[s];
const age = clock - sp.t0;
const fade = clamp(1 - age / (RING_LIFE + 400), 0, 1);
const tint = TINTS[sp.tint];
// 잉크 고임 — 착탄점에 남아 서서히 번지는 타원
const poolR = 14 + Math.min(Math.max(age, 0) * 0.02, 34);
ctx.fillStyle = rgba(mix(tint, INK, 0.3), 0.55);
ctx.beginPath();
ctx.ellipse(sp.x, sp.y, poolR, poolR * TILT, 0, 0, Math.PI * 2);
ctx.fill();
// 충격 링 — 알갱이와 같은 속도로 퍼지며 옅어지는 두 겹 펄스
const rr = 6 + Math.max(age, 0) * RING_PULSE / FIXED_DT;
if (fade > 0.02) {
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(mix(tint, INK, 0.4), 0.6 * fade);
ctx.beginPath();
ctx.ellipse(sp.x, sp.y, Math.min(rr, 330), Math.min(rr, 330) * TILT, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.3 * fade);
ctx.beginPath();
ctx.ellipse(sp.x, sp.y, rr * 0.7, rr * 0.7 * TILT, 0, 0, Math.PI * 2);
ctx.stroke();
}
// 뒤쪽 알갱이부터 그린다(화면 위쪽 = 먼 쪽)
const order = sp.beads.slice().sort(function (p: Bead, q: Bead): number { return Math.sin(p.a) - Math.sin(q.a); });
for (let i = 0; i < order.length; i += 1) {
const b = order[i];
const bx = sp.x + Math.cos(b.a) * b.r;
const groundY = sp.y + Math.sin(b.a) * b.r * TILT;
if (b.land > 0) {
const st = 3 + b.land * 3;
ctx.fillStyle = rgba(mix(TINTS[b.tint], INK, 0.3), 0.85);
ctx.beginPath();
ctx.ellipse(bx, groundY, st * 1.5, st * 0.75, 0, 0, Math.PI * 2);
ctx.fill();
continue;
}
// 솟는 알갱이 — 링 벽에서 튀어 오른 줄기와 끝의 방울
const by = groundY - b.h;
ctx.strokeStyle = rgba(mix(TINTS[b.tint], INK, 0.2), 0.7);
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.moveTo(bx, groundY);
ctx.lineTo(bx, by);
ctx.stroke();
const stretch = 1 + clamp(Math.abs(b.vh) * 0.08, 0, 1.2);
ctx.fillStyle = rgba(mix(TINTS[b.tint], INK, 0.15), 0.95);
ctx.beginPath();
ctx.ellipse(bx, by, 3.4, 3.4 * stretch, 0, 0, Math.PI * 2);
ctx.fill();
}
}
}
// 키보드로 고른 자리 — 화살표를 쓰면 다음 방울이 떨어질 곳을 눌린 타원으로 보여 준다
function draw(): void {
drawScene();
if (!showCursor) { return; }
ctx.beginPath();
ctx.ellipse(cursorX, cursorY, 10, 10 * TILT, 0, 0, Math.PI * 2);
ctx.strokeStyle = rgba(INK, 0.8);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// reduce — 알갱이가 모두 앉은 뒤의 장면을 같은 식으로 끝까지 돌려 만든다
function finishSplash(sp: Splash): void {
for (let i = 0; i < sp.beads.length; i += 1) {
const b = sp.beads[i];
for (let n = 0; n < 400 && b.land < 1; n += 1) { stepBead(b); }
}
}
function settle(): void {
splashes = [newSplash(W * 0.38, H * 0.5, -1e9), newSplash(W * 0.64, H * 0.6, -1e9)];
finishSplash(splashes[0]);
finishSplash(splashes[1]);
say(TXT_FALL);
}
function drop(x: number, y: number): void {
const sp = newSplash(x, y, still ? -1e9 : clock);
splashes.push(sp);
if (splashes.length > 3) { splashes.shift(); }
if (still) {
finishSplash(sp);
say(TXT_FALL);
draw();
return;
}
say(TXT_RING);
}
function demoTick(t: number): void {
if (crossed(t, 220, DEMO_LOOP)) { splashes.push(newSplash(W * 0.5, H * 0.55, clock)); say(TXT_RING); }
if (crossed(t, 700, DEMO_LOOP)) { say(TXT_RISE); }
if (crossed(t, 1100, DEMO_LOOP)) { splashes.push(newSplash(W * 0.27, H * 0.42, clock)); }
if (crossed(t, 1500, DEMO_LOOP)) { say(TXT_FALL); }
}
settle();
const btn = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (btn) {
btn.addEventListener('click', function (): void {
wake();
drop(W * (0.3 + hash1(splashCount, 21) * 0.4), H * (0.42 + hash1(splashCount, 5) * 0.2));
});
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
drop(clamp(p[0], 40, W - 40), clamp(p[1], 60, H - 40));
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight' && k !== 'ArrowUp' && k !== 'ArrowDown' && k !== 'Enter' && k !== ' ') { return; }
ev.preventDefault();
wake();
showCursor = true;
if (k === 'Enter' || k === ' ') { drop(cursorX, cursorY); return; }
cursorX = clamp(cursorX + (k === 'ArrowLeft' ? -20 : (k === 'ArrowRight' ? 20 : 0)), 40, W - 40);
cursorY = clamp(cursorY + (k === 'ArrowUp' ? -14 : (k === 'ArrowDown' ? 14 : 0)), 60, H - 40);
say(TXT_GUIDE);
if (still) { draw(); }
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="닿는 순간 잉크가 링 위로 솟았다 되떨어지는 왕관 튐"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">링이 퍼지는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="btn" type="button">방울 떨어뜨리기</button>
</div>
</div>
);
}06 · 닿는 순간 잉크가 링 위로 솟았다 되떨어지는 왕관 튐 — 측정 누적면적 14.168% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 알갱이 한 스텝 — 링과 함께 바깥으로 실려 가며(RING_PULSE) 위로는 중력에 감속한다. 높이가 0 아래로 내려가면 낙점
function stepBead(b) {
if (b.land > 0) {
b.land = Math.min(1, b.land + 0.025);
return;
}
b.r += RING_PULSE;
b.vh -= GRAVITY_FALL;
b.h += b.vh;
if (b.h <= 0 && b.vh < 0) {
b.h = 0;
b.land = 0.01;
}
}
07흩뿌린 점이 번지고 늘어나 얼룩지는 점 무늬 결
seedDots 는 5px 격자 안쪽 814자리마다 해시 값 하나를 SCATTER_P 0.55 와 견줘 작을 때만 점을 놓아 435개(53.4%)를 세우고, 위치와 크기(1.3~2.8)와 색도 자리 해시로 정해 다시 불러도 같은 점이 같은 자리에 난다. splatDots 는 점마다 자기 반경 상자만 돌며 (1 − r²)² × 0.55 무게와 그 무게만큼의 색을 함께 쌓아, 겹친 자리가 이웃 점 색의 가중 평균으로 섞이면서 화면 기준 12.5~26.9px 크기로 번진다. 이어 커널의 가로 반경과 x 위치를 최대 STRETCH_ALONG_COMB 1.9 배까지 늘려 점을 빗 방향으로 끌고, 렌더에서는 누적면적 68.5%·강도 26.9·16/23 프레임이 잡혔다.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="흩뿌린 점이 번지고 늘어나 얼룩지는 점 무늬 결"></canvas>
<p class="hs__status" data-role="status" aria-live="off">점을 흩뿌리는 중</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">점 다시 뿌리기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.hs {
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-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: 2px;
}
// 포인터로는 잉크를 다루고, 쓸어 넘기는 손가락 제스처는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
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: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER = [252, 246, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx = null;
let buf = null;
let px = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF = new Float32Array(0);
function initField(nw, nh) {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) {
return;
}
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) {
grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh);
}
}
}
function blitField(nw, nh) {
if (!sctx || !buf) {
return;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n, seed) {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t) { return t * t * (3 - 2 * t); }
function vnoise2(x, y, seed) {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x, y, oct, seed) {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x, y) {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 07 점 무늬 — 확률 산포 + 반경 내 이웃 혼합, 빗방향 이심 늘림
const SCATTER_P = 0.55; // 5px 격자 자리마다 점을 놓을 확률 — 자리마다 시드 해시 하나를 이 값과 견준다
const BLEND_RADIUS = 5.5; // 이웃 혼합 반경(필드 px)
const STRETCH_ALONG_COMB = 1.9; // 빗방향 이심 늘림 비(가로:세로)
const DOT_N = 210;
const DEMO_LOOP = 3200;
const FW = 192;
const FH = 120;
const TXT_SCAT = '점을 흩뿌리는 중';
const TXT_BLEND = '이웃 점과 섞여 번진다';
const TXT_STRETCH = '빗이 지나며 점을 늘린다';
let dots = [];
let scatterT = 0; // 산포 진행(0~1)
let stretchT = 0; // 늘림 진행(0~1)
let phase = 0;
function seedDots() {
dots = [];
let i = 0;
for (let gy = 1; gy < FH / 5 - 1; gy += 1) {
for (let gx = 1; gx < FW / 5 - 1; gx += 1) {
const h = hash2(gx, gy, 811);
// 격자 안쪽 칸에 시드 해시 확률로 점을 놓는다 — 자리마다 늘 같다
if (h < SCATTER_P) {
dots.push({
x: gx * 5 + (hash2(gx, gy, 977) - 0.5) * 3.6,
y: gy * 5 + (hash2(gx, gy, 341) - 0.5) * 3.6,
s: 1.3 + hash2(gx, gy, 553) * 1.5,
tint: Math.floor(hash2(gx, gy, 761) * TINTS.length) % TINTS.length,
k: 0.55 + hash2(gx, gy, 197) * 0.5,
});
i += 1;
}
}
}
scatterT = 0;
stretchT = 0;
}
const accF = new Float32Array(FW * FH);
const accR = new Float32Array(FW * FH);
const accG = new Float32Array(FW * FH);
const accB = new Float32Array(FW * FH);
// 반경 내 이웃 혼합 — 점마다 자기 반경 상자만 돌며 (1−r²)² 커널을 더한다. 겹친 곳은 색이 섞여 번진다.
// 빗방향 이심 늘림은 커널의 가로 반경만 STRETCH_ALONG_COMB 배까지 늘려 점을 빗 쪽으로 끈다
function splatDots() {
accF.fill(0);
accR.fill(0);
accG.fill(0);
accB.fill(0);
const sx = 1 + (STRETCH_ALONG_COMB - 1) * stretchT;
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
if (d.k > scatterT) {
continue;
}
const tc = TINTS[d.tint];
const ry = BLEND_RADIUS * d.s * 0.7;
const rx = ry * sx;
const cx = d.x * sx;
const x0 = Math.max(0, Math.floor(cx - rx)), x1 = Math.min(FW - 1, Math.ceil(cx + rx));
const y0 = Math.max(0, Math.floor(d.y - ry)), y1 = Math.min(FH - 1, Math.ceil(d.y + ry));
for (let y = y0; y <= y1; y += 1) {
const dy = (y - d.y) / ry;
for (let x = x0; x <= x1; x += 1) {
const dx = (x - cx) / rx;
const r2 = dx * dx + dy * dy;
if (r2 < 1) {
// 커널 무게만큼 그 점의 색도 함께 쌓는다 — 겹친 자리는 두 점 색의 가중 평균으로 섞인다
const wgt = (1 - r2) * (1 - r2) * 0.55;
const j = y * FW + x;
accF[j] += wgt;
accR[j] += wgt * tc[0];
accG[j] += wgt * tc[1];
accB[j] += wgt * tc[2];
}
}
}
}
}
function draw() {
if (!px || !sctx) {
return;
}
splatDots();
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
for (let x = 0; x < FW; x += 1) {
const g = grainF[y * FW + x];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
let col = base;
const j0 = y * FW + x;
const v = accF[j0];
if (v > 0.05) {
const tint = [accR[j0] / v, accG[j0] / v, accB[j0] / v];
col = mix(base, tint, smooth(Math.min(v, 1)) * 0.85);
if (v > 0.95) {
col = mix(col, INK, Math.min(v - 0.95, 1) * 0.35);
}
}
px[i4] = col[0];
px[i4 + 1] = col[1];
px[i4 + 2] = col[2];
px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle() {
scatterT = 1;
stretchT = 0.55;
say(TXT_STRETCH);
}
function step() {
if (phase === 0) {
scatterT = Math.min(1, scatterT + 0.010);
if (demo) {
say(TXT_SCAT);
}
if (scatterT >= 1) {
phase = 1;
}
}
else if (phase === 1) {
stretchT = Math.min(1, stretchT + 0.004);
if (demo) {
say(TXT_BLEND);
}
if (stretchT >= 0.5) {
phase = 2;
}
}
else {
stretchT = Math.min(1, stretchT + 0.003);
if (demo) {
say(TXT_STRETCH);
}
}
}
function rescatter() {
seedDots();
phase = 0;
if (still) {
settle();
draw();
return;
}
say(TXT_SCAT);
}
function demoTick(t) {
if (crossed(t, 200, DEMO_LOOP)) {
seedDots();
phase = 0;
}
}
seedDots();
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]');
if (btn) {
btn.addEventListener('click', function () {
wake();
rescatter();
});
}
cv.addEventListener('pointerdown', function (ev) {
wake();
// 손으로 점 뿌리기 — 그 자리에 점 몇 개를 얹는다
const p = toScene(ev);
const fx = p[0] * FW / W, fy = p[1] * FH / H;
for (let j = 0; j < 7; j += 1) {
dots.push({
x: clamp(fx + (hash1(dots.length + j, 91) - 0.5) * 16, 3, FW - 3),
y: clamp(fy + (hash1(dots.length + j, 33) - 0.5) * 16, 3, FH - 3),
s: 1.3 + hash1(dots.length + j, 7) * 1.4,
tint: (dots.length + j) % TINTS.length,
k: scatterT * 0.98,
});
}
scatterT = Math.max(scatterT, 0.99);
if (phase === 0) {
phase = 1;
}
if (still) {
draw();
return;
}
say(TXT_BLEND);
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k !== 'Enter' && k !== ' ') {
return;
}
ev.preventDefault();
wake();
rescatter();
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MblNonpareilDotGridScatter - vanilla 판(07-mbl-nonpareil-dot-grid-scatter) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MblNonpareilDotGridScatter.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MblNonpareilDotGridScatter() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER: RGB = [252, 246, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx: CanvasRenderingContext2D | null = null;
let buf: ImageData | null = null;
let px: Uint8ClampedArray | null = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF: Float32Array = new Float32Array(0);
function initField(nw: number, nh: number): void {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) { return; }
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) { grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh); }
}
}
function blitField(nw: number, nh: number): void {
if (!sctx || !buf) { return; }
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n: number, seed: number): number {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t: number): number { return t * t * (3 - 2 * t); }
function vnoise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x: number, y: number, oct: number, seed: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x: number, y: number): number {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 07 점 무늬 — 확률 산포 + 반경 내 이웃 혼합, 빗방향 이심 늘림
const SCATTER_P = 0.55; // 5px 격자 자리마다 점을 놓을 확률 — 자리마다 시드 해시 하나를 이 값과 견준다
const BLEND_RADIUS = 5.5; // 이웃 혼합 반경(필드 px)
const STRETCH_ALONG_COMB = 1.9;// 빗방향 이심 늘림 비(가로:세로)
const DOT_N = 210;
const DEMO_LOOP = 3200;
const FW = 192;
const FH = 120;
const TXT_SCAT = '점을 흩뿌리는 중';
const TXT_BLEND = '이웃 점과 섞여 번진다';
const TXT_STRETCH = '빗이 지나며 점을 늘린다';
type Dot = { x: number; y: number; s: number; tint: number; k: number };
let dots: Dot[] = [];
let scatterT = 0; // 산포 진행(0~1)
let stretchT = 0; // 늘림 진행(0~1)
let phase = 0;
function seedDots(): void {
dots = [];
let i = 0;
for (let gy = 1; gy < FH / 5 - 1; gy += 1) {
for (let gx = 1; gx < FW / 5 - 1; gx += 1) {
const h = hash2(gx, gy, 811);
// 격자 안쪽 칸에 시드 해시 확률로 점을 놓는다 — 자리마다 늘 같다
if (h < SCATTER_P) {
dots.push({
x: gx * 5 + (hash2(gx, gy, 977) - 0.5) * 3.6,
y: gy * 5 + (hash2(gx, gy, 341) - 0.5) * 3.6,
s: 1.3 + hash2(gx, gy, 553) * 1.5,
tint: Math.floor(hash2(gx, gy, 761) * TINTS.length) % TINTS.length,
k: 0.55 + hash2(gx, gy, 197) * 0.5,
});
i += 1;
}
}
}
scatterT = 0;
stretchT = 0;
}
const accF: Float32Array = new Float32Array(FW * FH);
const accR: Float32Array = new Float32Array(FW * FH);
const accG: Float32Array = new Float32Array(FW * FH);
const accB: Float32Array = new Float32Array(FW * FH);
// 반경 내 이웃 혼합 — 점마다 자기 반경 상자만 돌며 (1−r²)² 커널을 더한다. 겹친 곳은 색이 섞여 번진다.
// 빗방향 이심 늘림은 커널의 가로 반경만 STRETCH_ALONG_COMB 배까지 늘려 점을 빗 쪽으로 끈다
function splatDots(): void {
accF.fill(0);
accR.fill(0);
accG.fill(0);
accB.fill(0);
const sx = 1 + (STRETCH_ALONG_COMB - 1) * stretchT;
for (let i = 0; i < dots.length; i += 1) {
const d = dots[i];
if (d.k > scatterT) { continue; }
const tc = TINTS[d.tint];
const ry = BLEND_RADIUS * d.s * 0.7;
const rx = ry * sx;
const cx = d.x * sx;
const x0 = Math.max(0, Math.floor(cx - rx)), x1 = Math.min(FW - 1, Math.ceil(cx + rx));
const y0 = Math.max(0, Math.floor(d.y - ry)), y1 = Math.min(FH - 1, Math.ceil(d.y + ry));
for (let y = y0; y <= y1; y += 1) {
const dy = (y - d.y) / ry;
for (let x = x0; x <= x1; x += 1) {
const dx = (x - cx) / rx;
const r2 = dx * dx + dy * dy;
if (r2 < 1) {
// 커널 무게만큼 그 점의 색도 함께 쌓는다 — 겹친 자리는 두 점 색의 가중 평균으로 섞인다
const wgt = (1 - r2) * (1 - r2) * 0.55;
const j = y * FW + x;
accF[j] += wgt;
accR[j] += wgt * tc[0];
accG[j] += wgt * tc[1];
accB[j] += wgt * tc[2];
}
}
}
}
}
function draw(): void {
if (!px || !sctx) { return; }
splatDots();
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
for (let x = 0; x < FW; x += 1) {
const g = grainF[y * FW + x];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
let col = base;
const j0 = y * FW + x;
const v = accF[j0];
if (v > 0.05) {
const tint: RGB = [accR[j0] / v, accG[j0] / v, accB[j0] / v];
col = mix(base, tint, smooth(Math.min(v, 1)) * 0.85);
if (v > 0.95) { col = mix(col, INK, Math.min(v - 0.95, 1) * 0.35); }
}
px[i4] = col[0]; px[i4 + 1] = col[1]; px[i4 + 2] = col[2]; px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle(): void {
scatterT = 1;
stretchT = 0.55;
say(TXT_STRETCH);
}
function step(): void {
if (phase === 0) {
scatterT = Math.min(1, scatterT + 0.010);
if (demo) { say(TXT_SCAT); }
if (scatterT >= 1) { phase = 1; }
} else if (phase === 1) {
stretchT = Math.min(1, stretchT + 0.004);
if (demo) { say(TXT_BLEND); }
if (stretchT >= 0.5) { phase = 2; }
} else {
stretchT = Math.min(1, stretchT + 0.003);
if (demo) { say(TXT_STRETCH); }
}
}
function rescatter(): void {
seedDots();
phase = 0;
if (still) { settle(); draw(); return; }
say(TXT_SCAT);
}
function demoTick(t: number): void {
if (crossed(t, 200, DEMO_LOOP)) {
seedDots();
phase = 0;
}
}
seedDots();
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (btn) {
btn.addEventListener('click', function (): void {
wake();
rescatter();
});
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
// 손으로 점 뿌리기 — 그 자리에 점 몇 개를 얹는다
const p = toScene(ev);
const fx = p[0] * FW / W, fy = p[1] * FH / H;
for (let j = 0; j < 7; j += 1) {
dots.push({
x: clamp(fx + (hash1(dots.length + j, 91) - 0.5) * 16, 3, FW - 3),
y: clamp(fy + (hash1(dots.length + j, 33) - 0.5) * 16, 3, FH - 3),
s: 1.3 + hash1(dots.length + j, 7) * 1.4,
tint: (dots.length + j) % TINTS.length,
k: scatterT * 0.98,
});
}
scatterT = Math.max(scatterT, 0.99);
if (phase === 0) { phase = 1; }
if (still) { draw(); return; }
say(TXT_BLEND);
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k !== 'Enter' && k !== ' ') { return; }
ev.preventDefault();
wake();
rescatter();
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="흩뿌린 점이 번지고 늘어나 얼룩지는 점 무늬 결"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">점을 흩뿌리는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="btn" type="button">점 다시 뿌리기</button>
</div>
</div>
);
}07 · 흩뿌린 점이 번지고 늘어나 얼룩지는 점 무늬 결 — 측정 누적면적 68.47% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function seedDots() {
dots = [];
let i = 0;
for (let gy = 1; gy < FH / 5 - 1; gy += 1) {
for (let gx = 1; gx < FW / 5 - 1; gx += 1) {
const h = hash2(gx, gy, 811);
// 격자 안쪽 칸에 시드 해시 확률로 점을 놓는다 — 자리마다 늘 같다
if (h < SCATTER_P) {
dots.push({
x: gx * 5 + (hash2(gx, gy, 977) - 0.5) * 3.6,
y: gy * 5 + (hash2(gx, gy, 341) - 0.5) * 3.6,
s: 1.3 + hash2(gx, gy, 553) * 1.5,
tint: Math.floor(hash2(gx, gy, 761) * TINTS.length) % TINTS.length,
k: 0.55 + hash2(gx, gy, 197) * 0.5,
});
i += 1;
}
}
}
scatterT = 0;
stretchT = 0;
}
08층마다 밀도가 달라 띠 폭이 달라지는 마노 돌 결
warp 은 화면 좌표를 4옥타브 fbm2 두 개로 비틀어 넘기는 도메인 왜곡이라, 그림을 흔드는 대신 층을 찾을 주소가 최대 21.9px 휘고 그 비틂은 스텝마다 DOMAIN_SHIFT 0.0048 씩 흘러간다. layerAt 은 층 좌표 (x + 0.22y) × 0.055 에 저주파 노이즈를 더한 값의 소수부가 1 − AGATE_LAYER_DENSITY 를 넘는 부분만 짙게 칠하므로, 3px 간격 표본에서 짙은 층의 몫이 32.0% 로 밀도 값 0.35 를 거의 그대로 따라간다. 짙은 층 꼭대기(0.82 근처)에 얇은 광택을 얹어 돌 단면처럼 읽히게 했고, 렌더 수치는 누적면적 60.4%·강도 31.9·23/23 이다.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="층마다 밀도가 달라 띠 폭이 달라지는 마노 돌 결"></canvas>
<p class="hs__status" data-role="status" aria-live="off">층 경계가 굽어 흐른다</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">다른 단면</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.hs {
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-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: 2px;
}
// 포인터로는 잉크를 다루고, 쓸어 넘기는 손가락 제스처는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
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: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER = [252, 246, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx = null;
let buf = null;
let px = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF = new Float32Array(0);
function initField(nw, nh) {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) {
return;
}
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) {
grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh);
}
}
}
function blitField(nw, nh) {
if (!sctx || !buf) {
return;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n, seed) {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t) { return t * t * (3 - 2 * t); }
function vnoise2(x, y, seed) {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x, y, oct, seed) {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x, y) {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 08 마노 — 층별 색·밀도 + 도메인 왜곡 노이즈로 경계 굽힘
const AGATE_LAYER_DENSITY = 0.35; // 짙은 층의 기본 밀도(0~1)
const WARP_AMP = 26; // 도메인 왜곡 진폭(px)
const DOMAIN_SHIFT = 0.0048; // 왜곡 좌표가 스텝마다 흐르는 양
const DEMO_LOOP = 3800;
const FW = 240;
const FH = 150;
const TXT_FLOW = '층 경계가 굽어 흐른다';
const TXT_CLUMP = '짙은 층이 얇게 뭉친다';
const TXT_CUT = '다른 단면으로 갈았다';
let warpT = 0;
let slice = 0;
let saidClump = false;
function warp(x, y) {
// 도메인 왜곡 — 좌표 자체를 노이즈로 비틀어 층 경계를 굽힌다
const a = (fbm2(x * 0.012 + warpT, y * 0.012, 4, 101) - 0.5) * 2 * WARP_AMP;
const b = (fbm2(x * 0.012 + 519.3, y * 0.012 - warpT * 0.7, 4, 313) - 0.5) * 2 * WARP_AMP * 0.8;
return [x + a, y + b];
}
function layerAt(wx, wy) {
// 층 좌표 — 마노 단면의 띠 축. 밀도가 짙은 층은 얇고 촘촘하다
const s = (wx + wy * 0.22) * 0.055 + fbm2(wx * 0.006, wy * 0.006, 3, 61) * 1.5;
const fract = s - Math.floor(s);
// 층 밀도로 띠 폭 조절 — AGATE_LAYER_DENSITY 가 띠 내 짙은 비중을 정한다
const dense = smooth((fract - (1 - AGATE_LAYER_DENSITY)) / AGATE_LAYER_DENSITY);
return dense;
}
function draw() {
if (!px || !sctx) {
return;
}
const sx = FW / W, sy = FH / H;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
const wy = y / sy;
for (let x = 0; x < FW; x += 1) {
const wx = x / sx;
const g0 = grainF[y * FW + x];
const base = mix(PAPER, INK, g0 * 0.06 + 0.012);
const wp = warp(wx, wy);
const dense = layerAt(wp[0], wp[1]);
let col = base;
const li = Math.floor((wp[0] + wp[1] * 0.22) * 0.055 + fbm2(wp[0] * 0.006, wp[1] * 0.006, 3, 61) * 1.5);
const tint = TINTS[Math.abs(li + slice) % TINTS.length];
if (dense > 0.02) {
col = mix(base, tint, dense * 0.6);
if (dense > 0.6) {
col = mix(col, INK, (dense - 0.6) * 1.05);
}
}
else {
const pale = mix(base, mix(PAPER, tint, 0.4), 0.25 * (0.02 - dense > 0 ? 1 : 0));
col = pale;
}
// 결 광택 — 층 방향 수직의 얇은 하이라이트
const shine = Math.pow(clamp(1 - Math.abs(dense - 0.82) / 0.18, 0, 1), 3);
if (shine > 0.02) {
col = mix(col, PAPER, shine * 0.28);
}
px[i4] = col[0];
px[i4 + 1] = col[1];
px[i4 + 2] = col[2];
px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle() {
warpT = 1.3;
say(TXT_CLUMP);
}
function step() {
warpT += DOMAIN_SHIFT;
if (demo) {
if (!saidClump && warpT > 0.9) {
saidClump = true;
say(TXT_CLUMP);
}
else if (!saidClump) {
say(TXT_FLOW);
}
}
}
function nextSlice() {
slice += 3;
saidClump = false;
if (still) {
draw();
return;
}
say(TXT_CUT);
}
function demoTick(t) {
if (crossed(t, 200, DEMO_LOOP)) {
warpT = 0;
saidClump = false;
}
if (crossed(t, 2100, DEMO_LOOP)) {
slice += 3;
}
}
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]');
if (btn) {
btn.addEventListener('click', function () {
wake();
nextSlice();
});
}
cv.addEventListener('pointerdown', function (ev) {
wake();
nextSlice();
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight' && k !== 'Enter') {
return;
}
ev.preventDefault();
wake();
nextSlice();
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MblAgateLayerDensityBands - vanilla 판(08-mbl-agate-layer-density-bands) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MblAgateLayerDensityBands.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MblAgateLayerDensityBands() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER: RGB = [252, 246, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx: CanvasRenderingContext2D | null = null;
let buf: ImageData | null = null;
let px: Uint8ClampedArray | null = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF: Float32Array = new Float32Array(0);
function initField(nw: number, nh: number): void {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) { return; }
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) { grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh); }
}
}
function blitField(nw: number, nh: number): void {
if (!sctx || !buf) { return; }
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n: number, seed: number): number {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t: number): number { return t * t * (3 - 2 * t); }
function vnoise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x: number, y: number, oct: number, seed: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x: number, y: number): number {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 08 마노 — 층별 색·밀도 + 도메인 왜곡 노이즈로 경계 굽힘
const AGATE_LAYER_DENSITY = 0.35; // 짙은 층의 기본 밀도(0~1)
const WARP_AMP = 26; // 도메인 왜곡 진폭(px)
const DOMAIN_SHIFT = 0.0048; // 왜곡 좌표가 스텝마다 흐르는 양
const DEMO_LOOP = 3800;
const FW = 240;
const FH = 150;
const TXT_FLOW = '층 경계가 굽어 흐른다';
const TXT_CLUMP = '짙은 층이 얇게 뭉친다';
const TXT_CUT = '다른 단면으로 갈았다';
let warpT = 0;
let slice = 0;
let saidClump = false;
function warp(x: number, y: number): [number, number] {
// 도메인 왜곡 — 좌표 자체를 노이즈로 비틀어 층 경계를 굽힌다
const a = (fbm2(x * 0.012 + warpT, y * 0.012, 4, 101) - 0.5) * 2 * WARP_AMP;
const b = (fbm2(x * 0.012 + 519.3, y * 0.012 - warpT * 0.7, 4, 313) - 0.5) * 2 * WARP_AMP * 0.8;
return [x + a, y + b];
}
function layerAt(wx: number, wy: number): number {
// 층 좌표 — 마노 단면의 띠 축. 밀도가 짙은 층은 얇고 촘촘하다
const s = (wx + wy * 0.22) * 0.055 + fbm2(wx * 0.006, wy * 0.006, 3, 61) * 1.5;
const fract = s - Math.floor(s);
// 층 밀도로 띠 폭 조절 — AGATE_LAYER_DENSITY 가 띠 내 짙은 비중을 정한다
const dense = smooth((fract - (1 - AGATE_LAYER_DENSITY)) / AGATE_LAYER_DENSITY);
return dense;
}
function draw(): void {
if (!px || !sctx) { return; }
const sx = FW / W, sy = FH / H;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
const wy = y / sy;
for (let x = 0; x < FW; x += 1) {
const wx = x / sx;
const g0 = grainF[y * FW + x];
const base = mix(PAPER, INK, g0 * 0.06 + 0.012);
const wp = warp(wx, wy);
const dense = layerAt(wp[0], wp[1]);
let col = base;
const li = Math.floor((wp[0] + wp[1] * 0.22) * 0.055 + fbm2(wp[0] * 0.006, wp[1] * 0.006, 3, 61) * 1.5);
const tint = TINTS[Math.abs(li + slice) % TINTS.length];
if (dense > 0.02) {
col = mix(base, tint, dense * 0.6);
if (dense > 0.6) { col = mix(col, INK, (dense - 0.6) * 1.05); }
} else {
const pale = mix(base, mix(PAPER, tint, 0.4), 0.25 * (0.02 - dense > 0 ? 1 : 0));
col = pale;
}
// 결 광택 — 층 방향 수직의 얇은 하이라이트
const shine = Math.pow(clamp(1 - Math.abs(dense - 0.82) / 0.18, 0, 1), 3);
if (shine > 0.02) { col = mix(col, PAPER, shine * 0.28); }
px[i4] = col[0]; px[i4 + 1] = col[1]; px[i4 + 2] = col[2]; px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle(): void {
warpT = 1.3;
say(TXT_CLUMP);
}
function step(): void {
warpT += DOMAIN_SHIFT;
if (demo) {
if (!saidClump && warpT > 0.9) { saidClump = true; say(TXT_CLUMP); }
else if (!saidClump) { say(TXT_FLOW); }
}
}
function nextSlice(): void {
slice += 3;
saidClump = false;
if (still) { draw(); return; }
say(TXT_CUT);
}
function demoTick(t: number): void {
if (crossed(t, 200, DEMO_LOOP)) { warpT = 0; saidClump = false; }
if (crossed(t, 2100, DEMO_LOOP)) { slice += 3; }
}
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (btn) {
btn.addEventListener('click', function (): void {
wake();
nextSlice();
});
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
nextSlice();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight' && k !== 'Enter') { return; }
ev.preventDefault();
wake();
nextSlice();
});
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="층마다 밀도가 달라 띠 폭이 달라지는 마노 돌 결"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">층 경계가 굽어 흐른다</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="btn" type="button">다른 단면</button>
</div>
</div>
);
}08 · 층마다 밀도가 달라 띠 폭이 달라지는 마노 돌 결 — 측정 누적면적 60.386% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function warp(x, y) {
// 도메인 왜곡 — 좌표 자체를 노이즈로 비틀어 층 경계를 굽힌다
const a = (fbm2(x * 0.012 + warpT, y * 0.012, 4, 101) - 0.5) * 2 * WARP_AMP;
const b = (fbm2(x * 0.012 + 519.3, y * 0.012 - warpT * 0.7, 4, 313) - 0.5) * 2 * WARP_AMP * 0.8;
return [x + a, y + b];
}
function layerAt(wx, wy) {
// 층 좌표 — 마노 단면의 띠 축. 밀도가 짙은 층은 얇고 촘촘하다
const s = (wx + wy * 0.22) * 0.055 + fbm2(wx * 0.006, wy * 0.006, 3, 61) * 1.5;
const fract = s - Math.floor(s);
// 층 밀도로 띠 폭 조절 — AGATE_LAYER_DENSITY 가 띠 내 짙은 비중을 정한다
const dense = smooth((fract - (1 - AGATE_LAYER_DENSITY)) / AGATE_LAYER_DENSITY);
return dense;
}
09중심마다 퍼지는 동심원이 흔들리며 겹치는 먹 흐름 결
ringField 는 중심마다 스텝당 RING_SPEED 0.9px(초당 56.25px)로 자라는 고리를 두고, 각도마다 흐르는 노이즈로 반경을 최대 ±4.16px(RADIUS_NOISE 0.16 × RING_GAP 26)까지 흔들어 먹선이 떨리게 한다. 반환값은 Σc + INTERFERE_K × ((Σc)² − Σc²)/2 인데, 한 중심의 고리끼리는 26px 간격으로 떨어져 곱 항이 거의 0 이라 두 중심의 고리가 만나는 마디에서만 단순 합보다 짙은 먹이 선다. 시연은 200·480·760ms 에 첫 중심, 1000·1300ms 에 둘째 중심에 먹을 떨어뜨리고 이전 고리를 지우지 않으며, 렌더 측정은 누적면적 72.0%·강도 56.9·23/23 으로 아홉 장면 가운데 강도가 가장 높다.

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="중심마다 퍼지는 동심원이 흔들리며 겹치는 먹 흐름 결"></canvas>
<p class="hs__status" data-role="status" aria-live="off">동심원이 바깥으로 퍼진다</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">먹물 떨어뜨리기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.hs {
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-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, .9);
outline-offset: 2px;
}
// 포인터로는 잉크를 다루고, 쓸어 넘기는 손가락 제스처는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
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: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(58, 48, 76, 0.9);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER = [252, 246, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx = null;
let buf = null;
let px = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF = new Float32Array(0);
function initField(nw, nh) {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) {
return;
}
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) {
grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh);
}
}
}
function blitField(nw, nh) {
if (!sctx || !buf) {
return;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n, seed) {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t) { return t * t * (3 - 2 * t); }
function vnoise2(x, y, seed) {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x, y, oct, seed) {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x, y) {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 09 먹 흐름 — 시간 확산 동심원 + 반경 노이즈 흔들림, 이중심 간섭
const RING_SPEED = 0.9; // 동심원이 16ms 스텝마다 바깥으로 자라는 반경(px)
const RADIUS_NOISE = 0.16; // 반경에 흐르는 노이즈의 흔들림 진폭(비율)
const INTERFERE_K = 0.75; // 이중심 간섭 계수 — 원이 겹칠 때 밀어 세우는 힘
const RING_GAP = 26; // 동심원 이웃 고리 간격(px)
const RING_MAX_R = 320;
const DEMO_LOOP = 3600;
const FW = 192;
const FH = 120;
const TXT_SPREAD = '동심원이 바깥으로 퍼진다';
const TXT_SWAY = '반경 노이즈로 결이 흔들린다';
const TXT_INTER = '두 중심의 원이 간섭한다';
let rings = [];
let centers = [[W * 0.36, H * 0.5], [W * 0.66, H * 0.46]];
let inkN = 0;
function addCenter(x, y, t0) {
centers.push([x, y]);
if (centers.length > 3) {
centers.shift();
}
rings.push({ x: x, y: y, r: 2, born: t0 });
inkN += 1;
}
function ringField(x, y, now) {
// 시간 확산 동심원 — 스텝마다 반경이 자라고, 이웃 고리 간격으로 파동 무늬를 이룬다
let v = 0;
let v2 = 0;
for (let i = 0; i < rings.length; i += 1) {
const rg = rings[i];
const dx = x - rg.x, dy = y - rg.y;
const r = Math.sqrt(dx * dx + dy * dy);
if (r > rg.r + 2) {
continue;
}
// 반경 노이즈 — 각도마다 흐르는 노이즈로 결이 흔들린다
const ang = Math.atan2(dy, dx);
const sway = RADIUS_NOISE * RING_GAP * (fbm2(Math.cos(ang) * 2.4 + 7.1, Math.sin(ang) * 2.4 + now * 0.00021, 3, 401) - 0.5) * 2;
const d = r - (rg.r + sway);
const w = RING_GAP * 1.3;
if (Math.abs(d) < w) {
const c = Math.exp(-(d * d) / (w * w * 0.24)) * (1 - rg.r / RING_MAX_R);
v += c;
v2 += c * c;
}
}
// 이중심 간섭 — 고리끼리 겹치는 자리에 곱 항 ((Σc)² − Σc²)/2 를 INTERFERE_K 만큼 더한다.
// 한 중심의 고리끼리는 간격(RING_GAP)만큼 떨어져 거의 안 겹치므로 이 항은 두 중심의 고리가 만나는 마디에서 선다
return v + INTERFERE_K * (v * v - v2) * 0.5;
}
function draw() {
if (!px || !sctx) {
return;
}
const now = clock;
const sx = FW / W, sy = FH / H;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
const wy = y / sy;
for (let x = 0; x < FW; x += 1) {
const wx = x / sx;
const g0 = grainF[y * FW + x];
// 먹 흐름 바닥 — 종이가 약간 우유빛으로 받쳐 준다
const base = mix(mix(PAPER, MINT, 0.07), INK, g0 * 0.07 + 0.012);
let col = base;
const v = ringField(wx, wy, now);
if (v > 0.04) {
const stepK = Math.floor(v * 4) / 4;
col = mix(base, INK, clamp(stepK * 0.82, 0, 0.92));
}
px[i4] = col[0];
px[i4 + 1] = col[1];
px[i4 + 2] = col[2];
px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle() {
// reduce — 퍼진 상태의 고리들을 계산으로 만든다
rings = [];
for (let c = 0; c < centers.length; c += 1) {
for (let k = 0; k < 5; k += 1) {
rings.push({ x: centers[c][0], y: centers[c][1], r: 40 + k * 42, born: -1e9 });
}
}
say(TXT_SWAY);
}
function step() {
for (let i = 0; i < rings.length; i += 1) {
rings[i].r += RING_SPEED;
}
if (rings.length > 24) {
rings.splice(0, rings.length - 24);
}
if (demo) {
say(TXT_SPREAD);
}
}
function drop(x, y) {
addCenter(x, y, clock);
if (still) {
settle();
draw();
return;
}
say(TXT_INTER);
}
function demoTick(t) {
if (crossed(t, 200, DEMO_LOOP)) {
rings.push({ x: centers[0][0], y: centers[0][1], r: 2, born: clock });
say(TXT_SPREAD);
}
if (crossed(t, 480, DEMO_LOOP) || crossed(t, 760, DEMO_LOOP)) {
rings.push({ x: centers[0][0], y: centers[0][1], r: 2, born: clock });
}
if (crossed(t, 1400, DEMO_LOOP)) {
say(TXT_SWAY);
}
if (crossed(t, 1000, DEMO_LOOP) || crossed(t, 1300, DEMO_LOOP)) {
rings.push({ x: centers[1][0], y: centers[1][1], r: 2, born: clock });
say(TXT_INTER);
}
}
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]');
if (btn) {
btn.addEventListener('click', function () {
wake();
drop(W * (0.3 + hash1(inkN + 1, 23) * 0.4), H * (0.35 + hash1(inkN + 3, 29) * 0.3));
});
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
drop(clamp(p[0], 30, W - 30), clamp(p[1], 24, H - 24));
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k !== 'Enter' && k !== ' ') {
return;
}
ev.preventDefault();
wake();
drop(W * (0.32 + hash1(inkN + 7, 31) * 0.36), H * (0.36 + hash1(inkN + 11, 37) * 0.28));
});
settle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MblSuminagashiInkConcentricRings - vanilla 판(09-mbl-suminagashi-ink-concentric-rings) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MblSuminagashiInkConcentricRings.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MblSuminagashiInkConcentricRings() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER: RGB = [252, 246, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
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); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx: CanvasRenderingContext2D | null = null;
let buf: ImageData | null = null;
let px: Uint8ClampedArray | null = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF: Float32Array = new Float32Array(0);
function initField(nw: number, nh: number): void {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) { return; }
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) { grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh); }
}
}
function blitField(nw: number, nh: number): void {
if (!sctx || !buf) { return; }
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n: number, seed: number): number {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t: number): number { return t * t * (3 - 2 * t); }
function vnoise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x: number, y: number, oct: number, seed: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x: number, y: number): number {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 09 먹 흐름 — 시간 확산 동심원 + 반경 노이즈 흔들림, 이중심 간섭
const RING_SPEED = 0.9; // 동심원이 16ms 스텝마다 바깥으로 자라는 반경(px)
const RADIUS_NOISE = 0.16; // 반경에 흐르는 노이즈의 흔들림 진폭(비율)
const INTERFERE_K = 0.75; // 이중심 간섭 계수 — 원이 겹칠 때 밀어 세우는 힘
const RING_GAP = 26; // 동심원 이웃 고리 간격(px)
const RING_MAX_R = 320;
const DEMO_LOOP = 3600;
const FW = 192;
const FH = 120;
const TXT_SPREAD = '동심원이 바깥으로 퍼진다';
const TXT_SWAY = '반경 노이즈로 결이 흔들린다';
const TXT_INTER = '두 중심의 원이 간섭한다';
type Ring = { x: number; y: number; r: number; born: number };
let rings: Ring[] = [];
let centers: [number, number][] = [[W * 0.36, H * 0.5], [W * 0.66, H * 0.46]];
let inkN = 0;
function addCenter(x: number, y: number, t0: number): void {
centers.push([x, y]);
if (centers.length > 3) { centers.shift(); }
rings.push({ x: x, y: y, r: 2, born: t0 });
inkN += 1;
}
function ringField(x: number, y: number, now: number): number {
// 시간 확산 동심원 — 스텝마다 반경이 자라고, 이웃 고리 간격으로 파동 무늬를 이룬다
let v = 0;
let v2 = 0;
for (let i = 0; i < rings.length; i += 1) {
const rg = rings[i];
const dx = x - rg.x, dy = y - rg.y;
const r = Math.sqrt(dx * dx + dy * dy);
if (r > rg.r + 2) { continue; }
// 반경 노이즈 — 각도마다 흐르는 노이즈로 결이 흔들린다
const ang = Math.atan2(dy, dx);
const sway = RADIUS_NOISE * RING_GAP * (fbm2(Math.cos(ang) * 2.4 + 7.1, Math.sin(ang) * 2.4 + now * 0.00021, 3, 401) - 0.5) * 2;
const d = r - (rg.r + sway);
const w = RING_GAP * 1.3;
if (Math.abs(d) < w) {
const c = Math.exp(-(d * d) / (w * w * 0.24)) * (1 - rg.r / RING_MAX_R);
v += c;
v2 += c * c;
}
}
// 이중심 간섭 — 고리끼리 겹치는 자리에 곱 항 ((Σc)² − Σc²)/2 를 INTERFERE_K 만큼 더한다.
// 한 중심의 고리끼리는 간격(RING_GAP)만큼 떨어져 거의 안 겹치므로 이 항은 두 중심의 고리가 만나는 마디에서 선다
return v + INTERFERE_K * (v * v - v2) * 0.5;
}
function draw(): void {
if (!px || !sctx) { return; }
const now = clock;
const sx = FW / W, sy = FH / H;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
const wy = y / sy;
for (let x = 0; x < FW; x += 1) {
const wx = x / sx;
const g0 = grainF[y * FW + x];
// 먹 흐름 바닥 — 종이가 약간 우유빛으로 받쳐 준다
const base = mix(mix(PAPER, MINT, 0.07), INK, g0 * 0.07 + 0.012);
let col = base;
const v = ringField(wx, wy, now);
if (v > 0.04) {
const stepK = Math.floor(v * 4) / 4;
col = mix(base, INK, clamp(stepK * 0.82, 0, 0.92));
}
px[i4] = col[0]; px[i4 + 1] = col[1]; px[i4 + 2] = col[2]; px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle(): void {
// reduce — 퍼진 상태의 고리들을 계산으로 만든다
rings = [];
for (let c = 0; c < centers.length; c += 1) {
for (let k = 0; k < 5; k += 1) {
rings.push({ x: centers[c][0], y: centers[c][1], r: 40 + k * 42, born: -1e9 });
}
}
say(TXT_SWAY);
}
function step(): void {
for (let i = 0; i < rings.length; i += 1) {
rings[i].r += RING_SPEED;
}
if (rings.length > 24) { rings.splice(0, rings.length - 24); }
if (demo) { say(TXT_SPREAD); }
}
function drop(x: number, y: number): void {
addCenter(x, y, clock);
if (still) {
settle();
draw();
return;
}
say(TXT_INTER);
}
function demoTick(t: number): void {
if (crossed(t, 200, DEMO_LOOP)) {
rings.push({ x: centers[0][0], y: centers[0][1], r: 2, born: clock });
say(TXT_SPREAD);
}
if (crossed(t, 480, DEMO_LOOP) || crossed(t, 760, DEMO_LOOP)) {
rings.push({ x: centers[0][0], y: centers[0][1], r: 2, born: clock });
}
if (crossed(t, 1400, DEMO_LOOP)) {
say(TXT_SWAY);
}
if (crossed(t, 1000, DEMO_LOOP) || crossed(t, 1300, DEMO_LOOP)) {
rings.push({ x: centers[1][0], y: centers[1][1], r: 2, born: clock });
say(TXT_INTER);
}
}
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (btn) {
btn.addEventListener('click', function (): void {
wake();
drop(W * (0.3 + hash1(inkN + 1, 23) * 0.4), H * (0.35 + hash1(inkN + 3, 29) * 0.3));
});
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
drop(clamp(p[0], 30, W - 30), clamp(p[1], 24, H - 24));
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k !== 'Enter' && k !== ' ') { return; }
ev.preventDefault();
wake();
drop(W * (0.32 + hash1(inkN + 7, 31) * 0.36), H * (0.36 + hash1(inkN + 11, 37) * 0.28));
});
settle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="중심마다 퍼지는 동심원이 흔들리며 겹치는 먹 흐름 결"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">동심원이 바깥으로 퍼진다</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="btn" type="button">먹물 떨어뜨리기</button>
</div>
</div>
);
}09 · 중심마다 퍼지는 동심원이 흔들리며 겹치는 먹 흐름 결 — 측정 누적면적 72.013% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function ringField(x, y, now) {
// 시간 확산 동심원 — 스텝마다 반경이 자라고, 이웃 고리 간격으로 파동 무늬를 이룬다
let v = 0;
let v2 = 0;
for (let i = 0; i < rings.length; i += 1) {
const rg = rings[i];
const dx = x - rg.x, dy = y - rg.y;
const r = Math.sqrt(dx * dx + dy * dy);
if (r > rg.r + 2) {
continue;
}
// 반경 노이즈 — 각도마다 흐르는 노이즈로 결이 흔들린다
const ang = Math.atan2(dy, dx);
const sway = RADIUS_NOISE * RING_GAP * (fbm2(Math.cos(ang) * 2.4 + 7.1, Math.sin(ang) * 2.4 + now * 0.00021, 3, 401) - 0.5) * 2;
const d = r - (rg.r + sway);
const w = RING_GAP * 1.3;
if (Math.abs(d) < w) {
const c = Math.exp(-(d * d) / (w * w * 0.24)) * (1 - rg.r / RING_MAX_R);
v += c;
v2 += c * c;
}
}
// 이중심 간섭 — 고리끼리 겹치는 자리에 곱 항 ((Σc)² − Σc²)/2 를 INTERFERE_K 만큼 더한다.
// 한 중심의 고리끼리는 간격(RING_GAP)만큼 떨어져 거의 안 겹치므로 이 항은 두 중심의 고리가 만나는 마디에서 선다
return v + INTERFERE_K * (v * v - v2) * 0.5;
확산 반경·빗 이빨 간격·층 밀도 — 숫자 하나가 무늬의 결을 바꾸는 자리
여기 적은 값은 렌더로 잰 것이 아니다. inkAt·tineShift·layerAt·tradeDens 를 노드에서 그대로 불러 상수만 바꿔 가며 계산했으므로 위 측정 표와 섞어 읽으면 안 된다.
BLOOM_RADIUS 를 96 에서 200 으로 키우면 방울 하나가 화면을 거의 덮는다
방울 하나가 덮는 넓이가 화면의 20.1% 에서 87.3% 로 커진다. 고리 수 RING_N 5 는 그대로라 띠 한 칸이 19.2px 에서 40px 로 굵어지고, 중심에서 50px 떨어져 있던 잉크는 108.2px 이 아니라 206.2px 자리까지 밀려난다. 새 방울 하나가 떨어질 때마다 이전 무늬 대부분이 화면 밖으로 나가므로 겹겹이 쌓인 결 대신 큰 고리 한 벌만 남는다.
COMB_TOOTH_GAP 을 14 에서 34 로 벌리면 깃털이 2.4배 넓어진다
480px 폭을 지나가는 이빨이 34.3개에서 14.1개로 줄고, 끌림이 잦아드는 폭 λ 는 5.92px 에서 14.38px 로 커진다. 그런데 반 칸 지점의 끌림은 두 값 모두 29.3px 로 같다. λ 가 이빨 간격에 비례해 함께 늘어나서 깃털의 생김새 비율은 지키고 크기만 바뀌기 때문이다.
AGATE_LAYER_DENSITY 를 0.35 에서 0.72 로 올리면 가는 띠가 굵은 돌 결로 뭉친다
짙은 층에 드는 표본의 몫이 32.0% 에서 65.9% 로, 평균 밀도가 0.174 에서 0.359 로 늘어난다. WARP_AMP 26 은 그대로라 경계가 휘는 최대 폭 21.9px 는 바뀌지 않는다. 띠의 굵기와 휘는 정도가 서로 다른 상수에 묶여 있어서, 돌 결의 무게감과 물결감을 따로 고를 수 있다.
SPLIT_DEPTH 를 3 에서 1 로 내리면 틈이 조금 더 빨리 벌어진다
옥타브가 한 겹이면 이득 지도에 큰 골짜기만 남고 그 안의 작은 골짜기가 사라진다. 같은 160스텝 뒤 틈 비율은 22.7% 대신 24.5% 가 되어, 가지가 잘게 갈라지는 대신 굵은 틈 몇 줄이 먼저 벌어진다.
팔레트 색이 가까울 때·무늬가 화면보다 클 때·움직임 줄이기 설정에서 깨지는 곳
03 의 접힘 대비를 0.5 로 올리면 밝기 계단은 사라져도 접힌 면 그늘은 남는다
밝은 면 밝기는 1 − FOLD_CONTRAST, 접힌 면은 0.42 × (1 − 대비) + 0.58 × 대비라서, 대비가 0.5 가 되는 순간 두 면의 밝기 계단이 똑같이 0.5 가 된다. 이름만 보면 대비를 키울수록 주름이 또렷해질 것 같지만 0.5 를 지나면 오히려 접힌 면의 밝기 계단 값이 더 높아지기 시작하고, 정확히 0.5 에서는 계단이 사라진 채 접힌 면에 먹을 섞는 그늘 한 겹만 남는다.
07 의 번짐 반경을 키우면 섞인 색이 탁해진다
BLEND_RADIUS 가 커지면 한 자리에 겹치는 이웃 점이 늘고, 가중 평균으로 섞인 색은 다섯 파스텔 색의 가운데, 곧 회색 쪽으로 끌려간다. SCATTER_P 를 1 로 올려도 비슷하게 망가지는데, 814자리가 모두 차서 점 무늬가 아니라 고른 바탕이 된다.
01 의 큰 방울은 이전 무늬를 한꺼번에 밀어낸다
방울이 DROP_MAX 7개를 넘으면 가장 오래된 방울을 버린다. 이미 화면 밖으로 밀려났으리라는 가정인데, 화면 가운데 큰 방울이 오래 남아 있는 상황에서는 그 방울이 어느 순간 통째로 사라져 보인다. 픽셀마다 방울 수만큼 되짚기 때문에 상한을 올리면 그만큼 계산도 늘어난다.
07 의 늘림은 오른쪽 점부터 화면 밖으로 민다
늘림은 x 좌표 자체에 배율을 곱하는 방식이라 왼쪽 끝 점은 거의 제자리에 있고 오른쪽 점일수록 멀리 밀린다. 1.9배까지 늘리면 오른쪽 절반의 점은 무대 밖으로 나가 버리므로, 넓은 화면 전체에 깔 때는 늘림 기준점을 가운데로 옮기는 편이 낫다.
08 의 휨이 층 간격보다 크면 띠가 섬처럼 끊긴다
층과 층 사이는 약 18px 인데 WARP_AMP 를 키워 휨이 이 간격을 넘으면 같은 층이 한 줄 위에서 두 번 지나가며 접힌다. 그러면 이어져야 할 띠가 섬처럼 끊겨 돌보다는 얼룩처럼 보인다.
06 의 왕관은 바깥으로 벌어지지 않는다
알갱이는 링 벽을 따라 수직으로만 솟았다 떨어지는 근사라서, 실제 왕관처럼 가장자리가 바깥으로 벌어지며 갈라지는 모양은 나오지 않는다. 벌어짐까지 원하면 알갱이마다 바깥 방향 속도를 하나 더 두고, 솟아 있는 동안 그 값을 알갱이가 링 중심에서 떨어진 거리 b.r 에 더해 주면 된다. GRAVITY_FALL 을 너무 작게 하면 비행이 RING_LIFE 1500ms 보다 길어져 링이 사라진 뒤에도 알갱이가 떠 있고, 가장자리에 떨어뜨리면 알갱이가 무대 밖으로 나가기 때문에 포인터 좌표를 가로 40~W−40, 세로 60~H−40 안으로 잘라 둔다.
02 는 반응항이 빠지면 0.75초 만에 멈춘다
첫 판은 EDGE_KEEP 이 0 이었고, 잉크가 흐린 얼룩으로 고르게 퍼진 채 0.75초 만에 수렴해 그 뒤로는 프레임이 하나도 바뀌지 않았다. 반대로 DENSITY_DIFF_K 를 키워 교환량이 셀 밀도 24% 상한에 자주 걸리면 흐름이 상한에서 잘려 결이 격자 모양으로 굳는다.
05 는 두 빗이 같은 줄을 지나면 서로 지운다
STAGGER_PHASE 가 0 이면 둘째 빗이 첫 빗과 같은 줄을 반대로 끌어, 두 끌림이 같을 때 무늬가 처음 띠로 완전히 돌아간다. 필드 240×150 에서 이빨 간격 14px 은 필드 7칸이라, 간격을 6px 아래로 좁히면 이빨 하나가 필드 3칸도 안 되어 깃털 대신 깨진 줄이 남는다.
09 의 간섭 계수를 키우면 마디가 덩어리로 뭉친다
INTERFERE_K 를 올리면 두 중심의 고리가 만나는 마디가 먹색 상한 0.92 에 붙어 점 대신 덩어리가 된다. RING_MAX_R 320 을 넘은 고리는 보이지 않아도 최근 24개 안에 있는 동안 계속 계산된다.
작은 필드를 늘린 자리에는 계단이 보인다
01 은 160×100 을 3배로, 02 는 96×60 을 5배로 늘려 붙여서 띠와 셀 경계가 가까이 보면 계단으로 보인다. 필드를 키우면 계단은 줄지만 계산은 가로세로 곱으로 늘고, 그중 08 은 픽셀마다 4옥타브 노이즈 두 번과 3옥타브 노이즈 두 번을 240×150, 곧 3만 6천 픽셀에 돌려 아홉 장면 가운데 가장 무겁다.
움직임 줄이기 설정에서는 06 의 왕관이 솟는 순간이 보이지 않는다
움직임 줄이기가 켜져 있으면 06 은 finishSplash 로 같은 stepBead 를 알갱이가 다 내려앉을 때까지 한 번에 돌린 결과만 그린다. 그래서 점이 앉은 자리는 애니메이션과 똑같지만 왕관이 솟는 장면 자체는 나오지 않고, 09 도 퍼지는 과정 없이 다 퍼진 고리를 다시 계산해 보여 준다.
상수 하나씩 고쳐 가며 무늬가 무너지는 경계를 직접 찾아보려면 아래 zip 을 내려받아 95rs6qwq 을 넣고 풀면 된다. 순수 HTML 판(vanilla/)과 리액트 판(react/)이 같은 상수 이름을 쓰므로 어느 쪽에서 찾아도 글의 이름이 그대로 나온다.
잉크·빗·돌 결의 값을 바꾸는 세 가지와 접근성
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 잉크 확산 반경 바꾸기 | 01 BLOOM_RADIUS 96 → 200 | 방울 하나가 화면의 87.3% 를 덮고 띠 한 칸이 40px 로 굵어져, 겹친 결 대신 큰 고리 한 벌이 남는다. |
| 빗 이빨 간격 바꾸기 | 05 COMB_TOOTH_GAP 14 → 34 | 이빨이 14.1개로 줄고 깃털이 2.4배 넓어지지만 반 칸 끌림 29.3px 는 같아 생김새 비율은 유지된다. |
| 돌 결 층 밀도 바꾸기 | 08 AGATE_LAYER_DENSITY 0.35 → 0.72 | 짙은 층 몫이 65.9% 로 늘어 굵은 띠로 뭉치고, 휘는 폭 21.9px 는 그대로다. |
마우스가 없어도 아홉 장면을 모두 움직일 수 있다. 캔버스마다 tabindex="0" 과 항목 이름을 담은 한국어 aria-label 이 있고, 키는 장면마다 다르다 — 01 은 방향키로 원 모양 커서를 옮기고 Enter·Space 로 방울을 떨어뜨리며, 02 는 방향키로 잉크를 밀고, 03 은 좌우로 부채를 돌리고, 04 는 좌우로 빗질 방향을 정한다. 05 는 ↓ 로 첫 빗, ↑ 로 둘째 빗을 끌고, 06 은 방향키 커서와 Enter·Space 방울, 07 은 Enter·Space 로 다시 뿌리기, 08 은 좌우나 Enter 로 다른 단면, 09 는 Enter·Space 로 먹물을 떨어뜨린다. preventDefault 는 그 장면이 쓰는 키에서만 불러 Tab 이동은 막히지 않고, 장면마다 <button> 이 하나씩 있으며 캔버스와 버튼 모두 :focus-visible 테두리가 보인다. 터치에서는 touch-action: pan-y 라 무대 위에서 세로로 쓸어도 페이지가 스크롤된다.
상태 줄은 시연이 도는 동안 aria-live="off" 로 조용히 있다가, 사람이 처음 포인터나 키나 버튼을 건드리는 순간 wake() 가 시연을 끝내면서 polite 로 바뀐다. 자동 시연이 화면 읽기 프로그램을 계속 떠들게 하지 않으려는 순서다.
prefers-reduced-motion 은 아홉 장면 모두 스크립트의 matchMedia 와 스타일시트 양쪽에서 확인한다. 이 설정이 켜지면 rAF 루프를 아예 걸지 않고 완성된 상태로 맞춘 장면을 한 장만 그려 둔다. 입력이 들어와도 중간 과정 없이 목표로 건너뛴다 — 01 은 다 퍼진 방울을 더하고, 02 는 교환 24번을 한꺼번에, 03 은 0.28rad 회전을, 04 는 위상 0.9rad 를 바로 적용하며, 05 는 끌림 목표값으로, 06 은 알갱이가 다 앉은 상태로, 07 은 산포와 늘림 0.55 로, 08 은 단면 교체로, 09 는 다 퍼진 고리로 곧장 넘어간 뒤 draw() 를 한 번 부른다.
마블링 배경을 CSS 규칙만으로 어디까지 흘릴 수 있는가?
이 편에서 CSS 가 하는 일은 무대 틀이다. .hs 가 width: min(92vw, calc(160vh - 12.8vw)) 와 aspect-ratio: 480 / 300 으로 크기를 정하고, 16px 모서리와 overflow: hidden, 그림자를 준다. 아홉 장면의 스타일시트에 @keyframes 는 하나도 없고 움직임은 전부 스크립트에서 나온다. 그라디언트를 겹치면 줄무늬나 동심원처럼 정해진 무늬는 그릴 수 있지만, 01 처럼 새 방울이 이전의 모든 방울을 밀어내거나 02 처럼 이웃 칸끼리 잉크를 주고받는 일은 다음 장면이 직전 장면에 달려 있어 규칙만으로는 적어 둘 수 없다.
정해진 무늬를 시드로 다시 그리는 쪽이 필요하면 CSS 배경 패턴 9종, 그라디언트 층만으로 하늘색을 바꾸는 예는 css sunset gradient 9가지 가 가깝다. 픽셀 계산을 더 깊이 보려면 물방울 굴절을 다룬 html5 canvas 애니메이션 9종 과 격자 위 잉크 흐름을 푼 유체 시뮬레이션 9종 으로 이어 가면 된다. 버퍼를 화면에 올리는 방식은 putImageData 와 requestAnimationFrame 문서를 기준으로 짰다.
마블링 아트 화면을 내 페이지에 옮기기 전에 자주 묻는 질문
마블링 패턴의 색을 내 사이트 색으로 바꾸려면 어디를 고치나?
아홉 파일 모두 스크립트 앞쪽 27~34행에 바탕 PAPER(252, 246, 234), 먹 INK(58, 48, 76), 잉크 다섯 색을 담은 TINTS 가 같은 자리에 있다. 이 배열만 바꾸면 계산은 그대로 두고 색만 갈아 끼울 수 있다. 무늬 위에 글자를 얹을 계획이라면 가장 진한 색과 가장 옅은 색 양쪽에서 글자 대비를 확인해야 한다.
새로고침할 때마다 다른 무늬가 나오게 할 수 있나?
지금은 해시 호출에 넘기는 시드 숫자가 고정이라 매번 같은 무늬가 나온다. 07 이라면 hash2(gx, gy, 811) 의 811 을, 08 이라면 warp 안 fbm2 의 101·313 을 다른 수로 바꾸면 결이 달라진다. 파일마다 SEED 463 상수도 선언돼 있지만 지금의 계산 함수들은 그 상수가 아니라 호출마다 적힌 숫자를 쓴다.
페이지 전체 배경으로 늘려 깔아도 무겁지 않나?
비용은 화면 크기가 아니라 필드 픽셀 수를 따라간다. 무대가 커져도 필드는 그대로 두고 늘려 붙이기만 하므로 계산량은 같고, 대신 늘린 배율만큼 계단이 굵어진다. 가장 무거운 08 은 필드를 줄이거나, 멈춘 한 장면으로 써도 돌 단면으로 충분히 읽히니 움직임이 꼭 필요한 자리인지 먼저 따져 보면 좋다.









