Pantalla de renderizado de agua

imagen

Mi última tarea en gráficos técnicos / renderizado fue encontrar una buena solución para renderizar agua. En particular, la representación de chorros de agua delgados y de rápido movimiento basados ​​en partículas. Durante la semana pasada, pensé en buenos resultados, así que escribiré un artículo sobre esto.

Realmente no me gusta el enfoque de cubos voxelizados / de marcha al procesar agua (ver, por ejemplo, renderizar una simulación de fluido en Blender). Cuando el volumen de agua está en la misma escala que la cuadrícula utilizada para renderizar, el movimiento es notablemente discreto. Este problema se puede resolver aumentando la resolución de la cuadrícula, pero para chorros delgados a distancias relativamente largas en tiempo real, simplemente no es práctico porque afecta en gran medida el tiempo de ejecución y la memoria ocupada. (Hay un precedente para usar estructuras de voxel dispersas para mejorar la situación. Pero no estoy seguro de qué tan bien funciona para sistemas dinámicos. Además, este no es el nivel de dificultad con el que me gustaría trabajar).

La primera alternativa que exploré fue Screen Space Meshes de Müller. Utilizan la representación de partículas de agua en un tampón de profundidad, alisándolo, reconociendo fragmentos conectados de profundidad similar y construyendo una malla a partir del resultado utilizando cuadrados de marcha. Hoy, este método probablemente se ha vuelto más aplicable que en 2007 (ya que ahora podemos crear una malla en el sombreador de cómputo), pero todavía está asociado con un mayor nivel de complejidad y costo de lo que quisiera.

Al final, encontré la presentación de Simon Green con GDC 2010, Screen Space Fluid Rendering For Games. Comienza exactamente de la misma manera que Screen Space Meshes: renderizando partículas en el búfer de profundidad y suavizándolo. Pero en lugar de construir la malla, el búfer resultante se usa para sombrear y componer el líquido en la escena principal (al registrar explícitamente la profundidad). Decidí implementar dicho sistema.

Preparación


Varios proyectos anteriores de Unity me enseñaron a no lidiar con las limitaciones de renderizar el motor. Por lo tanto, los buffers fluidos son procesados ​​por una segunda cámara con una profundidad de campo menor para que se muestre frente a la escena principal. Cada sistema de fluido existe en una capa de representación separada; la cámara principal excluye una capa de agua y la segunda cámara solo produce agua. Ambas cámaras son hijos de un objeto vacío para garantizar su orientación relativa.

Tal esquema significa que puedo renderizar casi cualquier cosa en la capa líquida, y se verá como espero que sea. En el contexto de mi escena de demostración, esto significa que algunos chorros y salpicaduras de subemisores pueden fusionarse. Además, esto permitirá la mezcla de otros sistemas de agua, por ejemplo, volúmenes basados ​​en campos de altitud, que luego se pueden representar igual. (No he probado esto todavía).

La fuente de agua en mi escena es un sistema de partículas estándar. De hecho, no se realiza simulación de fluidos. Esto, a su vez, significa que las partículas no se superponen entre sí de una manera completamente física, pero el resultado final parece aceptable en la práctica.

Fluid buffer rendering


El primer paso en esta técnica es renderizar el tampón de fluido base. Este es un búfer fuera de la pantalla que contiene (en la etapa actual de mi implementación) lo siguiente: ancho de fluido, vector de movimiento en el espacio de la pantalla y valor de ruido. Además, renderizamos el búfer de profundidad registrando explícitamente la profundidad del sombreador de fragmentos para convertir cada cuadrángulo de una partícula en una "bola" esférica (bueno, en realidad elíptica).

Los cálculos de profundidad y anchura son 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; 

(Por supuesto, los cálculos de profundidad se pueden simplificar; desde la posición del clip solo necesitamos z y w).

Un poco más tarde, volveremos al sombreador de fragmentos para los vectores de movimiento y ruido.

