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 и нужен.

Та же матрица целиком, по компонентамОсторожно! Математика!

Блочная запись выше — это сокращение. Если раскрыть её по компонентам, где R — девять чисел поворота с масштабом, а t — три числа переноса, получится вот такое умножение:

(r11r12r13txr21r22r23tyr31r32r33tz0001)(xyzw)=(r11x+r12y+r13z+wtxr21x+r22y+r23z+wtyr31x+r32y+r33z+wtzw)\begin{pmatrix} r_{11} & r_{12} & r_{13} & t_x \\ r_{21} & r_{22} & r_{23} & t_y \\ r_{31} & r_{32} & r_{33} & t_z \\ 0 & 0 & 0 & 1 \end{pmatrix}\begin{pmatrix} x \\ y \\ z \\ w \end{pmatrix} = \begin{pmatrix} r_{11}x + r_{12}y + r_{13}z + w\,t_x \\ r_{21}x + r_{22}y + r_{23}z + w\,t_y \\ r_{31}x + r_{32}y + r_{33}z + w\,t_z \\ w \end{pmatrix}

Теперь видно буквально: в каждой из трёх строк перенос стоит последним слагаемым и домножен на w. Подставим точку, у неё w = 1:

(xyz1)=(r11x+r12y+r13z+txr21x+r22y+r23z+tyr31x+r32y+r33z+tz1)\begin{pmatrix} x' \\ y' \\ z' \\ 1 \end{pmatrix} = \begin{pmatrix} r_{11}x + r_{12}y + r_{13}z + t_x \\ r_{21}x + r_{22}y + r_{23}z + t_y \\ r_{31}x + r_{32}y + r_{33}z + t_z \\ 1 \end{pmatrix}

Перенос на месте — точка переехала. Теперь направление, у него w = 0:

(xyz0)=(r11x+r12y+r13zr21x+r22y+r23zr31x+r32y+r33z0)\begin{pmatrix} x' \\ y' \\ z' \\ 0 \end{pmatrix} = \begin{pmatrix} r_{11}x + r_{12}y + r_{13}z \\ r_{21}x + r_{22}y + r_{23}z \\ r_{31}x + r_{32}y + r_{33}z \\ 0 \end{pmatrix}

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

Покрутите — руками пощупать понятнее, чем прочитать:

[ DEMO 01 ]//

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Объект не двигается — двигается наблюдатель. Собственно, это и есть ответ на вопрос, с которого мы начали: у точки нет никаких «абсолютных» координат — она всегда живёт в чьей-то системе отсчёта, а View только меняет, в чьей именно.

И это не только про стройность модели. View держит «где стоит наблюдатель» ровно в одном месте — поэтому код самого объекта про камеру не знает вообще ничего. Пересели на вид от третьего лица, добавили вторую камеру для миникарты, отдали ту же сцену в кат-сцену с другого ракурса — в логике домика не поменялось ни строчки. Меняется одна матрица.

Projection: камера → clip

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

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

[ DEMO 02 ]//

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

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

Static, батчинг и «двигается мир»

Раз мир едет навстречу камере — получается, статичные объекты тоже двигаются? Вопрос хороший, и на нём удобно проверить, поняли ли мы, кто за что отвечает.

Нет. «Мир едет» — это стадия View, и живёт она только внутри конвейера: каждый кадр координаты вершин заново пересчитываются в систему камеры по дороге на экран. В самой сцене при этом никто не шелохнулся — ни одна позиция не изменилась.

А отметка Static в Unity — вообще не про матрицы. Это набор флагов: Contribute GI, Occluder и Occludee Static, Batching Static, Reflection Probe Static — и каждый читает своя подсистема. Смысл у них общий: вы заявляете, что объект не поедет, — значит, движок может посчитать что-то заранее, в редакторе, и посчитанное останется верным весь геймплей. Запечённый свет, данные окклюжн-калинга, пробы отражений.

Наших матриц из всей этой пачки касается ровно один флаг — Batching Static. Статик-батчинг заранее переводит вершины мешей в мировые координаты и складывает их в общий вершинный буфер. Для такого объекта Model фактически становится единичной: вершины уже в мире, переводить нечего. Отсюда, кстати, знакомая шейдерщикам боль — у сбатченного объекта пропадает локальное пространство, и шейдеры, которые дёргают вершины относительно пивота (ветер в листве, растворение от центра), начинают вести себя не так.

Ну и «в рантайме нельзя двигать» — это не запрет, а соглашение. transform.position = … на Static-объекте спокойно скомпилируется и выполнится, движок не бросит ошибку. Просто сломается тихо и по-разному, смотря какой флаг стоял: сбатченный меш визуально не поедет (его вершины уже вварены в мир), лайтмапленный поедет вместе с чужими тенями, окклюдер поедет и будет врать в предпосчитанных данных видимости. Движок уже посчитал за вас и пересчитывать не собирается.

Собственно, вот и ответ на исходный вопрос. Static — про то, что объект не меняет своё место в мире, и про предвычисления, которые на этом держатся. А View и Projection всё равно применяются каждый кадр ко всему подряд — и к статике, и к динамике. Поэтому неподвижная стена в мире так и стоит на месте, а по экрану едет, стоит вам повернуть камеру: в мире она не двинулась — двинулась система отсчёта.

Ну и уточнение напоследок, чтобы не было сюрприза при первом взгляде в шейдер. Отдельных шагов «умножили на View, потом на Projection» внутри Unity нет: шейдер видит уже готовое произведение unity_MatrixVP — константу на камеру, — а весь путь вершины пишется одной строкой вроде UnityObjectToClipPos. Помните, в первой части мы говорили, что цепочку матриц можно перемножить заранее и применить к вершине один раз? Движок ровно это и делает. Три матрицы — это модель, чтобы понимать, кто за что отвечает; в железе от них остаётся одно умножение.

Ну и что именно 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. Весь путь целиком

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

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

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

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

[ DEMO 05 ]//

Весь путь: от локальных координат до пикселя

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

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

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

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

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

Выводы

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

Поэтому навык тут не «выучить три матрицы», а привычка задавать вопрос, с которого мы начали: в какой системе отсчёта живёт это число? Локальной, мировой, камеры, clip, NDC, пиксельной? Как только у координаты появляется адрес, обычно сразу видно и что с ней не так, и какая матрица привела её сюда. Особенно полезно это в написании шейдеров и генерации процедурной геометрии. Как пример, эффекты вроде раскраски мешей удобнее делать в NDC координатах.

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

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

// @easy_dev_math

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