Deltadev-math.ruподписаться
// ПРОСТРАНСТВА

Где на экране этот треугольник: пространства и матрица MVP

Как вершина находит своё место на экране: однородные координаты и число w, три матрицы Model → View → Projection, деление на w, нелинейная глубина и путь local → NDC → пиксель. Пять интерактивов в браузере.

12 июля 2026·26 мин чтения·однородные координатыMVPпроекцияделение на w
Дейв проводит вершину через пространства к экрану, Delta подсвечивает фрустум и деление на w

Всем привет! Меня зовут Гриша Дядиченко, и я технический директор и основатель White Label Games. Больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном заказная разработка и собственные прототипы.

Почти каждый, кто трогал 3D руками, ловил такое. Ставите объект в сцену по координатам — вроде всё правильно, — двигаете камеру, и он уезжает не туда. Или далёкие поверхности начинают драться за пиксель и мерцать. Или объект из-за спины камеры вдруг вылезает вывернутым прямо перед носом. Знакомо? Каждый из этих багов на самом деле про одно и то же: точка живёт не в той системе отсчёта, в какой вы думаете.

Собственно, это и есть главный вопрос статьи, и его удобно задавать одной строкой: в какой системе отсчёта живёт эта точка? Пока на него нет ответа, координаты объекта — это просто три числа без адреса.

Чтож, давайте разберём дорогу, которую проходит одна вершина от строчки в вашем коде до пикселя на экране. По пути посмотрим:

  • зачем вершине четвёртое число w и почему без него нельзя;
  • что делают три матрицы Model, View и Projection — и почему это ровно три смены системы отсчёта;
  • почему в конце всё делится на w и откуда берётся перспектива;
  • почему глубина в буфере нелинейна — и как это связано с мерцанием далёких поверхностей;
  • и где ровно ломается любимый приём «а поставлю-ка я всё руками».

Пять частей, в каждой — интерактив в браузере, который можно покрутить пальцами прямо на телефоне. Если тема близка — добро пожаловать.

// @easy_dev_math

Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.

Подписаться

Часть 1. Однородные координаты: зачем вершине четвёртое число

Начнём с приёма, с которого начинают все. «Ставлю объект в точку (x, y, z) — он там и будет». Для статичной сцены с неподвижной камерой это чистая правда и прекрасно работает. Поставили сундук по координатам, как в редакторе Skyrim, — он стоит где поставили. Это не ошибка, это рабочий инструмент со своей областью применимости.

Ломаться приём начинает, когда точку надо переносить между системами отсчёта: из локального пространства объекта в мировое, из мирового — в пространство камеры, из камеры — на экран. Вот тут и появляется загадочное четвёртое число.

Точка против направления

Сначала — главный сдвиг в голове. В коде одинаково выглядят два совершенно разных зверя. Оба — три числа.

Первый зверь — точка. У неё есть «где»: позиция объекта, вершина меша, место попадания. Второй — направление (вектор). У него есть только «куда»: нормаль поверхности, направление света, ось вращения. У направления нет места в пространстве — оно одно и то же и в углу сцены, и в её центре.

Как их различить? Простейшая проверка — сдвиг. Передвинуть точку на пять метров вправо — осмысленно, она переехала. А передвинуть направление на пять метров вправо — бессмыслица: направление «вверх» остаётся «вверх», куда его ни неси. Значит, перенос обязан действовать на точки и не трогать направления.

Как одна матрица делает и то, и другое

И вот ради этого к трём числам дописывают четвёртое, w. Точка получает w = 1, направление — w = 0. Дальше всё преобразование записывается матрицей 4×4: в левом верхнем углу — линейная часть (поворот и масштаб), справа отдельным столбцом — перенос t.

(Rt01)(pw)=(Rp+wtw)\begin{pmatrix}\mathbf{R} & \mathbf{t} \\ \mathbf{0} & 1\end{pmatrix}\begin{pmatrix}\mathbf{p} \\ w\end{pmatrix} = \begin{pmatrix}\mathbf{R}\,\mathbf{p} + w\,\mathbf{t} \\ w\end{pmatrix}

Посмотрите на правую часть: перенос t домножается на w. У точки w = 1 — перенос применяется целиком. У направления w = 0 — перенос обнуляется. Одна и та же матрица корректно двигает точки и оставляет направления на месте. Собственно, для этого w и нужен.

Покрутите — это ровно тот случай, когда руками пощупать понятнее, чем прочитать:

[ DEMO 01 ]//

