На предыдущем уроке мы заложили основу для создания PBR-рендеринга. На этом уроке мы сосредоточимся непосредственно на внедрении в процедуру рендеринга ранее рассмотренной теории с применением прямых (или как их еще называют — аналитических) источников света: точечных, направленных и/или прожекторов.
Освещение в PBR
А начать я предлагаю с пересмотра окончательного уравнения отражения из предыдущего урока:
В целом мы теперь более-менее понимаем, какие вычисления в нем происходят, но при этом все еще остаются некоторые неизвестные для нас моменты, а именно: каким образом мы будем представлять значение облученности, являющееся суммарной энергетической яркостью L
сцены. Мы знаем, что L
(в теории компьютерной графики) определяет поток излучения ϕ
(или световую энергию) источника света, приходящийся на заданный телесный угол ω
, в пределах которого он распространяется. В нашем случае мы условились считать телесный угол ω
бесконечно малой величиной, а тогда энергетическая яркость определяет поток источника света для отдельного луча света или вектора направления.
Возникает вопрос: «Как соотнести всё вышесказанное с теми знаниями об освещении, которые мы почерпнули из предыдущих уроков?». Ну, представьте себе, что у нас есть точечный источник света (источник света, который светит одинаково ярко во всех направлениях) с потоком излучения, заданным в виде RGB-тройки (23.47, 21.31, 20.79)
. Сила излучения данного источника света равна его потоку излучения во всех направлениях. Однако при определении показателя светотени заданной точки p поверхности из всех возможных направлений входящего света в границах её полусферы Ω
непосредственно от точечного источника света будет исходить только один вектор входящего направления ωi
. Поскольку в нашей сцене присутствует только один источник света, представленный в виде точки в пространстве, то энергетическая яркость для всех других возможных входящих направлений света, наблюдаемых относительно точки p поверхности, будет равна нулю:
Если предположить, что ослабления яркости света с увеличением расстояния до точечного источника света не происходит, то энергетическая яркость входящего луча останется неизменной, независимо от того, где расположен источник (случай масштабирования яркости через cosθ
падающего угла в учет не берем). А так как точечный источник имеет одинаковую силу излучения, независимо от угла обзора, то сила излучения становится равной световому потоку, заданному в виде константного вектора (23.47, 21.31, 20.79)
.
Однако для вычисления значения энергетической яркости в качестве входных данных также используется точка p, и поскольку любой реалистичный точечный источник света учитывает затухание света, то сила излучения точечного источника света должна масштабироваться некоторой мерой расстояния между точкой p и источником света. Затем, как следует из исходного уравнения энергетической яркости, результат умножается на скалярное произведение между нормалью поверхности n
и направлением входящего света ωi
.
Если выражать это словами, более приближенными «к жизни», то, в случае прямого точечного источника света, функция энергетической яркости L
задает цвет света отдельно взятого светового луча ωi
, попадающего в точку p, теряющего свою энергию с ростом расстояния до точки p, учитывая при этом масштабирующий коэффициент в виде скалярного произведения n ⋅ ωi
. В коде это будет выглядеть следующим образом:
1 2 3 4 5 |
vec3 lightColor = vec3(23.47, 21.31, 20.79); vec3 wi = normalize(lightPos - fragPos); float cosTheta = max(dot(N, Wi), 0.0); float attenuation = calculateAttenuation(fragPos, lightPos); vec3 radiance = lightColor * attenuation * cosTheta; |
Если не обращать внимания на немного иные обозначения, то вышеописанный фрагмент кода должен быть вам ужасно знаком: ведь это то, как мы реализовывали рассеянное освещение на предыдущих уроках. Когда дело доходит до прямого освещения, энергетическая яркость вычисляется аналогично тому, как мы вычисляли освещение раньше, поскольку вклад в излучение поверхности вносит только один вектор направления света.
Примечание: Заметим, что это предположение справедливо только для источника света бесконечно малого размера, представленного точкой в пространстве. Если бы мы моделировали свет, который имеет площадь или объем, то его энергетическая яркость имела бы ненулевые значения в более чем одном направлении входящего света.
Для других подобных типов источников света с излучением, исходящим из одной точки, энергетическая яркость вычисляется аналогичным образом. Например, у направленного источника света константный вектор направления ωi
не имеет коэффициента затухания. Но в то же время прожектор будет иметь изменяющуюся интенсивность излучения, зависящую от вектора его направления.
В итоге, мы возвращаемся к интегралу ∫
по множеству Ω
. А поскольку мы заранее знаем расположение всех сопутствующих источников света, способствующих затенению заданной точки поверхности, то нет необходимости пытаться решить интеграл. Можем непосредственно взять (известное) количество источников света и вычислить общую облученность, учитывая, что каждый источник света имеет только одно направление света, влияющее на значение энергетической яркости поверхности. В результате расчет освещения в PBR при прямых источниках света становится относительно простым, поскольку нам фактически нужно только их циклически перебрать. Позже, когда на следующих уроках мы рассмотрим модель освещения на основе изображения (сокр. «IBL» от англ. «Image-Based Lighting») и начнем учитывать освещение окружающей среды, нам необходимо будет принять во внимание вышеобозначенный интеграл, поскольку появятся случаи, когда свет может исходить из любого направления.
PBR-модель поверхности
Начнем с написания фрагментного шейдера, реализующего ранее оговоренные PBR-модели. Во-первых, нам потребуются соответствующие входные данные PBR, необходимые для затенения поверхности:
1 2 3 4 5 6 7 8 9 10 11 12 |
#version 330 core out vec4 FragColor; in vec2 TexCoords; in vec3 WorldPos; in vec3 Normal; uniform vec3 camPos; uniform vec3 albedo; uniform float metallic; uniform float roughness; uniform float ao; |
Мы берем стандартные входные данные, вычисленные при помощи обыкновенного вершинного шейдера, и набор константных uniform-свойств материала поверхности объекта.
Затем, в начале фрагментного шейдера, мы делаем простые вычисления, необходимые для любого алгоритма освещения:
1 2 3 4 5 6 |
void main() { vec3 N = normalize(Normal); vec3 V = normalize(camPos - WorldPos); [...] } |
Прямое освещение
В демонстрационном примере данного урока у нас будет суммарно 4 точечных источника света, которые все вместе задают общую облученность сцены. Чтобы удовлетворить уравнению отражения, мы пройдемся циклом по каждому источнику света, вычислим индивидуальную энергетическую яркость источников и просуммируем их вклад, масштабируя результат при помощи значения BRDF и угла падения света. Цикл можно рассматривать как решение интеграла ∫
по поверхности Ω
для прямых источников света. Сначала мы вычисляем соответствующие переменные для каждого источника света:
1 2 3 4 5 6 7 8 9 10 |
vec3 Lo = vec3(0.0); for(int i = 0; i < 4; ++i) { vec3 L = normalize(lightPositions[i] - WorldPos); vec3 H = normalize(V + L); float distance = length(lightPositions[i] - WorldPos); float attenuation = 1.0 / (distance * distance); vec3 radiance = lightColors[i] * attenuation; [...] |
Так как вычисления освещения проводятся в линейном пространстве (в конце шейдера мы задействуем гамма-коррекцию), то ослабление света при удалении от источника будет происходить по более физически правильному закону обратных квадратов.
Примечание: Несмотря на физическую корректность, вы все же можете использовать уравнение затухания с постоянным, линейным и квадратичным членами, которое (хотя и не является при этом физически корректным) может дать вам значительно больший контроль над уменьшением энергии света.
Затем для каждого источника света вычисляется зеркальная составляющая BRDF Кука-Торренса:
Первое, что нужно сделать, — это вычислить коэффициент между зеркальным и диффузным отражениями или, другими словами — то, насколько поверхность отражает свет, по сравнению с тем, насколько она его преломляет. Из предыдущего урока мы знаем, что для этого используется уравнение Френеля:
1 2 3 4 |
vec3 fresnelSchlick(float cosTheta, vec3 F0) { return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); } |
Аппроксимация Френеля-Шлика получает параметр F0
, который известен как отражение поверхности при нулевом угле падения или по-простому — степень отражения поверхности, если смотреть на нее под прямым углом. Значение F0
варьируется, в зависимости от материала, и имеет некоторые дополнительные оттенки на металлах. Для PBR, в основе которого лежит концепция металличности, мы делаем упрощающее предположение, что большинство диэлектрических поверхностей выглядят визуально правильно со значением константы F0
, равным 0.04
, в то время как для металлических поверхностей значение F0
определяется их показателем альбедо. В коде это будет выглядеть следующим образом:
1 2 3 |
vec3 F0 = vec3(0.04); F0 = mix(F0, albedo, metallic); vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0); |
Как видите, для неметаллических поверхностей значение F0
всегда равно 0.04
. Для металлических поверхностей мы изменяем F0
путем линейной интерполяции между исходным значением F0
и значением альбедо, полученным из переменной-свойства metallic
.
Имея F, остается вычислить функцию нормального распределения D и геометрическую функцию G.
В PBR-шейдере прямого освещения их код представлен следующим фрагментом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
float DistributionGGX(vec3 N, vec3 H, float roughness) { float a = roughness*roughness; float a2 = a*a; float NdotH = max(dot(N, H), 0.0); float NdotH2 = NdotH*NdotH; float num = a2; float denom = (NdotH2 * (a2 - 1.0) + 1.0); denom = PI * denom * denom; return num / denom; } float GeometrySchlickGGX(float NdotV, float roughness) { float r = (roughness + 1.0); float k = (r*r) / 8.0; float num = NdotV; float denom = NdotV * (1.0 - k) + k; return num / denom; } float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness) { float NdotV = max(dot(N, V), 0.0); float NdotL = max(dot(N, L), 0.0); float ggx2 = GeometrySchlickGGX(NdotV, roughness); float ggx1 = GeometrySchlickGGX(NdotL, roughness); return ggx1 * ggx2; } |
Здесь важно отметить, что, в отличие от предыдущего урока, параметр шероховатости мы передаем этим функциям напрямую; таким образом внося некоторые специфические для каждой функции изменения в исходное значение шероховатости. Полагаясь на исследования компании Disney и последующую их адаптацию в своих продуктах компанией Epic Games, освещение будет выглядеть более правильным при использовании квадрата шероховатости как в геометрической функции, так и в функции нормального распределения.
При наличии всех вышеописанных функций вычисление NDF и G-члена становится простым делом техники:
1 2 |
float NDF = DistributionGGX(N, H, roughness); float G = GeometrySmith(N, V, L, roughness); |
Итак, у нас есть все данные для расчета BRDF Кука-Торренса:
1 2 3 |
vec3 numerator = NDF * G * F; float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0); vec3 specular = numerator / max(denominator, 0.001); |
Обратите внимание, что мы ограничиваем минимальное значение знаменателя числом 0.001
, чтобы предотвратить деление на ноль в случае, если скалярное произведение вернет 0.0
.
Теперь мы можем вычислить вклад каждого источника света в уравнение отражения. Поскольку значение коэффициента Френеля напрямую соотносится с kS
, то мы можем использовать переменную F
для выражения зеркального вклада света, попадающего на поверхность, от любого источника. А используя kS
, можно рассчитать коэффициент преломления kD
:
1 2 3 4 |
vec3 kS = F; vec3 kD = vec3(1.0) - kS; kD *= 1.0 - metallic; |
Учитывая тот факт, что kS
представляет собой энергию отраженной составляющей света, оставшаяся часть световой энергии — это преломленный свет, значение которого мы храним в переменной kD
. Кроме того, поскольку металлические поверхности не преломляют свет и, следовательно, не имеют диффузных отражений, мы применяем это свойство, обнуляя значение kD
для металлических поверхностей. Это дает нам окончательные данные, необходимые для расчета значения коэффициента отражения для каждого источника света:
1 2 3 4 5 |
const float PI = 3.14159265359; float NdotL = max(dot(N, L), 0.0); Lo += (kD * albedo / PI + specular) * radiance * NdotL; } |
Результирующее значение Lo
или исходящая энергетическая яркость, фактически, является результатом того самого интеграла ∫
по множеству Ω
из уравнения отражения. На самом деле нам не нужно пытаться решить интеграл для всех возможных направлений входящего света, поскольку мы точно знаем 4 направления входящего света, которые могут повлиять на фрагмент. Благодаря этому мы можем просто пройтись циклом по этим источникам света.
Остается добавить (импровизированный) член фонового освещения к результату прямого освещения Lo
, и у нас есть окончательный цвет фрагмента:
1 2 |
vec3 ambient = vec3(0.03) * albedo * ao; vec3 color = ambient + Lo; |
Линейное пространство и HDR-рендеринг
До сих пор мы предполагали, что все наши вычисления происходят в линейном цветовом пространстве, и, чтобы учесть это, в конце шейдера необходимо выполнить гамма-коррекцию. Расчет освещения в линейном пространстве невероятно важен, поскольку PBR требует, чтобы все входные данные были линейными. Если проигнорировать данное требование, то мы получим неправильное освещение. Кроме того, мы хотим, чтобы входные данные источников света были близки к их физическим эквивалентам, так что значения энергетической яркости или цвета могут сильно варьироваться в диапазоне. В результате, значение переменной Lo
может расти очень быстро, но после оно все равно усекается до диапазона [0.0, 0.1]
из-за того, что выходные данные по умолчанию представлены в LDR. Мы исправим это, выполнив тональную компрессию после шага с гамма-коррекцией:
1 2 |
color = color / (color + vec3(1.0)); color = pow(color, vec3(1.0/2.2)); |
Здесь мы проводим операцию тональной компрессии на карте HDR-цвета с помощью оператора Рейнхарда, сохраняя расширенный динамический диапазон при сильно изменяющихся значениях облученности поверхности, после чего применяем гамма-коррекцию цвета. У нас нет отдельного фреймбуфера или этапа постобработки, поэтому мы можем непосредственно применить как тональную компрессию, так и шаг гамма-коррекции в конце фрагментного шейдера.
Учет как линейного цветового пространства, так и расширенного динамического диапазона невероятно важен для PBR-конвейера. Без них невозможно правильно уловить крупные и мелкие детали, отражающие свет различной интенсивности, и наши расчеты в конечном итоге окажутся неверными и, следовательно, визуально неприятными.
PBR-шейдер прямого освещения
Всё, что теперь осталось сделать, — это передать окончательный цвет в выходной канал фрагментного шейдера и можно считать, что у нас есть свой собственный готовый PBR-шейдер прямого освещения. Для полноты картины ниже приведен окончательный код всей функции main():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
#version 330 core out vec4 FragColor; in vec2 TexCoords; in vec3 WorldPos; in vec3 Normal; // Параметры материалов uniform vec3 albedo; uniform float metallic; uniform float roughness; uniform float ao; // Освещение uniform vec3 lightPositions[4]; uniform vec3 lightColors[4]; uniform vec3 camPos; const float PI = 3.14159265359; float DistributionGGX(vec3 N, vec3 H, float roughness); float GeometrySchlickGGX(float NdotV, float roughness); float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness); vec3 fresnelSchlick(float cosTheta, vec3 F0); void main() { vec3 N = normalize(Normal); vec3 V = normalize(camPos - WorldPos); vec3 F0 = vec3(0.04); F0 = mix(F0, albedo, metallic); // Уравнение отражения vec3 Lo = vec3(0.0); for(int i = 0; i < 4; ++i) { // Вычисляем энергетическую яркость каждого источника света vec3 L = normalize(lightPositions[i] - WorldPos); vec3 H = normalize(V + L); float distance = length(lightPositions[i] - WorldPos); float attenuation = 1.0 / (distance * distance); vec3 radiance = lightColors[i] * attenuation; // BRDF Кука-Торренса float NDF = DistributionGGX(N, H, roughness); float G = GeometrySmith(N, V, L, roughness); vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0); vec3 kS = F; vec3 kD = vec3(1.0) - kS; kD *= 1.0 - metallic; vec3 numerator = NDF * G * F; float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0); vec3 specular = numerator / max(denominator, 0.001); // Добавляем к исходящей энергетической яркости Lo float NdotL = max(dot(N, L), 0.0); Lo += (kD * albedo / PI + specular) * radiance * NdotL; } vec3 ambient = vec3(0.03) * albedo * ao; vec3 color = ambient + Lo; color = color / (color + vec3(1.0)); color = pow(color, vec3(1.0/2.2)); FragColor = vec4(color, 1.0); } |
Надеюсь, что с теорией из предыдущего урока и знанием уравнения отражения данный шейдер больше не будет для вас таким пугающим. Если мы возьмем этот шейдер, 4 точечных источника света и довольно много сфер, у которых будем менять как их значения металличности, так и значения шероховатости, в зависимости от их вертикального и горизонтального рядов расположения, то получим что-то вроде этого:
Снизу вверх значение металличности колеблется от 0.0
до 1.0
, а шероховатость увеличивается слева направо от 0.0
до 1.0
. Вы можете видеть, что, изменив только эти два простых для понимания параметра, мы уже можем отображать широкий спектр различных материалов.
GitHub / Урок №41. Освещение в PBR — Исходный код №1
PBR и Текстуры
Если мы расширим нашу систему таким образом, чтобы принимать параметры поверхности не в виде uniform-значений, а в виде текстур, то получим пофрагментный контроль над свойствами материала поверхности:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
[...] uniform sampler2D albedoMap; uniform sampler2D normalMap; uniform sampler2D metallicMap; uniform sampler2D roughnessMap; uniform sampler2D aoMap; void main() { vec3 albedo = pow(texture(albedoMap, TexCoords).rgb, 2.2); vec3 normal = getNormalFromNormalMap(); float metallic = texture(metallicMap, TexCoords).r; float roughness = texture(roughnessMap, TexCoords).r; float ao = texture(aoMap, TexCoords).r; [...] } |
Обратите внимание, что текстуры альбедо, которые приходят от художников, как правило, создаются в sRGB-пространстве, поэтому мы сначала преобразуем их в линейное пространство, прежде чем использовать альбедо в наших расчетах освещения. Основываясь на системе, которую художники используют для создания карт фонового затенения, вам также может потребоваться преобразовать их из sRGB-пространства в линейное пространство. Карты металличности и шероховатости почти всегда создаются в линейном пространстве.
Замена свойств материала предыдущего набора сфер текстурами уже показывает значительное визуальное улучшение, по сравнению с предыдущими алгоритмами освещения, которые мы использовали ранее:
GitHub / Урок №41. Освещение в PBR — Исходный код №2
Использованные текстуры находятся здесь.
Имейте в виду, что металлические поверхности в условиях прямого освещения, как правило, выглядят слишком темными, поскольку они не имеют диффузного отражения. Они будут выглядеть более корректно, если принять во внимание зеркальную составляющую фонового освещения окружающей среды, на чем мы и сосредоточимся на следующих уроках.
Хотя полученный результат не так уж и впечатляет, по сравнению с другими примерами PBR-рендеринга, но стоит учитывать, что у нас еще нет встроенного освещения на основе изображений (IBL). Система, которую мы имеем сейчас, все еще является физически корректным рендерингом, и даже без IBL можно увидеть, что освещение выглядит очень реалистичным.