Character Depth Shader

Posted on Jul 28, 2023
Last updated Jul 28, 2023

An experiment with shaders in Godot for locating an object behind others.


⚠️ The version of Godot used for this experiment is 4.1

One of my favorite games, Super Mario Odyssey, includes a really nice quality of life feature for the player when an object comes between Mario and the camera.

Here is what it looks like:

mario running behind a wall and back out

As seen in the gif, the character model for Mario is still rendered even though it should be obscured by the wall. This is extremely helpful to the player since they do not lose sight of where the character is at any point.

Here is another look at a static image of the effect:

mario in a sombrero submerged partially in sand

Notice how the pixels of the material that would normally be rendered between Mario and the camera are still present, although they are replaced in a regular pattern by a dark pixel color wherever Mario is obscured.

Shaders

A couple of years ago, I became curious as to how this could be accomplished in Godot. I started learning the shader language, the basics about shaders, and what features were offered by Godot.

💭 I had no previous experience working with or writing shaders, so these were all new concepts to me.

My initial idea was to create a ShaderMaterial that could be used in addition to a StandardMaterial3D. This would mean using one material on the mesh and then setting the other as the next_pass property of the first material.

Here is the setup in Godot that I used for testing out the shader code:

mesh setup with shader overrides

The objects in use are of the type MeshInstance3D. This allows for quick prototyping, without having to create my own 3D objects. Then, using the Surface Material Override property of the MeshInstance3D, I can apply my material configuration as described above: in this case, the shader applied first, with the standard material as next_pass.

This gives a good starting point to begin writing shader code to test out the concept. The picture above shows the results of the following shader code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 1st iteration of `depth-check-simple.gdshader`

shader_type spatial;
render_mode depth_test_disabled, unshaded;

uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_linear_mipmap;

void fragment() {
    float zdepth = texture(DEPTH_TEXTURE, SCREEN_UV).r;
    float zpos = FRAGCOORD.z;

    if (zdepth < zpos) {
        ALPHA = 1.0;
    } else {
        discard;
    }
}

First thing to note would be the definition of a few render options:

4
render_mode depth_test_disabled, unshaded;

depth_test_disabled makes it so that this material will be rendered without consideration for its z-depth (distance away from the camera) in the scene, which basically means it will always be rendered on top of everything else. This is necessary for the fact that, even if the shader is able to determine when the mesh is obscured by another object, there needs to be a way to tell the renderer that the material should be rendered in front of the other object (ideally without having to trick the render pipeline with a fake z-depth, as I assume that would get tricky very fast).

unshaded is just a convenient way of being able to see the rendered material more easily, even in an unlit, or mostly dark scene (just to illustrate its effect).

Digression

A quick note about DEPTH_TEXTURE:

6
uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_linear_mipmap;

When I migrated this Godot project from version 3.5 to 4.1, I was informed by the editor that DEPTH_TEXTURE had been removed in version 4. The editor kindly informed me that I could instead utilize the above line to take advantage of its replacement, hint_depth_texture, without having to change any other code in the shader. Since this seemed to work, I went with it, although there is definitely a “version 4” way to write the shader code so that it does not exhibit the legacy use of DEPTH_TEXTURE.

Another quick note about reading from the depth texture:

editor warning about reading from the depth texture

I have the project set to use the Compatibility renderer (just because most of what I am doing does not need bleeding edge rendering features, which gives me the ability to run on more hardware). The editor (as shown in the above screenshot) tells me that what I want to do is not supported with that choice of renderer. For some reason, however, the shader still works, so I am not sure if it ends up using rendering features that may not be available on every machine or if the warning is not particularly accurate to my exact usage of the feature. Either way, only time and testing on other machines will tell, so let’s carry on (because “it works on my machine”).

Back on Track

