Deltadev-math.ru
// СВЕТ

Почему всё выглядит пластмассовым: нормали, N·L и блики

Откуда шейдер знает, куда повёрнута поверхность: нормали и обратно-транспонированная матрица, закон Ламберта и клампинг N·L, шейдинг Гуро против шейдинга Фонга, блик Blinn-Phong и его границы, нормал-маппинг и tangent space.

11 августа 2026·50 мин чтения·нормалиЛамбертГуро и Фонгбликнормал-маппинг
Дейв разворачивает нормаль на сфере, Delta подсвечивает направление света и блик

Всем привет! Меня зовут Гриша Дядиченко. Больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном заказная разработка и собственные прототипы.

В прошлой статье мы доставали из текстуры цвет и в самом конце проговорили важную вещь: albedo — это не «как выглядит поверхность», а «какую долю света она отражает». Долю. От чего-то. Вот про это «что-то» и поговорим.

Сталкивались с ощущением, что сцена вроде собрана, свет расставлен, материалы настроены — а выглядит всё как дешёвая пластмасса? Шар похож на бильярдный, камень похож на резину, металл вообще ни на что не похож. Крутишь ползунки — становится то ярче, то темнее, но пластмасса никуда не девается.

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

  • откуда шейдер вообще знает, куда повёрнута поверхность, — и почему нормаль ломается от одного неравномерного масштаба;
  • где считать свет — в вершине или в пикселе, чем шейдинг Гуро отличается от шейдинга Фонга и при чём тут полосы Маха;
  • как устроен блик Blinn-Phong, почему его экспоненту берут вчетверо больше фонговской и почему у этого блика нет никакого бюджета;
  • как карта нормалей рисует рельеф без единой лишней вершины;
  • и как всё вместе укладывается в восемь строк фрагментного шейдера.
// @easy_dev_math

Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.

Часть 1. В треугольнике нет света — есть вектор в вершине

Начнём с вопроса: откуда шейдер знает, что вот эта точка поверхности повёрнута к источнику, а вот эта — от него?

Никакой поверхности у видеокарты нет. Есть три точки в пространстве и правило, как их закрасить. Всё, что связывает эти три точки с понятием «повёрнута туда-то», — вектор нормали.

Edge нормаль: одна на треугольник

Для отдельного треугольника нормаль берётся из его же геометрии. Два ребра задают плоскость, векторное произведение даёт перпендикуляр:

n=(p1p0)×(p2p0)(p1p0)×(p2p0)\mathbf{n} = \frac{(\mathbf{p}_1 - \mathbf{p}_0) \times (\mathbf{p}_2 - \mathbf{p}_0)}{\left\lVert (\mathbf{p}_1 - \mathbf{p}_0) \times (\mathbf{p}_2 - \mathbf{p}_0) \right\rVert}

Порядок обхода вершин решает, куда этот перпендикуляр смотрит — наружу или внутрь. По нему же железо потом отсекает задние грани.

С такими нормалями сфера выглядит ровно тем, чем является: многогранником. Каждая грань освещена своим числом, между гранями — скачок.

Вершинная нормаль: усреднение соседей

Приём, которым из многогранника делают гладкий шар, придумал Анри Гуро в 1971 году (диссертация «Computer Display of Curved Surfaces» и статья «Continuous Shading of Curved Surfaces», IEEE Transactions on Computers, C-20(6), с. 623–629). Идея простая: в вершине сходится несколько граней — усредним их нормали и объявим результат нормалью вершины.

Дальше начинается интересное: с какими весами усреднять. Вариантов три, и они дают разный результат:

  • равные веса — как у самого Гуро, просто среднее;
  • по площади треугольника — большая грань голосует громче;
  • по углу при вершине — вклад пропорционален тому, какой кусок окрестности вершины занимает грань (Thürmer & Wüthrich, Journal of Graphics Tools 3(1), 1998).

Разница не косметическая. Возьмите ровный кусок поверхности и затриангулируйте его двумя способами: где-то в вершину сойдётся не одна грань, а две. Форма не изменилась, а вот равные веса и веса по площади дадут разные нормали — они поедут в сторону того куска, который натриангулирован гуще. Угловой вес от этого свободен: при разрезании многоугольника сумма углов при вершине сохраняется. Поэтому он и стал разумным дефолтом — например, ComputeNormals в DirectXMesh по умолчанию считает именно среднее, взвешенное по углу.

Чего я бы не стал утверждать — что угловой вес точнее всех. Замеры расходятся: у Нельсона Макса (Journal of Graphics Tools 4(2), 1999) на случайных поверхностях угловой вес показал худшую ошибку из шести схем, а у Jin, Lewis и West (The Visual Computer, 2005) на десяти моделях — наоборот, почти везде лучшую. Победитель зависит от класса поверхности, и с ростом плотности сетки разница между схемами стирается.

[ DEMO 01 ]//

Откуда берётся нормаль

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

Шов, который видно

Отдельная история — тумблер объединения вершин. У UV-сферы кольцо вершин при обходе на 360° задвоено: позиция одна, а вершин две, потому что у них разные u (ноль и единица). Так устроен любой UV-шов.

Для нормалей это ловушка. Каждая из двух вершин видит соседей только со своей стороны, усредняет половину окружения — и обе получают нормаль, наклонённую в свою сторону. На гладкой сфере вылезает вертикальная полоса, которой в геометрии нет. Лечится объединением вершин: перед усреднением вершины с совпадающей позицией объединяются в одну, а нормаль потом раздаётся обеим. Ровно та же история у полюсов, где позиция задвоена столько раз, сколько сегментов.

Жёсткое ребро (Hard Edge): та же задвоенность, но нарочно

У шва задвоенность случайная, и лечится она объединением. Но ровно та же механика — единственный способ получить по-настоящему острое ребро.

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

