Implementando nuvens volumétricas fisicamente corretas como no Horizon Zero Dawn

Anteriormente, as nuvens nos jogos eram desenhadas com sprites 2D comuns, que sempre são rotacionados na direção da câmera, mas nos últimos anos, novos modelos de placas de vídeo permitem desenhar nuvens fisicamente corretas sem perdas visíveis de desempenho. Acredita-se que nuvens volumosas no jogo trouxeram o estúdio Guerrilla Games junto com o jogo Horizon Zero Dawn. Obviamente, essas nuvens foram capazes de renderizar antes, mas o estúdio formou algo como um padrão do setor para os recursos de origem e os algoritmos usados, e agora qualquer implementação de nuvens volumétricas está de alguma forma em conformidade com esse padrão.



Todo o processo de renderização de nuvens é muito bem dividido em estágios e é importante observar que a implementação imprecisa, mesmo em um deles, pode levar a consequências que não ficarão claras onde está o erro e como corrigi-lo; portanto, é recomendável fazer uma conclusão de controle do resultado a cada vez.

Mapeamento de tons, sRGB


Antes de começar a trabalhar com iluminação, é importante fazer duas coisas:

  1. Antes de exibir a imagem final na tela, aplique pelo menos o mapeamento de tom mais simples:

    tunedColor=color/(1+color) 

    Isso é necessário porque os valores de cores calculados serão muito maiores que a unidade.
  2. Verifique se o buffer de quadros final no qual você está desenhando e exibido na tela está no formato sRGB. Se a ativação do modo sRGB for um problema, a conversão poderá ser feita manualmente no shader:

     finalColor=pow(color, vec3(1.0/2.2)) 

    A fórmula é adequada para a maioria dos casos, mas não 100%, dependendo do monitor. É importante que a conversão de sRGB seja sempre feita por último.

Modelo de iluminação


Considere um espaço preenchido com matéria parcialmente transparente de diferentes densidades. Quando um raio de luz passa através dessa substância, é exposto a quatro efeitos: absorção, dispersão, dispersão amplificada e auto-radiação. O último ocorre no caso de processos químicos em uma substância e não é afetado aqui.

Suponha que tenhamos um raio de luz que atravessa a matéria do ponto A ao ponto B:


Absorção

A luz que passa através de uma substância sofre absorção por essa mesma substância. A fração de luz não absorvida pode ser encontrada pela fórmula:


onde - a luz restante no ponto após absorção . - ponto no segmento AB à distância de A.

Dispersão

Parte da luz sob a influência de partículas da matéria muda de direção. A fração de luz que não mudou de direção pode ser encontrada pela fórmula:


onde - fração da luz que não mudou de direção após a dispersão em um ponto .

A absorção e a dispersão devem ser combinadas:


Função chamado atenuação ou extinção. Uma função - função de transferência. Ele mostra quanta luz resta ao passar do ponto A ao ponto B.

No que diz respeito e : , em que C é uma determinada constante, que pode ter um valor diferente para cada canal em RGB, É a densidade do meio no ponto .

Agora vamos complicar a tarefa. A luz se move do ponto A ao ponto B, desaparece durante o movimento. No ponto X, parte da luz é espalhada em direções diferentes, uma das direções corresponde ao observador no ponto O. Em seguida, uma parte da luz espalhada se move do ponto X para o ponto O e é úmida novamente. O caminho da luz AXO de interesse para nós.


A perda de luz ao passar de A para X, sabemos: , assim como sabemos a perda de luz de X para O - isso . Mas e a fração de luz que será espalhada na direção do observador?

Dispersão de amplificação

Se, no caso da dispersão comum, a intensidade da luz diminui, então, no caso da amplificação da dispersão, aumenta devido à dispersão da luz que ocorreu nas regiões vizinhas. A quantidade total de luz proveniente de regiões vizinhas pode ser encontrada pela fórmula:


onde significa assumir a integral sobre a esfera, - função de fase - luz vinda da direção .

É bastante difícil calcular a luz de todas as direções, no entanto, sabemos que a parte original da luz é transportada pelo nosso feixe AB original. A fórmula pode ser bastante simplificada:


onde - o ângulo entre o feixe luminoso e o feixe observador (ou seja, o ângulo AXO), - o valor inicial da intensidade da luz. Resumindo todas as opções acima, obtemos a fórmula:


onde - luz recebida - a luz atingindo o observador.

Nós complicamos a tarefa um pouco mais. Digamos que a luz seja emitida por uma luz direcional, ou seja, o sol:


Tudo acontece da mesma forma que no caso anterior, mas muitas vezes. A luz do ponto A1 está espalhada no ponto X1 em direção ao observador no ponto O, a luz do ponto A2 está espalhada no ponto X2 em direção ao observador no ponto O, etc. Vemos que a luz que chega ao observador é igual à soma:


Ou uma expressão integral mais precisa:


É importante entender que aqui , ou seja, o segmento é dividido em um número infinito de seções com comprimento zero.

O céu


Com uma ligeira simplificação, um raio de sol que passa através da atmosfera sofre apenas dispersão, isto é, .


E nem mesmo um tipo de espalhamento, mas dois: espalhamento Rayleigh e espalhamento Mi. O primeiro é causado por moléculas de ar e o segundo é causado por um aerossol de água.

A densidade total do ar (ou aerossol) através da qual passa um raio de luz, movendo-se do ponto A ao ponto B:
onde - altura de escala, h - altura atual.

Uma solução integral simples seria:

onde dh é o tamanho da etapa com a qual a amostra de altura é coletada.

Agora observe a figura e use a fórmula derivada na parte anterior do "modelo de iluminação":


O observador olha de O para O '. Queremos coletar toda a luz que atinge os pontos X1, X2, ..., Xn, é espalhada neles e, em seguida, atinge o observador:


onde a intensidade da luz emitida pelo sol, - altura no ponto ; no caso do céu, constante C, que está em função denotado como .

A solução da integral pode ser a seguinte:

Esta fórmula é válida para a dispersão Rayleigh e Mie. Como resultado, os valores de luz para cada uma das dispersões simplesmente somam:


Dispersão de Rayleigh



(contém valores para cada canal RGB)



Resultado:


Mi scatter



(os valores para todos os canais RGB são os mesmos)



Resultado:


O número de amostras por segmento e no segmento Você pode pegar 32 e acima. O raio da Terra é 6371000 m, a atmosfera é 100000 m.

O que fazer com tudo isso:

  1. Em cada pixel da tela, calculamos a direção do observador V
  2. Tomamos a posição do observador O igual a {0, 6371000, 0}
  3. Encontramos como resultado da interseção do raio com origem no ponto O, e a direção de V e a esfera centralizada no ponto {0,0,0} e um raio de 6471000
  4. Segmento de linha dividir em 32 seções de igual comprimento
  5. Para cada seção, calculamos a dispersão Rayleigh e Mie e adicionamos tudo. Além disso, para calcular também precisaremos dividir o segmento 32 parcelas iguais em cada caso. pode ser lido através de uma variável, cujo valor aumenta a cada etapa do ciclo.

O resultado final:


Modelo de nuvem


Vamos precisar de vários tipos de ruído em 3D. O primeiro é o ruído de movimento browniano (fBm) fractal de Perlin:

Resultado para uma fatia 2D:


O segundo é o ruído oculto de fBm de Voronoi.

Resultado para uma fatia 2D:


Para obter o ruído fBm de camuflagem de Vorley, você precisa inverter o ruído fBm de camuflagem de Voronoj. No entanto, alterei levemente os intervalos de valores a meu critério:

 float fbmTiledWorley3(...) { return clamp((1.0-fbmTiledVoronoi3(...))*1.5-0.25, 0.0, 1.0); } 

O resultado se assemelha imediatamente às estruturas da nuvem:


Para nuvens, você precisa obter duas texturas especiais. O primeiro tem um tamanho de 128x128x128 e é responsável pelo ruído de baixa frequência, o segundo tem um tamanho de 32x32x32 e é responsável pelo ruído de alta frequência. Cada textura usa apenas um canal no formato R8. Em alguns exemplos, 4 canais de R8G8B8A8 são usados ​​para a primeira textura e três canais de R8G8B8 para o segundo e, em seguida, os canais são misturados em um shader. Não vejo o ponto, porque a mistura pode ser feita antecipadamente, obtendo um impacto maior na coerência do cache.

Para mixar, e também em alguns lugares, a função remap () será usada, que dimensiona os valores de um intervalo para outro:

 float remap(float value, float minValue, float maxValue, float newMinValue, float newMaxValue) { return newMinValue+(value-minValue)/(maxValue-minValue)*(newMaxValue-newMinValue); } 

Vamos começar a preparar a textura com ruído de baixa frequência:
Canal R - ruído fBm de perlin
Canal G - ruído fBm Vorley lado a lado
Canal B - menor ruído fBm Worley com menor escala
Canal A - O ruído tBF de taylable de Varley com escala ainda menor