Однородная точка: зачем четвёртое число

Почему вообще матрица, а не «умножить и прибавить»

Тут возникает резонный вопрос: зачем городить 4×4, если можно сначала повернуть, а потом прибавить сдвиг? Дело в том, что перенос в трёхмерном пространстве — не линейная операция: он не оставляет ноль на месте, а линейность как раз это требует. А в четырёхмерном однородном пространстве перенос становится обычным линейным преобразованием — такой же матрицей, как поворот.

И вот тут прячется главная выгода, ради которой всё и затевалось. Раз перенос, поворот и масштаб — все матрицы, то всю цепочку преобразований можно перемножить в одну матрицу заранее и применять к каждой вершине один раз. Тысячи вершин, одно умножение на вершину. К этой мысли мы ещё вернёмся в пятой части, когда соберём весь путь целиком.

Итак, одна вершина, одно число w — понятно. Но между «локальными координатами модели» и «пикселем на экране» точка меняет систему отсчёта не раз. Кто её переводит? Три матрицы. Разберём.

Часть 2. Model → View → Projection: три смены системы отсчёта

Слово «MVP» кажется какой-то отдельной магической матрицей рендера. На деле это просто три перевода координат подряд, и каждый отвечает ровно за один шаг:

  • Model — из локального пространства объекта в мировое;
  • View — из мирового в пространство камеры;
  • Projection — из пространства камеры в пространство отсечения (clip), откуда рукой подать до экрана.

Давайте пройдём по ним по порядку.

Model: локальное → мировое

Вершины модели заданы вокруг её собственного центра. У домика конёк где-то в «(0, 1)» относительно его нуля — и неважно, где домик стоит в мире. Матрица Model — это «поставить, повернуть, отмасштабировать»: она переносит вершины из этих локальных координат в общий мир. По сути Model = перенос · поворот · масштаб (порядок важен, но это тема на отдельный разговор).

View: мировое → пространство камеры

А вот здесь — самый важный сдвиг сознания во всей статье. Камеру не двигают.

Звучит странно, но подумайте: в кадре нет никакой «камеры», есть только вершины, которые нужно куда-то спроецировать. Поэтому вместо «подвинуть камеру на два метра вперёд» графика делает обратное — двигает весь мир на два метра назад, так, чтобы камера оказалась ровно в начале координат и смотрела вдоль своей оси. Матрица View — это преобразование, обратное позе камеры.

Отсюда фраза, которую стоит унести из этой части целиком: объект не двигается — двигается наблюдатель. Это и есть ответ на вопрос Дельты про систему отсчёта.

Projection: камера → clip

Третья матрица садит трёхмерное в прямоугольник экрана и заодно заряжает то самое w под будущее деление. Здесь только назовём её — весь механизм в следующей части, чтобы не мешать всё в кучу.

А почему не руками?

Тут и всплывает наш Магистр Хардкод. Он считал экранные координаты вручную: синусы и косинусы прямо в игровой логике, x_screen = x·cos(a) − …, рядом пометка // TODO: proper matrices later. И пока камера стояла на месте — всё работало идеально.

Проблема вылезла ровно в тот момент, когда камере понадобилось поехать. С ручными формулами пришлось бы переписывать каждую из них: ведь «где стоит наблюдатель» было размазано по всему коду. View выносит это в одну матрицу — и код объекта про камеру больше вообще ничего не знает. В этом вся разница между «работает на демке» и «работает в игре».

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

[ DEMO 02 ]//

Три системы отсчёта: Local → World → View

Крутите камеру и следите за числами в View: объект не сдвинулся ни на метр, а его координаты в системе камеры меняются — потому что меняется сама система отсчёта.

Ну и что именно Projection делает с w и почему после неё глубина начинает врать — в следующей части.

Часть 3. Проекция и глубина: деление на w и почему z нелинейна

Мир мы уже увидели глазами камеры. Осталось положить трёхмерное на плоский экран — и вот тут появляется перспектива, деление на w и глубина, которая начинает врать.

Две проекции

Проекций, по-хорошему, две. Ортографическая — лучи параллельны, размер объекта не зависит от расстояния. Так рисуют карты, изометрию в стратегиях, чертежи в CAD. Перспективная — дальнее меньше ближнего, лучи сходятся в точку, как в глазу или объективе. Разница целиком спрятана в матрице Projection и в том, что происходит с w.

