Deltadev-math.ru
// СВЕТ

Куда уходит свет, который поверхность не отразила: BRDF, микрофасеты и metallic/roughness

Физика отражения в реальном времени: BRDF и уравнение отражения, микрофасеты и распределение GGX, Френель и F0, затенение по Смиту и знаменатель 4(N·L)(N·V), white furnace test как проверка энергии, metallic/roughness workflow.

14 августа 2026·40 мин чтения·BRDFмикрофасетыФренельэнергияmetallic/roughness
Дейв разворачивает нормаль на сфере, Delta подсвечивает направление света и блик

Прошлая статья закончилась списком того, что локальная модель освещения не умеет. Отражённой энергии в ней может оказаться больше, чем пришло. Металл от пластика отличается одним числом в поле specular. А весь непрямой свет заменён константой ambient. Собственно, про первые два пункта и поговорим.

Разберём:

  • что такое BRDF — функция, которая отвечает на вопрос «сколько света ушло в камеру», и почему у диффузного отражения появляется деление на π;
  • поверхность как набор микрозеркал: распределение GGX, ремап α=r2\alpha = r^2 и почему хвосты этого распределения видно глазом;
  • Френель: почему края объектов светлеют у всего на свете, и чем цветной металл отличается от бесцветного пластика;
  • затенение и знаменатель, проверку баланса на растворяющейся сфере и то место, где GGX перестаёт сходиться;
  • почему индустрия сошлась именно на паре карт metallic + roughness;
  • и как всё это собирается в один фрагментный шейдер.
// @easy_dev_math

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

Отражательная функция

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

Про BRDF подробнееОсторожно! Математика!

Аргументов у неё два: направление, откуда пришёл свет (l\mathbf{l}), и направление, куда мы смотрим (v\mathbf{v}). Значение — отношение того, что ушло в сторону v\mathbf{v}, к тому, что пришло со стороны l\mathbf{l}:

fr(l,v)=dLo(v)dE(l)f_r(\mathbf{l}, \mathbf{v}) = \frac{dL_o(\mathbf{v})}{dE(\mathbf{l})}

Яркость в камере получается интегрированием по всем направлениям, откуда что-то светит:

Lo(v)=H2fr(l,v)Li(l)(nl)+dωiL_o(\mathbf{v}) = \int_{H^2} f_r(\mathbf{l}, \mathbf{v}) \, L_i(\mathbf{l}) \, (\mathbf{n} \cdot \mathbf{l})^{+} \, d\omega_i

Это уравнение отражения, и вся статья дальше — про то, что подставить вместо frf_r. Косинус в нём вы уже знаете: это закон Ламберта из прошлой статьи, доля потока, попавшая на площадку. Знак «плюс» сверху — тот самый max(0, N·L), свет из-под поверхности не считается.

В числителе — яркость (radiance): мощность, уходящая с единицы площади в единицу телесного угла. В знаменателе — облучённость (irradiance): мощность, приходящая на единицу площади со всех направлений сразу. Величины разной размерности, поэтому у ОДЯ есть единица измерения — обратный стерадиан. Отсюда, кстати, и 1/π1/\pi у Ламберта: это не безразмерный коэффициент, а следствие того, что делим на телесный угол.

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

Три условия, которым обязана подчиняться любая ОДЯ

Неотрицательность. Поверхность не может отразить минус три люмена.

Взаимность. Если поменять местами источник и камеру, значение не изменится: fr(l,v)=fr(v,l)f_r(\mathbf{l}, \mathbf{v}) = f_r(\mathbf{v}, \mathbf{l}). Свойство неочевидное, но крайне полезное: на нём стоит вся трассировка «из камеры», иначе пришлось бы пускать лучи из лампы.

Сохранение энергии. Сколько бы света ни пришло, уйти должно не больше.

По сути это и есть энергетический бюджет. Всё остальное в статье — способы его не превысить.

И ещё один интегралОсторожно! Математика!
H2fr(l,v)(nv)+dωo1\int_{H^2} f_r(\mathbf{l}, \mathbf{v}) \, (\mathbf{n} \cdot \mathbf{v})^{+} \, d\omega_o \le 1

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