La diversión comienza en el sombreador de vértices, y es aquí donde me desvío de la técnica verde. El objetivo de este proyecto es hacer chorros de agua a alta velocidad; Se puede realizar con la ayuda de partículas esféricas, pero se necesitará una gran cantidad de ellas para crear un chorro continuo. En cambio, estiraré los cuadrángulos de las partículas en función de su velocidad, que a su vez estira las bolas de profundidad, haciéndolas no esféricas, sino elípticas. (Dado que los cálculos de profundidad se basan en UV, que no cambian, todo funciona.)

Los usuarios experimentados de Unity pueden preguntarse por qué simplemente no uso el modo de Cartelera Estirada incorporado disponible en el sistema de partículas Unity. Stretched Billboard realiza estiramientos incondicionales a lo largo del vector de velocidad en el espacio del mundo. En el caso general, esto es bastante adecuado, pero conduce a un problema muy notable cuando el vector de velocidad se codirige con el vector de cámara orientado hacia adelante (o muy cerca de él). Billboard se estira en la pantalla, lo que hace que su naturaleza bidimensional sea muy notable.

En cambio, uso una valla publicitaria dirigida a la cámara y proyecto el vector de velocidad en el plano de la partícula, utilizándolo para estirar el cuadrilátero. Si el vector de velocidad es perpendicular al plano (dirigido a la pantalla o lejos de él), entonces la partícula permanece sin estirar y esférica, como debería, y cuando está inclinada, la partícula se estira en esta dirección, que es lo que necesitamos.

Dejemos una larga explicación, aquí hay una función bastante simple:

 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 el vector de movimiento del espacio de la pantalla, calculamos dos conjuntos de posiciones de vectores:

 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)); 

Tenga en cuenta que dado que calculamos los vectores de movimiento en el pasaje principal y no en el pasaje de los vectores de velocidad, Unity no nos proporciona una proyección de corriente previa o sin distorsión desde la vista. Para solucionar esto, agregué un script simple a los sistemas de partículas correspondientes:

 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; } } 

Caché la matriz anterior manualmente porque Camera.previousViewProjectionMatrix da resultados incorrectos.

¯ \ _ (ツ) _ / ¯

(Además, este método viola el renderizado; puede ser prudente establecer constantes de matriz global en la práctica en lugar de usarlas para cada material).

Volvamos al sombreador de fragmentos: utilizamos las posiciones proyectadas para calcular los vectores de movimiento del espacio de la pantalla:

 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; 

