top of page
Search
  • alexlpc2015

[Shader Notes] #06 - Snow Field

Hi and welcome to my Shader Notes Series!

This is a comprehensive project with notes about shaders of nearly every common effect included (implemented in Unity). I will explain the key points and theories behind them for you to understand them thoroughly and then enjoy creating your own shaders with proficiency.

Let's start learning together!


#06 - Snow Field

Difficulty: 7/10

Points covered:

  • Tessellation

  • Texture Drawing

  • Height Displacement

  • Collision Detection

In this tutorial, we are going to create a snowfield shader that allows a “trampling” effect. This is perfect example as to how shaders and C# scripts cooperate. To achieve this relatively complex goal, let’s divide it into small steps.

First, to bring height difference to the plane where the track is, we can displace the vertices in the vertex shader. Normally, the number of vertices of a plane will not create enough details, so we need to embrace the power of tessellation - the process to divide triangles into smaller ones.

Next, to trace the track, we need to prepare another temporary texture (with another shader) for the player character to “draw” on. Then we can read the texture as a height map. On the C# side, some collision detection will be needed.

Finally, to erase the track drawn a long time ago, we need to dynamically update the temporary texture. Also, don’t forget to lerp between the snow texture and the ground one.

Okay! Now that the idea is formed, let’s dive into the code!



Final effect:




Setup:

Create a new material and attach it to an object.

Prepare the properties and declare them in CGPROGRAM.

//Properties
_Tess ("Tessellation", Range(1,32)) = 4
_SnowTex ("Snow Texture", 2D) = "white" {}
_SnowColor ("Snow Color", color) = (1,1,1,0)
_GroundTex ("Ground Texture", 2D) = "white" {}
_GroundColor ("Ground Color", color) = (1,1,1,0)
_DispTex ("Disp Texture", 2D) = "gray" {}
_NormalMap ("Normalmap", 2D) = "bump" {}
_Displacement ("Displacement", Range(0, 1.0)) = 0.3
_SpecColor ("Spec color", color) = (0.5,0.5,0.5,0.5)

To make things easier and focus on the important points, let’s use a surface shader to get rid of the lighting code. Also, tessellation is relatively easy to implement in a surface shader.

#pragma surface surf BlinnPhong

Point #1. Tessellation

To implement tessellation in our shader, the first thing to do is to specify the function name. The tessDistance function tessellates the mesh according to its distance from the camera. This is copied from the Unity manual here: https://docs.unity3d.com/Manual/SL-SurfaceShaderTessellation.html

//notice the tessellate:tessDistance line
#pragma surface surf BlinnPhong addshadow fullforwardshadows vertex:disp tessellate:tessDistance nolightmap
//don't forget the cginc
#include "Tessellation.cginc"

float4 tessDistance (appdata v0, appdata v1, appdata v2) {
    float minDist = 10.0;
    float maxDist = 25.0;
    return UnityDistanceBasedTess(v0.vertex, v1.vertex, v2.vertex,         
    minDist, maxDist, _Tess);
}

Point #2. Height Displacement

In the vertex shader, displace the vertex position using the displacement texture which will be generated later. Note that we specified the vertex function to have the name “disp”.

void disp (inout appdata v)
{
    float d = tex2Dlod(_DispTex, float4(v.texcoord.xy,0,0)).r *       _Displacement;
    v.vertex.xyz -= v.normal * d; //Create sunken areas
    v.vertex.xyz += v.normal * _Displacement; /*Raise the plane to the     level where the height of the sunken area matches the original height.*/
}

Then, prepare 2 textures, one for the snow, one for the ground.

_SnowTex ("Snow Texture", 2D) = "white" {}
_GroundTex ("Ground Texture", 2D) = "white" {}

In the surface function, sample the two textures and simply lerp between them using the height information.

void surf (Input IN, inout SurfaceOutput o) {
    fixed4 snowCol = tex2D(_SnowTex, IN.uv_SnowTex)*_SnowColor;
    fixed4 groundCol = tex2D(_GroundTex, IN.uv_GroundTex)*_GroundColor;

    half amount = tex2Dlod(_DispTex, float4(IN.uv_DispTex, 0,0)).r;
    fixed4 c = lerp(snowCol, groundCol, amount);

    o.Albedo = c.rgb;
    o.Specular = 0.2;
    o.Gloss = 1.0;
}