Откуда у Ламберта деление на πОсторожно! Математика!

Идеально диффузная поверхность раскидывает свет одинаково во все стороны. Значит, её ОДЯ — константа. Какая именно?

Пусть альбедо равно ρ\rho: доля пришедшего, которую поверхность возвращает наружу. Подставим константу в условие энергии и потребуем, чтобы ушло ровно ρ\rho:

H2ccosθdω=c02π ⁣ ⁣0π/2cosθsinθdθdφ=cπ=ρ\int_{H^2} c \, \cos\theta \, d\omega = c \int_0^{2\pi}\!\!\int_0^{\pi/2} \cos\theta \sin\theta \, d\theta \, d\varphi = c\pi = \rho

Отсюда c=ρ/πc = \rho/\pi. Число π здесь площадь проекции полусферы: интеграл косинуса по полусфере равен π.

Многие движки прячут 1/π1/\pi внутрь единиц измерения источника: интенсивность лампы задаётся уже поделённой. Тогда в шейдере остаётся albedo * NoL, и он выглядит как «наивная» форма.

Микрофасеты

Ламберт закрывает диффузную часть. Осталась зеркальная — блик. И вот тут нужно на секунду перестать думать про блик как про пятно на картинке и подумать про него как про то, чем он является физически.

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

Отсюда модель микрофасетов: поверхность — это множество идеальных микрозеркал. Мы не моделируем их поштучно (их миллиарды), мы описываем их статистикой. Какая доля микрограней смотрит в данную сторону вот главный вопрос. Функция, которая на него отвечает, называется распределением нормалей микрограней и обозначается DD.

Кто из микрограней работает

Свет пришёл из l\mathbf{l}, камера стоит в v\mathbf{v}. Микрогрань отправит луч из первого во второе только если она развёрнута строго по биссектрисе — по половинному вектору:

h=l+vl+v\mathbf{h} = \frac{\mathbf{l} + \mathbf{v}}{\lVert \mathbf{l} + \mathbf{v} \rVert}

Тот самый половинный вектор из Blinn-Phong. Только в прошлой статье он был удобным трюком («дешевле, чем считать отражение»), а здесь у него появился физический смысл: h\mathbf{h} — это ориентация работающих микрограней, а D(h)D(\mathbf{h}) — сколько их таких.

НормировкаОсторожно! Математика!

Распределение обязано быть нормировано:

H2D(h)(nh)dωh=1\int_{H^2} D(\mathbf{h}) \, (\mathbf{n} \cdot \mathbf{h}) \, d\omega_h = 1

Читается так: суммарная площадь микрограней, спроецированная на макроплоскость, равна площади самой макроплоскости. Микрорельеф не создаёт и не съедает поверхность, он её только мнёт.

Вот в этом и разница между (nh)s(\mathbf{n} \cdot \mathbf{h})^s из прошлой статьи и настоящим распределением. Возьмите ту же степень косинуса и нормируйте её:

DBlinn(h)=s+22π(nh)sD_{\text{Blinn}}(\mathbf{h}) = \frac{s + 2}{2\pi} (\mathbf{n} \cdot \mathbf{h})^{s}

Множитель (s+2)/2π(s+2)/2\pi и есть та разница, из-за которой в прошлой статье объект темнел при изменении глянцевости. Без него подъём экспоненты просто сужал пятно и терял энергию; с ним сужение пятна компенсируется ростом яркости, а суммарная отдача остаётся прежней.

GGX и её хвосты

Нормированных распределений много. Исторически первым в графике был Бекман — гауссово распределение наклонов микрограней. Сейчас индустрия почти целиком сидит на GGX:

DGGX(h)=α2π[(nh)2(α21)+1]2D_{\text{GGX}}(\mathbf{h}) = \frac{\alpha^2}{\pi \left[ (\mathbf{n} \cdot \mathbf{h})^2 (\alpha^2 - 1) + 1 \right]^2}

