07Tree Rings That Add One Wavy Layer at a Time
Each ring reads noise at points on a circle, so warping the radius by RING_NOISE_AMP still closes its RING_SEGMENTS points without a seam, and ring width varies each year between 0.45 and 1.55 times RING_SPACING, drawing wide years light and narrow years as dark, thick bands.
Best forBackground for a woodworking shop homepage
RING_SPACINGRING_NOISE_AMPRING_SEGMENTS

<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="Noise-warped tree rings are added outward one layer at a time, each year with a different width. 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">Adding rings one by one</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;
}
// 평균 고리 폭(px) — 해마다 이 값의 0.45~1.55 배로 넓었다 좁았다 한다
const RING_SPACING = 5.5;
// 노이즈가 반지름을 일그러뜨리는 비율 — 0.08 이면 반지름의 ±8%
const RING_NOISE_AMP = 0.08;
// 고리 하나를 이루는 점 수
const RING_SEGMENTS = 160;
// 고리 하나가 자라는 틱 수 — 성장 속도
const RING_TICKS = 5;
// 줄기 반지름(px)과 가운데
const TRUNK_R = 138;
const TRUNK_CX = 300;
const TRUNK_CY = 150;
// 노이즈 공간에 그리는 원의 반지름 — 각도를 이 Circles 위의 좌표로 바꿔 읽으므로 0 과 2π 가 같은 자리다
const NOISE_LOOP_R = 1.3;
// 고리마다 노이즈 원의 가운데를 이만큼 옮긴다 — 이웃 고리는 닮고, 멀리 떨어진 고리는 달라진다
const RING_DRIFT = 0.07;
const SEED0 = 47107;
const TXT_RUN = 'Adding rings one by one';
let wnoise = makeNoise(makeRng(SEED0));
let radii = [];
let widths = [];
let pts = [];
let grown = 0;
let ringTick = 0;
function plant(s) {
clearArt(INK);
const rng = makeRng(s);
wnoise = makeNoise(rng);
radii = [];
widths = [];
pts = [];
grown = 0;
ringTick = 0;
// 누적 반지름 — 해마다 다른 폭을 더해 간다
let r = 4;
while (r < TRUNK_R) {
const w = RING_SPACING * (0.45 + rng() * 1.1);
r = Math.min(TRUNK_R, r + w);
radii.push(r);
widths.push(w);
}
}
// k 번째 고리의 각도 a 에서의 반지름 — 각도를 노이즈 공간의 Circles 위 좌표로 바꿔 읽어, 한 바퀴 돌면 같은 값으로 닫힌다
function ringR(k, a, base) {
const nx = 7.3 + k * RING_DRIFT + Math.cos(a) * NOISE_LOOP_R;
const ny = 3.1 + Math.sin(a) * NOISE_LOOP_R;
return base * (1 + RING_NOISE_AMP * (fbm2(wnoise, nx, ny, 2) * 2 - 1));
}
function ringPoints(k, base) {
const out = new Float64Array(RING_SEGMENTS * 2);
for (let j = 0; j < RING_SEGMENTS; j += 1) {
const a = (j / RING_SEGMENTS) * TAU;
const r = ringR(k, a, base);
out[j * 2] = TRUNK_CX + Math.cos(a) * r;
out[j * 2 + 1] = TRUNK_CY + Math.sin(a) * r;
}
return out;
}
function grow() {
if (grown >= radii.length) {
return true;
}
ringTick += 1;
if (ringTick >= RING_TICKS) {
// 다 자란 고리는 점을 한 번만 계산해 둔다
pts.push(ringPoints(grown, radii[grown]));
grown += 1;
ringTick = 0;
}
return grown >= radii.length;
}
function ringPath(p) {
ctx.beginPath();
ctx.moveTo(p[0], p[1]);
for (let j = 1; j < RING_SEGMENTS; j += 1) {
ctx.lineTo(p[j * 2], p[j * 2 + 1]);
}
ctx.closePath();
}
const EARLY = mix(PAL[4], PAPER, 0.35);
const LATE = mix(PAL[4], INK, 0.42);
function paintFrame() {
blitArt();
const rings = pts.slice();
// 지금 자라는 고리 — 앞 고리 반지름에서 제 반지름까지 부드럽게 부푼다
if (grown < radii.length) {
const from = grown > 0 ? radii[grown - 1] : 0;
const k = ringTick / RING_TICKS;
rings.push(ringPoints(grown, lerp(from, radii[grown], k * k * (3 - 2 * k))));
}
// 바깥 고리부터 칠한다 — 안쪽 고리가 위에 덮여 고리 사이 띠만 남는다. 넓은 해는 밝게, 좁은 해는 진하게
for (let k = rings.length - 1; k >= 0; k -= 1) {
ringPath(rings[k]);
const wide = clamp((widths[k] / RING_SPACING - 0.45) / 1.1, 0, 1);
ctx.fillStyle = rgba(mix(LATE, EARLY, 0.35 + 0.65 * wide), 1);
ctx.fill();
ctx.strokeStyle = rgba(LATE, 0.95);
ctx.lineWidth = 0.8 + (1 - wide) * 1.4;
ctx.stroke();
}
if (grown >= radii.length && pts.length) {
ringPath(pts[pts.length - 1]);
ctx.strokeStyle = rgba(mix(LATE, INK, 0.5), 1);
ctx.lineWidth = 6;
ctx.stroke();
}
}
function statPairs() {
return [['Rings', grown + ' / ' + radii.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);
}
};
})();// TreeRingNoiseGrowth - vanilla 판(07-gen-art-tree-ring-noise-growth) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TreeRingNoiseGrowth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TreeRingNoiseGrowth() {
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;
}
// 평균 고리 폭(px) — 해마다 이 값의 0.45~1.55 배로 넓었다 좁았다 한다
const RING_SPACING = 5.5;
// 노이즈가 반지름을 일그러뜨리는 비율 — 0.08 이면 반지름의 ±8%
const RING_NOISE_AMP = 0.08;
// 고리 하나를 이루는 점 수
const RING_SEGMENTS = 160;
// 고리 하나가 자라는 틱 수 — 성장 속도
const RING_TICKS = 5;
// 줄기 반지름(px)과 가운데
const TRUNK_R = 138;
const TRUNK_CX = 300;
const TRUNK_CY = 150;
// 노이즈 공간에 그리는 원의 반지름 — 각도를 이 원 위의 좌표로 바꿔 읽으므로 0 과 2π 가 같은 자리다
const NOISE_LOOP_R = 1.3;
// 고리마다 노이즈 원의 가운데를 이만큼 옮긴다 — 이웃 고리는 닮고, 멀리 떨어진 고리는 달라진다
const RING_DRIFT = 0.07;
const SEED0 = 47107;
const TXT_RUN = '나이테가 한 겹씩 느는 중';
let wnoise: Noise = makeNoise(makeRng(SEED0));
let radii: number[] = [];
let widths: number[] = [];
let pts: Float64Array[] = [];
let grown = 0;
let ringTick = 0;
function plant(s: number): void {
clearArt(INK);
const rng = makeRng(s);
wnoise = makeNoise(rng);
radii = [];
widths = [];
pts = [];
grown = 0;
ringTick = 0;
// 누적 반지름 — 해마다 다른 폭을 더해 간다
let r = 4;
while (r < TRUNK_R) {
const w = RING_SPACING * (0.45 + rng() * 1.1);
r = Math.min(TRUNK_R, r + w);
radii.push(r);
widths.push(w);
}
}
// k 번째 고리의 각도 a 에서의 반지름 — 각도를 노이즈 공간의 원 위 좌표로 바꿔 읽어, 한 바퀴 돌면 같은 값으로 닫힌다
function ringR(k: number, a: number, base: number): number {
const nx = 7.3 + k * RING_DRIFT + Math.cos(a) * NOISE_LOOP_R;
const ny = 3.1 + Math.sin(a) * NOISE_LOOP_R;
return base * (1 + RING_NOISE_AMP * (fbm2(wnoise, nx, ny, 2) * 2 - 1));
}
function ringPoints(k: number, base: number): Float64Array {
const out = new Float64Array(RING_SEGMENTS * 2);
for (let j = 0; j < RING_SEGMENTS; j += 1) {
const a = (j / RING_SEGMENTS) * TAU;
const r = ringR(k, a, base);
out[j * 2] = TRUNK_CX + Math.cos(a) * r;
out[j * 2 + 1] = TRUNK_CY + Math.sin(a) * r;
}
return out;
}
function grow(): boolean {
if (grown >= radii.length) { return true; }
ringTick += 1;
if (ringTick >= RING_TICKS) {
// 다 자란 고리는 점을 한 번만 계산해 둔다
pts.push(ringPoints(grown, radii[grown]));
grown += 1;
ringTick = 0;
}
return grown >= radii.length;
}
function ringPath(p: Float64Array): void {
ctx.beginPath();
ctx.moveTo(p[0], p[1]);
for (let j = 1; j < RING_SEGMENTS; j += 1) { ctx.lineTo(p[j * 2], p[j * 2 + 1]); }
ctx.closePath();
}
const EARLY: RGB = mix(PAL[4], PAPER, 0.35);
const LATE: RGB = mix(PAL[4], INK, 0.42);
function paintFrame(): void {
blitArt();
const rings: Float64Array[] = pts.slice();
// 지금 자라는 고리 — 앞 고리 반지름에서 제 반지름까지 부드럽게 부푼다
if (grown < radii.length) {
const from = grown > 0 ? radii[grown - 1] : 0;
const k = ringTick / RING_TICKS;
rings.push(ringPoints(grown, lerp(from, radii[grown], k * k * (3 - 2 * k))));
}
// 바깥 고리부터 칠한다 — 안쪽 고리가 위에 덮여 고리 사이 띠만 남는다. 넓은 해는 밝게, 좁은 해는 진하게
for (let k = rings.length - 1; k >= 0; k -= 1) {
ringPath(rings[k]);
const wide = clamp((widths[k] / RING_SPACING - 0.45) / 1.1, 0, 1);
ctx.fillStyle = rgba(mix(LATE, EARLY, 0.35 + 0.65 * wide), 1);
ctx.fill();
ctx.strokeStyle = rgba(LATE, 0.95);
ctx.lineWidth = 0.8 + (1 - wide) * 1.4;
ctx.stroke();
}
if (grown >= radii.length && pts.length) {
ringPath(pts[pts.length - 1]);
ctx.strokeStyle = rgba(mix(LATE, INK, 0.5), 1);
ctx.lineWidth = 6;
ctx.stroke();
}
}
function statPairs(): [string, string][] {
return [['나이테', grown + ' / ' + radii.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>
);
}07 Tree Rings That Add One Wavy Layer at a Time — Measured area 35.837% · moving frames 18/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 UI04 An Interleaved-Noise Transition That Dissolves One Scene into the Next as DotsCSS Dither: 9 Scenes Drawn by Threshold Math
Creative UI01 The basic strike — one layer of midpoint displacement halving a segment9 Lightning Effect Background Scenes, No WebGL
Creative UI01 One field layer — counting threshold-crossing pixels as a first metaball9 Metaball Effect Scenes, Marching Squares Canvas