← Модуль 3/3D-конвейер растеризации
EN
Модуль 3 · 3D-революция (1993–1999)

3D-конвейер: от вершины к пикселю

Как треугольник в координатах модели становится пикселями на экране: цепочка матриц model→world→view→clip, деление на глубину (перспектива), клиппинг по ближней плоскости и растеризация, где z-буфер решает за каждый пиксель «кто ближе». Это скелет, общий для Quake, GPU и дифференцируемых рендеров.
🏠 лабаглубокий~18 мин
Суть за 30 секунд
Вершина проходит цепочку пространств: локальные координаты модели → мир (model-матрица) → пространство камеры (view-матрица) → клип-пространство (projection-матрица). Перспектива — это деление на глубину: матрица проекции кладёт z в координату w, а потом идёт perspective divide (делим на w) → нормализованные координаты [−1,1]³, которые масштабируются в пиксели. Перед делением обязателен клиппинг по ближней плоскости (на z≤0 делить нельзя). Глубину между пикселями разрешает z-буфер: на каждый пиксель храним ближайшую глубину и рисуем фрагмент, только если он ближе. Painter / BSP сортируют полигоны; z-буфер сортирует пиксели — и потому почти вытеснил всё остальное.

Цепочка матриц: пять пространств

«Где на экране эта вершина?» — это последовательность смен системы координат, каждая = умножение на матрицу 4×4 в однородных координатах:

модельлокальн. мир×model камера×view клип×proj, w=z NDC÷w · [−1,1]³ экранviewport→px зелёный — геометрия · синий — камера · жёлтый — проекция и деление клиппинг по ближней плоскости — между «клип» и «÷w» растеризация + z-буфер — после «экран»

Model ставит меш в мир (позиция/поворот/масштаб инстанса). View — это обратная матрица камеры: вместо «двигать камеру» двигаем весь мир так, будто камера в начале координат смотрит вдоль оси. Projection не проецирует сама — она готовит деление: кладёт глубину z в координату w и масштабирует по полю зрения. На GPU первые три обычно слиты в одну MVP-матрицу (model-view-projection) и умножаются за один проход в вершинном шейдере.

Перспектива = деление на глубину

Дальние объекты меньше — потому что экранная координата пропорциональна 1/z. Камера-обскура (pinhole) с фокусным расстоянием d: точка (x, y, z) в пространстве камеры падает на плоскость экрана в

xs= d·xz , ys= d·yz

Числовой пример. Фокус 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 в софте обходился сортировкой спанов — об этом ниже.

экран (вид сверху на пиксели), глубина растёт вправо → тр. A · z=5 тр. B · z=9 зона перекрытия: 5 < 9 → виден A (зелёный), B отброшен порядок отрисовки не важен — побеждает ближайший

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(z)= ff−n · (1−nz)

