In this lesson, we are going to create a vertical mirror shader for a side-scrolling game. You can use the same logic for any reflective surface in side-scrolling games. You can also use it for all sorts of mirror-like surfaces, be it a mirror, ice, some wet rock, etc.
You will learn to :
We need a scene with some sprites to reflects and an extra sprite to serve as our mirror or water plane. That sprite will display the composite colors of everything above it, using the screen texture.
Create a new scene, add some background and sprites of your choice to it, and create a new Sprite node at the bottom. Name it Reflection2D.
Alternatively, you can use the Reflection2DDemo.tscn scene from the Godot projects and replace the Reflection2D node.
Assign any texture to the Reflection2D sprite node. We are not going to use the texture itself as we want to reflect pixels above the sprite. But we need the texture to give the sprite some base size and be able to scale it. We will also use the TEXTURE_PIXEL_SIZE
value in the shadow, which depends on the original texture’s size.
For now, do not scale the sprite. Also, click on the label in the top-left of the viewport to reset the view to the 100% zoom level.
You want to use a sprite rather than a ColorRect here rather than something like a colored rectangle because sprite nodes have a scale
property. We can use it to calculate correct reflections when we resize our sprite in the editor.
With Reflection2D selected, scroll down to the Material property in the Inspector and create a new Shader Material. Click the material slot to expand it and add a new Shader resource in it. Click on the resource to open the shader editor.
Start by setting the shader type to canvas_item
and write the fragment function. It is the only one that we need for this lesson.
shader_type canvas_item;
void fragment() {
}
For our reflection to work, we need to sample the screen pixels above the sprite’s bounding box.
Our water is a rectangle inside of our screen. It has its own UV coordinates ranging from 0 to 1 on both you and the axis, and so does the screen. Except that the V axis of the SCREEN_UV
points up, while the V axis of every sprite points down.
So we need to first offset the UV coordinates of the screen within our Reflection2D sprite sample the pixels above it. Then we need to flip this portion of the screen vertically.
To map the screen texture to our sprite, we need to know the proportion of the screen that our Reflection2D node is covering. We only need to do so on the vertical axis as we are only going to mirror the pixels above the water plane.
To calculate this ratio, we can use the TEXTURE_PIXEL_SIZE
and the SCREEN_PIXEL_SIZE
values.
They are respectively the inverse of the texture’s size that you assigned to the sprite, and the inverse of the viewport’s resolution. For example, if the sprite’s texture is 32px * 16px, the TEXTURE_PIXEL_SIZE
will have a value of vec2(1.0 / 32.0, 1.0 / 16.0)
.
The division of the two tells us the percentage of the screen that our mirror covers vertically.
float uv_size_ratio_v = SCREEN_PIXEL_SIZE.y / TEXTURE_PIXEL_SIZE.y;
Note that we are calculating a ratio of V coordinates here, hence the variable name.
We can add the uv_size_ratio_v
value to our screen UVs to sample the part that is above the plane, like so:
vec2 uv_offset = vec2(SCREEN_UV.x, SCREEN_UV.y + uv_size_ratio_v); COLOR = texture(SCREEN_TEXTURE, uv_offset);
At this point, your fragment function should look like so:
void fragment() { float uv_size_ratio_v = SCREEN_PIXEL_SIZE.y / TEXTURE_PIXEL_SIZE.y; vec2 uv_offset = vec2(SCREEN_UV.x, SCREEN_UV.y + uv_size_ratio_v); COLOR = texture(SCREEN_TEXTURE, uv_offset); }
Note: you can use the output COLOR
to visualize the UV values generated by the shader. For example, you can replace the red and green channels of the color output to show your UVs on the screen as a colorful gradient.
COLOR.rg = uv_offset;
Ensure that you comment out any other line that manipulates the COLOR
variable if you do so.
You can also use that to visualize individual color channels as black-and-white values. To do so, assign a vec3
built from one floating-point value to COLOR.rgb
:
COLOR.rgb = vec3(uv_offset.x);
We have a copy of the image above the Reflection2D sprite, now we need to flip our samples. While we can achieve this with a simple multiplication, it can be hard to wrap your head around it.
First, let’s rename our uv_offset
variable to uv_reflected
:
void fragment() { float uv_size_ratio_v = SCREEN_PIXEL_SIZE.y / TEXTURE_PIXEL_SIZE.y; vec2 uv_reflected = vec2(SCREEN_UV.x, SCREEN_UV.y + uv_size_ratio_v); }
To flip the image, we want to multiply the Y components of our uv_size_ratio_v
UVs by UV.y * 2.0
.
vec2 uv_reflected = vec2(SCREEN_UV.x, SCREEN_UV.y + uv_size_ratio_v * UV.y * 2.0);
The UV.y value increases from 0.0
to 1.0
as the graphics card fills pixels going down the texture. And we want to use it to sample above the sprite. The lower the fragment we’re looking at is, the more we have to offset upwards to mirror a pixel. And as the V axes of our screen and sprite textures point in opposite directions, that multiplication allows us to reflect the image along the Reflection2D’s top edge.
The values UV.y
and UV.y * uv_size_ratio_v
represent the same amount of “vertical space”, but in different coordinate systems. As you can see in the image above, UV.y
is a vertical position relative to the sprite’s bounds, with the V axis pointing down, and UV.y * uv_size_ratio_v
is a vertical distance in screen UV coordinates, with the V axis is pointing up.
2.0 * UV.y * uv_size_ratio_v
gives us an offset in screen UV coordinates to sample the fragment we want to reflect in the final image.
It can seem confusing that the V axis of the screen UVs points up and that it points down for other textures. The reason is most likely that OpenGL captures the screen upside-down, as it does with viewport textures in general.
Let’s use the reflected UVs to display our mirrored image:
void fragment() { float uv_size_ratio_v = SCREEN_PIXEL_SIZE.y / TEXTURE_PIXEL_SIZE.y; vec2 uv_reflected = vec2(SCREEN_UV.x, SCREEN_UV.y + uv_size_ratio_v * UV.y * 2.0); vec4 reflection_color = texture(SCREEN_TEXTURE, uv_reflected); COLOR = reflection_color; }
Here, we’re storing our reflection_color
in a variable because we’re going to mix it with another color later on.
We have two more problems to solve. First, if we resize the water plane, the image gets stretched. The shader has no idea that by scaling the sprite, you want to sample a larger portion of the screen. Instead, it just scales the effect to the sprite’s new bounding box. Then, when we zoom in and out, the image is also inconsistent. Again, the shader doesn’t know that we’re zooming and that we should scale our reflected UVs accordingly.
We can use GDScript to send the sprite’s scale and the current zoom level of the editor or the game view to the shader. To do so, you need to add two uniform variables at the top of your shader. We only need to scale the V axis of the UVs, so let’s create two floats named scale_y
and zoom_y
:
uniform float scale_y = 1.0f; uniform float zoom_y = 1.0f;
Add a new script to the Reflection2D node and add the tool
keyword at the top.
tool extends Sprite
We can connect to the item_rect_changed
signal to know when its size or scale changed. On the signal callback, we can then assign scale.y
to the scale_y
uniform of our shader:
func _ready() -> void: connect("item_rect_changed", self, "_on_item_rect_changed") func _on_item_rect_changed() -> void: material.set_shader_param("scale_y", scale.y)
For the zoom, we can use one of two values: if you’re using a camera, you could send its zoom.y
property to the shader. In this example, we’re going to use the viewport’s global canvas transform instead and use the _process()
function:
func _process(delta: float) -> void: update_zoom(get_viewport_transform().y.y) func update_zoom(value: float) -> void: material.set_shader_param("zoom_y", value)
I recommend you to set it as a function or a property with a setter as, in your game, you will likely want to pass this value from the current camera via a signal.
Here is the complete class:
# Forwards the Y zoom and Y scale values to the mirror shader tool extends sprite func _ready() -> void: connect("item_rect_changed", self, "_on_item_rect_changed") func _process(delta: float) -> void: update_zoom(get_viewport_transform().y.y) func update_zoom(value: float) -> void: material.set_shader_param("zoom_y", value) func _on_item_rect_changed() -> void: material.set_shader_param("scale_y", scale.y)
In the shader, we can now update our uv_reflected
to take the scale and zoom into account.
vec2 uv_reflected = vec2(SCREEN_UV.x, SCREEN_UV.y + uv_size_ratio_v * UV.y * 2.0 * scale_y * zoom_y);
At this point, the shader should look like so:
shader_type canvas_item; uniform float scale_y = 1.0f; uniform float zoom_y = 1.0f; void fragment() { float uv_size_ratio_v = SCREEN_PIXEL_SIZE.y / TEXTURE_PIXEL_SIZE.y; vec2 uv_reflected = vec2(SCREEN_UV.x, SCREEN_UV.y + uv_size_ratio_v * UV.y * 2.0 * scale_y * zoom_y); vec4 reflection_color = texture(SCREEN_TEXTURE, uv_reflected); COLOR = reflection_color; }
Our shader still only produces a flipped copy of the screen. You’ll likely want to tint the result and add some controls to the reflections. That’s what we’re going to do now.
In the shader, add three uniforms:
vec4 color
, the background color we are going to mix with our reflection.float reflection_intensity
, the opacity of the reflection.sampler2D transition_gradient
, a gradient texture that controls the fade of the reflection.uniform vec4 color :hint_color; uniform float reflection_intensity = 0.5; uniform sampler2D transition_gradient :hint_black;
The hint_color
tells Godot to add a color picker in the Inspector, in the Shader Param category. And hint_black
tells we want to use the black color as a fallback for our texture.
In the Inspector, open the Shader Param and add a GradientTexture in the Transition Gradient slot. Add a Gradient resource inside of it.
Back in the shader, we need to change the way we calculate the output COLOR
. Let’s first sample our gradient map. Our gradient only changes on the U axis, which is the one we want to sample. It’s also horizontal, while we need to apply the transition vertically.
To rotate the gradient 90 degrees clockwise, we can use the following UV coordinates: vec2(UV.y, 1.0)
. With the default gradient, doing so results in having black at the top and white at the bottom of our Reflection2D. We’re going to use white to show reflections and black to hide them, so we need the gradient rotated 90 degrees counter-clockwise. We can use the UV coordinates vec2(1.0 - UV.y, 1.0)
for that.
Let us sample our texture using those coordinates:
float transition = texture(transition_gradient, vec2(1.0 - UV.y, 1.0)).r;
Finally, we blend the color
and reflection_color
using the mix
function. The third argument controls the interpolation between the two colors. We can use our gradient multiplied by reflection_intensity
here:
COLOR = mix(color, reflection_color, transition * reflection_intensity);
Here is the final shader:
shader_type canvas_item; uniform vec4 color :hint_color; uniform float reflection_intensity = 0.5; uniform sampler2D transition_gradient :hint_black; // Updated from GDScript uniform float scale_y = 1.0f; uniform float zoom_y = 1.0f; void fragment() { float uv_size_ratio_v = SCREEN_PIXEL_SIZE.y / TEXTURE_PIXEL_SIZE.y; vec2 uv_reflected = vec2(SCREEN_UV.x, SCREEN_UV.y + uv_size_ratio_v * UV.y * 2.0 * scale_y * zoom_y); vec4 reflection_color = texture(SCREEN_TEXTURE, uv_reflected); float transition = texture(transition_gradient, vec2(1.0 - UV.y, 1.0)).r; COLOR = mix(color, reflection_color, transition * reflection_intensity); }
Our reflection shader is ready! You can use it as a base for more complex shaders. It can reflect any portion of the screen above the reflection plane.
With this shader, you can only reflect what is above the node’s bounding box and rendered to the screen before it. You cannot place other sprites over it in such a way they overlap.
To have overlapping sprite, you need a different setup, involving other Godot features. We are going to look at that in another lesson.
The shader can also only reflect what’s above it. If you want to reflect in another direction, you have to write different code. That’s common with shaders: we generally design each to solve a specific problem. You want to code many different shaders that are optimized to do one effect and do it well rather than have more complex programs.