Algoritmo de renderização de volume rápido e fácil


Recentemente, escrevi um pequeno ShaderToy que faz renderização volumétrica simples e depois decidi publicar um post explicando seu trabalho. O próprio ShaderToy interativo pode ser visto aqui . Se você estiver lendo de um telefone ou laptop, recomendo assistir a esta versão rápida. Incluí trechos de código na postagem que ajudarão você a entender o desempenho do ShaderToy em alto nível, mas eles não têm todos os detalhes. Se você quiser ir mais fundo, recomendo verificar com o código ShaderToy.

Meu ShaderToy tinha três tarefas principais:

  1. Execução em tempo real
  2. Simplicidade
  3. Correção física (... ou algo assim)

Vou começar com esta cena de código em branco. Não vou entrar em detalhes da implementação, porque não é muito interessante, mas vou lhe dizer brevemente por onde começar:

  1. Traçado de raio de objetos opacos. Todos os objetos são primitivos com interseções simples com raios (1 plano e 3 esferas)
  2. Para calcular a iluminação, o sombreamento Phong é usado e, em três fontes de luz esféricas, é usado um coeficiente de atenuação da luz personalizado. Raios de sombras não são necessários, porque iluminamos apenas o plano.

Aqui está o que parece:

Imagem de ShaderToy

Renderizaremos o volume como uma passagem separada que se mistura com uma cena opaca; isso é semelhante ao modo como todos os mecanismos de renderização em tempo real processam individualmente superfícies opacas e translúcidas.

Parte 1: simular volume


Mas primeiro, antes que possamos iniciar a renderização volumétrica, precisamos desse mesmo volume! Para simular o volume, decidi usar as funções de distância assinada (SDF). Por que precisamente as funções dos campos de distância? Porque eu não sou um artista, mas eles permitem que você crie formas muito orgânicas em apenas algumas linhas de código. Não falarei em detalhes sobre as funções das distâncias com um sinal, porque Inigo Kiles já as explicou maravilhosamente. Se você estiver curioso, existe uma ótima lista de funções diferentes de distâncias e modificadores de sinal. E aqui está outro artigo sobre esses SDF raymarching.

Vamos começar com um simples e adicionar uma esfera aqui:

Imagem de ShaderToy

Agora vamos adicionar outra esfera e usar conjugação suave para mesclar as funções de distância das esferas. Este código eu peguei diretamente da página do Inigo, mas para maior clareza, vou inseri-lo aqui:

// Taken from https://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float sdSmoothUnion( float d1, float d2, float k ) 
{
    float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
    return mix( d2, d1, h ) - k*h*(1.0-h); 
}

O emparelhamento suave é uma ferramenta extremamente poderosa, porque você pode obter algo bastante interessante simplesmente combinando-o com algumas formas simples. Veja como são minhas muitas esferas suavemente conjugadas:

Imagem de ShaderToy

Então, temos algo em forma de lágrima, mas precisamos de algo mais parecido com uma nuvem do que com uma gota. Um ótimo recurso do SDF é como é fácil distorcer a superfície simplesmente adicionando um pouco de ruído ao SDF. Então, vamos adicionar um movimento browniano fractal (fBM) sobre o ruído, usando a posição para indexar a função de ruído. Inigo Kiles também abordou esse tópico em um ótimo artigo sobre ruído fBM. Aqui está a aparência da imagem com ruído fBM sobreposta:

Imagem de ShaderToy

Bem! Graças ao ruído fBM, o objeto de repente começou a parecer muito mais interessante!

Agora precisamos criar a ilusão de que o volume interage com o plano da terra. Para fazer isso, adicionei uma distância do plano assinado ligeiramente abaixo do plano do solo e reutilizei a combinação de emparelhamento suave com um valor de emparelhamento muito agressivo (parâmetro k). Depois disso, temos esta imagem:

Imagem de ShaderToy

O toque final será a alteração no índice xz do ruído fBM ao longo do tempo, para que o volume pareça uma névoa em turbilhão. Em movimento, parece muito bom!

Imagem de ShaderToy

Ótimo, conseguimos algo como uma nuvem! O código de cálculo SDF também é bastante compacto:

float QueryVolumetricDistanceField( in vec3 pos)
{    
    vec3 fbmCoord = (pos + 2.0 * vec3(iTime, 0.0, iTime)) / 1.5f;
    float sdfValue = sdSphere(pos, vec3(-8.0, 2.0 + 20.0 * sin(iTime), -1), 5.6);
    sdfValue = sdSmoothUnion(sdfValue,sdSphere(pos, vec3(8.0, 8.0 + 12.0 * cos(iTime), 3), 5.6), 3.0f);
    sdfValue = sdSmoothUnion(sdfValue, sdSphere(pos, vec3(5.0 * sin(iTime), 3.0, 0), 8.0), 3.0) + 7.0 * fbm_4(fbmCoord / 3.2);
    sdfValue = sdSmoothUnion(sdfValue, sdPlane(pos + vec3(0, 0.4, 0)), 22.0);
    return sdfValue;
}

