Всем привет! Меня зовут Гриша Дядиченко. Больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном заказная разработка и собственные прототипы.
В прошлой статье мы доставали из текстуры цвет и в самом конце проговорили важную вещь: albedo — это не «как выглядит поверхность», а «какую долю света она отражает». Долю. От чего-то. Вот про это «что-то» и поговорим.
Сталкивались с ощущением, что сцена вроде собрана, свет расставлен, материалы настроены — а выглядит всё как дешёвая пластмасса? Шар похож на бильярдный, камень похож на резину, металл вообще ни на что не похож. Крутишь ползунки — становится то ярче, то темнее, но пластмасса никуда не девается.
Чтож, давайте разберёмся. Штука в том, что «пластмассово» — это не одна ошибка, а несколько независимых, и живут они в разных местах шейдера. По сути вся статья — про то, как их развести и назвать поимённо. Разберём:
- откуда шейдер вообще знает, куда повёрнута поверхность, — и почему нормаль ломается от одного неравномерного масштаба;
- где считать свет — в вершине или в пикселе, чем шейдинг Гуро отличается от шейдинга Фонга и при чём тут полосы Маха;
- как устроен блик Blinn-Phong, почему его экспоненту берут вчетверо больше фонговской и почему у этого блика нет никакого бюджета;
- как карта нормалей рисует рельеф без единой лишней вершины;
- и как всё вместе укладывается в восемь строк фрагментного шейдера.
Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.
Часть 1. В треугольнике нет света — есть вектор в вершине
Начнём с вопроса: откуда шейдер знает, что вот эта точка поверхности повёрнута к источнику, а вот эта — от него?
Никакой поверхности у видеокарты нет. Есть три точки в пространстве и правило, как их закрасить. Всё, что связывает эти три точки с понятием «повёрнута туда-то», — вектор нормали.
Edge нормаль: одна на треугольник
Для отдельного треугольника нормаль берётся из его же геометрии. Два ребра задают плоскость, векторное произведение даёт перпендикуляр:
Порядок обхода вершин решает, куда этот перпендикуляр смотрит — наружу или внутрь. По нему же железо потом отсекает задние грани.
С такими нормалями сфера выглядит ровно тем, чем является: многогранником. Каждая грань освещена своим числом, между гранями — скачок.
Вершинная нормаль: усреднение соседей
Приём, которым из многогранника делают гладкий шар, придумал Анри Гуро в 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) на десяти моделях — наоборот, почти везде лучшую. Победитель зависит от класса поверхности, и с ростом плотности сетки разница между схемами стирается.
Откуда берётся нормаль
Переключите нормали на вершинные и утащите слайдер сегментов влево: каркас остаётся угловатым, а стрелки уже смотрят «как на гладкой сфере». Ошибка нормали в HUD показывает, насколько усреднение расходится с точной нормалью сферы — она падает с числом сегментов, но в ноль не приходит никогда.
Шов, который видно
Отдельная история — тумблер объединения вершин. У UV-сферы кольцо вершин при обходе на 360° задвоено: позиция одна, а вершин две, потому что у них разные u (ноль и единица). Так устроен любой UV-шов.
Для нормалей это ловушка. Каждая из двух вершин видит соседей только со своей стороны, усредняет половину окружения — и обе получают нормаль, наклонённую в свою сторону. На гладкой сфере вылезает вертикальная полоса, которой в геометрии нет. Лечится объединением вершин: перед усреднением вершины с совпадающей позицией объединяются в одну, а нормаль потом раздаётся обеим. Ровно та же история у полюсов, где позиция задвоена столько раз, сколько сегментов.
Жёсткое ребро (Hard Edge): та же задвоенность, но нарочно
У шва задвоенность случайная, и лечится она объединением. Но ровно та же механика — единственный способ получить по-настоящему острое ребро.
Возьмите куб. В его углу сходятся три грани, и каждая должна держать свой тон: усреднённая нормаль даст в углу мягкое перетекание, и куб поедет в сторону обмылка. Значит, в этой точке нужны три разные нормали. А нормаль живёт в вершине, и вершина хранит ровно одну — другого поля под неё нет.
Выход единственный: положить в одну точку пространства три вершины с одинаковой позицией и разными нормалями. Каждая грань берёт свою.
Отсюда арифметика, которая удивляет при первом взгляде на статистику импорта: у куба восемь углов, а вершин двадцать четыре. Восемь точек, в каждой по три копии, по одной на грань. То же самое на фаске, в углу комнаты и на любом ребре, помеченном в редакторе как hard edge: «жёсткость» — это не свойство ребра, а вторая вершина вместо одной.
Так что вершинный счётчик считает не углы модели, а различимые комбинации «позиция + нормаль + UV». Каждый жёсткий стык и каждый UV-шов расщепляет вершину.
Нормаль ломается от скейла
Теперь про то, обо что регулярно спотыкаются на практике. Вершины едут в мир через модельную матрицу. Нормаль — не едет.
Точнее, едет, но по другому правилу: позицию умножают на модельную матрицу, а нормаль — на обратно-транспонированную, то есть на обратную к ней и потом транспонированную.
Откуда берётся обратно-транспонированнаяОсторожно! Математика!
Позиции умножаются на M, а нормали — на обратно-транспонированную матрицу от верхней 3×3 части:
Касательная 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 и посмотрим на середину отрезка между ними:
где θ — угол между нормалями. Косинус половины угла всегда меньше единицы, кроме случая, когда нормали совпали.
Числа такие: при расхождении нормалей в 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 θ раз:
Собственно, всё. Поверхность темнеет при наклоне не потому, что она «хуже светит вбок», а потому, что на неё приходит меньше энергии на квадратный метр. Это чистая геометрия падающего пучка.
Закон назван по имени Иоганна Генриха Ламберта — он сформулирован в «Photometria» 1760 года. У закона Ламберта есть и вторая, излучательная форма: сила света от площадки падает как cos θ. Но в N·L мы пользуемся именно первой, «приходящей».
Ламбертовой при этом называется поверхность, которая рассеивает пришедшее одинаково во все стороны: уходящая яркость от угла взгляда не зависит вообще, только от угла падения. Поэтому у диффузной части и нет никакого V.
Идеально ламбертовых поверхностей не бывает, это идеализация. Ближе всего к ней матовая бумага или мел: белый лист выглядит одинаково белым, с какой стороны на него ни смотреть.
А шершавые материалы из идеализации выпадают. Орен и Найар в статье «Generalization of Lambert's Reflectance Model» (SIGGRAPH '94) показывают расхождение на глиняной вазе: Ламберт обещает, что цилиндр потемнеет к краям, а настоящая ваза выглядит плоской — яркость почти постоянна по всей поверхности. Причина в микрорельефе: шершавая поверхность отражает больше света назад, в сторону источника, а не по нормали. Самый известный носитель этого эффекта — Луна, её неламбертовость меряли ещё в 1920-е, задолго до компьютерной графики.
Множитель 1/π
В коде выше диффуз — это albedo * ndl, никакого π там нет. А в учебниках у альбедо стоит делитель π. Почему? Базовый ответ - в каких единицах измерения мы считаем.
Сначала откуда. Требование ровно одно и довольно скромное: поверхность не должна отдавать больше, чем получила, а ρ должно означать ровно долю отражённого. Дальше арифметика: ламбертова поверхность рассеивает пришедшее по всей полусфере, и если сложить её вклад сразу по всем направлениям, набегает не единица, а π. То есть с голым ρ в формуле поверхность отражала бы в π раз больше, чем на неё упало: при ρ = 1 — втрое с лишним. Делитель это и чинит.
Откуда в нормировке берётся именно πОсторожно! Математика!
Запись через энергию пучка:
В знаменателе — интеграл косинусного множителя по верхней полусфере:
Множитель sin θ здесь якобиан телесного угла — без него получилось бы 2π, и это самая частая ошибка в этом выводе. Смысл величины: π — проецированный телесный угол полусферы, тогда как обычный телесный угол полусферы равен 2π.
Теперь про шейдер. 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».
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 года приводит формулировку самого Маха и делает из неё практический вывод, который стоит запомнить: мельчить сетку бесполезно. Излом производной никуда не денется от того, что граней стало вдвое больше, — эффект уйдёт только когда грань схлопнется до размера пикселя.
Блик, который теряется между вершинами
Но главная беда Гуро даже не в полосах. Диффуз меняется по поверхности плавно, и линейная интерполяция врёт по нему умеренно.
А вот блик — узкий. При экспоненте под сотню он занимает считанные градусы. И если ни одна вершина в него не попала, то в вершинах блика нет, интерполировать нечего — блика не будет и на треугольнике. Стоит повернуть объект на пару градусов, вершина заедет в блик — и он вспыхнет целым треугольником.
Получается блик, который прыгает по сетке и мигает при вращении. Никаким сглаживанием это не лечится, потому что данных просто нет.
Где считать свет: в вершине или в пикселе
Поставьте Гуро, задерите экспоненту, потяните слайдер поворота — и увидите ровно это. Тот же слайдер на Фонге не даёт никакого мерцания: там нормаль интерполируется, и блик считается в каждом пикселе честь по чести.
Второй тумблер — та самая нормализация из первой части. Выключите её на грубой сетке: середина каждой грани потемнеет.
Немного истории
Ходовое объяснение «раньше считали по вершинам, потому что дорого» верно, но несовсем.
Да, вершин на порядки меньше, чем фрагментов, и модель освещения в вершине дешевле. Но вторая причина не менее важная: фиксированный конвейер не имел способа выразить освещение на уровне пикселя вообще. 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. Блик
Диффуз не зависит от того, откуда вы смотрите. Блик — зависит: он и есть та часть, где важен угол между отражённым лучом и глазом. Дальше начинается самое интересное, потому что тут заканчивается геометрия и начинается подгонка.
Фонг: отразить луч и посмотреть, куда он попал
У Фонга блик считается напрямую. Отражаем направление на источник относительно нормали и смотрим, насколько отражённый луч разошёлся со взглядом:
Экспонента p отвечает за ширину блика: чем больше, тем уже. У самого Фонга в статье 1975 года она гуляет от 1 до 10 — это позже её начали задирать до сотен.
Блинн: половинный вектор
Через два года Джеймс Блинн опубликовал «Models of Light Reflection for Computer Synthesized Pictures» (SIGGRAPH '77, с. 192–198), и оттуда пришла запись, которой сегодня пользуются чаще фонговской:
Заявленный вклад той работы — не половинный вектор, а микрофасетная модель блика Торранса—Спэрроу вида 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. Вдвое меньший угол требует вчетверо большей степени, чтобы блик остался той же ширины:
Отсюда правило: блик, который у Фонга даёт 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 раза больше».
Блик, у которого нет бюджета
Пунктирная кривая на графике — вторая модель при той же экспоненте. Видно и разницу в ширине, и вторую вещь: у Фонга кривая обрывается. При угле между 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.
Это система двух уравнений, и решается она обращением матрицы UV-дельт:
Знаменатель обращается в ноль, если у треугольника нулевая площадь в 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 кодифицирует это прямо: атрибут TANGENT — VEC4, где 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 не «дешевле», а качественнее при удвоенном размере.
Третья компонента при этом не хранится вовсе, а достраивается: нормаль единичная и всегда смотрит наружу, значит
У приёма есть цена: восстановление нелинейно и плохо дружит с билинейной фильтрацией — интерполированные семплы двухканальной карты перестают идти по кратчайшей дуге на единичной сфере.
На мобильных BC5 недоступен, и его роль делят два формата. Основной — ASTC: у компрессора Arm есть отдельный режим -normal, который упаковывает две компоненты и оптимизирует не поканальную ошибку, а угловую ошибку самого вектора; именно двухканальная упаковка позволяет опускаться до блока 6×6, то есть 3.56 бита на тексель. Запасной вариант на базовом уровне GLES 3.0 — COMPRESSED_RG11_EAC с двумя независимыми 11-битными каналами.
Рельеф, которого нет
Ограничения техники
А теперь главное. Карта подменяет только вектор, который уходит в расчёт освещения. Сама поверхность как была гладкой, так и осталась. Отсюда четыре ограничения, и они не лечатся настройкой:
- Силуэт не меняется. Пунктирная окружность в демке обведена по геометрии — и она не сдвигается ни на пиксель, как бы вы ни задирали рельеф. На скользящем угле край объекта выдаёт, что он прямой.
- Нет самоперекрытия. Бугор не заслоняет соседний участок поверхности, хотя должен.
- Нет самозатенения. Бугор не отбрасывает тень на соседний участок.
- Нет параллакса. При движении камеры вершины бугров и донья впадин смещаются в кадре с одинаковой скоростью, хотя должны с разной.
Обман тем заметнее, чем больше глубина рельефа и чем острее угол взгляда.
Три из четырёх пунктов частично лечатся, и лечатся разным. 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. Вся модель в одном шейдере
Соберём. Пять частей разбирали по кусочку, а в боевом коде это одна функция на десяток строк — и полезно посмотреть на неё целиком, чтобы стало видно, где что живёт.
Вся модель в одном шейдере
// 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-м), почему края объектов ярче и чем металл отличается от диэлектрика — следующий сезон, «Свет как физика».
Надеюсь, статья была полезна. Если в следующий раз, увидев пластмассовую сцену, вы сразу скажете, какой из семи пунктов выше в ней сработал, — значит, получилось. Заходите в телеграм-канал, такие разборы с интерактивами выходят там каждую неделю, и буду рад дополнениям в комментариях: в освещении нюансов сильно больше, чем влезло в шесть частей.
Разборы графики с кодом и интерактивами — каждую неделю в канале.