Формулу вывели Trowbridge и Reitz ещё в 1975 году, в графику её принесла работа Walter et al. 2007 — оттуда и название GGX. Забавно, что Блинн в своей статье 1977 года распределение Троубриджа-Рейца упоминал и даже разбирал: индустрии понадобилось тридцать лет, чтобы к нему вернуться.

Что в ней такого. В самом пике GGX и Бекман совпадают точно — обе дают 1/(πα2)1/(\pi\alpha^2). Дальше начинается разница: у Бекмана значение падает экспоненциально и практически обнуляется, а у GGX — как квадрат обратной величины, то есть медленно. У GGX длинный хвост.

Глазом это читается как дымка вокруг блика. Настоящие поверхности так и выглядят: яркое ядро отражения, вокруг него ореол, который не заканчивается резко. У Бекмана ореола нет — есть пятно с краем.

Заметьте, чем GGX за этот хвост платит. Полная энергия у обоих распределений одинаковая — нормировка одна. Значит, всё, что GGX унесла в хвост, она забрала из области сразу за пиком: на 15° от зеркального направления Бекман ярче.

Что такое α по смыслуОсторожно! Математика!

У Бекмана α\alpha — это среднеквадратичный наклон микрограней: насколько сильно в среднем они отклонены от макронормали. Величина с физическим смыслом, её в принципе можно измерить профилометром. У GGX α\alpha играет ту же роль параметра ширины, хотя распределение наклонов у неё другое.

Это, кстати, объясняет, почему ноль в знаменателе недопустим: α=0\alpha = 0 означает идеально гладкую поверхность, у которой все микрограни смотрят строго в одну сторону, и распределение вырождается в дельта-функцию. В коде такую поверхность приходится ограничивать снизу маленькой константой — иначе на пике получится деление на ноль.

Ремап: почему α = roughness²

В формуле стоит α\alpha, а художник крутит roughness. Связаны они не напрямую:

α=r2\alpha = r^2

Ремап предложили в Disney (Burley, 2012), и он прижился повсеместно — в частности, в UE4 (Karis, 2013). Причина чисто эргономическая: если подавать roughness в формулу напрямую, весь визуально интересный диапазон сжимается в первую четверть ползунка, а дальше картинка почти не меняется. С квадратом изменение по ползунку идёт равномерно для глаза.

Почему это важно знать, а не просто написать r*rОсторожно! Математика!

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

[ DEMO 01 ]//

Микрофасеты и хвосты GGX

Переключатель меняет распределение, ползунок — шероховатость. В счётчике: α\alpha, эквивалентная блинновская экспонента (связь s=2/α22s = 2/\alpha^2 - 2 из приложения к работе Walter et al.) и ширина хвоста — угол, на котором распределение спадает до одного процента от пика. На roughness = 0.3 GGX доходит до этого процента только к 16°, Бекман — уже к 11°. Разница в пять градусов и есть весь ореол.

Одно число на всю поверхность

Ещё одно допущение спрятано в том, что α\alpha — скаляр. Это значит, что микрогрань одинаково охотно смотрит в любую сторону: распределение симметрично относительно нормали.

У многих реальных поверхностей это не так. Шлифованный металл изрыт бороздами в одном направлении, у ткани есть нити, у волос — сама форма. Блик на них не круглый, а вытянутый поперёк борозд. Такие поверхности описывают анизотропным GGX: вместо одного α\alpha берут два, вдоль касательной и вдоль бинормали, и добавляют материалу направление. Стоит это лишней карты направления и заметно более дорогой формулы, поэтому в игровых материалах включается точечно — на шлифованные детали и волосы, а не на всё подряд.

Френель

Микрогрань развёрнута правильно — но это ещё не значит, что она отразила весь свет. Часть уйдёт внутрь материала. Какая именно — зависит от угла.

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

Формула Шлика

Точные уравнения Френеля зависят от поляризации и от показателя преломления, у металлов ещё. В реальном времени вместо них используют приближение Шлика (Schlick, 1994):

F(θ)=F0+(1F0)(1cosθ)5F(\theta) = F_0 + (1 - F_0)(1 - \cos\theta)^5

