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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 落ちたインクが輪を作りながら外へ広がるしずくの広がり
- 02 押されたインクの面が細いすき間に割れて枝分かれする断片の目
- 03 一点から伸びるひだが扇のように折れるブーケの目
- 04 一方向へ押された波の帯が斜めに固まる櫛の目
- 05 逆方向の二つの櫛がずれて出会い、羽に割れる目
- 06 触れた瞬間にインクが輪の上へ上がり、落ちて戻る王冠の跳ね
- 07 まいた点がにじみ、伸びて、まだらになる点模様の目
- 08 層ごとに密度が変わり帯の幅が変わる瑪瑙の目
- 09 中心ごとに広がる同心円が揺れ、重なる墨流しの目
css 背景 マーブル 9パターン — インクの広がり・櫛の目・石の目で描く流れる背景
並べ方は、色が水の上でたどる順番に合わせました。01から03は浮かんだインクが広がり、割れ、折れる段階、04と05は熊手と櫛でその膜をかく段階、06と07はしずくと点が新しく落ちる瞬間、08と09は層になって固まった石の断面と、輪になって広がる墨です。九つの場面はどれも <canvas> 一枚の上に描かれ、スタイルシートが受け持つのは舞台の縦横比や角の丸み、影、ボタンとフォーカスの枠といった外側の枠です。同じ計算でも定数ひとつで模様の表情が大きく変わるので、その数字は後ろの値の節にまとめました。
2秒・24枚のキャプチャで数えた動き
場面ごとに2秒間で24枚を撮り、直前の1枚と比べて変わったピクセルを数えました。右端の列は、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)。ファイルの先頭には const SEED = 463 も書かれていますが、どの計算もこの定数を使っていません。そのため再読み込みしても、同じ場所に同じ目が立ちます。
このあとの各項目のコードは、場面ごとの 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幅の帯5重で塗り、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 = '矢印で位置を動かし、Enter で落とします';
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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のあいだの値を両端へ押し戻し、谷が空く場所を鋭いすき間として立てます。ソースの関数を Node で回すと、すき間(密度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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 交換一回 — 右・下の隣の組だけを回り、同じ組を二度数えない。谷が空いてすき間になり、
// 反応項 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 櫛の歯一列がインクを引く量 — いちばん近い歯の列までの横の距離 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 がその位置を落点として固め、Node で測ると最も遅い粒は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 = '矢印で位置を動かし、Enter でしずくを落とします';
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 粒の1ステップ — 輪と一緒に外へ運ばれ(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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 を Node でそのまま呼び、定数だけを変えて計算した値なので、上の測定表とは分けて読んでください。
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を越えると段の値だけは折れた面のほうが高くなりますが、折れた面に混ぜる墨の量も FOLD_CONTRAST に比例して増えるので、折れた面がそのまま明るく見えるとは限りません。ちょうど0.5では段の差が消えても、折れた面に墨の色 INK を混ぜる行が別にあるので、陰がひと重ね残ります。
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 版(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場面、グラデーションの層だけで空の色を変える例なら夕焼けグラデーションの9場面が近い記事です。ピクセルの計算をもっと深く見たいなら、水滴の屈折を扱ったキャンバスの特殊効果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はフィールドを小さくするか、止めた一場面として使っても石の断面として十分に読めるので、動きが本当に要る場所かどうかを先に考えてみてください。









