Одна формула, позволяющая понять 3D-графику
- четверг, 5 марта 2026 г. в 00:00:04

Учась в школе, я обнаружил очень простую математическую формулу, о которой не перестаю думать и сегодня. Смысл её в следующем: представьте, что у вас есть 3D-точка в воображаемом 3D-пространстве за экраном. Для проецирования этой 3D-точки на экран нужно взять её координату X, поделённую на Z, и аналогично её Y / Z. И в результате вы получите проекцию точки на экран: и
. А если у вас есть множество точек в этом 3D-пространстве за экраном, и вы начнёте их анимировать и вращать их, а потом воспользуетесь этой формулой для рендеринга всех точек на экране, то это будет выглядеть, как 3D-сцена или 3D-объект. Давайте попробуем эту формулу в деле.
Для этого мы воспользуемся веб-технологиями. Создадим очень простую HTML-страницу. Я помещу на неё canvas с ID, например, game. Также мы загрузим отдельный скрипт index.js.
<canvas id="game"></canvas> <script src="index.js"></script>
В index.js мы просто будем выводить canvas. Особенность ID HTML-элементов заключается в том, что это валидные имена переменных в JavaScript, поэтому можно просто использовать их непосредственно, не нужно применять document.getElementById.
Дальше нужно создать 2D-контекст, который позволит нам выполнять рендеринг на этом canvas. Теперь попробуем отрисовать что-нибудь на нём, допустим, зелёный квадрат, а фон сделаем серым, и эти цвета запишем в отдельные константы. Код очистки canvas выделим в функцию clear. Теперь каждый раз, когда мне нужно будет очищать экран, я буду вызывать эту функцию. Чтобы размещать точки на экране, тоже создадим функцию point. Она будет принимать значения X и Y, а затем отрисовывать точку размера s.
const BACKGROUND = "#101010" const FOREGROUND = "#50FF50" console.log(game) game.width = 800 game.height = 800 const ctx = game.getContext("2d") console.log(ctx) function clear() { ctx.fillStyle = BACKGROUND ctx.fillRect(0, 0, game.width, game.height) } function point({x, y}) { const s = 20; ctx.fillStyle = FOREGROUND ctx.fillRect(x - s/2, y - s/2, s, s) }
Формула подразумевает использование на экране конкретной системы координат: точка начала координат (0, 0, 0) находится в центре экрана. Ось Y идёт вертикально, ось X — горизонтально. С левого края экрана находится X = -1, с правого — X = 1. Наверху находится Y = 1, внизу — Y = -1.

Но в HTML canvas система координат работает иначе: точка начала координат находится в левом верхнем углу, X увеличивается вправо, а Y вниз. В этой системе координат наша простая формула работать не будет.