That’s about it for our first shader!


Point #3. Drawing the Height Map

Now let us focus on the C# script to draw out the height map according to the player’s position. First create a SnowField class and attach it to the game object.

Remember we need to create a temporary texture with another shader (let’s call it track shader). Declare that shader here along with some other fields.

public class SnowField : MonoBehaviour
{
    public Shader shader; //Track shader
    public Texture2D noiseTex;

    public GameObject player;

    [Range(0.01f,1f)]
    public float brushSize = 1;
    public float brushStrength = 1;
    [Range(0.01f, 2f)]
    public float noiseStrength = 1;

    [Range(0.01f,5f)]
    public float recoverRate = 1;

    private RenderTexture rt;
    private Material snowMat, trackMat;
    private RaycastHit hit;
}

Since I am working with it in editor mode, I put the initialization code in the OnEnable lifecycle function and also attach the update to the editor update.

Here, create a new render texture to draw on.

private void OnEnable()
{
    //This is the new shader.
    trackMat = new Material(shader);
    trackMat.SetVector("_Color", Color.red);

    //This shader is what we've just written!
    snowMat = GetComponent<MeshRenderer>().sharedMaterial;

    //Set the _DispTex as the render texture.
    rt = new RenderTexture(1024, 1024, 0, RenderTextureFormat.ARGBFloat);
    snowMat.SetTexture("_DispTex", rt);

    EditorApplication.update += Update;
}

private void OnDisable()
{
    EditorApplication.update -= Update;
}

In the update function, do a raycast from the player’s bottom to the ground to determine the contact point. Then, we call the DrawTrack and the Recover functions. Let’s take a look at them one by one.

private void Update()
{
    if (!rt)
        return;

    Recover();

    if (!player)
        return;

    Physics.Raycast(player.transform.position, Vector3.down, out hit);
    if (hit.collider != null)
    {
        DrawTrack();
    }
}

When drawing the track, there are two things: 1. pass some variables into the track shader. 2. Copy the texture from the last frame to be used as a new texture for the current frame. This is donw by calling Graphics.Blit.

private void DrawTrack()
{
    trackMat.SetVector("_Coordinate", new Vector4(hit.textureCoord.x, hit.textureCoord.y, 0, 0));
    trackMat.SetFloat("_BrushSize", brushSize);
    trackMat.SetFloat("_Strength", brushStrength);
    trackMat.SetTexture("_NoiseTex", noiseTex);
    trackMat.SetFloat("_NoiseStrength", noiseStrength);

    RenderTexture t = RenderTexture.GetTemporary(rt.width, rt.height, 0, RenderTextureFormat.ARGBFloat);
    ///IMPORTANT! 
    ///Use a temp render texture to preserve the track drawn before. (Draw track shader will read the pixels of the last texture
    ///and add new track onto it.)
    Graphics.Blit(rt, t);
    Graphics.Blit(t, rt, trackMat);
    RenderTexture.ReleaseTemporary(t);
}

Recovering is basically the same thing since the main logic will be in the track shader.

private void Recover()
{
    trackMat.SetFloat("_Recover", recoverRate);
    RenderTexture t = RenderTexture.GetTemporary(rt.width, rt.height, 0, RenderTextureFormat.ARGBFloat);
    ///IMPORTANT! 
    ///Use a temp render texture to preserve the track drawn before. (Draw track shader will read the pixels of the last texture
    ///and add new track onto it.)
    Graphics.Blit(rt, t);
    Graphics.Blit(t, rt, trackMat);
    RenderTexture.ReleaseTemporary(t);
}

At this point, the C# part is also complete! It’s time to work on the final piece of code - the track shader!


Point #4. The Track Shader

Create a new shader, and write out the properties. These correspond to the variables we pass from the C# script.

Properties
{
    _MainTex ("Texture", 2D) = "white" {}
    _NoiseTex ("Noise Texture", 2D) = "black" {}
    _Coordinate ("Coordinate", Vector) = (0,0,0,0)
    _Color ("Color", Color) = (1,0,0,0)
    _BrushSize ("Brush Size", float) = 1
    _Strength ("Brush Strength", float) = 1
    _Recover ("Recover", float) = 1
    _NoiseStrength ("Noise Strength", float) = 1
}