То, что видит перспективная камера, — это frustum, усечённая пирамида: ближняя плоскость near, дальняя far, углы обзора по бокам. Задача Projection — отправить эту пирамиду в пространство отсечения (clip). А уже деление на w, к которому мы сейчас придём, превращает её в аккуратный куб [−1, 1] по всем осям — это и есть NDC, нормализованные координаты устройства.

Деление на w — сердце перспективы

Перспективная матрица устроена хитро: после умножения w вершины оказывается равен её глубине в камере. А дальше железо делит x, y и z на этот w:

NDC=(xclipw, yclipw, zclipw),w=zeye\text{NDC} = \left(\frac{x_{\text{clip}}}{w},\ \frac{y_{\text{clip}}}{w},\ \frac{z_{\text{clip}}}{w}\right), \qquad w = -z_{\text{eye}}

Вот это деление и даёт всю перспективу. Чем дальше точка — тем больше её w, тем сильнее она сжимается к центру. Никакой отдельной «магии перспективы» нет, есть деление на глубину. Собственно, поэтому в англоязычной литературе шаг так и называют — perspective divide.

Почему z нелинейна

Теперь тонкий момент, за который потом придётся расплачиваться весь пятый сезон. После деления NDC-z оказывается нелинейной функцией глубины: в неё входит 1/z_eye, а не сам z_eye. На практике это значит, что точность глубины копится у ближней плоскости и почти вся тает к дальней.

Следствие простое и злое. Две далёкие поверхности получают почти одинаковую глубину в буфере, буфер не может решить, кто ближе, — и они начинают драться за пиксель, мерцая при малейшем движении камеры. Это и есть z-fighting. Практический вывод, который стоит унести: тяните near как можно дальше от нуля — сдвинуть ближнюю плоскость часто самый дешёвый способ убрать мерцание. Полный разбор самого буфера глубины — это уже следующий сезон, но корень проблемы виден уже здесь.

Пощупайте, как глубина ложится в NDC. Оранжевый луч показывает деление, а нижний график — ту самую нелинейность:

[ DEMO 03 ]//

Проекция и деление на w

Подвигайте near и посмотрите, как выпрямляется кривая точности. И утащите точку за камеру — увидите красную зону, где w уходит в минус и делить уже нельзя.

Итак, точка получила NDC-координаты в кубе [−1, 1]. Но экран — не куб, а прямоугольник в пикселях, и половина треугольников торчит за краем. Как обрезать лишнее и как попасть в конкретный пиксель — разберём дальше.

Часть 4. Clip → NDC → экран: отсечение и попадание в пиксель

Точка получила координаты в кубе [−1, 1]. Осталось два шага: выкинуть то, что за пределами экрана, и перевести оставшееся в пиксели. Разберём оба.

Clipping: обрезать по фрустуму

Треугольники, которые частично торчат за границы видимого, отсекаются по плоскостям фрустума. На месте среза рождаются новые вершины прямо на рёбрах — треугольник может превратиться в четырёх-, пяти- или ещё более сложный многоугольник, который дальше растеризуется как несколько треугольников. Зачем это вообще: во-первых, не тратить растеризацию на невидимое; во-вторых — и это важнее — не пускать в деление точки с w ≤ 0.

NDC → viewport: последний перевод

Дальше всё внезапно просто. Куб [−1, 1] по осям экрана линейно растягивается в пиксельный прямоугольник окна: −1 уходит в 0, +1 — в ширину или высоту. Это уже обычная аффинная растяжка, никакого деления. Координата z из NDC уезжает в буфер глубины — тот самый, чью нелинейность мы разглядывали в прошлой части и чей полный разбор ждёт нас в следующем сезоне.

Тут стоит важная оговорка, чтобы потом не спорить в комментариях: конкретные соглашения у разных API разные. Где-то глубина в NDC живёт в [−1, 1], где-то в [0, 1]; ось Y у кого-то смотрит вверх, у кого-то вниз. Суть при этом одна и та же — линейная растяжка нормализованного куба в пиксели, — но детали я тут сознательно упрощаю.

Посмотрите на оба шага сразу: сверху — отсечение по рамке NDC, снизу — та же рамка, растянутая в пиксели viewport:

[ DEMO 04 ]//

Clip → NDC → экран

Погоняйте треугольник за край и сравните режимы. Без клипа картинка на экране получается той же самой — но GPU всё равно считает и то, что за рамкой, впустую. А viewport покрутите по ширине: видно, как один и тот же NDC-куб просто масштабируется в разное число пикселей.

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

