Туторіал · Процедурна генерація · Three.js · Алгоритми
📅 Липень 2026 ⏱ ≈ 22 хв 🎯 Середній рівень

L-системи: від рядка до 3D-рослини

Один-єдиний символьний рядок, жменька правил переписування і черепаха, яка ніколи не втомлюється, — це все, що потрібно, щоб виростити переконливе 3D-дерево. Цей туторіал будує рушій L-систем з нуля: граматику, 3D-інтерпретатор черепахи, стохастичні й параметричні варіанти для природної варіативності та фінальний крок перетворення шляху черепахи на справжню геометрію Three.js.

1. Що таке L-система?

Система Ліндермайєра (L-система) — це формальна граматика, винайдена 1968 року біологом Аристідом Ліндермайєром для моделювання росту водоростей, а пізніше й вищих рослин. На відміну від граматики хомскіанського типу, яку використовують для парсингу, L-система є генеративною: вона стартує з короткого рядка-«зерна» і переписує кожен символ паралельно на кожній ітерації. Саме це паралельне переписування робить її природним інструментом для моделювання біологічного росту — адже кожна клітина справжньої рослини ділиться в один і той самий «такт».

Формально L-система задається кортежем G = (V, ω, P):

V = алфавіт, множина символів, які можуть з'являтися у рядку ω = аксіома, початковий рядок («зерно») P = множина правил породження, кожне з яких зіставляє один символ із рядком-наступником: символ → наступник

Щоб отримати наступне покоління рядка, скануємо поточний рядок зліва направо й замінюємо кожен символ одночасно на відповідний наступник із P (символи без відповідного правила лишаються без змін — вони діють як власне тотожне правило). Повторення цього процесу n разів дає рядок «покоління n», який зазвичай позначають Lₙ.

2. Переписування рядків на прикладі

Найпростіша можлива L-система — модель водоростей, яку Ліндермайєр використав у своїй оригінальній статті 1968 року — має алфавіт {A, B}, аксіому A і два правила:

СимволНаступник
AAB
BA
L0: A L1: AB L2: ABA L3: ABAAB L4: ABAABABA L5: ABAABABAABAAB

Довжина рядка йде за послідовністю Фібоначчі — гарна перевірка, що ваш рушій переписування працює коректно. Сам рушій — це всього кілька рядків коду:

function rewrite(axiom, rules, generations) {
  let current = axiom;
  for (let g = 0; g < generations; g++) {
    let next = '';
    for (const symbol of current) {
      // якщо правила немає — використовуємо тотожне правило
      next += rules[symbol] ?? symbol;
    }
    current = next;
  }
  return current;
}

const algaeRules = { A: 'AB', B: 'A' };
rewrite('A', algaeRules, 5); // → "ABAABABAABAAB"

Для рослин алфавіт перевизначають так, щоб кожен символ означав команду малювання, а не біологічний стан — саме цим займається інтерпретатор-черепаха, розглянутий далі.

3. Графіка черепахи: символи в лінії

Щоб перетворити рядок символів на зображення, проходимо рядок зліва направо й інтерпретуємо кожен символ як інструкцію для черепахи — курсора з позицією та напрямком погляду. Класичний алфавіт (введений Пруцінкевичем і Ліндермайєром у книзі The Algorithmic Beauty of Plants) виглядає так:

СимволДія черепахи
Fрух вперед на один крок з малюванням відрізка
fрух вперед на один крок без малювання («перо піднято»)
+поворот ліворуч на кут δ
-поворот праворуч на кут δ
[зберегти поточний стан черепахи у стеку
]вийняти зі стеку — повернутися до збереженого стану

Саме пара [ ] перетворює просту ламану лінію на гіллясте дерево: збереження стану перед бічною гілкою й відновлення після неї дає змозі черепасі «телепортуватися» назад до стовбура й продовжити рух, ніби гілки й не було. Класична аксіома гілкування виглядає так:

аксіома: F правило: F → F[+F]F[-F]F δ = 25.7° Уже на другому поколінні видно рекурсивну структуру «гілка-з-гілок», яка робить дерева з L-систем схожими на органічні, а не на регулярно розгалужену схему.

4. Графіка черепахи у 3D

2D-черепасі достатньо кута напрямку. 3D-черепасі потрібна повна орієнтація — три взаємно ортогональні одиничні вектори: H (heading, напрямок руху), L (left, ліворуч) і U (up, вгору). Поворот черепахи означає обертання цієї системи координат навколо однієї з трьох осей. Розширений алфавіт Пруцінкевича додає ще шість символів:

СимволОбертання
& / ^нахил вниз / вгору, обертання навколо L
\ / /крен ліворуч / праворуч, обертання навколо H
< / >рискання ліворуч / праворуч, обертання навколо U
|розворот на 180°

