Post Lists

2018년 8월 23일 목요일

Physically Based Rendering - Specular IBL (4)

https://learnopengl.com/PBR/IBL/Specular-IBL

Specular IBL
이전 튜토리얼에서, 우리는 irradiance map을 lighting의 indirect diffuse 부분으로 미리 계산하여, IBL과 조합하여 PBR을 설정했다. 이 튜토리얼에서, 우리는 reflectance equation의 specular part에 집중할 것이다:



너는 (kS로 곱해진) Cook-Torrance specular 부분이 적분에 대해 상수가 아니고, 들어오는 빛의 방향에 의존한다는 것을 눈치챌 것이다. 모든 가능한 view direction을 포함하여 모든 들어오는 빛의 방향에 대해 적분을 풀려고 하는 것은 combinatorial overload이고, 실시간에서 계산하기에 너무 비싸다. Epic Games는 몇 가지 타협점들을 고려하여 실시간 목적을 위해 specular part를 미리 convolute할 수 있는 해결책을 제안했었다. 그리고 그것은
split sum approximation이라고 알려져있다.

split sum approximation은  reflectance equation의 specular part를 우리가 개별적으로 convolute하고 나중에 specular indirect image based lighting을 위해 PBR shader에서 결합할 수 있는 두 개의 별개의 부분으로 분리하는 것이다. 우리가 irradiance map에서 미리 convoluted한 것과 유사하게, 그 split sum approximation은 그것의 convolution input으로서 HDR environment map을 요구한다. 그 split sum approximation을 이해하기 위해서, 우리는 다시 reflectance equation을 볼 것이지만, 이번에는 오직 specular part에만 집중할 것이다( 우리는 이전 튜토리얼에서, diffuse part를 추출했었다)):



irradiance convolution의 같은 (성능의) 이유 때문에, 우리는 실시간으로 적분의 specular part를 해결할 수 없고, 합리적인 성능을 기대할 수 없다. 그래서 가급적, 우리는 specular IBL map과 같은 것을 얻기 위해 이 적분을 미리 계산하고, fragment의 normal로 이 map을 샘플링하고, 그것으로 처리된다. 그러나, 이것은 조금 까다로워 지는 부분이다. 우리는 그 적분이 w_i에만 의존했기 때문에 그 irradiance map을 미리 계산할 수 있었고 그래서 우리는 constant diffuse albedo terms을 적분 밖으로 옮길 수 있었다. 이 번에, 그 적분은 BRDF로의 증거로서 w_i 이상의 것에 의존한다:



이 번에 적분은 또한 w_o에 의존한다. 그리고 우리는 두 개의 방향벡터를 가진 미리 연산된 cubemap을 정말 샘플링할 수 없다. 그 위치 p는 이전 튜토리얼에서 설명된것과 무관한다. 모든 가능한 w_i와 w_o의 조합에 대해 이 적분을 미리 계산하는 것은 실시간 설정에서는 실용적이지 않다.

Epic Games의 split sum approximation은 그 문제를 그 pre-computation을 두 개의 부분으로 쪼개서 해결하는데, 그 두개의 부분은 우리가 나중에 최종 pre-computed 결과를 얻기위해 결합할 수 있는 것이다. 그 split sum approximation은 그 specular integral을 두 개의 별개의 integrals로 분리한다:


(convoluted 되었을 때) 그 첫 번쨰 부분은 pre-filtered environment map으로서 알려진다. 그리고 이것은 (irradiance map과 유사하게) pre-computed environment convolution map이다. 그러나 이번에 roughness를 고려한다. 증가하는 roughness levels에 대해, 그 environment map은 좀 더 scattered sample vectors로 convoluted 된다. 그리고 이것은 좀 더 blurry reflections를 만든다. 우리가 convolute하는 각 roughness level에 대해, 우리는 pre-filtered map의 mipmap levels에 순차적으로 blurrier results를 저장한다. 예를들어, 5개의 mipmap levels에서 5개의 다른 roughness values의 pre-convoluted result를 저장하는 pre-filtered environment map은 다음의 것 처럼 보인다:

입력으로 normal과 view direction을 취하는 Cook-Torrance BRDF의 normal distribution function (NDF)를 사용하여, 우리는 샘플 벡터와 그것들의 scattering strength를 생성한다. 우리는 environment map을 convoluting할 때 사전에 view direction을 모르기 때문에, Epic Games는 view direction이 (그리고 따라서 그 specular reflection direction) 항상 output sample direction w_o와 같다고 가정하여 더 근사화를 한다. 이것은 그 자체로 다음의 코드로 바뀐다:


vec3 N = normalize(w_o);
vec3 R = N;
vec3 V = R;

이 방식으로, 그 pre-filtered environment convolution은 view direction을 인지할 필요가 없다. 이것은 아래의 이미지에서 보이는대로 한 각도로부터 specular surface reflections를 바라 볼 때 우리가 좋은 grazing한 specular reflections을 얻지 못한다는 것을 의미한다 (Moving Frostbite to PBR article의 이미지 제공); 이것은 그러나 일반적으로 멋진 타협으로 고려된다:

그 방정식의 두 번째 부분의 specular integral의 BRDF 부분과 동일하다. 만약 우리가 들어오는 radiance가 완전히 모든 방향에 대해 하얗다고 한다면 (따라서 L(p,x) = 1.0), 우리는 input roughness와 normal n과 light direction w_i사이의 각인 input angle 또는 n・w_i가 주어진다면, BRDF의 response를 미리 계산할 수 있다. Epic Games는 BRDF integration map이라고 알려진 2D lookup texture (LUT)에 다양한 roughness values에 대해 각 normal과 light direction 조합에 대한 미리 연산된 BRDF의 response를 저장한다. 그 2D look up texture는 한 스케일(red)을 output하고, bias value(green)을 표면의 Fresnel response로 output한다. 이것은 우리에게 slit specular integral의 두 번째 부분을 준다:

우리는 한 면의 수평 텍스쳐 좌표를 (0.0 ~ 1.0의 범위) BRDF의 input n・w_i로, 그리고 그것의 수직 텍스펴 좌표를 input roughness value로 다루어서 lookup texture를 생성한다. 이 BRDF integration map과 pre-filtered environment map으로, 우리는 specular integral의 겨로가를 얻기위해 둘 을 합칠 수 있다":


float lod = geMipLevelFromRoughness(roughness);
vec3 prefilteredColor = textureCubeLod(PrefilteredEnvMap, refVec, lod);
vec2 envBRDF = texture2D(BRDFIntegrationMap, vec2(NdotV, roughness)).xy;
vec3 indirectSpecular = prefilteredColor * (F * envBRDF.x + envBRDF.y);

이것은 너에게 Epic Games의 spli sum approximation이 대강 reflectance equation의 indirect specular part에 접근하는 방식에 대한 overview를 조금 줄 것이다. 이제 pre-convoluted parts를 우리 스스로 시도해보고 구성해보자.

Pre-filtering an HDR environment map
한 environment map을 미리 필터링하는 것은 우리가 irradiance map을 convoluted 한 방식와 유사하다. 차이점은 우리가 이제 roughness를 고려하고, 순차적으로 더 거친 reflections들을 pre-filterered map의 mip levels에 저장한다는 것이다.

처음에, 우리는 pre-filtered된 environment map data를 보유할 새로운 cubemap을 생성할 필요가 있다. 우리가 그것의 mip levels를 위해 충분한 메모리를 할당하다록 하기 위해서, 우리는 요구되는 메모리양을 할당하는 쉬운 방법으로서 glGenerateMipmap 이라는 것을 호출한다.

우리가 prefilterMap을 그것의 mipmaps으로 샘플링할 계획이기 때문에, 너는 그것의 minification filter를 GL_LINEAR_MIPMAP_LINEAR로 설정하도록 할 필요가 있을 것이다. 우리는 그것의 기본 mip level에서 128x128의 face당 해상도에서 pre-filtered specular reflections을 저장한다. 이것은 대부분의 반사에 대해서는 충불한 가능성이 높지만, 만약 너가 많은 smooth materials들을 가진다면 (차의 반사에 대해서 생각해보아라), 너는 그 해상도를 증가시키고 싶을지도 모른다.

이전 튜토링러에서, 우리는 반구 Ω에 대해 구면좌표계를 사용하여 균일하게 펼쳐진 sample vectors를 생성하여 environment map을 convoluted 했었다. 이것은 irradiance에 대해 잘 작동하지만, specular reflections에게는 덜 효율적이다. 표면의 roughness를 기반으로하는 speuclar reflections에 대해서, 빛은 normal n에 대해 reflection vector v의 주위에서 가깝거나 또는 대충 반사하지만, (만약 그 표면이 너무 거칠지 않다면) reflection vector에 대해서 한다:

가능한 나가는 빛의 반사의 일반적인 모양의 specular lobe로 알려져 있다. roughness가 증가함에 따라, 그 specular lobe의 크기는 증가한다; 그리고 그 specular lobe의 모양은 들어오는 빛의 방향마다 변한다. specular lobe는 따라서 매우 물질에 종속적이다.

microsurface model에 관해서, 우리는 몇 가지 들어오는 빛의 방향을 고려하면 speular lobe를 microfacet halfway vectors에 대한 reflection orientation으로 상상할 수 있다.  대부분의 광선이 macrofacet halfway vectros 주변에서 반사된 specular lobe에서 끝날 거라고 본다면, 비슷한 방식으로 샘플 벡터들을 생성하는 것이 말이 된다. 대부분의 것들은 그렇지 않으면 버려지기 때문이다. 이 과정은 importance sampling이라고 알려져 있다.

Monte Carlo integration and importance sampling
관련된 importance sampling을 완전히 이해하기 위해서, 우리는 처음에 Monte Carlo integration이라고 알려진 수학적 구성을 파헤쳐야 한다. Monte Carlo integration은 통계 확률 이론의 조합에서 대개 많이 나온다. Monte Carlo는 우리가 이산적으로 모든 population을 고려하지않고 어떤 통계수치 또는 한 population 값을 알아내는 문제를 해결할 때 도움을 준다.

예를들어, 너가 한 나라의 모든 시민들의 평균 높이를 세고 싶다고 말해보자. 너의 결과를 얻기위해, 너는 모든 시민들을 측정하고, 그것들의 높이를 평균화 한다. 그리고 이것은 너에게 너가 찾는 정확한 답을 줄 거싱다. 그러나 대부분의 나라는 상당한 인구를 갖기 때문에, 이것은 현실적인 접근법이 아니다: 그것은 너무 많은 노력과 시간이 들 것이다.

또 다른 접근법은 많은 더 작은 완전한 무작위의 (unbiased) 인구의 subset을 고르고, 그것들의 높이를 측정하고 그 결과를 평균화 하는 것이다. 이 population(모집단)은  100명의 사람만큼 작을 수 있다. 정확한 답만큼 정확할지 않을지라도, 너는 상대적으로 실측 자료와 가까운 답을 얻을 것이다. 이것은 law of large numbers(대수 법칙)라고 알려져있다. 그 아이디어는 만약 너가 총 모집단으로부터 완전히 random samples의 size N의 더 작은 집합을 측정한다면, 그 결과는 상대적으로 진짜 답에 가까워질 것이고, 샘플의 개수 N이 증가할 수록 더 가까워진다.

