Однородные координаты и компонента 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].
Проверьте себя
1. Какое значение w у точки, а какое у направления?
AТочка w=0, направление w=1
BТочка w=1, направление w=0
CОба w=1
DОба w=0
2. Как из однородных координат (x,y,z,w) получить обычную 3D-точку?
AСложить все компоненты
BПоделить x, y, z на w
CУмножить на w
DОтбросить w
3. Почему перенос требует 4×4 матрицы, а не 3×3?
A3×3 слишком медленная
BПеренос нельзя выразить умножением 3×3 на 3D-вектор — нужна координата w
C4×4 экономит память
DТак требует цвет