A mistura é feita desta maneira:

 finalValue=remap(noise.x, (noise.y * 0.625 + noise.z*0.25 + noise.w * 0.125)-1, 1, 0, 1) 

Resultado para uma fatia 2D:


Agora prepare a textura com ruído de alta frequência:
Canal R - ruído fBm Vorley lado a lado
Canal G - menor ruído de fBm Vorley em escala reduzida
Canal B - ruído Varley taylivaya fBm com escala ainda menor


 finalValue=noise.x * 0.625 + noise.y*0.25 + noise.z * 0.125; 

Resultado para uma fatia 2D:


Também precisamos de um mapa 2D de clima e textura que determine a presença, densidade e forma das nuvens, dependendo das coordenadas do espaço. Ele é pintado por artistas para ajustar a cobertura de nuvens. A interpretação dos canais de cores do mapa climático pode ser diferente, na versão que emprestei, é a seguinte:


Canal R - cobertura de nuvens em baixa altitude
Canal G - cobertura de nuvens de alta altitude
Canal B - altura máxima da nuvem
Canal A - densidade da nuvem

Agora, estamos prontos para criar uma função que retornará a densidade das nuvens, dependendo das coordenadas do espaço 3D.

Na entrada, um ponto no espaço com coordenadas em km

 vec3 position 

Adicione imediatamente o deslocamento ao vento

 position.xz+=vec2(0.2f)*ufmParams.time; 

Obter os valores do mapa meteorológico

 vec4 weather=textureLod(ufmWeatherMap, position.xz/4096.0f, 0); 
Obtemos a porcentagem de altura (de 0 a 1)

 float height=cloudGetHeight(position); 

Adicione um pequeno arredondamento das nuvens abaixo:
 float SRb=clamp(remap(height, 0, 0.07, 0, 1), 0, 1); 
Fazemos uma diminuição linear na densidade para 0 com o aumento da altura de acordo com o canal B do mapa meteorológico:

 float SRt=clamp(remap(height, weather.b*0.2, weather.b, 1, 0), 0, 1); 
Combine o resultado:

 float SA=SRb*SRt; 

Adicione novamente o arredondamento das nuvens abaixo:

 float DRb=height*clamp(remap(height, 0, 0.15, 0, 1), 0, 1); 

Adicione também o arredondamento das nuvens no topo:

 float DRt=height*clamp(remap(height, 0.9, 1, 1, 0), 0, 1); 
Combinamos o resultado, aqui adicionamos a influência da densidade no mapa meteorológico e a influência da densidade, que é definida via gui:

 float DA=DRb*DRt*weather.a*2*ufmProperties.density; 

Combine ruídos de baixa e alta frequência de nossas texturas:

 float SNsample=textureLod(ufmLowFreqNoiseTexture, position/48.0f, 0).x*0.85f+textureLod(ufmHighFreqNoiseTexture, position/4.8f, 0).x*0.15f; 

Em todos os documentos que li, a fusão ocorre de uma maneira diferente, mas gostei dessa opção.

Determinamos a quantidade de cobertura (% do céu ocupado por nuvens), que é definida por GUI, os canais R e G do mapa meteorológico também são usados:

 float WMc=max(weather.r, clamp(ufmProperties.coverage-0.5, 0, 1)*weather.g*2); 

Calcular a densidade final:

 float d=clamp(remap(SNsample*SA, 1-ufmProperties.coverage*WMc, 1, 0, 1), 0, 1)*DA; 

Função inteira:

 float cloudSampleDensity(vec3 position) { position.xz+=vec2(0.2f)*ufmParams.time; vec4 weather=textureLod(ufmWeatherMap, position.xz/4096.0f+vec2(0.2, 0.1), 0); float height=cloudGetHeight(position); float SRb=clamp(remap(height, 0, 0.07, 0, 1), 0, 1); float SRt=clamp(remap(height, weather.b*0.2, weather.b, 1, 0), 0, 1); float SA=SRb*SRt; float DRb=height*clamp(remap(height, 0, 0.15, 0, 1), 0, 1); float DRt=height*clamp(remap(height, 0.9, 1, 1, 0), 0, 1); float DA=DRb*DRt*weather.a*2*ufmProperties.density; float SNsample=textureLod(ufmLowFreqNoiseTexture, position/48.0f, 0).x*0.85f+textureLod(ufmHighFreqNoiseTexture, position/4.8f, 0).x*0.15f; float WMc=max(weather.r, clamp(ufmProperties.coverage-0.5, 0, 1)*weather.g*2); float d=clamp(remap(SNsample*SA, 1-ufmProperties.coverage*WMc, 1, 0, 1), 0, 1)*DA; return d; } 