Monte Carlo integration은 이 대수의 법칙 위에 구성되었고, 적분을 풀어서 같은 접근법을 취한다. 모든 가능한 (이론적으로 무한의) 샘플 값 x에 대해 적분을 하기보다는 오히려, 간단히 총 모집단으로부터 무작위하게 골라진 N개의 샘플 값을 생성하고 평균화한다. N이 증가함에 따라, 우리는 적분의 정확한 답과 더 가까운 결과를 얻을 것이라고 보장받는다:



그 적분을 풀기위해서, 우리는 모집단 a에서 b까지에 대한 N개의 무작위 샘플들을 취하고, 그것들을 함께 더하고, 평균화 하기위해 샘플들의 총 개수로 나눈다. pdf는 probability density function을 말하는데, 이것은 우리에게 한 특정한 샘플이 전체 sample set에 대해 발생할 확률을 말해준다. 예를들어, 한 모집단의 높이의 pdf는 이것처럼 보인다:

이 그래프에서, 우리는 만약 우리가 그 모집단의 어떤 random sample을 취한다면, 높이 1.70의 어떤 사람의 샘플을 고를 확률이 더 높다는 것이다. 1.5의 높이인 샘플의 더 낮은 확률에 비해서.

Monte Carlo integration에 대해서, 몇 가지 샘플들은 다른 것들보다 생성될 더 높은 확률을 가질지도 모른다. 이것은 어떤 일반적인 Monter Carlo estimation에 대해, 우리가 pdf를 따라 sample probability로 샘플된 값을 나누거나 곱하는 이유이다. 지금까지, 한 적분을 측정하는 우리의 경우들 각각에서, 우리가 생성한 샘플들은 균일했었고, 생성되는 것이 정확히 같은 확률을 가졌었다. 지금까지 우리의 측정들은 unbiased였다. 그리고 이것은 점점 더 증가하는 샘플들의 양이 주어진다면, 우리는 결국에는 적분의 정확한 solution을 수렴한다는 것을 의미한다.

그러나, 몇 가지 Monte Carlo estimators는 biased이다. 이것은 생성된 samples들이 완전히 랜덤이 아니고, 특정한 값 또는 방향쪽으로 집중했다는 것을 의미한다. 이러한 biased Monte Carlo estimators는 더 높은 convergence rate(수렴률)을 가진다. 이것은 그것들이 더 빠른 속도로 정확한 해에 수렴할 수 있는 것을 의미한다. 그러나 그것들의 biased 특징 때문에, 그것들은 정확한 답에 수렴하지 못할 가능성이 높다. 이것은 일반적으로 받아들일만한 tradeoff이다, 특히 컴퓨터그래픽스에서, 정학한 솔루션이 그 결과가 시각적으로 허용할만하다면 중요하지 않기 때문이다. 우리가 곧 importance sampling으로 볼 것이지만 (biased estimator를 사용하는) 그 생성된 샘플들은 특정한 방향으로 biased 되어있다. 그 경우에 우리는 각 샘플을 그것의 해당되는 pdf로 곱하거나 나누어서 이것에 대해 보상한다.

Monte Carlo integration은 컴퓨터 그래픽스에서 꽤 널리 퍼져있다. 왜냐하면 discrete and efficient 방식으로 continuous integrals를 근사시키는 꽤 직관적인 방식이기 때문이다: (hemisphere Ω과 같은) 어떤 것에 대해 샘플링할 어떤 면적/부피를 취하고, 그 면적/부피 내에서 N개의 random samples를 생성하고, 합하고, 최종 결과에 대한 모든 샘플들의 기여를 가중치 준다.

Monte Carlo integration는 광범위한 수학적 도구이고, 나는 그 세부사항에 대해서 더 들어가지 않을 것이지만, 우리는 random samples들을 생성하는 많은 방법들이 있다는 것을 언급할 것이다. 기본적으로, 각 샘플은 우리가 그랬듯이 완전히 (psuedo, 가짜) 랜덤이다. 그러나 semi-random sequences의 어떤 특징을 활용하여, 우리는 여전히 랜덤하지만 흥미로운 특징을 가진 샘플 벡터들을 생성할 수 있다. 예를들어, 우리는 여전히 random samples를 생성하지만 각 샘플들이 좀 더 균일하게 분산되는 low-discrepancy sequences라고 불리는 어떤 것에 대해 Monte Carlo integration을 할 수 있다:


Monte Carlo sample vectors를 생성하기 위해 low-discrepancy sequence를 사용할 때, 그 과정은 Quasi-Monte Carlo integration이라고 알려져 있다. Quasi-Monte Carlo 방식들은 성능이 무거운 프로그램들에게 흥미롭게 만드는 더욱 빠른 수렴률을 가진다.

우리의 새롭게 얻어진 Monte Carlo and Quasi-Monte Carlo integration의 지식으로, importance sampling이라고 알려진 훨씬 빠른 수렴률을 위해 사용할 수 있는 흥미로운 특징이 있다. 이 튜토리얼에서 전에 그것을 언급했지만, 빛의 specular reflections에 대해, 그 반사된 빛 벡터는 specular lobe에서 표면의 roughness에 의해 결정된 그것의 크기로 제약을 받는다. 그 specular lobe 밖의 어떤 (quasi-)무작위로 생성된 샘플들이 specular integral과 관련이 없다는 것을 고려하면, specular lobe내에서의 샘플 생성에 주목하는 것이 말이된다. Monte Carlo estimator를 biased 되게 한 것의 대가를 치르고.

