🕹️자체엔진/입문

[번역강좌] 2. 조명 - 물리 기반 렌더링(Physically Based Rendering)

Mawile 2022. 9. 30.
728x90

이전 장에서 우리는 현실적인 물리적 기반 렌더러를 지상에서 끌어내기 위한 토대를 마련했다. 이 챕터에서는 앞에서 논의된 이론을 직접(또는 분석적) 광원을 사용하는 실제 렌더러(점등, 방향등 및/또는 스포트라이트)로 변환하는 데 초점을 맞출 것입니다.

먼저 이전 장의 최종 반사 방정식을 다시 살펴보겠습니다.

우리는 이제 거의 무슨 일이 일어나고 있는지 알고 있지만, 여전히 크게 알려지지 않은 것은 우리가 어떻게 정확하게 방사광도, 즉 현장의 총 광도 L을 나타낼 것인가 하는 것입니다. 우리는 광도 L(컴퓨터 그래픽 랜드에서 해석된 바와 같이)이 주어진 고체 각도(또는 실각) ω에 대한 광원의 복사 유량 γ 또는 빛 에너지를 측정한다는 것을 알고 있다. 우리의 경우, 우리는 고체의 각도 ω가 무한히 작다고 가정했는데, 이 경우 광도는 단일 광선 또는 방향 벡터에 대한 광원의 유량을 측정합니다.

이러한 지식을 바탕으로 이전 장에서 축적한 조명 지식으로 어떻게 해석할 수 있을까요? 자, RGB 삼중항으로 변환된 복사 플럭스(23.47, 21.31, 20.79)를 가진 단일 점광(모든 방향에서 동등하게 밝게 빛나는 광원)을 가지고 있다고 상상해 보세요. 이 광원의 복사 강도는 모든 나가는 방향 광선에서의 복사 유량과 같다. 그러나, 표면에 특정 점 p를 음영으로 칠할 때, 반구 Ω를 통해 가능한 모든 입사광 방향 중 오직 하나의 입사 방향 벡터만이 점 광원에서 직접 나온다. 우리 장면에는 오직 하나의 광원만 있기 때문에, 공간의 단일 점으로 가정할 때, 다른 모든 가능한 입사광 방향은 표면 점 p:

처음에, 우리는 빛 감쇠(거리에 따른 빛의 감쇠)가 점 광원에 영향을 미치지 않는다고 가정하면, 들어오는 광선의 광도는 우리가 빛을 어디에 배치하든(입사 각도 cosθ에 의한 광도 스케일링 제외) 동일하다. 이는 점광은 우리가 보는 각도와 상관없이 동일한 복사 강도를 가지고 있기 때문에 복사 강도를 복사 유량으로 효과적으로 모델링합니다: 상수 벡터(23.47, 21.31, 20.79).

그러나, 광도는 또한 입력으로 위치 p를 취하며, 어떤 현실적인 점 광원이 광 감쇠를 고려함에 따라 점 p와 광원 사이의 거리에 따라 점 광원의 복사 강도가 조정된다. 그런 다음, 원래의 광도 방정식에서 추출한 것처럼, 결과는 표면 법선 n과 들어오는 광 방향 wi 사이의 점 곱에 의해 스케일링된다.

이를 좀 더 실용적인 용어로 표현하자면, 직각 점광의 경우, 광도 함수 L은 빛의 색을 측정하는데, 이 밝기는 꼭대기 거리에 걸쳐 감쇠되고 wi에 의해 스케일링되지만, p에서 빛의 방향 벡터와 동일한 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);
vec3  radiance    = lightColor * attenuation * cosTheta;

 

다른 용어들을 제외하고, 이 코드 조각은 여러분에게 매우 익숙할 것입니다: 이것이 바로 우리가 지금까지 해온 확산 조명 방식입니다. 직접 조명에 관한 한, 광도는 단 하나의 광방향 벡터만이 표면의 광도에 기여하기 때문에 이전에 우리가 조명을 계산했던 방법과 유사하게 계산된다.

 