The vertex function is trivial, since we only need this shader to draw on a texture.

v2f vert (appdata v)
{
  v2f o;
  o.vertex = UnityObjectToClipPos(v.vertex);
  o.uv = TRANSFORM_TEX(v.uv, _MainTex);
  return o;
}

In the fragment shader, essentially we are using a formula to procedurally draw out circles along the player position. Then we apply some noise to make the circles rougher, and make the texture recover over time.

fixed4 frag (v2f i) : SV_Target
{
    //sample the texture from the last frame
    fixed4 col = tex2D(_MainTex, i.uv);

    //sample some noise to avoid drawing perfect circles
    fixed4 noiseCol = (tex2D(_NoiseTex, i.uv + frac(_Time.y * 0.1)) -0.5)* 0.05 * _NoiseStrength;
                
    //prodecurally draw a circle at the player position
    float draw = pow(saturate(1 - distance(i.uv, _Coordinate.xy + noiseCol.xy)), 50 / _BrushSize);
    fixed4 drawCol = _Color * draw * _Strength;

    //finally subtract a little amount from the color (used to indicate the depth) to recover
    return saturate(col +drawCol - _Recover * 0.0002);
}

And now, my dear reader, we are done with all of the code!

ENDCG

Finally, assemble the scene, add the scripts, drag in the references, and you now have a beautiful interactive snow field!

By tweaking the variables and textures, this effect can be applied to a lot of scenarios, such as a sand land:



I hope this chapter is helpful. See you in the next tutorial!


Full Code:

SnowField.shader

Shader "SnowField" {
        Properties {
            _Tess ("Tessellation", Range(1,32)) = 4
            _SnowTex ("Snow Texture", 2D) = "white" {}
            _SnowColor ("Snow Color", color) = (1,1,1,0)
            _GroundTex ("Ground Texture", 2D) = "white" {}
            _GroundColor ("Ground Color", color) = (1,1,1,0)
            _DispTex ("Disp Texture", 2D) = "gray" {}
            _NormalMap ("Normalmap", 2D) = "bump" {}
            _Displacement ("Displacement", Range(0, 1.0)) = 0.3
            _SpecColor ("Spec color", color) = (0.5,0.5,0.5,0.5)
        }
        SubShader {
            Tags { "RenderType"="Opaque" }
            LOD 300
            
            CGPROGRAM
            #pragma surface surf BlinnPhong addshadow fullforwardshadows vertex:disp tessellate:tessDistance nolightmap
            #pragma target 4.6
            #include "Tessellation.cginc"

            struct appdata {
                float4 vertex : POSITION;
                float4 tangent : TANGENT;
                float3 normal : NORMAL;
                float2 texcoord : TEXCOORD0;
            };

            float _Tess;

            float4 tessDistance (appdata v0, appdata v1, appdata v2) {
                float minDist = 10.0;
                float maxDist = 25.0;
                return UnityDistanceBasedTess(v0.vertex, v1.vertex, v2.vertex, minDist, maxDist, _Tess);
            }

            sampler2D _DispTex;
            float _Displacement;

            void disp (inout appdata v)
            {
                float d = tex2Dlod(_DispTex, float4(v.texcoord.xy,0,0)).r * _Displacement;
                v.vertex.xyz -= v.normal * d;
                v.vertex.xyz += v.normal * _Displacement;
            }

            struct Input {
                float2 uv_SnowTex;
                float2 uv_GroundTex;
                float2 uv_DispTex;
            };

            sampler2D _SnowTex;
            sampler2D _GroundTex;
            sampler2D _NormalMap;
            fixed4 _SnowColor;
            fixed4 _GroundColor;

            void surf (Input IN, inout SurfaceOutput o) {
                fixed4 snowCol = tex2D(_SnowTex, IN.uv_SnowTex)*_SnowColor;
                fixed4 groundCol = tex2D(_GroundTex, IN.uv_GroundTex)*_GroundColor;
                half amount = tex2Dlod(_DispTex, float4(IN.uv_DispTex, 0,0)).r;
                fixed4 c = lerp(snowCol, groundCol, amount);
                o.Albedo = c.rgb;
                o.Specular = 0.2;
                o.Gloss = 1.0;
            }
            ENDCG
        }
        FallBack "Diffuse"
    }