Выход единственный: положить в одну точку пространства три вершины с одинаковой позицией и разными нормалями. Каждая грань берёт свою.

Отсюда арифметика, которая удивляет при первом взгляде на статистику импорта: у куба восемь углов, а вершин двадцать четыре. Восемь точек, в каждой по три копии, по одной на грань. То же самое на фаске, в углу комнаты и на любом ребре, помеченном в редакторе как hard edge: «жёсткость» — это не свойство ребра, а вторая вершина вместо одной.

Так что вершинный счётчик считает не углы модели, а различимые комбинации «позиция + нормаль + UV». Каждый жёсткий стык и каждый UV-шов расщепляет вершину.

Нормаль ломается от скейла

Теперь про то, обо что регулярно спотыкаются на практике. Вершины едут в мир через модельную матрицу. Нормаль — не едет.

Точнее, едет, но по другому правилу: позицию умножают на модельную матрицу, а нормаль — на обратно-транспонированную, то есть на обратную к ней и потом транспонированную.

Откуда берётся обратно-транспонированнаяОсторожно! Математика!

Позиции умножаются на M, а нормали — на обратно-транспонированную матрицу от верхней 3×3 части:

n=(M3×31)Tn\mathbf{n}' = \left(M_{3\times3}^{-1}\right)^{\mathsf{T}} \mathbf{n}

Касательная t лежит в поверхности, поэтому она преобразуется как обычный вектор: t' = M·t. Нормаль по определению перпендикулярна касательной: nᵀt = 0. Хочется, чтобы после преобразования перпендикулярность сохранилась: (G·n)ᵀ(M·t) = 0, то есть nᵀ(GᵀM)t = 0. Это выполняется для любой касательной, если GᵀM = I, откуда G = (M⁻¹)ᵀ.

Отсюда же видно, почему на повороте и равномерном масштабе живёт и неправильный код. Для ортогональной матрицы M⁻¹ = Mᵀ, значит (M⁻¹)ᵀ = M — формулы совпадают буквально, и даже длина сохраняется. Для равномерного масштаба M = sR получается (M⁻¹)ᵀ = (1/s)R — тот же вектор с точностью до положительного множителя.

Смысл за формулой такой: нормаль — не стрелка, приклеенная к поверхности, а описание касательной плоскости. Сожмите объект по одной оси — касательная наклонится в одну сторону, а нормаль должна наклониться в противоположную. Обратная матрица это и делает.

Верхняя 3×3 берётся потому, что нормаль — направление, а не точка: перенос на неё не действует.

Где inverse transpose врётОсторожно! Нюанс!

Обратно-транспонированная матрица гарантирует перпендикулярность — и только её. Ориентацию она не гарантирует.

Если в матрице есть зеркалирование (определитель отрицательный — скажем, масштаб −1 по одной оси), деление на отрицательный определитель разворачивает нормаль в противоположную сторону. Освещение получается вывернутым: свет приходит оттуда, откуда не должен.

Устойчивая замена — транспонированная матрица алгебраических дополнений (кофакторная, она же присоединённая). Она равна det(M)·(M⁻¹)ᵀ, то есть отличается ровно на знак определителя, который в обратной матрице и теряется. Приятный побочный эффект: кофакторная матрица считается без деления, поэтому дешевле и численно устойчивее, а нормаль всё равно нормируется на выходе — общий положительный множитель роли не играет.

Интерполяция укорачивает нормаль

И последнее в этой части — вещь, которая объясняет строчку normalize() во фрагментном шейдере.

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

Насколько именно корочеОсторожно! Математика!

Растеризатор интерполирует нормаль между вершинами линейно. Возьмём два единичных вектора a и b и посмотрим на середину отрезка между ними:

12(a+b)=cosθ2\left\lVert \tfrac{1}{2}(\mathbf{a} + \mathbf{b}) \right\rVert = \cos\frac{\theta}{2}

где θ — угол между нормалями. Косинус половины угла всегда меньше единицы, кроме случая, когда нормали совпали.

Числа такие: при расхождении нормалей в 30° середина ребра теряет 3% длины, при 60° — 13%, при 90° — 29%. Для треугольника вывод тот же, барицентрические веса тоже дают выпуклую комбинацию.

Короткая нормаль занижает и N·L, и N·H, поэтому середина каждой грани темнеет — тем заметнее, чем сильнее расходятся вершинные нормали. По блику видно сильнее всего: там короткий вектор ещё и возводится в степень.

Лечится нормализацией в пикселе, а не в вершине: короче вектор становится именно при интерполяции, так что нормализовать до неё бессмысленно.

// вершинный шейдер
vNormal = mat3(uNormalMatrix) * aNormal;   // нормализовать тут — мимо

// фрагментный шейдер
vec3 N = normalize(vNormal);               // а вот тут обязательно

Врезка в левом нижнем углу демки показывает эту геометрию отдельно: две единичные нормали и то, что получается из них в середине.

Итак, нормаль у нас есть — единичная, в мировом пространстве, посчитанная в нужном месте. Дальше вопрос: что с ней делать.

Часть 2. N·L — это геометрия

Самая известная строчка во всей теме выглядит так:

// N — нормаль, L — направление на источник, оба единичные
float cosTheta = dot(N, L);          // dot единичных векторов = косинус угла между ними
float ndl      = max(cosTheta, 0.0); // отрицательный косинус — свет из-за спины, его в ноль

Скалярное произведение нормали и направления на источник, прижатое к нулю. Работает, все так пишут.

Откуда берётся косинус

Представьте параллельный пучок с поперечным сечением A. Если он приходит по нормали, вся эта энергия ложится на площадку A. Если он приходит под углом θ к нормали, тот же пучок растекается по площадке A / cos θ — она больше, а энергии в пучке столько же. Значит, на единицу площади достаётся меньше, ровно в cos θ раз:

E=Ecosθ=E(nl)E = E_{\perp} \cos\theta = E_{\perp} (\mathbf{n} \cdot \mathbf{l})

Собственно, всё. Поверхность темнеет при наклоне не потому, что она «хуже светит вбок», а потому, что на неё приходит меньше энергии на квадратный метр. Это чистая геометрия падающего пучка.

Закон назван по имени Иоганна Генриха Ламберта — он сформулирован в «Photometria» 1760 года. У закона Ламберта есть и вторая, излучательная форма: сила света от площадки падает как cos θ. Но в N·L мы пользуемся именно первой, «приходящей».

Ламбертовой при этом называется поверхность, которая рассеивает пришедшее одинаково во все стороны: уходящая яркость от угла взгляда не зависит вообще, только от угла падения. Поэтому у диффузной части и нет никакого V.

Идеально ламбертовых поверхностей не бывает, это идеализация. Ближе всего к ней матовая бумага или мел: белый лист выглядит одинаково белым, с какой стороны на него ни смотреть.

А шершавые материалы из идеализации выпадают. Орен и Найар в статье «Generalization of Lambert's Reflectance Model» (SIGGRAPH '94) показывают расхождение на глиняной вазе: Ламберт обещает, что цилиндр потемнеет к краям, а настоящая ваза выглядит плоской — яркость почти постоянна по всей поверхности. Причина в микрорельефе: шершавая поверхность отражает больше света назад, в сторону источника, а не по нормали. Самый известный носитель этого эффекта — Луна, её неламбертовость меряли ещё в 1920-е, задолго до компьютерной графики.

Множитель 1/π

В коде выше диффуз — это albedo * ndl, никакого π там нет. А в учебниках у альбедо стоит делитель π. Почему? Базовый ответ - в каких единицах измерения мы считаем.

Сначала откуда. Требование ровно одно и довольно скромное: поверхность не должна отдавать больше, чем получила, а ρ должно означать ровно долю отражённого. Дальше арифметика: ламбертова поверхность рассеивает пришедшее по всей полусфере, и если сложить её вклад сразу по всем направлениям, набегает не единица, а π. То есть с голым ρ в формуле поверхность отражала бы в π раз больше, чем на неё упало: при ρ = 1 — втрое с лишним. Делитель это и чинит.

Откуда в нормировке берётся именно πОсторожно! Математика!

Запись через энергию пучка:

Lo=ρπE(nl)L_o = \frac{\rho}{\pi} \, E_{\perp} \, (\mathbf{n} \cdot \mathbf{l})

В знаменателе — интеграл косинусного множителя по верхней полусфере:

H2(nl)+dω=02π ⁣ ⁣0π/2cosθsinθdθdφ=π\int_{H^2} (\mathbf{n} \cdot \mathbf{l})^{+} \, d\omega = \int_0^{2\pi}\!\!\int_0^{\pi/2} \cos\theta \sin\theta \, d\theta \, d\varphi = \pi

Множитель sin θ здесь якобиан телесного угла — без него получилось бы , и это самая частая ошибка в этом выводе. Смысл величины: π — проецированный телесный угол полусферы, тогда как обычный телесный угол полусферы равен .

Теперь про шейдер. E_⊥ — это энергия пучка на квадратный метр. Там задают lightColor — цвет, который показала бы белая ламбертова поверхность, освещённая этим источником по нормали. Такое определение само тащит в себе π:

через энергию пучка:  L_o = (ρ/π) · E_⊥ · (N·L)
единицы движка:       E_⊥ = π · lightColor
что остаётся:         L_o = ρ · lightColor · (N·L)

Собственно, поэтому деления в шейдере и нет — π из нормировки сократился с π из единиц света. Пока весь свет в кадре описан этими единицами, всё сходится.

Теперь нам нужен кламп

N·L — косинус, а косинус бывает отрицательным. Отрицателен он ровно тогда, когда угол больше 90°, то есть источник ушёл за горизонт поверхности и физически её не освещает.

Без клампа отрицательный косинус не «ничего не добавляет» — он вычитает. С одним источником это ещё можно не заметить, а вот когда их несколько, задняя лампа начинает гасить подсветку от передней, и на модели появляются провалы в абсолютный чёрный. В HDR-конвейере отрицательные значения поедут дальше — в тонмаппинг, в блум, куда угодно.

В формальной записи уравнения рендеринга это фиксируют нотацией (n·l)⁺, где x⁺ = max(x, 0).

Отдельно замечу, что клампить надо и в бликовой части, причём до возведения в степень. Чётная степень превратит отрицательное основание в положительное, и на неосвещённой стороне объекта загорится блик. А для дробных показателей pow() от отрицательного аргумента в GLSL вообще не определён и вернёт NaN.

В шейдере обрезка живёт в трёх местах, и все три обязательны:

float ndl = max(dot(N, L), 0.0);            // 1. диффуз: отрицательную половину — в ноль
vec3  diffuse = albedo * ndl;

vec3  H   = normalize(L + V);               // половинный вектор, о нём — часть 4
float ndh = max(dot(N, H), 0.0);            // 2. основание степени: кламп ДО pow
float spec = ndl > 0.0 ? pow(ndh, exponent) // 3. гейт: на неосвещённой стороне блика нет
                       : 0.0;

Свет считается в линейном пространстве

И ещё одна вещь, без которой диффуз выглядит неправильно.

В статье про гамму мы разбирали, что значения в текстуре и в кадровом буфере — sRGB-кодированные, а не линейные. Свет складывается и умножается линейно, поэтому вся арифметика освещения обязана идти по линейным величинам, а кодирование в sRGB — это последний шаг конвейера, уже на выводе.

