Lighting
이전 튜토링러에서, 우리는 현실적인 PBR을 위해 기초를 밑바닥부터 닦았다. 이 튜토리얼에서, 우리는 이전에 이야기한 이론을 direct (or analytic) 광원을 사용하는 실제 렌더러에 옮기는데 집중할 것이다: point lights, directional lights and/or spotlights를 생각해보아라.
이전 튜토리얼의 최종 reflectance equation을 다시 봐보자:
우리는 이제 무엇이 일어나는지 대개 알지만, 여진히 크게 모르는 것은 우리가 총 radiance L인 scene의 irradiance를 어떻게 나타내는지이다. 우리는 radiance L (컴퓨터 그래픽스에서 land라고 번역되는)이 radiant flux Φ 또는 주어진 solid angle ω에 대한 광원의 light energy를 측정한다는 것을 안다. 우리의 경우에, 우리는 solid angle ω가 무한히 작다고 가정했었다. 그리고 그 경우에, radiance는 한 단일의 광선 또는 방향벡터에 대해 광원의 flux를 측정한다.
이 지식을 고려하여, 어떻게 우리는 이것을 이전 튜토리얼에서 축적한 lighting knowledge로 바꾸는가? 음, 우리가 단 일의 point light를 가지고 있다고 상상하자 (모든 방향에서 동일하게 밝게 빛나는 광원이다) 그리고 이것은 (23.47, 21.31, 20.79)의 radian flux를 가지고 있고, RGB triplet으로 바뀐다. 이 광원의 radiant intensity는 모두 나가는 방향 광선에서 그것의 radiant flux와 동일하다. 그러나, 한 표면에 있는 특정한 점 p를 shading할 때, 그 점의 반구 Ω에 대해 모든 가능한 들어오는 빛의 방향 중에서, 오직 하나의 들어오는 방향 벡터 w_i는 직접적으로 그 point light source로부터 들어온다. 우리는 우리의 scene에서 오직 하나의 광원만을 가지고 있기 때문에, 공간에서 한 점이라고 가정된다면, 모든 다른 가능한 들어오는 빛의 방향은 표면 점 p에 대해 zero radiance로 관찰된다:
처음에 만약 우리가 light attenuation (거리에 따라 light가 어두워지는 것)이 point light source에 영향을 미치지 않는다고 가정한다면, 그 들어오는 광선의 radiance는 우리가 그 빛을 어디에 두는지 상관없이 같게 된다 (radiance를 incident angle cosθ로 스케일링 하는 것을 제외하고). 이렇게 그 point light는 우리가 그것을 바라보는 각도에 상관없이 같은 radiant intensity를 가지기 때문에, 이것은 효과적으로 그것의 radiant intensity를 그것의 radiant flux로 모델링한다 : constant vector (23.47, 21.31, 20.79).
그러나, radiance는 또한 위치 p를 입력으로 받고, 어떤 현실적인 point light source가 light attenuation을 고려하기 때문에, 그 point light source의 radiant intensity는 점 p와 광원 사이의 거리의 측정치로 스케일링 된다. 그러고나서, 원래의 radiance equation에서 추출되었듯이, 그 결과는 표면의 법선 벡터 n과 들어오는 빛의 방향 w_i 사이의 내적으로 스케일링 된다.
이 것을 좀 더 실제적인 말로 하자면 : direct point light의 경우에, 그 radiance function L은 p에 대한 그것의 거리에 따라 감쇠되고, n・w_i로 스케일링 된다. 그러나 오직 p로부터 빛의 방향벡터와 동일한 p에 닿는 단일의 광선 w_i에 대해서만 이다. 코드에서 이것은 이렇게 바뀐다:
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) lighting을 한 방법이다. direct lighting에 대해서, radiance는 우리가 이전에 lighting을 계산한 방법과 유사하다. 오직 단일의 light direction vector만이 surface의 radiance에 기여하기 때문이다.
Green Box
이 가정은 point lights가 무한히 작고, 공간에서 단일의 점이기 때문에 유효하다. 만약 우리가 volume을 갖는 빛을 모델링하려 한다면, 그것의 radiance는 하나의 들어오는 빛의 방향보다 더 큰 0이 아닌 것이다.
단ㄴ일의 점으로부터 나오는 광원의 여러 타입들에 대해, 우리는 radiance를 유사하게 계산한다. 예를들어, directional light source는 어떠한 attenuation factor없이 상수 w_i를 갖는다. 그리고 spotlight는 constant radiant intensity를 갖지 않지만, spotlight의 forward direction vector에 의해 스케일링 된 것을 가진다.
이것은 또한 우리에게 표면의 반구 Ω에 대한 적분 ∫으로 우리를 되돌려준다. 우리가 미리 모든 기여하는 광원의 단일 위치를 알기 때문에, 한 single surface point를 쉐이딩 하는 동안, 그 적분을 시도하고 해결하는 것은 요구되지 않는다. 우리는 직접적으로 광원의 (알려진) 개수를 취할 수 있고, 그것들의 총 irradiance를 계산할 수 있따. 각 광원이 오직 그 표면의 radiance에 영향을 미치는 단일의 light direction을 가지기만 한다는 것을 고려하면. 이것은 direct light sources에서 PBR을 비교적 간단하게 만든다. 우리가 효과적으로 오직 contributing light sources에 대해서만 루프를 돌면 되기 때문이다. 우리가 나중에 IBL tutorial에서 environment lighting을 고려할 때, 우리는 빛이 어떤 방향으로든 들어올 수 있음에 따라 적분을 고려해야만 한다.
A PBR surface model
이전에 설명된 PBR models을 구현하는 fragment shader를 써보자. 처음에, 우리는 surface를 쉐이딩하는데 요구되는 관련된 PBR 입력을 취할 필요가 있다:
#version 330 core out vec4 FragColor; in vec2 TexCoords; in vec3 WorldsPos; in vec3 Normal; uniform vec3 camPos; uniform vec3 albedo; uniform float metallic; uniform float roughness; uniform float ao;
우리는 generic vertex shader에서 계산된 표준입력을 취하고, 그 오브젝트의 표면에 대한 상수 material properties의 집합을 받는다.
그러고나서 fragmetn shadser의 시작에서, 우리는 lighting algorithm을 위해 요구되는 보통의 계산을 한다:
void main() { vec3 N = normalize(Normal); vec3 V = normalize(camPos - WorldPos); }
Direct lighting
이 튜토리얼의 예제 데모에서, 우리는 scene의 irradiance를 직접적으로 나타내는 총 4개의 point lights를 가진다. reflectance 방정식을 만족시키기 위해, 우리는 각 광원에 대해 loop를 돌고, 그것의 개별 radiance를 계산하고, BRDF와 빛의 incident angle로 스케일된 그것의 기여를 합한다. 우리는 그 루프를 direct light sources의 Ω에 대한 ∫ 적분을 푸는 것으로 생각할 수 있다. 처음에, 우리는 관련된 per-light variables를 계산한다:
void main() { vec3 N = normalize(Normal); vec3 V = normalize(camPos - WorldPos); vec3 Lo = vec3(0.0); for(int i = 0; i < 4; ++i) { vec3 L = normalize(lightPositions[i] - WorldPos); vec3 H = normalize(V + L); float dist = length(lightPositions[i] - WorldPos); float attenuation = 1.0 / (dist * dist); vec3 radiance = lightColors[i] * attenuation; } }
우리가 선형 공간에서 lighting을 계산하기 때문에 (우리는 쉐이더 끝에서 감마 보정을 할 것이다), 우리는 광원을 좀 더 물리적으로 올바른 inverse-square law로 감쇠시킨다.
Green Box
물리적으로 올바른 반면에, 너는 여전히 너에게 상당히 light의 energy 저하에 대한 좀 더 많은 통제력을 줄 수 있는 constant, linear, quadratic attenuation equation을 쓰고 싶어할지도 모른다 (물리적으로 올바르지 않지만)
그러고나서 각 light에 대해, 우리는 완전한 Cook-Torrance specular BRDF 항을 계산하고 싶어한다:
우리가 해야하는 첫 번째 것은 specular and diffuse reflection사이의 비율을 계산하는 것이고 또는 그 표면이 빛을 얼마나 반사하고 얼마나 많이 굴절시키는지이다. 우리는 이전 튜토리얼로부터 Fresnel equation이 그것을 계산한다는 것을 안다:
vec3 fresnelSchlick(float cosTheta, vec3 F0) { return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); }
Fresnel-Schlick 근사는 zero incidence에서의 surface reflection이라고 알려진 F0 파라미터를 기대한다. 또는 그 표면을 직접 보았을 때 그 표면이 얼마나 많이 반사시키는 지다. F0는 물질마다 다르고, 금속에서는 색이 칠해진다. 우리는 large material databases에서 그것을 찾을 수 있다. PBR metallic workflow에서, 우리는 대부분의 dielectric surfaces가 시각적으로 0.04의 F0을 가지면 옳게 보인다는 간단화된 가정을 한다. 반면에 metallic surfaces에 대한 F0은 명시한다. albedo value가 주어지듯이. 이것은 코드에서 다음과 같이 된다:
vec3 F0 = vec3(0.04); F0 = mix(F0, albedo, metallic); vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);
너도 볼 수 있듯이, non-metallic surfaces에 대해 F0는 항상 0.04이다. 반면에, 우리는 원래의 F0과 metallic property가 주어진 albedo value사이의 선형보간으로 한 표면의 metalness를 기반으로 F0을 다르게 한다.
F가 주어진다면, 계산할 남은 항들은 normal distribution function D와 geometry function G이다.
direct PBR lighting shader에서 그것들의 코드는 다음과 같다:
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 k) { float nom = NdotV; float denom = NdotV * (1.0 - k) + k; return nom / denom; } float GeometrySmith(vec3 N, vec3 V, vec3 L, float k) { float NdotV = max(dot(N, V), 0.0); float NdotL = max(dot(N, L), 0.0); float ggx1 = GeometrySchlickGGX(NdotV, k); float ggx2 = GeometrySchlickGGX(NdotL, k); return ggx1 * ggx2; }
여기에서 주목해야할 중요한 것은 이론 튜토리얼과 대조적으로 우리가 이러한 함수들에 직접적으로 roughness parameter를 넘긴다는 것이다; 이 방식으로 우리는 원래 roughness value에 대해 term-specific modiciations을 할 수 있다. Disney에 의한 관찰을 기반으로하고, Epic Games에 의해 채택된 그 lighting은 geometry and normal distribution function 둘 다에서 roughness를 제곱하여 좀 더 올게 보인다.
두 함수를 정의한 채, reflectance loop와 NDF와 G항을 계산하는 것은 간단하다:
float NDF = DistributionGGX(N, H, roughness); float G = GeometrySmith(N, V, L, roughness);
이것은 우리에게 Cook-Torrance BRDF를 계산하기에 충분한 것을 준다:
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.0으로 끝나는 경우에 0으로 나누는 것을 방지하고자 denominator가 0.001로 제한한 것에 주목해라.
이제 우리는 마침내 각 빛의 reflectance equation에 대한 기여를 계산할 수 있다. Fresnel value는 직접적으로 k_s와 대응되기 때문에, 우리는 표면에 닿는 어떤 빛의 specular contribution을 표기하기 위해 F를 사용할 수 있다. k_s로부터 우리는 그러고나서 직접저긍로 굴절비율 k_d를 계산할 수 있다:
vec3 kS = F; vec3 kD = vec3(1.0) - kS; kD *= 1.0 - metallic;
kS가 반사된 빛의 에너지를 나타내는 것으로 보아, 빛에너지의 남은 비율은 kD로 저장하는 굴절된 빛이다. 게다가, metallic surfaces는 빛을 굴절시키기 않고 따라서 어떠한 diffuse reflections을 갖지 않기 때문에, 우리는 만약 surface가 metallic이라면, kD를 무효화시켜 이 특성을 강요시킨다. 이것은 우리에게 각 빛의 나가는 reflectance value를 계산하기위해 필요한 최종 데이터를 준다:
const float PI = 3.14159265359; float NdotL = max(dot(N, L), 0.0); L0 += (kD * albedo / PI + specular) * radiance * NdotL;
최종 Lo value, 또는 outgoing radiance는 효과적으로 Ω에 대한 reflectance equations의 ∫ 적분의 결과이다. 우리는 정말 모든 가능한 들어오는 빛의 방향에 대해 적분을 시도하고 풀 필요가 없다. 우리는 정확히 4개의 들어오는 fragment에 영향을 줄 수 있는 빛의 방향을 알기 때문이다. 이 때문에, 우리는 직접적으로 이러한 들어오는 빛의 방향에 대해 루프를 돌 수 있다. 예를들어, scene에 있는 lights의 개수.
남은 것은 direct lighting result Lo에 (improvised) ambient term를 더하는 것이다. 그리고 우리는 그 fragment의 최종 비춰진 color를 갖는다:
vec3 ambient = vec3(0.03) * albedo * ao; vec3 color = ambient + Lo;
Linear and HDR rendering
이제까지, 우리는 모든 계산이 선형 color space에 있다고 가정해왔고, 이것에 대해 우리는 쉐이더에 끝에서 감마 보정을 할 필요가 있다. 선형 공간에서 lighting을 계산하는 것은 놀랍게도 중요하도. PBR은 모든 inputs이 linear인 것을 요구하기 떄문이다. 이것을 고려하지 않은 것은 정확하지 않은 lighting을 만들어 내는 것은 아니다. 부가적으로, 우리는 light inputs이 그것들의 physical equivalents와 가깝기를 원한다. 그것들의 radiance or color values가 높은 값의 스펙트럼에 대해 다양할 수 있도록 하기 위해서이다. 결과적으로 Lo는 급격하게 매우 높에 성장할 수 있다. 그리고 이것은 0.0 ~ 1.0사이로 clamped 된다. 디폴트 low dynamic range (LDR) output 때문에. 우리는 이것을 Lo를 취하고, 감마 보정전에 LDR에 대해 정확하게 high dynamic ragne (HDR) 값으로 tone or exposure map을 한다:
color = color / (color + vec3(1.0)); color = pow(color, vec3(1.0/2.2));
여기에서 우리는 Reinhard operator를 사용하여 HDR color로 tone map한다. 이것은 가능하게 매우 다양한 irradiance의 HDR을 보존한다. 그 후에 우리는 그 컬러를 감마 보정한다. 우리는 별개의 framebuffer 또는 후처리 단계를 갖지 않는다. 그래서 우리는 직접적으로 tone mapping step과 감마보정 스텝을 forward fragment shader의 끝에서 직접적으로 둘 다를 적용한다.
linear color space와 high dynamic range 둘 다를 고려하는 것은 놀랍게도 PBR pipeline에서 중요하다. 이러한 것 없이, 적절히 빛의 강도를 다양하게 하는 것의 높은 디테일과 낮은 디테일을 적절히 포착하는 것은 불가능하고, 너의 계산은 부정확하게 되고, 따라서 시각적으로도 좋지 않을 것이다.
Full direct lighting PBR shader
이제 남은 모든 것은 최종 tone mapped and 감마보정된 color를 fragment shader의 output channel로 보내는 것이고, 우리는 direct PBR lighting shader를 갖는다. 완전함을 위해, 완전한 main 함수는 아래에 있다:
#version 330 core out vec4 FragColor; in vec2 TexCoords; in vec3 WorldsPos; in vec3 Normal; uniform vec3 camPos; uniform vec3 albedo; uniform float metallic; uniform float roughness; uniform float ao; uniform vec3 lightPositions[4]; uniform vec3 lightColors[4]; const float PI = 3.14159265359; float DistributionGGX(vec3 N, vec3 H, float roughness); float GeometrySchlickGGX(float NdotV, float k); float GeometrySmith(vec3 N, vec3 V, vec3 L, float k); 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); // 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 dist = length(lightPositions[i] - WorldPos); float attenuation = 1.0 / (dist * dist); vec3 radiance = lightColors[i] * attenuation; // cook-torrance brdf float D = DistributionGGX(N, H, roughness); vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0); float G = GeometrySmith(N, V, L, roughness); vec3 numerator = D * F * G; float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0); vec3 specular = numerator / max(denominator, 0.001); vec3 kS = F; vec3 kD = vec3(1.0) - kS; kD *= 1.0 - metallic; 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); } 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 k) { float nom = NdotV; float denom = NdotV * (1.0 - k) + k; return nom / denom; } float GeometrySmith(vec3 N, vec3 V, vec3 L, float k) { float NdotV = max(dot(N, V), 0.0); float NdotL = max(dot(N, L), 0.0); float ggx1 = GeometrySchlickGGX(NdotV, k); float ggx2 = GeometrySchlickGGX(NdotL, k); return ggx1 * ggx2; } vec3 fresnelSchlick(float cosTheta, vec3 F0) { return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); }
희망스럽게도, 이전 튜토리얼의 이론과 reflectance equation과 함께, 이 쉐이더는 더 이상 두렵지 않을 것이다. 만약 우리가 이 쉐이더, 4개의 point lights 그리고 우리가 그것들이 metallic과 roughness values를 그것들의 vertical and horizontal axis에 따라 개별적으로 다르게 하는 꽤 몇 개의 구들을 취한다면, 우리는 이것과 같은 것을 얻는다:
밑에서부터 위에까지는 metallic value 0.0에서 1.0까지의 범위이다. roughness는 왼쪽에서 오른쪽으로 0.0 ~ 1.0까지 이다. 너는 파라미터를 이해하기 위해 이러한 두 개의 간단한 것을 바꾸어서, 우리가 이미 다른 물질들의 다양한 array를 보여줄 수 있다는 것을 알 수 있다.
너는 여기에서 데모의 풀 소스코드를 찾을 수 있다.
Textured PBR
uniform values 대신에 그것의 surface parameters로 텍스쳐를 받기위해 그 시스템을 화장하는 것은 우리에게 surface material의 특징에 대해 per-fragment control을 준다:
uniform sampler2D albedoMap; uniform sampler2D normalMap; uniform sampler2D metallicMap; uniform sampler2D roughnessMap; uniform sampler2D aoMap; void main() { vec3 albedo = pow(texture(albedoMap, TexCoords).rgb, vec3(2.2)); float metallic = texture(metallicMap, TexCoords).r; float roughness = texture(roughnessMap, TexCoords).r; float ao = texture(aoMap, TexCoords).r; vec3 N = getNormalFromMap();
아티스트로부터 오는 albedo texture는 일반적으로 sRGB space에서 관리된다. 그래서 우리의 lighting calculations에서 albedo를 사용하기 전에 우리가 처음에 그것들을 linear space로 바꾼 이유이다. ambient occlusion maps을 생성하기 위해 아티스트들이 사용하는 시스템을 기반으로, 너는 또한 이러한 것들을 sRGB에서 linear space으로 바꿔야 할지도 모른다. Metallic and roughness maps는 거의 항상 linear space로 관리된다.
텍스쳐가 있는 구의 이전 set의 material properties를 바꾸는 것은 벌써 우리가 사용한 이전의 lighting algorithms보다 더 중요한 시각적 개선을 보여준다:
너는 여기에서 텍스쳐 데모의 완전한 소스코드를 볼 수 있다. 그리고 여기에서 내가 상요한 텍스쳐 세트를 볼 수 있다. (white ao map도 함께). metallic surfaces는 direct lighting environments에서 너무 어둡게 보이는 경향이 있다. 그것들이 diffuse reflectance를 가지지 않기 때문이다. 그것들은 우리가 다음 튜토리얼에서 집중할 환경의 specular ambient lighting을 고려할 때 좀 더 옳게 보인다.
너가 밖에서 찾은 PBR render demos의 몇몇 만큼 시각적으로 인상적이지 않을지라도, 우리가 아직 image based lighting을 구현하지 않은 것을 고려한다면, 우리가 지금 가진 시스템은 여전히 physically based renderer이고, 심지어 IBL 없이도, 너는 너의 라이팅 외관이 좀 더 현실적이게 된 것을 볼 것이다.
댓글 없음:
댓글 쓰기