Пишем свой текстовый 3D движок в браузере
- пятница, 17 апреля 2026 г. в 00:00:08
Сразу скажу: это перевод моей же статьи на Medium, но с небольшими дополнениями и более практичным разбором реализации.
Вот ссылки на демо‑страницу проекта и мой GitHub:
Когда я впервые решил поэкспериментировать с 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-текст.
С самого начала, я решил попробовать генерировать текст в 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.
Самое пугающее в 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-движках, только в максимально упрощённом виде.
Вместо того чтобы рисовать в <canvas>, движок рисует в двумерный символьный буфер.
Внутренне это выглядит примерно так:
chars[y][x] depth[y][x]
chars хранит символ, который нужно показать в конкретной ячейке.depth хранит глубину для этой же позиции.
Это простая версия Z-буфера: если в ту же клетку пытается попасть другая линия, движок сравнивает глубину и оставляет ближайший фрагмент. В engine.js буфер действительно создаётся как две двумерные структуры: для символов и для глубины.
Когда линия рисуется на экран, координаты и глубина интерполируются по шагам. На каждом шаге происходит примерно следующее:
вычисляется текущая позиция
сравнивается текущий z с уже сохранённым значением в буфере
если текущая точка ближе к камере, символ заменяется
То есть даже в ASCII-рендерере у нас есть нормальная проверка глубины.
Чтобы картинка выглядела объёмнее, я добавил простое затенение на основе глубины. Идея в том, чтобы использовать разные символы в зависимости от того, насколько близко находится линия.
Например:
const SHADE_CHARS = ' ·∙░▒▓█'
Ближние рёбра можно рисовать более “тяжёлыми” символами вроде █, а дальние — более лёгкими, вроде ·. В репозитории действительно используется выбор символа по позиции внутри строки SHADE_CHARS.
Это не физически корректное освещение. Здесь нет нормалей, источников света или BRDF. Но даже такой простой градиент отлично усиливает ощущение объёма.
Один из самых важных моментов в таких маленьких самодельных рендерах — near clipping, то есть отсечение геометрии слишком близко к камере.
Проблема в том, что если линия пересекает область около z = 0, перспективная проекция начинает вести себя нестабильно. Деление на очень маленькое число даёт огромные скачки координат, и изображение буквально “взрывается”.
Поэтому перед проекцией рёбра нужно обрезать по ближней плоскости:
x' = x * cos(θ) - z * sin(θ)z' = x * sin(θ) + z * cos(θ)
Если одна вершина находится перед этой плоскостью, а другая — за ней, можно просто интерполировать новую точку ровно на границе. В твоём репозитории это уже реализовано внутри drawLineToBuffer.
Это защищает рендер от трёх типичных проблем:
бесконечно больших проекций
“взрывающейся” геометрии
мерцающих артефактов
На мой взгляд, именно такие мелочи сильнее всего приближают самодельный движок к чему-то “настоящему”.
Весь цикл анимации здесь максимально простой:
const render = () => { clearBuffer() const transformed = transformModel(model) renderModel(transformed) flushBuffer(container) requestAnimationFrame(render) }
В репозитории это оформлено чуть более структурно, но идея ровно та же: пересчитать угол, преобразовать вершины, отрисовать модель, вывести буфер и запросить следующий кадр через requestAnimationFrame.
Именно этим мне и нравится такой подход: здесь нет GPU, нет WebGL-контекста, нет тяжёлого графического стека. Только математика, буфер и текст.
Самое полезное, что даёт такой проект, — он убирает ощущение, будто 3D — это “чёрная магия”.
На практике оказывается, что:
перспектива — это обычное деление
вращение — это тригонометрия
depth buffer — это просто сравнение значений
а сам 3D-движок может занимать совсем немного кода
Современные графические движки кажутся сложными не потому, что их основы непостижимы, а потому что поверх этих основ построено очень много дополнительных слоёв абстракции.
Когда один раз руками реализуешь поворот, проекцию, буфер глубины и отрисовку линий, 3D перестаёт пугать. Оно становится не магией, а обычной инженерной задачей.
И, пожалуй, в этом и есть главный кайф таких маленьких проектов.
P.S.
Я не мастер писать статьи, но думаю исходники помогут лучше лучше помочь с понимаением. Если что, готов ответить на вопросы.