2d shadows with a shader
There are basically two cheap ways to get shadows on a 2D game: prebacking the shadows in the images, or using a shader.
prebacked images
The first solution is simple: just draw the shadows on the sprite. (Or add a specific sprite with only the object shadow.) sprite with shadow directly on the image
This approach certainly is the simplest, but it suffers a few limitations:
- the required texture is larger, meaning a little more pressure on the GPU
- the shadow is static and cannot be changed dynamically
- it may require more work for the artist. (Or a preprocessing step in the asset pipeline.)
- it does not interact well with other shaders such as contour shaders
outline on a sprite with shadow. Actually not ugly as I expected, but I’m think that’s a bit lucky
- and finally, it does not work when the image casting the shadow is made of several sprites.
This final limitation is the main issue in the case of Gobs: all our characters are made of several sprites, for their body parts and equipped items. Like this: A NPC components. The equipment can be looted and equipped on player’s Gobs
For this reason, I used a shader instead.
2D shadows with a Shader
The other solution is to write a shader to compute the shadow directly on the GPU.
The basic computation is actually quite straightforward:
- on current pixel (x,y), the pixel which may cast a shadow here is located at “(x,y) - (y - yBottom) * shadowDirection”
- just read this pixel color and merge it with the current pixel to get the image with a shadow.
yBottom here is a parameter of the shader, which controls where the shadow is ‘rooted’.
This gives us a “basic” shader like this:
shader_type canvas_item;
render_mode blend_mix;
uniform float _yBottom = 0.95; // Bottom of the sprite (in UV coordinates.)
uniform vec4 modulate : hint_color;
uniform vec2 shadowDirection = vec2(0.5, 0.5);
void fragment() {
// height of the pixel
float dy = clamp(_yBottom - UV.y, 0.0 , 1.0) ;
// point casting the shadow
vec2 shadowUV = UV - dy * shadowDirection;
// read shadow-casting pixel color
vec4 shadow = vec4(modulate.rgb, texture(TEXTURE, shadowUV).a * modulate.a);
// read color of current pixel
vec4 col = texture(TEXTURE, correctedUV);
// mix current pixel with shadow behind.
COLOR = mix(shadow, col, col.a);
}
But of course, this naive shader has several issues:
- First, the shadow pixel may be outside of the texture box, and thus would not be rendered.
- then, the shadow angle with this implementation depends on the size ratio of the texture.
- finally, there are some artefacts when the original image has non transparent pixels on its border.
To solve this, I needed to expand the area where the texture is displayed in the ‘vertex’ function, and to define the shadow direction in pixel space instead of UV space. The final shader can be found on godotshaders.
Keeping the direction independent from the image size ratio
This is done by defining the shadow direction in pixel space, not in UV space. We can use TEXTURE_PIXEL_SIZE to convert between both:
vec2 size = 1.0 / TEXTURE_PIXEL_SIZE;
// position in pixels.
vec2 xy = UV * size;
// height of the pixel
float dy = clamp(_yRoot - UV.y, 0.0 , 1.0) ;
// point casting the shadow
vec2 xyShadow = xy - dy * size.y * shadowDirection;
vec2 shadowUV = xyShadow / size;
Increasing the texture box
The reason why we need to increase the texture box is clearly visible on the left part of the image below:
To simplify the implementation, I assumed that the shadow would be always to the right of the object; and would no be higher than the object. This mean I mean to keep both x and y component of the shadow direction positive. It was not an issue in my case, but keep that in mind if you want to reuse this shader.
This mean I only need to extend the box to its right. By how much? It is quite clear that the pixel whose shadow goes the furthest to the right is the top tight corner of the original image. We thus need to find the pixel where the shadow is casted by this top right corner ,and extended the shader bow to include this pixel. This is done by inverting the equation for shadowUV in the snipet above.
void vertex()
{
// how far to the right is the shadow of the top right corner?
float pixelsAdded = _yRoot * shadowDirection.x / TEXTURE_PIXEL_SIZE.y / max( 1.0, 1.0 + shadowDirection.y) ;
// is this a right side pixel?
int vertexid = VERTEX_ID % 4; // not sure 'WHY' exactly %4 is required, but it's from there: https://www.reddit.com/r/godot/comments/17l9eqn/understanding_vertex_and_sprite_offset_for_simple/
bool is_right_vertex = vertexid == 1 || vertexid == 2;
// moving the right side pixels to the right to cover potential shadow position
VERTEX += vec2( pixelsAdded * (is_right_vertex ? 1. : 0.), 0.);
}
However only applying this ‘vertex()’ function would increase the scale of the image on the screen. This is not what we want, so we need to correct for it in fragment().
void vertex()
{
// original image size in pixels
vec2 size = 1.0 / TEXTURE_PIXEL_SIZE;
// pixels added in vertex
float pixelsAdded = _yRoot * shadowDirection.x * size.y / max( 1.0, 1.0 + shadowDirection.y) ;
// size in pixels of the area covered by shader
// total size after vertex() increase
vec2 sizeTotal = vec2(size.x + pixelsAdded, size.y );
// position in pixels. (note that UV is normalised with sizeTotal)
vec2 xy = UV * sizeTotal;
// UV in texture coordinates
vec2 correctedUV = xy / size;
...
// read color of current pixel
vec4 col = texture(TEXTURE, correctedUV);
...
}
Avoiding visual artefacts
Finally, I noticed that this shader produce quite awful artefacts when there is a non-transparent pixel on the border of the image. One solution might be “make sure all images have transparent pixels on the border”, but this is not really sufficient: if the image is zoomed out, the shader might be applied to mipmaps of the texture, and now we need these mipmaps to also have transparent borders.
And if you think, “who cares, a few artefact on edges case should be ok”, just look at the image below:
Why these artefcats? They happen because we when reading the texture, uv is capped to 0,1, and we read a border pixel instead of a non existing pixel oustide the texture
These artefacts are removed by zeroing the alpha when the pixel we read is outside the texture box.
vec2 shadowUV = xyShadow / size;
// read shadow-casting pixel color
vec4 shadow = vec4(modulate.rgb, texture(TEXTURE, shadowUV).a * modulate.a);
// if this pixel is on the border, it may cause artefacts => (smoothly) hide border pixels.
vec2 shadowUV_OnBorder = smoothstep(vec2(0.),vec2(1.) , shadowUV *100. ) ;
shadowUV_OnBorder *= smoothstep(vec2(0.),vec2(1.) , (1.-shadowUV) *100. ) ;
shadow.a *= shadowUV_OnBorder.x * shadowUV_OnBorder.y;
Applying the shader on the Gobs
So I could not precompute the goblins shadows because goblins are made of several sprites. But a shader is applied to a single sprite, so it shouldn’t work either, right? Indeed. But there is a workaround for that in Godot. It requires to make a new viewport, add the different components as children of the viewport, and use this viewport texture on a sprite. Setting the “shadows” shader on this sprite gives the intended result.
Sounds complicated? Of course, this introduce a bit of an overhead, but once correctly packaged it is not so bad. I have a class which looks like the ‘MultiSprite’ one below. Then I just add components as children of the multiSprite.WorldContainer , and I can set a shader on this “multisprite” just as if it was a normal sprite.
public class MultiSprite : Sprite
{
// a sprite using a viewport texture.
// Set subcomponents as children of WorldContainer to view them on this sprite.
readonly Viewport _viewport;
readonly Node2D _container;
public readonly Vector2 _size;
public Node2D WorldContainer => _container;
const int MultiSpriteSize = 256;
public MultiSprite()
{
_size = Vector2.One * MultiSpriteSize;
_viewport = new Viewport();
base.AddChild(_viewport);
_viewport.RenderTargetClearMode = Viewport.ClearMode.Always;
_viewport.RenderTargetUpdateMode = Viewport.UpdateMode.WhenVisible;
_viewport.SetProcessInput(false);
_viewport.TransparentBg = true;
_viewport.Size = _size;
_viewport.Usage = Viewport.UsageEnum.Usage2d;
_container = new Godot.Node2D();
_viewport.AddChild(_container);
_container.Position = _size / 2;
var texture = _viewport.GetTexture();
this.Texture = texture;
this.FlipV = true;
Texture.Flags = TextureFlagMipmapsAndFilter;
}
const uint TextureFlagMipmapsAndFilter = (uint)Texture.FlagsEnum.Mipmaps + (uint)Texture.FlagsEnum.Filter;
}