더보기

이 가정은 점등이 무한히 작고 공간의 단일 점에만 해당한다는 점에 유의하십시오. 만약 우리가 면적이나 부피를 가진 빛을 모델링한다면, 그것의 광도는 하나 이상의 들어오는 빛 방향에서 0이 아닐 것이다.

단일 지점에서 발생하는 다른 유형의 광원에 대해서도 마찬가지로 광도를 계산합니다. 예를 들어, 지향성 광원은 감쇠 계수 없이 일정한 wi를 갖는다. 그리고 스포트라이트는 일정한 복사 강도를 가지는 것이 아니라 스포트라이트의 전방 방향 벡터에 의해 크기가 조정됩니다.

이것은 또한 우리를 표면의 반구 Ω 위에 있는 적분 γ로 돌아오게 한다. 우리가 단일 표면 점을 음영으로 나타내면서 모든 기여 광원의 단일 위치를 미리 알고 있듯이, 적분을 시도하고 해결할 필요는 없다. 우리는 각 광원이 표면의 광도에 영향을 미치는 단일 광선 방향만을 가지고 있다는 점을 고려할 때, (알려진) 광원의 수를 직접 취하여 총 방사 조도를 계산할 수 있다. 이것은 우리가 기여하는 광원 위에 효과적으로 루프만 하면 되기 때문에 직접 광원의 PBR을 비교적 단순하게 만든다. 나중에 IBL 장에서 환경 조명을 고려할 때 빛이 어느 방향에서나 올 수 있으므로 적분을 고려해야 합니다.

 

 

PBR 표면 모형

앞서 설명한 PBR 모델을 구현하는 프래그먼트(또는 픽셀) 셰이더를 작성하는 것으로 시작하겠습니다. 먼저, 표면의 음영 조정에 필요한 관련 PBR 입력을 받아야 합니다.

#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;

우리는 일반적인 정점 셰이더와 객체 표면에 걸쳐 일정한 재료 특성 세트에서 계산된 표준 입력을 취한다.
그런 다음 프래그먼트(또는 픽셀) 셰이더를 시작할 때 모든 조명 알고리즘에 필요한 일반적인 계산을 수행합니다.

 

void main()
{
    vec3 N = normalize(Normal); 
    vec3 V = normalize(camPos - WorldPos);
    [...]
}

 

직접 조명

이 장의 예제 데모에서는 장면의 방사 조도를 함께 나타내는 총 4개의 점등이 있습니다. 반사율 방정식을 만족시키기 위해 우리는 각 광원에 루프하고, 그것의 개별 광도를 계산하고, BRDF와 빛의 입사 각도에 의해 스케일링된 기여도를 합한다. 우리는 루프가 직접 광원에 대한 Ω에 대한 적분 γ를 해결하는 것으로 생각할 수 있다. 먼저 관련 광도 변수를 계산합니다.

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; 
    [...]

 

선형 공간에서 조명을 계산할 때(쉐이더의 끝에서 감마 보정) 보다 물리적으로 정확한 역제곱 법칙에 의해 광원을 감쇠시킨다.

더보기

물리적으로 정확하지만, (물리적으로 정확하지는 않지만) 빛의 에너지 감소를 훨씬 더 잘 제어할 수 있는 상수-선형-2차 감쇠 방정식을 사용할 수도 있습니다.


그런 다음 각 조명에 대해 전체 Cook-Torrance 분광 BRDF 항을 계산하려고 합니다.

우리가 하고 싶은 첫 번째 일은 분광반사와 확산반사 사이의 비율, 즉 표면이 빛을 반사하는 양과 빛을 굴절시키는 양을 계산하는 것이다. 우리는 프레넬 방정식이 다음과 같이 계산한다는 것을 이전 장에서 알고 있다(검은 반점을 방지하기 위해 여기에 클램프를 주목하라).

vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

프레넬-슐릭 근사치는 제로 입사 시 표면 반사 또는 표면을 직접 볼 때 표면이 얼마나 반사되는지 알려진 F0 매개변수를 예상한다. F0은 재료마다 다르며, 대형 재료 데이터베이스에서 볼 수 있듯이 금속에 착색됩니다. PBR 금속 작업 흐름에서 우리는 대부분의 유전체 표면이 0.04의 상수 F0으로 시각적으로 정확해 보인다고 단순화하는 가정을 하는 반면, 알베도 값에 의해 주어진 대로 금속 표면에 F0을 지정합니다. 이것은 다음과 같이 코드로 변환된다.

 

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을 변화시킨다.

F가 주어지면 나머지 계산 항은 정규 분포 함수 D와 형상 함수 G입니다.

직접 PBR 조명 셰이더에서 코드 방정식은 다음과 같습니다:

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;
}

 

여기서 주목해야 할 것은 이론 장과 달리 거칠기 매개 변수를 이러한 함수에 직접 전달한다는 것입니다. 이렇게 하면 원래 거칠기 값에 대한 용어별 수정을 할 수 있습니다. 디즈니의 관찰과 에픽게임즈에 의해 채택된 것에 기초하여, 조명은 기하학과 정규 분포 기능 모두에서 거칠기를 제곱하는 것이 더 정확해 보인다.

두 함수를 모두 정의하면 반사 루프에서 NDF와 G 항을 계산하는 것은 간단합니다.

float NDF = DistributionGGX(N, H, roughness);       
float G   = GeometrySmith(N, V, L, roughness);


이를 통해 쿡-토랜스 BRDF를 계산할 수 있습니다.

vec3 numerator    = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0)  + 0.0001;
vec3 specular     = numerator / denominator;

0에 의한 분모를 방지하기 위해 0.0001을 추가한다는 점에 유의하십시오.

이제 우리는 마침내 반사 방정식에 대한 각 빛의 기여도를 계산할 수 있다. 프레넬 값은 kS에 직접 대응하므로 F를 사용하여 표면에 부딪히는 빛의 스펙트럼 기여도를 나타낼 수 있습니다. kS로부터 굴절률 kD를 계산할 수 있다.

vec3 kS = F;
vec3 kD = vec3(1.0) - kS;
  
kD *= 1.0 - metallic;

 

kS가 반사되는 빛의 에너지를 나타내는 것을 보면, 빛 에너지의 나머지 비율은 우리가 kD로 저장하는 굴절되는 빛이다. 게다가, 금속 표면은 빛을 굴절시키지 않고 따라서 확산 반사가 없기 때문에 우리는 표면이 금속일 경우 kD를 무효화함으로써 이 특성을 시행한다. 이것은 각 빛의 발신 반사 값을 계산하는 데 필요한 최종 데이터를 제공합니다.

    const float PI = 3.14159265359;
  
    float NdotL = max(dot(N, L), 0.0);        
    Lo += (kD * albedo / PI + specular) * radiance * NdotL;
}

 

결과 Lo 값 또는 방출 광도는 반사율 방정식의 Ω에 대한 적분의 결과입니다. 우리는 파편에 영향을 줄 수 있는 4개의 들어오는 빛의 방향을 정확히 알고 있기 때문에 가능한 모든 들어오는 빛의 방향에 대한 적분을 실제로 시도하고 해결할 필요가 없다. 이 때문에, 우리는 이러한 들어오는 빛 방향(예: 장면의 조명 수) 위에 직접 루프할 수 있다.

남은 것은 직접 조명 결과 Lo에 (임시된) 주변 용어를 추가하는 것이고, 우리는 조각의 최종 조명 색상을 가지고 있다.

vec3 ambient = vec3(0.03) * albedo * ao;
vec3 color   = ambient + Lo;

 

선형 및 HDR 렌더링