Одна степень пятой вместо пары уравнений с арифметикой комплексных чисел. Здесь F0F_0 — отражение при взгляде строго по нормали, а угол θ\theta отсчитывается от микронормали, то есть от половинного вектора, а не от нормали поверхности. Отражает микрогрань, а не усреднённая макроплоскость.

Обратите внимание на структуру: при cosθ=1\cos\theta = 1 остаётся F0F_0, при cosθ=0\cos\theta = 0 — ровно единица. То есть на скользящем угле любая поверхность отражает всё.

Насколько резко

Рост неравномерный, и почти весь набирается в последних градусах. Для диэлектрика с F0=0,04F_0 = 0{,}04:

  • на 45° отражение — 0.042, рост всего в 1.05 раза;
  • на 60° — 0.070, вдвое от исходного;
  • на 80° — 0.41, то есть в десять раз.

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

Откуда берётся F₀

Для диэлектрика — из показателя преломления:

F0=(n1n2n1+n2)2F_0 = \left( \frac{n_1 - n_2}{n_1 + n_2} \right)^{2}

Для перехода воздух → стекло (n=1,5n = 1{,}5) получается 0.04. Те самые четыре процента, которые прошиты константой в любом PBR-шейдере. Для воды (n=1,33n = 1{,}33) — 0.02. Диапазон у диэлектриков вообще узкий: почти всё, что не металл, лежит между 2 и 6 процентами. Поэтому в metallic/roughness-пайплайне ползунка «сколько отражает пластик» и нет — там константа.

У металлов иначе. Отражение большое (0.5–1.0) и цветное: у золота красный канал отражается почти целиком, а синий — вдвое хуже, отсюда жёлтый цвет. Значения берутся из измерений, вот линейные RGB из документации Filament:

металлF₀ (линейный RGB)
железо0.77 · 0.78 · 0.78
золото1.00 · 0.85 · 0.57
медь0.97 · 0.74 · 0.62
серебро0.97 · 0.96 · 0.91

Френель на шершавой поверхности

Есть у этой формулы неприятная особенность. Стремление F1F \to 1 на скользящем угле верно для гладкой границы. У шершавой поверхности до касательного угла дело не доходит: микрогрань, развёрнутая почти вдоль взгляда, к этому моменту уже загорожена соседями.

Если Френель посчитан, а затенение — нет, на кромке любого объекта загорается яркий белый кант. Выглядит это как дешёвый rim light, только неуправляемый. Лечится он не подкруткой Френеля, а членом GG из следующей части: именно он гасит вклад тех микрограней, которые физически не видны.

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

Как из UE выпилили ползунок SpecularОсторожно! Математика!

Интересно посмотреть, что с этим числом делали на практике. В ранних версиях материала Unreal Engine у художника был вход Specular — как раз чтобы подвинуть отражение диэлектрика. В докладе Karis 2013 рассказано, чем это кончилось: название путало людей, диапазон забывали, а дефолт — 0.5, соответствующий четырём процентам отражения, — регулярно принимали за единицу. Использовали параметр в итоге почти исключительно не по назначению, для мелкомасштабного затенения.

Кончилось тем, что вход убрали совсем: F0F_0 диэлектриков стал константой 0.04, а вместо Specular появился параметр Cavity — ровно под ту задачу, к которой его де-факто и приспособили. Переменный показатель преломления у неметаллов посчитали не настолько важным, чтобы отдавать под него отдельный вход материала.

Почему у металла нет диффузной части

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

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

А что тогда с цветом у краёв?Осторожно! Математика!

К скользящему углу FF стремится к единице во всех трёх каналах, значит цвет отражения пропадает. Кромка золотого шара — белая, а не жёлтая. Это заметно на картинке и хорошо проверяется: если у вас золото жёлтое до самого силуэта, Френель по каналам считается неправильно.

[ DEMO 02 ]//

Край всегда ярче

Затенение, маскировка и знаменатель

Есть распределение микрограней, есть доля отражённого. Осталась третья поправка, и она самая приземлённая: не каждая правильно развёрнутая микрогрань вообще участвует в деле. Соседние бугры её загораживают — от источника (это называют затенением) или от камеры (маскировкой).

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