(Los cálculos de vectores de movimiento con casi ningún cambio se toman de https://github.com/keijiro/ParticleMotionVector/blob/master/Assets/ParticleMotionVector/Shaders/Motion.cginc )

Finalmente, el último valor en el tampón de fluido es el ruido. Utilizo un número aleatorio estable para cada partícula para seleccionar uno de los cuatro ruidos (empaquetados en una sola textura). Luego se escala por la velocidad y la unidad menos el tamaño de partícula (por lo tanto, las partículas rápidas y pequeñas son más ruidosas). Este valor de ruido se utiliza en el paso de sombreado para distorsionar las normales y agregar una capa de espuma. El trabajo de Green utiliza ruido blanco de tres canales, pero un trabajo más nuevo (Renderizado de fluidos de espacio de pantalla con flujo de curvatura) propone utilizar el ruido de Perlin. Utilizo ruido Voronoi / ruido celular con diferentes escalas:


Problemas de mezcla (y soluciones)


Y aquí aparecen los primeros problemas de mi implementación. Para el correcto cálculo del espesor de las partículas se mezclan aditivamente. Como la mezcla afecta a todas las salidas, esto significa que los vectores de ruido y movimiento también se mezclan aditivamente. El ruido aditivo nos conviene bastante, pero no los vectores aditivos, y si los dejas como están, obtienes un repugnante antisolapamiento (TAA) y un desenfoque de movimiento. Para resolver este problema, cuando renderizo un búfer fluido, simplemente multiplico los vectores de movimiento por el grosor y divido por el grosor total en el paso de sombreado. Esto nos da un vector de movimiento promedio ponderado para todas las partículas superpuestas; no es exactamente lo que necesitamos (se crean artefactos extraños cuando se cruzan varios chorros), pero es bastante aceptable.

Un problema más complejo es la profundidad; Para la representación adecuada del búfer de profundidad, necesitamos tener activos tanto el registro de profundidad como la verificación de profundidad. Esto puede causar problemas si las partículas no se clasifican (porque la diferencia en el orden de renderizado puede hacer que la salida de partículas superpuestas por otras se recorte). Por lo tanto, ordenamos al sistema de partículas Unity que clasifique las partículas por profundidad, y luego cruzamos nuestros dedos y esperamos. que los sistemas también rendirán en profundidad. Tendremos * casos * de sistemas superpuestos (por ejemplo, la intersección de dos chorros de partículas) que no se procesan correctamente, lo que conducirá a un grosor menor. Pero esto no sucede con mucha frecuencia y no afecta en gran medida la apariencia.

Lo más probable es que el enfoque correcto sea hacer que los buffers de profundidad y color estén completamente separados; La recompensa por esto será la representación de dos pasos. Vale la pena explorar este problema al configurar el sistema.

Suavizado de profundidad


Finalmente, lo más importante en la técnica verde. Colocamos un montón de bolas esféricas en el búfer de profundidad, pero en realidad, el agua no consiste en "bolas". Así que ahora tomamos esta aproximación y la difuminamos para que se parezca más a la superficie de un líquido.

El enfoque ingenuo es simplemente aplicar profundidades de ruido gaussianas a todo el búfer. Crea resultados extraños: suaviza los puntos distantes más que los cercanos y difumina los bordes de las siluetas. En cambio, podemos cambiar el radio de desenfoque en profundidad y usar desenfoque de dos lados para guardar los bordes.

Aquí solo surge un problema: tales cambios hacen que el desenfoque sea indistinguible. El desenfoque compartido se puede realizar en dos pasadas: desenfoque horizontal y luego vertical. El desenfoque indistinguible se realiza en una sola pasada. Esta diferencia es importante porque el desenfoque compartido se escala linealmente (O (w) + O (h)), y el desenfoque no compartido se escala directamente (O (w * h)). El desenfoque no compartido a gran escala se está volviendo rápidamente inaplicable en la práctica.

Como adultos, desarrolladores responsables, podemos hacer el movimiento obvio: cerrar los ojos, fingir que el ruido bidireccional * es * compartido y aún implementarlo con pasillos horizontales y verticales separados.

Green en su presentación demostró que aunque este enfoque crea artefactos en el resultado resultante (especialmente al reconstruir normales), la etapa de sombreado los oculta bien. Cuando trabajo con las corrientes de agua más estrechas que creo, estos artefactos son aún menos notables y no afectan particularmente el resultado.

Sombreado


Finalmente terminamos de trabajar con el tampón fluido. Ahora pase a la segunda parte del efecto: sombrear y componer la imagen principal.

Aquí nos encontramos con muchas restricciones de representación de Unity. Decidí iluminar el agua solo con la luz del sol y el skybox; El soporte de fuentes de iluminación adicionales requiere varios pases (¡esto es un desperdicio!) O construir una estructura de búsqueda de iluminación en el lado de la GPU (costosa y bastante complicada). Además, dado que Unity no proporciona acceso a los mapas de sombras, y las luces direccionales usan sombras en el espacio de la pantalla (basadas en un búfer de profundidad representado por una geometría opaca), no tenemos acceso a la información de sombras de una fuente de luz solar. Puede adjuntar un búfer de comando a una fuente de luz solar para crear un mapa de sombra del espacio de la pantalla específicamente para el agua, pero hasta ahora no lo he hecho.

La última etapa de sombreado se controla mediante un script y utiliza el búfer de comandos para enviar llamadas de sorteo. Esto es necesario porque la textura del vector de movimiento (utilizada para el suavizado temporal (TAA) y el desenfoque de movimiento) no se puede utilizar para la representación directa con Graphics.SetRenderTarget (). En el guión adjunto a la cámara principal, escribimos lo siguiente:

 void Start() { //... m_QuadMesh = new Mesh(); m_QuadMesh.subMeshCount = 1; m_QuadMesh.vertices = new Vector3[] { new Vector3(0, 0, 0.1f), new Vector3(1, 0, 0.1f), new Vector3(1, 1, 0.1f), new Vector3(0, 1, 0.1f), }; m_QuadMesh.uv = new Vector2[] { new Vector2(0, 0), new Vector2(1, 0), new Vector2(1, 1), new Vector2(0, 1), }; m_QuadMesh.triangles = new int[] { 0, 1, 2, 0, 2, 3, }; m_QuadMesh.UploadMeshData(false); m_CommandBuffer = new CommandBuffer(); m_CommandBuffer.Clear(); m_CommandBuffer.SetProjectionMatrix( GL.GetGPUProjectionMatrix( Matrix4x4.Ortho(0, 1, 0, 1, -1, 100), false)); m_CommandBuffer.SetRenderTarget( BuiltinRenderTextureType.CameraTarget, BuiltinRenderTextureType.CameraTarget); m_CommandBuffer.DrawMesh( m_QuadMesh, Matrix4x4.identity, m_Mat, 0, m_Mat.FindPass("LIQUIDCOMPOSITE")); m_CommandBuffer.SetRenderTarget( BuiltinRenderTextureType.MotionVectors, BuiltinRenderTextureType.Depth); m_CommandBuffer.DrawMesh( m_QuadMesh, Matrix4x4.identity, m_Mat, 0, m_Mat.FindPass("MOTION")); } 

Los buffers de color y los vectores de movimiento no se pueden representar simultáneamente con MRT (objetivos de renderizado múltiple). No pude encontrar la razón. Además, requieren unión a diferentes tampones de profundidad. Afortunadamente, escribimos la profundidad en estos dos buffers de profundidad, por lo que volver a proyectar el suavizado temporal funciona bien (oh, es un placer trabajar con el motor de caja negra).

En cada cuadro, arrojamos un render compuesto 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); } } 