У коді орієнтацію черепахи краще представляти кватерніоном (або матрицею обертання 3×3), а не окремими векторами H/L/U — це дозволяє уникнути gimbal lock і чисто композиціюється з Three.js:

class Turtle3D {
  constructor() {
    this.pos = new THREE.Vector3(0, 0, 0);
    this.quat = new THREE.Quaternion(); // тотожність = напрямок +Y
    this.stepLen = 1.0;
    this.stack = [];
  }

  forward(draw, segments) {
    const dir = new THREE.Vector3(0, 1, 0).applyQuaternion(this.quat);
    const next = this.pos.clone().addScaledVector(dir, this.stepLen);
    if (draw) segments.push({ from: this.pos.clone(), to: next.clone() });
    this.pos = next;
  }

  rotate(axis, degrees) {
    const q = new THREE.Quaternion().setFromAxisAngle(axis, THREE.MathUtils.degToRad(degrees));
    this.quat.multiply(q); // обертання у власній локальній системі черепахи
  }

  push() {
    this.stack.push({ pos: this.pos.clone(), quat: this.quat.clone(), len: this.stepLen });
  }

  pop() {
    const s = this.stack.pop();
    this.pos = s.pos; this.quat = s.quat; this.stepLen = s.len;
  }
}

const UP = new THREE.Vector3(0, 0, 1);   // вісь H
const LEFT = new THREE.Vector3(1, 0, 0); // вісь L
const FWD = new THREE.Vector3(0, 1, 0);  // початковий напрямок

function interpret3D(str, turtle, angle = 22.5) {
  const segments = [];
  for (const c of str) {
    switch (c) {
      case 'F': turtle.forward(true, segments); break;
      case 'f': turtle.forward(false, segments); break;
      case '&': turtle.rotate(LEFT,  angle); break;
      case '^': turtle.rotate(LEFT, -angle); break;
      case '+': turtle.rotate(UP,    angle); break;
      case '-': turtle.rotate(UP,   -angle); break;
      case '\\': turtle.rotate(FWD,  angle); break;
      case '/': turtle.rotate(FWD, -angle); break;
      case '|': turtle.rotate(UP,    180); break;
      case '[': turtle.push(); break;
      case ']': turtle.pop();  break;
    }
  }
  return segments;
}
Порядок обертання важливий. rotate() вище домножує кватерніон справа, тож кожен поворот відбувається у поточній локальній системі черепахи, а не у світовій — саме це потрібно, коли зігнута гілка знову повертається відносно власного кінчика, а не відносно початкової орієнтації стовбура.

5. Стохастичні L-системи

Детерміновані L-системи створюють ідеально самоподібну рослину — кожна гілка є точною масштабованою копією кожної іншої гілки того самого рівня. Реальні дерева не такі регулярні. Стохастична L-система прив'язує ймовірність до кількох кандидатів-наступників для одного символу, і механізм переписування обирає один із них випадково на кожному застосуванні:

F → F[+F]F[-F]F p = 0.34 F → F[+F][-F]F p = 0.34 F → F[+F][-F][F] p = 0.32 Σp = 1.0 (ймовірності для одного символу мають давати в сумі 1)
function pickWeighted(options) {
  // options: [{ str, p }, ...], де p у сумі дає ~1
  let r = Math.random();
  for (const opt of options) {
    if ((r -= opt.p) < 0) return opt.str;
  }
  return options[options.length - 1].str; // запобіжник на похибку округлення
}

const stochasticRules = {
  F: [
    { str: 'F[+F]F[-F]F',   p: 0.34 },
    { str: 'F[+F][-F]F',    p: 0.34 },
    { str: 'F[+F][-F][F]',  p: 0.32 },
  ],
};

Оскільки кожна гілка розв'язує власний випадковий вибір незалежно, дві гілки, що починалися однаково, швидко розходяться — це візуальна ознака природної крони, а не фрактального «шпалерного» візерунка. Завжди задавайте seed для джерела випадковості (напр., невеликий PRNG mulberry32), якщо потрібні відтворювані дерева для збереженої сцени.

6. Параметричні L-системи

Проста L-система лише каже черепасі, де повернути — вона не знає нічого про товщину гілки чи про те, що гілочки біля верхівки дерева коротші й тонші за стовбур. Параметрична L-система прикріплює числові параметри до символів, напр. F(length, radius), і правила породження обчислюють нові значення параметрів замість простого копіювання символів:

аксіома: F(1.0, 0.1) правило: F(l, r) → F(l·0.9, r·0.7) [ + F(l·0.7, r·0.5) ] F(l·0.9, r·0.7) Кожне покоління зменшує довжину на 10% і радіус на 30% уздовж головної осі, а також породжує бічну гілку, коротшу (70%) і вдвічі тоншу (половина радіуса) — цього одного правила достатньо, щоб отримати переконливий звужений стовбур із звуженими бічними гілками.
function expandParametric(axiomTokens, apply, generations) {
  let tokens = axiomTokens; // [{sym:'F', args:[1.0, 0.1]}, ...]
  for (let g = 0; g < generations; g++) {
    const next = [];
    for (const tok of tokens) {
      const produced = apply(tok); // повертає масив токенів або null → залишити як є
      next.push(...(produced ?? [tok]));
    }
    tokens = next;
  }
  return tokens;
}

function applyRule(tok) {
  if (tok.sym !== 'F') return null;
  const [l, r] = tok.args;
  if (l < 0.05) return [tok]; // умова зупинки: гілка занадто мала, щоб розгалужуватись далі
  return [
    { sym: 'F', args: [l * 0.9, r * 0.7] },
    { sym: '[' }, { sym: '+' },
    { sym: 'F', args: [l * 0.7, r * 0.5] },
    { sym: ']' },
    { sym: 'F', args: [l * 0.9, r * 0.7] },
  ];
}
Явна умова зупинки. Параметричні правила дають те, чого не мають звичайні L-системи: природне місце для перевірки l < ε і зупинки рекурсії. Без цього фіксована кількість поколінь або недорощує тонкі гілки, або породжує вибух мікроскопічних гілочок на товстих.

7. Від шляху черепахи до сітки

Інтерпретатор черепахи, описаний вище, дає плаский список відрізків — чудово для налагоджувального превью LineSegments, але справжньому дереву потрібні тверді звужені гілки. Стандартний підхід: замінити кожен відрізок коротким циліндром (або зрізаним конусом, оскільки радіус змінюється вздовж гілки), орієнтованим від точки from до точки to, а потім об'єднати всі їх в одну BufferGeometry для одного виклику малювання.

function segmentToCylinder(seg) {
  const dir = new THREE.Vector3().subVectors(seg.to, seg.from);
  const len = dir.length();
  const geo = new THREE.CylinderGeometry(
    seg.radiusEnd, seg.radiusStart, len, 6, 1
  );
  geo.translate(0, len / 2, 0); // точка опори в основі, циліндр за замовчуванням центрований

  // вирівняти локальну +Y з напрямком відрізка
  const quat = new THREE.Quaternion().setFromUnitVectors(
    new THREE.Vector3(0, 1, 0), dir.clone().normalize()
  );
  geo.applyQuaternion(quat);
  geo.translate(seg.from.x, seg.from.y, seg.from.z);
  return geo;
}

function buildTreeMesh(segments, material) {
  const geometries = segments.map(segmentToCylinder);
  const merged = THREE.BufferGeometryUtils.mergeGeometries(geometries);
  return new THREE.Mesh(merged, material);
}

Невеликої кількості радіальних сегментів (5–6 граней) достатньо для віддалених гілок; залиште 8–12 граней для стовбура, де фасети дійсно видно зблизька. Якщо ваша L-система параметрична, беріть radiusStart/radiusEnd напряму з аргументів F(l, r), записаних під час інтерпретації, замість фіксованого радіуса.

8. Додавання листя через інстансинг

Листя найкраще розміщувати на кінчиках рекурсії — фіксуйте позицію й орієнтацію черепахи щоразу, коли інтерпретація доходить до символу листка (зазвичай L), а потім малюйте тисячі листків одним викликом малювання за допомогою InstancedMesh замість тисяч окремих об'єктів Mesh:

const leafGeo = new THREE.PlaneGeometry(0.3, 0.5);
const leafMat = new THREE.MeshStandardMaterial({ color: 0x4aade80, side: THREE.DoubleSide });
const leaves = new THREE.InstancedMesh(leafGeo, leafMat, leafPositions.length);

const dummy = new THREE.Object3D();
leafPositions.forEach((leaf, i) => {
  dummy.position.copy(leaf.pos);
  dummy.quaternion.copy(leaf.quat);
  const s = 0.8 + Math.random() * 0.4; // невелика варіація розміру листка
  dummy.scale.setScalar(s);
  dummy.updateMatrix();
  leaves.setMatrixAt(i, dummy.matrix);
});
leaves.instanceMatrix.needsUpdate = true;
Одне дерево — три виклики малювання. Об'єднана геометрія гілок + один InstancedMesh для листя + опційно ще один для квітів/плодів — цього достатньо, щоб відрендерити цілий ліс із частотою 60 FPS: об'єднання гілок із кроку 7 у поєднанні з інстансингом листя тримає кількість викликів малювання сталою незалежно від того, скільки тисяч сегментів згенерувала L-система.

9. Продуктивність і типові пастки

Коли окремі дерева рендеряться плавно, природний наступний крок — рендеринг цілого лісу; дивіться GPU-прискорені дерева з L-систем про об'єднання тисяч згенерованих дерев в один інстансований виклик малювання.