Rastreamento de caminho da unidade GPU - parte 2

imagem

"Não há nada pior do que uma imagem clara de um conceito embaçado." - fotógrafo Ansel Adams

Na primeira parte do artigo, criamos um traçador de raios brancos, capaz de traçar reflexos perfeitos e sombras nítidas. Mas não temos os efeitos da imprecisão: reflexo difuso, reflexos brilhantes e sombras suaves.

Com base no código que já temos , resolveremos iterativamente a equação de renderização formulada por James Cajia em 1986 e transformaremos nosso renderizador em um rastreador de caminho capaz de transmitir os efeitos acima. Voltaremos a usar o C # para scripts e o HLSL para shaders. O código é carregado no Bitbucket .

Este artigo é muito mais matemático que o anterior, mas não se assuste. Vou tentar explicar cada fórmula o mais claramente possível. As fórmulas são necessárias aqui para ver o que está acontecendo e por que nosso renderizador funciona, por isso recomendo tentar entendê-las e, se algo não estiver claro, faça perguntas nos comentários do artigo original.

A imagem abaixo é renderizada usando o mapa Graffiti Shelter no site da HDRI Haven. Outras imagens deste artigo foram renderizadas usando o cartão Kiara 9 Dusk .

imagem

Equação de renderização


Do ponto de vista formal, a tarefa do renderizador fotorrealista é resolver a equação de renderização, que é escrita da seguinte maneira:

L(x, vec omegao)=Le(x, vec omegao)+ int Omegafr(x, vec omegai, vec omegao)( vec omegai cdot vecn)L(x, vec omegai)d vec omegai


Vamos analisar isso. Nosso objetivo final é determinar o brilho do pixel da tela. A equação de renderização nos dá a quantidade de iluminação L(x, vec omegao) vindo de um ponto x (ponto de incidência do feixe) na direção  vec omegao (a direção na qual o feixe cai). A superfície em si pode ser uma fonte de luz que emite luz Le(x, vec omegao) em nossa direção. A maioria das superfícies não, então elas refletem apenas a luz do lado de fora. É por isso que a integral é usada. Ele acumula iluminação vinda de todas as direções possíveis do hemisfério.  Omega em torno do normal (portanto, enquanto levamos em conta a iluminação que cai na superfície por cima e não por dentro , o que pode ser necessário para materiais translúcidos).

A primeira parte é fr é chamada de função de distribuição de refletância bidirecional (BRDF). Essa função descreve visualmente o tipo de material com o qual estamos lidando: metal ou dielétrico, escuro ou brilhante, brilhante ou fosco. O BRDF determina a proporção de iluminação proveniente de  vec omegai que se reflete na direção  vec omegao . Na prática, isso é implementado usando um vetor de três componentes com valores de vermelho, verde e azul no intervalo [0,1] .

Segunda parte - ( vec omegai cdot vecn) É o equivalente a 1 cos theta onde  theta - ângulo entre luz incidente e superfície normal  vecn . Imagine uma coluna de raios paralelos de luz caindo na superfície perpendicularmente. Agora imagine o mesmo raio caindo na superfície em um ângulo plano. A luz será distribuída por uma área maior, mas também significa que todos os pontos dessa área parecerão mais escuros. O cosseno é necessário para levar isso em consideração.

Finalmente, a própria iluminação obtida de  vec omegai é determinado recursivamente usando a mesma equação. Ou seja, a iluminação no ponto x Depende da luz incidente de todas as direções possíveis no hemisfério superior. Em cada uma dessas direções a partir de um ponto x existe outro ponto x prime , cujo brilho depende novamente da luz que cai de todas as direções possíveis do hemisfério superior deste ponto. Todos os cálculos são repetidos.

Eis o que acontece aqui: esta é uma equação integral infinitamente recursiva com um número infinito de regiões hemisféricas de integração. Não podemos resolver essa equação diretamente, mas existe uma solução bastante simples.



1 Não se esqueça disso! Falaremos frequentemente sobre cosseno e sempre teremos em mente o produto escalar. Desde  veca cdot vecb= | veca |  | vecb | cos( theta) , e como estamos lidando com direções (vetores unitários), o produto escalar é o cosseno na maioria das tarefas de computação gráfica.