Y aquí es donde termina la participación de la CPU, luego solo van los sombreadores.

Comencemos con el paso de los vectores de movimiento. Así es como se ve todo el sombreador:

 #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; } 

La velocidad en el espacio de la pantalla se almacena en el canal verde y azul del tampón de fluido. Como escalamos la velocidad por el grosor al renderizar el búfer, nuevamente dividimos el grosor total (ubicado en el canal alfa) para obtener una velocidad promedio ponderada.

Vale la pena señalar que cuando se trabaja con grandes volúmenes de agua, puede ser necesario otro método de procesamiento del buffer de velocidad. Como renderizamos sin mezclar, los vectores de movimiento para todo lo que se encuentra detrás del agua se pierden, destruyendo el TAA y el desenfoque de movimiento de estos objetos. Cuando se trabaja con corrientes delgadas de agua, esto no es un problema, pero puede interferir cuando se trabaja con una piscina o lago cuando necesitamos TAA o objetos de desenfoque de movimiento para ser claramente visibles a través de la superficie.

Más interesante es el pase de sombreado principal. Nuestra primera prioridad después de enmascarar con el grosor del líquido es reconstruir la posición y la normalidad del espacio de visualización (espacio de visualización).

 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; // Pick the closest float3 dvpdx = dot(dvpdx0, dvpdx0) > dot(dvpdx1, dvpdx1) ? dvpdx1 : dvpdx0; float3 dvpdy = dot(dvpdy0, dvpdy0) > dot(dvpdy1, dvpdy1) ? dvpdy1 : dvpdy0; return normalize(cross(dvpdy, dvpdx)); } 

Esta es una forma costosa de reconstruir la posición del espacio de visualización: tomamos la posición en el espacio del clip y realizamos la operación inversa de la proyección.

Después de encontrar una manera de reconstruir las posiciones, las normales son más simples: calculamos la posición de los puntos vecinos en el búfer de profundidad y construimos una base tangente a partir de ellos. Para trabajar con los bordes de las siluetas, tomamos muestras en ambas direcciones y seleccionamos el punto más cercano al espacio de vista para reconstruir lo normal. Este método funciona sorprendentemente bien y causa problemas solo en el caso de objetos muy delgados.

Esto significa que realizamos cinco operaciones separadas de proyección inversa por píxel (para el punto actual y cuatro vecinas). Hay una forma menos costosa, pero esta publicación ya es demasiado larga, así que la dejaré para más adelante.

