Oblina Labs

Килим Серпінського

Як побудувати рекурсивний фрактал на Canvas і додати глибокий zoom

Килим Серпінського — це фрактал, який виникає з одного простого правила. Беремо квадрат, ділимо його на дев’ять однакових частин, прибираємо центральну, а потім повторюємо ті самі дії для восьми квадратів, що залишилися.

Після першого кроку ми бачимо лише один виріз. Але з кожним повторенням усередині квадратів з’являються нові, дедалі менші отвори. Так із простої операції поступово утворюється складний геометричний візерунок.

У цій статті ми побудуємо Килим Серпінського на Canvas: почнемо з одного центрального вирізу, знайдемо координати восьми дочірніх квадратів і перетворимо повторення цього правила на рекурсію. Потім додамо камеру та zoom, щоб наближати окремі частини фрактала й автоматично відкривати нові рівні деталізації.

Спробуйте самі

Наведіть курсор на частину фрактала, яку хочете роздивитися, і прокрутіть колесо миші. Килим наближатиметься саме до точки під курсором, а його ефективна глибина автоматично збільшуватиметься разом із масштабом.

На мобільному пристрої використовуйте жест двома пальцями. Кнопка Скинути вигляд повертає камеру до початкового положення.

Інтерактивний приклад

Килим Серпінського

Наближайте фрактал коліщатком миші або жестом двома пальцями. Деталізація автоматично збільшується разом із масштабом.

Ваш браузер не підтримує Canvas.

Ефективна глибина:5

Колесо миші або жест двома пальцями — масштабування.

Початкова глибина фрактала дорівнює 5, а під час наближення може збільшуватися до 24 рівнів.

Від одного вирізу до восьми квадратів

Почнемо з одного великого квадрата. Щоб знайти його центральну частину, ділимо сторону на три:

function drawCenterCutout(
  x: number,
  y: number,
  size: number,
) {
  const partSize = size / 3;
  const centerX = x + partSize;
  const centerY = y + partSize;

  context.fillStyle = "#d9f56f";
  context.fillRect(centerX, centerY, partSize, partSize);
}

Параметри x і y містять координати верхнього лівого кута поточного квадрата, а size — довжину його сторони. Тому сторона однієї частини дорівнює size / 3.

Центральний квадрат розташований на одну частину правіше й на одну частину нижче від початкової точки. Отже, до x та y потрібно додати partSize.

Але для побудови фрактала недостатньо знайти лише центр. Нам також потрібні координати восьми частин, які залишилися навколо нього. Уявімо квадрат як сітку з трьох рядків і трьох колонок:

const partSize = size / 3;

for (let row = 0; row < 3; row++) {
  for (let column = 0; column < 3; column++) {
    if (row === 1 && column === 1) {
      continue;
    }

    const childX = x + column * partSize;
    const childY = y + row * partSize;
  }
}

Зовнішній цикл проходить по рядках, а внутрішній — по колонках. Координати кожної частини залежать від її положення в сітці: номер колонки зміщує квадрат по горизонталі, а номер рядка — по вертикалі.

Комірка з індексами row === 1 і column === 1 розташована в центрі. Вона вже є вирізом, тому пропускаємо її за допомогою continue. У результаті отримуємо координати восьми дочірніх квадратів.

Повторюємо правило за допомогою рекурсії

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

Замість того щоб вручну повторювати однакові дії, функція drawCarpet() викликає саму себе з новими координатами та меншим розміром:

function drawCarpet(
  x: number,
  y: number,
  size: number,
  depth: number,
) {
  drawCenterCutout(x, y, size);

  if (depth <= 1) {
    return;
  }

  const partSize = size / 3;

  for (let row = 0; row < 3; row++) {
    for (let column = 0; column < 3; column++) {
      if (row === 1 && column === 1) {
        continue;
      }

      const childX = x + column * partSize;
      const childY = y + row * partSize;

      drawCarpet(childX, childY, partSize, depth - 1);
    }
  }
}

