Wolfenstein 3D: traçado de raios com WebGL1

imagem

Após o surgimento das placas gráficas Nvidia RTX no verão passado, o ray tracing recuperou sua antiga popularidade. Nos últimos meses, meu feed do Twitter foi preenchido com um fluxo interminável de comparações gráficas com o RTX ativado e desativado.

Depois de admirar tantas imagens bonitas, eu queria tentar combinar o renderizador avançado clássico com um traçador de raios sozinho.

Sofrendo de uma síndrome de rejeição do desenvolvimento de outras pessoas , como resultado, criei meu próprio mecanismo de renderização híbrido baseado no WebGL1. Você pode jogar com a renderização no nível de demonstração do Wolfenstein 3D com as esferas (que usei devido ao traçado de raios) aqui .

Protótipo


Comecei este projeto criando um protótipo, tentando recriar a iluminação global com o traçado de raios do Metro Exodus .


O primeiro protótipo a mostrar iluminação global difusa (Diffuse GI)

O protótipo é baseado em um renderizador para frente, que renderiza toda a geometria da cena. O sombreador usado para rasterizar a geometria não apenas calcula a iluminação direta, mas também emite raios aleatórios da superfície da geometria renderizada para acumular usando a reflexão indireta da luz do traçador de raios resultante de superfícies não brilhantes (Diffuse GI).

Na imagem acima, você pode ver como todas as esferas são iluminadas corretamente apenas por iluminação indireta (os raios de luz são refletidos na parede atrás da câmera). A fonte de luz em si é coberta por uma parede marrom no lado esquerdo da imagem.

Wolfenstein 3D


O protótipo usa uma cena muito simples. Possui apenas uma fonte de luz e apenas algumas esferas e cubos são renderizados. Graças a isso, o código de rastreamento de raio no shader é muito simples. O ciclo aproximado de verificação de interseção, no qual o feixe é testado para interseção com todos os cubos e esferas na cena, ainda é rápido o suficiente para o programa executá-lo em tempo real.

Depois de criar esse protótipo, eu queria fazer algo mais complexo, adicionando mais geometria e muitas fontes de luz à cena.

O problema com um ambiente mais complexo é que ainda preciso traçar raios na cena em tempo real. Normalmente, uma estrutura de hierarquia de volume delimitadora (BVH) seria usada para acelerar o processo de rastreamento de raios, mas minha decisão de criar este projeto no WebGL1 não permitiu isso: é impossível carregar dados de 16 bits em uma textura no WebGL1 e operações binárias não podem ser usadas em um shader. Isso complica o cálculo preliminar e a aplicação do BVH nos shaders WebGL1.

Por isso, decidi usar o nível de demonstração Wolfenstein 3D para isso. Em 2013, criei um shader WebGL de fragmento em Shadertoy , que não apenas renderiza níveis semelhantes a Wolfenstein, mas também cria processualmente todas as texturas necessárias. Pela minha experiência trabalhando neste shader, eu sabia que o design de nível baseado em grade de Wolfenstein também pode ser usado como uma estrutura de aceleração rápida e fácil, e que o traçado de raios ao longo dessa estrutura será muito rápido.

A captura de tela da demonstração é mostrada abaixo e, no modo de tela cheia, você pode reproduzi-la aqui: https://reindernijhoff.net/wolfrt .


Breve descrição


A demonstração usa um mecanismo de renderização híbrido. Para renderizar todos os polígonos no quadro, ele usa a rasterização tradicional e, em seguida, combina o resultado com sombras, GI difuso e reflexões criadas pelo traçado de raios.


Sombras


Gi difuso


Reflexões

Renderização proativa


Os cartões Wolfenstein podem ser totalmente codificados em uma grade bidimensional de 64 × 64. O mapa usado na demo é baseado no primeiro nível do episódio 1 do Wolfenstein 3D.

Na inicialização, toda a geometria necessária para passar a renderização proativa é criada. Uma malha de paredes é gerada a partir de dados do mapa. Também cria planos de piso e teto, malhas separadas para luzes, portas e esferas aleatórias.

Todas as texturas usadas para paredes e portas são empacotadas em um único atlas de textura, para que todas as paredes possam ser desenhadas em uma única chamada.

Sombras e iluminação


A iluminação direta é calculada no sombreador usado para o passe de renderização direta. Cada fragmento pode ser iluminado (máximo) por quatro fontes diferentes. Para saber quais fontes podem influenciar o fragmento no sombreador, quando a demonstração é iniciada, a textura da pesquisa é pré-calculada. Essa textura de pesquisa tem um tamanho de 64 por 128 e codifica as posições das 4 fontes de luz mais próximas para cada posição na grade do mapa.