Monte Carlo vem em socorro


A Integração Monte Carlo é uma técnica de integração numérica que permite calcular aproximadamente qualquer integral usando um número finito de amostras aleatórias. Além disso, Monte Carlo garante convergência para a decisão certa - quanto mais amostras coletarmos, melhor. Aqui está sua forma generalizada:

FN approx frac1N sumNn=0 fracf(xn)p(xn)


Portanto, a integral da função f(xn) pode ser calculado aproximadamente calculando a média de amostras aleatórias no domínio de integração. Cada amostra é dividida pela probabilidade de sua seleção. p(xn) . Por esse motivo, a amostra escolhida com mais frequência terá mais peso do que a amostra escolhida com menos frequência.

No caso de amostras uniformes no hemisfério (cada direção tem a mesma probabilidade de ser selecionada), a probabilidade de amostras é constante: p( omega)= frac12 pi (porque 2 pi É a área da superfície de um único hemisfério). Se juntarmos tudo isso, obteremos o seguinte:

L(x, vec omegao) aproximadamenteLe(x, vec omegao)+ frac1N sumNn=0 colorGreen2 pifr(x, vec omegai, vec omegao)( vec omegai cdot vecn)L(x, vec omegai)


Radiação Le(x, vec omegao) É apenas o valor retornado pela nossa função Shade .  frac1N já está sendo executado em nossa função AddShader . Multiplicação por L(x, vec omegai) acontece quando refletimos o raio e o rastreamos ainda mais. Nossa tarefa é dar vida à parte verde da equação.

Pré-requisitos


Antes de embarcar em uma jornada, vamos cuidar de alguns aspectos: acumular amostras, cenas determinísticas e aleatoriedade do shader.

Acumulação


Por alguma razão, o Unity não me passa a textura HDR como destination no OnRenderImage . O formato R8G8B8A8_Typeless funcionou para mim, então a precisão se torna rapidamente muito baixa para acumular um grande número de amostras. Para lidar com isso, vamos adicionar private RenderTexture _converged ao private RenderTexture _converged C #. Este será o nosso buffer, acumulando com alta precisão os resultados antes de exibi-los na tela. Inicializamos / liberamos a textura da mesma maneira que _target na função InitRenderTexture . Na função Render , duplique o blitting:

 Graphics.Blit(_target, _converged, _addMaterial); Graphics.Blit(_converged, destination); 

Cenas determinísticas


Ao fazer alterações na renderização para avaliar o efeito, é útil comparar com os resultados anteriores. Até agora, a cada reinício do modo Play ou a recompilação do script, obteremos uma nova cena aleatória. Para evitar isso, adicione o public int SphereSeed ao public int SphereSeed C # e a seguinte linha no início do SetUpScene :

 Random.InitState(SphereSeed); 

Agora podemos definir manualmente as cenas iniciais. Digite qualquer número e ative / desative o RayTracingMaster novamente até obter a cena certa.

Os seguintes parâmetros foram usados ​​para imagens de amostra: Sphere Seed 1223832719, Sphere Radius [5, 30], Spheres Max 10000, Sphere Placement Radius 100.

Aleatoriedade do sombreador


Antes de iniciar a amostragem estocástica, precisamos adicionar aleatoriedade ao shader. Usarei a string canônica encontrada na rede, modificada por conveniência:

 float2 _Pixel; float _Seed; float rand() { float result = frac(sin(_Seed / 100.0f * dot(_Pixel, float2(12.9898f, 78.233f))) * 43758.5453f); _Seed += 1.0f; return result; } 

Inicialize _Pixel diretamente no CSMain como _Pixel = id.xy para que cada pixel possa usar valores aleatórios diferentes. _Seed inicializado a partir do C # na função SetShaderParameters .

 RayTracingShader.SetFloat("_Seed", Random.value); 

A qualidade dos números aleatórios gerados aqui é instável. No futuro, valeria a pena explorar e testar essa função analisando a influência dos parâmetros e comparando-a com outras abordagens. Mas, por enquanto, vamos usá-lo e esperar o melhor.

