Всем привет! Меня зовут Гриша Дядиченко, и я технический директор и основатель White Label Games. Больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном заказная разработка и собственные прототипы.
Почти каждый, кто трогал 3D руками, ловил такое. Далёкие поверхности начинают драться за пиксель и мерцать. Или объект из-за спины камеры вдруг вылезает вывернутым прямо перед носом. Знакомо? Каждый из этих багов на самом деле про одно и то же: точка живёт не в той системе отсчёта, в какой вы думаете.
Собственно, это и есть главный вопрос статьи, и его удобно задавать одной строкой: в какой системе отсчёта живёт эта точка? Пока на него нет ответа, координаты объекта — это просто три числа.
Чтож, давайте разберём путь, который проходит одна вершина от строчки в вашем коде до пикселя на экране. По нему посмотрим:
- зачем вершине четвёртое число
wи почему без него нельзя; - что делают три матрицы Model, View и Projection — и почему это ровно три смены системы отсчёта;
- почему в конце всё делится на
wи откуда берётся перспектива; - почему глубина в буфере нелинейна — и как это связано с мерцанием далёких поверхностей;
Пять частей, в каждой — интерактив.
Такие разборы — с рабочим кодом — выходят в канале каждую неделю.
Часть 1. Однородные координаты: зачем вершине четвёртое число
Координаты выглядят самой понятной вещью на свете: три числа — вот и вся позиция. Поставили сундук в (x, y, z), как в редакторе Skyrim, — он там и стоит.
Вопросы начинаются, когда ту же точку надо переносить между системами отсчёта: из локального пространства объекта в мировое, из мирового — в пространство камеры, из камеры — на экран. Вот тут и выясняется, что трёх чисел мало, — и появляется загадочное четвёртое.
Точка против направления
Сначала — главное. В коде одинаково выглядят два совершенно разных зверя. Оба — три числа.
Первый зверь — точка. У неё есть «где»: позиция объекта, вершина меша, место попадания. Второй — направление (вектор). У него есть только «куда»: нормаль поверхности, направление света, ось вращения. У направления нет места в пространстве — оно одно и то же и в углу сцены, и в её центре.
Как их различить? Простейшая проверка — сдвиг. Передвинуть точку на пять метров вправо — осмысленно, она переехала. А передвинуть направление на пять метров вправо — бессмыслица: направление «вверх» остаётся «вверх», куда его ни неси. Значит, перенос обязан действовать на точки и не трогать направления.
Как одна матрица делает и то, и другое
И вот ради этого к трём числам дописывают четвёртое, w. Точка получает w = 1, направление — w = 0. Дальше всё преобразование записывается матрицей 4×4: в левом верхнем углу — линейная часть (поворот и масштаб), справа отдельным столбцом — перенос t.
Посмотрите на правую часть: перенос t домножается на w. У точки w = 1 — перенос применяется целиком. У направления w = 0 — перенос обнуляется. Одна и та же матрица корректно двигает точки и оставляет направления на месте. Собственно, для этого w и нужен.
Та же матрица целиком, по компонентамОсторожно! Математика!
Блочная запись выше — это сокращение. Если раскрыть её по компонентам, где R — девять чисел поворота с масштабом, а t — три числа переноса, получится вот такое умножение:
Теперь видно буквально: в каждой из трёх строк перенос стоит последним слагаемым и домножен на w. Подставим точку, у неё w = 1:
Перенос на месте — точка переехала. Теперь направление, у него w = 0:
Слагаемые с t просто исчезли: направление повернулось и отмасштабировалось, но никуда не поехало. И обратите внимание на нижнюю строку матрицы — (0, 0, 0, 1): она отдаёт на выход то же w, что было на входе. Точка остаётся точкой, направление — направлением, сколько матриц на них ни перемножай.
Покрутите — руками пощупать понятнее, чем прочитать:
Однородная точка: зачем четвёртое число
Почему вообще матрица, а не «умножить и прибавить»
Тут возникает резонный вопрос: зачем городить 4×4, если можно сначала повернуть, а потом прибавить сдвиг? Дело в том, что перенос в трёхмерном пространстве — не линейная операция: он не оставляет ноль на месте, а линейность как раз это требует. А в четырёхмерном однородном пространстве перенос становится обычным линейным преобразованием — такой же матрицей, как поворот.
И вот тут и есть главная выгода, ради которой всё и затевалось. Раз перенос, поворот и масштаб — все матрицы, то всю цепочку преобразований можно перемножить в одну матрицу заранее и применять к каждой вершине один раз. Тысячи вершин, одно умножение на вершину. К этой мысли мы ещё вернёмся в пятой части, когда соберём весь путь целиком.
Итак, одна вершина, одно число w — понятно. Но между «локальными координатами модели» и «пикселем на экране» точка меняет систему отсчёта не раз. Кто её переводит? Три матрицы. Разберём.
Часть 2. Model → View → Projection: три смены системы отсчёта
Слово «MVP» кажется какой-то отдельной магической матрицей рендера. На деле это просто три перевода координат подряд, и каждый отвечает ровно за один шаг:
- Model — из локального пространства объекта в мировое;
- View — из мирового в пространство камеры;
- Projection — из пространства камеры в пространство отсечения (clip), откуда рукой подать до экрана.
Давайте пройдём по ним по порядку.
Model: локальное → мировое
Вершины модели заданы вокруг её собственного центра. Допустим у домика окно где-то в «(0, 1)» относительно его нуля — и неважно, где домик стоит в мире. Матрица Model — это «поставить, повернуть, отмасштабировать»: она переносит вершины из этих локальных координат в общий мир. По сути Model = перенос · поворот · масштаб (порядок важен, но это тема на отдельный разговор).
View: мировое → пространство камеры
А вот здесь — самое важное. Камеру не двигают.
Звучит странно, но подумайте: в кадре нет никакой «камеры», есть только вершины, которые нужно куда-то спроецировать. Поэтому вместо «подвинуть камеру на два метра вперёд» графика делает обратное — двигает весь мир на два метра назад, так, чтобы камера оказалась ровно в начале координат и смотрела вдоль своей оси. Матрица View — это преобразование, обратное позе камеры.
Объект не двигается — двигается наблюдатель. Собственно, это и есть ответ на вопрос, с которого мы начали: у точки нет никаких «абсолютных» координат — она всегда живёт в чьей-то системе отсчёта, а View только меняет, в чьей именно.
И это не только про стройность модели. View держит «где стоит наблюдатель» ровно в одном месте — поэтому код самого объекта про камеру не знает вообще ничего. Пересели на вид от третьего лица, добавили вторую камеру для миникарты, отдали ту же сцену в кат-сцену с другого ракурса — в логике домика не поменялось ни строчки. Меняется одна матрица.
Projection: камера → clip
Третья матрица переносит трёхмерное в прямоугольник экрана и заодно заряжает то самое w под будущее деление. Здесь только назовём её — весь механизм в следующей части, чтобы не мешать всё в кучу.
Посмотрите, как одна вершина получает три разных набора координат, оставаясь той же самой точкой:
Три системы отсчёта: 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:
Вот это деление и даёт всю перспективу. Чем дальше точка — тем больше её w, тем сильнее она сжимается к центру. Никакой отдельной «магии перспективы» нет, есть деление на глубину. Собственно, поэтому в англоязычной литературе шаг так и называют — perspective divide.
Почему z нелинейна
Теперь тонкий момент, за который потом придётся расплачиваться. После деления NDC-z оказывается нелинейной функцией глубины: в неё входит 1/z_eye, а не сам z_eye. На практике это значит, что точность глубины копится у ближней плоскости и почти вся тает к дальней.
Следствие простое и злое. Две далёкие поверхности получают почти одинаковую глубину в буфере, буфер не может решить, кто ближе, — и они начинают драться за пиксель, мерцая при малейшем движении камеры. Это и есть z-fighting. Практический вывод, который стоит унести: тяните near как можно дальше от нуля — сдвинуть ближнюю плоскость часто самый дешёвый способ убрать мерцание. Полный разбор самого буфера глубины — это уже следующие разборы, но корень проблемы виден уже здесь.
Пощупайте, как глубина ложится в NDC. Оранжевый луч показывает деление, а нижний график — ту самую нелинейность:
Проекция и деление на 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:
Clip → NDC → экран
Погоняйте треугольник за край и сравните режимы. Без клипа картинка на экране получается той же самой — но GPU всё равно считает и то, что за рамкой, впустую. А viewport покрутите по ширине: видно, как один и тот же NDC-куб просто масштабируется в разное число пикселей.
Ну что ж, все четыре перевода по отдельности мы разобрали. Теперь самое интересное — проведём одну вершину по всей дороге разом и посмотрим.
Часть 5. Весь путь целиком
Давайте теперь проведём одну вершину домика по всей дороге, от начала до конца, сшивая всё, что разбирали.
- Local. Вершина задана относительно центра объекта — конёк домика где-то над его нулём.
- Model переносит её в мир: поставили, повернули, отмасштабировали. Теперь у вершины мировые координаты.
- View перевыражает мир в системе камеры — не двигая объект, а двигая всё вокруг так, чтобы камера села в ноль.
- Projection отправляет её в clip-пространство и кладёт в
wглубину. - Деление на
wсжимает всё по перспективе и даёт NDC в кубе[−1, 1]. - Viewport растягивает куб в пиксели окна. Вершина нашла свой пиксель.
По сути, вся эта дорога — одна точка, шесть раз пересчитанная в новых координатах. Стадии, на которой «объект двигается», тут нет вообще: двигается только система отсчёта, в которой мы его читаем.
Демка ниже проводит домик по этому пути целиком. Ленты сверху — координаты одной и той же вершины, конька крыши, на каждой стадии. Крутите ручки любой из трёх матриц и следите, где именно числа начинают меняться:
Весь путь: от локальных координат до пикселя
Ленты сверху — это, по сути, отладочный вывод: видно, на какой стадии координаты вершины впервые стали не теми, каких вы ждёте. В движке такой ленты перед глазами нет, поэтому дальше — шпаргалка, которая переводит симптом на экране в стадию, где искать.
Диагностика: в какой системе отсчёта беда
Соберём симптомы всех частей в одну шпаргалку:
| Симптом | В какой системе отсчёта беда | Куда смотреть |
|---|---|---|
| Объект уезжает не туда при движении камеры | забыли или сломали View | поза камеры, порядок View · Model |
| Из-за спины камеры объект вылез вывернутым | w ≤ 0, клип после деления | ближняя плоскость, отсечение до ÷w |
| Далёкие поверхности мерцают, дерутся за пиксель | нелинейная z, near у нуля | отодвинуть near, диапазон near/far |
Карта, конечно, упрощённая — в настоящем движке стадий и деталей больше, и они любят накладываться друг на друга. Но как первый диагностический проход она работает.
Выводы
Если унести из статьи одну мысль, то вот она. Все баги из вступления — это один и тот же баг. Далёкие поверхности дерутся за пиксель, объект из-за спины вылезает вывернутым перед носом, объект уезжает не туда, стоит подвинуть камеру, — каждый раз число живёт не в той системе отсчёта, в какой вы считаете.
Поэтому навык тут не «выучить три матрицы», а привычка задавать вопрос, с которого мы начали: в какой системе отсчёта живёт это число? Локальной, мировой, камеры, clip, NDC, пиксельной? Как только у координаты появляется адрес, обычно сразу видно и что с ней не так, и какая матрица привела её сюда. Особенно полезно это в написании шейдеров и генерации процедурной геометрии. Как пример, эффекты вроде раскраски мешей удобнее делать в NDC координатах.
А ещё вершина у нас нашла свой пиксель — но треугольник ещё никто не закрасил. Кто решает, какие именно пиксели лежат внутри треугольника и кто из них ближе к камере (тот самый буфер глубины, чью нелинейность мы уже разглядели), — это следующую неделю, она будет о том «Треугольник становится пикселями». Там мы наконец спустимся от геометрии к самим пикселям.
Надеюсь, статья была полезна и хотя бы пара багов из вступления теперь читается по-другому. Если разбор зашёл — заходите в телеграм-канал, такие штуки с интерактивами выходят там каждую неделю. И буду рад, если поправите или дополните в комментариях: нюансов в проекциях и правда много.
Разборы графики с кодом и интерактивами — каждую неделю в канале.
