07Parametric plant — leaves grown from an F+-[] string
Add generation and angle to a string whose brackets remember branches and a houseplant leaf unfurls — a generative-grammar lab where a few letters make a different species.
Best forCreation tools, interior art
Math.sinMath.coslineToglobalAlpha
![Parametric plant — leaves grown from an F+-[] string](https://ayo-oss.github.io/godrichstory-demo/lsystem-fractal-tree-canvas/07-lsys-plant-parametric/poster.png)
<main class="stage">
<div class="ls is-demo" id="lsRoot">
<p class="ls__hint">
<span>Adding F→FF to the one line X→F+[[X]-X]-F[-FX]+X and changing just two numbers — angle and ratio — grows entirely different plants, from ferns to shrubs, out of the same grammar: the parameters decide the species</span>
</p>
<div class="ls__field" role="application" aria-label="Parametric plant lab" tabindex="0">
<canvas class="ls__cv" id="lsCv"></canvas>
</div>
<div class="ls__bar">
<span class="ls__meta">rule X→F+[[X]-X]-F[-FX]+X · angle <b id="lsAng">25</b>°</span>
<span class="ls__meta ls__meta--dim">canvas · globalAlpha</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 매개변수 식물(07). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(문자 재쓰기 세대 성장 + 분기각 매개변수 흔들림)가 만든다.
// 무대는 편 공통 숲 밤 이끼(01~06 과 동일 팔레트).
$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;
}
}// 매개변수 식물(07) — Prusinkiewicz 고전 식물 L-시스템(X 축, X→F+[[X]-X]-F[-FX]+X, F→FF).
// 대괄호 [ ] 로 위치·방향을 스택에 쌓아 가지치기를 표현하는 전형적 매개변수 문법.
// 움직임 = gen 크로스페이드 성장(0→5) + 완성 뒤 각도를 서서히 흔들어(매개변수 자체가 시간 함수)
// "같은 문법·다른 매개변수 = 다른 식물"을 실시간으로 보여준다. 잎은 globalAlpha 로 깊이 페이드.
(function () {
// ── 변형 상수 블록 ──
var ANGLE0 = 25; // 기준 분기각(도) — 이 값이 흔들리며 식물 형태가 바뀐다
var SWING = 6; // 각도 흔들림 폭(도)
var MAXGEN = 5;
var CYCLE = 2.0;
// ─────────────────
var root = document.getElementById('lsRoot');
var field = document.getElementById('lsCv').parentElement;
var cv = document.getElementById('lsCv');
var angEl = document.getElementById('lsAng');
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_STEM = 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 = { 'X': 'F+[[X]-X]-F[-FX]+X', 'F': 'FF' };
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 STRS = [];
for (var g0 = 0; g0 <= MAXGEN; g0++) STRS.push(expand('X', g0));
function rgb(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
// 거북 렌더러 — angleDeg 는 매 프레임 바뀌는 매개변수(같은 문자열, 다른 각도 → 다른 식물)
function drawPlant(ctx, str, x0, y0, stepLen, angleDeg, ox, oy, sc, alphaMul, drawFrac) {
var x = x0, y = y0, a = -Math.PI / 2;
var stack = [];
var n = str.length;
var drawUpto = Math.floor(n * drawFrac);
for (var i = 0; i < drawUpto; i++) {
var ch = str.charAt(i);
if (ch === 'F') {
var nx = x + Math.cos(a) * stepLen;
var ny = y + Math.sin(a) * stepLen;
var depth = stack.length;
var t = Math.min(1, depth / 6);
var cr = C_STEM[0] + (C_LEAF[0] - C_STEM[0]) * t;
var cg = C_STEM[1] + (C_LEAF[1] - C_STEM[1]) * t;
var cb = C_STEM[2] + (C_LEAF[2] - C_STEM[2]) * t;
ctx.globalAlpha = alphaMul * (0.55 + 0.45 * t);
ctx.strokeStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.lineWidth = Math.max(0.9, 3.2 * Math.pow(0.72, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (depth >= 4) {
ctx.globalAlpha = alphaMul * 0.5;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, 3.2, 0, Math.PI * 2);
ctx.fillStyle = 'rgb(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ')';
ctx.fill();
}
x = nx; y = ny;
} else if (ch === '+') {
a += angleDeg * Math.PI / 180;
} else if (ch === '-') {
a -= angleDeg * Math.PI / 180;
} else if (ch === '[') {
stack.push({ x: x, y: y, a: a });
} else if (ch === ']') {
var st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; }
}
}
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.16;
var ox = W * 0.5, oy = H * 0.95;
var grPhase = Math.min(loop / 0.55, 1);
var genF = grPhase * MAXGEN;
var gA = Math.min(MAXGEN, Math.floor(genF));
var drawFrac = gA >= MAXGEN ? 1 : Math.min(1, genF - gA);
// 성장 뒤 각도를 흔들어 매개변수가 형태를 바꾸는 걸 실시간으로 보여준다
var angT = loop >= 0.55 ? (loop - 0.55) / 0.45 : 0;
var angle = ANGLE0 + (loop >= 0.55 ? Math.sin(angT * Math.PI * 3) * SWING : 0);
if (angEl) angEl.textContent = String(Math.round(angle));
drawPlant(ctx, STRS[gA], 0, 0, 1 / Math.pow(1.55, gA) * 0.62, angle, ox, oy, sc, 1, gA >= MAXGEN ? 1 : drawFrac);
}
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(reduce ? 0.85 : 0.8);
});
fit();
if (reduce) {
render(0.85);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// LsysPlantParametric — vanilla 판(run/328/demo/07-lsys-plant-parametric/index.html) 의 React 포트.
// Prusinkiewicz 고전 식물 L-시스템(X 축, X→F+[[X]-X]-F[-FX]+X, F→FF). 대괄호 [ ] 로 위치·방향을
// 스택에 쌓아 가지치기를 표현하는 전형적 매개변수 문법.
// 움직임 = 세대 크로스페이드 성장(0→5) + 완성 뒤 각도를 서서히 흔들어 "같은 문법·다른 매개변수 = 다른 식물"을 보여준다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LsysPlantParametric.module.scss";
export type LsysPlantParametricProps = {
duration?: string;
easing?: string;
color?: string;
angle0?: number; // 기준 분기각(도)
swing?: number; // 각도 흔들림 폭(도)
maxGen?: number;
cycle?: number;
};
const ANGLE0 = 25;
const SWING = 6;
const MAXGEN = 5;
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 };
const HINT_TEXT = "X→F+[[X]-X]-F[-FX]+X 한 줄에 F→FF 를 더해 각도·비율 두 숫자만 바꾸면 고사리부터 관목까지 전혀 다른 식물이 같은 문법에서 자라납니다 — 매개변수가 종을 결정합니다";
const META_LEFT_PREFIX = "규칙 X→F+[[X]-X]-F[-FX]+X · 각도 ";
const META_RIGHT = "canvas · globalAlpha";
const FIELD_LABEL = "매개변수 식물 실험판";
const RULE: Record<string, string> = { X: "F+[[X]-X]-F[-FX]+X", F: "FF" };
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 LsysPlantParametric({
duration = "2s",
easing = "ease-spring",
color = "#bfe3c8",
angle0 = ANGLE0,
swing = SWING,
maxGen = MAXGEN,
cycle = CYCLE
}: LsysPlantParametricProps) {
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<{ stem: RGB; leaf: RGB; ink: RGB } | null>(null);
const strsRef = useRef<string[]>([]) 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 [ang, setAng] = useState(ANGLE0);
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 drawPlant(ctx: CanvasRenderingContext2D, str: string, x0: number, y0: number, stepLen: number, angleDeg: number, ox: number, oy: number, sc: number, alphaMul: number, drawFrac: number): void {
const col = colRef.current;
if (!col) return;
let x = x0, y = y0, a = -Math.PI / 2;
const stack: Stack[] = [];
const n = str.length;
const drawUpto = Math.floor(n * drawFrac);
for (let i = 0; i < drawUpto; i++) {
const ch = str.charAt(i);
if (ch === "F") {
const nx = x + Math.cos(a) * stepLen;
const ny = y + Math.sin(a) * stepLen;
const depth = stack.length;
const t = Math.min(1, depth / 6);
const cr = col.stem[0] + (col.leaf[0] - col.stem[0]) * t;
const cg = col.stem[1] + (col.leaf[1] - col.stem[1]) * t;
const cb = col.stem[2] + (col.leaf[2] - col.stem[2]) * t;
ctx.globalAlpha = alphaMul * (0.55 + 0.45 * t);
ctx.strokeStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.lineWidth = Math.max(0.9, 3.2 * Math.pow(0.72, depth));
ctx.beginPath();
ctx.moveTo(ox + x * sc, oy + y * sc);
ctx.lineTo(ox + nx * sc, oy + ny * sc);
ctx.stroke();
if (depth >= 4) {
ctx.globalAlpha = alphaMul * 0.5;
ctx.beginPath();
ctx.arc(ox + nx * sc, oy + ny * sc, 3.2, 0, Math.PI * 2);
ctx.fillStyle = "rgb(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ")";
ctx.fill();
}
x = nx; y = ny;
} else if (ch === "+") {
a += angleDeg * Math.PI / 180;
} else if (ch === "-") {
a -= angleDeg * Math.PI / 180;
} else if (ch === "[") {
stack.push({ x, y, a });
} else if (ch === "]") {
const st = stack.pop();
if (st) { x = st.x; y = st.y; a = st.a; }
}
}
ctx.globalAlpha = 1;
}
function draw(loop: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const STRS = strsRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col || !STRS.length) return;
ctx.fillStyle = rgba(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const sc = Math.min(W, H) * 0.16;
const ox = W * 0.5, oy = H * 0.95;
const grPhase = Math.min(loop / 0.55, 1);
const genF = grPhase * maxGen;
const gA = Math.min(maxGen, Math.floor(genF));
const drawFrac = gA >= maxGen ? 1 : Math.min(1, genF - gA);
const angT = loop >= 0.55 ? (loop - 0.55) / 0.45 : 0;
const angle = angle0 + (loop >= 0.55 ? Math.sin(angT * Math.PI * 3) * swing : 0);
setAng(Math.round(angle));
drawPlant(ctx, STRS[gA], 0, 0, 1 / Math.pow(1.55, gA) * 0.62, angle, ox, oy, sc, 1, gA >= maxGen ? 1 : drawFrac);
}
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 = {
stem: tokRGB(root, "--ls-bark"),
leaf: tokRGB(root, "--ls-leaf"),
ink: tokRGB(root, "--ls-ink")
};
const strs: string[] = [];
for (let g0 = 0; g0 <= maxGen; g0++) strs.push(expand("X", g0));
strsRef.current = strs;
ctxRef.current = cv.getContext("2d");
fit();
const reduce = reduced();
function onResize(): void {
fit();
draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduce) {
draw(0.85);
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
}, [angle0, swing, maxGen, 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>{ang}</b>°</span>
<span className={`${styles.meta} ${styles.metaDim}`}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}07 Parametric plant — leaves grown from an F+-[] string — Measured area 6.449% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · L-System Fractal Tree Generator: 9 Demos
The post this demo lives in
L-System Fractal Tree Generator: 9 DemosOne F steps forward, one + turns. Reread those few letters enough times and branches grow on their own — the whole recur See all demos in this post