Если посчитать N·L прямо по sRGB-значениям и отправить результат на экран без кодирования, на дисплее получится примерно albedo · (N·L)^2.2 вместо albedo · (N·L). То есть косинусный спад возводится в степень 2.2. Что видно:

  • полутона проваливаются. Точка под 60° к источнику (N·L = 0.5) должна быть вдвое темнее полностью освещённой, а выглядит вчетверо темнее: 0.5^2.2 ≈ 0.22;
  • терминатор становится жёстким. Переход от света к тени сжимается в узкую полосу, сфера читается не как объём, а как яркое пятно с обрывом в черноту;
  • ломается аддитивность. Два источника перестают складываться как в реальности: зона их пересечения либо выбивается в клиппинг, либо недобирает;
  • уезжает оттенок. Белый блик, добавленный к диффузу в нелинейном пространстве, тянет в жёлтый.

Классический разбор всего этого — глава 24 GPU Gems 3, «The Importance of Being Linear».

[ DEMO 02 ]//

N·L: сколько света ловит площадка

Тащите источник мышкой. Переключатель пространства и есть то, о чём выше: в положении «прямо в sRGB» формула та же самая, меняется только момент, когда применяется кривая. Кламп выключите при источнике, уведённом за край сферы, — тёмная сторона провалится ниже подсветки.

Точная передаточная функция sRGBДля тех, кто пишет её руками

sRGB (IEC 61966-2-1) — не просто степень 2.2, а кусочная кривая: линейный отрезок у нуля плюс степенной участок с показателем 2.4 и смещением.

Кодирование, линейное → sRGB:

C_srgb = 12.92 · C_lin,                    C_lin ≤ 0.0031308
C_srgb = 1.055 · C_lin^(1/2.4) − 0.055,    иначе

Декодирование, sRGB → линейное:

C_lin = C_srgb / 12.92,                    C_srgb ≤ 0.04045
C_lin = ((C_srgb + 0.055) / 1.055)^2.4,    иначе

Линейный участок у нуля нужен, чтобы у кривой не было бесконечной производной в нуле и чтобы самые тёмные уровни не схлопывались при 8-битном квантовании. Из-за него и из-за смещения суммарная кривая по форме близка к гамме 2.2 — отсюда и приближение, которое годится для прикидок, но не является определением.

В шейдерах писать это руками обычно не нужно: sRGB-форматы текстур и кадрового буфера делают преобразование аппаратно и бесплатно.

Итак, с диффузом разобрались: косинус с клампом, альбедо, линейное пространство. Дальше вопрос не «как считать», а «где».

Часть 3. Где считать свет: в вершине или в пикселе

Формула у нас есть. Теперь — в какой точке её вычислять. Мест три, и они дают три разные картинки при одной и той же математике.

Плоский шейдинг. Одна нормаль на треугольник, один цвет на весь треугольник. Дёшево, и видно каждую грань.

Шейдинг Гуро. Освещение считается в вершинах, а по треугольнику интерполируется уже готовый результат. Тот самый Гуро из первой части: он и усреднение нормалей предложил, и эту схему закраски.

Шейдинг Фонга. По треугольнику интерполируется нормаль, а модель освещения вычисляется в каждой точке растра. Предложил Буй Тыонг Фонг — сначала в диссертации 1973 года, затем в статье «Illumination for Computer Generated Pictures», Communications of the ACM 18(6), 1975, с. 311–317.

Разница между Гуро и Фонгом в одной фразе: у Гуро по треугольнику едет результат освещения, у Фонга — нормаль.

Полосы Маха: почему Гуро видно даже на гладком диффузе

Интерполированная по треугольнику интенсивность непрерывна — разрывов яркости на рёбрах нет. Но она кусочно-линейна, а значит, на рёбрах скачком меняется её производная. Излом.

И вот тут вмешивается зрение. Полосы Маха — зрительная иллюзия, описанная Эрнстом Махом ещё в 1865 году: там, где меняется наклон профиля яркости, глаз дорисовывает светлую и тёмную каёмки, которых в изображении физически нет. Механизм — латеральное торможение между соседними клетками сетчатки.

Фонг в статье 1975 года приводит формулировку самого Маха и делает из неё практический вывод, который стоит запомнить: мельчить сетку бесполезно. Излом производной никуда не денется от того, что граней стало вдвое больше, — эффект уйдёт только когда грань схлопнется до размера пикселя.

Блик, который теряется между вершинами

Но главная беда Гуро даже не в полосах. Диффуз меняется по поверхности плавно, и линейная интерполяция врёт по нему умеренно.

А вот блик — узкий. При экспоненте под сотню он занимает считанные градусы. И если ни одна вершина в него не попала, то в вершинах блика нет, интерполировать нечего — блика не будет и на треугольнике. Стоит повернуть объект на пару градусов, вершина заедет в блик — и он вспыхнет целым треугольником.

Получается блик, который прыгает по сетке и мигает при вращении. Никаким сглаживанием это не лечится, потому что данных просто нет.

[ DEMO 03 ]//

Где считать свет: в вершине или в пикселе

Поставьте Гуро, задерите экспоненту, потяните слайдер поворота — и увидите ровно это. Тот же слайдер на Фонге не даёт никакого мерцания: там нормаль интерполируется, и блик считается в каждом пикселе честь по чести.

Второй тумблер — та самая нормализация из первой части. Выключите её на грубой сетке: середина каждой грани потемнеет.

Немного истории

Ходовое объяснение «раньше считали по вершинам, потому что дорого» верно, но несовсем.

Да, вершин на порядки меньше, чем фрагментов, и модель освещения в вершине дешевле. Но вторая причина не менее важная: фиксированный конвейер не имел способа выразить освещение на уровне пикселя вообще. OpenGL 1.x и Direct3D вплоть до DX7 умели освещать только по вершинам, и аппаратный блок T&L ускорял ровно то же самое per-vertex освещение.