지금까지 우리는 모든 계산이 선형 색 공간에 있다고 가정했고, 이를 설명하려면 셰이더의 끝에서 감마 보정해야 합니다. PBR은 모든 입력이 선형이어야 하기 때문에 선형 공간에서 조명을 계산하는 것은 매우 중요하다. 이 점을 고려하지 않으면 조명이 올바르지 않게 됩니다. 또한, 우리는 광 입력이 높은 값의 스펙트럼에 걸쳐 광도 또는 색상 값이 크게 달라질 수 있도록 물리적 등가물에 근접하기를 원한다. 그 결과, Lo는 매우 빠르게 증가할 수 있으며, 이는 기본 LDR(low dynamic range) 출력으로 인해 0.0과 1.0 사이에서 클램프된다. 감마 보정 전에 Lo를 취하고 HDR(고다이나믹 레인지) 값을 LDR에 올바르게 매핑하여 이를 해결한다.

color = color / (color + vec3(1.0));
color = pow(color, vec3(1.0/2.2));


여기서 우리는 Reinhard 연산자를 사용하여 HDR 색상을 톤 매핑하여, 고도로 변동될 수 있는 방사 조도의 높은 동적 범위를 보존하고, 그 후 색을 감마 보정한다. 별도의 프레임 버퍼나 후처리 단계가 없어 포워드 프래그먼트 셰이더 끝에 톤 매핑과 감마 보정 단계를 모두 직접 적용할 수 있다.

 

모두 고려 선형 색 공간과 높은 동적 범위 엄청난 PBR파이프 라인에서 중요하다.이 없이 적절하게 다양한 빛 강도 높고 낮은 세부 사항도 포착할 당신의 계산과 따라서 시각적으로 유쾌하지 않은 잘못된 결국 불가능하다.

 

직접조명 PBR 전체 코드

이제 남은 것은 최종 톤 매핑 및 감마 보정 색상을 프래그먼트 셰이더의 출력 채널에 전달하고 우리는 직접 PBR 조명 셰이더를 가지고 있습니다. 완전성을 위해 전체 주요 기능은 다음과 같습니다.

#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);

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) + 0.0001;
        vec3 specular     = numerator / denominator;  
            
        // 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);
}

 

이전 장의 이론과 반사 방정식에 대한 지식으로 이 셰이더가 더 이상 그렇게 위압적이지 않기를 바랍니다. 만약 우리가 이 셰이더, 4점 조명, 그리고 수직축과 수평축의 금속과 거칠기 값을 각각 다르게 하는 꽤 많은 구들을 취한다면, 우리는 다음과 같은 것을 얻을 수 있을 것입니다.

금속 값은 아래에서 위로 0.0 ~ 1.0 범위이며, 거칠기는 0.0 ~ 1.0에서 왼쪽 ~ 오른쪽으로 증가합니다. 이 두 가지 간단한 매개 변수만 변경해도 이미 다양한 재료를 표시할 수 있습니다.

더보기
#version 300 es
precision highp float;

uniform vec2  iResolution;

uniform vec3  albedo;     // value=1,0,0
uniform float metallic;   // value=0, min=0, max=1, step=0.001
uniform float roughness;  // value=0.2, min=0, max=1, step=0.001

in  vec2 vScreen;
out vec4 fragColor;

const float PI = 3.14159265359;

float distributionGGX (vec3 N, vec3 H, float roughness){
    float a2    = roughness * roughness * roughness * roughness;
    float NdotH = max (dot (N, H), 0.0);
    float denom = (NdotH * NdotH * (a2 - 1.0) + 1.0);
    return a2 / (PI * denom * denom);
}

float geometrySchlickGGX (float NdotV, float roughness){
    float r = (roughness + 1.0);
    float k = (r * r) / 8.0;
    return NdotV / (NdotV * (1.0 - k) + k);
}

