javascript

Пишем свой текстовый 3D движок в браузере

  • пятница, 17 апреля 2026 г. в 00:00:08
https://habr.com/ru/articles/1024010/

Сразу скажу: это перевод моей же статьи на Medium, но с небольшими дополнениями и более практичным разбором реализации.

TL;DR

Вот ссылки на демо‑страницу проекта и мой GitHub:

  1. Github

  2. Demo

Когда я впервые решил поэкспериментировать с 3D в браузере, мне казалось, что это что-то очень сложное: матрицы, движки, WebGL, куча формул. Но на практике, чтобы собрать простой 3D-рендерер, достаточно базовой тригонометрии, понимания перспективы и пары аккуратных преобразований координат.

По сути, вся “магия” перспективы сводится к одной простой функции:

export const normalizeZ = ({ x, y, z }) => {
  if (z <= 0) {
    return { x: 0, y: 0 }
  }

  return { x: x / z, y: y / z }
}

Идея очень простая: чем дальше объект от камеры, тем ближе его проекция к центру экрана. Именно это и создаёт ощущение глубины. В репозитории эта функция действительно используется как часть пайплайна проекции 3D-точки в 2D.


Что здесь вообще происходит

Этот проект работает без привычного графического стека:

  • без WebGL

  • без сторонних 3D-библиотек

  • только на математике и возможностях браузера

Несмотря на это, на выходе получается вполне живой ASCII/текстовый 3D-рендерер. В публичном API он оформлен как маленький браузерный рендерер текста: передаёшь контейнер, размеры и строку и получаешь анимированный 3D-текст.


Шаг 1. Как представить текст в 3D

С самого начала, я решил попробовать генерировать текст в 3D.

Я не стал генерировать полноценные 3D-меши и не использовал триангуляцию шрифтов. Вместо этого выбрал гораздо более простой путь: каждая буква описывается как набор точек и связей между ними.

Упрощённо модель выглядит так:

type Point3D = {
  x: number
  y: number
  z: number
}

А сама геометрия хранится примерно в таком виде:

{
  vertices: Point3D[],
  edges: [number, number][]
}

То есть у нас есть вершины и есть рёбра, которые говорят, какие вершины нужно соединять линиями. В коде репозитория именно такая структура и используется для моделей.

Сами буквы можно строить по-разному. В раннем варианте я фактически брал 2D-форму символа и “выдавливал” её вдоль оси Z, превращая плоскую букву в объёмный объект. В текущем проекте эта идея уже расширилась: внутри есть и экструзия толстых контуров, и более универсальная voxel-like модель текста на основе пиксельного 3x5-шрифта.

По сути, это делает движок чем-то средним между wireframe-рендерером и простым ASCII voxel text renderer.


Шаг 2. Поворот в 3D

Самое пугающее в 3D на старте — это вращение. На деле всё гораздо проще: это обычные синусы и косинусы.

Например, поворот вокруг оси Y:

x' = x * cos(θ) - z * sin(θ)
z' = x * sin(θ) + z * cos(θ)

Поворот вокруг оси X:

y' = y * cos(θ) - z * sin(θ)
z' = y * sin(θ) + z * cos(θ)

В каждом кадре происходит одна и та же последовательность действий:

  • берём исходные вершины

  • применяем масштабирование

  • поворачиваем модель

  • смещаем её относительно камеры

  • проецируем в 2D

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

Это, по сути, тот же пайплайн, что и в “настоящих” 3D-движках, только в максимально упрощённом виде.


Шаг 3. ASCII-фреймбуфер

Вместо того чтобы рисовать в <canvas>, движок рисует в двумерный символьный буфер.

Внутренне это выглядит примерно так:

chars[y][x]
depth[y][x]

chars хранит символ, который нужно показать в конкретной ячейке.
depth хранит глубину для этой же позиции.

Это простая версия Z-буфера: если в ту же клетку пытается попасть другая линия, движок сравнивает глубину и оставляет ближайший фрагмент. В engine.js буфер действительно создаётся как две двумерные структуры: для символов и для глубины.

Когда линия рисуется на экран, координаты и глубина интерполируются по шагам. На каждом шаге происходит примерно следующее:

  • вычисляется текущая позиция

  • сравнивается текущий z с уже сохранённым значением в буфере

  • если текущая точка ближе к камере, символ заменяется

То есть даже в ASCII-рендерере у нас есть нормальная проверка глубины.


Шаг 4. Псевдозатенение символами

Чтобы картинка выглядела объёмнее, я добавил простое затенение на основе глубины. Идея в том, чтобы использовать разные символы в зависимости от того, насколько близко находится линия.

Например:

const SHADE_CHARS = ' ·∙░▒▓█'

Ближние рёбра можно рисовать более “тяжёлыми” символами вроде , а дальние — более лёгкими, вроде ·. В репозитории действительно используется выбор символа по позиции внутри строки SHADE_CHARS.

Это не физически корректное освещение. Здесь нет нормалей, источников света или BRDF. Но даже такой простой градиент отлично усиливает ощущение объёма.


Шаг 5. Ближнее отсечение

Один из самых важных моментов в таких маленьких самодельных рендерах — near clipping, то есть отсечение геометрии слишком близко к камере.

Проблема в том, что если линия пересекает область около z = 0, перспективная проекция начинает вести себя нестабильно. Деление на очень маленькое число даёт огромные скачки координат, и изображение буквально “взрывается”.

Поэтому перед проекцией рёбра нужно обрезать по ближней плоскости:

x' = x * cos(θ) - z * sin(θ)z' = x * sin(θ) + z * cos(θ)

Если одна вершина находится перед этой плоскостью, а другая — за ней, можно просто интерполировать новую точку ровно на границе. В твоём репозитории это уже реализовано внутри drawLineToBuffer.

Это защищает рендер от трёх типичных проблем:

  • бесконечно больших проекций

  • “взрывающейся” геометрии

  • мерцающих артефактов

На мой взгляд, именно такие мелочи сильнее всего приближают самодельный движок к чему-то “настоящему”.


Шаг 6. Цикл рендеринга

Весь цикл анимации здесь максимально простой:

const render = () => {
  clearBuffer()
  const transformed = transformModel(model)
  renderModel(transformed)
  flushBuffer(container)
  requestAnimationFrame(render)
}

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

Именно этим мне и нравится такой подход: здесь нет GPU, нет WebGL-контекста, нет тяжёлого графического стека. Только математика, буфер и текст.


Что мне дал этот проект

Самое полезное, что даёт такой проект, — он убирает ощущение, будто 3D — это “чёрная магия”.

На практике оказывается, что:

  • перспектива — это обычное деление

  • вращение — это тригонометрия

  • depth buffer — это просто сравнение значений

  • а сам 3D-движок может занимать совсем немного кода

Современные графические движки кажутся сложными не потому, что их основы непостижимы, а потому что поверх этих основ построено очень много дополнительных слоёв абстракции.

Когда один раз руками реализуешь поворот, проекцию, буфер глубины и отрисовку линий, 3D перестаёт пугать. Оно становится не магией, а обычной инженерной задачей.

И, пожалуй, в этом и есть главный кайф таких маленьких проектов.

P.S.

Я не мастер писать статьи, но думаю исходники помогут лучше лучше помочь с понимаением. Если что, готов ответить на вопросы.