Вехи перехода, если интересно:

  • 1996 — лайтмапы Quake: попиксельная диффузная составляющая, но посчитанная заранее;
  • 20 мая 1999 — Matrox G400 с EMBM, первое железо с попиксельным bump-эффектом из DirectX 6;
  • 11 октября 1999 — NVIDIA GeForce 256, первый одночиповый потребительский GPU с T&L на борту (в рабочих станциях аппаратный T&L был раньше — 3Dlabs GLINT Gamma, Comdex 1997);
  • 14 августа 2001 — операция DOT3 (N·L на пиксель через комбайнеры) входит в ядро OpenGL 1.3;
  • 9 ноября 2000 / 27 февраля 2001 — анонс DirectX 8.0 и GeForce 3: программируемые пиксельные шейдеры, очень короткие и с фиксированной точкой;
  • лето 2002 — ATI Radeon 9700 Pro на чипе R300: Pixel Shader 2.0, плавающая точка на пиксель. Карта приехала раньше самого DirectX 9.0, который вышел 19 декабря 2002;
  • 7 сентября 2004 — GLSL входит в ядро OpenGL 2.0;
  • 3 августа 2004 — Doom 3: id Tech 4 отказывается от лайтмап в пользу полностью динамического попиксельного освещения.
Half-Life 2 — не тот же случайЧастая ошибка в пересказах

Half-Life 2 вышла 16 ноября 2004, через три месяца после Doom 3, и её часто ставят в тот же ряд. По механике это другое.

Освещение мира в Source — radiosity normal mapping: три предрасчитанные направленные лайтмапы в специальном базисе, которые считываются на пиксель поверх карты нормалей. Попиксельно — да. Динамически — нет. Разбор от самих авторов: доклад Гэри Мактаггарта на GDC 2004.

И сразу оговорка: per-vertex освещение не умерло. Оно живо на слабом железе, на мобилках и для статики. Главный довод против него сегодня — не цена, а то, что блик между вершинами теряется, а попиксельные нормали (то есть весь нормал-маппинг из части 5) в вершинной схеме невыразимы в принципе.

Часть 4. Блик

Диффуз не зависит от того, откуда вы смотрите. Блик — зависит: он и есть та часть, где важен угол между отражённым лучом и глазом. Дальше начинается самое интересное, потому что тут заканчивается геометрия и начинается подгонка.

Фонг: отразить луч и посмотреть, куда он попал

У Фонга блик считается напрямую. Отражаем направление на источник относительно нормали и смотрим, насколько отражённый луч разошёлся со взглядом:

r=2(nl)nl,spec=max(0, rv)p\mathbf{r} = 2(\mathbf{n} \cdot \mathbf{l})\mathbf{n} - \mathbf{l}, \qquad \text{spec} = \max(0,\ \mathbf{r} \cdot \mathbf{v})^{p}

Экспонента p отвечает за ширину блика: чем больше, тем уже. У самого Фонга в статье 1975 года она гуляет от 1 до 10 — это позже её начали задирать до сотен.

Блинн: половинный вектор