SnowTrackGeneration.shader

Shader "SnowTrackGeneration"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _NoiseTex ("Noise Texture", 2D) = "black" {}
        _Coordinate ("Coordinate", Vector) = (0,0,0,0)
        _Color ("Color", Color) = (1,0,0,0)
        _BrushSize ("Brush Size", float) = 1
        _Strength ("Brush Strength", float) = 1
        _Recover ("Recover", float) = 1
        _NoiseStrength ("Noise Strength", float) = 1
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

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

            sampler2D _MainTex;
            sampler2D _NoiseTex;
            float4 _MainTex_ST;

            fixed4 _Coordinate;
            fixed4 _Color;

            float _BrushSize;
            float _Strength;
            float _Recover;
            float _NoiseStrength;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                fixed4 noiseCol = (tex2D(_NoiseTex, i.uv + frac(_Time.y * 0.1)) -0.5)* 0.05 * _NoiseStrength;
                            
                float draw = pow(saturate(1 - distance(i.uv, _Coordinate.xy + noiseCol.xy)), 50 / _BrushSize);
                fixed4 drawCol = _Color * draw * _Strength;
    
                return saturate(col +drawCol - _Recover * 0.0002);
            }
            ENDCG
        }
    }
}

SnowTrack.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using UnityEngine.UI;

[ExecuteInEditMode]

public class SnowField : MonoBehaviour
{
    public Shader shader;
    public Texture2D noiseTex;

    public GameObject player;

    [Range(0.01f,1f)]
    public float brushSize = 1;
    public float brushStrength = 1;
    [Range(0.01f, 2f)]
    public float noiseStrength = 1;

    [Range(0.01f,5f)]
    public float recoverRate = 1;

    private RenderTexture rt;
    private Material snowMat, trackMat;
    private RaycastHit hit;

    private void OnEnable()
    {
        trackMat = new Material(shader);
        trackMat.SetVector("_Color", Color.red);

        snowMat = GetComponent<MeshRenderer>().sharedMaterial;

        rt = new RenderTexture(1024, 1024, 0, RenderTextureFormat.ARGBFloat);
        snowMat.SetTexture("_DispTex", rt);

        EditorApplication.update += Update;
    }
    private void OnDisable()
    {
        EditorApplication.update -= Update;
    }
    private void Update()
    {
        if (!rt)
            return;

        Recover();

        if (!player)
            return;

        Physics.Raycast(player.transform.position, Vector3.down, out hit);
        if (hit.collider != null)
        {
            snowMat.SetTexture("_DispTex", rt);
            DrawTrack();
        }
    }

    private void Recover()
    {
        trackMat.SetFloat("_Recover", recoverRate);
        RenderTexture t = RenderTexture.GetTemporary(rt.width, rt.height, 0, RenderTextureFormat.ARGBFloat);
        ///IMPORTANT! 
        ///Use a temp render texture to preserve the track drawn before. (Draw track shader will read the pixels of the last texture
        ///and add new track onto it.)
        Graphics.Blit(rt, t);
        Graphics.Blit(t, rt, trackMat);
        RenderTexture.ReleaseTemporary(t);
    }

    private void DrawTrack()
    {
        trackMat.SetVector("_Coordinate", new Vector4(hit.textureCoord.x, hit.textureCoord.y, 0, 0));
        trackMat.SetFloat("_BrushSize", brushSize);
        trackMat.SetFloat("_Strength", brushStrength);
        trackMat.SetTexture("_NoiseTex", noiseTex);
        trackMat.SetFloat("_NoiseStrength", noiseStrength);

        RenderTexture t = RenderTexture.GetTemporary(rt.width, rt.height, 0, RenderTextureFormat.ARGBFloat);
        ///IMPORTANT! 
        ///Use a temp render texture to preserve the track drawn before. (Draw track shader will read the pixels of the last texture
        ///and add new track onto it.)
        Graphics.Blit(rt, t);
        Graphics.Blit(t, rt, trackMat);
        RenderTexture.ReleaseTemporary(t);
    }
}

Thank you for reading!

13 views0 comments

Recent Posts

See All
Post: Blog2_Post
bottom of page