2D Isometric Shadows in Godot 4
Here’s a sneak preview of the effect in action.
The Problem
Godot has pretty good support for 2D shadows out of the box. 2D lights and light occluders can be used to create shadows that look great in top-down or side-scrolling games.
However you’ll quickly see limitations when working in an isometric project.
As you can see here, instead of the shadow stopping at the base and climbing vertically up the wall like one would expect, the shadow is projected across the isometric wall as if it weren’t there. We shouldn’t expect anything different because our “isometric wall” is just another 2D sprite to the engine. There’s no real depth or height information to project correct looking shadows like there would be if this were a 3D scene.
So how do we solve this problem?
✨Shaders✨
Godot provides a neat fragment shader built-in for CanvasItem (and CanvasItem subclasses) called SHADOW_VERTEX.
This built-in provides the pixel position in screen-space of the current fragment in regards to the 2D shadowmap generated by Godot. Why is this useful? Well the really neat part of this built-in is that it is an “inout” built-in which means we can WRITE to it. Meaning we can effectively manipulate the position of our fragment when it comes to shadowing.
The Desired Effect
When the shadow hits the base of our wall we want it to travel vertically up the wall as if there were really a 3D wall there.
So at any fragment that we’re rendering of the wall, if we change its SHADOW_VERTEX’s Y value to sit at the base of the wall where a shadow may intersect, it will also be shaded as if it was itself in the shadow’s path.
So consider we’re shading a fragment somewhere in the green dot in the image above. We need to modify its SHADOW_VERTEX.Y value to the screen-space coordinate of the base of the wall (denoted by the blue dot).
Finding the base of the wall
Finding the correct SHADOW_VERTEX.Y value is a little tricky.
The first step is to determine the fragment’s position in a screen-space coordinate system with an origin at the start of the base of the wall.
I accomplished this by sending the base origin in screen-space coordinates (denoted by the red dot in the image above) to the fragment shader as a uniform.
public override void _Process(double delta)
{
var basePosition = new Godot.Vector2 {
X = this.Position.X - (this.GetRect().Size.X * this.Scale.X * 0.5F),
// The start of the wall's base is roughly 130 pixels from the bottom of the texture
Y = this.Position.Y + (this.GetRect().Size.Y * this.Scale.Y * 0.5F) - 130.0
};
var cameraTransform = GetViewport().GetCamera2D().Transform;
var cameraPosition = basePosition * cameraTransform;
var screenPosition = cameraPosition * this.GetViewport().GetScreenTransform();
(this.Material as ShaderMaterial).SetShaderParameter("shadow_offset", screenPosition);
}
This script first obtains a point at the start of the base. That point is then transformed by the current camera and viewport to obtain the screen-space coordinate at the start of the base. That point is then set as the value of the shader uniform “shadow_offset” for use in the fragment shader.
shader_type canvas_item;
uniform vec2 shadow_offset;
void fragment() {
float baseHeight = shadow_offset.y + (VERTEX.x - shadow_offset.x) * 0.5;
if (VERTEX.y < baseHeight) {
SHADOW_VERTEX.y = baseHeight;
}
}
Now that we have the shadow_offset
available in the shader, we can use it to calculate the screen-space Y value of the base of the wall.
Because this is an isometric sprite, we know the slope of the wall as it travels across the X axis is 1/2 or 0.5. So if we take the difference of our fragment’s current X position (VERTEX.x) and our wall base X position (shadow_offset.x) and multiply that by 0.5, and add that to our wall base Y position we have the current Y position of the base of our wall!
From there we assign that value to our local variable baseHeight
and check if our fragment is above that height. If so we set our SHADOW_VERTEX.y
value to baseHeight
to make sure it is shadowed correctly.
Limitations
Passing a custom uniform with the base position to each sprite is not ideal. I originally wanted to get this to work with a tilemap but I haven’t found a straightforward way to pass uniforms to tiles on a tile by tile basis.
Additionally this technique needs adjusting if the Y difference between the sprite base and the start of the actual object differs between your different sprites.
And finally the 0.5 slope constant we’re using in the shader will have to change based on the “rotation” of your isometric sprites.