Модель Смита (Smith, 1967) считает две доли независимо и перемножает:

G(l,v)=G1(l)G1(v)G(\mathbf{l}, \mathbf{v}) = G_1(\mathbf{l}) \, G_1(\mathbf{v})

Форма выглядит естественной, но она пессимистична: перемножая вероятности, мы предполагаем, что от источника и от камеры загораживают независимые бугры. На деле это одни и те же бугры. Высотно-коррелированная версия (Heitz, 2014) это учитывает и пропускает заметно больше — особенно на больших углах и большой шероховатости.

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

Модель Смита строится на простом рассуждении. Смотрим на изрытую поверхность под углом с косинусом cc. Чем меньше cc, тем более полого мы смотрим и тем больше шанс, что бугор впереди закроет обзор. Чем больше α\alpha, тем бугры выше и тем этот шанс тоже больше. Собирается это в

G1=2cc+α2+(1α2)c2G_1 = \frac{2c}{c + \sqrt{\alpha^2 + (1 - \alpha^2)c^2}}

Проверить формулу можно по краям. При α0\alpha \to 0 корень даёт cc, и всё выражение — единицу: на гладкой поверхности заслонять нечем. При c1c \to 1 (смотрим по нормали) снова единица: сверху видно всё независимо от рельефа. А при c0c \to 0 числитель уезжает в ноль быстрее знаменателя — на касательном взгляде не видно ничего.

Откуда берётся 4(N·L)(N·V)

Соберём зеркальный член:

fspec(l,v)=D(h)F(vh)G(l,v)4(nl)(nv)f_{\text{spec}}(\mathbf{l}, \mathbf{v}) = \frac{D(\mathbf{h}) \, F(\mathbf{v} \cdot \mathbf{h}) \, G(\mathbf{l}, \mathbf{v})}{4 \, (\mathbf{n} \cdot \mathbf{l})(\mathbf{n} \cdot \mathbf{v})}

Числитель читается по-человечески: сколько микрограней смотрит куда надо, какая доля от них отразилась, сколько из них не загорожено. А вот знаменатель ниоткуда не «читается». Двойка с двойкой набегают при переходе от плотности микрограней к плотности отражённых направлений (половинный вектор движется вдвое медленнее отражённого — та самая четвёрка), а косинусы — из перевода площади микрограней в проекцию на макроплоскость и на направление взгляда.

Выводить это здесь целиком не буду, вывод есть у Walter et al. 2007. Практически важно другое: знаменатель важен. Выбросьте его — и модель начнёт отдавать больше, чем получила.

Почему в шейдерах вместо G пишут VОсторожно! Математика!

Знаменатель сокращается с частью GG аналитически, и в коде обычно хранят сразу частное:

V(l,v)=G(l,v)4(nl)(nv)V(\mathbf{l}, \mathbf{v}) = \frac{G(\mathbf{l}, \mathbf{v})}{4 (\mathbf{n} \cdot \mathbf{l})(\mathbf{n} \cdot \mathbf{v})}

Так не остаётся деления на почти нулевые косинусы — а на кромке объекта они именно почти нулевые, и наивная запись выдаёт там белые точки-артефакты.

Сфера, которая обязана исчезнуть

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

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

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

[ DEMO 03 ]//

Сфера, которая обязана исчезнуть

Поиграйте тумблерами. Без знаменателя сфера светится ярче фона. С раздельной формой GG вместо коррелированной — темнеет. Каждая комбинация здесь считается своим интегралом, а не поправкой к правильному ответу: выброшенный знаменатель меняет саму подынтегральную функцию.

Где не сходится сама GGX

А теперь неприятное. Поставьте всё правильно — коррелированный Смит, знаменатель на месте, компенсация выключена — и подвиньте шероховатость вверх. Сфера всё равно темнее фона.

Числа такие (угол обзора 45°):

  • roughness = 0.5 → возвращается 0.88, потеряно 12%;
  • roughness = 0.7 → 0.69, потеряно почти треть;
  • roughness = 1.0 → 0.38, а при взгляде по нормали и вовсе 0.31.