이것이 본질적으로 importance sampling에 대한 것이다: microfacet의 halfway vector를 향하는 roughness로 제한된 어떤 구역에서 샘플벡터를 생성해라. Quasi-Monte Carlo smapling을 low-discrepancy sequence와 합치고, importance sampling을 사용하여 sample vector는 biasing 하여, 우리는 더 높은 수렴율을 얻게 된다. 그 솔루션에 더욱 빠른 비율로 도달하기 때문에, 우리는 충분한 근사에 도달하기위해 더 적은 샘플들이 필요할 것이다. 이것 때문에, 그 조합은 심지어 그래픽스 프로그램이 그 specular 적분을 실시간으로 하게 해준다. 비록 그것은 여전히 사당히 결과를 미리 연산하는 것보다 느릴지라도.

A low-discrepancy sequence
이 튜토리얼에서, 우리는 Quasi-Monte Carlo method를 기반으로하는 random low-discrepancy sequence를 고려하여 importance sampling을 사용하여  indirect reflectance equation의 specular portion을 미리 계산할 것이다. 우리가 사용할 sequence는 Holger Dammertz에의해 세세히 설명된 Hammersley Sequence로 알려져있다. 그 Hammersley sequence는 Van Der Corpus sequence를 기반으로 하는데, 그것은 그것의 소수점 주위의 decimal binary representation을 mirrors한다.

몇 가지 멋진 트릭을 고려하여, 우리는 꽤 효율적으로 쉐이더 프로그램에서 Van Der Corpus sequence를 생성할 수 있다. 그리고 우리는 N개의 total samples에 대해 Hammersley sequence sample i를 얻기위해 그것을 사용할 것이다:


float RadicalInverse_VdC(uint bits)
{
 bits = (bits << 16u) | (bits >> 16u);
 bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
    bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
    bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
    bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
    return float(bits) * 2.3283064365386963e-10; // / 0x100000000
}

vec2 Hammersley(uint i, uint N)
{
 return vec2(float(i)/float(N), RadicalInverse_VdC(i));
}

GLSL Hammersley 함수는 우리에게 사이즈 N의 총 샘플 세트의 low-discrepancy sample i를 준다.

  Green Box
  Hammersley sequence without bit operator support
  모든 OpenGL 관련 드라이버들이 비트 연산자를 지원하는 것은 아니다(예를들어, WebGL 그리고 OpenGL ES 2.0). 그러한 경우에 너는 비트 연산자에 의존하지 않는 Van Der Corpus Sequence의 대안 버전을 사용하고 싶을지도 모른다:

float VanDerCorpus(uint n, uint base)
{
    float invBase = 1.0 / float(base);
    float denom   = 1.0;
    float result  = 0.0;

    for(uint i = 0u; i < 32u; ++i)
    {
        if(n > 0u)
        {
            denom   = mod(float(n), 2.0);
            result += denom * invBase;
            invBase = invBase / 2.0;
            n       = uint(float(n) / 2.0);
        }
    }

    return result;
}

// ----------------------------------------------------------------------------
vec2 HammersleyNoBitOps(uint i, uint N)
{
    return vec2(float(i)/float(N), VanDerCorpus(i, 2u));
}
GLSL loop 제한 때문에, 더 오래된 하드웨어에서, sequence loops는 모든 가능한 32 비트들에 대해서 루프를 돈다느 것에 유의해라. 이 버전은 덜 성능 기준에 맞지만, 만약 너가 비트 연산자없이 찾는다면 모든 하드웨어에 동작하느 ㄴ것이다.

GGX Importance sampling
적분의 반구 Ω에 대해 균일하게 또는 랜덤하게 샘플 벡터를 생성하는 것 대신에, 우리는 surface의 roughness를 기반으로 microsurface halfway vector의 일반 reflection orientation 쪽으로 biased된 샘플 벡터를 생성할 것이다. 그 샘플링 고자ㅓㅇ은 우리가 이전에 본 것과 유사할 것이다: large loop를 시작하고, random (low-discrepancy) sequence value를 생성하고, tangent space에서 샘플 벡터를 생성하기 위해 sequence value를 취하고, world space로 변환하고, 그 scene의 radiance를 샘플링 한다. 다른 것은 우리가 이제 샘플 벡터를 생성하기위해 입력으로서 low-discrepancy sequence 값을 사용한다는 것이다:


const uint SAMPLE_COUNT = 4096u;
for(uint i = 0u; i < SAMPLE_COUNT; ++i)
{
     vec2 Xi = Hammersley(i, SAMPLE_COUNT);

부가적으로, 샘플 벡터를 만들기 위해서, 우리는 어떤 표면 roughness의 specular lobe쪽으로 샘플벡터가 향하도록 하고 bias를 주는 방식이 필요하다. 우리는 Theory 튜토리얼에서 설명된 NDF를 취하고, Epic Games에서 설명된 spherical sample vector process에서 GGX NDF를 결합한다:


vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness)
{
 float a = roughness * roughness;
 float phi = 2.0 * PI * Xi.x;
 float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a*a - 1.0) * Xi.y));
 float sinTheta = sqrt(1.0 -cosTheta * cosTehta);

 // from spherical coordinates to cartesian coordinates
 vec3 H;
 H.x = cos(phi) * sinTheta;
 H.y = sin(phi) * sinTheta;
 H.z = cosTheta;

 // from tangent-space vector to world-space sample vector
 vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
 vec3 tangent = normalize(cross(up, N);
 vec3 bitangent = cross(N, tangent);

 vec3 sampleVec = tangent * H.x + bitangent * H.y + N * H.z;
 return normalize(sampleVec);
}