Проверка: 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 без этого «плавила» текстуры.
Аналогия
Конвейер — это таможенный коридор смен координат: на каждом посту вершине ставят штамп новой системы отсчёта (модель → мир → камера → клип), а на выходе один общий «пересчёт в масштаб 1:1» (деление на w) переводит всех в экранные пиксели. Z-буфер — очередь по близости у каждого окошка-пикселя: окошко помнит, кто сейчас ближе всех, и пускает нового, только если он встал ещё ближе. Никто никого не сортирует заранее — просто на каждом пикселе побеждает ближайший.
Почему это важно
Это скелет всей real-time графики — от софт-рендера Quake до современного GPU и дифференцируемых рендеров. Понимать, что перспектива — это деление на w, что клиппинг обязателен до деления, и что z-буфер сортирует пиксели, а не полигоны, — значит читать любой графический баг (вывернутая геометрия, z-fighting, плывущие текстуры, исчезающие прозрачности) как следствие конкретного шага конвейера, а не магию.
🔁 За пределами игр — куда это переносится
Конвейер — это два общеинженерных приёма: композиция линейных преобразований (цепочка матриц = пайплайн, который можно слить в одну) и per-element reduction (z-буфер = поэлементный argmin без глобальной сортировки), плюс проективная нормализация (деление на 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 = быстрый путь для «целиком внутри / целиком снаружи», медленный только для граничного случая.

Принцип: сливай линейные шаги в один; нелинейность откладывай в конец; и не сортируй всё, если на каждой ячейке достаточно хранить победителя.

🏠 Лаба — мини-растеризатор куба
Интерактивная лаба без кода: вращающийся куб, прогнанный через ту же цепочку. Крути FOV и поворот, включай/выключай z-буфер (увидишь, как дальние грани лезут поверх ближних — painter-баг), backface culling (половина граней пропадает), переключай перспективу на ортографию. Глубина из формулы — глазами. Открыть лабу →
Лучший момент: выключи z-буфер при вращении — задняя грань начнёт «протыкать» переднюю в зависимости от порядка отрисовки. Это ровно то, от чего z-буфер избавляет.

🕹 В какие игры поиграть — и что заметить

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

PlayStation 1 1994 · конвейер без z-буфера

Самый наглядный кейс — урезаны три шага сразу. (1) Нет z-буфера: глубину сортировали по полигону через ordering table (GTE считал среднюю глубину вершин треугольника) → при близких глубинах полигоны выскакивают друг перед другом. (2) Только fixed-point, нет субпиксельной точности → вершины дрожат/прыгают при движении камеры. (3) Аффинные текстуры без перспективной коррекции → текстуры плывут и колышутся на больших полигонах. Три знаменитых «PS1-глюка» = три выкинутых шага конвейера.

🎮 Сыграй: запусти Crash Bandicoot, Tomb Raider или Final Fantasy VII (эмулятор/PS-классика). Поводи камеру у длинной стены — увидишь дрожание вершин и колыхание текстуры; смотри на стыки полов/стен — фрагменты мерцают, кто перед кем (нет z-буфера). Это конвейер с тремя дырами.

Nintendo 64 1996 · полный конвейер в железе

Прямой контраст PS1: RDP делал per-pixel z-буфер, перспективно-корректные текстуры, трилинейный мип-маппинг и сглаживание — всё аппаратно. Геометрия не дрожит, текстуры не плывут. Цена компромисса уехала в другое место: крошечный 4 КБ texture cache (TMEM) → текстуры маленькие и замыленные, плюс фирменный «вазелиновый» AA-блюр.

🎮 Сыграй: в Super Mario 64 поводи камерой у той же стены — вершины стоят как влитые, текстура не колышется (есть z-буфер и коррекция), но сама текстура мутная и тянется на крупный полигон (4 КБ кэша). Сравни бок о бок с PS1 — увидишь, какой шаг кто оставил, а какой урезал.

Quake — софт-рендер 1996 · спаны вместо z-буфера для мира

Третий путь: на Pentium полноэкранный z-буфер дорог, и id Software рисовала мир (BSP) сортировкой спанов по 1/z через edge list — каждый пиксель мира рисуется ровно один раз, без буфера глубины. А вот модели (монстры, оружие), спрайты и частицы — уже через z-буфер, чтобы корректно пересекаться с миром. Гибрид: дорогой z-буфер только там, где сортировка полигонов не справляется.

🎮 Сыграй: в QuakeSpasm введи r_speeds 1 — счётчик нарисованных поверхностей мира. Благодаря edge-list мир рисуется почти без overdraw (счётчик скромный). Подойди вплотную к монстру у стены — он корректно входит в геометрию: это уже z-буфер моделей поверх спанов мира.

GLQuake / 3dfx Voodoo 1997 · z-буфер на всё

Аппаратное ускорение сделало z-буфер дешёвым → им стали крыть всё, и ручные трюки с сортировкой спанов и FDIV исчезли. Зато проявился чисто-буферный артефакт: z-fighting на копланарных поверхностях вдали (не хватает разрядов глубины — см. хардкор про точность).

🎮 Сыграй: в GLQuake (или любой игре конца 90-х на Voodoo) найди далёкую стену с декалью/плакатом вплотную к ней — на расстоянии увидишь мерцание между ними при движении. Это z-буфер, которому не хватило точности именно вдали (точность ушла к камере).

Современный движок Godot / Unity · debug-буферы

Тот же конвейер, но видимый через дев-инструменты: можно отрисовать сам буфер глубины, wireframe (треугольники после клиппинга), overdraw-тепловую карту (сколько раз перекрыт пиксель).

🎮 Посмотри: в Godot включи debug-draw Overdraw и Wireframe, в Unity — Rendering Debugger / Frame Debugger. Поводи камеру: увидишь, как backface cull срезает обратные грани, а перекрытия гасит z-буфер. Конвейер из этого урока — на экране.

🔧 Запусти и поковыряй — на домашнем компе
Во что поиграть — выше (🕹). Здесь — залезть в конвейер инструментами:
🔧 Поковырять (debug) ~40 мин, Godot/RenderDoc
В Godot выведи на квад DEPTH_TEXTURE (буфер глубины) и посмотри, как нелинейно распределена точность: ближнее почти белое/чёрное, дальнее «слипается». Поставь два копланарных меша вплотную → поймай z-fighting, потом разнеси их или подвигай near/far камеры — мерцание уйдёт. Через RenderDoc сделай кадр-кап любой игры и пройди вершину по стадиям: clip-coords → NDC → screen, посмотри на отбракованные backface-треугольники.
🧪 Потестить (глазами QA) ~15 мин
Ищи артефакты по шагам конвейера: вывернутая/исчезающая геометрия у самого носа камеры (клиппинг по near / near выставлен слишком далеко); z-fighting вдали (точность глубины); прозрачность, рисующаяся в неверном порядке (сортировка back-to-front сломана); «дырки» в модели при взгляде в упор (backface cull + одностенная геометрия). Чеклист по стадиям пайплайна.
Чеклист: увидел нелинейность буфера глубины; воспроизвёл и убрал z-fighting; нашёл хотя бы один артефакт near-клиппинга или сортировки прозрачности.
Связи
основа
Doom и BSP — painter / BSP-порядок сортируют полигоны; z-буфер сортирует пиксели и потому победил. Контраст «сортировка геометрии vs буфер глубины».
основа
Quake: настоящий 3D — перспективно-корректные текстуры (u/z, 1/z) и спан-сортировка мира вместо z-буфера: тот же конвейер, ручная оптимизация под Pentium.
контраст
SNES Mode 7 — вырожденный конвейер: один плоский слой, аффинное обратное отображение, перспектива построчным делением. Здесь — полный 3D с per-pixel глубиной.
дальше
Камеры — view-матрица из этого урока с точки зрения дизайна: как двигать и не дезориентировать игрока.
Вопросы пытливого ума
Почему перспектива — это деление на 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 и сортировкой спанов?
Память и пропускная способность 1993–96. Полноэкранный z-буфер — это лишний буфер размером с кадр плюс чтение+запись глубины на каждый фрагмент. На железе без видеопамяти под это (и на PS1 в 1994) дешевле было сортировать полигоны заранее (BSP-порядок, ordering table) или рисовать каждый пиксель ровно раз (edge list Quake). Z-буфер стал дефолтом, только когда аппаратное ускорение сделало память глубины дешёвой — тогда сортировку геометрии и выкинули.
Painter's algorithm и z-буфер делают одно и то же — зачем оба существуют сегодня?
Z-буфер бинарен: пиксель либо ближе, либо нет. Он не умеет смешивать полупрозрачное — для стекла/дыма нужно знать, что за ним, и сложить с весом альфы. Поэтому непрозрачную геометрию рисуют z-буфером в любом порядке (быстро, точно по пикселям), а прозрачную — отдельным проходом, отсортированным back-to-front (painter возвращается). Один кадр использует оба: z-буфер для твёрдого, painter для прозрачного.
Backface culling по экранной площади — а если меш одностенный (бумажный)?
Тогда culling его убьёт с обратной стороны: лист, флаг, листва станут невидимы со спины (классическая «дыра» в модели при взгляде в упор). Решение — материал с двусторонним рендером (cull off) для таких поверхностей, ценой удвоения растеризуемых треугольников. Поэтому culling — настройка материала, а не глобальный переключатель: для замкнутых тел экономит половину, для плоских — ломает.
Что почитать