09A Hero Backdrop Where Three Layers Grow From One Seed
From one HERO_SEED split into separate random streams, flow lines, veins and packed circles start growing LAYER_STAGGER_MS apart and stack up (circles avoid vein nodes); once finished the scene holds for HOLD_MS before fading into the next seed's picture.
Best forLanding hero for a startup
HERO_SEEDLAYER_STAGGER_MSHOLD_MS

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A hero backdrop where flow lines, veins and packed circles grow in turn from one seed, hold when finished, then fade into the next seed's picture. Press Enter for a new seed, R to regrow the same seed from the start, and Space to pause."></canvas>
<p class="pc__status" data-role="status" aria-live="off">Three layers growing in turn</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">Redraw</button>
<button class="pc__btn" data-role="replay" type="button">Same seed</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
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;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
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;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 Lines·면을 얹어 명도 차로 형태가 읽힌다
const INK = [29, 26, 44];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + 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)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 Seed — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const actx = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]');
const replayBtn = root.querySelector('[data-role="replay"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, Pause 동안은 멈춘다)
let seed = 0;
let age = 0;
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;
}
}
let dprNow = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) {
return;
}
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) {
kctx.drawImage(artEl, 0, 0);
}
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) {
actx.drawImage(keep, 0, 0, W, H);
}
dprNow = dpr;
}
function clearArt(c) {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt() { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs) {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3);
}
}
function makeNoise(rng) {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) {
perm[i] = i;
vals[i] = rng();
}
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
return { perm: perm, vals: vals };
}
function noise2(n, x, y) {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n, x, y, octaves) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeBuckets(cell) {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b = [];
for (let i = 0; i < cols * rows; i += 1) {
b.push([]);
}
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g, x, y, id) {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g) {
for (let i = 0; i < g.b.length; i += 1) {
g.b[i].length = 0;
}
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g, x, y, rad, visit) {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) {
return true;
}
}
}
}
return false;
}
// 무작위 출발점이 연달아 이만큼 거절되면 빈자리가 없다고 보고 끝낸다
const FLOW_SEED_FAILS = 160;
function makeFlow(rng, noise, o) {
const fcols = Math.ceil(W / o.cell) + 2;
const frows = Math.ceil(H / o.cell) + 2;
const vx = new Float64Array(fcols * frows);
const vy = new Float64Array(fcols * frows);
// 칸 꼭짓점마다 방향을 한 번만 정해 둔다 — 노이즈 0~1 을 두 바퀴(4π) 각도로
for (let j = 0; j < frows; j += 1) {
for (let i = 0; i < fcols; i += 1) {
const a = fbm2(noise, i * o.cell * o.scale, j * o.cell * o.scale, 2) * TAU * 2;
vx[j * fcols + i] = Math.cos(a);
vy[j * fcols + i] = Math.sin(a);
}
}
return {
o: o, rng: rng, vx: vx, vy: vy, fcols: fcols, frows: frows,
px: [], py: [], pl: [], po: [], grid: makeBuckets(o.sep),
cand: [], candAt: 0, line: -1, dir: 1, ord: 0, x: 0, y: 0, sx: 0, sy: 0, pts: [],
active: false, lines: 0, length: 0, fails: 0, done: false,
};
}
// 네 꼭짓점 방향을 거리 비율로 섞어(쌍선형) Length 1 로 — 칸 경계에서 방향이 꺾이지 않는다
function flowDir(f, x, y) {
const gx = clamp(x / f.o.cell, 0, f.fcols - 1.001);
const gy = clamp(y / f.o.cell, 0, f.frows - 1.001);
const i = Math.floor(gx);
const j = Math.floor(gy);
const u = gx - i;
const v = gy - j;
const k = j * f.fcols + i;
const dx = lerp(lerp(f.vx[k], f.vx[k + 1], u), lerp(f.vx[k + f.fcols], f.vx[k + f.fcols + 1], u), v);
const dy = lerp(lerp(f.vy[k], f.vy[k + 1], u), lerp(f.vy[k + f.fcols], f.vy[k + f.fcols + 1], u), v);
const L = Math.hypot(dx, dy);
return L < 1e-6 ? [1, 0] : [dx / L, dy / L];
}
// (x, y) 반경 d 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 Length 안)은 세지 않는다
function flowTooClose(f, x, y, d, line, ord) {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id) {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) {
return false;
}
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
function flowInside(f, x, y) {
const m = f.o.margin;
return x >= m && x <= W - m && y >= m && y <= H - m;
}
function flowAddPoint(f, x, y) {
const id = f.px.length;
f.px.push(x);
f.py.push(y);
f.pl.push(f.line);
f.po.push(f.ord);
bucketAdd(f.grid, x, y, id);
f.pts.push(id);
}
function flowStart(f, x, y) {
f.line = f.lines;
f.lines += 1;
f.dir = 1;
f.ord = 0;
f.x = x;
f.y = y;
f.sx = x;
f.sy = y;
f.pts = [];
flowAddPoint(f, x, y);
f.active = true;
}
// Lines 하나가 끝나면 그 선을 따라 양옆으로 간격만큼 떨어진 자리를 다음 출발 후보로 줄 세운다
function flowEndLine(f) {
const stride = Math.max(1, Math.round(f.o.sep / f.o.step));
for (let k = 0; k < f.pts.length; k += stride) {
const id = f.pts[k];
const d = flowDir(f, f.px[id], f.py[id]);
f.cand.push(f.px[id] - d[1] * f.o.sep, f.py[id] + d[0] * f.o.sep);
f.cand.push(f.px[id] + d[1] * f.o.sep, f.py[id] - d[0] * f.o.sep);
}
f.active = false;
}
// 한 틱의 일 — 걸음 하나가 1, 출발점 검사 하나가 0.2. budget 을 다 쓰면 다음 틱으로 넘긴다
function flowStep(f, budget, seg) {
let b = budget;
while (b > 0 && !f.done) {
if (!f.active) {
b -= 0.2;
if (f.candAt < f.cand.length) {
const cx = f.cand[f.candAt];
const cy = f.cand[f.candAt + 1];
f.candAt += 2;
if (flowInside(f, cx, cy) && !flowTooClose(f, cx, cy, f.o.sep * 0.999, -1, 0)) {
flowStart(f, cx, cy);
}
continue;
}
// 후보가 바닥나면 무작위 자리를 뽑아 거절 표본으로 빈자리를 찾는다
const m = f.o.margin;
const rx = m + f.rng() * (W - 2 * m);
const ry = m + f.rng() * (H - 2 * m);
if (!flowTooClose(f, rx, ry, f.o.sep * 0.999, -1, 0)) {
f.fails = 0;
flowStart(f, rx, ry);
}
else {
f.fails += 1;
if (f.fails >= FLOW_SEED_FAILS) {
f.done = true;
}
}
continue;
}
// 한 걸음 — 반걸음 앞의 방향으로 걷는 중간점 방법(곡선 바깥으로 새는 오차를 줄인다)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 Lines 하나 끝
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
}
else {
flowEndLine(f);
}
continue;
}
seg(f.x, f.y, nx, ny, f.line);
f.length += f.o.step;
f.x = nx;
f.y = ny;
f.ord = nord;
flowAddPoint(f, nx, ny);
}
return f.done;
}
function makeVein(rng, o) {
const ax = [];
const ay = [];
// 거절 표본 — 상자 안에서 뽑아 모양(inside) 밖이면 버린다
let tries = 0;
while (ax.length < o.count && tries < o.count * 40) {
tries += 1;
const x = lerp(o.x0, o.x1, rng());
const y = lerp(o.y0, o.y1, rng());
if (o.inside(x, y)) {
ax.push(x);
ay.push(y);
}
}
const agrid = makeBuckets(o.influence);
for (let i = 0; i < ax.length; i += 1) {
bucketAdd(agrid, ax[i], ay[i], i);
}
const alive = new Uint8Array(ax.length);
alive.fill(1);
const v = {
o: o, rng: rng, ax: ax, ay: ay, alive: alive, left: ax.length,
nx: [], ny: [], par: [], desc: [], grid: makeBuckets(o.influence), agrid: agrid, iter: 0, reached: false, stall: 0, done: false,
};
veinAdd(v, o.rootX, o.rootY, -1);
veinKill(v, 0);
return v;
}
// Nodes 하나를 더하고, 뿌리까지 거슬러 올라가며 조상마다 후손 수를 하나씩 늘린다(굵기의 재료)
function veinAdd(v, x, y, parent) {
const id = v.nx.length;
v.nx.push(x);
v.ny.push(y);
v.par.push(parent);
v.desc.push(0);
bucketAdd(v.grid, x, y, id);
let p = parent;
while (p >= 0) {
v.desc[p] += 1;
p = v.par[p];
}
}
// from 번 마디부터 새로 난 마디들 Perimeter kill 안의 끌개를 지운다
function veinKill(v, from) {
const k2 = v.o.kill * v.o.kill;
for (let i = from; i < v.nx.length; i += 1) {
const x = v.nx[i];
const y = v.ny[i];
bucketNear(v.agrid, x, y, v.o.kill, function (a) {
if (v.alive[a]) {
const dx = v.ax[a] - x;
const dy = v.ay[a] - y;
if (dx * dx + dy * dy < k2) {
v.alive[a] = 0;
v.left -= 1;
}
}
return false;
});
}
}
const VEIN_STALL_LIMIT = 40;
function veinStep(v) {
if (v.done) {
return true;
}
v.iter += 1;
const n = v.nx.length;
const sx = new Float64Array(n);
const sy = new Float64Array(n);
const cnt = new Uint16Array(n);
const r2 = v.o.influence * v.o.influence;
let any = false;
// 끌개마다 반경 안에서 가장 가까운 Nodes 하나 — 그 Nodes 쪽으로 단위 방향을 더해 준다
for (let a = 0; a < v.ax.length; a += 1) {
if (!v.alive[a]) {
continue;
}
const x = v.ax[a];
const y = v.ay[a];
let best = -1;
let bd = r2;
bucketNear(v.grid, x, y, v.o.influence, function (id) {
const dx = x - v.nx[id];
const dy = y - v.ny[id];
const d2 = dx * dx + dy * dy;
if (d2 < bd) {
bd = d2;
best = id;
}
return false;
});
if (best >= 0) {
const d = Math.sqrt(bd) || 1;
sx[best] += (x - v.nx[best]) / d;
sy[best] += (y - v.ny[best]) / d;
cnt[best] += 1;
any = true;
}
}
if (any) {
v.reached = true;
}
if (!any) {
// 아직 어느 끌개도 닿은 적이 없으면 마지막 마디를 끌개 무리의 가운데 쪽으로 곧게 민다(줄기).
// 한 번 닿은 뒤로 끌개가 반경 밖에만 남으면 더 자라지 않는다 — 외딴 끌개 사이에서 줄기가 맴돌지 않게
if (v.left > 0 && !v.reached) {
let mx = 0;
let my = 0;
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) {
mx += v.ax[a];
my += v.ay[a];
}
}
mx /= v.left;
my /= v.left;
const last = n - 1;
const dx = mx - v.nx[last];
const dy = my - v.ny[last];
const L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[last] + (dx / L) * v.o.seg, v.ny[last] + (dy / L) * v.o.seg, last);
}
}
else {
for (let i = 0; i < n; i += 1) {
if (!cnt[i]) {
continue;
}
let dx = sx[i];
let dy = sy[i];
let L = Math.hypot(dx, dy);
// 끌개들이 서로 반대쪽에서 맞당겨 평균이 거의 0 이면 그 마디는 이번에 자라지 않는다
if (L < 0.2) {
continue;
}
// 아주 작은 흔들림 — 같은 자리에 마디가 겹쳐 쌓이지 않게 대칭을 깬다
dx = dx / L + (v.rng() - 0.5) * 0.12;
dy = dy / L + (v.rng() - 0.5) * 0.12;
L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
}
}
const leftBefore = v.left;
veinKill(v, n);
// 지워지는 끌개 없이 VEIN_STALL_LIMIT 번 연달아 자라기만 하면 멈춘다 — 닿지 못할 끌개를 쫓아 맴돌지 않게
v.stall = v.left === leftBefore ? v.stall + 1 : 0;
if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) {
v.done = true;
}
return v.done;
}
// 굵기 = base + √(후손 수 + 1) × k (cap 에서 자름). 같은 굵기 칸끼리 묶어 한 번에 긋는다
const VEIN_WIDTH_BINS = 16;
function veinDraw(v, c, col, alpha, base, k, cap) {
const n = v.nx.length;
const bin = new Uint8Array(n);
for (let i = 1; i < n; i += 1) {
const w = Math.min(cap, base + Math.sqrt(v.desc[i] + 1) * k);
bin[i] = Math.round((w / cap) * (VEIN_WIDTH_BINS - 1));
}
c.lineCap = 'round';
c.strokeStyle = rgba(col, alpha);
for (let b = 0; b < VEIN_WIDTH_BINS; b += 1) {
c.beginPath();
let any = false;
for (let i = 1; i < n; i += 1) {
if (bin[i] !== b || v.par[i] < 0) {
continue;
}
const p = v.par[i];
c.moveTo(v.nx[p], v.ny[p]);
c.lineTo(v.nx[i], v.ny[i]);
any = true;
}
if (any) {
c.lineWidth = Math.max(0.4, ((b + 0.5) / VEIN_WIDTH_BINS) * cap);
c.stroke();
}
}
}
// 아직 남은 끌개를 작은 점으로 — 잎맥이 지나간 자리부터 비어 간다
function veinDots(v, c, col, alpha) {
c.fillStyle = rgba(col, alpha);
c.beginPath();
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) {
c.rect(v.ax[a] - 0.8, v.ay[a] - 0.8, 1.6, 1.6);
}
}
c.fill();
}
function makePack(rng, o) {
return { o: o, rng: rng, cx: [], cy: [], cr: [], col: [], growing: [], grid: makeBuckets(o.maxR), fails: 0, tries: 0, rejects: 0, done: false };
}
// 반지름 r 인 원을 (x, y) 에 둘 수 있는가 — 테두리, 격자 버킷 안 이웃 Circles(중심 거리 < r + 이웃 r + gap), 막힌 자리 순서로 본다
function packFree(p, x, y, r, self) {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) {
return false;
}
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j) {
if (j === self) {
return false;
}
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) {
return false;
}
return !p.o.blocked(x, y, r + g);
}
function packStep(p, onStop) {
if (p.done) {
return true;
}
// 심기 — attempts 번 무작위 자리를 뽑고, 겹치는 자리는 버린다(거절 표본)
let made = 0;
for (let a = 0; a < p.o.attempts && made < p.o.perTick; a += 1) {
const x = lerp(p.o.x0, p.o.x1, p.rng());
const y = lerp(p.o.y0, p.o.y1, p.rng());
p.tries += 1;
if (packFree(p, x, y, p.o.startR, -1)) {
const id = p.cx.length;
p.cx.push(x);
p.cy.push(y);
p.cr.push(p.o.startR);
p.col.push(Math.floor(p.rng() * PAL.length));
bucketAdd(p.grid, x, y, id);
p.growing.push(id);
made += 1;
}
else {
p.rejects += 1;
}
}
p.fails = made === 0 ? p.fails + 1 : 0;
// 키우기 — 한 칸 키운 반지름이 자리에 맞으면 키우고, 안 맞으면 그 원은 여기서 멈춘다
const next = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
}
else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) {
p.done = true;
}
return p.done;
}
// 첫 그림의 Seed — 다시 그리기를 누르면 이 수만 바뀌고 세 겹이 모두 함께 바뀐다
const HERO_SEED = 47109;
// 겹과 겹 사이 출발 간격(ms) — 흐름 Lines 0, 잎맥 1배, Circles 채우기 2배 뒤에 자라기 시작한다
const LAYER_STAGGER_MS = 480;
// 세 겹이 다 자란 뒤 머무는 시간(ms) — 그 뒤 다음 씨앗의 그림으로 옅게 바뀐다
const HOLD_MS = 1400;
// 앞 그림이 사라지는 교차 페이드 시간(ms)
const FADE_MS = 700;
// 겹마다 한 틱의 일 — 프레임당 계산량 상한
const HERO_FLOW_STEPS = 44;
const SEED0 = HERO_SEED;
const TXT_RUN = 'Three layers growing in turn';
// 같은 씨앗에서 갈래(lane)마다 다른 난수열 — 세 겹이 같은 수를 나눠 쓰지 않아 서로 무늬가 겹치지 않는다
function laneRng(base, lane) { return makeRng((base ^ Math.imul(lane + 1, 0x9e3779b1)) >>> 0); }
const VEIN_CX = 316;
const VEIN_CY = 124;
function insideCrown(x, y) {
const dx = (x - VEIN_CX) / 150;
const dy = (y - VEIN_CY) / 100;
return dx * dx + dy * dy < 1;
}
function buildFlow(s) {
return makeFlow(laneRng(s, 0), makeNoise(laneRng(s, 3)), { sep: 12, test: 0.5, step: 2.5, scale: 0.004, cell: 14, maxPts: 500, margin: 4 });
}
function buildVein(s) {
return makeVein(laneRng(s, 1), {
count: 420, influence: 70, kill: 7, seg: 4, rootX: VEIN_CX, rootY: H + 2, maxNodes: 3000, maxIter: 300,
x0: VEIN_CX - 150, y0: VEIN_CY - 100, x1: VEIN_CX + 150, y1: VEIN_CY + 100, inside: insideCrown,
});
}
let fl = buildFlow(SEED0);
let vn = buildVein(SEED0);
// 원은 잎맥 마디를 피해 자란다 — 마디가 Circles 안(r + 2px)에 있으면 그 자리는 막혔다
function blockedByVein(x, y, r) {
const lim = r + 2;
return bucketNear(vn.grid, x, y, lim, function (id) {
const dx = vn.nx[id] - x;
const dy = vn.ny[id] - y;
return dx * dx + dy * dy < lim * lim;
});
}
function buildPack(s) {
return makePack(laneRng(s, 2), {
grow: 0.6, attempts: 50, gap: 3, maxR: 20, startR: 1, perTick: 6, failTicks: 18,
x0: 8, y0: 8, x1: W - 8, y1: H - 8, blocked: blockedByVein,
});
}
let pk = buildPack(SEED0);
let holdAt = -1;
let fadeLeft = 0;
const prevEl = document.createElement('canvas');
const pctx = prevEl.getContext('2d');
function plantLayers(s) {
clearArt(INK);
fl = buildFlow(s);
vn = buildVein(s);
pk = buildPack(s);
holdAt = -1;
}
function plant(s) {
plantLayers(s);
fadeLeft = 0;
}
function flowSeg(ax, ay, bx, by) {
actx.strokeStyle = rgba(mix(PAL[3], PAL[0], clamp((bx - ax) / 2.5 * 0.5 + 0.5, 0, 1)), 0.5);
actx.lineWidth = 1;
actx.beginPath();
actx.moveTo(ax, ay);
actx.lineTo(bx, by);
actx.stroke();
}
function drawScene(c) {
c.drawImage(artEl, 0, 0, W, H);
veinDraw(vn, c, mix(PAL[2], PAPER, 0.3), 0.95, 0.5, 0.17, 4.4);
for (let i = 0; i < pk.cx.length; i += 1) {
c.fillStyle = rgba(PAL[pk.col[i]], 0.88);
c.beginPath();
c.arc(pk.cx[i], pk.cy[i], pk.cr[i], 0, TAU);
c.fill();
}
}
// 앞 그림을 따로 떠 둔다 — 새 씨앗이 자라는 동안 그 위에 옅어지며 겹친다
function snapshot() {
if (!pctx) {
return;
}
prevEl.width = cv.width;
prevEl.height = cv.height;
pctx.setTransform(cv.width / W, 0, 0, cv.height / H, 0, 0);
drawScene(pctx);
}
function grow() {
fadeLeft = Math.max(0, fadeLeft - FIXED_DT);
if (!fl.done) {
flowStep(fl, HERO_FLOW_STEPS, flowSeg);
}
if (age >= LAYER_STAGGER_MS && !vn.done) {
veinStep(vn);
}
if (age >= LAYER_STAGGER_MS * 2 && !pk.done) {
packStep(pk, function () { });
}
const all = fl.done && vn.done && pk.done;
if (!all) {
return false;
}
// 동작 줄이기에서는 다 자란 한 장에서 멈춘다
if (still) {
return true;
}
if (holdAt < 0) {
holdAt = age;
}
if (age - holdAt >= HOLD_MS) {
snapshot();
seed = nextSeed(seed);
plantLayers(seed);
age = 0;
fadeLeft = FADE_MS;
}
return false;
}
function paintFrame() {
drawScene(ctx);
if (fadeLeft > 0) {
ctx.globalAlpha = fadeLeft / FADE_MS;
ctx.drawImage(prevEl, 0, 0, W, H);
ctx.globalAlpha = 1;
}
}
function statPairs() {
return [['Lines', String(fl.lines)], ['Nodes', String(vn.nx.length)], ['Circles', String(pk.cx.length)]];
}
function runText() { return TXT_RUN; }
function doneText() { return TXT_DONE; }
const TXT_DONE = 'Fully grown — Enter for a new seed';
const TXT_PAUSE = 'Paused — Space to resume';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s) {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow() {
if (done) {
return;
}
age += FIXED_DT;
if (grow()) {
done = true;
}
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow() {
let g = 0;
while (!done && g < GROW_GUARD) {
tickGrow();
g += 1;
}
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (done) {
say(doneText());
}
else {
say(runText());
}
}
function render() {
paintFrame();
const pairs = [['Seed', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after() {
if (still) {
finishNow();
}
render();
}
function newSeed() { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed() { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function () { wake(); after(); });
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
newSeed();
}
else if (k === 'r' || k === 'R') {
replaySeed();
}
else if (k === ' ') {
setPaused(!paused);
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (redrawBtn) {
redrawBtn.addEventListener('click', function () { wake(); newSeed(); after(); });
}
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replaySeed(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick() {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) {
return;
}
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) {
reseed(nextSeed(seed));
}
return;
}
}
if (!paused) {
tickGrow();
}
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// HeroSeededGrowth - vanilla 판(09-gen-art-hero-seeded-growth-scene) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./HeroSeededGrowth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HeroSeededGrowth() {
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;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
const INK: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + 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)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s: number): number { return ((s * 7919 + 104729) % 89989) + 10007; }
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;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const actx: CanvasRenderingContext2D = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]') as HTMLButtonElement | null;
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 일시정지 동안은 멈춘다)
let seed = 0;
let age = 0;
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; }
}
let dprNow = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) { return; }
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) { kctx.drawImage(artEl, 0, 0); }
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) { actx.drawImage(keep, 0, 0, W, H); }
dprNow = dpr;
}
function clearArt(c: RGB): void {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt(): void { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs: [string, string][]): void {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines: string[] = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) { ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3); }
}
// 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값을 하나씩 두고, 그 사이를 3t²−2t³ 곡선으로 부드럽게 이어 읽는다
type Noise = { perm: Uint16Array; vals: Float64Array };
function makeNoise(rng: () => number): Noise {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) { perm[i] = i; vals[i] = rng(); }
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
return { perm: perm, vals: vals };
}
function noise2(n: Noise, x: number, y: number): number {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n: Noise, x: number, y: number, octaves: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 격자 버킷 — 점 번호를 칸(한 변 cell px)별 목록에 담아 두고, 반경 안 이웃은 주변 칸만 훑어 찾는다
type Buckets = { cell: number; cols: number; rows: number; b: number[][] };
function makeBuckets(cell: number): Buckets {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b: number[][] = [];
for (let i = 0; i < cols * rows; i += 1) { b.push([]); }
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g: Buckets, x: number, y: number, id: number): void {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g: Buckets): void {
for (let i = 0; i < g.b.length; i += 1) { g.b[i].length = 0; }
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g: Buckets, x: number, y: number, rad: number, visit: (id: number) => boolean): boolean {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) { return true; }
}
}
}
return false;
}
// 간격 제한 흐름 선 — 칸마다 노이즈로 정한 방향을 따라 선을 앞뒤로 늘리고, 다른 선의 점이 정한 간격보다 가까우면 그 자리에서 멈춘다
type FlowOpts = { sep: number; test: number; step: number; scale: number; cell: number; maxPts: number; margin: number };
type FlowSeg = (ax: number, ay: number, bx: number, by: number, line: number) => void;
type Flow = {
o: FlowOpts; rng: () => number; vx: Float64Array; vy: Float64Array; fcols: number; frows: number;
px: number[]; py: number[]; pl: number[]; po: number[]; grid: Buckets;
cand: number[]; candAt: number; line: number; dir: number; ord: number; x: number; y: number; sx: number; sy: number; pts: number[];
active: boolean; lines: number; length: number; fails: number; done: boolean;
};
// 무작위 출발점이 연달아 이만큼 거절되면 빈자리가 없다고 보고 끝낸다
const FLOW_SEED_FAILS = 160;
function makeFlow(rng: () => number, noise: Noise, o: FlowOpts): Flow {
const fcols = Math.ceil(W / o.cell) + 2;
const frows = Math.ceil(H / o.cell) + 2;
const vx = new Float64Array(fcols * frows);
const vy = new Float64Array(fcols * frows);
// 칸 꼭짓점마다 방향을 한 번만 정해 둔다 — 노이즈 0~1 을 두 바퀴(4π) 각도로
for (let j = 0; j < frows; j += 1) {
for (let i = 0; i < fcols; i += 1) {
const a = fbm2(noise, i * o.cell * o.scale, j * o.cell * o.scale, 2) * TAU * 2;
vx[j * fcols + i] = Math.cos(a);
vy[j * fcols + i] = Math.sin(a);
}
}
return {
o: o, rng: rng, vx: vx, vy: vy, fcols: fcols, frows: frows,
px: [], py: [], pl: [], po: [], grid: makeBuckets(o.sep),
cand: [], candAt: 0, line: -1, dir: 1, ord: 0, x: 0, y: 0, sx: 0, sy: 0, pts: [],
active: false, lines: 0, length: 0, fails: 0, done: false,
};
}
// 네 꼭짓점 방향을 거리 비율로 섞어(쌍선형) 길이 1 로 — 칸 경계에서 방향이 꺾이지 않는다
function flowDir(f: Flow, x: number, y: number): [number, number] {
const gx = clamp(x / f.o.cell, 0, f.fcols - 1.001);
const gy = clamp(y / f.o.cell, 0, f.frows - 1.001);
const i = Math.floor(gx);
const j = Math.floor(gy);
const u = gx - i;
const v = gy - j;
const k = j * f.fcols + i;
const dx = lerp(lerp(f.vx[k], f.vx[k + 1], u), lerp(f.vx[k + f.fcols], f.vx[k + f.fcols + 1], u), v);
const dy = lerp(lerp(f.vy[k], f.vy[k + 1], u), lerp(f.vy[k + f.fcols], f.vy[k + f.fcols + 1], u), v);
const L = Math.hypot(dx, dy);
return L < 1e-6 ? [1, 0] : [dx / L, dy / L];
}
// (x, y) 반경 d 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 길이 안)은 세지 않는다
function flowTooClose(f: Flow, x: number, y: number, d: number, line: number, ord: number): boolean {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id: number): boolean {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) { return false; }
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
function flowInside(f: Flow, x: number, y: number): boolean {
const m = f.o.margin;
return x >= m && x <= W - m && y >= m && y <= H - m;
}
function flowAddPoint(f: Flow, x: number, y: number): void {
const id = f.px.length;
f.px.push(x);
f.py.push(y);
f.pl.push(f.line);
f.po.push(f.ord);
bucketAdd(f.grid, x, y, id);
f.pts.push(id);
}
function flowStart(f: Flow, x: number, y: number): void {
f.line = f.lines;
f.lines += 1;
f.dir = 1;
f.ord = 0;
f.x = x;
f.y = y;
f.sx = x;
f.sy = y;
f.pts = [];
flowAddPoint(f, x, y);
f.active = true;
}
// 선 하나가 끝나면 그 선을 따라 양옆으로 간격만큼 떨어진 자리를 다음 출발 후보로 줄 세운다
function flowEndLine(f: Flow): void {
const stride = Math.max(1, Math.round(f.o.sep / f.o.step));
for (let k = 0; k < f.pts.length; k += stride) {
const id = f.pts[k];
const d = flowDir(f, f.px[id], f.py[id]);
f.cand.push(f.px[id] - d[1] * f.o.sep, f.py[id] + d[0] * f.o.sep);
f.cand.push(f.px[id] + d[1] * f.o.sep, f.py[id] - d[0] * f.o.sep);
}
f.active = false;
}
// 한 틱의 일 — 걸음 하나가 1, 출발점 검사 하나가 0.2. budget 을 다 쓰면 다음 틱으로 넘긴다
function flowStep(f: Flow, budget: number, seg: FlowSeg): boolean {
let b = budget;
while (b > 0 && !f.done) {
if (!f.active) {
b -= 0.2;
if (f.candAt < f.cand.length) {
const cx = f.cand[f.candAt];
const cy = f.cand[f.candAt + 1];
f.candAt += 2;
if (flowInside(f, cx, cy) && !flowTooClose(f, cx, cy, f.o.sep * 0.999, -1, 0)) { flowStart(f, cx, cy); }
continue;
}
// 후보가 바닥나면 무작위 자리를 뽑아 거절 표본으로 빈자리를 찾는다
const m = f.o.margin;
const rx = m + f.rng() * (W - 2 * m);
const ry = m + f.rng() * (H - 2 * m);
if (!flowTooClose(f, rx, ry, f.o.sep * 0.999, -1, 0)) { f.fails = 0; flowStart(f, rx, ry); }
else {
f.fails += 1;
if (f.fails >= FLOW_SEED_FAILS) { f.done = true; }
}
continue;
}
// 한 걸음 — 반걸음 앞의 방향으로 걷는 중간점 방법(곡선 바깥으로 새는 오차를 줄인다)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 선 하나 끝
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
} else {
flowEndLine(f);
}
continue;
}
seg(f.x, f.y, nx, ny, f.line);
f.length += f.o.step;
f.x = nx;
f.y = ny;
f.ord = nord;
flowAddPoint(f, nx, ny);
}
return f.done;
}
// 끌개 점 잎맥 — 끌개마다 가장 가까운 마디를 찾고, 마디는 자기를 고른 끌개 방향들의 평균으로 한 칸 자란다. 마디가 닿은 끌개는 지운다
type VeinOpts = {
count: number; influence: number; kill: number; seg: number; rootX: number; rootY: number;
maxNodes: number; maxIter: number; x0: number; y0: number; x1: number; y1: number;
inside: (x: number, y: number) => boolean;
};
type Vein = {
o: VeinOpts; rng: () => number; ax: number[]; ay: number[]; alive: Uint8Array; left: number;
nx: number[]; ny: number[]; par: number[]; desc: number[]; grid: Buckets; agrid: Buckets;
iter: number; reached: boolean; stall: number; done: boolean;
};
function makeVein(rng: () => number, o: VeinOpts): Vein {
const ax: number[] = [];
const ay: number[] = [];
// 거절 표본 — 상자 안에서 뽑아 모양(inside) 밖이면 버린다
let tries = 0;
while (ax.length < o.count && tries < o.count * 40) {
tries += 1;
const x = lerp(o.x0, o.x1, rng());
const y = lerp(o.y0, o.y1, rng());
if (o.inside(x, y)) { ax.push(x); ay.push(y); }
}
const agrid = makeBuckets(o.influence);
for (let i = 0; i < ax.length; i += 1) { bucketAdd(agrid, ax[i], ay[i], i); }
const alive = new Uint8Array(ax.length);
alive.fill(1);
const v: Vein = {
o: o, rng: rng, ax: ax, ay: ay, alive: alive, left: ax.length,
nx: [], ny: [], par: [], desc: [], grid: makeBuckets(o.influence), agrid: agrid, iter: 0, reached: false, stall: 0, done: false,
};
veinAdd(v, o.rootX, o.rootY, -1);
veinKill(v, 0);
return v;
}
// 마디 하나를 더하고, 뿌리까지 거슬러 올라가며 조상마다 후손 수를 하나씩 늘린다(굵기의 재료)
function veinAdd(v: Vein, x: number, y: number, parent: number): void {
const id = v.nx.length;
v.nx.push(x);
v.ny.push(y);
v.par.push(parent);
v.desc.push(0);
bucketAdd(v.grid, x, y, id);
let p = parent;
while (p >= 0) {
v.desc[p] += 1;
p = v.par[p];
}
}
// from 번 마디부터 새로 난 마디들 둘레 kill 안의 끌개를 지운다
function veinKill(v: Vein, from: number): void {
const k2 = v.o.kill * v.o.kill;
for (let i = from; i < v.nx.length; i += 1) {
const x = v.nx[i];
const y = v.ny[i];
bucketNear(v.agrid, x, y, v.o.kill, function (a: number): boolean {
if (v.alive[a]) {
const dx = v.ax[a] - x;
const dy = v.ay[a] - y;
if (dx * dx + dy * dy < k2) { v.alive[a] = 0; v.left -= 1; }
}
return false;
});
}
}
const VEIN_STALL_LIMIT = 40;
function veinStep(v: Vein): boolean {
if (v.done) { return true; }
v.iter += 1;
const n = v.nx.length;
const sx = new Float64Array(n);
const sy = new Float64Array(n);
const cnt = new Uint16Array(n);
const r2 = v.o.influence * v.o.influence;
let any = false;
// 끌개마다 반경 안에서 가장 가까운 마디 하나 — 그 마디 쪽으로 단위 방향을 더해 준다
for (let a = 0; a < v.ax.length; a += 1) {
if (!v.alive[a]) { continue; }
const x = v.ax[a];
const y = v.ay[a];
let best = -1;
let bd = r2;
bucketNear(v.grid, x, y, v.o.influence, function (id: number): boolean {
const dx = x - v.nx[id];
const dy = y - v.ny[id];
const d2 = dx * dx + dy * dy;
if (d2 < bd) { bd = d2; best = id; }
return false;
});
if (best >= 0) {
const d = Math.sqrt(bd) || 1;
sx[best] += (x - v.nx[best]) / d;
sy[best] += (y - v.ny[best]) / d;
cnt[best] += 1;
any = true;
}
}
if (any) { v.reached = true; }
if (!any) {
// 아직 어느 끌개도 닿은 적이 없으면 마지막 마디를 끌개 무리의 가운데 쪽으로 곧게 민다(줄기).
// 한 번 닿은 뒤로 끌개가 반경 밖에만 남으면 더 자라지 않는다 — 외딴 끌개 사이에서 줄기가 맴돌지 않게
if (v.left > 0 && !v.reached) {
let mx = 0;
let my = 0;
for (let a = 0; a < v.ax.length; a += 1) { if (v.alive[a]) { mx += v.ax[a]; my += v.ay[a]; } }
mx /= v.left;
my /= v.left;
const last = n - 1;
const dx = mx - v.nx[last];
const dy = my - v.ny[last];
const L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[last] + (dx / L) * v.o.seg, v.ny[last] + (dy / L) * v.o.seg, last);
}
} else {
for (let i = 0; i < n; i += 1) {
if (!cnt[i]) { continue; }
let dx = sx[i];
let dy = sy[i];
let L = Math.hypot(dx, dy);
// 끌개들이 서로 반대쪽에서 맞당겨 평균이 거의 0 이면 그 마디는 이번에 자라지 않는다
if (L < 0.2) { continue; }
// 아주 작은 흔들림 — 같은 자리에 마디가 겹쳐 쌓이지 않게 대칭을 깬다
dx = dx / L + (v.rng() - 0.5) * 0.12;
dy = dy / L + (v.rng() - 0.5) * 0.12;
L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
}
}
const leftBefore = v.left;
veinKill(v, n);
// 지워지는 끌개 없이 VEIN_STALL_LIMIT 번 연달아 자라기만 하면 멈춘다 — 닿지 못할 끌개를 쫓아 맴돌지 않게
v.stall = v.left === leftBefore ? v.stall + 1 : 0;
if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) { v.done = true; }
return v.done;
}
// 굵기 = base + √(후손 수 + 1) × k (cap 에서 자름). 같은 굵기 칸끼리 묶어 한 번에 긋는다
const VEIN_WIDTH_BINS = 16;
function veinDraw(v: Vein, c: CanvasRenderingContext2D, col: RGB, alpha: number, base: number, k: number, cap: number): void {
const n = v.nx.length;
const bin = new Uint8Array(n);
for (let i = 1; i < n; i += 1) {
const w = Math.min(cap, base + Math.sqrt(v.desc[i] + 1) * k);
bin[i] = Math.round((w / cap) * (VEIN_WIDTH_BINS - 1));
}
c.lineCap = 'round';
c.strokeStyle = rgba(col, alpha);
for (let b = 0; b < VEIN_WIDTH_BINS; b += 1) {
c.beginPath();
let any = false;
for (let i = 1; i < n; i += 1) {
if (bin[i] !== b || v.par[i] < 0) { continue; }
const p = v.par[i];
c.moveTo(v.nx[p], v.ny[p]);
c.lineTo(v.nx[i], v.ny[i]);
any = true;
}
if (any) {
c.lineWidth = Math.max(0.4, ((b + 0.5) / VEIN_WIDTH_BINS) * cap);
c.stroke();
}
}
}
// 아직 남은 끌개를 작은 점으로 — 잎맥이 지나간 자리부터 비어 간다
function veinDots(v: Vein, c: CanvasRenderingContext2D, col: RGB, alpha: number): void {
c.fillStyle = rgba(col, alpha);
c.beginPath();
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) { c.rect(v.ax[a] - 0.8, v.ay[a] - 0.8, 1.6, 1.6); }
}
c.fill();
}
// 부푸는 원 채우기 — 빈자리를 몇 번 골라 원을 심고 틱마다 반지름을 키우다가, 이웃 원·테두리와의 틈이 gap 에 닿으면 그 원만 멈춘다
type PackOpts = {
grow: number; attempts: number; gap: number; maxR: number; startR: number; perTick: number; failTicks: number;
x0: number; y0: number; x1: number; y1: number; blocked: (x: number, y: number, r: number) => boolean;
};
type Pack = {
o: PackOpts; rng: () => number; cx: number[]; cy: number[]; cr: number[]; col: number[];
growing: number[]; grid: Buckets; fails: number; tries: number; rejects: number; done: boolean;
};
function makePack(rng: () => number, o: PackOpts): Pack {
return { o: o, rng: rng, cx: [], cy: [], cr: [], col: [], growing: [], grid: makeBuckets(o.maxR), fails: 0, tries: 0, rejects: 0, done: false };
}
// 반지름 r 인 원을 (x, y) 에 둘 수 있는가 — 테두리, 격자 버킷 안 이웃 원(중심 거리 < r + 이웃 r + gap), 막힌 자리 순서로 본다
function packFree(p: Pack, x: number, y: number, r: number, self: number): boolean {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) { return false; }
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j: number): boolean {
if (j === self) { return false; }
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) { return false; }
return !p.o.blocked(x, y, r + g);
}
function packStep(p: Pack, onStop: (i: number) => void): boolean {
if (p.done) { return true; }
// 심기 — attempts 번 무작위 자리를 뽑고, 겹치는 자리는 버린다(거절 표본)
let made = 0;
for (let a = 0; a < p.o.attempts && made < p.o.perTick; a += 1) {
const x = lerp(p.o.x0, p.o.x1, p.rng());
const y = lerp(p.o.y0, p.o.y1, p.rng());
p.tries += 1;
if (packFree(p, x, y, p.o.startR, -1)) {
const id = p.cx.length;
p.cx.push(x);
p.cy.push(y);
p.cr.push(p.o.startR);
p.col.push(Math.floor(p.rng() * PAL.length));
bucketAdd(p.grid, x, y, id);
p.growing.push(id);
made += 1;
} else {
p.rejects += 1;
}
}
p.fails = made === 0 ? p.fails + 1 : 0;
// 키우기 — 한 칸 키운 반지름이 자리에 맞으면 키우고, 안 맞으면 그 원은 여기서 멈춘다
const next: number[] = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
} else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) { p.done = true; }
return p.done;
}
// 첫 그림의 씨앗 — 다시 그리기를 누르면 이 수만 바뀌고 세 겹이 모두 함께 바뀐다
const HERO_SEED = 47109;
// 겹과 겹 사이 출발 간격(ms) — 흐름 선 0, 잎맥 1배, 원 채우기 2배 뒤에 자라기 시작한다
const LAYER_STAGGER_MS = 480;
// 세 겹이 다 자란 뒤 머무는 시간(ms) — 그 뒤 다음 씨앗의 그림으로 옅게 바뀐다
const HOLD_MS = 1400;
// 앞 그림이 사라지는 교차 페이드 시간(ms)
const FADE_MS = 700;
// 겹마다 한 틱의 일 — 프레임당 계산량 상한
const HERO_FLOW_STEPS = 44;
const SEED0 = HERO_SEED;
const TXT_RUN = '세 겹이 차례로 자라는 중';
// 같은 씨앗에서 갈래(lane)마다 다른 난수열 — 세 겹이 같은 수를 나눠 쓰지 않아 서로 무늬가 겹치지 않는다
function laneRng(base: number, lane: number): () => number { return makeRng((base ^ Math.imul(lane + 1, 0x9e3779b1)) >>> 0); }
const VEIN_CX = 316;
const VEIN_CY = 124;
function insideCrown(x: number, y: number): boolean {
const dx = (x - VEIN_CX) / 150;
const dy = (y - VEIN_CY) / 100;
return dx * dx + dy * dy < 1;
}
function buildFlow(s: number): Flow {
return makeFlow(laneRng(s, 0), makeNoise(laneRng(s, 3)), { sep: 12, test: 0.5, step: 2.5, scale: 0.004, cell: 14, maxPts: 500, margin: 4 });
}
function buildVein(s: number): Vein {
return makeVein(laneRng(s, 1), {
count: 420, influence: 70, kill: 7, seg: 4, rootX: VEIN_CX, rootY: H + 2, maxNodes: 3000, maxIter: 300,
x0: VEIN_CX - 150, y0: VEIN_CY - 100, x1: VEIN_CX + 150, y1: VEIN_CY + 100, inside: insideCrown,
});
}
let fl: Flow = buildFlow(SEED0);
let vn: Vein = buildVein(SEED0);
// 원은 잎맥 마디를 피해 자란다 — 마디가 원 안(r + 2px)에 있으면 그 자리는 막혔다
function blockedByVein(x: number, y: number, r: number): boolean {
const lim = r + 2;
return bucketNear(vn.grid, x, y, lim, function (id: number): boolean {
const dx = vn.nx[id] - x;
const dy = vn.ny[id] - y;
return dx * dx + dy * dy < lim * lim;
});
}
function buildPack(s: number): Pack {
return makePack(laneRng(s, 2), {
grow: 0.6, attempts: 50, gap: 3, maxR: 20, startR: 1, perTick: 6, failTicks: 18,
x0: 8, y0: 8, x1: W - 8, y1: H - 8, blocked: blockedByVein,
});
}
let pk: Pack = buildPack(SEED0);
let holdAt = -1;
let fadeLeft = 0;
const prevEl = document.createElement('canvas');
const pctx = prevEl.getContext('2d');
function plantLayers(s: number): void {
clearArt(INK);
fl = buildFlow(s);
vn = buildVein(s);
pk = buildPack(s);
holdAt = -1;
}
function plant(s: number): void {
plantLayers(s);
fadeLeft = 0;
}
function flowSeg(ax: number, ay: number, bx: number, by: number): void {
actx.strokeStyle = rgba(mix(PAL[3], PAL[0], clamp((bx - ax) / 2.5 * 0.5 + 0.5, 0, 1)), 0.5);
actx.lineWidth = 1;
actx.beginPath();
actx.moveTo(ax, ay);
actx.lineTo(bx, by);
actx.stroke();
}
function drawScene(c: CanvasRenderingContext2D): void {
c.drawImage(artEl, 0, 0, W, H);
veinDraw(vn, c, mix(PAL[2], PAPER, 0.3), 0.95, 0.5, 0.17, 4.4);
for (let i = 0; i < pk.cx.length; i += 1) {
c.fillStyle = rgba(PAL[pk.col[i]], 0.88);
c.beginPath();
c.arc(pk.cx[i], pk.cy[i], pk.cr[i], 0, TAU);
c.fill();
}
}
// 앞 그림을 따로 떠 둔다 — 새 씨앗이 자라는 동안 그 위에 옅어지며 겹친다
function snapshot(): void {
if (!pctx) { return; }
prevEl.width = cv.width;
prevEl.height = cv.height;
pctx.setTransform(cv.width / W, 0, 0, cv.height / H, 0, 0);
drawScene(pctx);
}
function grow(): boolean {
fadeLeft = Math.max(0, fadeLeft - FIXED_DT);
if (!fl.done) { flowStep(fl, HERO_FLOW_STEPS, flowSeg); }
if (age >= LAYER_STAGGER_MS && !vn.done) { veinStep(vn); }
if (age >= LAYER_STAGGER_MS * 2 && !pk.done) { packStep(pk, function (): void { /* 멈춘 원도 프레임마다 다시 그린다 */ }); }
const all = fl.done && vn.done && pk.done;
if (!all) { return false; }
// 동작 줄이기에서는 다 자란 한 장에서 멈춘다
if (still) { return true; }
if (holdAt < 0) { holdAt = age; }
if (age - holdAt >= HOLD_MS) {
snapshot();
seed = nextSeed(seed);
plantLayers(seed);
age = 0;
fadeLeft = FADE_MS;
}
return false;
}
function paintFrame(): void {
drawScene(ctx);
if (fadeLeft > 0) {
ctx.globalAlpha = fadeLeft / FADE_MS;
ctx.drawImage(prevEl, 0, 0, W, H);
ctx.globalAlpha = 1;
}
}
function statPairs(): [string, string][] {
return [['선', String(fl.lines)], ['마디', String(vn.nx.length)], ['원', String(pk.cx.length)]];
}
function runText(): string { return TXT_RUN; }
function doneText(): string { return TXT_DONE; }
const TXT_DONE = '다 자람 — Enter 로 새 씨앗';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s: number): void {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow(): void {
if (done) { return; }
age += FIXED_DT;
if (grow()) { done = true; }
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow(): void {
let g = 0;
while (!done && g < GROW_GUARD) { tickGrow(); g += 1; }
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (done) { say(doneText()); }
else { say(runText()); }
}
function render(): void {
paintFrame();
const pairs: [string, string][] = [['씨앗', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after(): void {
if (still) { finishNow(); }
render();
}
function newSeed(): void { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed(): void { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function (): void { wake(); after(); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { newSeed(); }
else if (k === 'r' || k === 'R') { replaySeed(); }
else if (k === ' ') { setPaused(!paused); }
else { return; }
ev.preventDefault();
wake();
after();
});
if (redrawBtn) { redrawBtn.addEventListener('click', function (): void { wake(); newSeed(); after(); }); }
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replaySeed(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick(): void {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) { return; }
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) { reseed(nextSeed(seed)); }
return;
}
}
if (!paused) { tickGrow(); }
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="같은 씨앗에서 흐름 선·잎맥·원 채우기가 차례로 자라 겹치고, 다 자라면 머문 뒤 다음 씨앗의 그림으로 옅게 바뀌는 히어로 배경. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">세 겹이 차례로 자라는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="redraw" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="replay" type="button">같은 씨앗</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}09 A Hero Backdrop Where Three Layers Grow From One Seed — Measured area 39.016% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Generative Art JavaScript: 9 Patterns That Grow
The post this demo lives in
Generative Art JavaScript: 9 Patterns That GrowWith generative art JavaScript, one seed gives the same picture on every redraw. 02 reaches veins toward scattered attra See all demos in this post
If you like this effect - similar demos
Creative UI09 Player Hero Scene Where a Blurred Backdrop and Ring InterlockCustom Audio Player CSS — 9 Computed UI Patterns
Creative UI09 Hero-Scene Automaton Where Several Rule Values Run Side by Side in One FrameConway Game of Life in Canvas, 9 Patterns
Creative UI04 Coral growth — the reaction front reaches outward from a seed9 Reaction Diffusion Simulations, No Library