Cracked Ice Shader (using Parallax Effect)

Over the holidays I have decided to spend some time learning a little more about shaders, and this time I wanted to investigate a little more about parallax effects. Due to the recent weather and the time of the season I have decided to make an ice shader, and also because I have been playing a lot of Dragon Age: Inquisition. I began my journey by exploring what other people have made using parallax in Unity.

At first I came across a tutorial by Binary Impact in which a similar effect is shown, but they have used Unity’s Shader Graph instead – link – but I personally prefer writing the shader myself, although it was a great first introduction to what the effect is and how it can be utilized.

Binary Impact shows an approach to creating a cracked ice shader that mimics the depth without adding any geometry to the ground. This was exactly what I wanted, but while their approach had a good effect, it wasn’t exactly what I was looking after, but it was a great starting point.

Left: Ali Youssef’s cracked ice shader | Right: Dragon Age: Inqusition, cracked ice in Emprise du Lion

As you can see in the screenshot above, the two shaders are quite distinct. The image on the left shows the final effect shown in Binary Impact’s shader, where the cracks seem to be going deeper into the ice; on the other hand, the image on the right is the stylized shader seen in the Dragon Age game where the cracks are on the surface level but also quite deep in the ice and there are multiple layers to the ice.

I have first begun experimenting in shader graph to try and re-create this effect, and potentially get closer to creating the effect I’m after. As the base, I have used Binary Impact’s shader, which is actually a translated version of Unreal Engine shader by Ali Youssef.

I removed some of the layering at first, as in my shader I will only need three layers maximum, but that can be adjusted later on. 

For now I will share the graph I have been working with, and later in the post I will share the Unity shader code.


Shader Graph

image showing three layers of cracks combined together
In this variant, I will only be using three layers to create the depth effect. Without the ADD node at the top left corner, the first layer would show on the surface.

In my shader, the first layer would be displayed on the surface; this is where I would like to have the first cracks in the ice shown. Next, I use the shader’s normal texture to distort the cracks which are on layers two and three, this makes them look more realistic and as if they are actually underneath the surface and they are distorted by the rough icy surface.

image showing normal texture altering UVs of the cracks texture
In this version, I use the normal texture to offset the UVs of the cracks texture.

The next change is small but allows users more choice; I want the user to be able to adjust the colors of the surface, and for this I have made the texture white and black, and I will apply the color specified by use on top of the black and white texture; the darker areas will appear more shallow and closer to surface while white areas will appear deeper under the surface.

Diffuse texture used in shader, other textures come from Binary Impact’s tutorial.

From there on, I have tried different values and offsets to get as close to the effect as I could; additionally I wanted to think about optimizations, so I have decided to pack my three cracks textures into one file – in order to do this, each channel of a texture (RED – GREEN – BLUE) have their own cracks texture. You can read more about using different channels of a texture and packing textures on Polycount, here.

Using the RGB channels to pack different crack textures.

Shader Code

The next part of my journey was figuring out how to recreate this effect using code, and I have never done the parallax effect myself in code and it also uses tangent space view direction which is something I have never used before.

Thankfully, Harry Alisvakis has written a tutorial about parallax effects and how to write them in code and the reference used for Harry’s post was also the Binary Impact video. You can find the link to Harry’s post here.

I don’t want to copy what Harry has written in his post, so feel free to read it in your time to learn more about the effect, but in order to achieve the tangent space we need to do some math in the vertex shader:

float4 objCam = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos, 1.0));
float3 viewDir = v.vertex.xyz - objCam.xyz;
float tangentSign = v.tangent.w * unity_WorldTransformParams.w;
float3 bitangent = cross(v.normal.xyz, v.tangent.xyz) * tangentSign;
o.viewDirTangent = float3(
    dot(viewDir, v.tangent.xyz),
    dot(viewDir, bitangent.xyz),
    dot(viewDir, v.normal.xyz)
);

What we are doing here is calculating the view direction and the bitangent in order to multiply our view direction by the tangent, bitangent and normal values. Just like with Binary Impact’s shader, I needed to make some adjustments in order to achieve the desired effect.

