Agora que sabemos o básico da combinação de funções de distância assinada, você pode usá-las para criar coisas legais. Neste tutorial, vamos usá-los para renderizar sombras bidimensionais suaves. Se você não leu meus tutoriais anteriores sobre campos de distância assinados (SDF), recomendo que você os estude, começando com um
tutorial sobre como criar formas simples .
[GIF gerou artefatos adicionais durante a recompressão.]
Configuração básica
Eu criei uma configuração simples com uma sala, ele usa as técnicas descritas nos tutoriais anteriores. Anteriormente, não mencionei que usei a função
abs
do vetor2 para espelhar a posição em relação aos eixos xey, além de inverter a distância da figura para trocar as partes interna e externa.
Vamos copiar o arquivo
2D_SDF.cginc do tutorial anterior em uma pasta com o shader, que iremos escrever neste tutorial.
Shader "Tutorial/037_2D_SDF_Shadows"{ Properties{ } SubShader{
Se ainda utilizássemos a técnica de visualização do tutorial anterior, a figura ficaria assim:
Sombras simples
Para criar sombras nítidas, percorremos o espaço desde a posição da amostra até a posição da fonte de luz. Se encontrarmos um objeto no caminho, decidimos que o pixel deve ser sombreado e, se chegarmos à fonte sem impedimentos, dizemos que não é sombreado.
Começamos calculando os parâmetros básicos do feixe. Já temos um ponto de partida (a posição do pixel que estamos renderizando) e um ponto de destino (a posição da fonte de luz) para o feixe. Precisamos de um comprimento e uma direção normalizada. A direção pode ser obtida subtraindo o início do final e normalizando o resultado. O comprimento pode ser obtido subtraindo as posições e passando o valor ao método de
length
.
float traceShadow(float2 position, float2 lightPosition){ float direction = normalise(lightPosition - position); float distance = length(lightPosition - position); }
Então, iterativamente, contornamos o raio no loop. Definiremos as iterações do loop na declaração de definição, e isso permitirá configurar o número máximo de iterações posteriormente, além de permitir que o compilador otimize um pouco o shader expandindo o loop.
No loop, precisamos da posição em que estamos agora, então o declaramos fora do loop com o valor inicial 0. No loop, podemos calcular a posição da amostra adicionando o avanço do feixe multiplicado pela direção do feixe com a posição base. Depois, amostramos a função de distância assinada na posição recém-calculada.
Depois, verificamos se já estamos no ponto em que podemos parar o ciclo. Se a distância da cena da função de distância com o sinal for próxima de 1, podemos assumir que o feixe está bloqueado por uma figura e retornar 0. Se o feixe se espalhar mais longe do que a distância da fonte de luz, podemos assumir que alcançamos a fonte sem colisões e retornamos o valor 1
Se o retorno não tiver sido concluído, a próxima posição da amostra deve ser calculada. Isso é feito adicionando a distância na cena de avanço do feixe. A razão para isso é que a distância na cena nos dá a distância da figura mais próxima; portanto, se adicionarmos esse valor à viga, provavelmente não seremos capazes de emitir a viga além da figura mais próxima ou mesmo além dela, o que levará ao fluxo de sombras.
No caso de não encontrarmos nada e não alcançarmos a fonte de luz no momento em que o estoque da amostra foi concluído (o ciclo terminou), também precisamos retornar o valor. Como isso ocorre principalmente próximo às formas, pouco antes do pixel ainda ser considerado sombreado, aqui usamos o valor de retorno 0.
#define SAMPLES 32 float traceShadows(float2 position, float2 lightPosition){ float2 direction = normalize(lightPosition - position); float lightDistance = length(lightPosition - position); float rayProgress = 0; for(int i=0 ;i<SAMPLES; i++){ float sceneDist = scene(position + direction * rayProgress); if(sceneDist <= 0){ return 0; } if(rayProgress > lightDistance){ return 1; } rayProgress = rayProgress + sceneDist; } return 0; }
Para usar essa função, chamamos de função de fragmento com a posição do pixel e a posição da fonte de luz. Em seguida, multiplicamos o resultado por qualquer cor para misturá-lo com a cor das fontes de luz.
Também usei a técnica descrita no
primeiro tutorial sobre campos de distância com um sinal para visualizar a geometria. Então eu apenas adicionei dobrado e geometria. Aqui, podemos apenas usar a operação de adição, e não executar interpolação linear ou ações semelhantes, porque a forma é preta em todos os lugares onde a forma não é e a sombra é preta em todos os lugares onde a forma está.
fixed4 frag(v2f i) : SV_TARGET{ float2 position = i.worldPos.xz;
float2 lightPos; sincos(_Time.y, lightPos.x , lightPos.y ); float shadows = traceShadows(position, lightPos); float3 light = shadows * float3(.6, .6, 1); float sceneDistance = scene(position); float distanceChange = fwidth(sceneDistance) * 0.5; float binaryScene = smoothstep(distanceChange, -distanceChange, sceneDistance); float3 geometry = binaryScene * float3(0, 0.3, 0.1); float3 col = geometry + light; return float4(col, 1); }
Sombras suaves
Passar dessas sombras ásperas para mais suaves e mais realistas é bastante fácil. Ao mesmo tempo, o shader não se torna muito mais caro computacionalmente.
Primeiro, simplesmente obtemos a distância do objeto de cena mais próximo para cada amostra que contornamos e selecionamos o mais próximo. Então, onde costumávamos retornar 1, será possível retornar a distância para a figura mais próxima. Para que o brilho da sombra não seja muito alto e não leve à criação de cores estranhas, passaremos pelo método
saturate
, que o limita a um intervalo de 0 a 1. Obtemos um mínimo entre a figura mais próxima atual e a próxima depois de verificar se o feixe da fonte de luz já alcançou a distribuição caso contrário, podemos coletar amostras que vão além da fonte de luz e obtêm artefatos estranhos.
float traceShadows(float2 position, float2 lightPosition){ float2 direction = normalize(lightPosition - position); float lightDistance = length(lightPosition - position); float rayProgress = 0; float nearest = 9999; for(int i=0 ;i<SAMPLES; i++){ float sceneDist = scene(position + direction * rayProgress); if(sceneDist <= 0){ return 0; } if(rayProgress > lightDistance){ return saturate(nearest); } nearest = min(nearest, sceneDist); rayProgress = rayProgress + sceneDist; } return 0; }

A primeira coisa que notamos depois disso são os estranhos "dentes" nas sombras. Eles surgem porque a distância da cena até a fonte de luz é menor que 1. Tentei contrariar isso de várias maneiras, mas não consegui encontrar uma solução. Em vez disso, podemos implementar a nitidez da sombra. Nitidez será outro parâmetro na função de sombra. No loop, multiplicamos a distância na cena pela nitidez e, em seguida, com uma nitidez de 2, a parte cinza e suave da sombra se tornará a metade. Ao usar a nitidez, a fonte de luz pode estar na figura a uma distância de pelo menos 1 dividida pela nitidez, caso contrário, os artefatos aparecerão. Portanto, se você usar uma nitidez de 20, a distância deverá ser de pelo menos 0,05 unidades.
float traceShadows(float2 position, float2 lightPosition, float hardness){ float2 direction = normalize(lightPosition - position); float lightDistance = length(lightPosition - position); float rayProgress = 0; float nearest = 9999; for(int i=0 ;i<SAMPLES; i++){ float sceneDist = scene(position + direction * rayProgress); if(sceneDist <= 0){ return 0; } if(rayProgress > lightDistance){ return saturate(nearest); } nearest = min(nearest, hardness * sceneDist); rayProgress = rayProgress + sceneDist; } return 0; }
Ao minimizar esse problema, notamos o seguinte: mesmo em áreas que não devem ser sombreadas, o enfraquecimento ainda é visível perto das paredes. Além disso, a suavidade da sombra parece ser a mesma para toda a sombra e não é nítida ao lado da figura e mais suave ao se afastar do objeto que emite a sombra.
Vamos resolver isso dividindo a distância na cena pela propagação do feixe. Graças a isso, dividiremos a distância em números muito pequenos no início do feixe, ou seja, ainda obteremos valores altos e uma bela sombra clara. Quando encontramos o ponto mais próximo do raio em pontos subseqüentes, o ponto mais próximo é dividido por um número maior, o que torna a sombra mais suave. Como isso não está totalmente relacionado à menor distância, renomearemos a variável como
shadow
.
Também faremos mais uma pequena alteração: como estamos dividindo por rayProgress, você não deve começar com 0 (dividir por zero é quase sempre uma má ideia). Para começar, você pode escolher qualquer número muito pequeno.
float traceShadows(float2 position, float2 lightPosition, float hardness){ float2 direction = normalize(lightPosition - position); float lightDistance = length(lightPosition - position); float rayProgress = 0.0001; float shadow = 9999; for(int i=0 ;i<SAMPLES; i++){ float sceneDist = scene(position + direction * rayProgress); if(sceneDist <= 0){ return 0; } if(rayProgress > lightDistance){ return saturate(shadow); } shadow = min(shadow, hardness * sceneDist / rayProgress); rayProgress = rayProgress + sceneDist; } return 0; }
Várias fontes de iluminação
Nesta implementação simples de núcleo único, a maneira mais fácil de obter várias fontes de luz é calculá-las individualmente e adicionar os resultados.
fixed4 frag(v2f i) : SV_TARGET{ float2 position = i.worldPos.xz; float2 lightPos1 = float2(sin(_Time.y), -1); float shadows1 = traceShadows(position, lightPos1, 20); float3 light1 = shadows1 * float3(.6, .6, 1); float2 lightPos2 = float2(-sin(_Time.y) * 1.75, 1.75); float shadows2 = traceShadows(position, lightPos2, 10); float3 light2 = shadows2 * float3(1, .6, .6); float sceneDistance = scene(position); float distanceChange = fwidth(sceneDistance) * 0.5; float binaryScene = smoothstep(distanceChange, -distanceChange, sceneDistance); float3 geometry = binaryScene * float3(0, 0.3, 0.1); float3 col = geometry + light1 + light2; return float4(col, 1); }
Código fonte
Biblioteca SDF bidimensional (não foi alterada, mas é usada aqui)
Sombras suaves bidimensionais
Shader "Tutorial/037_2D_SDF_Shadows"{ Properties{ } SubShader{
Este é apenas um dos muitos exemplos de uso de campos de distância assinados. Até agora, eles são bastante complicados, porque todas as formas precisam ser registradas no shader ou passadas pelas propriedades do shader, mas tenho algumas idéias sobre como torná-las mais convenientes para futuros tutoriais.