Minha última tarefa em gráficos técnicos / renderização foi encontrar uma boa solução para renderizar água. Em particular, a renderização de jatos finos e velozes de água com base em partículas. Na semana passada, pensei em bons resultados, por isso vou escrever um artigo sobre isso.
Eu realmente não gosto da abordagem de cubos voxelizados / marcadores ao renderizar água (veja, por exemplo, renderizar uma simulação de fluido no Blender). Quando o volume de água está na mesma escala da grade usada para renderização, o movimento é notavelmente discreto. Esse problema pode ser resolvido aumentando a resolução da grade, mas para jatos finos em distâncias relativamente longas em tempo real é simplesmente impraticável, pois afeta muito o tempo de execução e a memória ocupada. (Existe um precedente para o uso de estruturas esparsas de voxel para melhorar a situação. Mas não tenho certeza de como isso funciona em sistemas dinâmicos. Além disso, esse não é o nível de dificuldade com o qual gostaria de trabalhar.)
A primeira alternativa que eu explorei foi a Screen Space Malhes de Müller. Eles usam a renderização de partículas de água em um buffer de profundidade, suavizando-o, reconhecendo fragmentos conectados de profundidade semelhante e construindo uma malha a partir do resultado usando quadrados de marcha. Hoje, esse método provavelmente se tornou
mais aplicável do que em 2007 (já que agora podemos criar uma malha no shader de computação), mas ainda está associado a um nível maior de complexidade e custo do que eu gostaria.
No final, encontrei a apresentação de Simon Green com a GDC 2010, Screen Space Fluid Rendering For Games. Começa exatamente da mesma maneira que as malhas do espaço da tela: renderizando partículas no buffer de profundidade e suavizando-o. Mas, em vez de construir a malha, o buffer resultante é usado para sombrear e compor o líquido na cena principal (registrando explicitamente a profundidade.) Decidi implementar esse sistema.
Preparação
Vários projetos anteriores do Unity me ensinaram a não lidar com as limitações de renderização do mecanismo. Portanto, os buffers de fluido são renderizados por uma segunda câmera com uma profundidade de campo mais rasa, para renderizar na frente da cena principal. Cada sistema de fluido existe em uma camada de renderização separada; a câmara principal exclui uma camada de água e a segunda câmara processa apenas água. Ambas as câmeras são filhos de um objeto vazio para garantir sua orientação relativa.
Esse esquema significa que eu posso renderizar quase tudo na camada líquida, e parece que eu espero que seja. No contexto da minha cena demo, isso significa que alguns jatos e respingos de sub-emissores podem se fundir. Além disso, isso permitirá a mistura de outros sistemas de água, por exemplo, volumes baseados em campos de altitude, que podem ser renderizados da mesma forma. (Ainda não testei isso.)
A fonte de água na minha cena é um sistema de partículas padrão. De fato, nenhuma simulação de fluido é realizada. Isso, por sua vez, significa que as partículas não se sobrepõem de uma maneira completamente física, mas o resultado final parece aceitável na prática.
Renderização de buffer de fluido
O primeiro passo nesta técnica é renderizar o buffer do fluido base. Este é um buffer fora da tela que contém (no estágio atual da minha implementação) o seguinte: largura do fluido, vetor de movimento no espaço na tela e valor do ruído. Além disso, renderizamos o buffer de profundidade gravando explicitamente a profundidade do shader de fragmento para transformar cada quadrilátero de uma partícula em uma "bola" esférica (bem, na verdade elíptica).
Os cálculos de profundidade e largura são bastante simples:
frag_out o; float3 N; N.xy = i.uv*2.0 - 1.0; float r2 = dot(N.xy, N.xy); if (r2 > 1.0) discard; Nz = sqrt(1.0 - r2); float4 pixel_pos = float4(i.view_pos + N * i.size, 1.0); float4 clip_pos = mul(UNITY_MATRIX_P, pixel_pos); float depth = clip_pos.z / clip_pos.w; o.depth = depth; float thick = Nz * i.size * 2;
(É claro que os cálculos de profundidade podem ser simplificados; da posição do clipe, precisamos apenas de z e w.)
Um pouco mais tarde, retornaremos ao shader de fragmento para os vetores de movimento e ruído.
A diversão começa no sombreador de vértices, e é aqui que eu me afasto da técnica de Green. O objetivo deste projeto é produzir jatos de água de alta velocidade; isso pode ser realizado com a ajuda de partículas esféricas, mas uma grande quantidade delas será necessária para criar um jato contínuo. Em vez disso, esticarei os quadrângulos das partículas com base em sua velocidade, que, por sua vez, estica as esferas de profundidade, tornando-as não esféricas, mas elípticas. (Como os cálculos de profundidade são baseados em UV, que não mudam, tudo funciona.)
Usuários experientes do Unity podem se perguntar por que eu simplesmente não uso o modo de outdoor estendido disponível no sistema de partículas do Unity. Quadro de avisos esticado realiza alongamentos incondicionais ao longo do vetor de velocidade no espaço do mundo. No caso geral, isso é bastante adequado, mas leva a um problema muito perceptível quando o vetor de velocidade é co-direcionado com o vetor de câmera voltado para a frente (ou muito próximo a ele). O outdoor se estende na tela, o que torna sua natureza bidimensional muito perceptível.
Em vez disso, uso um quadro de avisos voltado para a câmera e projeto o vetor de velocidade no plano da partícula, usando-o para esticar o quadrilátero. Se o vetor de velocidade é perpendicular ao plano (direcionado para a tela ou para longe dele), a partícula permanece não esticada e esférica, como deveria, e quando é inclinada, a partícula é esticada nessa direção, que é o que precisamos.
Vamos deixar uma longa explicação, aqui está uma função bastante simples:
float3 ComputeStretchedVertex(float3 p_world, float3 c_world, float3 vdir_world, float stretch_amount) { float3 center_offset = p_world - c_world; float3 stretch_offset = dot(center_offset, vdir_world) * vdir_world; return p_world + stretch_offset * lerp(0.25f, 3.0f, stretch_amount); }
Para calcular o vetor de movimento do espaço da tela, calculamos dois conjuntos de posições de vetores:
float3 vp1 = ComputeStretchedVertex( vertex_wp, center_wp, velocity_dir_w, rand); float3 vp0 = ComputeStretchedVertex( vertex_wp - velocity_w * unity_DeltaTime.x, center_wp - velocity_w * unity_DeltaTime.x, velocity_dir_w, rand); o.motion_0 = mul(_LastVP, float4(vp0, 1.0)); o.motion_1 = mul(_CurrVP, float4(vp1, 1.0));
Observe que, como computamos vetores de movimento na passagem principal e não na passagem de vetores de velocidade, o Unity não nos fornece uma projeção de corrente anterior ou sem distorção da vista. Para corrigir isso, adicionei um script simples aos sistemas de partículas correspondentes:
public class ScreenspaceLiquidRenderer : MonoBehaviour { public Camera LiquidCamera; private ParticleSystemRenderer m_ParticleRenderer; private bool m_First; private Matrix4x4 m_PreviousVP; void Start() { m_ParticleRenderer = GetComponent(); m_First = true; } void OnWillRenderObject() { Matrix4x4 current_vp = LiquidCamera.nonJitteredProjectionMatrix * LiquidCamera.worldToCameraMatrix; if (m_First) { m_PreviousVP = current_vp; m_First = false; } m_ParticleRenderer.material.SetMatrix("_LastVP", GL.GetGPUProjectionMatrix(m_PreviousVP, true)); m_ParticleRenderer.material.SetMatrix("_CurrVP", GL.GetGPUProjectionMatrix(current_vp, true)); m_PreviousVP = current_vp; } }
Coloco em cache a matriz anterior manualmente porque Camera.previousViewProjectionMatrix fornece resultados incorretos.
¯ \ _ (ツ) _ / ¯
(Além disso, esse método viola a renderização; pode ser prudente definir constantes da matriz global na prática, em vez de usá-las para cada material.)
Vamos voltar ao shader de fragmento: usamos as posições projetadas para calcular os vetores de movimento do espaço da tela:
float3 hp0 = i.motion_0.xyz / i.motion_0.w; float3 hp1 = i.motion_1.xyz / i.motion_1.w; float2 vp0 = (hp0.xy + 1) / 2; float2 vp1 = (hp1.xy + 1) / 2; #if UNITY_UV_STARTS_AT_TOP vp0.y = 1.0 - vp0.y; vp1.y = 1.0 - vp1.y; #endif float2 vel = vp1 - vp0;
(O cálculo dos vetores de movimento é praticamente inalterado em
https://github.com/keijiro/ParticleMotionVector/blob/master/Assets/ParticleMotionVector/Shaders/Motion.cginc )
Finalmente, o último valor no buffer de fluido é ruído. Uso um número aleatório estável para cada partícula para selecionar um dos quatro ruídos (agrupados em uma única textura). Em seguida, é dimensionado pela velocidade e pela unidade, menos o tamanho das partículas (portanto, partículas pequenas e rápidas são mais ruidosas). Esse valor de ruído é usado no passe de sombreamento para distorcer os normais e adicionar uma camada de espuma. O trabalho de Green usa ruído branco de três canais, mas um trabalho mais recente (Renderização de fluidos no espaço da tela com fluxo de curvatura) propõe usar o ruído Perlin. Uso o ruído Voronoi / ruído celular em diferentes escalas:
Problemas de mistura (e soluções alternativas)
E aqui aparecem os primeiros problemas da minha implementação. Para o cálculo correto da espessura das partículas são misturados aditivamente. Como a mistura afeta toda a saída, isso significa que os vetores de ruído e movimento também são misturados de maneira aditiva. O ruído aditivo nos convém bastante, mas não os vetores aditivos, e se você deixá-los como estão, você obtém um tempo repugnante de anti-aliasing (TAA) e desfoque de movimento. Para resolver esse problema, ao renderizar um buffer de fluido, simplesmente multiplico os vetores de movimento pela espessura e divido pela espessura total no passo de sombreamento. Isso nos fornece um vetor de movimento médio ponderado para todas as partículas sobrepostas; não exatamente o que precisamos (artefatos estranhos são criados quando vários jatos se cruzam), mas bastante aceitável.
Um problema mais complexo é a profundidade; Para uma renderização adequada do buffer de profundidade, precisamos ter o registro e a verificação de profundidade ativos. Isso pode causar problemas se as partículas não forem classificadas (porque a diferença na ordem de renderização pode fazer com que a saída de partículas sobrepostas por outras pessoas seja cortada). Portanto, ordenamos que o sistema de partículas da Unidade classifique as partículas por profundidade e depois cruzamos os dedos e esperamos. que os sistemas também renderizarão em profundidade. Teremos * casos * de sistemas sobrepostos (por exemplo, a interseção de dois jatos de partículas) que não são processados corretamente, o que levará a uma espessura menor. Mas isso não acontece com muita frequência e não afeta muito a aparência.
Muito provavelmente, a abordagem correta seria separar completamente os buffers de profundidade e cor; o retorno para isso será a renderização em duas passagens. Vale a pena explorar esse problema ao configurar o sistema.
Suavização da profundidade
Finalmente, a coisa mais importante na técnica verde. Nós processamos um monte de bolas esféricas no buffer de profundidade, mas, na realidade, a água não consiste em "bolas". Então agora tomamos essa aproximação e a desfocamos para torná-la mais parecida com a superfície de um líquido.
A abordagem ingênua é simplesmente aplicar profundidades de ruído gaussianas em todo o buffer. Cria resultados estranhos - suaviza os pontos distantes mais do que os próximos e desfoca as bordas das silhuetas. Em vez disso, podemos alterar o raio do desfoque em profundidade e usar o desfoque nos dois lados para salvar as bordas.
Apenas um problema surge aqui: essas mudanças tornam o desfoque indistinguível. O desfoque compartilhado pode ser realizado em duas passagens: desfoque horizontal e verticalmente. O desfoque indistinguível é feito de uma só vez. Essa diferença é importante porque o desfoque compartilhado é escalado linearmente (O (w) + O (h)), e o desfoque não compartilhado é escalado diretamente (O (w * h)). O desfoque não compartilhado em larga escala está rapidamente se tornando inaplicável na prática.
Como adultos, desenvolvedores responsáveis, podemos fazer a jogada óbvia: feche os olhos, finja que o ruído bidirecional * é * compartilhado e ainda o implemente com corredores horizontais e verticais separados.
Green em sua apresentação demonstrou que, embora essa abordagem
crie artefatos no resultado resultante (especialmente na reconstrução de normais), o estágio de sombreamento os oculta bem. Ao trabalhar com as correntes mais estreitas de água que eu crio, esses artefatos são ainda menos visíveis e não afetam particularmente o resultado.
Sombreamento
Finalmente terminamos de trabalhar com o buffer de fluido. Agora vamos para a segunda parte do efeito: sombreamento e composição da imagem principal.
Aqui encontramos muitas restrições de renderização do Unity. Decidi iluminar a água apenas com a luz do sol e da caixa do céu; O suporte a fontes de iluminação adicionais requer várias passagens (isso é um desperdício!) Ou a construção de uma estrutura de pesquisa de iluminação no lado da GPU (cara e bastante complicada). Além disso, como o Unity não fornece acesso a mapas de sombras e as luzes direcionais usam sombras do espaço da tela (com base em um buffer de profundidade renderizado pela geometria opaca), não temos acesso a informações sobre sombras de uma fonte de luz solar. Você pode anexar um buffer de comando a uma fonte de luz solar para criar um mapa de sombra do espaço da tela especificamente para a água, mas até agora não o fiz.
O último estágio do sombreamento é controlado por meio de um script e usa o buffer de comando para enviar chamadas de empate. Isso é
necessário porque a textura do vetor de movimento (usada para anti-aliasing temporário (TAA) e desfoque de movimento) não pode ser usada para renderização direta usando Graphics.SetRenderTarget (). No script anexado à câmera principal, escrevemos o seguinte:
void Start() {
Buffers de cores e vetores de movimento não podem ser renderizados simultaneamente com o MRT (destinos de renderização múltipla). Não consegui descobrir o motivo. Além disso, eles exigem ligação a diferentes buffers de profundidade. Felizmente, escrevemos a profundidade para esses
dois buffers de profundidade, portanto, reprojetar o anti-aliasing temporário funciona bem (oh, é um prazer trabalhar com o mecanismo da caixa preta).
Em cada quadro, jogamos fora uma renderização composta de OnPostRender ():
RenderTexture GenerateRefractionTexture() { RenderTexture result = RenderTexture.GetTemporary(m_MainCamera.activeTexture.descriptor); Graphics.Blit(m_MainCamera.activeTexture, result); return result; } void OnPostRender() { if (ScreenspaceLiquidCamera && ScreenspaceLiquidCamera.IsReady()) { RenderTexture refraction_texture = GenerateRefractionTexture(); m_Mat.SetTexture("_MainTex", ScreenspaceLiquidCamera.GetColorBuffer()); m_Mat.SetVector("_MainTex_TexelSize", ScreenspaceLiquidCamera.GetTexelSize()); m_Mat.SetTexture("_LiquidRefractTexture", refraction_texture); m_Mat.SetTexture("_MainDepth", ScreenspaceLiquidCamera.GetDepthBuffer()); m_Mat.SetMatrix("_DepthViewFromClip", ScreenspaceLiquidCamera.GetProjection().inverse); if (SunLight) { m_Mat.SetVector("_SunDir", transform.InverseTransformVector(-SunLight.transform.forward)); m_Mat.SetColor("_SunColor", SunLight.color * SunLight.intensity); } else { m_Mat.SetVector("_SunDir", transform.InverseTransformVector(new Vector3(0, 1, 0))); m_Mat.SetColor("_SunColor", Color.white); } m_Mat.SetTexture("_ReflectionProbe", ReflectionProbe.defaultTexture); m_Mat.SetVector("_ReflectionProbe_HDR", ReflectionProbe.defaultTextureHDRDecodeValues); Graphics.ExecuteCommandBuffer(m_CommandBuffer); RenderTexture.ReleaseTemporary(refraction_texture); } }
E é aí que a participação da CPU termina, depois apenas os shaders.
Vamos começar com a passagem de vetores de movimento. Aqui está a aparência de todo o shader:
#include "UnityCG.cginc" sampler2D _MainDepth; sampler2D _MainTex; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert(appdata v) { v2f o; o.vertex = mul(UNITY_MATRIX_P, v.vertex); o.uv = v.uv; return o; } struct frag_out { float4 color : SV_Target; float depth : SV_Depth; }; frag_out frag(v2f i) { frag_out o; float4 fluid = tex2D(_MainTex, i.uv); if (fluid.a == 0) discard; o.depth = tex2D(_MainDepth, i.uv).r; float2 vel = fluid.gb / fluid.a; o.color = float4(vel, 0, 1); return o; }
A velocidade no espaço da tela é armazenada no canal verde e azul do buffer de fluido. Como escalamos a velocidade pela espessura ao renderizar o buffer, dividimos novamente a espessura total (localizada no canal alfa) para obter uma velocidade média ponderada.
Vale ressaltar que, ao trabalhar com grandes volumes de água, outro método de processamento do buffer de velocidade pode ser necessário. Como renderizamos sem misturar, os vetores de movimento para tudo
o que está por
trás da água são perdidos, destruindo o TAA e o borrão de movimento desses objetos. Ao trabalhar com finas correntes de água, isso não é um problema, mas pode interferir ao trabalhar com uma piscina ou lago quando precisamos de objetos TAA ou borrões de movimento para serem claramente visíveis através da superfície.
Mais interessante é o principal passe de sombreamento. Nossa primeira prioridade após mascarar a espessura do líquido é reconstruir a posição e o normal do espaço de visualização (espaço de visualização).
float3 ViewPosition(float2 uv) { float clip_z = tex2D(_MainDepth, uv).r; float clip_x = uv.x * 2.0 - 1.0; float clip_y = 1.0 - uv.y * 2.0; float4 clip_p = float4(clip_x, clip_y, clip_z, 1.0); float4 view_p = mul(_DepthViewFromClip, clip_p); return (view_p.xyz / view_p.w); } float3 ReconstructNormal(float2 uv, float3 vp11) { float3 vp12 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(0, 1)); float3 vp10 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(0, -1)); float3 vp21 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(1, 0)); float3 vp01 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(-1, 0)); float3 dvpdx0 = vp11 - vp12; float3 dvpdx1 = vp10 - vp11; float3 dvpdy0 = vp11 - vp21; float3 dvpdy1 = vp01 - vp11;
Esta é uma maneira dispendiosa de reconstruir a posição do espaço de visualização: tomamos a posição no espaço do clipe e realizamos a operação reversa da projeção.
Depois que conseguimos uma maneira de reconstruir as posições, as normais são mais simples: calculamos a posição dos pontos vizinhos no buffer de profundidade e construímos uma base tangente a partir deles. Para trabalhar com as arestas das silhuetas, amostramos nas duas direções e selecionamos o ponto mais próximo ao espaço da vista para reconstruir o normal. Esse método funciona surpreendentemente bem e causa problemas apenas no caso de objetos muito finos.
Isso significa que realizamos cinco operações separadas de projeção reversa por pixel (para o ponto atual e quatro vizinhos). Existe uma maneira menos cara, mas esse post já é muito longo, então deixarei para depois.
As normais resultantes são:
Eu distorço esse normal calculado usando as derivadas do valor do ruído do buffer de fluido, dimensionado pelo parâmetro force e normalizado dividindo pela espessura do jato (pelo mesmo motivo da velocidade):
N.xy += NoiseDerivatives(i.uv, fluid.r) * (_NoiseStrength / fluid.a); N = normalize(N);
Finalmente, podemos prosseguir com o sombreamento em si. O sombreamento da água consiste em três partes principais: reflexão especular, refração especular e espuma.
A reflexão é um GGX padrão, retirado inteiramente do shader padrão do Unity. (Com uma correção, o F0 correto de 2% é usado para a água.)
Com refração, tudo é mais interessante. A refração correta requer rastreamento de raios (ou marcação de raios para obter um resultado aproximado). Felizmente, a refração é menos intuitiva para os olhos do que a reflexão e, portanto, resultados incorretos não são tão perceptíveis. Portanto, alteramos a amostra UV para a textura refrativa por normais x e y, dimensionadas pelo parâmetro de espessura e força:
float aspect = _MainTex_TexelSize.y * _MainTex_TexelSize.z; float2 refract_uv = (i.grab_pos.xy + N.xy * float2(1, -aspect) * fluid.a * _RefractionMultiplier) / i.grab_pos.w; float4 refract_color = tex2D(_LiquidRefractTexture, refract_uv);
(Observe que a correção de correlação é usada; é
opcional - afinal, é apenas uma aproximação, mas a adição é bastante simples.)
Essa luz refratada passa pelo líquido, portanto parte dela é absorvida:
float3 water_color = _AbsorptionColor.rgb * _AbsorptionIntensity; refract_color.rgb *= exp(-water_color * fluid.a);
Observe que _AbsorptionColor é determinado exatamente do modo oposto ao esperado: os valores de cada canal indicam a quantidade de luz
absorvida e não transmitida. Portanto, _AbsorptionColor com um valor de (1, 0, 0) não fornece vermelho, mas uma cor turquesa (verde-azulado).
Reflexão e refração são misturadas usando os coeficientes de Fresnel:
float spec_blend = lerp(0.02, 1.0, pow(1.0 - ldoth, 5)); float4 clear_color = lerp(refract_color, spec, spec_blend);
Até aquele momento, jogávamos pelas regras (principalmente) e usamos sombreamento físico.
Ele é muito bom, mas ele tem um problema com a água. É um pouco difícil de ver:
Para consertar, vamos adicionar um pouco de espuma.
A espuma aparece quando a água é turbulenta e o ar se mistura com a água para formar bolhas. Tais bolhas criam todos os tipos de variações de reflexão e refração, o que dá a toda a água uma sensação de iluminação difusa. Modelarei esse comportamento com luz ambiente envolvida:
float3 foam_color = _SunColor * saturate((dot(N, L)*0.25f + 0.25f));
É adicionado à cor final usando um fator especial, dependendo do ruído do fluido e do coeficiente de Fresnel amolecido:
float foam_blend = saturate(fluid.r * _NoiseStrength) * lerp(0.05f, 0.5f, pow(1.0f - ndotv, 3)); clear_color.rgb += foam_color * saturate(foam_blend);
A iluminação ambiente envolvida é normalizada para economizar energia, para que possa ser usada como uma aproximação de difusão. A mistura da cor da espuma é mais visível. É uma violação bastante clara da lei de conservação de energia.
Mas, em geral, tudo parece bom e torna o fluxo mais perceptível:
Mais trabalhos e melhorias
No sistema criado, muito pode ser melhorado.
- Usando várias cores. No momento, a absorção é calculada apenas no último estágio do sombreamento e usa uma cor e brilho constantes para todo o líquido na tela. O suporte para cores diferentes é possível, mas requer um segundo tampão de cor e a solução da absorção integral para cada partícula no processo de renderização do tampão do fluido base. Isso pode ser potencialmente uma operação cara.
- Cobertura total. Tendo acesso à estrutura de pesquisa de iluminação no lado da GPU (construída manualmente ou graças à ligação ao novo pipeline de renderização Unity HD), podemos iluminar adequadamente a água com qualquer número de fontes de luz e criar a iluminação ambiente correta.
- Refração aprimorada. Com as texturas mip borradas da textura de fundo, podemos simular melhor a refração para superfícies ásperas. Na prática, isso não é muito útil para pequenos sprays de líquido, mas pode ser útil para volumes maiores.
Se eu tivesse a oportunidade, melhoraria esse sistema com a perda de um pulso, mas no momento ele pode ser chamado de completo.