Amostragem no hemisfério


Vamos começar de novo: precisamos de direções aleatórias distribuídas uniformemente no hemisfério. Esta tarefa não trivial para o escopo completo é descrita em detalhes neste artigo por Corey Simon. É fácil se adaptar ao hemisfério. Aqui está a aparência do código shader:

 float3 SampleHemisphere(float3 normal) { //     float cosTheta = rand(); float sinTheta = sqrt(max(0.0f, 1.0f - cosTheta * cosTheta)); float phi = 2 * PI * rand(); float3 tangentSpaceDir = float3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); //      return mul(tangentSpaceDir, GetTangentSpace(normal)); } 

As direções são geradas para um hemisfério centralizado no eixo Z positivo, portanto, precisamos transformá-las para que elas sejam centralizadas no normal desejado. Geramos uma tangente e binormal (dois vetores ortogonais ao normal e ortogonais um ao outro). Primeiro, selecionamos um vetor auxiliar para gerar a tangente. Para fazer isso, pegamos o eixo X positivo e retornamos ao Z positivo somente se ele estiver normalmente (aproximadamente) alinhado com o eixo X. Em seguida, podemos usar o produto vetorial para gerar a tangente e depois o binormal.

 float3x3 GetTangentSpace(float3 normal) { //       float3 helper = float3(1, 0, 0); if (abs(normal.x) > 0.99f) helper = float3(0, 0, 1); //   float3 tangent = normalize(cross(normal, helper)); float3 binormal = normalize(cross(normal, tangent)); return float3x3(tangent, binormal, normal); } 

Espalhamento de Lambert


Agora que temos instruções aleatórias uniformes, podemos prosseguir com a implementação do primeiro BRDF. Para reflexão difusa, o mais usado é o BRDF Lambert, que é surpreendentemente simples: fr(x, vec omegai, vec omegao)= frackd pi onde kd - Esta é uma superfície albedo. Vamos inseri-lo em nossa equação de renderização de Monte Carlo (ainda não levarei em consideração a emissividade) e ver o que acontece:

L(x, vec omegao) approx frac1N sumNn=0 colorBlueViolet2kd( vec omegai cdot vecn)L(x, vec omegai)


Vamos inserir essa equação no shader imediatamente. Na função Shade , substitua o código dentro da construção if (hit.distance < 1.#INF) pelas seguintes linhas:

 //   ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(hit.normal); ray.energy *= 2 * hit.albedo * sdot(hit.normal, ray.direction); return 0.0f; 

A nova direção do feixe refletido é determinada usando nossa função de amostras homogêneas do hemisfério. A energia do feixe é multiplicada pela parte correspondente da equação mostrada acima. Como a superfície não emite nenhuma iluminação (reflete apenas a luz recebida direta ou indiretamente do céu), retornamos 0. Aqui, não esqueça que o AddShader média das amostras, portanto, não precisamos nos preocupar com  frac1N soma . CSMain já contém multiplicação por L(x, vec omegai) (o próximo feixe refletido), para não termos muito trabalho.

sdot é uma função auxiliar que eu defini para mim. Ele simplesmente retorna o resultado do produto escalar com um coeficiente adicional e o limita ao intervalo [0,1] :

 float sdot(float3 x, float3 y, float f = 1.0f) { return saturate(dot(x, y) * f); } 

Vamos resumir o que nosso código está fazendo até agora. CSMain gera os raios primários da câmera e chama Shade . Ao atravessar a superfície, essa função, por sua vez, gera um novo feixe (uniformemente aleatório no hemisfério em torno do normal) e leva em consideração o BRDF do material e o cosseno na energia do feixe. Na interseção do raio com o céu, amostramos o HDRI (nossa única fonte de iluminação) e retornamos a iluminação, que é multiplicada pela energia do raio (ou seja, o resultado de todas as interseções anteriores, começando pela câmera). Este é um exemplo simples que se mistura com um resultado convergente. Como resultado, o impacto é levado em consideração em cada amostra.  frac1N .

É hora de verificar tudo no trabalho. Como os metais não têm reflexão difusa, vamos desativá-los por enquanto na função SetUpScene de um script C # (mas ainda chame Random.value aqui para preservar o determinismo da cena):

 bool metal = Random.value < 0.0f; 

Inicie o modo Play e veja como a imagem inicialmente barulhenta é limpa e converge para uma bela renderização:

Imagem em espelho Phong


Nada mal para apenas algumas linhas de código (e uma pequena fração da matemática). Vamos refinar a imagem adicionando reflexos no espelho usando o BRDF de Phong. A formulação original de Fong teve seus problemas (falta de relacionamentos e conservação de energia), mas felizmente outras pessoas os eliminaram . O BRDF aprimorado é mostrado abaixo.  vec omegar A direção da luz perfeitamente refletida e  alpha É um indicador Phong que controla a rugosidade:

fr(x, vec omegai, vec omegao)=ks frac alpha+22 pi( vec omegar cdot vec omegao) alpha


Um gráfico bidimensional interativo mostra como o BRDF se parece com Phong quando  alpha=15 para um incidente de feixe num ângulo de 45 °. Tente alterar o valor.  alpha .

Cole isso em nossa equação de renderização de Monte Carlo:

L(x, vec omegao) approx frac1N sumNn=0 colorbrownks( alpha+2)( vec omegar cdot vec omegao) alpha( vec omegai cdot vecn)L(x, vec omegai)


E, finalmente, vamos adicionar isso ao Lambert BRDF existente:

L(x, vec omegao) approx frac1N sumNn=0[ colorBlueViolet2kd+ colorbrownks( alpha+2)( vec omegar cdot vec omegao) alpha]( vec omegai cdot vecn)L(x, vec omegai)


E é assim que eles se parecem no código, juntamente com a dispersão de Lambert:

 //    ray.origin = hit.position + hit.normal * 0.001f; float3 reflected = reflect(ray.direction, hit.normal); ray.direction = SampleHemisphere(hit.normal); float3 diffuse = 2 * min(1.0f - hit.specular, hit.albedo); float alpha = 15.0f; float3 specular = hit.specular * (alpha + 2) * pow(sdot(ray.direction, reflected), alpha); ray.energy *= (diffuse + specular) * sdot(hit.normal, ray.direction); return 0.0f; 

Observe que substituímos o produto escalar por um ligeiramente diferente, mas equivalente (refletido  omegao em vez de  omegai ) Agora transforme os materiais metálicos novamente nas funções SetUpScene e verifique como ele funciona.

Experimentando valores diferentes  alpha , você pode perceber um problema: mesmo o baixo desempenho requer muito tempo para convergência e, com alto desempenho, o ruído é especialmente impressionante. Mesmo após alguns minutos de espera, o resultado está longe de ser ideal, o que é inaceitável para uma cena tão simples.  alpha=15 e  alpha=300 com 8192 amostras são assim:



Por que isso aconteceu? Afinal, antes tivemos reflexões ideais tão bonitas (  alpha= infty )! .. O problema é que geramos amostras homogêneas e atribuímos pesos a elas de acordo com o BRDF. Com altos valores de Phong, o BRDF é pequeno para todos, mas essas direções estão muito próximas do reflexo perfeito e é muito improvável que as selecionemos aleatoriamente usando nossas amostras homogêneas . Por outro lado, se realmente cruzarmos uma dessas direções, o BRDF será enorme para compensar todas as outras pequenas amostras. O resultado é uma dispersão muito grande. Caminhos com várias reflexões especulares são ainda piores e resultam em ruído visível nas imagens.

Amostragem aprimorada


Para tornar nosso traçador de caminhos prático, precisamos mudar o paradigma. Em vez de desperdiçar amostras preciosas em áreas nas quais elas acabam não sendo importantes (uma vez que obtêm um valor muito baixo de BRDF e / ou cosseno), vamos gerar amostras importantes .

Como primeiro passo, retornaremos nossas reflexões ideais e depois veremos como essa ideia pode ser generalizada. Para fazer isso, dividimos a lógica de sombreamento em reflexão difusa e especular. Para cada amostra, escolheremos aleatoriamente um ou outro (dependendo da proporção kd e ks ) No caso de reflexão difusa, aderiremos a amostras homogêneas, mas para o especular, refletiremos explicitamente o feixe na única direção importante. Como agora menos amostras serão gastas em cada tipo de reflexão, precisamos aumentar a influência de acordo, para obter o mesmo valor total:

 //       hit.albedo = min(1.0f - hit.specular, hit.albedo); float specChance = energy(hit.specular); float diffChance = energy(hit.albedo); float sum = specChance + diffChance; specChance /= sum; diffChance /= sum; //     float roulette = rand(); if (roulette < specChance) { //   ray.origin = hit.position + hit.normal * 0.001f; ray.direction = reflect(ray.direction, hit.normal); ray.energy *= (1.0f / specChance) * hit.specular * sdot(hit.normal, ray.direction); } else { //   ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(hit.normal); ray.energy *= (1.0f / diffChance) * 2 * hit.albedo * sdot(hit.normal, ray.direction); } return 0.0f; 

energy é uma função auxiliar pequena que calcula a média dos canais de cores:

 float energy(float3 color) { return dot(color, 1.0f / 3.0f); } 

Por isso, criamos um traçador de raios Whited mais bonito da parte anterior, mas agora com sombreamento difuso real (o que implica sombras suaves, oclusão ambiental, iluminação global difusa):

imagem

Amostra de importância


Vamos dar uma outra olhada na fórmula básica de Monte Carlo:

FN approx frac1N sumNn=0 fracf(xn)p(xn)


Como você pode ver, dividimos a influência de cada amostra (amostra) na probabilidade de escolher essa amostra em particular. Até agora, usamos amostras homogêneas do hemisfério, por isso tivemos uma constante p( omega)= frac12 pi . Como vimos acima, isso está longe de ser ideal, por exemplo, no caso do Phong BRDF, que é grande em um número muito pequeno de direções.

Imagine que poderíamos encontrar uma distribuição de probabilidade exatamente igual à função integrável: p(x)=f(x) . Então o seguinte acontecerá:

FN approx frac1N sumNn=01


Agora não temos amostras que contribuam muito pouco. Essas amostras terão menos probabilidade de serem selecionadas. Isso reduzirá significativamente a variação do resultado e acelerará a convergência da renderização.

Na prática, é impossível encontrar uma distribuição ideal, porque algumas partes da função integrável (no nosso caso BRDF × cosseno × luz incidente) são desconhecidas (isso é mais óbvio para a luz incidente), mas a distribuição de amostras de acordo com o BRDF × cosseno ou mesmo apenas com o BRDF ajudará nós Este princípio é chamado de amostragem por importância.

Amostra de cosseno


Nas etapas a seguir, precisamos substituir nossa distribuição homogênea de amostras pela distribuição de acordo com a regra do cosseno. Não se esqueça, em vez de multiplicar amostras homogêneas por cosseno, reduzindo sua influência, queremos gerar um número proporcionalmente menor de amostras.

Este artigo de Thomas Poole descreve como fazer isso. Adicionaremos o parâmetro alpha à nossa função SampleHemisphere . A função determina o índice da seleção de cossenos: 0 para uma amostra uniforme, 1 para a seleção de cossenos ou maior para valores mais altos de Phong. No código, fica assim:

 float3 SampleHemisphere(float3 normal, float alpha) { //  ,      float cosTheta = pow(rand(), 1.0f / (alpha + 1.0f)); float sinTheta = sqrt(1.0f - cosTheta * cosTheta); float phi = 2 * PI * rand(); float3 tangentSpaceDir = float3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); //      return mul(tangentSpaceDir, GetTangentSpace(normal)); } 

Agora a probabilidade de cada amostra é igual p( omega)= frac alpha+12 pi( vec omega cdot vecn) alpha . A beleza dessa equação pode não parecer imediatamente óbvia, mas um pouco mais tarde você entenderá.

Amostra de Lambert por importância


Para iniciantes, refinaremos a renderização de reflexão difusa. Em nossa distribuição homogênea, o constante BRDF de Lambert já é usado, mas podemos melhorá-lo adicionando o cosseno. A distribuição de probabilidade da amostra por cosseno (onde  alpha=1 ) é igual  frac( vec omegai cdot vecn) pi , que simplifica nossa fórmula de Monte Carlo para reflexão difusa:

L(x, vec omegao) approx frac1N sumNn=0 colorBlueVioletkdL(x, vec omegai)


 //   ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(hit.normal, 1.0f); ray.energy *= (1.0f / diffChance) * hit.albedo; 

Isso irá acelerar um pouco o nosso sombreamento difuso. Agora vamos entrar na questão real.

Amostragem de Fongov por importância


Para Phong BRDF, o procedimento é semelhante. Desta vez, temos o produto de dois cossenos: o cosseno padrão da equação de renderização (como no caso da reflexão difusa), multiplicado pelo cosseno próprio do BRDF. Nós vamos lidar apenas com o último.

Vamos inserir a distribuição de probabilidade dos exemplos acima na equação de Phong. Uma conclusão detalhada pode ser encontrada em Lafortune e Willems: Usando o modelo de refletância de Phong modificado para renderização com base física (1994) :

L(x, vec omegao) approx frac1N sumNn=0 colorbrownks frac alpha+2 alpha+1( V e c o m e g um i c d o t v e c n )    L ( x , v e c o m e g a i ) 


 //   float alpha = 15.0f; ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(reflect(ray.direction, hit.normal), alpha); float f = (alpha + 2) / (alpha + 1); ray.energy *= (1.0f / specChance) * hit.specular * sdot(hit.normal, ray.direction, f); 

Essas alterações são suficientes para eliminar quaisquer problemas com alto desempenho em Phong e fazer nossa renderização convergir em um tempo muito mais razoável.

Materiais


Por fim, vamos expandir nossa geração de cenas para criar valores variáveis ​​para a suavidade e a emissividade das esferas! Na struct Sphere de um script C #, adicione public float smoothness public Vector3 emission e public Vector3 emission . Como alteramos o tamanho da estrutura, precisamos alterar a etapa ao criar o Buffer de computação (4 × o número de números flutuantes, lembra-se?). Faça com que a função SetUpScene insira valores para suavidade e SetUpScene .

No sombreador, adicione ambas as variáveis ​​para struct Sphere e struct RayHit e, em seguida, inicialize-as em CreateRayHit . E, finalmente, defina os dois valores no IntersectGroundPlane (codificado permanentemente, cole qualquer valor) e no IntersectSphere (obtendo valores do Sphere ).

Quero usar os valores de suavidade da mesma maneira que no shader padrão do Unity, que difere de um expoente Fong bastante arbitrário. Aqui está uma boa conversão que pode ser usada na função Shade :

 float SmoothnessToPhongAlpha(float s) { return pow(1000.0f, s * s); } 

 float alpha = SmoothnessToPhongAlpha(hit.smoothness); 



O uso da emissividade é feito retornando um valor em Shade :

 return hit.emission; 

Resultados


Respire fundo. relaxe e aguarde até a imagem se transformar em uma imagem tão bonita:

imagem

Parabéns! Você conseguiu superar o emaranhado de expressões matemáticas. Implementamos um rastreador de caminho que realiza sombreamento difuso e espelhado, aprendido sobre amostragem por importância, aplicando imediatamente esse conceito para que a renderização converja em minutos, não horas ou dias.

Comparado ao anterior, este artigo foi um grande passo em termos de complexidade, mas também melhorou significativamente a qualidade do resultado. Trabalhar com cálculos matemáticos leva tempo, mas se justifica porque pode aprofundar significativamente sua compreensão do que está acontecendo e permitirá expandir o algoritmo sem destruir a confiabilidade física.

Obrigado pela leitura! Na terceira parte, nós (por um tempo) deixaremos a selva de amostragem e sombreamento e voltaremos à civilização para encontrar os cavalheiros Moller e Trumbor. Precisamos conversar com eles sobre triângulos.

Sobre o autor: David Curie é desenvolvedor da Three Eyed Games, programador do Laboratório de Engenharia Virtual da Volkswagen, pesquisador de computação gráfica e músico de heavy metal.

imagem

Source: https://habr.com/ru/post/pt412685/


All Articles