Всем привет! Меня зовут Гриша Дядиченко, и я технический директор и основатель 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 и нужен.
Покрутите — это ровно тот случай, когда руками пощупать понятнее, чем прочитать:
Однородная точка: зачем четвёртое число
Почему вообще матрица, а не «умножить и прибавить»
Тут возникает резонный вопрос: зачем городить 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 выносит это в одну матрицу — и код объекта про камеру больше вообще ничего не знает. В этом вся разница между «работает на демке» и «работает в игре».
Посмотрите, как одна вершина получает три разных набора координат, оставаясь той же самой точкой:
Три системы отсчёта: 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:
Вот это деление и даёт всю перспективу. Чем дальше точка — тем больше её 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. Весь путь целиком: MVP руками
Давайте теперь проведём одну вершину домика по всей дороге, от начала до конца, сшивая всё, что разбирали.
- Local. Вершина задана относительно центра объекта — конёк домика где-то над его нулём.
- Model переносит её в мир: поставили, повернули, отмасштабировали. Теперь у вершины мировые координаты.
- View перевыражает мир в системе камеры — не двигая объект, а двигая всё вокруг так, чтобы камера села в ноль.
- Projection отправляет её в clip-пространство и кладёт в
wглубину. - Деление на
wсжимает всё по перспективе и даёт NDC в кубе[−1, 1]. - Viewport растягивает куб в пиксели окна. Вершина нашла свой пиксель.
По сути весь конвейер держит та стадия, где меняется система отсчёта, а не та, где «больше арифметики». И вот тут собирается главный практический вывод статьи.
Хак Хардкода и его граница
Стартовая, намеренно «испорченная» конфигурация в демке ниже — это ровно то, что делал Магистр Хардкод. Вершины прибиты к экранным координатам, рядом его вечное // TODO: proper matrices later. Пока камера стояла — картинка была абсолютно верной. Стоило камере поехать — и всё разъехалось.
И вот что важно понять про этот хак. Он не был враньём. У ручной расстановки есть совершенно точная область применимости: двумерка, HUD, интерфейс, миникарта, фиксированная камера в платформере. Там считать экранные координаты руками — правильно и дешевле любого MVP. Ошибка Хардкода не в синусах, а в том, что он не посмотрел на границу своего приёма: «а если камера поедет?». Дейв, когда чинит это, не выкидывает его код с проклятиями — он выносит наблюдателя в матрицу View, а всё остальное оставляет как было.
Собственно, вот демка. Старт — «руками», камера уже сдвинута, домик стоит криво; тусклый призрак показывает, где ему следовало быть. Переключите на «ЧЕРЕЗ MVP» — и он поедет за камерой, а ленты сверху заполнятся координатами на каждой стадии:
MVP руками: вся дорога от локальных координат до пикселя
Порядок тут ровно тот же, что в реальной отладке: увидели, что объект живёт не в той системе отсчёта, — перевели его через нужные матрицы, а не подкрутили синус на глаз.
Диагностика: в какой системе отсчёта беда
Соберём симптомы всех частей в одну шпаргалку:
| Симптом | В какой системе отсчёта беда | Куда смотреть |
|---|---|---|
| Объект уезжает не туда при движении камеры | забыли или сломали View | поза камеры, порядок View · Model |
| Из-за спины камеры объект вылез вывернутым | w ≤ 0, клип после деления | ближняя плоскость, отсечение до ÷w |
| Далёкие поверхности мерцают, дерутся за пиксель | нелинейная z, near у нуля | отодвинуть near, диапазон near/far |
| Спрайт или HUD «плывёт» при повороте камеры | посчитан руками в экранных координатах | вынести в MVP — или осознанно оставить в screen-space |
Карта, конечно, упрощённая — в настоящем движке стадий и деталей больше, и они любят накладываться друг на друга. Но как первый диагностический проход она работает.
Выводы
Если унести из статьи одну мысль, то вот она. «Поставлю руками» — не глупость и не признак джуна. Это инструмент с очень чёткой границей. Внутри границы — двумерка, HUD, интерфейс, неподвижная камера — ручная расстановка правильна и дешевле MVP. Снаружи — перспектива, движущаяся камера, настоящая глубина — она ломается, и чинится ровно тем, что вы выносите наблюдателя и проекцию в матрицы.
Мастерство, по сути, не в том, чтобы никогда не хардкодить, а в том, чтобы всегда знать, где вы относительно этой границы. Это, кстати, ровно то, о чём весь наш канал говорит и применительно к нейросетям: любое упрощение полезно ровно до своей области применимости — и опасно за ней.
А ещё вершина у нас нашла свой пиксель — но треугольник ещё никто не закрасил. Кто решает, какие именно пиксели лежат внутри треугольника и кто из них ближе к камере (тот самый буфер глубины, чью нелинейность мы уже разглядели), — это следующий сезон, «Треугольник становится пикселями». Там мы наконец спустимся от геометрии к самим пикселям.
Надеюсь, статья была полезна и хотя бы пара багов из вступления теперь читается по-другому. Если разбор зашёл — заходите в телеграм-канал, такие штуки с интерактивами выходят там каждую неделю. И буду рад, если поправите или дополните в комментариях: нюансов в проекциях и правда много.
Разборы графики с кодом и интерактивами — каждую неделю в канале.
