Графика › Пространство
Пространства: где находится точка
Нужно знать заранее: Шейдер: функция для каждого пикселя
Этот урок есть в курсе «Как движок рисует кадр».
У одной и той же точки камня много адресов: от центра модели, от начала мира, от камеры, от угла экрана. Всё зависит от того, от чего считать. Разберём цепочку пространств, по которой вершина идёт от файла модели до пикселя, что делают матрицы и почему в материале то и дело приходится переводить стрелки из одного пространства в другое. Это первый урок курса «Как движок рисует кадр».
Зачем это
В уроке про шейдер было сказано одной строкой: вершинный шейдер переводит позицию из координат модели в координаты экрана. Здесь разберём, как именно.
Это знание нужно не только программисту. Мох растёт сбоку после поворота камня. Текстура «плывёт» по движущейся лодке. Ветер качает соседние деревья в разные стороны. Модель из Blender приезжает в движок лёжа на боку и в сто раз меньше. Во всех этих случаях числа верные, но посчитаны не в том пространстве. В материалах Unreal половина нод про координаты: World Position, Object Position, Camera Vector, Transform — и у каждой своё пространство.
Одна точка — много адресов
В жизни это знакомо. Где сейчас твои ключи? «В кармане куртки» — если считать от тебя. «В двух метрах от двери» — если от комнаты. «На третьем этаже дома на углу» — если от города. Место одно, а адреса разные, потому что отсчитаны от разных начал. И адреса вложены: положи ключи в сумку, а сумку в машину. Поедет машина — поедут и сумка, и ключи, хотя внутри сумки ключи лежат на месте.
В 3D то же самое, только пространств больше и у каждого своё имя:
- Пространство объекта (object, local space) — координаты от пивота модели, в осях самой модели. Так точки записаны в файле: нос лодки — «полтора метра вперёд от пивота», где бы лодка ни стояла.
- Мировое пространство (world space) — координаты от начала мира уровня. Одно на всех: по нему расставляют предметы и считают расстояния.
- Пространство камеры (view space) — от камеры: столько-то вперёд, столько-то вправо, столько-то вверх.
- Экранное пространство (screen space) — где точка оказалась на экране: пиксель по горизонтали и вертикали плюс глубина.
нос лодки в пространстве объекта: (1.5, 0) // из файла модели, в метрах лодка: пивот в мире (20, 10), повёрнута на 90° в мире: (20, 10) + 1.5 × (0, 1) = (20, 11.5) // ось X лодки теперь смотрит вдоль Y мира камера в (20, 0) смотрит вдоль +Y от камеры: 11.5 м вперёд, 0 м вправо на экране: ровно посередине
Путь вершины: цепочка пространств
Вершинный шейдер ведёт каждую вершину по цепочке, и на каждом шаге меняет систему отсчёта:
объект ──(трансформ предмета)──→ мир ──(обратный трансформ камеры)──→ камера
──(проекция)──→ отсечение ──(деление на глубину)──→ NDC ──→ пиксели экрана
- Объект → мир. Масштабировать, повернуть, сдвинуть на место предмета. Эти три числа (точнее, три тройки) и есть трансформ в панели деталей.
- Мир → камера. Камера на самом деле не ездит: видеокарта сдвигает и поворачивает весь мир так, чтобы камера оказалась в начале координат и смотрела вдоль своей оси. Для этого применяют трансформ камеры наоборот.
- Камера → экран. Проекция и деление на глубину: дальнее становится меньше. Об этом раздел про перспективу.
Попробуй
Это вид сверху: лодка, камера и начало мира. Двигай и поворачивай лодку, поворачивай камеру. Переключай, из какого пространства смотреть: в пространстве объекта лодка неподвижна, а крутится мир; в пространстве камеры камера стоит внизу и смотрит вверх, а поворачивается всё остальное. Числа внизу — адреса носа лодки в каждом пространстве.
Заметь: в пространстве объекта нос лодки всегда в одной точке, как её ни двигай и ни крути. Именно поэтому модель хранят в своих координатах — её можно поставить куда угодно, не трогая файл. А мировые координаты носа меняются от любого движения лодки, координаты камеры — ещё и от поворота камеры.
Матрица: оси и начало координат
Как перевести точку из пространства объекта в мир? Представь, что знаешь, где у лодки пивот и куда в мире смотрят её оси. Тогда точка «1.5 вперёд и 0.3 вбок» в мире — это пивот плюс 1.5 шага вдоль оси X лодки плюс 0.3 шага вдоль её оси Y:
мир = пивот + x × (ось X объекта) + y × (ось Y объекта) + z × (ось Z объекта)
Всё, что нужно, — начало координат и три оси, записанные в мировых координатах. Матрица трансформа — это они и есть, сложенные в таблицу: столбцы — оси объекта, последний столбец — пивот. Поворот поворачивает оси, масштаб меняет их длину, перенос двигает пивот.
- Порядок важен. Движки применяют масштаб, потом поворот, потом перенос. Если сначала сдвинуть, а потом повернуть, предмет повернётся вокруг начала мира и улетит по дуге. В жизни так же: «шаг вперёд, потом поворот направо» и «поворот направо, потом шаг вперёд» приводят в разные места.
- Обратный путь — обратная матрица. Из мира в пространство объекта ведёт матрица, которая отменяет трансформ: сдвинуть назад, повернуть обратно, разделить на масштаб. Мир → камера — это обратная матрица трансформа камеры.
- Иерархия — произведение матриц. Фонарь в руке персонажа: его трансформ задан относительно кисти, кисть — относительно руки, рука — относительно персонажа. Мировая матрица фонаря — произведение всей цепочки, как у ключей в сумке в машине.
Однородные координаты: зачем четвёртое число
Поворот и масштаб — это умножение на матрицу 3 × 3, а перенос — сложение. Чтобы вся цепочка была одним умножением, к точке дописывают четвёртую координату w = 1, а матрицу делают 4 × 4 и кладут перенос в четвёртый столбец. Такие координаты называют однородными.
| X.x Y.x Z.x P.x | | x | | P.x + x·X.x + y·Y.x + z·Z.x | | X.y Y.y Z.y P.y | × | y | = | P.y + … | | X.z Y.z Z.z P.z | | z | | P.z + … | | 0 0 0 1 | | 1 | | 1 |
Трюк с w решает ещё одну задачу. Направление — нормаль, ветер, скорость — сдвигать нельзя: у стрелки нет положения, есть только направление. Направлениям пишут w = 0, и столбец переноса на них не действует. Поэтому в шейдерах есть отдельные функции для точек и для векторов, а в Unreal — ноды TransformPosition (для точек) и Transform (для направлений).
Вся цепочка «объект → мир → камера → проекция» — это три матрицы 4 × 4. Камеру и проекцию перемножают заранее на процессоре, по разу на кадр. Переход «объект → мир» делают отдельным умножением: мировая позиция вершины нужна материалу, ветру и свету. Так вершинный шейдер делает два умножения на вершину. В старых движках все три матрицы сливали в одну.
Камера и перспектива
В пространстве камеры камера стоит в начале координат и смотрит вдоль своей оси. Остаётся превратить три координаты «вперёд, вправо, вверх» в точку на плоском экране.
Возьми линейку и держи её перед глазом. Столб в 10 метрах закрывает на ней столько-то сантиметров, а такой же столб в 20 метрах — вдвое меньше. Размер на экране обратно пропорционален расстоянию. Это и есть перспектива, и в формуле это деление на глубину:
экран.x = f × вправо / вперёд // от −1 до 1 — края кадра экран.y = f × aspect × вверх / вперёд // aspect = ширина / высота, у 16:9 — 1.78 f = 1 / tan(FOV / 2) // FOV по горизонтали: 90° → f = 1, 60° → f ≈ 1.73
Множитель aspect нужен, потому что кадр не квадратный: по высоте он уже, и та же шкала «от −1 до 1» укладывается в меньший угол. Без него картинка растянется по вертикали. Если угол обзора задан по вертикали, как в Unity, всё наоборот: f считают от него, а aspect делит экран.x.
Пример: FOV 90° по горизонтали, экран 1920 пикселей. Предмет шириной 1 метр в 10 метрах займёт 1 / 10 = 0.1 от половины кадра, то есть 96 пикселей. В 20 метрах — 48. При FOV 60° тот же предмет в 10 метрах займёт около 166 пикселей: узкий угол обзора работает как зум.
Попробуй
Вид сбоку: камера слева, три столба высотой 2 метра. Двигай ближний столб и меняй угол обзора — здесь он вертикальный, поэтому f считают по высоте кадра. Справа — как столбы выглядят на экране высотой 1080 пикселей.
Деление на глубину — единственная нелинейная операция во всей цепочке, поэтому его делают отдельным шагом. Проекционная матрица не делит, а кладёт глубину в четвёртую координату w. Видеокарта сама делит x, y и z на w после вершинного шейдера — это перспективное деление. Получается нормализованное пространство (NDC): всё, что видно, лежит от −1 до 1 по x и y. Остальное за краем кадра, и его отсекают.
Пространство отсечения, near и far
После проекционной матрицы, до деления, координаты называют пространством отсечения (clip space). Здесь видеокарта отрезает треугольники, вылезающие за кадр: условие «x между −w и w» проверить проще, чем после деления, и оно не ломается для точек за спиной камеры, где глубина отрицательная.
У камеры есть ближняя и дальняя плоскости отсечения, near и far. Что ближе near или дальше far, не рисуется. Глубина между ними переводится в число от 0 до 1 и пишется в буфер глубины, по которому видеокарта решает, кто кого закрывает. Как именно устроено это число и почему его точность распределена неравномерно, — урок про буфер глубины.
Последний шаг — окно вывода (viewport): из −1…1 в пиксели. Для экрана 1920 × 1080 это пиксель = (x + 1) / 2 × 1920. Ось y при этом переворачивают: в NDC вверх — плюс, а пиксели считают сверху вниз.
Точки, направления и нормали
Переводить из пространства в пространство можно три разных вещи, и правила у них разные.
- Точка — положение. Для неё работают все три шага: масштаб, поворот, перенос.
- Направление — ветер, скорость, направление на солнце. Его только поворачивают и масштабируют, переносить нельзя: у стрелки нет места, есть только куда она смотрит.
- Нормаль — перпендикуляр к поверхности. При обычном повороте и равномерном масштабе она ведёт себя как направление. Но если растянуть предмет по одной оси, нормаль, повёрнутая той же матрицей, перестанет быть перпендикулярной.
Попробуй
Круг растягивается по горизонтали. Красные стрелки — нормали, пересчитанные как обычные направления. Зелёные — пересчитанные правильно. Растяни сильнее и сравни с настоящим перпендикуляром.
Правильная матрица для нормалей — обратная транспонированная к матрице трансформа. Для поворота и равномерного масштаба она совпадает с обычной (с точностью до длины), поэтому разница видна только при неравномерном масштабе. Движки делают это сами для нормалей мешей. Но если ты считаешь в материале свою стрелку и переводишь её вручную, об этом стоит помнить.
Пространства в материале
Правило одно: складывать, сравнивать и брать скалярное произведение можно только у стрелок из одного пространства. Иначе числа складываются правильно, а смысл теряется, как сумма метров и футов.
| Задача | Пространство | Почему |
|---|---|---|
| Мох, снег и пыль сверху | мировое | «Верх» — это небо, а не ось модели. Иначе после поворота камня мох уедет на бок. |
| Ветер для травы и деревьев | мировое | Ветер дует в мире: соседние деревья должны качаться в одну сторону, как бы их ни повернули при расстановке. |
| Текстура, общая для стыка модулей или склона | мировое | Рисунок не рвётся на границах кусков. Подробно — урок про triplanar. |
| Эффект, который едет вместе с предметом | объекта | Узор на лодке или бочке не должен плыть, когда она движется. |
| Карта нормалей | касательное | Стрелки записаны относительно поверхности, поэтому текстура работает на любой модели. Касательное пространство |
| Виньетка, эффекты по экрану | экранное | Нужна позиция пикселя в кадре, а не в мире. |
У движков и 3D-программ разные договорённости об осях и единицах. Отсюда модели, которые приезжают лёжа или крошечными:
| Программа | Вверх | Система | Единица |
|---|---|---|---|
| Unreal | Z | левая | сантиметр |
| Unity | Y | левая | метр |
| Godot | Y | правая | метр |
| Blender | Z | правая | метр |
Левая и правая системы отличаются направлением одной оси. Выстави большой палец правой руки по X, указательный по Y — средний покажет Z правой системы. В левой Z смотрит в обратную сторону. При переносе между ними одну ось переворачивают, иначе модель выходит зеркальной. Экспортёры делают это сами, если оси выставлены верно; подробно — во флоу экспорта из Blender в Unreal.
Далеко от начала мира
У координат есть ещё одна ловушка — точность. Обычное число с плавающей точкой (float) хранит около семи значащих цифр. Рядом с началом мира это доли миллиметра, а вдали шаг растёт. В 10 км от начала координат float различает только 0.6 мм, в 100 км — целые сантиметры. Вершины начинают прыгать с шагом этой сетки: модели дрожат, тени мерцают, анимация трясётся.
Поэтому большие миры либо держат игрока близко к началу, переставляя мир вокруг него (origin rebasing), либо считают позиции с двойной точностью. В Unreal 5 так и сделано: большие мировые координаты (Large World Coordinates) хранятся в double. В материале варианты World Position «относительно камеры» (Camera Relative) и точнее, и дешевле абсолютных: рядом с камерой числа маленькие.
Где ломается
- Мох растёт сбоку, когда камень повернули. «Верх» взят в пространстве объекта. Сравнивай нормаль с мировым верхом (0, 0, 1) и переводи нормаль в мир.
- Текстура плывёт по движущемуся предмету. Координаты для текстуры взяты из мира. Для того, что движется, бери координаты объекта или UV.
- Соседние деревья качаются в разные стороны. Ветер задан в пространстве объекта и поворачивается вместе с каждым деревом. Задай направление ветра в мире.
- Модель приехала лёжа, зеркальной или в сто раз меньше. Разные оси и единицы программ. Проверь оси и масштаб экспорта, а не поворачивай каждую модель руками.
- Свет по карте нормалей странный после своей математики. Стрелки из карты нормалей в касательном пространстве сложили с мировыми. Сначала переведи их в одно пространство.
- Растянутый по одной оси предмет освещён криво в твоём эффекте. Нормаль переведена как обычное направление. Нужна обратная транспонированная матрица или нода, которая делает это за тебя.
- Далеко от центра карты модели дрожат. Точности float не хватает. Держи игровую зону ближе к началу мира или используй расчёты относительно камеры.
Связи: та же идея в других областях
- АнимацияСкелет — та же иерархия трансформов: каждая кость задана относительно родителя, и мировая поза кисти — произведение всей цепочки от таза. Смешивание анимаций
- ИнтерфейсМетка над головой персонажа — точка из мира, переведённая в экранные координаты той же проекцией. Интерфейс бывает экранным и мировым, и выбор между ними — выбор пространства.
- ЗвукЧтобы понять, в каком ухе звучит выстрел, движок переводит позицию звука в пространство слушателя: знак координаты «вправо» решает панораму. Звук как данные
- Механики и кодПрицеливание, движение персонажа относительно камеры, «куда смотрит враг» — всё это переводы между пространствами и скалярные произведения. Математика графики
- Мир и уровниМодульные киты ставят по сетке в мировых координатах, а мировые текстуры скрывают стыки между модулями. Модульные киты
Главное
- У точки много адресов: в пространстве объекта, мира, камеры и экрана. Все верные, только отсчитаны от разных начал.
- Вершина идёт по цепочке: объект → мир → камера → отсечение → деление на глубину → пиксели.
- Матрица трансформа — это оси объекта и его пивот, записанные в мировых координатах. Порядок: масштаб, поворот, перенос.
- Мир → камера — обратный трансформ камеры: двигается не камера, а мир.
- Перспектива — деление на глубину: вдвое дальше — вдвое меньше. Узкий угол обзора работает как зум.
- Точки переносят, направления — нет, нормали при неравномерном масштабе требуют обратной транспонированной матрицы.
- Складывать и сравнивать можно только стрелки из одного пространства: мох и ветер — в мире, узор на движущемся — в объекте, нормали из карты — в касательном.
Проверь себя
1. Лодку передвинули на 10 метров. Какие координаты её носа не изменились?
2. После поворота камня на 90° мох переехал с верхушки на бок. Что не так в материале?
3. Как видеокарта «двигает камеру» по уровню?
4. Столб в 10 метрах занимает на экране 190 пикселей в высоту. Сколько он займёт в 20 метрах при том же угле обзора?
5. Почему направление ветра нельзя переводить в другое пространство той же формулой, что и точку?
6. Предмет растянули вдвое по X. Нормали перевели той же матрицей, что и точки. Что будет?
7. Модель из Blender приехала в Unreal в сто раз меньше. Почему?
8. В 100 км от начала мира модели начали дрожать. В чём причина?
Где упоминается
Что-то осталось непонятным? Выдели этот кусок текста и нажми «Спросить про это». Вопрос уйдёт автору, и это место перепишут понятнее.