I recently saw an Artstation post by Naughty Dog's Matthew Trevelyan Johns and how the tech art team developed a sort of submerged water shader. Essentially, the shader gets the position of a reference plane (water, mud, etc.) and anything underneath that plane would appear rusty and the rest of the model would have its normal textures.
That didn't seem too bad, there's even an excellent breakdown by Martin Donald and his implementation in Godot. So I thought I'd take a crack at creating the material for Unreal Engine 5.
UE5 Editor Blueprint
The shader needs to know the plane's position and surface normal angle, from there offsets and blend distances are fed as uniform values into the shader exposed as parameters for the artist to modify. Naughty Dog used a similar shader for rust based on a water plane, but other uses could be mud, general wetness, paint, etc. I think the shader could be very dynamic and useful if applied at load to procedurally paint objects in your scene.
Naughty Dog's In-Game Result - Good Stuff
My favorite shaders are ones that can really add to the artwork. They become tools for the artists to use to add to their work instead of distracting from it. Especially instances like this where shader development adds an immediate bonus to the gameplay and environmental storytelling. It hits that nice sweet spot. I wanted to try an create an implementation for my own work.
For the material, I began by grabbing the pixel's position in world space via the Absolute World Position node. I exposed two parameters that will get overwritten at runtime by the actor blueprint, these are n and h. N is the surface normal of the plane and h is the plane's distance from the origin.
These values plug into a custom node I called "Point to Plane" containing the formula: dot(pixelPosition, n) - h. This formula came from Inigo Quilez's blog and is the formula for a plane's distance function. This function lets us determine the intersection of an object by a plane. The next custom node is used to soften the intersection area. Without this node and parameters, there would be no blending between the two materials. See the picture below. The multiple scalar parameters and lerp nodes are to make the values a little easier to digest in the material instance UI. The final edge softening function: max(min((dist + blend_offset) * blend_size + 0.5, 1.0), 0.0);
Material Part 1
No Blend Tweaks
The next section takes the final blended mask and overlays a noise textures on top. This will break up the transition so it's not a pure gradient. This is a nice feature because depending on the surface (wet, mud, rust, etc.) all the artists needs to do is swap out the grayscale noise with one that matches the surface. After the overlay node, two sets of standard PBR textures are lerped by the new blended overlay (which is pure black to white). Plug these into the nodes and voila!
Material Part 2!
But there's more! The material isn't enough. Technically an artist could take the material as-is and play with the n and h parameters and get good results, but we can get that info from a plane via a script or Blueprint. Essentially we're creating a dynamic material and setting the n and h parameters driven by the plane's attributes.
Full Blueprint Script
I start by setting the In Editor event so I can set the material and update it in the editor. This makes a handy UI button, I use this as a refresh. From there we need to get the static mesh of the plane, so casting the created external var "Plane Mesh" as a StaticMeshActor and going through the Static Mesh Component and getting the Static Mesh lets us use the mesh's data. In this case I'm after the plane's surface normal.
BP - Creating a Dynamic Material and Getting the Static Mesh Data
Once we have access to the mesh data, we need the normals in world space, so we transform the normal based on the actor's transform with the "Get Actor Transform" node and feed it into the "Transform Location", after that we normalize the surface normal and use it to drive the n parameter in our material.
BP - Normalizing the Surface Normal and Setting Material Values
The only other thing we need is the world location of the plane and get the dot product of the world location and the normalized surface normal, again feed that into the material parameter. Below are alternative ways of getting the exact surface normal via two edges of a triangle.
Getting the Dot Product of the Plane and Surface Normal
Manual Cross Product - Gross Noodle Code
Thanks for checking out this post! I'll try and add the shader to something other than a capsule to it looks dope. No time for post-apocalyptic Astro vans sadly.
©2021 Bruce Evans