3D-конвейер: от вершины к пикселю
model→world→view→clip, деление на глубину (перспектива), клиппинг по ближней плоскости и растеризация, где z-буфер решает за каждый пиксель «кто ближе». Это скелет, общий для Quake, GPU и дифференцируемых рендеров.z в координату w, а потом идёт perspective divide (делим на w) → нормализованные координаты [−1,1]³, которые масштабируются в пиксели. Перед делением обязателен клиппинг по ближней плоскости (на z≤0 делить нельзя). Глубину между пикселями разрешает z-буфер: на каждый пиксель храним ближайшую глубину и рисуем фрагмент, только если он ближе. Painter / BSP сортируют полигоны; z-буфер сортирует пиксели — и потому почти вытеснил всё остальное.
Цепочка матриц: пять пространств
«Где на экране эта вершина?» — это последовательность смен системы координат, каждая = умножение на матрицу 4×4 в однородных координатах:
Model ставит меш в мир (позиция/поворот/масштаб инстанса). View — это обратная матрица камеры: вместо «двигать камеру» двигаем весь мир так, будто камера в начале координат смотрит вдоль оси. Projection не проецирует сама — она готовит деление: кладёт глубину z в координату w и масштабирует по полю зрения. На GPU первые три обычно слиты в одну MVP-матрицу (model-view-projection) и умножаются за один проход в вершинном шейдере.
Перспектива = деление на глубину
Дальние объекты меньше — потому что экранная координата пропорциональна 1/z. Камера-обскура (pinhole) с фокусным расстоянием d: точка (x, y, z) в пространстве камеры падает на плоскость экрана в
Числовой пример. Фокус d=4. Точка A=(2, 1, 5) → x_s = 4·2/5 = 1.6, y_s = 4·1/5 = 0.8. Та же точка вдвое дальше, z=10 → x_s = 4·2/10 = 0.8. Удвоил глубину — вдвое сократил экранный сдвиг. Это и есть форшортенинг, и он нелинеен по z — отсюда все эффекты глубины (и проблемы точности дальше).
Чтобы упаковать это деление в матричную цепочку, переходят в однородные координаты: вершина — это (x, y, z, w), и точка (x, y, z, w) «значит» (x/w, y/w, z/w). Матрица проекции устроена так, что в выходную w попадает z (в правосторонней системе — −z):
clip = Projection · (x, y, z, 1)ᵀ x_clip = x / (aspect · tan(fov/2)) // масштаб по горизонтали y_clip = y / tan(fov/2) // масштаб по вертикали z_clip = A·z + B // A,B зависят от near/far w_clip = −z // ← сюда уехала глубина NDC = clip / w_clip // perspective divide: деление на −z → x_ndc, y_ndc, z_ndc ∈ [−1, 1]
Деление на w и есть perspective divide — единственный нелинейный шаг во всей цепочке. После него координаты лежат в кубе NDC [−1,1]³ (Normalized Device Coordinates), и финальное viewport-преобразование линейно растягивает x_ndc, y_ndc в пиксели окна, а z_ndc — в значение для z-буфера.
Зачем клиппинг — и почему до деления
На z=0 деление взрывается, а на z<0 (за камерой) точка с отрицательным w при делении зеркалится в кадр — геометрия за спиной «вылезает» спереди вверх ногами. Поэтому перед perspective divide треугольники режут по ближней плоскости отсечения (а заодно по остальным граням фрустума). Полигон, пересекающий плоскость, обрезается алгоритмом Сазерленда–Ходжмана: идём по рёбрам, точки внутри оставляем, на пересечении ребра с плоскостью вставляем новую вершину. Треугольник может стать четырёх-/пятиугольником — его заново триангулируют.
Ключ: клиппинг делают в клип-пространстве, до деления на w, где плоскости фрустума — простые линейные неравенства −w ≤ x ≤ w, −w ≤ y ≤ w, −w ≤ z ≤ w. После деления знак w уже потерян, и точки из-за камеры не отличить от точек перед ней. Дешёвый частый случай — guard-band: целиком видимые и целиком отбракованные треугольники пропускают мимо дорогого клиппинга, режут только пересекающие край.
Растеризация и z-буфер
После проекции треугольник — это три точки на экране. Растеризатор проходит пиксели внутри (тест по знаку рёберных функций / барицентрика) и для каждого интерполирует глубину. Кто ближе при перекрытии? Ответ дал z-буфер (Кэтмулл и Штрассер, 1974): отдельный буфер размером с экран, в каждой ячейке — глубина ближайшего пока фрагмента.
// на старте кадра: z_buffer[*] = +∞ (дальше некуда)
for each триангл:
for each пиксель (px,py) внутри:
z = интерполировать_глубину(px, py)
if z < z_buffer[px, py]: // ближе того, что уже лежит?
z_buffer[px, py] = z // обновляем глубину
frame_buffer[px, py] = цвет // и пишем пиксель
// иначе фрагмент скрыт — выбрасываем
Числовой пример. Стена на z=7 уже залила пиксель (z_buffer=7). Приходит столб на z=5: 5 < 7 → рисуем, z_buffer=5. Следом фрагмент дыма на z=9: 9 < 5? нет → выбрасываем. Порядок прихода не важен — побеждает ближайший. В этом сила: не надо сортировать полигоны, как в painter / BSP. Цена — память на полноэкранный буфер глубины и запись/чтение на каждый фрагмент. Именно поэтому PS1 (1994) z-буфер не потянула, а Quake в софте обходился сортировкой спанов — об этом ниже.
Backface culling отсекает половину треугольников ещё до растеризации: после проекции у видимой лицевой грани вершины идут по экрану в условленном направлении обхода (скажем, по часовой); если знак ориентированной площади в экранных координатах противоположный — грань повёрнута от нас, выбрасываем. Один знак вместо рисования — поэтому культинг делают в 2D после проекции, а не скалярным произведением нормали в 3D.
Хардкор · теория: матрица проекции, w=−z и почему z-буфер «слепнет» вдалиможно пропустить
Почему деление прячут в матрицу
Аффинная матрица 4×4 не умеет делить — она линейна. Трюк однородных координат: разреши последней строке матрицы быть не (0 0 0 1), а (0 0 −1 0). Тогда на выходе w_clip = −z, и последующее «привести w к 1» (perspective divide) автоматически делит x,y,z на глубину. Так нелинейная перспектива становится линейным умножением плюс одно общее деление в конце — ровно то, что удобно конвейеру и GPU.
Распределение точности глубины
В буфер пишут не z, а величину, монотонную по 1/z. Для отображения [near, far] → [0,1]:
Проверка: b(n)=0, b(f)=1. Но b зависит от 1/z → точность сгущается у камеры. Пример n=0.1, f=1000: уровень b=0.5 достигается уже при z≈0.2. То есть половина всех значений буфера тратится на ближайшие 0.1…0.2, а весь остаток 0.2…1000 делит вторую половину. Отсюда z-fighting — мерцание двух почти совпавших дальних поверхностей, у которых не хватило разрядов на различие.
Лечение: reversed-z
Современный приём: float-буфер глубины + поменять near/far местами (близкое → 1.0, далёкое → 0.0). Гипербола 1/z и неравномерная плотность float около нуля сокращаются почти в линейную относительную точность по всей сцене. Один из самых дешёвых апгрейдов рендера: те же данные, обратное сравнение, кратно меньше z-fighting.
Хардкор · инженерия: порядок отсечений, early-z и борьба с z-fightingможно пропустить
- Каскад отбраковки (дёшево→дорого): frustum cull целых объектов (по bounding-box) → backface cull (знак площади) → клиппинг по near (только пересекающие плоскость) → растеризация. Каждый слой убирает свою долю до того, как включится дорогой.
- Early-Z: GPU делает z-тест до пиксельного шейдера, если шейдер не пишет глубину сам — скрытый фрагмент не платит за дорогой шейдинг. Поэтому сцены часто рисуют грубо front-to-back или отдельным z-prepass: сперва заполнить глубину, потом шейдить только победителей.
- Прозрачность ломает z-буфер. Полупрозрачный фрагмент должен смешаться с тем, что за ним, а z-тест бинарен (видно/нет). Поэтому непрозрачное рисуют с z-буфером в любом порядке, а прозрачное — отдельным проходом, отсортированным back-to-front (painter возвращается именно сюда).
- Z-fighting лечат разнесением near/far (не ставить near=0.001 «на всякий случай»),
polygon offsetдля копланарных декалей и reversed-z float-буфером. - Перспективно-корректная интерполяция: атрибуты (UV, цвет) линейны не по экрану, а в 3D — интерполируют
attr/wи1/w, делят в пикселе. Та же причина, по которой Quake тянулu/z, 1/z(см. урок Quake), а PS1 без этого «плавила» текстуры.
w) переводит всех в экранные пиксели. Z-буфер — очередь по близости у каждого окошка-пикселя: окошко помнит, кто сейчас ближе всех, и пускает нового, только если он встал ещё ближе. Никто никого не сортирует заранее — просто на каждом пикселе побеждает ближайший.
w, что клиппинг обязателен до деления, и что z-буфер сортирует пиксели, а не полигоны, — значит читать любой графический баг (вывернутая геометрия, z-fighting, плывущие текстуры, исчезающие прозрачности) как следствие конкретного шага конвейера, а не магию.
w).
Системы / данные: цепочка MVP = композиция трансформаций в ETL-пайплайне, слитая в один проход (как слияние матриц экономит умножения). Z-буфер = «keep-max/keep-min по ключу» — дедуп по последней версии, LWW-регистры в CRDT, top-1 на партицию без сортировки всего.
ML / AI: цепочка матриц — это тот самый граф линейных слоёв, через который бэкпропят дифференцируемые рендеры (PyTorch3D, nvdiffrast): проекция камеры и есть слой сети. Проективная камера x/z живёт в NeRF и 3D Gaussian Splatting — там перспективное деление дифференцируемо, а растеризатор гауссиан = «мягкий» z-буфер с альфа-композитингом. Сам z-тест z<buf = max-pooling / scatter-reduce (argmax по глубине = argmax по каналу). Однородные координаты = проективное вложение в калибровке камер, SfM, эпиполярной геометрии.
Перф: early-z = ранний выход дорогого вычисления по дешёвому предикату (short-circuit, predicate pushdown в SQL — отбрось строку до тяжёлого join). Guard-band = быстрый путь для «целиком внутри / целиком снаружи», медленный только для граничного случая.
Принцип: сливай линейные шаги в один; нелинейность откладывай в конец; и не сортируй всё, если на каждой ячейке достаточно хранить победителя.
🕹 В какие игры поиграть — и что заметить
Один конвейер, но у каждого железа свой компромисс на шагах «глубина», «точность вершин» и «текстуры». Артефакты эпохи — это прямой рентген того, какой шаг урезали. По каждому: что внутри и во что сыграть, чтобы увидеть руками.
Самый наглядный кейс — урезаны три шага сразу. (1) Нет z-буфера: глубину сортировали по полигону через ordering table (GTE считал среднюю глубину вершин треугольника) → при близких глубинах полигоны выскакивают друг перед другом. (2) Только fixed-point, нет субпиксельной точности → вершины дрожат/прыгают при движении камеры. (3) Аффинные текстуры без перспективной коррекции → текстуры плывут и колышутся на больших полигонах. Три знаменитых «PS1-глюка» = три выкинутых шага конвейера.
🎮 Сыграй: запусти Crash Bandicoot, Tomb Raider или Final Fantasy VII (эмулятор/PS-классика). Поводи камеру у длинной стены — увидишь дрожание вершин и колыхание текстуры; смотри на стыки полов/стен — фрагменты мерцают, кто перед кем (нет z-буфера). Это конвейер с тремя дырами.
Прямой контраст PS1: RDP делал per-pixel z-буфер, перспективно-корректные текстуры, трилинейный мип-маппинг и сглаживание — всё аппаратно. Геометрия не дрожит, текстуры не плывут. Цена компромисса уехала в другое место: крошечный 4 КБ texture cache (TMEM) → текстуры маленькие и замыленные, плюс фирменный «вазелиновый» AA-блюр.
🎮 Сыграй: в Super Mario 64 поводи камерой у той же стены — вершины стоят как влитые, текстура не колышется (есть z-буфер и коррекция), но сама текстура мутная и тянется на крупный полигон (4 КБ кэша). Сравни бок о бок с PS1 — увидишь, какой шаг кто оставил, а какой урезал.
Третий путь: на Pentium полноэкранный z-буфер дорог, и id Software рисовала мир (BSP) сортировкой спанов по 1/z через edge list — каждый пиксель мира рисуется ровно один раз, без буфера глубины. А вот модели (монстры, оружие), спрайты и частицы — уже через z-буфер, чтобы корректно пересекаться с миром. Гибрид: дорогой z-буфер только там, где сортировка полигонов не справляется.
🎮 Сыграй: в QuakeSpasm введи r_speeds 1 — счётчик нарисованных поверхностей мира. Благодаря edge-list мир рисуется почти без overdraw (счётчик скромный). Подойди вплотную к монстру у стены — он корректно входит в геометрию: это уже z-буфер моделей поверх спанов мира.
Аппаратное ускорение сделало z-буфер дешёвым → им стали крыть всё, и ручные трюки с сортировкой спанов и FDIV исчезли. Зато проявился чисто-буферный артефакт: z-fighting на копланарных поверхностях вдали (не хватает разрядов глубины — см. хардкор про точность).
🎮 Сыграй: в GLQuake (или любой игре конца 90-х на Voodoo) найди далёкую стену с декалью/плакатом вплотную к ней — на расстоянии увидишь мерцание между ними при движении. Это z-буфер, которому не хватило точности именно вдали (точность ушла к камере).
Тот же конвейер, но видимый через дев-инструменты: можно отрисовать сам буфер глубины, wireframe (треугольники после клиппинга), overdraw-тепловую карту (сколько раз перекрыт пиксель).
🎮 Посмотри: в Godot включи debug-draw Overdraw и Wireframe, в Unity — Rendering Debugger / Frame Debugger. Поводи камеру: увидишь, как backface cull срезает обратные грани, а перекрытия гасит z-буфер. Конвейер из этого урока — на экране.
u/z, 1/z) и спан-сортировка мира вместо z-буфера: тот же конвейер, ручная оптимизация под Pentium.Почему перспектива — это деление на z, а не вычитание или что-то линейное?
(x,z) пересекает плоскость экрана на расстоянии d в точке x·d/z — отношение катетов. Это проективная, а не аффинная операция: параллельные в мире линии сходятся в точку схода именно из-за деления на глубину. Линейная (аффинная) проекция — это ортография: глубина не влияет на размер, сходимости нет. Деление на z — математическая суть «дальнее меньше».Зачем вообще однородная координата w, если можно просто поделить на z вручную?
MVP. Если делить вручную посреди цепочки, перед делением нельзя слить с последующими матрицами и нельзя клиппить в удобном линейном пространстве. w переносит деление в самый конец и делает его одним общим шагом для x,y,z. Бонус: w хранит знак глубины — по нему режут точки из-за камеры до того, как деление их «завернёт» в кадр.z-буфер хранит ближайшую глубину — почему тогда точность хуже у дальних объектов, а не у ближних?
1/z, а не z (так выпадает из деления на w). Функция 1/z круто меняется у камеры и почти плоская вдали → одинаковым шагам буфера соответствуют микроскопические интервалы глубины вблизи и огромные вдали. При n=0.1, f=1000 половина значений буфера тратится на z<0.2. Отсюда z-fighting именно у дальних поверхностей. Лечит reversed-z float-буфер (см. хардкор).Если z-буфер так хорош, зачем Doom/Quake вообще возились с BSP и сортировкой спанов?
Painter's algorithm и z-буфер делают одно и то же — зачем оба существуют сегодня?
Backface culling по экранной площади — а если меш одностенный (бумажный)?
- Scratchapixel — «Rasterization: a Practical Implementation» и «The Perspective and Orthographic Projection Matrix» (вывод с нуля).
- Akenine-Möller, Haines, Hoffman, «Real-Time Rendering» — конвейер, клиппинг, z-буфер, reversed-z.
- Michael Abrash, «Graphics Programming Black Book» — софт-растеризатор и спан-сортировка Quake.
- Pikuma, «How PlayStation Graphics & Visual Artefacts Work» — почему PS1 дрожит и плывёт (конвейер без трёх шагов).
- nvdiffrast / PyTorch3D — дифференцируемая растеризация: тот же конвейер, через который бэкпропят.
- Модуль 3 (
03-3d-revolution-1993-1999.md), раздел «3D Graphics Pipeline».