То есть нам нужна некая функция, которая будет брать точку в системе координат формулы и преобразовывать её в экранные координаты. Давайте назовём её screen. Она будет принимать точку в виде объекта P, и этот объект будет иметь два поля, X и Y. Это будет наш формат векторов. На данный момент X и Y нашей точки могут иметь значения от -1 до 1, но нам нужно преобразовать их в значения от 0 до width или height. Как это сделать? Возьмём x и прибавим к нему единицу, теперь диапазон её значений будет от 0 до 2. Теперь его можно легко нормализовать, поделив на 2, по сути, получив диапазон от 0 до 1. Потом мы умножаем этот диапазон на width или height, получив точку в экранных координатах. Так как Y увеличивается вниз, нужно сначала перевернуть эту координату, вычтя её из единицы.
function screen(p) { // -1..1 => 0..2 => 0..1 => 0..w return { x: (p.x + 1)/2*game.width, y: (1 - (p.y + 1)/2)*game.height, } }
Давайте теперь создадим точку с координатами (0, 0), спроецируем её на экран и отрендерим.

А теперь давайте реализуем нашу формулу. Создадим функцию project, которая будет принимать трёхмерную точку p. И создадим двухмерную точку, координата X которой равна x / z, а координата Y равна y / z. Возьмём точку (0, 0, 0), спроецируем её на 2D-дисплей, а затем спроецируем её на экран.
function project({x, y, z}) { return { x: x/z, y: y/z, } } clear() point(screen(project({x: 0, y: 0, z: 0})))
При этом мы ничего не увидим, и это логично, потому что z равно нулю, то есть у нас получается деление на ноль. Эта формула подразумевает, что наш глаз находится в нуле. z = z означает, что объект (точка) находится точно в нашем глазу, то есть её некуда проецировать. Значит, она должна быть на расстоянии от нас, предпочтительно за экраном. Присвоим z какое-то другое значение, например, 1. И теперь мы увидим точку. При этом вот, что интересно: если сделать z равной, например, 2, то ничего не поменяется, потому что точка смотрит прямо на нас. Даже если сдвинуть её, допустим, по оси X, это всё равно не покажет нам полной картины.
Чтобы увидеть её, нам нужно анимировать точку. Пусть она сдвигается от нас по оси Z. Обернём наш код в функцию frame и будем выполнять её с заданным таймаутом. Пусть она анимируется с частотой примерно 60 fps, для этого нужно 1000 миллисекунд поделить на FPS. Добавим переменную dz, которая будет отслеживать смещение точки по оси Z. Точка будет находиться в координате 1 плюс смещение. В каждом кадре мы будем увеличивать это смещение, допустим, на 1. Также будет здорово синхронизировать это с таймингом, поэтому выполним умножение на дельту времени, равную 1 / FPS. Здесь 1 — это одна секунда, которую мы делим на частоту кадров, и это, по сути, разность времени между кадрами.
const FPS = 60; let dz = 0; function frame() { const dt = 1/FPS; dz += 1*dt; clear() point(screen(project({x: 0.5, y: 0, z: 1 + dz}))) setTimeout(frame, 1000/FPS); } setTimeout(frame, 1000/FPS);
Если мы запустим код, то точка будет двигаться справа налево. И это вполне логично: когда мы смотрим в реальном мире на движущиеся объекты, то чем дальше они от нас становятся, тем ближе к центру, к так называемой точке схода. И именно это мы видим у нас. Но на примере одной точки мы снова не можем увидеть картину по-настоящему. Давайте добавим ещё одну точку, которая смещена от центра влево.
const FPS = 60; let dz = 0; function frame() { const dt = 1/FPS; dz += 1*dt; clear() point(screen(project({x: 0.5, y: 0, z: 1 + dz}))) point(screen(project({x: -0.5, y: 0, z: 1 + dz}))) setTimeout(frame, 1000/FPS); } setTimeout(frame, 1000/FPS);
Теперь мы видим, что они приближаются к центру с обеих сторон.
Если сдвинуть их немного выше, то они будут приближаться к точке схода сверху.
Давайте создадим ещё пару точек, которые движутся со всех сторон.
Можно воспринимать эти четыре точки, как удаляющуюся от нас поверхность. Именно это здесь и происходит.
Давайте возьмём все эти точки и сохраним их в каком-нибудь массиве. Назовём его vs (сокращённо от vertices, «вершины»), потому что в будущем мы будем добавлять всё больше точек.
const vs = [ {x: 0.5, y: 0.5, z: 1}, {x: -0.5, y: 0.5, z: 1}, {x: 0.5, y: -0.5, z: 1}, {x: -0.5, y: -0.5, z: 1}, ]
То есть, по сути, мы будем выполнять итерацию по каждому отдельному v из vs, но нам нужно смещать эту v на dz. Введём для этого функцию translate_z, принимающую x, y, z и возвращающую точку с изменившейся на dz координатой z.
function translate_z({x, y, z}, dz) { return {x, y, z: z + dz}; } function frame() { const dt = 1/FPS; dz += 1*dt; clear() for (const v of vs) { point(screen(project(translate_z(v, dz)))) } setTimeout(frame, 1000/FPS); } setTimeout(frame, 1000/FPS);
Итак, в каждом кадре мы итеративно обходим массив точек, меняя их координаты z на переменную, далее проецируем эту 3D-точку на 2D-дисплей, затем этот 2D-дисплей проецируем на экран и помещаем всё это на экран.
Теперь давайте сместим ближе к экрану наши точки и добавим в массив ещё одну плоскость, находящуюся перед экраном.
const vs = [ {x: 0.5, y: 0.5, z: 0.5}, {x: -0.5, y: 0.5, z: 0.5}, {x: 0.5, y: -0.5, z: 0.5}, {x: -0.5, y: -0.5, z: 0.5}, {x: 0.5, y: 0.5, z: -0.5}, {x: -0.5, y: 0.5, z: -0.5}, {x: 0.5, y: -0.5, z: -0.5}, {x: -0.5, y: -0.5, z: -0.5} ]
Мы как будто находимся внутри этого куба. Если анимировать dz, то мы увидим куб целиком, он будет удаляться от нас
Но пока всё ещё не совсем очевидно, что это куб. Давайте сделаем с ним что-нибудь более интересное. Будем вращать его вместо перемещения. Я хочу, чтобы вращение происходило по оси Y, то есть в плоскости XZ. Функция rotate_xz будет принимать координаты точки и угол. Как нам выполнить вращение вектора? Функция вращения выглядит так:

Она довольно запутанная, потому что это одна из тех формул, которые нужно запомнить и не пытаться понять. Можно легко доказать, что 2D-точка будет выглядеть именно так при повороте на угол φ (фи), но даже если вы это докажете, то это всё равно не объяснит, почему дело обстоит именно так. Это один из тех моментов, когда нужно просто взять формулу и выполнять по ней вычисления. Если хотите, можете посмотреть короткое пятиминутное видео с доказательством, я крайне рекомендую его.
А пока мы просто воспользуемся формулой и напишем нашу функцию, заменив при этом Y на Z, потому что мы выбрали для вращения плоскость XZ:
function rotate_xz({x, y, z}, angle) { const c = Math.cos(angle); const s = Math.sin(angle); return { x: x*c-z*s, y, z: x*s+z*c, }; }
В функции frame введём переменную angle для отслеживания угла. Изначально она будет равна нулю. Возьмём полную окружность, то есть 2*pi, и умножим её на dt, то есть один полный поворот будет выполняться за одну секунду.
function frame() { const dt = 1/FPS; angle += 2*Math.PI*dt; clear() for (const v of vs) { point(screen(project(translate_z(rotate_xz(v, angle), dz)))) } setTimeout(frame, 1000/FPS); } setTimeout(frame, 1000/FPS);
Куб слишком близко к нам, давайте сделаем его вдвое меньше и вдвое уменьшим скорость вращения
const vs = [ {x: 0.25, y: 0.25, z: 0.25}, {x: -0.25, y: 0.25, z: 0.25}, {x: 0.25, y: -0.25, z: 0.25}, {x: -0.25, y: -0.25, z: 0.25}, {x: 0.25, y: 0.25, z: -0.25}, {x: -0.25, y: 0.25, z: -0.25}, {x: 0.25, y: -0.25, z: -0.25}, {x: -0.25, y: -0.25, z: -0.25} ]
angle += Math.PI*dt;
И теперь это действительно выглядит, как вращающийся куб, но ещё не совсем.
Нам стоит соединить все эти вершины линиями. Давайте добавим функцию line, рисующую отрезки, она будет получать точки концов отрезка p1 и p2. Отрезки в HTML рисуются при помощи контуров (path). Это чем-то похоже на черепашью графику. Сначала мы перемещаем черепашку в точку p1, потом создаём отрезок из неё в точку p2, но этого недостаточно, нужно создать ещё и штрих (stroke).
function line(p1, p2) { ctx.strokeStyle = FOREGROUND ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); }
Теперь нам нужно задать способ соединения этих вершин. Добавим ещё один массив, который назовём fs (сокращение от faces, то есть «грани»). Каждая отдельная грань в нём сама по себе будет массивом индексов, соединяемых в виде полигона.
const fs = [ [0, 1, 2, 3], [4, 5, 6, 7], ]
Рендерить всё это мы будем, итеративно обходя все грани. И для каждой грани мы смотрим на текущий индекс. Нам нужно соединять текущую вершину и следующую, допустим, 0 и 1, 1 и 2 и так далее. Когда я дойду до конца, мне нужно будет вернуться в начало, то есть соединить 3 и 0. То есть это будет своего рода замкнутый контур. Итерации будут выполняться до размера массива + 1. Но тут нужно быть аккуратными, если индекс будет последним, то произойдёт переполнение буфера. Что же нам делать. Нужно вернуться назад при помощи модульной арифметики. Если индекс последний, мы возвращаемся и снова указываем на ноль. Чтобы отрезки были видны, нужно задать стиль штриха, потому что пока он чёрный. А чтобы отрезки были не такими тонкими, их можно сделать потолще с помощью ctx.lineWidth в функции line.
function frame() { const dt = 1/FPS; // dz += 1*dt; angle += Math.PI*dt; clear() // for (const v of vs) { // point(screen(project(translate_z(rotate_xz(v, angle), dz)))) // } for (const f of fs) { for (let i = 0; i < f.length; ++i) { const a = vs[f[i]]; const b = vs[f[(i+1)%f.length]]; line(screen(project(translate_z(rotate_xz(a, angle), dz))), screen(project(translate_z(rotate_xz(b, angle), dz)))) } } setTimeout(frame, 1000/FPS); } setTimeout(frame, 1000/FPS);
Всё стало красивее, но выглядит не очень правильно. Мы соединяем вершины не в том порядке. Поменяем его в массиве вершин.
const vs = [ {x: 0.25, y: 0.25, z: 0.25}, {x: -0.25, y: 0.25, z: 0.25}, {x: -0.25, y: -0.25, z: 0.25}, {x: 0.25, y: -0.25, z: 0.25}, {x: 0.25, y: 0.25, z: -0.25}, {x: -0.25, y: 0.25, z: -0.25}, {x: -0.25, y: -0.25, z: -0.25}, {x: 0.25, y: -0.25, z: -0.25} ]
Теперь нам нужно соединить пары вершин двух плоскостей, чтобы это действительно выглядело, как куб.
const fs = [ [0, 1, 2, 3], [4, 5, 6, 7], [0, 4], [1, 5], [2, 6], [3, 7] ]
Можно сделать его ещё лучше, отказавшись от рисования вершин.
function frame() { const dt = 1/FPS; // dz += 1*dt; angle += Math.PI*dt; clear() // for (const v of vs) { // point(screen(project(translate_z(rotate_xz(v, angle), dz)))) // } for (const f of fs) { for (let i = 0; i < f.length; ++i) { const a = vs[f[i]]; const b = vs[f[(i+1)%f.length]]; line(screen(project(translate_z(rotate_xz(a, angle), dz))), screen(project(translate_z(rotate_xz(b, angle), dz)))) } } setTimeout(frame, 1000/FPS); } setTimeout(frame, 1000/FPS);
Итак, мы получили каркасную модель куба. Замечательная формула, правда?
Но возникает вопрос: почему эта формула вообще работает? Давайте рассмотрим сцену, которую моделирует формула. Наш глаз расположен в нуле, а экран в единице. Нам нужно взять точку P и направить луч из этой точки в глаз, в результате получив точку P' на экране. Можно заметить, что у нас получилось два треугольника, большой и малый. Как видите, они имеют одинаковые углы, то есть эти треугольники подобны. Определим точку P, как (X, Y, Z), а P', как (X', Y'). Поскольку эти треугольники подобны, отношение между 1 (расстоянием до экрана) и значением Z точки P равно отношению X' или Y' (в зависимости от того, что мы хотим найти) точки P' к X или Y точки P.

Немного преобразовав эти выражения, получим:
Интересно во всём этом то, что мы получили очень простой 3D-движок, способный рендерить модели произвольной сложности. Достаточно лишь найти подходящие вершины и грани. Я подготовил более сложную модель, состоящую приблизительно из 326 вершин и 626 граней. Если вставить всю модель вместо куба, то мы получим следующее.
Стоит отметить, что мы не пользовались здесь OpenGL, WebGL, WebGPU или чем-то подобным. Нам понадобился лишь 2D-контекст HTML и очень простая формула.
Github проекта: https://github.com/tsoding/formula
Модель пингвина Penger: https://github.com/Max-Kawula/penger-obj