08風に揺れる木 — 世代ごとに位相がずれる揺れ
枝の深さごとに風の位相を少しずつずらすと、幹はゆっくり、先端は速く揺れます。世代番号を振動パラメータに使う計算が乗る、生きた項目です。
使いどころヒーロー背景、季節のムード
Math.sinlineTorequestAnimationFrameglobalAlpha

<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>文字列 F→F[+F]F[-F] はそのままに、亀が一歩進むたびに曲げる角度へ世代の深さに比例して大きくなる正弦波を足すと、根元はほぼ動かず枝先だけが大きく揺れる風になります</span>
</p>
<div class="ls__field" role="application" aria-label="風に揺れる木ラボ" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">規則 F→F[+F]F[-F] · 風の強さ <b id="lsWind">0</b></span>
<span class="ls__meta ls__meta--dim">canvas · globalAlpha</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 바람 흔들림 나무(08). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(고정 문자열 + 깊이비례 사인파 흔들림)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~07 과 동일 팔레트).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
$ls-stage: #1a2116;
@include stage($ls-stage);
$ls-ink: #12160e;
$ls-bark: #d8c4a0;
$ls-leaf: #bfe3c8;
$ls-mist: #dfe6d6;
.ls {
--ls-ink: #{$ls-ink};
--ls-bark: #{$ls-bark};
--ls-leaf: #{$ls-leaf};
--ls-mist: #{$ls-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $ls-mist;
}
.ls__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $ls-ink;
box-shadow: inset 0 0 0 1px rgba($ls-mist, .18);
}
.ls__field:focus-visible { outline: 2px solid $subject-mint; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.ls__meta { font-size: 10px; font-weight: 500; color: rgba($ls-mist, .72); }
.ls__meta--dim { font-size: 10px; }
.ls__meta b { font-weight: 700; color: $ls-mist; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 100px; }
.ls__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a2116;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--ls-ink: #12160e;
--ls-bark: #d8c4a0;
--ls-leaf: #bfe3c8;
--ls-mist: #dfe6d6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #dfe6d6;
}
.ls__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #12160e;
box-shadow: inset 0 0 0 1px rgba(223, 230, 214, 0.18);
}
.ls__field:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ls__meta {
font-size: 10px;
font-weight: 500;
color: rgba(223, 230, 214, 0.72);
}
.ls__meta--dim {
font-size: 10px;
}
.ls__meta b {
font-weight: 700;
color: #dfe6d6;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 100px;
}
.ls__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
}// 바람 흔들림 木(08) — 문자열 자체(F→F[+F]F[-F])는 고정. 거북이 걸을 때 방향각에
// "깊이(世代)에 비례하는 사인파"를 더해서, 같은 문자열이 매 프레임 다른 모양으로 보인다.
// 뿌리는 위상이 0 이라 거의 안 흔들리고, 잎 끝은 깊이가 깊어 크게 흔들린다(자연스러운 바람 감쇠).
(function () {
// ── 변형 상수 블록 ──
var ANGLE = 24;
var MAXGEN = 5;
var WIND_AMP = 30; // 잎 끝 최대 흔들림(도)
var WIND_HZ = 1.6; // 바람 진동수(회/루프)
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var windEl = document.getElementById('lsWind');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_BARK = tokRGB('--ls-bark');
var C_LEAF = tokRGB('--ls-leaf');
var C_INK = tokRGB('--ls-ink');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cv.width = W; cv.height = H;
}
var RULE = { 'F': 'F[+F]F[-F]' };
function expand(axiom, gen) {
var s = axiom;
for (var g = 0; g < gen; g++) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
var STR = expand('F', MAXGEN);
function rgb(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
// 거북 렌더러 — 매 F 걸음마다 깊이 비례 사인파를 각도에 더해(바람) 그린다.
function drawWind(ctx, str, x0, y0, stepLen, ox, oy, sc, windPhase, windAmp, windGust) {
var x = x0, y = y0, a = -Math.PI / 2;
var stack = [];
var depth = 0, maxDepth = MAXGEN;
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var depthT = depth / maxDepth;
// 世代 깊이에 비례(선형)해 커지는 사인파 — 잔가지뿐 아니라 중간 가지도 함께 휘어야
// 프레임마다 바뀌는 픽셀 면적이 뿌리 근처 몇 개 선분에만 갇히지 않는다.
var sway = windAmp * depthT * Math.sin(windPhase + depth * 0.8) * Math.PI / 180;
a += sway;
var segLen = stepLen * Math.pow(0.78, depth);
var nx = x + Math.cos(a) * segLen;
var ny = y + Math.sin(a) * segLen;
var cr = C_BARK[0] + (C_LEAF[0] - C_BARK[0]) * depthT;
var cg = C_BARK[1] + (C_LEAF[1] - C_BARK[1]) * depthT;
var cb = C_BARK[2] + (C_LEAF[2] - C_BARK[2]) * depthT;
ctx.globalAlpha = 0.6 + 0.4 * depthT;
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(1.5, 5.2 * Math.pow(0.66, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (depth >= maxDepth - 1) {
// 잎 끝 무리 — 반경 자체도 돌풍 세기를 따라 커지고 작아져 정지 상태로 겹치지 않는다.
var glowR = 7 + 5 * windGust;
var coreR = 3 + 2 * windGust;
ctx.fillStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.globalAlpha = 0.2;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, glowR, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.62;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, coreR, 0, Math.PI * 2);
ctx.fill();
}
x = nx; y = ny;
} else if (ch === '[') {
stack.push({ x: x, y: y, a: a, d: depth });
depth++;
} else if (ch === ']') {
var st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; depth = st.d; }
} else if (ch === '+') {
a += ANGLE * Math.PI / 180;
} else if (ch === '-') {
a -= ANGLE * Math.PI / 180;
}
}
ctx.globalAlpha = 1;
}
function render(loop) {
var ctx = cv.getContext('2d');
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var sc = Math.min(W, H) * 0.24;
var ox = W * 0.5, oy = H * 0.95;
var windPhase = loop * Math.PI * 2 * WIND_HZ;
var windGust = 0.55 + 0.45 * Math.sin(loop * Math.PI * 2 * 0.5); // 돌풍 세기 자체도 서서히 오르내린다
var windAmp = WIND_AMP * windGust;
if (windEl) windEl.textContent = String(Math.round(windGust * 10));
drawWind(ctx, STR, 0, 0, 1, ox, oy, sc, windPhase, windAmp, windGust);
}
function frame(now) {
if (t0 < 0) t0 = now;
var loop = (((now - t0) / 1000) % CYCLE) / CYCLE;
render(loop);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
render(0.3);
});
fit();
if (reduce) {
render(0.3);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysTreeWindSway — vanilla 판(run/328/demo/08-lsys-tree-wind-sway/index.html) 의 React 포트.
// F 축 나무(F→F[+F]F[-F]F, 24도)를 완성해 둔 채 각 마디의 흔들림 위상을 깊이(depth)에 비례해 지연시켜
// 바람이 뿌리에서 가지 끝으로 퍼져 나가는 것처럼 보이게 한다. 세기는 sin(t·WIND_HZ) 로 계산.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysTreeWindSway.module.scss";
export type LsysTreeWindSwayProps = {
duration?: string;
easing?: string;
color?: string;
angle?: number;
maxGen?: number;
windAmp?: number;
windHz?: number;
cycle?: number;
};
const ANGLE = 24;
const MAXGEN = 5;
const WIND_AMP = 30;
const WIND_HZ = 1.6;
const CYCLE = 2.0;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Stack = { x: number; y: number; a: number; d: number };
const HINT_TEXT = "F→F[+F]F[-F]F 로 완성해 둔 나무에 깊이(depth)마다 위상을 지연시킨 sin(t·1.6Hz) 바람을 더하면, 뿌리는 그대로인데 가지 끝만 크게 흔들리는 실제 나무의 바람 반응을 흉내 낼 수 있습니다";
const META_LEFT_PREFIX = "규칙 F→F[+F]F[-F]F · 바람 ";
const META_RIGHT = "canvas · 위상 지연 · requestAnimationFrame";
const FIELD_LABEL = "바람 흔들림 나무 실험판";
const RULE: Record<string, string> = { F: "F[+F]F[-F]F" };
function expand(axiom: string, gen: number): string {
let s = axiom;
for (let g = 0; g < gen; g++) {
let out = "";
for (let i = 0; i < s.length; i++) {
const ch = s.charAt(i);
out += RULE[ch] !== undefined ? RULE[ch] : ch;
}
s = out;
}
return s;
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LsysTreeWindSway({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle = ANGLE,
maxGen = MAXGEN,
windAmp = WIND_AMP,
windHz = WIND_HZ,
cycle = CYCLE
}: LsysTreeWindSwayProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ bark: RGB; leaf: RGB; ink: RGB } | null>(null);
const strRef = useRef("") as Box<string>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [wind, setWind] = useState(0);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function drawWind(ctx: CanvasRenderingContext2D, str: string, ox: number, oy: number, sc: number, stepLen: number, t: number, windStrength: number): void {
const col = colRef.current;
if (!col) return;
let x = 0, y = 0, a = -Math.PI / 2, d = 0;
const stack: Stack[] = [];
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (ch === "F") {
const phase = t * windHz * Math.PI * 2 - d * 0.35;
const sway = Math.sin(phase) * (windAmp * Math.PI / 180) * windStrength * Math.min(1, d / 3);
const aa = a + sway;
const nx = x + Math.cos(aa) * stepLen;
const ny = y + Math.sin(aa) * stepLen;
const tcol = Math.min(1, d / 6);
const cr = col.bark[0] + (col.leaf[0] - col.bark[0]) * tcol;
const cg = col.bark[1] + (col.leaf[1] - col.bark[1]) * tcol;
const cb = col.bark[2] + (col.leaf[2] - col.bark[2]) * tcol;
ctx.strokeStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.lineWidth = Math.max(0.9, 3 * Math.pow(0.74, d));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (d >= 4) {
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, 2.6, 0, Math.PI * 2);
ctx.fillStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.fill();
}
x = nx; y = ny; a = aa;
} else if (ch === "+") {
a += angle * Math.PI / 180;
} else if (ch === "-") {
a -= angle * Math.PI / 180;
} else if (ch === "[") {
stack.push({ x, y, a, d });
d++;
} else if (ch === "]") {
const st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; d = st.d; }
}
}
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const str = strRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !str) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.5;
const ox = W * 0.5, oy = H * 0.95;
const t = loop * cycle;
const windStrength = 0.35 + 0.65 * Math.abs(Math.sin(t * windHz * Math.PI * 0.5));
setWind(Math.round(windStrength * 100));
drawWind(ctx, str, ox, oy, sc, sc / Math.pow(1.6, maxGen) * 0.5, t, windStrength);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const loop = (((now - t0) / 1000) % cycle) / cycle;
draw(loop);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
colRef.current = {
bark: tokRGB(root, "--ls-bark"),
leaf: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
strRef.current = expand("F", maxGen);
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.3);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.3);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [angle, maxGen, windAmp, windHz, cycle]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}><span>{HINT_TEXT}</span></p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT_PREFIX}<b>{wind}</b>%</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}08 風に揺れる木 — 世代ごとに位相がずれる揺れ — 計測 累積面積 1.706% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · シェルピンスキーの三角形など9種 — 文字が枝を生むキャンバス
このデモが属する記事
シェルピンスキーの三角形など9種 — 文字が枝を生むキャンバスFは前進、+は回転。この数文字を読み直すだけで枝がひとりでに伸びます。再帰エンジン一式をコピペで持ち帰れます。シェルピンスキーの三角形も含む9つの図形はすべて、この文字列書き換え(Lシステム)とタートルグラフィックの組み合わせで一枚のキャン この記事のデモをすべて見る


