02A Leaf Tumbling on Three Axes with Front and Back Shading
The leaf rotates on three axes and is drawn through perspective projection, its brightness set by how squarely it faces the light, switching to a paler back color when it flips over.
Best forSeasonal event banner
FOCAL_LENGTHLIGHT_DIRTUMBLE_RATES

<main class="stage">
<div class="lf is-demo" id="lf">
<canvas class="lf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="Leaves flipping on three axes as they fall. The side facing the light is bright and the back shows a paler color."></canvas>
<button class="lf__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.lf {
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-cream;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 일시정지 단추 — 장면 오른쪽 위. aria-pressed 가 참이면 멈춘 상태라 눌린 모양으로
.lf__pause {
position: absolute;
right: $sp-2;
top: 30px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.lf__pause:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause { font-size: 11px; padding: $sp-1 $sp-2; top: 26px; }
}
// 동작 줄이기 — 장면은 스크립트가 잎이 내려앉은 한 장면으로 멈춘다. 단추도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.lf__pause { transition: none !important; }
.lf__pause:hover { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lf {
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: #fff7e6;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.lf__pause {
position: absolute;
right: 8px;
top: 30px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.lf__pause:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause {
font-size: 11px;
padding: 4px 8px;
top: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.lf__pause {
transition: none !important;
}
.lf__pause:hover {
transform: none !important;
}
}(function () {
const root = document.getElementById("lf");
if (!root) return;
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP = [212, 230, 243];
const SKY_LOW = [253, 245, 231];
const CREAM = [255, 250, 241];
const INK = [70, 54, 58];
const BARK = [118, 88, 76];
const BARK_DEEP = [82, 60, 54];
const SOIL = [228, 208, 180];
const SOIL_DEEP = [176, 146, 118];
const APRICOT = [240, 166, 114];
const PEACH = [246, 194, 156];
const BUTTER = [238, 206, 110];
const MAPLE = [222, 112, 98];
const SAGE = [146, 182, 132];
const LEAF_COLORS = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
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(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 mixRGB(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(seed) {
let s = seed >>> 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;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function corner(ix, iy) { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind) {
const pts = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
}
else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
}
else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind) {
const out = [];
if (kind === 1) {
const base = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g, kind, k) {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) {
g.lineTo(pts[i][0] * k, pts[i][1] * k);
}
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g, side, r) {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch];
}
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch];
}
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind, color, blur) {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) {
return hit;
}
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) {
return sp;
}
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) {
blurSprite(g, side, Math.round(blur * SPRITE_RES));
}
return sp;
}
function backColor(c) { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp, x, y, size, angle, squash, alpha) {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
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 clearSky() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text, x, y, alignRight) {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v, digits) { return v.toFixed(digits); }
const TUMBLER_COUNT = 8;
// 원근 — 화면에서 FOCAL_LENGTH 만큼 떨어진 눈. 깊이 z 인 점은 FOCAL_LENGTH / (FOCAL_LENGTH + z) 배로 줄어든다
const FOCAL_LENGTH = 520;
// 빛이 오는 쪽(왼쪽 위 앞) — 단위 벡터로 맞춘다
const LIGHT_DIR = unit3([-0.45, -0.7, -0.55]);
// 밝기 = AMBIENT_LIGHT + (1 − AMBIENT_LIGHT) × 빛을 향한 정도
const AMBIENT_LIGHT = 0.32;
// 세 축 회전 빠르기(라디안/초) — 잎마다 0.6~1.4 배
const TUMBLE_RATES = [1.9, 2.6, 0.8];
const FALL_SPEED = 54;
const SWAY_AMP = 46;
const SWAY_RATE = 0.9;
const TUMBLE_GROUND = 274;
const START_MS_02 = 200;
const LOOP_MS_02 = 9000;
const FACE_FRONT = 'Front';
const FACE_BACK = 'Back';
const FACE_LABEL = '{f} · facing the light {b}%';
const SHADE_FORMULA = 'brightness = normal · light · normal z picks the side';
let tumblers = [];
let t02 = 0;
let recycled = 0;
function unit3(v) { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; }
// 회전 행렬 R = Rz · Ry · Rx (행 우선 9칸)
function rotation(rx, ry, rz) {
const cx = Math.cos(rx), sx = Math.sin(rx);
const cy = Math.cos(ry), sy = Math.sin(ry);
const cz = Math.cos(rz), sz = Math.sin(rz);
return [
cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx,
sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx,
-sy, cy * sx, cy * cx
];
}
function apply(m, x, y, z) {
return [m[0] * x + m[1] * y + m[2] * z, m[3] * x + m[4] * y + m[5] * z, m[6] * x + m[7] * y + m[8] * z];
}
function build() {
const rng = makeRng(4792);
tumblers = [];
for (let i = 0; i < TUMBLER_COUNT; i += 1) {
const k = 0.6 + rng() * 0.8;
tumblers.push({
x0: 40 + (i + 0.5) / TUMBLER_COUNT * 400 + (rng() - 0.5) * 30,
y: -40 + ((i * 3) % TUMBLER_COUNT) / TUMBLER_COUNT * 330,
z0: rng() * 160 - 40,
rx: rng() * TAU, ry: rng() * TAU, rz: rng() * TAU,
wx: TUMBLE_RATES[0] * k * (rng() < 0.5 ? -1 : 1),
wy: TUMBLE_RATES[1] * (1.4 - k * 0.5),
wz: TUMBLE_RATES[2] * k,
kind: i % LEAF_KINDS,
color: LEAF_COLORS[i % LEAF_COLORS.length],
size: 58 + rng() * 26,
phase: rng() * TAU,
vy: FALL_SPEED * (0.75 + rng() * 0.5),
flips: 0,
wasFront: true
});
}
t02 = 0;
recycled = 0;
}
function loopLength() { return LOOP_MS_02; }
function step(dt) {
t02 += dt * 1000;
if (t02 < START_MS_02) {
return;
}
for (let i = 0; i < tumblers.length; i += 1) {
const p = tumblers[i];
p.rx += p.wx * dt;
p.ry += p.wy * dt;
p.rz += p.wz * dt;
p.y += p.vy * dt;
// 화면 아래로 빠진 잎은 새로 만들지 않고 위로 되돌려 다시 쓴다
if (p.y > H + 70) {
p.y = -70;
recycled += 1;
}
}
}
function leafX(p) { return p.x0 + SWAY_AMP * Math.sin(p.phase + t02 / 1000 * SWAY_RATE); }
function leafZ(p) { return p.z0 + 60 * Math.sin(p.phase * 1.7 + t02 / 1000 * 0.6); }
// 한 장을 3차원으로 돌려 화면 좌표로. 법선은 잎 판의 (0, 0, −1) — 처음에 눈을 본다
function project(p) {
const m = rotation(p.rx, p.ry, p.rz);
const cx = leafX(p);
const cz = leafZ(p);
const half = p.size / 2;
const pts = [];
let depth = 0;
const outline = OUTLINES[p.kind];
function toScreen(u, v) {
const q = apply(m, u * half, v * half, 0);
const z = cz + q[2];
const k = FOCAL_LENGTH / (FOCAL_LENGTH + z);
return [W / 2 + (cx - W / 2 + q[0]) * k, H / 2 + (p.y - H / 2 + q[1]) * k];
}
for (let i = 0; i < outline.length; i += 1) {
pts.push(toScreen(outline[i][0], outline[i][1]));
}
const veins = VEINS[p.kind].map(function (s) { return [toScreen(s[0][0], s[0][1]), toScreen(s[1][0], s[1][1])]; });
depth = cz;
const n = apply(m, 0, 0, -1);
// 법선 z 가 음수면 앞면이 눈을 향한다. 보이는 면의 법선으로 빛과 내적한다
const front = n[2] < 0;
const s = front ? 1 : -1;
const lit = Math.max(0, s * (n[0] * LIGHT_DIR[0] + n[1] * LIGHT_DIR[1] + n[2] * LIGHT_DIR[2]));
const base = front ? p.color : backColor(p.color);
const bright = AMBIENT_LIGHT + (1 - AMBIENT_LIGHT) * lit;
const fill = mixRGB(mixRGB(base, INK, 0.36 * (1 - bright)), CREAM, 0.34 * lit * lit);
return { pts: pts, veins: veins, depth: depth, lit: lit, front: front, fill: fill, leaf: p };
}
// 동작 줄이기 — 바닥에 눕힌 잎들(앞으로 기운 판이라 납작하게 보인다)
function finalScene() {
build();
for (let i = 0; i < tumblers.length; i += 1) {
const p = tumblers[i];
p.y = TUMBLE_GROUND - 6 - (i % 3) * 4;
p.rx = 1.25;
p.ry = (i % 2 === 0 ? 0.2 : -0.25);
p.z0 = (i % 4) * 30;
p.phase = 0;
p.x0 = 50 + i * 54;
}
t02 = START_MS_02;
}
function onWake() { }
function drawShape(sh) {
const pts = sh.pts;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i += 1) {
ctx.lineTo(pts[i][0], pts[i][1]);
}
ctx.closePath();
ctx.fillStyle = rgba(sh.fill, 1);
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(mixRGB(sh.fill, INK, 0.4), 0.9);
ctx.stroke();
ctx.beginPath();
for (let i = 0; i < sh.veins.length; i += 1) {
ctx.moveTo(sh.veins[i][0][0], sh.veins[i][0][1]);
ctx.lineTo(sh.veins[i][1][0], sh.veins[i][1][1]);
}
ctx.lineWidth = 0.9;
ctx.strokeStyle = rgba(mixRGB(sh.fill, INK, sh.front ? 0.32 : 0.18), 0.8);
ctx.stroke();
}
function draw() {
clearSky();
drawGround(TUMBLE_GROUND + 6);
// 빛이 오는 자리 표시 — 왼쪽 위의 옅은 해
const sun = ctx.createRadialGradient(46, 40, 4, 46, 40, 70);
sun.addColorStop(0, rgba(CREAM, 0.95));
sun.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = sun;
ctx.fillRect(0, 0, 130, 120);
const shapes = tumblers.map(project);
// 먼 것부터 그린다
shapes.sort(function (a, b) { return b.depth - a.depth; });
for (let i = 0; i < shapes.length; i += 1) {
const sh = shapes[i];
if (sh.leaf.wasFront !== sh.front) {
sh.leaf.flips += 1;
sh.leaf.wasFront = sh.front;
}
drawShape(sh);
}
const lead = shapes.filter(function (s) { return s.leaf === tumblers[0]; })[0];
if (lead) {
drawCaption(FACE_LABEL.replace('{f}', lead.front ? FACE_FRONT : FACE_BACK).replace('{b}', fmt(lead.lit * 100, 0)), 8, 8, false);
}
drawCaption(SHADE_FORMULA, W - 8, H - 28, true);
}
// Pause 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause() { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render() { draw(); }
cv.addEventListener('pointermove', function (ev) {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) {
render();
}
}, { signal: off.signal });
cv.addEventListener('pointerleave', function () {
hoverX = -1;
hoverY = -1;
if (paused && ready) {
render();
}
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (paused || !alive) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) {
clock = 0;
build();
}
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill() {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play() {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function () {
wake();
paused = !paused;
syncPause();
if (paused) {
stopLoop();
render();
return;
}
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) {
play();
}
else {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref() {
still = reduceQuery.matches;
if (!ready) {
return;
}
paused = still;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
return;
}
if (!paused && ready) {
startLoop();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
if (ready) {
render();
}
}, { signal: off.signal });
function begin() {
fitCanvas();
build();
ready = true;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
begin();
return function () {
alive = false;
stopLoop();
off.abort();
};
})();// LeafTumbleLightBackside - vanilla 판(02-leaf-3d-tumble-light-backside) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LeafTumbleLightBackside.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LeafTumbleLightBackside() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 잎 스프라이트 한 장 — 오프스크린 캔버스와, 판 좌표로 잰 반 변(기준 크기 SPRITE_SIZE 일 때)
type Sprite = { cv: HTMLCanvasElement; half: number };
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP: RGB = [212, 230, 243];
const SKY_LOW: RGB = [253, 245, 231];
const CREAM: RGB = [255, 250, 241];
const INK: RGB = [70, 54, 58];
const BARK: RGB = [118, 88, 76];
const BARK_DEEP: RGB = [82, 60, 54];
const SOIL: RGB = [228, 208, 180];
const SOIL_DEEP: RGB = [176, 146, 118];
const APRICOT: RGB = [240, 166, 114];
const PEACH: RGB = [246, 194, 156];
const BUTTER: RGB = [238, 206, 110];
const MAPLE: RGB = [222, 112, 98];
const SAGE: RGB = [146, 182, 132];
const LEAF_COLORS: RGB[] = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
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(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 mixRGB(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(seed: number): () => number {
let s = seed >>> 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;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function corner(ix: number, iy: number): number { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind: number): Pt[] {
const pts: Pt[] = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
} else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
} else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES: Pt[][] = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind: number): [Pt, Pt][] {
const out: [Pt, Pt][] = [];
if (kind === 1) {
const base: Pt = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS: [Pt, Pt][][] = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g: CanvasRenderingContext2D, kind: number, k: number): void {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) { g.lineTo(pts[i][0] * k, pts[i][1] * k); }
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g: CanvasRenderingContext2D, side: number, r: number): void {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch]; }
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch]; }
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map<string, Sprite>();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind: number, color: RGB, blur: number): Sprite {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) { return hit; }
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp: Sprite = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) { return sp; }
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) { blurSprite(g, side, Math.round(blur * SPRITE_RES)); }
return sp;
}
function backColor(c: RGB): RGB { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp: Sprite, x: number, y: number, size: number, angle: number, squash: number, alpha: number): void {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
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 clearSky(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text: string, x: number, y: number, alignRight: boolean): void {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v: number, digits: number): string { return v.toFixed(digits); }
// 02 — 세 축 회전 행렬로 뒤집히며 떨어지는 잎. 원근 투영으로 그리고, 면 법선과 빛 방향의 내적으로 밝기를,
// 법선 z 부호로 앞면·뒷면을 고른다. 잎 윤곽은 pre 의 단위 좌표(OUTLINES)를 그대로 3차원 판에 올린다
type V3 = [number, number, number];
type Tumbler = { x0: number; y: number; z0: number; rx: number; ry: number; rz: number; wx: number; wy: number; wz: number; kind: number; color: RGB; size: number; phase: number; vy: number; flips: number; wasFront: boolean };
type Shape = { pts: Pt[]; veins: [Pt, Pt][]; depth: number; lit: number; front: boolean; fill: RGB; leaf: Tumbler };
const TUMBLER_COUNT = 8;
// 원근 — 화면에서 FOCAL_LENGTH 만큼 떨어진 눈. 깊이 z 인 점은 FOCAL_LENGTH / (FOCAL_LENGTH + z) 배로 줄어든다
const FOCAL_LENGTH = 520;
// 빛이 오는 쪽(왼쪽 위 앞) — 단위 벡터로 맞춘다
const LIGHT_DIR: V3 = unit3([-0.45, -0.7, -0.55]);
// 밝기 = AMBIENT_LIGHT + (1 − AMBIENT_LIGHT) × 빛을 향한 정도
const AMBIENT_LIGHT = 0.32;
// 세 축 회전 빠르기(라디안/초) — 잎마다 0.6~1.4 배
const TUMBLE_RATES: V3 = [1.9, 2.6, 0.8];
const FALL_SPEED = 54;
const SWAY_AMP = 46;
const SWAY_RATE = 0.9;
const TUMBLE_GROUND = 274;
const START_MS_02 = 200;
const LOOP_MS_02 = 9000;
const FACE_FRONT = '앞면';
const FACE_BACK = '뒷면';
const FACE_LABEL = '{f} · 빛을 향한 정도 {b}%';
const SHADE_FORMULA = '밝기 = 법선 · 빛 방향 · 법선 z 로 앞뒤';
let tumblers: Tumbler[] = [];
let t02 = 0;
let recycled = 0;
function unit3(v: V3): V3 { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; }
// 회전 행렬 R = Rz · Ry · Rx (행 우선 9칸)
function rotation(rx: number, ry: number, rz: number): number[] {
const cx = Math.cos(rx), sx = Math.sin(rx);
const cy = Math.cos(ry), sy = Math.sin(ry);
const cz = Math.cos(rz), sz = Math.sin(rz);
return [
cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx,
sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx,
-sy, cy * sx, cy * cx
];
}
function apply(m: number[], x: number, y: number, z: number): V3 {
return [m[0] * x + m[1] * y + m[2] * z, m[3] * x + m[4] * y + m[5] * z, m[6] * x + m[7] * y + m[8] * z];
}
function build(): void {
const rng = makeRng(4792);
tumblers = [];
for (let i = 0; i < TUMBLER_COUNT; i += 1) {
const k = 0.6 + rng() * 0.8;
tumblers.push({
x0: 40 + (i + 0.5) / TUMBLER_COUNT * 400 + (rng() - 0.5) * 30,
y: -40 + ((i * 3) % TUMBLER_COUNT) / TUMBLER_COUNT * 330,
z0: rng() * 160 - 40,
rx: rng() * TAU, ry: rng() * TAU, rz: rng() * TAU,
wx: TUMBLE_RATES[0] * k * (rng() < 0.5 ? -1 : 1),
wy: TUMBLE_RATES[1] * (1.4 - k * 0.5),
wz: TUMBLE_RATES[2] * k,
kind: i % LEAF_KINDS,
color: LEAF_COLORS[i % LEAF_COLORS.length],
size: 58 + rng() * 26,
phase: rng() * TAU,
vy: FALL_SPEED * (0.75 + rng() * 0.5),
flips: 0,
wasFront: true
});
}
t02 = 0;
recycled = 0;
}
function loopLength(): number { return LOOP_MS_02; }
function step(dt: number): void {
t02 += dt * 1000;
if (t02 < START_MS_02) { return; }
for (let i = 0; i < tumblers.length; i += 1) {
const p = tumblers[i];
p.rx += p.wx * dt;
p.ry += p.wy * dt;
p.rz += p.wz * dt;
p.y += p.vy * dt;
// 화면 아래로 빠진 잎은 새로 만들지 않고 위로 되돌려 다시 쓴다
if (p.y > H + 70) { p.y = -70; recycled += 1; }
}
}
function leafX(p: Tumbler): number { return p.x0 + SWAY_AMP * Math.sin(p.phase + t02 / 1000 * SWAY_RATE); }
function leafZ(p: Tumbler): number { return p.z0 + 60 * Math.sin(p.phase * 1.7 + t02 / 1000 * 0.6); }
// 한 장을 3차원으로 돌려 화면 좌표로. 법선은 잎 판의 (0, 0, −1) — 처음에 눈을 본다
function project(p: Tumbler): Shape {
const m = rotation(p.rx, p.ry, p.rz);
const cx = leafX(p);
const cz = leafZ(p);
const half = p.size / 2;
const pts: Pt[] = [];
let depth = 0;
const outline = OUTLINES[p.kind];
function toScreen(u: number, v: number): Pt {
const q = apply(m, u * half, v * half, 0);
const z = cz + q[2];
const k = FOCAL_LENGTH / (FOCAL_LENGTH + z);
return [W / 2 + (cx - W / 2 + q[0]) * k, H / 2 + (p.y - H / 2 + q[1]) * k];
}
for (let i = 0; i < outline.length; i += 1) { pts.push(toScreen(outline[i][0], outline[i][1])); }
const veins: [Pt, Pt][] = VEINS[p.kind].map(function (s: [Pt, Pt]): [Pt, Pt] { return [toScreen(s[0][0], s[0][1]), toScreen(s[1][0], s[1][1])]; });
depth = cz;
const n = apply(m, 0, 0, -1);
// 법선 z 가 음수면 앞면이 눈을 향한다. 보이는 면의 법선으로 빛과 내적한다
const front = n[2] < 0;
const s = front ? 1 : -1;
const lit = Math.max(0, s * (n[0] * LIGHT_DIR[0] + n[1] * LIGHT_DIR[1] + n[2] * LIGHT_DIR[2]));
const base = front ? p.color : backColor(p.color);
const bright = AMBIENT_LIGHT + (1 - AMBIENT_LIGHT) * lit;
const fill = mixRGB(mixRGB(base, INK, 0.36 * (1 - bright)), CREAM, 0.34 * lit * lit);
return { pts: pts, veins: veins, depth: depth, lit: lit, front: front, fill: fill, leaf: p };
}
// 동작 줄이기 — 바닥에 눕힌 잎들(앞으로 기운 판이라 납작하게 보인다)
function finalScene(): void {
build();
for (let i = 0; i < tumblers.length; i += 1) {
const p = tumblers[i];
p.y = TUMBLE_GROUND - 6 - (i % 3) * 4;
p.rx = 1.25;
p.ry = (i % 2 === 0 ? 0.2 : -0.25);
p.z0 = (i % 4) * 30;
p.phase = 0;
p.x0 = 50 + i * 54;
}
t02 = START_MS_02;
}
function onWake(): void { }
function drawShape(sh: Shape): void {
const pts = sh.pts;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i += 1) { ctx.lineTo(pts[i][0], pts[i][1]); }
ctx.closePath();
ctx.fillStyle = rgba(sh.fill, 1);
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(mixRGB(sh.fill, INK, 0.4), 0.9);
ctx.stroke();
ctx.beginPath();
for (let i = 0; i < sh.veins.length; i += 1) {
ctx.moveTo(sh.veins[i][0][0], sh.veins[i][0][1]);
ctx.lineTo(sh.veins[i][1][0], sh.veins[i][1][1]);
}
ctx.lineWidth = 0.9;
ctx.strokeStyle = rgba(mixRGB(sh.fill, INK, sh.front ? 0.32 : 0.18), 0.8);
ctx.stroke();
}
function draw(): void {
clearSky();
drawGround(TUMBLE_GROUND + 6);
// 빛이 오는 자리 표시 — 왼쪽 위의 옅은 해
const sun = ctx.createRadialGradient(46, 40, 4, 46, 40, 70);
sun.addColorStop(0, rgba(CREAM, 0.95));
sun.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = sun;
ctx.fillRect(0, 0, 130, 120);
const shapes = tumblers.map(project);
// 먼 것부터 그린다
shapes.sort(function (a: Shape, b: Shape): number { return b.depth - a.depth; });
for (let i = 0; i < shapes.length; i += 1) {
const sh = shapes[i];
if (sh.leaf.wasFront !== sh.front) { sh.leaf.flips += 1; sh.leaf.wasFront = sh.front; }
drawShape(sh);
}
const lead = shapes.filter(function (s: Shape): boolean { return s.leaf === tumblers[0]; })[0];
if (lead) {
drawCaption(FACE_LABEL.replace('{f}', lead.front ? FACE_FRONT : FACE_BACK).replace('{b}', fmt(lead.lit * 100, 0)), 8, 8, false);
}
drawCaption(SHADE_FORMULA, W - 8, H - 28, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause(): void { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render(): void { draw(); }
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) { render(); }
}, { signal: off.signal });
cv.addEventListener('pointerleave', function (): void {
hoverX = -1;
hoverY = -1;
if (paused && ready) { render(); }
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (paused || !alive) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) { clock = 0; build(); }
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill(): void {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play(): void {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function (): void {
wake();
paused = !paused;
syncPause();
if (paused) { stopLoop(); render(); return; }
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) { play(); } else { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref(): void {
still = reduceQuery.matches;
if (!ready) { return; }
paused = still;
syncPause();
if (still) { showStill(); } else { play(); }
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); return; }
if (!paused && ready) { startLoop(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
if (ready) { render(); }
}, { signal: off.signal });
function begin(): void {
fitCanvas();
build();
ready = true;
syncPause();
if (still) { showStill(); } else { play(); }
}
begin();
return function (): void {
alive = false;
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("lf") + " is-demo"} ref={rootRef}>
<canvas className={cx("lf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="세 축으로 뒤집히며 떨어지는 잎들. 빛을 향한 면은 밝고 뒷면은 옅은 색으로 보인다."></canvas>
<button className={cx("lf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}02 A Leaf Tumbling on Three Axes with Front and Back Shading — Measured area 29.32% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Falling Leaves Animation: 9 Scenes, Sway to Pile
The post this demo lives in
Falling Leaves Animation: 9 Scenes, Sway to PileIn a falling leaves animation, drop a leaf straight down and it reads as a pebble. So 01 turns tilt into sideways-slidin See all demos in this post
If you like this effect - similar demos
Creative UI01 Basic three-sensor agent — one slime cell sniffing front, left, right and turningSlime Mold Simulation, 9 Ways (No Library)
Creative UI09 Fractal hero scene — a front-door stage layered with type and starlightMandelbrot Zoom, 9 Ways (Escape-Time, No Library)
Creative UI09 Butterfly hero scene — a front-door stage layered with type and particlesLorenz Attractor: 9 Butterfly Chaos Demos