Через два года Джеймс Блинн опубликовал «Models of Light Reflection for Computer Synthesized Pictures» (SIGGRAPH '77, с. 192–198), и оттуда пришла запись, которой сегодня пользуются чаще фонговской:

h=l+vl+v,spec=max(0, nh)s\mathbf{h} = \frac{\mathbf{l} + \mathbf{v}}{\lVert \mathbf{l} + \mathbf{v} \rVert}, \qquad \text{spec} = \max(0,\ \mathbf{n} \cdot \mathbf{h})^{s}

Заявленный вклад той работы — не половинный вектор, а микрофасетная модель блика Торранса—Спэрроу вида D·G·F/(N·E): распределение ориентаций микрограней, геометрическое затенение и френелевское отражение. В качестве распределения Блинн берёт распределение Троубриджа—Рейца — то самое, которое сегодня все знают как GGX.

Половинный вектор он вводит по ходу, в разделе про модель Фонга. И вводит осмысленно: H — это направление, вдоль которого нормаль идеально зеркальной микрограни отразила бы свет источника точно в глаз. То есть блик в направлении V дают ровно те микрограни, чья нормаль совпала с H, а (N·H)^s — это утверждение о том, сколько таких микрограней. Не «дешёвая замена R·V», а другая постановка вопроса.

В прочем она и дешевле:

  • не нужно на каждую точку строить вектор отражения;
  • если считать источник и камеру бесконечно удалёнными, H вообще постоянен по всему кадру и вычисляется один раз на смену направления света.

Почему экспоненту берут вчетверо больше

Если подставить в обе формулы одно и то же число, блики получатся разной ширины. Причина геометрическая.

Когда N, L и V лежат в одной плоскости — в плоскости падения, — угол между N и H ровно вдвое меньше угла между R и V. Вне этой плоскости равенство только приближённое, и тем точнее, чем меньше углы.

Дальше простая оценка. Около максимума cos^n θ ≈ exp(−n·θ²/2), то есть ширина лепестка идёт как 1/√n. Вдвое меньший угол требует вчетверо большей степени, чтобы блик остался той же ширины:

cospθ    cos4p ⁣(θ2)\cos^{p}\theta \;\approx\; \cos^{4p}\!\left(\frac{\theta}{2}\right)

Отсюда правило: блик, который у Фонга даёт p = 8, у Блинна—Фонга требует s ≈ 32. Первоисточник — Fisher & Woo, «R.E versus N.H Specular Highlights», Graphics Gems IV, 1994, с. 388–400; то же соотношение приводит Real-Time Rendering (4-е изд., с. 422). Множитель приближённый: он тем точнее, чем выше степень, у Готанды встречается уточнение 4.2, а практические руководства вроде LearnOpenGL дают вилку «в 2–4 раза больше».

[ DEMO 04 ]//

Блик, у которого нет бюджета

Пунктирная кривая на графике — вторая модель при той же экспоненте. Видно и разницу в ширине, и вторую вещь: у Фонга кривая обрывается. При угле между R и V больше 90° скалярное произведение уходит в минус, зажимается в ноль — и блик заканчивается видимой кромкой. На широких бликах и у краёв объектов это заметно. У половинного вектора такого не бывает: угол между N и H на видимой полусфере всегда меньше 90°, блик гаснет плавно.

Про энергию

А теперь то, ради чего эта часть и написана.

Посмотрите на счётчик «отдано / пришло» в демке. Это отношение энергии, которую блик-член отдаёт по всей полусфере, к энергии, которая на площадку пришла. Покрутите экспоненту.

Число живёт своей жизнью. На низких экспонентах оно больше единицы — модель отдаёт больше света, чем получила. На высоких — падает. Никакого закона сохранения в формуле нет: (N·H)^s — это просто степень косинуса, и то, сколько она в сумме отдаст, зависит от s как получится.

Отсюда следствие, которое в работе выглядит как «магия материала»: у голого (N·H)^s яркость в центре блика не зависит от s вообще. Растёт s — лепесток сужается, суммарная отражённая энергия падает, и материал темнеет от одного лишь изменения глянцевости. Художник крутит ползунок «гладкость», объект уходит в темноту, художник компенсирует это интенсивностью источника — и ломает баланс со всеми остальными объектами в сцене.

Лечится это нормировкой: множителем, который делает суммарную отдачу независимой от экспоненты. Но это уже разговор про то, как модель приводят к физике, и он целиком относится к следующему сезону.

Ambient — заглушка, и проблемы у неё свои

Последний член в наивной формуле — постоянная добавка ambient, которая не даёт неосвещённой стороне провалиться в чёрный.

Формально это модель, в которой приходящая яркость одинакова со всех направлений. Для ламбертовой поверхности интеграл тогда схлопывается в один член ρ · L_A, который не зависит ни от нормали, ни от геометрии вокруг, ни от того, видно ли из этой точки небо.

Чем это отличается от настоящего непрямого света:

  • настоящий непрямой свет направлен — сверху приходит небо, снизу отражение от пола, и они разного цвета;
  • он загорожен геометрией — в щелях и под объектами света меньше;
  • он окрашен отражающими поверхностями;
  • он зависит от места в сцене, а константа одинакова в комнате и на улице.

И проблема у константного ambient вполне конкретный: теневая сторона объекта превращается в равномерную заливку. Там пропадает форма, мелкая геометрия и карта нормалей в тени просто не читаются, контактного затемнения нет — объект выглядит наклеенным на фон. А чем сильнее задрали константу, чтобы вытащить тени, тем более молочными становятся чёрные и тем ниже общий контраст.

Как выглядит нормировкаСпойлер следующего сезона

Нормировок две, и их путают.

Как распределение микрограней: D(H) = ((α + 2)/(2π)) · (N·H)^α. В полной формуле затенения с неявным геометрическим членом это даёт множитель (α + 2)/8 — вывод у Нэйти Хоффмана в курсовых заметках SIGGRAPH 2010.

Отдельно ходит нормировка всей ОДЯ из Real-Time Rendering 3-го издания: дешёвое приближение (α + 8)/(8π).

Первая нормирует только распределение, вторая — всю функцию вместе с неявным членом видимости, и потому чуть ярче.

Часть 5. Рельеф, которого нет

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

Так и работает карта нормалей. В текстуре лежит не цвет, а направление, и шейдер берёт нормаль оттуда, а не из интерполированного атрибута вершины.

Вопрос — в какой системе координат хранить это направление. В мировой нельзя: повернули объект, и вся карта поехала. В объектной — можно, но тогда карту не переиспользовать между разными местами модели и она не тайлится. Поэтому хранят в системе координат самой поверхности.

Тангентное пространство и TBN

Базис поверхности строится из трёх векторов: T смотрит туда, куда растёт u, B — туда, куда растёт v, N — наружу. Первые два выводятся из треугольника: рёбра в пространстве выражаются через рёбра в UV.

e1=Δu1T+Δv1B,e2=Δu2T+Δv2B\mathbf{e}_1 = \Delta u_1 \mathbf{T} + \Delta v_1 \mathbf{B}, \qquad \mathbf{e}_2 = \Delta u_2 \mathbf{T} + \Delta v_2 \mathbf{B}

Это система двух уравнений, и решается она обращением матрицы UV-дельт:

T=f(Δv2e1Δv1e2),B=f(Δu1e2Δu2e1),f=1Δu1Δv2Δu2Δv1\mathbf{T} = f\,(\Delta v_2 \mathbf{e}_1 - \Delta v_1 \mathbf{e}_2), \quad \mathbf{B} = f\,(\Delta u_1 \mathbf{e}_2 - \Delta u_2 \mathbf{e}_1), \quad f = \frac{1}{\Delta u_1 \Delta v_2 - \Delta u_2 \Delta v_1}

Знаменатель обращается в ноль, если у треугольника нулевая площадь в UV, — такой треугольник карту нормалей всё равно не покажет.

Дальше тангенты усредняются по вершинам и интерполируются растеризатором, и после обеих операций T уже не строго перпендикулярен N. Поэтому в шейдере базис обычно чинят одним шагом Грама-Шмидта — вычитают из тангента его проекцию на нормаль:

vec3 T = normalize(vTangent - dot(vTangent, N) * N);
vec3 B = cross(N, T) * vTangentSign;
mat3 TBN = mat3(T, B, N);

Отдельная деталь — знак. Бинормаль, полученная как cross(N, T), может смотреть в противоположную сторону от той, что задана развёрткой: так бывает на зеркально развёрнутых UV-островах, а это типовой приём для симметричных моделей. Поэтому хранят не саму бинормаль, а её знак, упакованный четвёртой компонентой тангента. Формат glTF 2.0 кодифицирует это прямо: атрибут TANGENTVEC4, где W обязан быть ровно +1 или −1, а бинормаль восстанавливается как cross(normal.xyz, tangent.xyz) * tangent.w.

Почему карта синяя

Нормаль — вектор со знаком, а обычная 8-битная текстура хранит беззнаковые значения. Поэтому при записи делают n·0.5 + 0.5, а в шейдере разворачивают обратно: n = 2·c − 1. X кладут в красный, Y в зелёный, Z в синий.

Тангентные нормали не могут смотреть внутрь поверхности — они лежат на верхней полусфере, и у подавляющего большинства текселей Z близок к единице. Значит, синий канал почти везде у верхнего края диапазона, а красный и зелёный колеблются вокруг середины. Отсюда и лавандово-синий вид: невозмущённая нормаль (0, 0, 1) кодируется в RGB (128, 128, 255).

Мелочь: (128,128,255) — не ровно нольДля педантов

При стандартном развороте n = 2·c − 1 значение 128/255 = 0.50196 даёт 0.00392, а не ноль.

Причина фундаментальна: линейным отображением целых чисел в диапазон [−1, +1] невозможно точно представить сразу −1, 0 и +1. Общепринятая схема (0 → −1, 255 → +1) точно представляет ±1 и не представляет ноль. Практических последствий у этой погрешности нет, но писать «ровно ноль» некорректно.

Подробный разбор — van Waveren и Castaño, «Real-Time Normal Map DXT Compression».

Карта нормалей — не картинка

Из того, что в текстуре лежит вектор, а не цвет, следует практическое правило: никакой передаточной функции к ней применять нельзя. Карта нормалей грузится как линейные данные, в отличие от базового цвета и эмиссии, которые в sRGB.

Что будет, если ошибиться, — видно в демке, и цифры там неприятные. Аппаратный sRGB-семплер применяет декодирующую кривую до того, как шейдер сделает 2·c − 1. Тексель 128/255 = 0.50196 после декодирования превращается в 0.2159, и вместо примерно нуля шейдер получает −0.568 по X и по Y. Синий канал (255 → 1.0) кривая не трогает.

То есть плоский участок поверхности, который должен давать (0, 0, 1), даёт (−0.568, −0.568, 1.0) — после нормализации это наклон почти на 40° в одну фиксированную диагональ. Вся поверхность подсвечена так, будто источник сместился; рельеф съезжает в одну сторону; симметрия отклика на свет пропадает. Обратной операции нет, лечится только переимпортом текстуры с правильной галочкой.

Косвенное подтверждение правила: sRGB-варианты форматов существуют для BC1, BC2, BC3 и BC7, а у BC4 и BC5 их нет вообще — только UNORM и SNORM, потому что эти форматы предназначены для нецветовых данных.

Чем жать карту нормалей

Раз уж в прошлой статье мы разбирали блочное сжатие, стоит закрыть вопрос и здесь.

BC1 проектировался под цвет: блок 4×4 аппроксимируется четырьмя точками на одной прямой в RGB-пространстве, а опорные точки квантуются в 5:6:5 бит. Для нормалей это плохо вдвойне. Четырёх различных векторов на блок мало, и, поскольку все они лежат на одной прямой, единичной длины из них могут быть не больше двух. Результат — заметная блочность и бандинг.

BC5 (он же 3Dc, он же RGTC) сделан специально под тангентные карты. Он хранит два канала независимо, каждый — как альфа-блок BC3: две 8-битные опорные точки без квантования и 3-битный индекс на тексель. Цена — вдвое больше памяти: BC1 это 8 байт на блок 4×4, BC5 — 16 байт. То есть BC5 не «дешевле», а качественнее при удвоенном размере.

Третья компонента при этом не хранится вовсе, а достраивается: нормаль единичная и всегда смотрит наружу, значит

z=1x2y2z = \sqrt{1 - x^2 - y^2}

У приёма есть цена: восстановление нелинейно и плохо дружит с билинейной фильтрацией — интерполированные семплы двухканальной карты перестают идти по кратчайшей дуге на единичной сфере.

На мобильных BC5 недоступен, и его роль делят два формата. Основной — ASTC: у компрессора Arm есть отдельный режим -normal, который упаковывает две компоненты и оптимизирует не поканальную ошибку, а угловую ошибку самого вектора; именно двухканальная упаковка позволяет опускаться до блока 6×6, то есть 3.56 бита на тексель. Запасной вариант на базовом уровне GLES 3.0 — COMPRESSED_RG11_EAC с двумя независимыми 11-битными каналами.

[ DEMO 05 ]//

Рельеф, которого нет

Ограничения техники

А теперь главное. Карта подменяет только вектор, который уходит в расчёт освещения. Сама поверхность как была гладкой, так и осталась. Отсюда четыре ограничения, и они не лечатся настройкой:

  1. Силуэт не меняется. Пунктирная окружность в демке обведена по геометрии — и она не сдвигается ни на пиксель, как бы вы ни задирали рельеф. На скользящем угле край объекта выдаёт, что он прямой.
  2. Нет самоперекрытия. Бугор не заслоняет соседний участок поверхности, хотя должен.
  3. Нет самозатенения. Бугор не отбрасывает тень на соседний участок.
  4. Нет параллакса. При движении камеры вершины бугров и донья впадин смещаются в кадре с одинаковой скоростью, хотя должны с разной.

Обман тем заметнее, чем больше глубина рельефа и чем острее угол взгляда.

Три из четырёх пунктов частично лечатся, и лечатся разным. Parallax mapping (Kaneko et al., ICAT 2001) сдвигает UV вдоль вектора взгляда по одному отсчёту карты высот — дешёвый параллакс, который разваливается на скользящих углах, откуда правка Уэлша с ограничением смещения (2004). Relief mapping (Policarpo, Oliveira, Comba, I3D 2005) трассирует луч по карте высот комбинацией линейного и бинарного поиска и добавляет самоперекрытие с самозатенением. Parallax occlusion mapping (Brawley и Tatarchuk, ShaderX3, 2004) — родственный подход с обратной трассировкой карты высот.

А вот силуэт не чинит ни одна из трёх. Чтобы исправить его, нужна либо настоящая геометрия — тесселяция и displacement, — либо специальные надстройки вроде трассировки по выдавленным призмам.

Часть 6. Вся модель в одном шейдере

Соберём. Пять частей разбирали по кусочку, а в боевом коде это одна функция на десяток строк — и полезно посмотреть на неё целиком, чтобы стало видно, где что живёт.

[ DEMO 06 ]//

Вся модель в одном шейдере

// N — нормаль, L — направление на свет, V — направление на камеру.
// Все три единичные. Возвращаем линейный цвет, кодирование — в самом конце.
vec3 shade(vec3 N, vec3 L, vec3 V, vec3 albedo) {
  float ndl = max(dot(N, L), 0.0);          // косинусный множитель Ламберта
  vec3  diffuse = albedo * ndl;

  vec3  H   = normalize(L + V);             // половинный вектор Блинна
  float ndh = max(dot(N, H), 0.0);
  // Кламп ДО возведения в степень: иначе чётная степень вернёт блик
  // на неосвещённую сторону, а дробная даст NaN.
  float spec = ndl > 0.0 ? pow(ndh, uExponent) : 0.0;

  return albedo * uAmbient                  // постоянная подсветка — заглушка
       + diffuse                            // рассеянный свет
       + vec3(spec) * uSpecular;            // блик, у которого нет бюджета
}

Пройдёмся по шейдеру в порядке, в котором разбирали:

  • N, L, V приходят единичными. N — из части 1: интерполированная и нормализованная в пикселе, а если включена карта нормалей — ещё и подменённая через TBN из части 5.
  • max(dot(N, L), 0.0) — косинусный множитель из части 2.
  • H = normalize(L + V) — половинный вектор Блинна из части 4. Никакого отражённого луча тут нет и не нужно.
  • Проверка ndl > 0.0 перед возведением в степень — вторая половина того же клампа. Без неё чётная экспонента вернёт блик на неосвещённую сторону, а дробная выдаст NaN.
  • albedo * uAmbient — та самая заглушка вместо непрямого света.

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

Ползунок силы блика полезно довести до нуля и обратно. На нуле остаётся чистый Ламберт: объём читается, форма читается, но поверхность выглядит как бумага. Возвращаете блик — появляется ощущение материала.

Экспонента же меняет не только ширину, но и общую яркость блика — заметьте, как объект темнеет, когда вы уводите её вправо. Это то самое отсутствие энергетического бюджета из части 4: сужая лепесток, вы уменьшаете суммарную отдачу, а модель об этом не знает и не компенсирует.

Выводы

Если оставить от статьи одну мысль, пусть будет такая: вся локальная модель освещения — это разговор с поверхностью через один вектор. Нормаль решает, сколько энергии на площадку пришло (N·L), нормаль же вместе с половинным вектором решает, сколько уйдёт в камеру. Всё остальное — про то, откуда этот вектор взять и в какой точке спросить.

Отсюда и разбор той самой пластмассы, с которой мы начали. Это не одна поломка, а несколько, и лечатся они в разных местах:

  • грани видно на гладком объекте — нормали граневые, а не вершинные, либо вершины шва развёртки не объединены;
  • объект темнеет в середине граней — забыли normalize() во фрагментном шейдере;
  • блик прыгает и мигает при вращении — свет считается в вершинах;
  • тени плоские и молочные, форма в тени пропала — константный ambient;
  • переход в тень слишком резкий, полутона провалились — свет посчитан не в линейном пространстве;
  • материал темнеет от одного лишь изменения глянцевости — у блика нет нормировки;
  • металл и пластик отличаются только числом в поле specular — вот это и есть пластмасса.

Последний пункт эта статья вылечить не может.

Что осталось несходящимся

Считать баланс мы так и не научились. Экспонента блика меняет суммарную отданную энергию, и ничто не мешает ей превысить пришедшую — счётчик в четвёртой демке это показывает прямым числом.

Разницы между металлом и диэлектриком в модели нет вообще. Нет и роста отражения к скользящим углам, хотя в реальности к краю объекта отражает почти всё и почти всегда.

А ambient остаётся заглушкой: одно число вместо всего света, который пришёл не напрямую от источника, а отразившись от чего-то ещё.

Обратите внимание на закономерность — она сквозная для всей серии про путь кадра. Ни один приём здесь не является ошибкой. N·L — точная геометрия падающего пучка. Гуро корректен там, где освещение меняется по поверхности плавно. Blinn-Phong даёт правильную форму лепестка, что подтверждают измерения. Карта нормалей верно подменяет нормаль. Ломается не техника, а её применение за пределами того, для чего он верен: Гуро на узком блике, карта нормалей на силуэте, степень косинуса там, где нужен баланс энергии. Разница между «покрутил ползунки, стало лучше» и «знаю, что именно я чиню» — ровно в том, чтобы держать эти границы в голове.

Дальше по конвейеру — та самая физика, которой тут не хватило. Что такое ОДЯ и почему она обязана сводить баланс, откуда берутся микрофасеты и распределение GGX (то самое, которое Блинн взял у Троубриджа и Рейца ещё в 1977-м), почему края объектов ярче и чем металл отличается от диэлектрика — следующий сезон, «Свет как физика».

Надеюсь, статья была полезна. Если в следующий раз, увидев пластмассовую сцену, вы сразу скажете, какой из семи пунктов выше в ней сработал, — значит, получилось. Заходите в телеграм-канал, такие разборы с интерактивами выходят там каждую неделю, и буду рад дополнениям в комментариях: в освещении нюансов сильно больше, чем влезло в шесть частей.

// @easy_dev_math

Разборы графики с кодом и интерактивами — каждую неделю в канале.