Taking a look at the core of the shader code, we have a fragment shader that…

  1. reads from the depth texture, yielding the z-depth of the object that is going to be rendered at the given fragment’s location in screen space (this could be the fragment point in space with which we are dealing in the shader or it could be from another point in space)
  2. compares the z-depth from step 1 to the z-depth of the given fragment (this is a “simple” way of differentiating the point in space we are trying to render from something between it and the camera)
  3. uses the above information to decide whether to (a) “show” (as it otherwise would be obscured) or (b) “not show” the fragment (given the mesh we are trying to show will already be rendered normally)
 8
 9
10
11
12
13
14
15
16
17
void fragment() {
    float zdepth = texture(DEPTH_TEXTURE, SCREEN_UV).r;  // 1
    float zpos = FRAGCOORD.z;

    if (zdepth < zpos) {  // 2
        ALPHA = 1.0;  // 3a
    } else {
        discard;  // 3b
    }
}

To make this even more obvious, let’s adjust the albedo color:

ALBEDO = vec3(1, 0, 0);  // red

mesh shaded red

Excellent, so now we have an object that, when obscured by another object in the scene, is still rendered to the screen. Additionally, we have control over the “overlay” rendering (it’s not just rendering the same mesh again over top) so we can mimic the same effect of Mario.

“Texturing” the Shader

First, the code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Updated `depth-check-simple.gdshader`

shader_type spatial;
render_mode depth_test_disabled, unshaded;

uniform int magnitude = 4;
uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_linear_mipmap;

void fragment() {
    bool should_render = int(FRAGCOORD.x) % magnitude < magnitude / 2;

    if (int(FRAGCOORD.y) % magnitude < magnitude / 2) {
        should_render = !should_render;
    }

    float zdepth = texture(DEPTH_TEXTURE, SCREEN_UV).r;
    float zpos = FRAGCOORD.z;

	if (zdepth < zpos) {
        if (!should_render) {
            ALPHA = 0.0;
        } else {
            ALPHA = 1.0;
        }
	} else {
        discard;
	}
}

A shader parameter called magnitude has been added (explained later) along with the logic for determining when to render the fragment or not. The logic may seem a bit convoluted, however, in short, it is basically a “checker pattern” of either rendered or not rendered fragments. The size of the “checker pattern” can be tweaked with magnitude (lower values leading to smaller checker squares).

It was at this point, I realized the code could be made simpler, since the ALPHA = 1.0; line was not actually doing anything (since that value is the default for a basic material). Here is the final version along with an example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Final `depth-check-simple.gdshader`

shader_type spatial;
render_mode depth_test_disabled, unshaded;

uniform int magnitude = 16;
uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_linear_mipmap;

void fragment() {
    bool should_render = int(FRAGCOORD.x) % magnitude < magnitude / 2;

    if (int(FRAGCOORD.y) % magnitude < magnitude / 2) {
        should_render = !should_render;
    }

    float zdepth = texture(DEPTH_TEXTURE, SCREEN_UV).r;
    float zpos = FRAGCOORD.z;

	if (zdepth < zpos) {
        if (!should_render) {
            discard;
        }
	} else {
        discard;
	}
}

Notably, this shader also automatically works with transparent objects because the transparent objects are not added to the depth texture. The blue-ish mesh is transparent in the below gif (it may be difficult to see the difference unless viewing it at full resolution):

mesh shaded final

Complications

There are a couple of as-of-yet unsolved problems with this approach:

  1. If the object obscuring the mesh clashes with the shading used for the “overlay” rendering then it will be impossible to see the result.

mesh shaded clashing

  1. If the object obscures another part of itself, the effect is applied, since the obscuring bit is written to the depth texture as well.

mesh shaded taurus

Both of these issues are presumably solvable, but will need some further learning/experimentation.

Closing

Some discussion of the Mario effect and potential solutions using Unity can be seen in this Game Development Stack Exchange thread.

For reference to the materials created for this post, visit the link to the Godot project.

Hopefully I will be able to spend a little bit more time to figure out the last couple of issues with the shader, but this will do as a good first attempt.