Isso está apenas renderizando um objeto opaco. Precisamos de uma bela névoa magnífica!

Como a renderizamos na forma de volume, e não em um objeto opaco? Vamos primeiro falar sobre a física que simulamos. O volume é um grande número de partículas em uma determinada área do espaço. E quando digo "enorme", quero dizer "ENORME". Tanto é que modelar cada uma dessas partículas hoje em dia é uma tarefa impossível, mesmo para renderização offline. Bons exemplos disso são fogo, nevoeiro e nuvens. A rigor, tudo é volume, mas, por uma questão de velocidade dos cálculos, é mais fácil fechar os olhos para isso e fingir que não é. Representamos o acúmulo dessas partículas como valores de densidade que geralmente são armazenados em algum tipo de grade 3D (ou algo mais complexo, por exemplo, no OpenVDB).

Quando a luz passa através de um volume, um par de fenômenos pode ocorrer quando a luz colide com uma partícula. Ele pode se espalhar e seguir na outra direção, ou parte da luz pode ser absorvida pela partícula e dissolver-se. Para cumprir o requisito de execução em tempo real, executaremos o que é chamado de dispersão única. Isso significa o seguinte: assumiremos que a luz é dispersa apenas uma vez, quando a luz colide com uma partícula e voa em direção à câmera. Ou seja, não seremos capazes de simular os efeitos da dispersão múltipla, por exemplo, neblina, na qual objetos à distância geralmente parecem mais vagos. Mas para o nosso sistema, isso é suficiente. Veja como é a dispersão única ao raymarching:

Imagem de ShaderToy

O pseudocódigo para ele se parece com isso:

for n steps along the camera ray:
   Calculate what % of your ray hit particles (i.e. were absorbed) and needs lighting
   for m lights:
      for k steps towards the light:
         Calculate % of light that were absorbe in this step
      Calculate lighting based on how much light is visible
Blend results on top of opaque objects pass based on % of your ray that made it through the volume

Ou seja, estamos lidando com cálculos com complexidade O (n * m * k). Portanto, a GPU terá que trabalhar duro.

Nós calculamos a absorção


Primeiro, vejamos a absorção de luz em volume ao longo do feixe da câmera (ou seja, ainda não realizamos marcações de raios na direção das fontes de luz). Para fazer isso, precisamos de duas ações:

  1. Realize raymarching dentro do volume
  2. Calcular a absorção / iluminação em cada etapa

Para calcular a quantidade de luz absorvida em cada ponto, usamos a lei Bouguer - Lambert - Beer , que descreve a atenuação da luz ao passar por um material. Os cálculos são surpreendentemente simples:

float BeerLambert(float absorptionCoefficient, float distanceTraveled)
{
    return exp(-absorptionCoefficient * distanceTraveled);
}

O coeficiente de absorção é um parâmetro do material. Por exemplo, em um volume transparente, por exemplo, em água, esse valor será baixo e, para algo mais espesso, por exemplo, leite, o coeficiente será maior.

Para realizar a marcação de raio de volume, simplesmente executamos etapas de tamanho fixo ao longo do feixe e obtemos absorção a cada etapa. Você pode não entender por que executar etapas fixas em vez de algo mais rápido, por exemplo, rastrear uma esfera, mas se você lembrar que a densidade dentro do volume é heterogênea, tudo ficará claro. Abaixo está o código de raymarching e absorção de acumulação. Algumas variáveis ​​estão fora do escopo deste trecho de código, portanto, confira a implementação completa no ShaderToy.

float opaqueVisiblity = 1.0f;
const float marchSize = 0.6f;
for(int i = 0; i < MAX_VOLUME_MARCH_STEPS; i++) {
	volumeDepth += marchSize;
	if(volumeDepth > opaqueDepth) break;
	
	vec3 position = rayOrigin + volumeDepth*rayDirection;
	bool isInVolume = QueryVolumetricDistanceField(position) < 0.0f;
	if(isInVolume) 	{
		float previousOpaqueVisiblity = opaqueVisiblity;
		opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
		float absorptionFromMarch = previousOpaqueVisiblity - opaqueVisiblity;
		for(int lightIndex = 0; lightIndex < NUM_LIGHTS; lightIndex++) {
			float lightDistance = length((GetLight(lightIndex).Position - position));
			vec3 lightColor = GetLight(lightIndex).LightColor * GetLightAttenuation(lightDistance);  
			volumetricColor += absorptionFromMarch * volumeAlbedo * lightColor;
		}
		volumetricColor += absorptionFromMarch * volumeAlbedo * GetAmbientLight();
	}
}

