Unity Shaders 2: Un shader simple de nieve

(Publiqué este artículo originalmente en Pixels & Coffee el 16 de Mayo de 2014)

Este es el segundo de 6 artículos que fueron publicados originalmente en inglés en la web Unity Gems por Mike Talbot quien me ha autorizado a traducirlos al español.

Si no habéis leído el primer artículo, podéis encontrarlo aquí: Unity Shaders 1: Introducción

El título original de este segundo artículo es:

Habiendo cubierto los aspectos básicos de la estructura de un surface shader en la primera parte, esta entrada muestra cómo usarlos para crear un shader de acumulación de nieve, con deformación de malla y bump mapping

Deberías leer este tutorial si eres nuevo en la programación de shaders y quieres aprender acerca de:

  • Construcción de un shader de nieve acumulativo
  • Crear un shader de bump mapping
  • Modificar las texturas aplicadas a un pixel
  • Modificar los vértices de un modelo en un surface shader

Introducción

¡En esta segunda parte de mi guía para shaders vamos a construir algo útil! Tras todo el trabajo de trasfondo de la primera parte vamos a construir un shader de nieve simple.

The shader on the rock

Ejemplo del shader en acción sobre una roca disponible de forma gratuita en la Asset Store

Planificando el shader

Lo que queremos hacer es bastante simple y podemos expresarlo de la siguiente forma:

  • A medida que el nivel de la nieve se incrementa, queremos convertir los píxeles que encaran la dirección de la nieve a un color de nieve, en lugar de la textura del material.
  • A medida que el nivel de la nieve se incrementa, queremos deformar el modelo para que sea ligeramente más grande, principalmente en la parte en que está cayendo la nieve.

Paso 1 – Shader Difuso con Relieve (Bumped Diffuse Shader)

Empecemos con un nuevo shader difuso al que le añadimos bump mapping:


Shader "Custom/SnowShader" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}

        // Nueva textura para el mapa de normales
        _Bump ("Bump", 2D) = "bump" {}
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200

        CGPROGRAM
        #pragma surface surf Lambert

        sampler2D _MainTex;
        // Hay que añadir un sampler con el mismo nombre que la propiedad
        sampler2D _Bump;

        struct Input {
            float2 uv_MainTex;
            // Coordenadas uv para el mapa de normales
            float2 uv_Bump;
        };

        void surf (Input IN, inout SurfaceOutput o) {
            half4 c = tex2D (_MainTex, IN.uv_MainTex);

            // Extrae la información sobre la normal del mapa de normales
            o.Normal = UnpackNormal(tex2D(_Bump, IN.uv_Bump);

            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

Esto es básicamente el shader que nos crea Unity automáticamente como base, pero añadiendo lo necesario para al bump.

Por lo tanto, hemos:

  • Definido una propiedad llamada _Bump que es una imagen 2D con un valor por defecto de “bump” (mapa de normales vacío).
  • Creado un sampler2D con exáctamente el mismo nombre
  • Creado una entrada en nuestra estructura Input para obtener las coordenadas uv para el bump (de nuevo, usando el mismo nombre)
  • Añadido una línea de código que llama a la función UnpackNormal, que toma como parámetro un mapa de normales y convierte el resultado en una normal. Le pasamos el píxel de la textura obtenido mediante tex2D, que usa la variable _Bump y las coordenadas uv de la estructura Input

Tras esto tenemos un shader con relieve bastante normalito…

Paso 2 – Añadiendo Algo de Nieve

Ok, en este paso vamos a tener que averiguar si la normal de un píxel está más o menos apuntando en la misma dirección desde la que viene la nieve.

Para hacerlo vamos a usar el producto escalar (dot product). El producto escalar entre dos vectores unitarios es igual al coseno de los ángulos que formas los vectores. Por suerte, CG tiene una función dot que hará el cálculo por nosotros. Lo bueno del producto escalar es que es 1 cuando los vectores apuntan exactamente de la misma forma y -1 cuando apuntan en sentido contrario, con una bonita estala lineal entre estos valores. Así que nunca necesitamos conocer el ángulo en nuestro shader, solo el producto escalar entre la normal de los píxeles y la dirección de la nieve.

Un vector unitario es aquel cuya magnitud es 1, de forma que la raíz cuadrada de la suma de los cuadrados de sus componentes x, y y z debe ser 1. No caigas en la trampa de pensar que un vector como (1, 1, 1) es unitario, porque no lo es.

Para averiguar el ángulo se debe escalar cada vector de magnitud mayor a  la unidad de forma que sea unitario.

Bien, armados con este conocimiento, definamos algunas propiedades de nuestro shader:

Properties {
    _MainTex ("Base (RGB)", 2D) = "white" {}
    _Bump ("Bump", 2D) = "bump" {}
    _Snow ("Snow Level", Range(0,1) ) = 0
    _SnowColor ("Snow Color", Color) = (1.0,1.0,1.0,1.0)
    _SnowDirection ("Snow Direction", Vector) = (0,1,0)
    _SnowDepth ("Snow Depth", Range(0,0.3)) = 0.1
}

Hemos creado:

  • Una variable Snow que indicará la cantidad de nieve que cubre la roca. Siempre estará en el rango 0..1
  • Un color para nuestra nieve (¡evitad el amarillo!) que por defecto es blanco.
  • Una dirección desde la que car la nieve (por defecto cae directamente hacia abajo, así que el vector de acumulación es hacia arriba)
  • Una profundidad para nuestra nieve, que usaremos para modificar los vértices de la roca en el paso 3. Esta profundidad está en el rango 0..0.3

Siguiendo lo aprendido en el primer tutorial, tenemos que asegurarnos de que nuestras variables tienen nombres apropiados:

sampler2D _MainTex;
sampler2D _Bump;
float _Snow;
float4 _SnowColor;
float4 _SnowDirection;
float _SnowDepth;

Fíjate que, dejando de lado las texturas, podemos tratar todo como floats.

Lo siguiente que necesitamos en actualizar la información de entrada de nuestro shader (la estructura Input). El mapa de texturas normales nos dará la modificación de la textura del píxel, pero para que nuestro efecto funcione vamos a necesitar obtener el valor de la normal en el mundo para poder compararlo con la dirección de la nieve.

Esto necesita leerse un poco la documentación. Dado que escribir en o.Normal en nuestro shader, tenemos que incluir en nuestra estructura de entrada INTERNAL_DATA, que nos provee Unity, y posteriormente llamar a la función WorldNormalVector en la función que necesite dicha información. En la práctica esto significa que necesitamos añadir estas cosas a la estructura Input:

struct Input {
        float2 uv_MainTex;
        float2 uv_Bump;
        float3 worldNormal;
        INTERNAL_DATA
    };

Ahora podemos escribir, finalmente, nuestro shader

void surf (Input IN, inout SurfaceOutput o) {

    // Color normal del píxel
    half4 c = tex2D (_MainTex, IN.uv_MainTex);
    // Obtiene la normal del bump map
    o.Normal = UnpackNormal (tex2D (_Bump, IN.uv_Bump));

    // Obtiene el producto escalar entre el vector normal real y la dirección de
    // la nieve y la compara con el nivel de nieve
    if(dot(WorldNormalVector(IN, o.Normal), _SnowDirection.xyz)>lerp(1,-1,_Snow)){
        // Si esto debiera ser nieve, asignar el color de la nieve.
        o.Albedo = _SnowColor.rgb;
    } else {
        o.Albedo = c.rgb;
    }
    o.Alpha = 1;
}

Vale, probablemente queramos diseccionar esta sentencia if, que es donde ocurre la magia:

  • Vamos a obtener el producto escalar de dos vectores. Uno es la dirección de la nieve y el otro el vector que se usará realmente para la normal del píxel. Una combinación de la normal en espacio del mundo para este punto y el bump map.

Obtenemos esa normal llamando a WorldNormalVector pasándole la estructura de entrada (Input IN) con nuestro INTERNAL_DATA y la normal del píxel para el bump map.

Tras realizar el producto escalar, tendremos un valor entre 1 (la normal apunta exáctamente a la dirección de la nieve) y -1 (lo opuesto)

  • Entonces comparamos este resultado (el del producto escalar) con un lerp (interpolación lineal) de forma que si el nivel de nieve es 0 (sin nieve) devolverá 1, y si el nivel es 1, devolverá -1 (toda la roca estará cubierta). Es bastante normal variar el nivel de la nieve entre 0..0.5 cuando se usa este shader, de forma que solo tenemos nieve en las superficies que realmente apuntan en la dirección de la nieve.
  • Cuando el producto escalar es mayor que el valor interpolado de nieve, usamos el color de nieve. En caso contrario, el de la textura.

Ahora tenemos un shader completo totalmente funcional con el siguiente aspecto:

Shader "Custom/SnowShader" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Bump ("Bump", 2D) = "bump" {}
        _Snow ("Snow Level", Range(0,1) ) = 0
        _SnowColor ("Snow Color", Color) = (1.0,1.0,1.0,1.0)
        _SnowDirection ("Snow Direction", Vector) = (0,1,0)
        _SnowDepth ("Snow Depth", Range(0,3)) = 0.1
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200

        CGPROGRAM
        #pragma surface surf Lambert

        sampler2D _MainTex;
        sampler2D _Bump;
        float _Snow;
        float4 _SnowColor;
        float4 _SnowDirection;
        float _SnowDepth; 

        struct Input {
            float2 uv_MainTex;
            float2 uv_Bump;
            INTERNAL_DATA
        };

        void surf (Input IN, inout SurfaceOutput o) { 

            // Color normal del píxel
            half4 c = tex2D (_MainTex, IN.uv_MainTex);

            // Obtiene la normal del bump map
            o.Normal = UnpackNormal (tex2D (_Bump, IN.uv_Bump));

           // Obtiene el producto escalar entre el vector normal real y la dirección de
           // la nieve y la compara con el nivel de nieve
            if(dot(WorldNormalVector(IN, o.Normal), _SnowDirection.xyz)>=lerp(1,-1,_Snow))
                //S i esto debiera ser nieve, asignar el color de la nieve
                o.Albedo = _SnowColor.rgb;
            else
                o.Albedo = c.rgb;
            o.Alpha = 1;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

Deformando el Modelo

El paso final es deformar el modelo para hacerlo más grande, principalmente (pero no completamente) en la dirección de la nieve.

Para hacer esto necesitamos modificar los vértices del modelo. Esto significa decirle a nuestro surface shader que queremos escribir una función para hacer justo eso


#pragma surface surf Lambert vertex:vert

Al final de la sentencia pragma añadimos un parámetro llamado vertex que provee el nombre de nuestra función de modificación de vértices: vert

Nuestra función vert tiene el siguiente aspecto:


void vert (inout appdata_full v) {
    // Convierte la normal a coordenadas del mundo
    float4 sn = mul(UNITY_MATRIX_IT_MV, _SnowDirection);
    if(dot(v.normal, sn.xyz) >= lerp(1,-1, (_Snow*2)/3))
    {
        v.vertex.xyz += (sn.xyz + v.normal) * _SnowDepth * _Snow;
    }
}

Primero le pasamos un parámetro. Esta es la información de entrada y hemos elegido usar appdata_full (provista por Unity) que contiene ambas coordenadas de texturas, la normal, la posición de los vértices y la tangente. Puedes pasar información extra a esta función especificando un segundo parámetro con tu propia estructura Input, donde puedes añadir los valores extra que quieras. Ahora mismo no necesitamos hacerlo.

La dirección de lanieve está en el espacio del mundo, pero estamos trabajando en el espacio del objeto (coordenadas del modelo) así que tenemos que transformar la dirección de lanieve al espacio del objeto multiplicándola por una matriz provista porUnity que está diseñada con ese propósito.

Ahora solo tenemos la normal del vértice, así que hacemos el mismo cálculo para la dirección de lanieve que hicimos antes, peroescalamos el nivel de lanieve en 2/3 para que solo las áreas que estén ya bien cubiertas denieve se modifiquen.

Si el test tiene éxito, entonces modificamos el vértice multiplicando su normal + la nueva dirección por el factor de profundidad y el nivel actual denieve. Esto tiene el efecto de hacer que los vértices se muevan en dirección a lanieve e incrementa la distorsión a medida que el nivel denieve crece.

¡Eso es todo! !Trabajo hecho!

Código fuente

El código completo del shader es:
Shader "Custom/SnowShader" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Bump ("Bump", 2D) = "bump" {}
        _Snow ("Snow Level", Range(0,1) ) = 0
        _SnowColor ("Snow Color", Color) = (1.0,1.0,1.0,1.0)
        _SnowDirection ("Snow Direction", Vector) = (0,1,0)
        _SnowDepth ("Snow Depth", Range(0,0.2)) = 0.1
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200

        CGPROGRAM
        #pragma surface surf Lambert vertex:vert 

        sampler2D _MainTex;
        sampler2D _Bump;
        float _Snow;
        float4 _SnowColor;
        float4 _SnowDirection;
        float _SnowDepth; 

        struct Input {
            float2 uv_MainTex;
            float2 uv_Bump;
            float3 worldNormal;
            INTERNAL_DATA
        };

        void vert (inout appdata_full v) {
          // Convierte la normal a coordenadas del mundo
          float4 sn = mul(UNITY_MATRIX_IT_MV, _SnowDirection);

          if(dot(v.normal, sn.xyz) >= lerp(1,-1, (_Snow*2)/3))
          {
             v.vertex.xyz += (sn.xyz + v.normal) * _SnowDepth * _Snow;
          }
        }

        void surf (Input IN, inout SurfaceOutput o) {
            half4 c = tex2D (_MainTex, IN.uv_MainTex);
            o.Normal = UnpackNormal (tex2D (_Bump, IN.uv_Bump));
            if(dot(WorldNormalVector(IN, o.Normal), _SnowDirection.xyz)>=lerp(1,-1,_Snow))
                o.Albedo = _SnowColor.rgb;
            else
                o.Albedo = c.rgb;
            o.Alpha = 1;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

2 pensamientos en “Unity Shaders 2: Un shader simple de nieve

  1. Pingback: Unity Shaders 3: Nieve realista | David Erosa

  2. Pingback: Unity Shaders 1: Introducción | David Erosa

Dime algo...