Las normales resultantes son:


Distorsiono esta normal calculada usando las derivadas del valor de ruido del buffer de fluido, escaladas por el parámetro de fuerza y ​​normalizadas dividiendo por el grosor del chorro (por la misma razón que la velocidad):

 N.xy += NoiseDerivatives(i.uv, fluid.r) * (_NoiseStrength / fluid.a); N = normalize(N); 

Finalmente podemos proceder con el sombreado en sí. El sombreado del agua consta de tres partes principales: reflexión especular, refracción especular y espuma.

Reflection es un GGX estándar tomado completamente del sombreador Unity estándar. (Con una corrección, se usa la F0 correcta del 2% para el agua).

Con la refracción, todo es más interesante. La refracción correcta requiere trazado de rayos (o marcaje de rayos para un resultado aproximado). Afortunadamente, la refracción es menos intuitiva para el ojo que la reflexión y, por lo tanto, los resultados incorrectos no son tan notables. Por lo tanto, cambiamos la muestra de UV para la textura refractiva por x e y normales, escaladas por el parámetro de espesor y fuerza:

 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); 

(Tenga en cuenta que se utiliza la corrección de correlación; es opcional ; después de todo, es solo una aproximación, pero agregarla es bastante simple).

Esta luz refractada pasa a través del líquido, por lo que parte de ella se absorbe:

 float3 water_color = _AbsorptionColor.rgb * _AbsorptionIntensity; refract_color.rgb *= exp(-water_color * fluid.a); 

Tenga en cuenta que _AbsorptionColor se determina exactamente en el sentido opuesto al esperado: los valores de cada canal indican la cantidad de luz absorbida en lugar de transmitida. Por lo tanto, _AbsorptionColor con un valor de (1, 0, 0) no da rojo, sino un color turquesa (verde azulado).

La reflexión y la refracción se mezclan utilizando 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); 

Hasta ese momento, jugamos con las reglas (principalmente) y usamos sombreado físico.

Es bastante bueno, pero tiene un problema con el agua. Es un poco difícil de ver:


Para solucionarlo, agreguemos un poco de espuma.

La espuma aparece cuando el agua es turbulenta y el aire se mezcla con el agua para formar burbujas. Tales burbujas crean todo tipo de variaciones en la reflexión y la refracción, lo que le da a toda el agua una sensación de iluminación difusa. Modelaré este comportamiento con luz ambiental envuelta:

 float3 foam_color = _SunColor * saturate((dot(N, L)*0.25f + 0.25f)); 

Se agrega al color final usando un factor especial, dependiendo del ruido del fluido y del coeficiente de Fresnel suavizado:

 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); 

La iluminación ambiental envuelta se normaliza para conservar energía y poder usarse como una aproximación de la difusión. La mezcla del color de la espuma es más notable. Es una violación bastante clara de la ley de conservación de la energía.

Pero en general, todo se ve bien y hace que la transmisión sea más notable:


Más trabajo y mejoras


En el sistema creado, mucho se puede mejorar.

  • Usando múltiples colores. Por el momento, la absorción se calcula solo en la última etapa de sombreado y utiliza un color y brillo constantes para todo el líquido en la pantalla. Es posible el soporte para diferentes colores, pero requiere un segundo tampón de color y la solución de la integral de absorción para cada partícula en el proceso de renderización del tampón de fluido base. Esto podría ser potencialmente una operación costosa.
  • Cobertura total Al tener acceso a la estructura de búsqueda de iluminación en el lado de la GPU (ya sea construida a mano o gracias a la unión a la nueva tubería de renderizado Unity HD), podemos iluminar adecuadamente el agua con cualquier cantidad de fuentes de luz y crear la iluminación ambiental adecuada.
  • Refracción mejorada. Con las texturas borrosas de la textura de fondo, podemos simular mejor la refracción para superficies rugosas. En la práctica, esto no es muy útil para pequeñas pulverizaciones de líquido, pero puede ser útil para grandes volúmenes.

Si tuviera la oportunidad, mejoraría este sistema a la pérdida de un pulso, pero en este momento se puede llamar completo.

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


All Articles