Однородные координаты и компонента w
Чтобы перенос и перспектива выражались одним матричным умножением, к 3D-точке добавляют четвёртое число — w.
Однородные координаты — представление 3D-точки четвёркой (x, y, z, w); обычная точка получается делением на w: (x/w, y/w, z/w).
Зачем это знать
Поворот и масштаб выражаются умножением 3×3 матрицы на вектор, а вот перенос — нет: его нельзя записать как умножение 3×3. Решение элегантно: добавить четвёртую координату w и работать с 4×4 матрицами. Тогда и поворот, и масштаб, и перенос, и даже перспектива становятся единообразным умножением матрицы на вектор.
Точки и направления
Правило простое: точка имеет w = 1, направление (вектор) имеет w = 0. Это не каприз: при переносе точка должна сдвинуться, а направление — нет (стрелка «вправо» остаётся «вправо», где бы её ни нарисовали). Нулевой w как раз обнуляет вклад переноса.
# 4x4 матрица переноса на (tx,ty,tz); умножаем на однородный вектор
def translate_matrix(tx, ty, tz):
return [
[1, 0, 0, tx],
[0, 1, 0, ty],
[0, 0, 1, tz],
[0, 0, 0, 1],
]
def mat_vec(M, v):
return [sum(M[r][c] * v[c] for c in range(4)) for r in range(4)]
T = translate_matrix(5, 0, 0)
point = [2, 3, 1, 1] # точка: w = 1
direction = [2, 3, 1, 0] # направление: w = 0
print("Точка после переноса: ", mat_vec(T, point))
print("Направление после переноса:", mat_vec(T, direction))
Вывод:
Точка после переноса: [7, 3, 1, 1] Направление после переноса: [2, 3, 1, 0]
Точка сдвинулась на +5 по X, а направление осталось неизменным — ровно потому, что у него w = 0.
Деление перспективы
Главный фокус: проекционная матрица кладёт глубину в координату w. После умножения w перестаёт быть единицей, и финальное перспективное деление (делим x, y, z на w) автоматически сжимает дальние объекты. Перспектива оказывается просто следствием деления на w.
def perspective_divide(v):
x, y, z, w = v
return (round(x/w, 3), round(y/w, 3), round(z/w, 3))
# после проекции глубина попала в w
near = [1, 1, 0, 1] # близкий объект, w=1
far = [1, 1, 0, 5] # далёкий объект, w=5
print("Близкий после деления:", perspective_divide(near))
print("Далёкий после деления:", perspective_divide(far))
Вывод:
Близкий после деления: (1.0, 1.0, 0.0) Далёкий после деления: (0.2, 0.2, 0.0)
Тот же объект на экране стал в пять раз меньше — перспектива получилась «бесплатно» из деления на w.
Как работает под капотом
Все вершины в вершинном шейдере выводятся как vec4 — это и есть однородные координаты в clip space. Сразу после шейдера железо делает деление на w (если w≠1), получая нормализованные координаты устройства (NDC) в диапазоне [-1, 1]. Затем NDC растягиваются на пиксели вьюпорта.
Частые ошибки
- Ставить w = 1 направлению (нормали, оси) — тогда оно начнёт «ездить» при переносе и освещение поедет.
- Забывать про деление на w и удивляться, почему перспектива «не работает».
- Путать clip space (до деления) и NDC (после деления на w).
Итоги
- Четвёртая координата w делает перенос и перспективу обычным матричным умножением.
- Точка: w = 1; направление: w = 0 (перенос на него не влияет).
- Перспектива — это деление x, y, z на w после проекции.
- Вершинный шейдер выдаёт vec4 в clip space; деление на w даёт NDC [-1, 1].