이것은 우리에게 어떤 입력 roughness와 low-discrepancy sequence value Xi를 기반으로 예상되는 microsurface의 halfway vector를 어느정도 향하는 sample vector를 준다. Epic Games는 Disney의 원래 PBR 연구를 기반으로 더 좋은 시각적 결과를 위해 제곱이 된 roughness를 사용한다는 것에 유의해라.

low-discrepancy Hammersley sequence와 sample generation이 정의되고, 우리는 그 pre-filter convolution shader를 마무리 할 수 있다:


void main()
{
 vec3 N = normalize(localPos);
 vec3 R = N;
 vec3 V = R;

 const uint SAMPLE_COUNT = 4096u;
 float totalWeight = 0.0;
 vec3 prefilteredColor = vec3(0.0);
 for(uint i = 0u; i < SAMPLE_COUNT; ++i)
 {
  vec2 Xi = Hammersley(i, SAMPLE_COUNT);
  vec3 H = ImportanceSampleGGX(Xi, N, roughness);
  vec3 L = normalize(2.0 * dot(V, H) * H - V);

  float NdotL = max(dot(N, L), 0.0);
  if(NdotL > 0.0)
  {
   prefilteredColor += texture(environmentMap, L).rgb * NdotL;
   totalWeight += NdotL;
  }
 }

 prefilteredColor = prefilteredColor / totalWeight;

 FragColor = vec4(prefilteredColor, 1.0);
}

우리는 (0.0 ~ 1.0) pre-filter cubemap의 각 mipmap level에 대해 다양한 몇 가지 input roughness를 기반으로 그 환경을 pre-filter한다. 그리고 그 결과를 prefilteredColor에 저장한다. 그 최종 prefilteredColor는 총 샘플 weight로 나눠진다. 거기에서 (small NdotL) 최종 결과에 덜 영향이 있는 샘플들은 최종 weight에 덜 공헌한다.

Capturing pre-filter mipmap levels
해야 할 남은 것은 OpenGL이 다양한 mipmap levels에 대해 다른 roughness values를 가진 환경 map을 pre-filter하는 것이다. 이것은 irradiance tutorial의 원래 설정과 하는 것이 실제로 꽤 쉽다:


unsigned int prefilterMap;
glGenTextures(1, &prefilterMap);
glBindTexture(GL_TEXTURE_CUBE_MAP, prefilterMap);
for (unsigned int i = 0; i < 6; ++i)
 glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 128, 128, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glGenerateMipmap(GL_TEXTURE_CUBE_MAP);

Shader prefilterShader("ShaderFolder/Equirect2Cube.vs", "ShaderFolder/preFilterConvolution.fs");
prefilterShader.loadShader();
prefilterShader.use();
prefilterShader.setInt("environmentMap", 0);
prefilterShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);

glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
unsigned int maxMipLevels = 5;
for (unsigned int mip = 0; mip < maxMipLevels; ++mip)
{
 // resize framebuffer according to mip-level size.
 unsigned int mipWidth = 128 * std::pow(0.5, mip);
 unsigned int mipHeight = 128 * std::pow(0.5, mip);
 glBindFramebuffer(GL_RENDERBUFFER, captureFBO);
 glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, mipWidth, mipHeight);
 glViewport(0, 0, mipWidth, mipHeight);

 float roughness = (float)mip / (float)(maxMipLevels - 1);
 prefilterShader.setFloat("roughness", roughness);
 for (unsigned int i = 0; i < 6; ++i)
 {
  prefilterShader.setMat4("view", captureViews[i]);
  glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
   prefilterMap, mip);
  glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
  renderCube();
 }
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

그 프로세스는 irradiance map convolution과 유사하지만, 이번에 우리는 framebuffer의 크기를 적절한 mipmap scale로 스케일링한다. 각 mip level은 그 크기들을 2씩 줄인다. 부가적으로, 우리는 glFramebufferTexture2D의 마지막 파라미터에 렌더링 할 그 mip level을  명시하고, pre-filter shader를 위해 우리가 미리 필터링할 roughness를 넘긴다.

이것은 우리에게 적절히 우리가 더 높은 mip level로부터 접근함에 따라 더 흐려진 반사를 반환하는 pre-filtered environment map을 준다.  만약 우리가 skybox shader에서 pre-filtered environment cubemap을 보이고, 강하게 그것의 쉐이더에서 첫번쨰 ㅡㅑㅔ ㅣㄷㅍ디dmf toavmffldgksekaus, :

  vec3 envColor = textureLod(environmentMap, WorldPos, 1.2).rgb;

우리는 원래의 환경의 더 흐려진 버전같이 보이는 한 결과를 얻는다:

만약 그것이 어느정도 비슷하게 보인다면, 너는 HDR environment map을 성공적으로 pre-filter한 것이다. 증가되는 mip levels에서 날카로운 것부터 흐린 반사까지의 pre-filter map이 점차적으로 변하는 것을 보기 위해 다른 mipmap levels을 가지고 놀아보아라.

Pre-filter convolution artifacts
현재 pre-filter map이 대부분의 목적에 작동하지만, 조만간 너는 pre-filter convolution과 직접 연관되어있는 몇 가지 render artifacts를 만나게 될 것이다. 나는 그것들을 어떻게 고칠지를 포함하여 여기에서 가장 흔한 것을 열거할 것이다.

Cubemap seams at high roughness
거친 표면을 가진 표면에 대해 pre-filter map을 샘플링하는 것은 그것의 더 낮은 mip levels에서 pre-filter map을 샘플링하는 것을 의미한다. 큐브맵을 샘플링할 때, OpenGL은 기본적으로 선형으로 cubemap faces들을 보간하지 않는다. 더 낮은 mip levels은 둘 다 더 낮은 해상도이고, pre-filter map은 더 큰 샘플 lobe로 convoluted 되었기 때문에, between-cube-face filtering의 부족이 꽤 명백해진다:

우링게 운 좋게도, OpenGL은 우리에게 GL_TEXTURE_CUBE_MAP_SEAMLESS를 활성화하여 cubemap faces들에 대해 적절히 필터하게 해주는 옵션을 준다:

  glEnable(GL_TEXTURE_CUBE_MAP_SEAMLESS);

간단히 이 특징을 너의 프로그램 시작하는 어딘가에서 활성화해라. 그러면 그 seams들은 사라질 것이다.

Bright dots in the pre-filter convolution
high frequency details과 specular reflections에서 몹시 다양한 빛의 강도 때문에, specular reflections를 convoluting하는 것은 HDR environmental reflections의 몹시 다양한 특징을 적절히 보상할 많은 개수의 샘플들을 요구한다. 우리는 이미 많은 수의 샘플들을 취했지만, 어떤 환경에서, 더 거친 mip levels에서 그것은 여전히 충분하지 않을지도 모른다. 그런 경우에 너는 밝은 구역 주변에서 점으로 된 패턴들이 있는 것을 보기싲가할 것이다.

한 옵션은 샘플 개수를 증가시키는 것이지만, 이것은 모든 환경에 충분하지 않을 것이다. Chetan Jags에 의해 설명되었듯이, 우리는 (pre-filter convolution 동안) 직접적으로 그 environment map을 샘플링하지 않고, 적분의 PDF와 roughness를 기반으로 한 mip level의 environment map을 샘플링하여 이 artifact를 줄일 수 있다:


float D = DistributionGGX(NdotH, roughness);
float pdf = (D * NdotH / (4.0 * HdotV)) + 0.0001;

float resolution = 512.0; // resolution of source cubemap (per face)
float saTexel = 4.0 * PI / (6.0 * resolution * resolution);
float saSample = 1.0 / (float(SAMPLE_COUNT) * pdf + 0.0001);

float mipLevel = roughness == 0.0? 0.0 : 0.5 * log2(saSample / saTexel);

mip levels를 샘플링하기 위해서 너가 원하는 environment map에 linear filtering을 활성화하는 것을 잊지말아라:

glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
glGenerateMipmap(GL_TEXTURE_CUBE_MAP);

그리고 OpenGL이 cubemap의 base texture가 설정된 후 mipmap을 생성해라:

glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
        glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);

이것은 놀랍게도 잘 작동하고, 더 거친 표면에서 너의 pre-filter map에 있는 대부분의 점들을 제거할 것이다. 모든 것은 아니지만.

Pre-computing the BRDF
pre-filtered environment가 설정되고 작동되면서, 우리는 split-sum approximation의 두 번째 부분에 집중할 수 있다: 그 BRDF. 간단하게 specular split sum approximation을 다시 봐보자:



우리는 다른 roughness levels에 대해 pre-filter map에서 spli sum approximation의 왼쪽 부분을 미리 연산했다. 그 오른쪽은 각도 n・w_o, surface roughness and Fresnel의 F0에 대해 BRDF equation을 convolute하는 것을 요구한다. 이것은 solid-white environment 또는 1.0의 constant radiance L_i를 가진 BRDF 방정식을 convolute하는 것을 요구한다. 3개의 변수에 대해 BRDF를 convoluting하는 것은 너무 많지만, 우리는 specular BRDF 방정식에서 F_0를 빼낼 수 있다:



F가 Fresnel 방정식이다. Fresnel 분모를 BRDF로 옮기는 것은 우리에게 다음과 같은 식을 준다:



가장 오른쪽의 F를 Fresnel-Schlick 근사로 대체하는 것은 우리에게 다음을 준다:



(1 - w_o・h)^5를 F_0에 대해 쉽게 풀기위해 a로 대체하자:



그러고나서 우리는 Fresnel function F를 두 적분에 대해 분리한다:



두 최종 적분은 각각 F_0에 대해 scale과 bias를 나타낸다. f(p, w_i, w_o)는 이미 F에 대한 항을 포함하고 있기 때문에, 그것들 둘 다 cancle out될 수 있다. 이것은 f로부터 F를 제거한다.

이전의 convoluted environment maps와 유사한 방식으로 우리는 그것들의 입력으로 BRDF 방정식들을 convolute할 수 있다: n과 w_o 사이의 각도, 그리고 roughness, 그리고 한 텍스쳐에 convoluted result를 저장할 수 있다. 우리는 convoluted results를 우리가 나중에 최종 convoluted indirect specular result를 얻기위해 우리의 PBR lighting shader에서 사용할 BRDF integration이라고 알려진 2D look up texture (LUT)에 저장한다.

그 BRDF convolution shader는 2D plane에 대해 작동한다. BRDF convolution 에 대한 입력으로서(NdotV and roughness) 직접적으로 그것의 2D 텍스쳐 좌표를 사용하여. 그 convolutioon code는 크게 pre-filter convolution과 유사하다. 그것이 이제 BRDF의 geometry function과 Fresnel-Schlick의 근사를 따라 sample vector를 처리하는 것을 제외하고:


void main()
{
 vec2 integrateBRDF = IntegrateBRDF(TexCoords.x, TexCoords.y);
 FragColor = integrateBRDF;
}