Часть 5. Весь путь целиком: MVP руками

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

  1. Local. Вершина задана относительно центра объекта — конёк домика где-то над его нулём.
  2. Model переносит её в мир: поставили, повернули, отмасштабировали. Теперь у вершины мировые координаты.
  3. View перевыражает мир в системе камеры — не двигая объект, а двигая всё вокруг так, чтобы камера села в ноль.
  4. Projection отправляет её в clip-пространство и кладёт в w глубину.
  5. Деление на w сжимает всё по перспективе и даёт NDC в кубе [−1, 1].
  6. Viewport растягивает куб в пиксели окна. Вершина нашла свой пиксель.

По сути весь конвейер держит та стадия, где меняется система отсчёта, а не та, где «больше арифметики». И вот тут собирается главный практический вывод статьи.

Хак Хардкода и его граница

Стартовая, намеренно «испорченная» конфигурация в демке ниже — это ровно то, что делал Магистр Хардкод. Вершины прибиты к экранным координатам, рядом его вечное // TODO: proper matrices later. Пока камера стояла — картинка была абсолютно верной. Стоило камере поехать — и всё разъехалось.

И вот что важно понять про этот хак. Он не был враньём. У ручной расстановки есть совершенно точная область применимости: двумерка, HUD, интерфейс, миникарта, фиксированная камера в платформере. Там считать экранные координаты руками — правильно и дешевле любого MVP. Ошибка Хардкода не в синусах, а в том, что он не посмотрел на границу своего приёма: «а если камера поедет?». Дейв, когда чинит это, не выкидывает его код с проклятиями — он выносит наблюдателя в матрицу View, а всё остальное оставляет как было.

Собственно, вот демка. Старт — «руками», камера уже сдвинута, домик стоит криво; тусклый призрак показывает, где ему следовало быть. Переключите на «ЧЕРЕЗ MVP» — и он поедет за камерой, а ленты сверху заполнятся координатами на каждой стадии:

[ DEMO 05 ]//

MVP руками: вся дорога от локальных координат до пикселя

Порядок тут ровно тот же, что в реальной отладке: увидели, что объект живёт не в той системе отсчёта, — перевели его через нужные матрицы, а не подкрутили синус на глаз.

Диагностика: в какой системе отсчёта беда

Соберём симптомы всех частей в одну шпаргалку:

СимптомВ какой системе отсчёта бедаКуда смотреть
Объект уезжает не туда при движении камерызабыли или сломали Viewпоза камеры, порядок View · Model
Из-за спины камеры объект вылез вывернутымw ≤ 0, клип после деленияближняя плоскость, отсечение до ÷w
Далёкие поверхности мерцают, дерутся за пиксельнелинейная z, near у нуляотодвинуть near, диапазон near/far
Спрайт или HUD «плывёт» при повороте камерыпосчитан руками в экранных координатахвынести в MVP — или осознанно оставить в screen-space

Карта, конечно, упрощённая — в настоящем движке стадий и деталей больше, и они любят накладываться друг на друга. Но как первый диагностический проход она работает.

Выводы

Если унести из статьи одну мысль, то вот она. «Поставлю руками» — не глупость и не признак джуна. Это инструмент с очень чёткой границей. Внутри границы — двумерка, HUD, интерфейс, неподвижная камера — ручная расстановка правильна и дешевле MVP. Снаружи — перспектива, движущаяся камера, настоящая глубина — она ломается, и чинится ровно тем, что вы выносите наблюдателя и проекцию в матрицы.

Мастерство, по сути, не в том, чтобы никогда не хардкодить, а в том, чтобы всегда знать, где вы относительно этой границы. Это, кстати, ровно то, о чём весь наш канал говорит и применительно к нейросетям: любое упрощение полезно ровно до своей области применимости — и опасно за ней.

А ещё вершина у нас нашла свой пиксель — но треугольник ещё никто не закрасил. Кто решает, какие именно пиксели лежат внутри треугольника и кто из них ближе к камере (тот самый буфер глубины, чью нелинейность мы уже разглядели), — это следующий сезон, «Треугольник становится пикселями». Там мы наконец спустимся от геометрии к самим пикселям.

Надеюсь, статья была полезна и хотя бы пара багов из вступления теперь читается по-другому. Если разбор зашёл — заходите в телеграм-канал, такие штуки с интерактивами выходят там каждую неделю. И буду рад, если поправите или дополните в комментариях: нюансов в проекциях и правда много.

// @easy_dev_math

Разборы графики с кодом и интерактивами — каждую неделю в канале.

Подписаться