Unity Shader de acumulación

¿Te has preguntado cuánto tiempo se tarda en aplicar nieve a todas las texturas de tu juego? Probablemente muchas veces. Me gustaría mostrarle cómo crear un efecto de imagen (sombreado de espacio de pantalla) que cambiará inmediatamente la temporada de su escena en Unity.

/img/c/nieve01.png
.
/img/c/nieve02.png
.

¿Como funciona?

En las imágenes de arriba puedes ver dos capturas de pantalla que presentan la misma escena. La única diferencia es que en el segundo habilité el efecto de nieve en la cámara. No se han realizado cambios en ninguna de las texturas. ¿Cómo es posible?

La teoría es realmente simple. El supuesto es que debe haber nieve cuando la imagen normal de un píxel renderizado está orientada hacia arriba (suelo, techos, etc.) También debe haber una transición suave entre la textura de la nieve y la textura original si la imagen está orientada hacia cualquier otra dirección (pinos, paredes).

Obteniendo los datos necesarios

Para que el efecto presentado funcione, requiere al menos dos cosas:

  • Rendering path configurada como deferred (Por alguna razón, no pude adelantar la reproducción para que funcione correctamente con este efecto. El sombreador de profundidad se procesó incorrectamente. Si tiene alguna idea de por qué podría ser, házmelo saber en el repositorio.)
  • Camera.depthTextureMode establecido en DepthNormals

GitHub

Ya que la segunda opción puede ser configurada fácilmente por el propio script de efectos de imagen, la primera opción puede causar un problema si su juego ya está utilizando forward rendering path.

Configurar Camera.depthTextureMode como DepthNormals nos permitirá leer la profundidad de la pantalla (la distancia a la que se ubican los píxeles de la cámara) y las normales (dirección opuesta).

Ahora, si nunca ha creado un Efecto de imagen antes, debe saber que se crean a partir de al menos un script y al menos un shader. Por lo general, este shader, en lugar de representar un objeto 3D, muestra la imagen en pantalla completa de los datos de entrada. En nuestro caso, los datos de entrada son una imagen renderizada por la cámara y algunas propiedades configuradas por el usuario.

ScreenSpaceSnow.cs


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	[ExecuteInEditMode]
	public class ScreenSpaceSnow : MonoBehaviour 
	{
		#region Variables
		public Texture2D SnowTexture;
		public Color SnowColor = Color.white;
		public float SnowTextureScale = 0.1f;
		[Range(0, 1)] public float BottomThreshold = 0f;
		[Range(0, 1)] public float TopThreshold = 1f;
		private Material _material;
		#endregion

		#region Metodos
		private void OnEnable()
		{
			// Crea dinamicamente un material que utilizara nuestro shader
			_material = new Material(Shader.Find("MoonAntonio/ScreenSpaceSnow"));

			// Decir a la camara que cree profundidad y normales.
			this.GetComponent<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;
		}

		private void OnRenderImage(RenderTexture src, RenderTexture dest)
		{
			// Establecer propiedades de shader
			_material.SetMatrix("_CamToWorld", GetComponent<Camera>().cameraToWorldMatrix);
			_material.SetColor("_SnowColor", SnowColor);
			_material.SetFloat("_BottomThreshold", BottomThreshold);
			_material.SetFloat("_TopThreshold", TopThreshold);
			_material.SetTexture("_SnowTex", SnowTexture);
			_material.SetFloat("_SnowTexScale", SnowTextureScale);

			// Ejecutar el shader en la textura de entrada(src) y escribir en la salida(dest)
			Graphics.Blit(src, dest, _material);
		}
		#endregion
	}
}

Es solo la configuración básica, no generará nieve. Ahora comienza la verdadera diversión …

El shader

Nuestro shader de nieve debe ser un sombreado no iluminado, no queremos aplicarle ninguna información de luz ya que en el espacio de la pantalla no hay luz. Aquí está la plantilla básica:

ScreenSpaceSnow.shader


Shader "MoonAntonio/ScreenSpaceSnow"
{
	Properties
	{
		_MainTex("Texture", 2D) = "white" {}
	}
		SubShader
	{
		Cull Off ZWrite Off ZTest Always

		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag

			#include "UnityCG.cginc"

			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_MVP, v.vertex);
				o.uv = v.uv;
				return o;
			}

			fixed4 frag(v2f i) : SV_Target
			{
				// TODO
			}
			ENDCG
		}
	}
}

Tenga en cuenta que si crea un nuevo shader de unity no iluminado (Crear-> Shader-> Unlit Shader) obtendrá casi el mismo código.

Centrémonos ahora solo en la parte importante: el shader de fragmentos. Primero, necesitamos capturar todos los datos pasados ​​por el script ScreenSpaceSnow:


sampler2D _MainTex;
sampler2D _CameraDepthNormalsTexture;
float4x4 _CamToWorld;

sampler2D _SnowTex;
float _SnowTexScale;

half4 _SnowColor;

fixed _BottomThreshold;
fixed _TopThreshold;


half4 frag (v2f i) : SV_Target
{
	
}

No se preocupe si aún no sabe por qué necesitamos todos estos datos. Lo explicaré en detalle en un momento.

Averiguar dónde nevar