In order to achieve the effect I want, I need to first create a custom blend function; luckily Unity’s shader graph is well documented and I was able to create a function that gives me the same result as in the shader graph.

// From Unity's shader graph manual.
float4 blendMultiply (float4 baseTex, float4 blendTex, float opacity)
{
    float4 baseBlend = baseTex * blendTex;
    float4 ret = lerp(baseTex, baseBlend, opacity);
    return ret;
}

Next is the surface (fragment) function – I start off by sampling the main diffuse texture first, this is the black and white texture I have mentioned before and then I apply the tint on top of it by multiplying the tint. Next I sample the normal texture, which we first need to unpack, and finally I also sample the roughness texture which is just a float (one channel).

fixed4 mainTex = tex2D(_MainTex, IN.uv_MainTex) * _IceTint;
fixed3 normalTex = UnpackNormal(tex2D(_NormalTex, IN.uv_NormalTex));
fixed rougnessTex = tex2D(_Roughness, IN.uv_Roughness).r * _RoughnessStrength;

Next I loop through all of the layers (cracks), this is not the most performant option as GPU’s don’t like loops and if statements… and we’re using both here. But in our case the shader isn’t very heavy and the performance impact here won’t be noticeable so we’ll go with this for now and if I can find some optimizations I will update this post/make a new post.

If this is the first time looping (surface layer), I want to do nothing – the layers below the surface only should be distorted. If this is the second loop, first layer of cracks underneath the surface, I want to apply the distortion to the UV’s of the texture. I will be using the `lerp()` function with the normalized tangent view direction in order to properly calculate the offset for the parallax effect.

Finally at the end I add the normal texture to the UVs of the cracked ice shader, this is what creates the distortion effect. I sample the green channel to get the second cracks texture out of the packed texture, and multiply this by a parameter defined in the inspector to change the alpha of the texture (this way we can make deeper layers less opaque). I do the same for the third layer.

Finally, in Binary Impact’s video we can see a multiply blend node where the diffuse texture and cracks texture is blended together. This is the custom function I have mentioned earlier in the post. Here is the final effect:

fixed parallax = 0;
for (int j = 0; j < 4; j ++) {
    float ratio = (float) j / 4;
 
    if (j == 0) {
        // I don't want to show the first layer, because this would be flat on the object (no depth),
        // I want to start with the second iteration of the parallax effect, which will have depth.
    }
    else if (j == 1) {
        // First layer of cracks.
        parallax += tex2D(_CrackLayers, IN.uv_CrackLayers + lerp(0, _OffsetScale, ratio) * normalize(IN.viewDirTangent) + normalTex).g * _CracksStrength.y;
    } else if (j == 2) {
        // Second layer of cracks.
        parallax += tex2D(_CrackLayers, IN.uv_CrackLayers + lerp(0, _OffsetScale, ratio) * normalize(IN.viewDirTangent) + normalTex).b * _CracksStrength.z;
    } else if (j == 3) {
        // Third layer of cracks.
        parallax += tex2D(_CrackLayers, IN.uv_CrackLayers + lerp(0, _OffsetScale, ratio) * normalize(IN.viewDirTangent) + normalTex).r * _CracksStrength.w;
    }
}
parallax *= 1.5;

fixed4 blended = blendMultiply(mainTex, parallax, 0.55);
o.Albedo = blended;
Final effect of the cracked ice shader.

After that I have applied the snow effect which can be seen in the Binary Impact shader, to achieve the same effect on the surface of the ice.

That is it, this is the shader; it is not as difficult as I first expected and was a good learning experience for myself. If you have any questions about the shader, feel free to leave a comment down below; I will do my best to answer! I will shortly be posting a link to the shader code on GitHub.

Once again, thank you to Yosuf for originally creating the effect in Unreal Engine. Thank you to Binary Impact for creating this effect in shader graph and supplying the textures and lastly but not least thank you to Harry for writing about the parallax effect and showing how to write it in code.


logo-black-transparent

Leave a Comment