O que exatamente essa função deve ser é uma questão em aberto, porque ignorando as leis que as nuvens obedecem ao definir parâmetros, você pode obter um resultado muito incomum e bonito. Tudo depende da aplicação.


Integração


A atmosfera da Terra é dividida em duas camadas: interna e externa, entre as quais as nuvens podem ser localizadas. Essas camadas podem ser representadas por esferas, mas também por planos. Eu me acomodei nas esferas. Para a primeira camada, peguei o raio da esfera de 6415 km, para a segunda camada, o raio de 6435 km. O raio da Terra arredondou para 6400 km. Alguns parâmetros dependerão da espessura condicional da parte "nublada" da atmosfera (20 km).



Ao contrário do céu, as nuvens são opacas e a integração requer não apenas obter a cor, mas também obter o valor do canal alfa. Primeiro, você precisa de uma função que retorne a densidade total da nuvem através da qual um raio de luz do sol passará.


Ninguém chama a atenção para isso, mas a prática mostrou que não é necessário levar em consideração todo o caminho do feixe, apenas a lacuna mais extrema é necessária. Assumimos que as nuvens acima de um segmento truncado não existem.


Além disso, somos muito limitados no número de amostras de densidade que podem ser feitas sem prejudicar o desempenho. Guerrilla Games do 6. Além disso, em uma das apresentações, o desenvolvedor disse que eles espalham essas amostras dentro do cone, e a última amostra é especialmente feita muito longe do resto para cobrir o máximo de espaço possível. As imprecisões e ruídos resultantes ainda serão suavizados contra o fundo das amostras vizinhas, e isso, pelo contrário, se tornará uma precisão maior.


No final, decidi por 4 amostras que estão na mesma linha, mas a última é feita com um passo aumentado em 6 vezes. O tamanho da etapa é 20 km * 0,01, que é de 200 m.