Две трети энергии просто исчезли. Это не ошибка реализации — это ограничение модели. Микрофасетная ОДЯ считает однократное отражение: луч попал на микрогрань, отразился, улетел. А в изрытой поверхности он попадает на соседнюю грань, отражается ещё раз и только потом уходит. Модель этот второй отскок не учитывает, и вся его энергия для неё пропадает.

Чем шершавее поверхность, тем больше таких отскоков и тем больше дыра. У гладкой её практически нет — там и отскок один.

Чинят это компенсацией: добавляют отдельный член, который возвращает ровно недостающее. Способ Kulla и Conty (2017) строится прямо из условия «после добавки сфера обязана вернуть единицу», отсюда и форма добавки. В демке это третий тумблер — включите, и сфера растворяется на любой шероховатости.

Две карты вместо десяти ползунков

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

Индустрия свернула это в два числа. Одно — roughness, оно и так одно. Второе — metallic, и вот оно интересное.

Что делает metallic

Это не «степень металличности». Это переключатель между двумя ветками, у каждой из которых базовый цвет означает своё:

diffuse=baseColor(1m),F0=lerp(0,04, baseColor, m)\text{diffuse} = \text{baseColor} \cdot (1 - m), \qquad F_0 = \text{lerp}(0{,}04,\ \text{baseColor},\ m)

При m=0m = 0 базовый цвет — это альбедо, а отражает поверхность стандартные четыре процента бесцветно. При m=1m = 1 диффуза нет вовсе, а базовый цвет уезжает в F0F_0 и становится цветом отражения.

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

Чем плох старый specular/glossiness

До metallic/roughness индустрия жила на specular/glossiness: отдельная карта цвета диффуза, отдельная карта цвета зеркала, отдельная — глянцевости. Формально это гибче: можно задать любой F0F_0 независимо от альбедо.

Гибкость и оказалась проблемой. Ничто не мешает нарисовать диэлектрик с зеркальным отражением 0.3 или металл с ненулевым диффузом — материалы, которых не существует. Художник, который «на глаз» подкрутил specular, получал сцену, разваливающуюся при смене освещения. Metallic/roughness просто не даёт нарисовать невозможное: третьего варианта в формуле нет.

Плюс экономия: две карты вместо трёх, причём metallic и roughness — одноканальные и часто пакуются в разные каналы одной текстуры (в glTF 2.0 это прямо так и зафиксировано: зелёный канал — roughness, синий — metallic).

Три способа испортить материал картами

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

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

Это не означает, что свет в текстуры печь совсем нельзя. Я использую эту оптимизацию в AR-проектах и в окружениях, где объектам с такой запечённой картой можно поставить Unlit-шейдер. Но эта оптимизация не имеет отношения к PBR. В ней мы платим PBR за то, что расходуем меньше памяти устройства и меньше нагружаем шину. Так можно на средних мобилках поднять достаточно красивое окружение на миллионы полигонов, но физически недостоверное.

Слишком тёмный или слишком светлый базовый цвет. Реальные материалы почти не выходят за диапазон от 0.05 до 0.85 по альбедо: даже уголь отражает несколько процентов, даже свежий снег — не сто. Чистый чёрный #000 в базовом цвете — это поверхность, поглощающая весь свет, физически такой нет.

Roughness, загруженный как sRGB. Карта шероховатости — это данные, а не цвет, и передаточную функцию к ней применять нельзя (та же история, что с картами нормалей из прошлой статьи). Загрузили как sRGB — и значение 0.5 превратится примерно в 0.21, то есть весь материал станет заметно глянцевее задуманного. Симптом узнаваемый: художник в редакторе видит одно, в движке — другое, и «чинит» это, перекручивая карту.

Как отличить эту ошибку от неправильного ремапаОсторожно! Математика!

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