vec2 IntegrateBRDF(float NdotV, float roughness)
{
 vec3 V;
 V.x = sqrt(1.0 - NdotV * NdotV);
 V.y = 0.0;
 V.z = NdotY;

 float A = 0.0;
 float B = 0.0;

 vec3 N = vec3(0.0, 0.0, 1.0);
 
 const uint SAMPLE_COUNT = 1024u
 for(uint i = 0u; i < SAMPLE_COUNT; ++i)
 {
  vec2 Xi = Hammersley( i, SAMPLE_COUNT);
  ve3c H = ImportanceSampleGGX(Xi, N, roughness);
  vec3 L = normalize(2.0 * dot(V, H) * H - V);
  
  float NdotL = max(L.z, 0.0);
  float NdotH = max(H.z, 0.0);
  float VdotH = mat(dot(V, H), 0.0);

  if(NdotL > 0.0)
  {
   float G = GeometrySmith(N, V, L, roughness);
   float G_Vis = (G * VdotH) / (NdotH * NdotY);
   float Fc = pow(1.0 - VdotH, 5.0);
  
   A += (1.0 - Fc) * G_Vis;
   B += Fc * G_Vis;
  }
 }

 A /= float(SAMPLE_COUNT);
 B /= float(SAMPLE_COUNT);
 
 return vec2(A, B);
}

너도 볼 수 있듯이, BRDF convolution은 수학에서 코드로의 직접 바꾼 것이다. 우리는 각 theta와 roughness를 입력으로 받아서, importance sampling으로 샘플 벡터를 생성하고, 그것을 그 geometry와 BRDF의 도출된 Fresnel 항에 대해 처리하고, 각 샘플에 대해 F_0에 대한 scale과 bias를 만들어낸다. 그리고 끝에서 그것들을 평균화 한다.

너는 theory tutorial로부터 BRDF의 geometry term이 IBL에서 사용될 때 다소 다르다고 기억할지도 모른다. 그것의 k 변수가 다소 다른 interpretation을 갖기 떄문이다:




BRDF convolution은 specular IBL integral의 부분이기 때문에, 우리는 Schlick-GGX geometry funciton에 대해 k_IBL을 사용할 것이다:


float GeometrySchlickGGX(float NdotV, float roughness)
{
 float a = roughness;
 float k = (a * a) / 2.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 ggx1 = GeometrySchlickGGX(NdotV, roughness);
 float ggx2 = GeometrySchlickGGX(NdotL, roughness);
 return ggx1 * ggx2;
}

k가 a를 그것의 파라미터로 받고 있어도, 우리는 roughness를 a로서 제곱하지 않는다. 우리는 원래 a의 다른 interpretations 때문에 한 것이기 때문이다. a는 여기서 이미 제곱된다. 나는 이것이 Epic Games의 부분 또는 원래 Disney paper의 불일관성인지 확신할 수 없지만, roughness를 a로 바꾸는 것은 Epic Games' 버전과 동일한 BRDF integration map을 준다.

마지막으로, BRDF convolution 결과를 저장하기 위해, 우리는 512x512 해상도의 2D texture를 만들 것이다.


unsigned int brdfLUTTexture;
glGenTextures(1, &brdfLUTTexture);

// pre-allocate enough memory for the LUT texture
glBindTexture(GL_TEXTURE_2D, brdfLUTTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RG16F, 512, 512, 0, GL_RG, GL_FLOAT, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

우리가 Epic Games에 추천한대로 16bit precision floating format을 사용하는 것에 유의해라. edge sampling artifacts를 방지하기 위해 그 wrapping mode가 GL_CLAMP_TO_EDGE가 되도록 설정해라.

그러고나서, 우리는 같은 framebuffer object를 재 사용하고, 이 쉐이더를 NDC screen-space quad에 대해 작동시킨다:


glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, brdfLUTTexture, 0);

glViewport(0, 0, 512, 512);
brdfShader.use();
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
renderQuad();
glBindFramebuffer(GL_FRAMEBUFFER, 0);

split sum integral의 그 convoluted BRDF 부분은 다음의 결과를 준다:

pre-filtered environment map과 BRDF 2D LUT으로, 우리는 indirect specular integral을 split sum approximation에 따라 재 구성할 수 있다. 그 결합된 결과는 그러고나서 indirect or ambient specular light로 작동한다.

Completing the IBL reflectance
reflectance equation의 indirect specular part를 가져와서 작동시키기 위해, 우리는 split sum approximation의 두 부분을 함꼐 바느질 할 필요가 있다. pre-computed lighting data를 PBR shader의 위에 추가해서 시작하자:

    uniform samplerCube prefilterMap;
    uniform sampler2D brdfLUT;

처음에, 우리는 reflection vector를 사용하여 pre-filtered environment map을 샘플링하여 surface의 indirect specular reflections를 얻는다. 우리가 surface roughness를 기반으로 적절한 mip level을 샘플링한다는 것에 주의해라. 이것은 더 거친 표면들에는 더 흐린(blurrier) specular reflections를 준다.


vec3 R = reflect(-V, N);
const float MAX_REFLECTION_LOD = 4.0;
vec3 prefilteredColor = textureLod(prefilterMap, R, roughness * MAX_REFLECTION_LOD).rgb;

pre-filter step에서 우리는 오직 최대 5의 mip levels (0부터 4까지)의 environment map을 convoluted 했다. 그리고 우리는 그 mip level을 MAX_REFLECTION_LOD로 표기한다. 이것은 우리가 a mip level where there's no (relevant) data인 것을 샘플링하지 않도록 하기 위해서이다.

그러고나서, 우리는 material의 roughness와 normal과 view vector사이의 각으로 BRDF lookup texture로부터 샘플링한다:


vec3 F = FresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
vec2 envBRDF = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
vec3 specular = prefilteredColor * (F * envBRDF.x + envBRDF.y);

BRDF look up texture로 부터 F_0에 대해 scale과 bias가 주어지고나서 (여기에서 우리는 indriect Fresnel result F를 직접 사용하고 있다) 우리는 이것을 IBL reflectance equation의  왼쪽 pre-filter portion과 결합하고, approximated integral result를 specular로 재구성한다.