A função é bem simples:

 float cloudSampleDirectDensity(vec3 position, vec3 sunDir) { //   float avrStep=(6435.0-6415.0)*0.01; float sumDensity=0.0; for(int i=0;i<4;i++) { float step=avrStep; //      6 if(i==3) step=step*6.0; //  position+=sunDir*step; //  ,  ,   //  float density=cloudSampleDensity(position)*step; sumDensity+=density; } return sumDensity; } 

Agora você pode passar para a parte mais difícil. Determinamos o observador na superfície da Terra no ponto {0, 6400,0} e encontramos a interseção do feixe de observação com uma esfera de raio de 6415 km e centro {0,0,0} - obtemos o ponto de partida S.


Abaixo está a versão básica da função:

 vec4 mainMarching(vec3 viewDir, vec3 sunDir) { vec3 position; crossRaySphereOutFar(vec3(0.0, 6400.0, 0.0), viewDir, vec3(0.0), 6415.0, position); float avrStep=(6435.0-6415.0)/64.0; for(int i=0;i<128;i++) { position+=viewDir*step; if(length(position)>6435.0) break; } return vec4(0.0); } 

O tamanho da etapa é definido como 20 km / 64. no caso da direção estritamente vertical do feixe do observador, faremos 64 amostras. No entanto, quando essa direção for mais horizontal, as amostras serão um pouco maiores, portanto não haverá 64 etapas no ciclo, mas 128 com uma margem.

No início, assumimos que a cor final é preta e a transparência é unidade. A cada passo, aumentaremos o valor da cor e diminuiremos o valor da transparência. Se a transparência estiver próxima de 0, você poderá pré-sair do loop:

 vec3 color=vec3(0.0); float transmittance=1.0; … //    //      float density=cloudSampleDensity(position)*avrStep; //   ,   //   float sunDensity=cloudSampleDirectDensity(position, sunDir); //      float m2=exp(-ufmProperties.attenuation*sunDensity); float m3=ufmProperties.attenuation2*density; float light=ufmProperties.sunIntensity*m2*m3; //       color+=sunColor*light*transmittance; transmittance*=exp(-ufmProperties.attenuation*density); … return vec4(color, 1.0-transmittance); 

ufmProperties.attenuation - Não há nada além de C em e ufmProperties.attenuation2 é C em . ufmProperties.sunIntensity - a intensidade da radiação do sol. sunColor - a cor do sol.

Resultado:


Uma falha é imediatamente evidente - sombreamento intenso. Mas agora vamos corrigir a falta de iluminação amplificada perto do sol. Isso aconteceu porque não adicionamos uma função de fase. Para calcular a dispersão da luz que passa pelas nuvens, usamos a fase da função Hengy-Greenstein, que a abriu em 1941 para cálculos semelhantes em grupos de gases no espaço:


Uma digressão deve ser feita aqui. De acordo com o modelo de iluminação canônica, a função de fase deve ser uma. No entanto, na realidade, o resultado obtido não se adequa a ninguém e todo mundo usa funções de duas fases, e até combina seus valores de maneira especial. Também me concentrei em funções de duas fases, mas simplesmente adiciono seus valores. A função de primeira fase tem g perto de 1 e permite que você faça uma iluminação brilhante perto do sol. A função da segunda fase tem g próximo de 0,5 e permite que você faça uma diminuição gradual da iluminação em toda a esfera celeste.

Código atualizado:

 // cos(theta) float mu=max(0, dot(viewDir, sunDir)); float m11=ufmProperties.phaseInfluence*cloudPhaseFunction(mu, ufmProperties.eccentrisy); float m12=ufmProperties.phaseInfluence2*cloudPhaseFunction(mu, ufmProperties.eccentrisy2); float m2=exp(-ufmProperties.attenuation*sunDensity); float m3=ufmProperties.attenuation2*density; float light=ufmProperties.sunIntensity*(m11+m12)*m2*m3; 

ufmProperties.eccentrisy, ufmProperties.eccentrisy2 são valores de g

Resultado:


Agora você pode começar a luta com muito sombreamento. Está presente porque não levamos em conta a luz das nuvens circundantes e do céu, que está na vida real.

Eu resolvi esse problema assim:

 return vec4(color+ambientColor*ufmProperties.ambient, 1.0-transmittance); 

Onde ambientColor é a cor do céu na direção do feixe de observação, ufmProperties.ambient é o parâmetro de ajuste.

Resultado:


Resta resolver o último problema. Na vida real, quanto mais horizontal a visão é mantida, mais vemos um certo nevoeiro ou neblina que não nos permite ver objetos muito distantes. Isso também precisa ser refletido no código. Peguei o cosseno usual do ângulo do olhar e a função exponencial. Com base nisso, um certo coeficiente de mistura é calculado, o que permite a interpolação linear entre a cor resultante e a cor de fundo.

 float blending=1.0-exp(-max(0.0, dot(viewDir, vec3(0.0,1.0,0.0)))*ufmProperties.fog); blending=blending*blending*blending; return vec4(mix(ambientColor, color+ambientColor*ufmProperties.ambient, blending), 1.0-transmittance); 

ufmProperties.fog - para configuração manual.


Função Resumo:

 vec4 mainMarching(vec3 viewDir, vec3 sunDir, vec3 sunColor, vec3 ambientColor) { vec3 position; crossRaySphereOutFar(vec3(0.0, 6400.0, 0.0), viewDir, vec3(0.0), 6415.0, position); float avrStep=(6435.0-6415.0)/64.0; vec3 color=vec3(0.0); float transmittance=1.0; for(int i=0;i<128;i++) { float density=cloudSampleDensity(position)*avrStep; if(density>0.0) { float sunDensity=cloudSampleDirectDensity(position, sunDir); float mu=max(0.0, dot(viewDir, sunDir)); float m11=ufmProperties.phaseInfluence*cloudPhaseFunction(mu, ufmProperties.eccentrisy); float m12=ufmProperties.phaseInfluence2*cloudPhaseFunction(mu, ufmProperties.eccentrisy2); float m2=exp(-ufmProperties.attenuation*sunDensity); float m3=ufmProperties.attenuation2*density; float light=ufmProperties.sunIntensity*(m11+m12)*m2*m3; color+=sunColor*light*transmittance; transmittance*=exp(-ufmProperties.attenuation*density); } position+=viewDir*avrStep; if(transmittance<0.05 || length(position)>6435.0) break; } float blending=1.0-exp(-max(0.0, dot(viewDir, vec3(0.0,1.0,0.0)))*ufmProperties.fog); blending=blending*blending*blending; return vec4(mix(ambientColor, color+ambientColor*ufmProperties.ambient, blending), 1.0-transmittance); } 

Vídeo de demonstração:


Otimização e possíveis melhorias


Depois de implementar o algoritmo básico de renderização, o próximo problema é que ele funciona muito lentamente. Minha versão produziu 25 fps em full hd na radeon rx 480. As duas abordagens a seguir para resolver o problema foram sugeridas pelos próprios Guerrilla Games.

Desenhamos o que é realmente visível

A tela é dividida em blocos de 16x16 pixels de tamanho. Primeiro, o ambiente 3D usual é desenhado. Acontece que a maior parte do céu é coberta por montanhas ou objetos grandes. Portanto, você precisa executar o cálculo apenas nos blocos em que as nuvens não são bloqueadas por nada.

Reprojeção

Quando a câmera está parada, acontece que as nuvens em geral não podem ser atualizadas. No entanto, se a câmera se mover, isso não significa que precisamos atualizar a tela inteira. Tudo já está desenhado, você só precisa reconstruir a imagem de acordo com as novas coordenadas. Encontrar coordenadas antigas em novas, através das matrizes de projeção e visualização dos quadros atuais e anteriores, é chamada de projeção. Assim, no caso de troca de câmera, simplesmente transferimos as cores de acordo com as novas coordenadas. Nos casos em que essas coordenadas indicam fora da tela, as nuvens devem ser honestamente redesenhadas.

Atualização parcial

Não gosto da ideia de reprojeção, porque, com uma curva brusca da câmera, pode acontecer que as nuvens tenham que ser renderizadas para um terço da tela, o que pode causar atraso. Não sei como a Guerrilla Games lidou com isso, mas pelo menos no Horizon Zero Dawn, ao controlar o joystick, a câmera se move sem problemas e não há problemas com saltos agudos. Portanto, como um experimento, criei minha própria abordagem. As nuvens são desenhadas em um mapa cúbico, em 5 faces, porque o fundo não nos interessa.O lado do mapa cúbico tem uma resolução reduzida igual a ⅔ da altura da tela. Cada face do mapa cúbico é dividida em blocos 8x8. Cada quadro em cada face é atualizado com apenas um dos 64 pixels em cada bloco. Isso fornece artefatos visíveis durante mudanças repentinas, mas porque como as nuvens são estáticas, esse truque é invisível. Como resultado, a radeon rx 480 produz 500 fps em full hd para o vulcão e 330 fps para opengl. A série Radeon hd 5700 produz 109 fps em full hd em opengl (o vulkan não suporta).

Usando níveis de mip

Ao acessar texturas com ruído, você pode coletar dados do nível de mip zero apenas nas primeiras amostras e, quanto mais as amostras que fazemos, maior o nível de mip.

Nuvens altas

Para simular a presença de nuvens cirrus-altitude e cirrocumulus nos Jogos de Guerrilha durante a integração, as amostras mais recentes são feitas não das texturas 3D de que falei, mas de uma textura 2D especial.


Ruído de

curvatura Várias texturas adicionais no ruído de curvatura são usadas para criar o efeito das nuvens de vento. Essas texturas são necessárias para mudar as coordenadas originais.


Raios divinos


Tais raios, captando dramas, são realizados no pós-processamento. Primeiro, uma iluminação brilhante é desenhada ao redor do sol, onde não é bloqueada pelas nuvens. Então essa luz de fundo deve ser deslocada radialmente para longe do sol.


Agora você precisa aplicar a suavização radial.


De fato, existem muito mais melhorias e sutilezas, mas eu não verifiquei todas elas, então não posso contar com confiança sobre elas. No entanto, você pode se familiarizar com eles. O mais forte que eu acho é a documentação em nuvem do mecanismo Frostbite.

Links úteis


Guerrilla Games
d1z4o56rleaq4j.cloudfront.net/downloads/assets/Nubis-Authoring-Realtime-Volumetric-Cloudscapes-with-the-Decima-Engine-Final.pdf?mtime=20170807141817
killzone.dl.playstation.net/killzone/horizonzerodawn/presentations/Siggraph15_Schneider_Real-Time_Volumetric_Cloudscapes_of_Horizon_Zero_Dawn.pdf
www.youtube.com/watch?v=-d8qT5-1LOI

GPU Pro 7
vk.com/doc179245989_437393482?hash=a9af5f665eda4edf58&dl=806d4dbdac0f7a761c


www.scratchapixel.com/lessons/procedural-generation-virtual-worlds/simulating-sky/simulating-colors-of-the-sky

Frostbite
media.contentapi.ea.com/content/dam/eacom/frostbite/files/s2016-pbs-frostbite-sky-clouds-new.pdf
www.shadertoy.com/view/XlBSRz

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


All Articles