Кожен рекурсивний виклик отримує власні значення x, y і size. Дочірній квадрат стає новим поточним квадратом, а його сторона дорівнює третині сторони батьківського.

Параметр depth показує, скільки рівнів залишилося побудувати. Під час переходу до дочірнього квадрата передаємо depth - 1. Коли значення доходить до 1, центральний виріз уже намальовано, але нові рекурсивні виклики більше не створюються.

За глибини 1 маємо один центральний виріз. За глибини 2 вирізи з’являються також у восьми дочірніх квадратах, а за глибини 3 правило повторюється ще раз. Так проста функція поступово будує весь фрактал.

Світові координати й камера

Поки фрактал повністю займав Canvas, його можна було малювати безпосередньо в пікселях. Але для zoom цього недостатньо: потрібно окремо зберігати сам фрактал і те, яку його частину зараз бачить користувач.

Тому перенесемо килим у світову систему координат. У ній весь фрактал завжди займає квадрат від 0 до 1, а його центр розташований у точці (0.5, 0.5). Ці значення не залежать від розміру Canvas або поточного масштабу.

Стан видимої області зберігатимемо в об’єкті камери:

type Camera = {
  centerX: number;
  centerY: number;
  scale: number;
};

const camera: Camera = {
  centerX: 0.5,
  centerY: 0.5,
  scale: 1,
};

centerX і centerY визначають світову точку, розташовану в центрі Canvas. Значення scale визначає масштаб: за значення 1 видно весь килим, а зі збільшенням масштабу — лише його частину.

Canvas не вміє працювати зі світовими координатами, тому перед малюванням переводимо їх у пікселі:

function worldToScreenX(worldX: number) {
  return (
    (worldX - camera.centerX) *
      canvas.width *
      camera.scale +
    canvas.width / 2
  );
}

function worldToScreenY(worldY: number) {
  return (
    (worldY - camera.centerY) *
      canvas.height *
      camera.scale +
    canvas.height / 2
  );
}

function worldToScreenWidth(worldSize: number) {
  return worldSize * canvas.width * camera.scale;
}

function worldToScreenHeight(worldSize: number) {
  return worldSize * canvas.height * camera.scale;
}

Спочатку від світової координати віднімаємо координату центра камери. Так отримуємо відстань від потрібної точки до центра видимої області. Потім переводимо цю відстань у пікселі, застосовуємо масштаб і додаємо половину розміру Canvas.

Розміри квадратів переводяться простіше: світовий розмір множиться на розмір Canvas і масштаб. Під час zoom координати самого фрактала не змінюються — камера лише визначає, де і якого розміру він буде намальований на екрані.

Zoom до точки під курсором

Якщо під час прокручування колеса змінювати лише camera.scale, фрактал завжди наближатиметься відносно центра Canvas. Точка під курсором зміщуватиметься, тому керування здаватиметься неприродним.

Щоб зберегти вибрану точку на місці, потрібно вміти виконувати зворотне перетворення: знаходити світову координату, яка відповідає певному пікселю Canvas.

function screenToWorldX(screenX: number) {
  return (
    (screenX - canvas.width / 2) /
      (canvas.width * camera.scale) +
    camera.centerX
  );
}

function screenToWorldY(screenY: number) {
  return (
    (screenY - canvas.height / 2) /
      (canvas.height * camera.scale) +
    camera.centerY
  );
}

Функції screenToWorldX() і screenToWorldY() є зворотними до перетворення світових координат в екранні. Вони прибирають зміщення до центра Canvas, ділять координату на масштаб і повертають зміщення камери.

Сам zoom виконується в кілька кроків:

function zoomAt(
  screenX: number,
  screenY: number,
  multiplier: number,
) {
  const worldXBeforeZoom = screenToWorldX(screenX);
  const worldYBeforeZoom = screenToWorldY(screenY);

  camera.scale *= multiplier;
  camera.scale = Math.max(
    MIN_SCALE,
    Math.min(camera.scale, MAX_SCALE),
  );

  const worldXAfterZoom = screenToWorldX(screenX);
  const worldYAfterZoom = screenToWorldY(screenY);

  camera.centerX += worldXBeforeZoom - worldXAfterZoom;
  camera.centerY += worldYBeforeZoom - worldYAfterZoom;

  constrainCamera();
  scheduleRender();
}
  1. Знаходимо світову точку під курсором до зміни масштабу.
  2. Збільшуємо або зменшуємо масштаб камери.
  3. Знову знаходимо світову точку під тим самим пікселем.
  4. Пересуваємо центр камери на різницю між двома отриманими точками.

Завдяки цьому обрана частина фрактала залишається під курсором. На мобільному пристрої використовується той самий принцип, але точкою zoom стає середина між двома пальцями, а множник масштабу визначається зміною відстані між ними.

Динамічна глибина й оптимізація

За початкового масштабу п’яти рівнів достатньо: менші деталі майже не відрізняються на Canvas. Але під час наближення вони стають помітними. Якщо залишити глибину незмінною, збільшена частина фрактала виглядатиме як суцільний квадрат.

На кожному рівні частини килима стають утричі меншими. Тому після кожного збільшення масштабу приблизно втричі додаємо ще один рівень деталізації:

const BASE_DEPTH = 5;
const MAX_DEPTH = 24;

function getEffectiveDepth(scale: number) {
  const additionalLevels = Math.floor(
    Math.log(scale) / Math.log(3),
  );

  return Math.min(
    MAX_DEPTH,
    BASE_DEPTH + additionalLevels,
  );
}

За масштабу 1 ефективна глибина дорівнює 5. За масштабів 3, 9 і 27 вона збільшується до 6, 7 і 8. Значення не може перевищити 24.

Проте побудувати весь Килим Серпінського до 24 рівня неможливо: кожен квадрат створює ще вісім дочірніх квадратів. Тому рекурсія продовжується лише там, де її результат можна побачити.

const isOutsideCanvas =
  screenX + screenWidth <= 0 ||
  screenY + screenHeight <= 0 ||
  screenX >= canvas.width ||
  screenY >= canvas.height;

if (isOutsideCanvas) {
  return;
}

const cutoutWidth = screenWidth / 3;
const cutoutHeight = screenHeight / 3;

if (cutoutWidth < 1 || cutoutHeight < 1) {
  return;
}

Якщо квадрат повністю розташований за межами Canvas, функція завершує цю гілку рекурсії. Вона не малює квадрат і не переходить до його дочірніх частин.

Рекурсія також зупиняється, коли наступний центральний виріз стає меншим за один піксель. Подальші рівні вже не додадуть видимих деталей, але вимагатимуть значно більше обчислень.

Події колеса та руху пальців можуть надходити швидше, ніж браузер встигає перемальовувати екран. Тому оновлення планується через requestAnimationFrame(): протягом одного кадру програма виконує лише одне перемальовування з найновішим станом камери.

Висновок

Килим Серпінського показав, як із простого правила може виникнути складна графіка. Для побудови фрактала достатньо поділити квадрат на дев’ять частин, пропустити центральну й повторити ті самі дії для восьми інших квадратів. Рекурсія дозволила описати це повторення однією функцією.

Найскладнішою частиною для мене виявилася не сама рекурсія, а zoom. Щоб наближення відбувалося до точки під курсором, довелося розділити світові й екранні координати, навчитися перетворювати їх в обидва боки та змінювати положення камери після кожної зміни масштабу.

Також було неочевидно, що максимальна глибина 24 не означає, що програма щоразу будує весь фрактал до цього рівня. Вона продовжує рекурсію лише для видимих частин і зупиняється, коли деталі стають меншими за один піксель.

У результаті один приклад поєднав роботу з Canvas, вкладеними координатами, рекурсією, станом камери, масштабуванням і оптимізацією рендеру. Ці самі принципи можна використати не лише для фракталів, а й для карт, ігор, графічних редакторів та інших інтерактивних просторів.