float geometrySmith (vec3 N, vec3 V, vec3 L, float roughness){
    return geometrySchlickGGX (max (dot (N, L), 0.0), roughness) * 
           geometrySchlickGGX (max (dot (N, V), 0.0), roughness);
}

vec3 fresnelSchlick (float cosTheta, vec3 F0){
    return F0 + (1.0 - F0) * pow (1.0 - cosTheta, 5.0);
}

// https://www.shadertoy.com/view/4d2XWV by Inigo Quilez
float sphereIntersect(vec3 ro, vec3 rd, vec4 sph) {
	vec3 oc = ro - sph.xyz;
	float b = dot( oc, rd );
	float c = dot( oc, oc ) - sph.w*sph.w;
	float h = b*b - c;
	if( h<0.0 ) return -1.0;
	return -b - sqrt( h );
}

void main (){
    vec3 lightPos   = vec3(1.25, 1.0, -2);
    vec3 lightColor = vec3(1.0);
    vec3 totColor = vec3(0.0);
    
    vec3 ro = vec3(0.0, 0.0, -2.0);

    for (float x = 0.0; x <= 1.0; x += 1.) {
        for (float y = 0.0; y <= 1.0; y += 1.) {
            
            vec3 rd = normalize(vec3(vScreen + vec2(x, y) / iResolution.y, 1.2));
            float d = sphereIntersect(ro, rd, vec4(0,0,0,1));
            
            if (d > 0.) {
                vec3 worldPos = ro + d * rd;
                vec3 N = normalize (worldPos);
                vec3 V = -rd;
                vec3 L = normalize (lightPos - worldPos);
                vec3 H = normalize (V + L);
                
                // Cook-Torrance BRDF
                vec3  F0 = mix (vec3 (0.04), pow(albedo, vec3 (2.2)), metallic);
                float NDF = distributionGGX(N, H, roughness);
                float G   = geometrySmith(N, V, L, roughness);
                vec3  F   = fresnelSchlick(max(dot(H, V), 0.0), F0);        
                vec3  kD  = vec3(1.0) - F;
                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);  
                    
                float NdotL = max(dot(N, L), 0.0);                
                vec3  color = lightColor * (kD * pow(albedo, vec3 (2.2)) / PI + specular) * 
                              (NdotL / dot(lightPos - worldPos, lightPos - worldPos));
                
                totColor += color;
            }
        }
    }
    
    // HDR tonemapping gamma correct
    fragColor = vec4(pow(totColor/(totColor + 1.0), vec3 (1.0/2.2)), 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;
    [...]
}

 

예술가에서 나오는 알베도 텍스처는 일반적으로 sRGB 공간에서 작성되므로 조명 계산에 알베도를 사용하기 전에 먼저 선형 공간으로 변환한다. 아티스트가 주변 폐색 맵을 생성하는 데 사용하는 시스템을 기반으로 sRGB에서 선형 공간으로 변환해야 할 수도 있습니다. 금속 및 거칠기 맵은 거의 항상 선형 공간에서 작성됩니다.

이전 구체의 재료 특성을 텍스처로 대체하면 이전 조명 알고리즘에 비해 시각적 효과가 크게 향상되었습니다.

 

텍스처 데모의 전체 소스 코드와 여기에 사용된 텍스처 세트를 찾을 수 있습니다(흰색 ao 맵 사용). 금속 표면은 확산 반사율이 없기 때문에 직접 조명 환경에서는 너무 어둡게 보이는 경향이 있습니다. 환경의 스펙트럼 주변 조명을 고려할 때 더 정확해 보입니다. 다음 장에서 중점적으로 다룰 내용입니다.

이미지 기반 조명이 아직 내장되어 있지 않다는 점을 감안할 때, 일부 PBR 렌더 데모만큼 시각적으로 인상적이지는 않지만, 현재 보유하고 있는 시스템은 여전히 물리적 기반 렌더러이며, IBL이 없어도 조명이 훨씬 더 사실적으로 보입니다.

728x90

댓글