이것은 우리에게 reflectance equation의 indirect specular part를 준다. 이제, 이것을 지난 튜토리얼의  reflectance equation의 diffuse part와 합쳐라. 그러면 우리는 완전한 PBR IBL result를 얻는다:


// ambient lighting (we now use IBL as the ambient term)
vec3 F = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
 
vec3 kS = F;
vec3 kD = vec3(1.0) - kS;
kD *= 1.0 - metallic;

vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse = irradiance * albedo;
 
vec3 R = reflect(-V, N);
const float MAX_REFLECTION_LOD = 4.0;
vec3 prefilteredColor = textureLod(prefilterMap, R, roughness * MAX_REFLECTION_LOD).rgb;
vec2 envBRDF = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
vec3 specular = prefilteredColor * (F * envBRDF.x + envBRDF.y);

vec3 ambient = (kD * diffuse + specular) * ao;
vec3 color = ambient + Lo;

우리가 specular에 ks를 곱하지 않는 것에 주의해라. 우리는 이미 거기에 Fresnel multiplication을 했기 때문이다.

이제, 이 정확한 코드를 그것들의 roughness와 metallic properties가 다른 일련의 구에 작동시켜, 우리는 마침내 최종 PBR renderer의 진정한 컬러를 보게 될 것이다:


우리는 심지어 더 들어갈 수 있고, 몇 가지 멋진 텍스쳐입힌 PBR materials들을 사용할 수 있다:

또는 Andrew Maximov의 이 놀라운 무료 PBR 3D model을 불러올 수 있다:

우리는 모두 우리의 lighting이 이제 조 ㅁ더 설득력있게 보이는 것에 동의한다고 확신한다. 심지어 더 좋은 것은, 우리의 lighting은 물리적으로 옳게 보인다. 우리가 어떤 환경 맵을 사용하든 상관없이. 아래에서 너는 몇 가지 다른 pre-computed HDR maps를 볼 것이고, 완전히 그 lighting dynamics가 바껴있다. 그러나 여전히 단일의 lighting variable을 바꾸는 것 없이 물리적으로 옳게 보인다!

음, 이 PBR 모험은 꽤 긴 여정이였었다. 많은 단계들이 있었고, 또한 잘못될 수 있는 많은 것이 있었다. 그래서 sphere scene 또는 textured scene code samples들을 (모든 쉐이더들을 포함하는) 통해 신중하게 작업해라. 만약 너가 막힌다면, 체크하고 댓글에서 물어보라ㅏ.

What's next?
희망스럽게도, 이 튜토리얼의 끝에, 너는 PBR이 무엇인지에 대해 꽤 명확한 이해를 가질 것이고, 심지어 실제 PBR renderer를 가졌고 작동시킬 수 있다. 이러한 튜토리얼들에서, 우리는 우리의 프로그램의 처음에, render loop 전에 모든 관련된 PBR image-based lighting data를 미리 연산했다. 이것은 교육 목적으로는 괜찮지만, 어떤 PBR의 실제적인 사용에는 너무 훌륭하지 않다. 처음에, pre-computation은 오직 한 번 처리되어야 하고, 모든 startup에서 는 될 필요 없다. 그리고 두 번째로, 너가 다양한 environment maps를 사용하는 순간, 너는 각각을 미리 연산해야하고 그것들 중 하나를 매번 시작할 때 해야할 것이다.

이 이유 때문에, 너는 일반적으로 한 environment map을 irradiance와 pre-filter map으로 한 번 미리 연산하고, 그것을 disk에 저장한다 (BRDF integration map은 environment map에 의존하지 않는다는 것을 주목해라 그래서 너는 오직 그것을 한 번만 계산하거나 불러올 필요가 있다). 이것은 너가 HDR cubemaps를 저장할 custom image format을 생각해낼 필요가 있다는 것을 의미한다. 그것들의 mip levels를 포함해서, 또는 너는 그것을 이용가능한 포맷들 중 하나로서 저장할 것(또는 불러올)이다. (mip levels를 저장하는 것을 지원하는 .dds 같은).

게다가, 우리는 PBR pipeline의 이해를 더욱 돕기 위해 pre-computed IBL images를 생성하는 것까지 포함하여 이러한 튜토리얼에서 전체 프로세스를 설명했다. 그러나, 너는 너를 위해 이러한 미리 연산된 maps을 생성하기 위해 cmftStudio 또는 IBLBaker같은 몇 가지 훌륭한 도구들을 사용해도 괜찮을 것이다.

우리가 건너띈 한 가지 요점은 reflection probes로서의 pre-computed cubemaps이다: cubemap interpolation and parallax correction. 이것은 몇 가지 reflection probes를 너의 scene에 두는 프로세스인데, 그 특정한 위치에서 scene의 cubemap snapshot을 찍는다. 그리고 우리는 그것을 그 scene의 일부를 위해 IBL data로서 convolute할 수 있다. 카메라의 근처를 기반으로 이러한 몇가지 probes 사이를 보간함으로써, 우리는 우리가 기꺼이 둘려는 reflection probes의 양에의해 간단히 제한되는 local high-detail image-based lighting을 얻을 수 있다. 이 방식으로, IBL은 예를들어 한 scene의 밝은 야외 부분에서 더 어두운 실내로 움직일 때 정확히 업데이트 될 수 있다. 나는 미래 어딘가에 reflection probes에 대한 튜토리얼을 쓸것이지만, 지금은 나는 너에게 처음 시작을 주기위해서 아래의 Chetan Jags의 자료를 추천한다.

Further reading













댓글 없음:

댓글 쓰기