varying vec3 vWorldPos; varying vec3 vNormal; void main(void) { vec3 ro = vWorldPos; vec3 normal = normalize(vNormal); vec3 light = vec3(0); for (int i=0; i<LIGHTS_ENCODED_IN_MAP; i++) { light += sampleLight(i, ro, normal); } 

Para obter sombras suaves para cada fragmento e fonte de luz, é amostrada uma posição aleatória na fonte de luz. Usando o código de rastreamento de raios no sombreador (consulte a seção Rastreamento de raios abaixo), um raio de sombra é emitido no ponto de amostragem para determinar a visibilidade da fonte de luz.

Após adicionar reflexões (auxiliares) (consulte a seção Reflexão abaixo), a IG difusa é adicionada à cor calculada do fragmento, realizando uma pesquisa no alvo de renderização da IG difusa (veja abaixo).

Traçado de raio


Embora o código do protótipo de rastreamento de raios para GI difuso tenha sido combinado com um sombreador preventivo, na demonstração, decidi separá-los.


Separei-os fazendo uma segunda renderização de toda a geometria em um destino de renderização separado (Diffuse GI Render Target) usando outro sombreador que emite apenas raios aleatórios para coletar GI difusa (consulte a seção “Diffuse GI” abaixo). A iluminação coletada neste destino de renderização é adicionada à iluminação direta calculada na passagem de renderização direta.

Ao separar o passe proativo e o GI difuso, podemos emitir menos de um feixe GI difuso por pixel da tela. Isso pode ser feito reduzindo a escala do buffer (movendo o controle deslizante nas opções no canto superior direito da tela).

Por exemplo, se a Escala de buffer for 0,5, apenas um raio será emitido para cada quatro pixels da tela. Isso proporciona um enorme aumento de produtividade. Usando a mesma interface do usuário no canto superior direito da tela, você também pode alterar o número de amostras por pixel no alvo de renderização (SPP) e o número de reflexões de feixe.

Emite um feixe


Para poder emitir raios para a cena, toda a geometria de nível deve ter um formato que o traçador de raios no shader possa usar. A camada Wolfenstein codificou uma grade de 64 × 64, portanto, é fácil codificar todos os dados em uma única textura de 64 × 64:

  • No canal vermelho da cor da textura, todos os objetos localizados na célula correspondente x, y da grade do mapa são codificados. Se o valor do canal vermelho for zero, não haverá objetos na célula; caso contrário, ele será ocupado por uma parede (valores de 1 a 64), uma porta, uma fonte de luz ou uma esfera que precisa ser verificada quanto à interseção.
  • Se uma esfera ocupa uma célula da grade de nível, os canais verde, azul e alfa são usados ​​para codificar o raio e as coordenadas relativas x e y da esfera dentro da célula da grade.

Um raio é emitido em uma cena atravessando uma textura usando o seguinte código:

 bool worldHit(n vec3 ro,in vec3 rd,in float t_min, in float t_max, inout vec3 recPos, inout vec3 recNormal, inout vec3 recColor) { vec3 pos = floor(ro); vec3 ri = 1.0/rd; vec3 rs = sign(rd); vec3 dis = (pos-ro + 0.5 + rs*0.5) * ri; for( int i=0; i<MAXSTEPS; i++ ) { vec3 mm = step(dis.xyz, dis.zyx); dis += mm * rs * ri; pos += mm * rs; vec4 mapType = texture2D(_MapTexture, pos.xz * (1. / 64.)); if (isWall(mapType)) { ... return true; } } return false; } 

Um código de rastreamento de raio de malha semelhante pode ser encontrado neste shader Wolfenstein em Shadertoy.

Depois de calcular o ponto de interseção com a parede ou porta (usando o teste de interseção com um paralelogramo ), pesquisar no mesmo atlas de textura usado para passar a renderização proativa nos dá pontos de interseção albedo. As esferas têm uma cor que é determinada procedimentalmente com base em suas coordenadas x, y na grade e na função gradiente de cores .

As portas são um pouco mais complicadas porque estão se movendo. Para que a representação de cena na CPU (usada para renderizar malhas no passo de renderização direta) seja igual à representação de cena na GPU (usada para rastreamento de raios), todas as portas se movem de forma automática e determinística, com base na distância da câmera à porta.



Gi difuso


A iluminação global dispersa (IG difusa) é calculada emitindo raios no sombreador, que é usado para desenhar toda a geometria no alvo de renderização da IG difusa. A direção desses raios depende do normal para a superfície, determinado pela amostragem do hemisfério com cosseno.

Tendo a direção do feixe rd e o ponto inicial ro , a iluminação refletida pode ser calculada usando o seguinte ciclo:

 vec3 getBounceCol(in vec3 ro, in vec3 rd, in vec3 col) { vec3 emitted = vec3(0); vec3 recPos, recNormal, recColor; for (int i=0; i<MAX_RECURSION; i++) { if (worldHit(ro, rd, 0.001, 20., recPos, recNormal, recColor)) { // if (isLightHit) { // direct light sampling code // return vec3(0); // } col *= recColor; for (int i=0; i<2; i++) { emitted += col * sampleLight(i, recPos, recNormal); } } else { return emitted; } rd = cosWeightedRandomHemisphereDirection(recNormal); ro = recPos; } return emitted; } 

Para reduzir o ruído, a amostragem direta de luz é adicionada ao loop. Isso é semelhante à técnica usada no meu outro shader Cornell Box no Shadertoy.

Reflexão


Graças à capacidade de rastrear a cena com raios no shader, é muito fácil adicionar reflexos. Na minha demonstração, as reflexões são adicionadas chamando o mesmo método getBounceCol mostrado acima, usando o feixe refletido da câmera:

 #ifdef REFLECTION col = mix(col, getReflectionCol(ro, reflect(normalize(vWorldPos - _CamPos), normal), albedo), .15); #endif 

As reflexões são adicionadas no passo de renderização direta; portanto, um raio de reflexão sempre emitirá um feixe de reflexão.


Anti-aliasing temporal


Como ambas as sombras suaves na renderização direta passam e a aproximação GI difusa usa aproximadamente uma amostra por pixel, o resultado final é extremamente barulhento. Para reduzir a quantidade de ruído, o anti-aliasing temporal (TAA) foi usado com base no TAA de Playdead: Anti-Aliasing de reprojeção temporal no INSIDE .

Re-projeção


A idéia por trás do TAA é bastante simples: o TAA calcula um subpixel por quadro e calcula a média de seus valores com o pixel correlacionado do quadro anterior.

Para saber onde o pixel atual estava no quadro anterior, a posição do fragmento é reprojetada usando a matriz de exibição de modelo-projeção do quadro anterior.

Coletar amostras e limitar bairros


Em alguns casos, uma amostra salva do passado é inválida, por exemplo, quando a câmera se move de tal maneira que um fragmento do quadro atual no quadro anterior foi fechado por geometria. Para descartar amostras inválidas, é usada uma restrição de vizinhança. Eu escolhi o tipo mais simples de restrição:

 vec3 history = texture2D(_History, uvOld ).rgb; for (float x = -1.; x <= 1.; x+=1.) { for (float y = -1.; y <= 1.; y+=1.) { vec3 n = texture2D(_New, vUV + vec2(x,y) / _Resolution).rgb; mx = max(n, mx); mn = min(n, mn); } } vec3 history_clamped = clamp(history, mn, mx); 

Também tentei usar o método de restrição com base no paralelogramo delimitador, mas não vi muita diferença com minha solução. Provavelmente isso aconteceu porque na cena da demonstração existem muitas cores escuras idênticas e quase nenhum objeto em movimento.

Vibrações da câmera


Para obter a suavização de serrilhado, a câmera em cada quadro oscila devido ao uso de (pseudo) deslocamento aleatório de subpixel. Isso é implementado alterando a matriz de projeção:

 this._projectionMatrix[2 * 4 + 0] += (this.getHaltonSequence(frame % 51, 2) - .5) / renderWidth; this._projectionMatrix[2 * 4 + 1] += (this.getHaltonSequence(frame % 41, 3) - .5) / renderHeight; 

O barulho


O ruído é a base dos algoritmos usados ​​para calcular o GI difuso e sombras suaves. O uso de ruído bom afeta muito a qualidade da imagem, enquanto o ruído ruim cria artefatos ou diminui a convergência da imagem.

Receio que o ruído branco usado nesta demonstração não seja muito bom.

Usar um bom ruído é provavelmente o aspecto mais importante para melhorar a qualidade da imagem nesta demonstração. Por exemplo, você pode usar ruído azul .

Realizei experimentos com ruído com base na proporção áurea, mas eles não tiveram sucesso. Até agora, o infame Hash sem seno de Dave Hoskins é usado:

 vec2 hash2() { vec3 p3 = fract(vec3(g_seed += 0.1) * HASHSCALE3); p3 += dot(p3, p3.yzx + 19.19); return fract((p3.xx+p3.yz)*p3.zy); } 


Redução de ruído


Mesmo com o TAA ativado, a demonstração ainda mostra muito ruído. É especialmente difícil renderizar o teto, porque ele é iluminado apenas por iluminação indireta. Isso não simplifica a situação em que o teto é uma grande superfície plana, preenchida com uma cor sólida: se tivesse textura ou detalhes geométricos, o ruído se tornaria menos perceptível.

Como não queria gastar muito tempo nessa parte da demonstração, tentei aplicar apenas um filtro de redução de ruído: a Median3x3 de Morgan McGuire e Kyle Witson . Infelizmente, esse filtro não funciona muito bem com gráficos de "pixel art" de texturas de parede: remove todos os detalhes à distância e arredonda os cantos dos pixels das paredes próximas.

Em outro experimento, apliquei o mesmo filtro no destino de renderização GI difuso. Embora ele tenha reduzido um pouco o ruído, ao mesmo tempo quase sem alterar os detalhes das texturas da parede, decidi que essa melhoria não valia os milissegundos extras gastos.

Demo


Você pode jogar a demo aqui .

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


All Articles