E aqui está o que obtemos com isso:

Imagem de ShaderToy

Parece algodão doce! Talvez para alguns efeitos isso seja suficiente! Mas não temos auto-sombra. A luz atinge todas as partes do volume igualmente. Mas isso não é fisicamente correto, dependendo do tamanho do volume entre o ponto renderizado e a fonte de luz, receberemos uma quantidade diferente de luz recebida.

Auto-sombreamento


Já fizemos o mais difícil. Precisamos fazer o mesmo que fizemos para calcular a absorção ao longo do feixe da câmera, mas apenas ao longo do feixe de luz. O código para calcular a quantidade de luz que atinge cada ponto será essencialmente uma repetição do código, mas duplicá-lo é mais fácil do que invadir o HLSL para obter a recursão de que precisamos. Então, aqui está como será:

float GetLightVisiblity(in vec3 rayOrigin, in vec3 rayDirection, in float maxT, in int maxSteps, in float marchSize) {
    float t = 0.0f;
    float lightVisiblity = 1.0f;
    for(int i = 0; i < maxSteps; i++) {                       
        t += marchSize;
        if(t > maxT) break;

        vec3 position = rayOrigin + t*rayDirection;
        if(QueryVolumetricDistanceField(position) < 0.0) {
            lightVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
        }
    }
    return lightVisiblity;
}

Adicionar auto-sombreamento nos dá o seguinte:

Imagem de ShaderToy

Suavizar as bordas


No momento, eu já gosto bastante do nosso volume. Eu o mostrei ao talentoso líder do departamento de efeitos visuais da The Coalition, James Sharp. Ele imediatamente notou que as margens do volume pareciam muito nítidas. E isso é absolutamente verdade - objetos como nuvens estão constantemente espalhados no espaço ao seu redor, então suas bordas se misturam com o espaço vazio ao redor do volume, o que deve levar à criação de bordas muito suaves. James me ofereceu uma ótima idéia - reduzir a densidade dependendo de quão perto estamos da borda. E como estamos trabalhando com funções de distância com um sinal, é muito fácil de implementar! Então, vamos adicionar uma função que pode ser usada para solicitar densidade em qualquer ponto do volume:

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
    return sdfMultiplier;
}

E então simplesmente reduzimos o valor de absorção:

opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT * GetFogDensity(position), marchSize);

E aqui está o que parece:

Imagem de ShaderToy

Função de densidade


Agora que temos a função de densidade, você pode facilmente adicionar um pouco de ruído ao volume para fornecer detalhes e esplendor adicionais. Nesse caso, apenas reutilizo a função fBM que usamos para ajustar a forma do volume.

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
   return sdfMultiplier * abs(fbm_4(position / 6.0) + 0.5);
}

E assim temos o seguinte:

Imagem de ShaderToy

Auto-sombreamento opaco


O volume já parece bem bonito! Mas um pouco de luz ainda vaza através dele. Aqui vemos como a cor verde escoa onde o volume deve definitivamente absorvê-lo:

Imagem de ShaderToy

Isso acontece porque objetos opacos são renderizados antes do volume ser processado, portanto, eles não levam em consideração o sombreamento causado pelo volume. Isso é bastante simples de corrigir - temos uma função GetLightVisiblity que pode ser usada para calcular o sombreamento, portanto, basta chamá-lo para iluminar um objeto opaco. Temos o seguinte:

Imagem de ShaderToy

Além de criar belas sombras multicoloridas, isso ajuda a melhorar as sombras e aumentar o volume da cena. Além disso, graças às margens suaves do volume, obtemos sombras suaves, apesar de, a rigor, trabalharmos com fontes pontuais de iluminação. Isso é tudo! Muito mais pode ser feito aqui, mas parece-me que atingi a qualidade visual necessária, preservando a relativa simplicidade do exemplo.

Otimizações


No final, listarei brevemente algumas possíveis otimizações:

  1. Antes de realizar a marcação de raios na direção da fonte de luz, é necessário verificar pelo valor da extinção da luz se uma quantidade significativa dessa luz realmente atinge o ponto em questão. Na minha implementação, observo o brilho da luz, multiplicado pelo albedo do material, e certifico-me de que o valor seja grande o suficiente para que o raymarching seja executado.
  2. , , raymarching
  3. raymarching . , . , raymarching , .


Isso é tudo! Pessoalmente, fiquei surpreso que você possa criar algo fisicamente correto em uma quantidade tão pequena de código (cerca de 500 linhas). Obrigado pela leitura, espero que tenha sido interessante.

E mais uma observação: aqui está uma mudança divertida - adicionei emissão de luz com base na distância SDF para criar um efeito de explosão. Afinal, as explosões nunca são muitas.

Imagem de ShaderToy

All Articles