Чек-лист материала

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

  1. Металл или не металл? metallic стоит в 0 или 1, промежуточное — только на границах на карте.
  2. Базовый цвет в диапазоне? От 0.05 до 0.85. Чистый чёрный и чистый белый — это не материалы.
  3. Запечённый свет лежит в своём канале? В baseColor — ни бликов, ни теней, ни затемнений по углам; всё это в лайтмапе или карте затенения.
  4. Карты данных загружены как данные? roughness, metallic, карта нормалей — линейные, без sRGB. Цветными остаются только baseColor и карта свечения.
  5. F₀ металла взят в линейном пространстве? Если брали значения из туториала — проверьте, не sRGB ли это.
  6. Ремап шероховатости совпадает с исходным пайплайном? Особенно если материал приехал из другого движка.
  7. В сцене есть окружение? Без него металл настраивать бессмысленно.

Первые шесть пунктов ловят почти все случаи «в Substance выглядело иначе». Седьмой — почти все случаи «металл получается чёрным».

Сетка

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

[ DEMO 04 ]//

Сетка сфер: roughness × metallic

Что здесь стоит покрутить.

Верхний ряд против нижнего. Один и тот же базовый цвет: сверху он работает цветом отражения, снизу — цветом диффуза. Это два совершенно разных материала из одного числа.

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

Тумблер «как в S7». Модель из прошлой статьи на той же сетке: строки не различаются вообще, потому что metallic в ней некуда подставить. Ровно этот скриншот и есть ответ на вопрос, зачем всё, что было выше.

Один шейдер целиком

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

float NoL = max(dot(N, L), 0.0);
vec3  H   = normalize(L + V);
float spec = pow(max(dot(N, H), 0.0), shininess);
return (albedo * NoL + specColor * spec) * lightColor;

Дальше — четыре правки, каждая из первых четырёх частей.

Правка первая: нормировать диффуз.

- return (albedo * NoL + specColor * spec) * lightColor;
+ return (albedo / PI * NoL + specColor * spec) * lightColor;

Одна строка, а цена вопроса — множитель π в яркости всей сцены.

Правка вторая: заменить степень косинуса распределением.

- float spec = pow(max(dot(N, H), 0.0), shininess);
+ float a = max(roughness * roughness, 1e-4);
+ float spec = D_GGX(NoH, a);

shininess уходит из интерфейса материала целиком, вместо него roughness в диапазоне 0…1 и ремап внутри. У блика появляется нормировка, и сужение лепестка перестаёт красть энергию.

Правка третья: добавить Френель.

- vec3 specColor = vec3(0.04);          // или что художник нарисовал
+ vec3 f0 = mix(vec3(0.04), baseColor, metallic);
+ vec3 F  = f0 + (1.0 - f0) * pow(1.0 - VoH, 5.0);

Здесь же появляется metallic, и вместе с ним — развилка материала: у диэлектрика базовый цвет уходит в диффуз, у металла в F0F_0. Диффузу теперь достаётся только то, что не отразилось: diffuseColor * (1.0 - F).

Правка четвёртая: добавить затенение и знаменатель.

- vec3 specular = D * F;
+ vec3 specular = D * V_SmithGGX(NoL, NoV, a) * F;

Знаменатель 4(nl)(nv)4(\mathbf{n}\cdot\mathbf{l})(\mathbf{n}\cdot\mathbf{v}) здесь уже внутри V_SmithGGX — сокращён аналитически, чтобы не делить на околонулевые косинусы на кромке.

Получается три функции по три строки и одна сборка. Это и есть весь Cook-Torrance — модель, которую в 1982 году предложили Роберт Кук и Кеннет Торранс и которая сегодня стоит в каждом движке. Обратите внимание на порядок правок: он не случайный. Каждая следующая чинит то, что предыдущая сделала заметным.

[ DEMO 05 ]//

Один шейдер целиком

Переключатель «что выключить» убирает по одному члену за раз. Без D шероховатость перестаёт на что-либо влиять — блик теряет форму. Без F пропадает свечение кромки, и материал выглядит нарисованным. Без G и знаменателя на скользящих углах загорается яркий кант из ниоткуда.

Ниже — исходник шейдера их демки. Тумблеры подставляются препроцессором через #define.

