Part 3: The Snow Shader
The juiciest part: the actual snow shader! Before starting to work on the actual code, it's always useful to make a list of the feature we'll need in our shader to get a realistic look.
- displacement: fresh snow is rarely a completely flat surface, it has hills and valleys
- fine grain: fresh snow is grainy and full of micro structures that reflect light
- icy cristals: the ice crystals reflect the light pretty sharply, even from distance
- material blending: we'll need to somehow blend between the fresh snow material and the roof material below
We'll make use of the surface shader system provided by Unity, which will avoid us a lot of code rewriting to support different platforms and render pipelines, letting us focus on the important stuff.
Displacement
To displace vertex in a surface shader we'll need to declare a vertex function in which we're going to do our calculations, and hook it up to the surface system by adding vertex:custom_vertex_function_name to the #pragma directive we already have in our shader code. Inside our vertex function, we'll need to compute the snow height by sampling the _HeighTex provided by our custom depthProcessor shader, thus having the very foundation of our deformable snow system, and displace the vertices along the up vector (which in Unity's case is the Y vector: float3(0.0, 1.0, 0.0)) by the amount indicated by the _HeightTex aforementioned. This is not particularly complex to implement (actually it's just a matter of adding the height to the current vertex position) but if you check how the plane renders at this point you'll notice a happy little artifact: the surface isn't being shaded properly along the curved surface generated by the snow trails. If you check the Deferred Normal draw mode (or just output the normal value in the Albedo field of the shader) you'll immediately understand why: the normals along these steps are exactly the same as anywhere else on the surface.
This happens because in the original mesh each normal is pointing in the same direction, and even if you're displacing the vertices, the stored normal informations remain the same. This means we'll have to find a way to somehow re-orient the normal in a way that follows the displaced surface. If you've studied some physics at school, you may remember a vector operation which gives you a vector perpendicular to something. This operation may not be as famous as the dot product but it's still fundamental in 3D computer graphic, and goes by the name of cross product.
The Cross Product
In mathematics and vector algebra, the cross product is a binary operation on two vectors in three-dimensional space R3 and is denoted by the symbol x . Given two linearly independent vectors a and b , the cross product a x b is a vector that is perpendicular to both a and b and thus normal to the plane containing them. (you can find more info here on wikipedia)
So the cross product actually gives us a vector normal to the surface on which the two vectors are lying. That sounds nice! How do we get vectors a and b in the first place though? The idea itself is actually quite simple. We try to get an idea of how the surface is in the nearest surroundings of our vertex by sampling the heightmap two more times, one slightly to the right (moving on the u axis) and one slightly forward (moving on the v axis). We can then build two vectors originating from the current vertex and pointing respectively right and forward, and cross-product them to get the correct world-space normal for the surface. Here's an example of how we could implement this kind of technique:
[...] //define a fixed constant to offset uv with. can also be exposed as a parameter, or be dependent on the _TexelSize #define OFFSET_CONST (0.0025) [...] float3 d; //fill the displacement vector with the _HeightTex values //remember we're in the vertex shader, so we'll need a tex2D function that works regardless of the partial derivatives of u and v //(essentially used to compute the correct mip level), so tex2Dlod should work. you can find more info here d.x = tex2Dlod(_HeightTex, float4(v.texcoord.xy, 0, 0)).r; d.y = tex2Dlod(_HeightTex, float4(v.texcoord.x + OFFSET_CONST, v.texcoord.y, 0, 0)).r; d.z = tex2Dlod(_HeightTex, float4(v.texcoord.x, v.texcoord.y + OFFSET_CONST, 0, 0)).r; //v0 is the current vertex, v1 and v2 are fake neighbour vertices used to compute the normal float4 v0 = mul(unity_ObjectToWorld, v.vertex); float4 v1 = v0 + float4(OFFSET_CONST, 0, 0, 0); float4 v2 = v0 + float4(0, 0, OFFSET_CONST, 0); //get the correct height for the three point needed for analytical normal reconstruction v0.y += d.x * tex2Dlod(_HSnowMixMap, float4(v.texcoord.xy, 0, 0)).b; v1.y += d.y * tex2Dlod(_HSnowMixMap, float4(v.texcoord.x + OFFSET_CONST, v.texcoord.y, 0, 0)).b; v2.y += d.z * tex2Dlod(_HSnowMixMap, float4(v.texcoord.x, v.texcoord.y + OFFSET_CONST, 0, 0)).b; //compute the world space normal as the cross product of the Z and the X vector float3 wsn = normalize(cross((v2 - v0).xyz, (v1 - v0).xyz));
Note that the cross product IS NOT commutative, so pay attention to the order of the vectors if you don't want to get an inverted normal.
Fine Grain
Get the correct snow grain it's actually just a matter of nailing the texture in Substance Designer. Look at this reference photo, for instance:
As you can see, the snow itself, apart from the icy cristals it's quite rough, so mixing something like a Clouds2 and a BnW Spots 2 should give you a good start. Something like this, for example:
Icy Cristals
The icy cristals are actually just very small and reflective surfaces, scattered in random directions among the snow flakes. The idea is create a black texture with very tiny white dots (they can be as small as a single pixel, actually) scattered around, ad sum them to your specular texture. Of course, depending on the workflow (specular or metallic), you'll need to invert the texture colors. My approach to make them visible even with very low light was actually output them in the Emissive channel as well, to get a clear bright reflection. I also added a tunable falloff range near the camera, since I noticed that having these bright dots in the immediate vicinity wasn't very pleasant, but that's just a matter of personal taste, so you can actually eliminate the falloff completely.
Material Blending
Since we're done with the snow material writing, it's time to get to the last step: the blending. We'll need two sets of textures, one for the snow and one for the roof below, but with a bit of packing we can get away with just six textures in total. The blending should be based on the _HeightTex processed in chapter one of this article series. Note that the value sampled from this texture it's just a grayscale value varying from one (soft snow) to zero (roof and trampled snow) in a linear fashion, which is not ideal when used to blend from very different colors (let's say, for example, from the snow to an orange brick roof). We'll need to empirically determine the best transition sharpness to get both a believable color blend and avoid a sharp, pixellated transition line, especially if we're working with a low-res depth buffer. The strategy is to use a float remap function to "move" the values into the desired range, like this one:
inline float remap(float value, float oldMin, float oldMax, float newMin, float newMax)
{
return newMin + (value - oldMin) / (newMax - newMin) * (oldMax - oldMin);
}which can even be optimized if the desired oldMin and oldMac values are 0 and 1. Notice that if we do something like
blend_amount = remap(tex2D(_HeightTex, uv).r, 0.0, 1.0, 0.0, 0.5)
whenever the sampled texture value is above 0.5 the result will become > 1.0, so it's necessary to clamp the value after the remap function if you're planning to use it in a lerp function like we're about to do.
The only thing we're left with is to actually blend the values from the snow sampling and the roof sampling for each channel (Albedo, Specular / Metallic, Roughness, Normal, Emissive, Occlusion and Alpha). As always, there's room for improvement! For example we could consider that when snow gets trampled it does not just disappear, but instead spreads around the impact point, originating smaller snow heaps here and there, like this:
We could then try to implement some kind of trampled snow instead of just showing the plain roof textures, blending them with a lowered version of the same snow textures we're already using before blending them again with the soft snow samples.
You can take a look at the complete result in action here:
You can find the complete shader code here on GitHub
This is the end of Part 3 and of this article series as well. You can find Part 1 here and Part 2 here











