光源辐射率:
辐射率(radiance)表示光源在给定立体角ω下的辐射通量(或光源发射的能量)。
那么假设立体角ω无限小时,辐射率就表示单束光线(或说某个单一方向)的辐射通量。
点光源:point light,在所有方向都有相同的亮度,辐射强度(radiant intensity)等于其发射出来的所有方向的辐射通量(radiant flux)。
对于场景中的一个点p,只会有一束光直接照射到点p,其他 光线的辐射率都为0。
辐射强度:点光源无论从任何角度看,点光源都有相同的辐射强度,所以可以简单地
使用其辐射通量表示辐射强度,也就是一个RGB常亮。
辐射率:需要考虑点p的位置,距离衰减,法线角度衰减。
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); float radiance = lightColor * attenuation * cosTheta;
基本和普通diffuse漫反射光照一样。
前提:假设点光源无限小,如果有体积,点光源会有一个以上的入射光线辐射率不为0。
方向光:directional light,辐射率拥有恒定的入射方向,而且不会有衰减。
聚光灯:spotlight,没有恒定的辐射强度,而是会根据照射方向有所不同。
直接光照:
辐照度=所有光源的辐射率,所以直接光照的计算非常简单,只需要逐个光源计算辐射率,然后加在一起。接着根据BRDF和光源的入射角来缩放该辐射率。
这个算法也是符合反射率方程(The reflectance equation)的积分运算的。
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; [...]
由于我们在线性空间内计算光照,我们使用在物理上更为准确的平方倒数(inverse-square law)作为衰减因子。
对于每一个光源都需要计算完整的BRDF项:
Ks = F,也就是菲涅尔系数,表示光线给反射的百分比:
vec3 fresnelSchlick(float cosTheta, vec3 F0) { return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); }
Fo表示0度入射角的反射(surface relfection at zero incidence),也就是垂直看向材质时的反光率。Fo会因材质的不同而不同,而且金属的反光还会带有颜色。大多数非金属材质取0.04都能取得视觉上物理可信的效果。对于金属材质则直接取albedo贴图的颜色值。
vec3 F0 = vec3(0.04); F0 = mix(F0, albedo, metallic); vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);
D和G计算的实现:
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 nom = a2; float denom = (NdotH2 * (a2 - 1.0) + 1.0); denom = PI * denom * denom; return nom / denom; } float GeometrySchlickGGX(float NdotV, float roughness) { float r = (roughness + 1.0); float k = (r*r) / 8.0; float nom = NdotV; float denom = NdotV * (1.0 - k) + k; return nom / 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采用经验,在D和G的计算公式中
使用roughness * roughness来进行计算会有更正确的光照效果。
float NDF = DistributionGGX(N, H, roughness); float G = GeometrySmith(N, V, L, roughness);
Cook-Torrance BRDF反射高光部分:
vec3 nominator = NDF * G * F; float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.001; vec3 specular = nominator / denominator;
额外加了一个0.001是为了防止除0。
菲涅尔方程求出了Ks,那么Kd=1-Ks,但同时考虑到金属没有折射光线,也就是没有漫反射,所以金属的Kd=0,代码如下:
vec3 kS = F; vec3 kD = vec3(1.0) - kS; kD *= 1.0 - metallic;
最终的反射率:
const float PI = 3.14159265359; float NdotL = max(dot(N, L), 0.0); Lo += (kD * albedo / PI + specular) * radiance * NdotL; }
可以看到specular没有再乘一次Ks,是因为DFG里面的F实际上就是Ks。
再加一个环境光项(ao贴图):
vec3 ambient = vec3(0.03) * albedo * ao; vec3 color = ambient + Lo;
IBL环境光照:
需要计算积分,因为光线会在任何一个方向入射到物体表面。
线性空间和HDR渲染:
我们之前所有的运算都必须在线性空间(linear space)进行,所以我们需要在shader的最后做伽马校正(gamma correct)。
线性空间:PBR要求所有的输入都是线性的,不然计算的结果会不正确。
我们希望输入的光照都尽可能接近真实,那么计算出的辐射率范围可能会非常大,也就是说Lo是一个HDR的值(大于1.0)。但最终的颜色输出范围是LDR的,所以在gamma correct之前,我们会先通过色调映射(tone or exposure map)将HDR值映射到LDR的值。
color = color / (color + vec3(1.0)); color = pow(color, vec3(1.0/2.2));
这里我们使用了Reinhard方法来进行色调映射,我们没有使用framebuffer或post-processing的方式来进行色调映射,所以我们直接在shader的最后添加了这两步。
PBR pipeline需要考虑线性空间和HDR的因素,不然可能渲染出来的画面会丢失细节,并且视觉上不正确看起来也不好看。
完整的直接光照PBR shader:
#version 330 core out vec4 FragColor; in vec2 TexCoords; in vec3 WorldPos; in vec3 Normal; // material parameters uniform vec3 albedo; uniform float metallic; uniform float roughness; uniform float ao; // lights 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, float roughness); void main() { vec3 N = normalize(Normal); vec3 V = normalize(camPos - WorldPos); vec3 F0 = vec3(0.04); F0 = mix(F0, albedo, metallic); // reflectance equation vec3 Lo = vec3(0.0); for(int i = 0; i < 4; ++i) { // calculate per-light radiance 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; // cook-torrance 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); // add to outgoing radiance 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); }
带贴图的PBR:
将主要参数都放到贴图中,这样可以带来更大的灵活性。
[...] 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; [...] }
美术一般在输出albedo贴图时都会使用sRGB空间,所以需要在计算光照之前手动转到线性空间。ao贴图也一样。不过metallic和roughness多数都会直接使用线性空间来保存。