// ── Микрофасетная модель Cook-Torrance ───────────────────────────────
// D — сколько микрограней смотрит в сторону H (GGX / Trowbridge-Reitz)
float D_GGX(float NoH, float a) {
  float a2 = a * a;
  float d  = NoH * NoH * (a2 - 1.0) + 1.0;
  return a2 / (PI * d * d);
}

// G — сколько из них не загорожено соседями (Смит, высотно-коррелированный).
// Возвращается сразу в форме V = G / (4·NoL·NoV): знаменатель зеркального
// члена сокращается аналитически и деления на нулевые косинусы не остаётся.
float V_SmithGGX(float NoL, float NoV, float a) {
  float a2 = a * a;
  float lv = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
  float ll = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
  return 0.5 / (lv + ll);
}

// F — какая доля света вообще отражается под этим углом (Шлик).
vec3 F_Schlick(vec3 f0, float VoH) {
  return f0 + (1.0 - f0) * pow(1.0 - VoH, 5.0);
}

vec3 shade(vec3 N, vec3 V, vec3 L, vec3 baseColor, float roughness, float metallic) {
  float NoL = max(dot(N, L), 0.0);
  float NoV = max(dot(N, V), 1e-4);
  if (NoL <= 0.0) return vec3(0.0);

  vec3  H   = normalize(L + V);
  float NoH = max(dot(N, H), 0.0);
  float VoH = max(dot(V, H), 0.0);

  // Ремап художника: roughness линеен для глаза, alpha — для формул.
  float a = max(roughness * roughness, 1e-4);

  // Металл не имеет диффузной части, и его F0 — это базовый цвет.
  // Диэлектрик отражает 4% в нормаль, а цвет отдаёт диффузу.
  vec3 diffuseColor = baseColor * (1.0 - metallic);
  vec3 f0 = mix(vec3(0.04), baseColor, metallic);

  float D = D_GGX(NoH, a);
  float Vis = V_SmithGGX(NoL, NoV, a);
  vec3  F = F_Schlick(f0, VoH);

#if USE_D == 0
  D = 1.0 / PI;      // без распределения: блик теряет форму
#endif
#if USE_G == 0
  Vis = 1.0 / (4.0 * NoL * NoV);   // без затенения — только знаменатель
#endif
#if USE_F == 0
  F = f0;            // без Френеля край перестаёт светлеть
#endif
#if USE_DENOM == 0
  Vis = Vis * 4.0 * NoL * NoV;     // знаменатель убран: энергия не сходится
#endif

  vec3 specular = D * Vis * F;
  // Диффузу достаётся то, что не отразилось зеркалом.
  vec3 diffuse = diffuseColor * (1.0 - F) / PI;

  return (diffuse + specular) * NoL * LIGHT_INTENSITY;
}

Выводы

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

Отсюда и разбор проблем, с которых мы начали:

  • материал разваливается при смене света — где-то в модели нет нормировки, и результат зависит от того, сколько именно света на него посветили;
  • ползунок глянцевости меняет общую яркость — блик без нормировки: сужаете лепесток, теряете энергию;
  • кромки объектов мёртвые — нет Френеля;
  • на скользящих углах горит яркий кант — нет затенения или потерян знаменатель;
  • матовый металл выглядит припылённым — нет компенсации многократного рассеяния;
  • металл похож на серый пластик — базовый цвет ушёл в диффуз вместо F0F_0;
  • золото выглядит латуньюF0F_0 взят в sRGB и подставлен как линейный.

Что PBR не чинит

Тени эта модель не считает вообще. max(0, N·L) отвечает на вопрос «повёрнута ли площадка к источнику», а не «видит ли она его» — между ними может стоять стена, и модели это безразлично. Как выясняют второе, разберём в следующий раз.

Непрямой свет она тоже не считает. В демках окружение задано формулой и подставляется приближением; настоящая карта окружения требует предфильтрации, а отражения объектов друг в друге — вообще отдельного механизма. Тот самый ambient, который в прошлой статье был заглушкой, здесь заглушкой и остался.

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

Наконец, сама GGX сходится не везде: на большой шероховатости она теряет энергию, и это лечится отдельной поправкой.

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

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

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

// @easy_dev_math

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