Como expliqué antes, nos gustaría poner la nieve en superficies que miran hacia arriba. Ya que esta configurado en la cámara, que está configurada para generar una textura de profundidad normal, ahora podemos acceder a ella.


sampler2D _CameraDepthNormalsTexture;

En el código ¿Por qué se llama así? Puedes conocerlo en la documentación de Unity:

Las texturas de profundidad están disponibles para mostrarse en shaders como propiedades de sombreado global. Al declarar una muestra llamada _CameraDepthTexture, podrá mostrar la textura de profundidad principal de la cámara. _CameraDepthTexture Siempre se refiere a la textura de profundidad principal de la cámara.

Ahora comencemos con la normal:


half4 frag (v2f i) : SV_Target
{
	half3 normal;
	float depth;

	DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), depth, normal);
	normal = mul( (float3x3)_CamToWorld, normal);

	return half4(normal, 1);
}

La documentación de Unity dice que la profundidad y las normales se empaquetan en 16 bits cada una. Para poder descomprimirlo, necesitamos llamar a DecodeDepthNormal como se vio anteriormente.

Las normales recuperadas de esta manera son normales de espacio de cámara. Eso significa que si giramos la cámara, entonces también cambiará la orientación de los normales. No queremos eso, y por eso tenemos que multiplicarlo por _CamToWorld, una matriz establecida en el script anterior. Convierte las normales de la cámara a coordenadas mundiales para que no dependan más de la perspectiva de la cámara. Para que el shader se compile, tiene que devolver algo, así que configuro la declaración de retorno como se ve arriba. Para ver si nuestros cálculos son correctos, es una buena idea obtener una vista previa del resultado.

/img/c/nieve03.png
.

Estamos haciendo esto como RGB. En Unity el color verde muestra el valor de la coordenada Y.

Ahora vamos a convertirlo en factor de cantidad de nieve.


half4 frag (v2f i) : SV_Target
{
	half3 normal;
	float depth;

	DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), depth, normal);
	normal = mul( (float3x3)_CamToWorld, normal);

	half snowAmount = normal.g;
	half scale = (_BottomThreshold + 1 - _TopThreshold) / 1 + 1;
	snowAmount = saturate( (snowAmount - _BottomThreshold) * scale);

	return half4(snowAmount, snowAmount, snowAmount, 1);
}

Deberíamos estar usando el canal G, por supuesto. Ahora, esto puede ser suficiente, pero me gusta iterar un poco más para poder configurar los umbrales superior e inferior del área nevada. Permitirá ajustar la cantidad de nieve que debería haber en la escena.

/img/c/nieve04.png
.

Textura de nieve

La nieve puede no parecer real sin una textura. Esta es la parte más difícil: ¿cómo aplicar una textura en un objetos 3D si solo tiene una imagen 2D (estamos trabajando en el espacio de la pantalla, recuerde)? Una forma es averiguar la posición mundial del píxel. Luego podemos usar las coordenadas del mundo X y Z como coordenadas de textura.


half4 frag (v2f i) : SV_Target
{
	half3 normal;
	float depth;

	DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), depth, normal);
	normal = mul( (float3x3)_CamToWorld, normal);

	// Averiguar la cantidad de nieve
	half snowAmount = normal.g;
	half scale = (_BottomThreshold + 1 - _TopThreshold) / 1 + 1;
	snowAmount = saturate( (snowAmount - _BottomThreshold) * scale);

	// Descubre el color de la nieve
	float2 p11_22 = float2(unity_CameraProjection._11, unity_CameraProjection._22);
	float3 vpos = float3( (i.uv * 2 - 1) / p11_22, -1) * depth;
	float4 wpos = mul(_CamToWorld, float4(vpos, 1));
	wpos += float4(_WorldSpaceCameraPos, 0) / _ProjectionParams.z;

	half3 snowColor = tex2D(_SnowTex, wpos.xz * _SnowTexScale * _ProjectionParams.z) * _SnowColor;

	return half4(snowColor, 1);
}

Fusionandolo!

¡Es hora de finalmente fusionarlo todo junto!


half4 frag (v2f i) : SV_Target
{
	half3 normal;
	float depth;

	DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), depth, normal);
	normal = mul( (float3x3)_CamToWorld, normal);

	// Averiguar la cantidad de nieve
	half snowAmount = normal.g;
	half scale = (_BottomThreshold + 1 - _TopThreshold) / 1 + 1;
	snowAmount = saturate( (snowAmount - _BottomThreshold) * scale);

	// Descubre el color de la nieve
	float2 p11_22 = float2(unity_CameraProjection._11, unity_CameraProjection._22);
	float3 vpos = float3( (i.uv * 2 - 1) / p11_22, -1) * depth;
	float4 wpos = mul(_CamToWorld, float4(vpos, 1));
	wpos += float4(_WorldSpaceCameraPos, 0) / _ProjectionParams.z;

	wpos *= _SnowTexScale * _ProjectionParams.z;
	half4 snowColor = tex2D(_SnowTex, wpos.xz) * _SnowColor;

	// Consigue color y lerp a la textura de la nieve.
	half4 col = tex2D(_MainTex, i.uv);
	return lerp(col, snowColor, snowAmount);
}

/img/c/nieve05.png
.

El toque final, vamos a establecer el valor _TopThreshold en 0.6:

/img/c/nieve06.gif
.

/img/ref.png
.