Привет! Мы продолжаем цикл статей по базовым принципам работы с canvas. Сегодня мы рассмотрим L-системы в качестве примера для создания различных интересных визуализаций.

Так что же такое L-ситемы? L-системы (или системы Линденмайера) — это набор простых правил, которые используются для моделирования роста водорослей (и не только), созданные венгерским биологом Аристидом Линденмайером в 1968 году.

В общем виде L-система представляет собой набор правил, применяемых к начальной строке, называемой аксиомой. Помимо этого система может содержать символы константы, на которые правила не распространяются. В самом простом виде правила могут быть описаны следующим образом:

Аксиома — "A"

Правило 1: "A" заменяется на "AB"

Правило 2: "B" заменяется на "A"

Природа таких систем рекурсивна и поэтому приводит к самоподобию, то есть к фракталам. В общем виде представление аксиомы для 5 поколения будет выглядеть так:

Значения аксиомы

n = 0: A

n = 1: AB

n = 2: ABA

n = 3: ABAAB

n = 4: ABAABABA

n = 5: ABAABABAABAAB

Давайте попробуем реализовать представленную выше систему правил для того, чтобы проверить, как ведет себя аксиома в коде:

let axiom = 'A';

const generation = 10;
const rules = {
  'A': 'AB',
  'B': 'A'
}

function applyRules(axiom) {
  let result = '';

  for (let char of axiom) {
    result += rules[char];
  }

  return result;
}

for (let i = 0; i < generation; i++) {
  console.log(`generation ${i}: ${axiom}`);
  axiom = applyRules(axiom)
}

Если мы посмотрим на получившиеся значения, то заметим, как быстро растет строка в каждом новом поколении:

Значения аксиомы для 10 поколений

generation 0: A

generation 1: AB

generation 2: ABA

generation 3: ABAAB

generation 4: ABAABABA

generation 5: ABAABABAABAAB

generation 6: ABAABABAABAABABAABABA

generation 7: ABAABABAABAABABAABABAABAABABAABAAB

generation 8: ABAABABAABAABABAABABAABAABABAABAABABAABABAABAABABAABABA

generation 9: ABAABABAABAABABAABABAABAABABAABAABABAABABAABAABABAABABAABAABABAABAABABAABABAABAABABAABAAB

Занимательный факт: если мы будем выводить не значение строки, а ее длину, то эти числа будут равны числам из последовательности Фибоначчи.

Черепашья графика

Так вот, для построения визуальной составляюшей L-систем обычно используется принцип черепашьей графики, который построен на идее существования указателя, который перемещается на заданное количество пикселей и угол и оставляет за собой шлейф.

Мы не будем сегодня рассматривать математику, которая лежит в основе черепашьей графики, так как это тема для отдельного урока, и в своих эксперементах будем использовать библиотеку better-turtle.

Давайте для начала разберем пример использования такой графики и нарисуем простой треугольник. Для этого устанавливаем библиотеку и проводим базовую настройку:

import { Turtle } from 'better-turtle';

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext('2d');

canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

const turtle = new Turtle(ctx);

После устанавливаем толщину линии, а командой forward двигаем указатель на 300 пикселей вперед и по достижению целевой точки поворачиваем указатель на 120 градусов:

turtle.setWidth(5);
turtle.right(90);

turtle.forward(300);
turtle.left(120);
turtle.forward(300);
turtle.left(120);
turtle.forward(300);

Получаем следующий результат:

Получившийся треугольник

Давайте попробуем сделать еще что-нибудь. Для этого заведем цикл и на каждой итеррации будем поворачивать указатель на 90 градусов и сдвигать на постоянно растущий шаг:

let step = 0;

while (step < 400) {
  turtle.forward(step);
  turtle.right(90);
  step += 20;
}

В результате получим интересную спираль:

Спираль

Применение черепашьей графики в L-системах

Интерпретацию описанной выше L-системы в черепашьей графике опишем в следующем виде:

"A" — поворот налево на 60 градусов и перемещение на расстояние step.

"B" — поворот направо на 60 градусов и перемещение на расстояние step.

Для начала сформируем аксиому для заданного поколения, немного модернизировав наш код:

function getAxiom(generation, axiom) {
  for (let i = 0; i < generation; i++) {
    axiom = applyRules(axiom);
  }

  return axiom;
}

После реализуем метод создания черепашки, в котором зальем фон черным цветом и выставим толщину линии:

function createTurtle() {
  const canvas = document.getElementById("canvas");
  const ctx = canvas.getContext("2d");

  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;

  const turtle = new Turtle(ctx);

  ctx.fillStyle = "black";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  turtle.setWidth(3);

  return turtle;
}

Осталось только реализовать функцию отрисовки, в которой будем формировать аксиому для заданного поколения и отрисовывать согласно описанным выше правилам:

function draw() {
  const turtle = createTurtle();

  axiom = getAxiom(generation, axiom);

  for (let char of axiom) {
    if (char === "A") {
      turtle.left(angle);
      turtle.forward(step);
    } else if (char === "B") {
      turtle.right(angle);
      turtle.forward(step);
    }
  }
}

При step = 40, angle = 60 и generation = 14 получаем следующий результат:

Результат для 14 поколения

Судя по рекурсивной природе L-систем, множество малых многоугольников будут формировать один большой. Таким образом, повышая поколения, мы можем получить большой многоугольник. Однако стоит учесть, что значение аксиомы растет очень быстро, как и число вычислений.

Треугольник Серпинского

Давайте теперь рассмотрим другой набор правил и попробуем получить треугольник Серпинского. Для этого возьмем следующий набор правил:

Переменные: F и G

Константы: + и -

Стартовая аксиома: F

Правило 1: "F" — F-G+F+G-F

Правило 2: "G" — G-G

Здесь F и G обозначает рисование отрезка, + — поворот угла направо и - — поворот угла налево на 120 градусов.

Изменим функцию применения правил, чтобы учесть константные значения:

function applyRules(axiom) {
  let result = "";

  for (let char of axiom) {
    const rule = rules[char];
    result += rule != null ? rule : char;
  }

  return result;
}

И перепишем главный цикл под новые правила, где char1 и char2 — это F и G соответственно:

function draw() {
  const turtle = createTurtle();

  axiom = getAxiom(generation, axiom);

  for (let char of axiom) {
    if (char === char1 || char === char2) {
      turtle.forward(step);
    } else if (char === "+") {
      turtle.right(angle);
    } else if (char === "-") {
      turtle.left(angle);
    }
  }
}

В результате получаем треугольник Серпинского:

Треугольник Серпинского

Это фрактал, двухмерный аналог множества Кантора. Реализация получения треугольника Серпинского через L-систему — очень интересная задача, поскольку обычно его получают методом хаоса.

Кривая дракона

Теперь получим другую интересную кривую, которая называется кривая дракона. Для этого определим новые правила следующего вида:

Переменные: X и Y

Константы: F, + и -

Стартовая аксиома: FX

Правило 1: "X" — X+YF+

Правило 2: "Y" — -FX-Y

Здесь X и Y обозначают рисование отрезка, + — поворот угла направо и - — поворот угла налево на 120 градусов.

В результате получаем интересную кривую под названием дракон Хартера-Хейтуэя:

Снежинка Коха

Для визуализации снежинки Коха зададим следующий набор правил:

Переменные: F

Константы: + и -

Стартовая аксиома: F++F++F

Правило 1: "F" — F-F++F-F

Здесь F обозначает рисование отрезка, + — поворот угла направо и - — поворот угла налево на 60 градусов.

В результате получим следующие снежинки для первого, второго и третьего поколений:

Снежинки Коха
Первое поколение
Второе поколение
Третье поколение

Данная фрактальная кривая примечательна тем, что это кривая бесконечной длины. Однако есть еще более интересный вид правил: так называемые L-системы со скобками. Такие системы позволяют строить растения, которые выглядят очень реалистично.

L-системы со скобками

Для реализации L-системы со скобками зададим следующий набор правил:

Переменные: X и F

Константы: [, +, ] и -

Стартовая аксиома: X

Правило 1: "X" — F[+X]F[-X]+X

Правило 2: "F" — FF

Здесь X и F обозначают рисование отрезка, [ — соответствует сохранению текущих значений позиции и угла, которые восстанавливаются, когда появляется символ ], + — поворот угла направо и - — поворот угла налево на 22.5 градусов.

Для реализации подобных правил нам нужно внести некоторые изменения в код, а именно реализовать стек для выполнения условия со скобками. В случае, когда символ равен [, мы будем сохранять текущую позицию и угол и при появлении символа ] будем восстанавливать позицию. Таким образом, основной цикл примет вид:

function draw() {
  const turtle = createTurtle();

  axiom = getAxiom(generation, axiom);

  for (let char of axiom) {
    if (char === char1 || char === char2) {
      turtle.forward(step);
    } else if (char === "+") {
      turtle.right(angle);
    } else if (char === "-") {
      turtle.left(angle);
    } else if (char === "[") {
      stack.push({
        position: turtle.position,
        angle: turtle.angle,
      });
    } else if (char === "]") {
      const state = stack.pop();
      turtle.setAngle(state.angle);
      turtle.putPenUp();
      turtle.goto(state.position.x, state.position.y);
      turtle.putPenDown();
    }
  }
}

В результате получим изображение, которое очень близко напоминает укроп:

Растение для 5 поколения

Итого: мы получили довольно интересные визуализации, а также узнали, что такое L-системы и черепашья графика.

В дальнейших статьях мы рассмотрим принципы создания сцен и анимацию спрайтов, а также немного поговорим о шейдерах. До скорых встреч!