Waterfall trail shader
– Setup –
This shader requires a very specific setup. This is what the node tree must look like:
- SubViewportContainer
- SubViewport
- BackBufferCopy
- ColorRect <- apply shader here
- SubViewport
- At the SubViewportContainer make sure to enable “Stretch”
- The SubViewportContainer should have exactly the size of the space you want to paint on, e. g. in my case the waterfall.
- At the SubViewport go to “Render Target” and configure “Clear Mode” to Never and “Update Mode” to Always
- The BackBufferCopy‘s “CopyMode” should be Viewport
- Configure the ColorRect‘s “Anchor Preset” to Full Rect
- Apply the Shader to the ColorRect!
– Shader params –
The shader itself just draws a circle on the ColorRect and moves it down by a Pixel every frame . Because the SubViewports “Clear Mode” is Never, the painted circle will be “smeared” down. At the edges there is also some noise to make it fade out on the way down.
- water_color: The color you want to draw (and “smear” down)
- shrink: How aggressively the noise removes pixels at the edges
- texture_size: Should be set to the same size as the SubViewportContainer
- brush_size: The radius of the circle brush
- brush_position: The position of the brush in UV coordinates
- advance: Should be set to 1.0 or 0.0 depending on if the painted color should be smeared down this frame or not. This can be used to keep a constant movement at different framerates (see below).
Here is an example of how to use this shader with a script (in my case the SubViewportContainer is centered as a child of the parent node that has the script, hence the UV calculation):
@export var affected_player: Node2D
@export var color_rect: ColorRect
@export var fall_speed: float = 0.008
var elapsed: float = 0.0
func _process(delta: float) -> void:
elapsed += delta
var advance := 0.0
if elapsed > fall_speed:
advance = floor(elapsed / fall_speed)
elapsed -= advance * fall_speed
color_rect.get_material().set_shader_parameter("advance", advance)
color_rect.get_material().set_shader_parameter("brush_position", to_viewport_uv(affected_player.global_position))
func to_viewport_uv(pos: Vector2) -> Vector2:
var pos_local = to_local(pos)
var viewport_local = to_local(sub_viewport_container.global_position)
return (pos_local - viewport_local) / Vector2(sub_viewport.size)
Shader code
shader_type canvas_item;
render_mode blend_disabled;
uniform sampler2D screen_texture: hint_screen_texture, filter_nearest;
uniform sampler2D noise_texture: repeat_enable;
uniform vec4 water_color: source_color;
uniform float advance;
uniform float shrink: hint_range(0.0, 1.0, 0.01);
uniform vec2 texture_size;
uniform float brush_size;
uniform vec2 brush_position;
void fragment() {
vec2 ratio = vec2(1.0, texture_size.y / texture_size.x);
vec2 pixel_size = 1.0 / texture_size;
vec2 shifted_uv = UV - vec2(0.0, (1.0 / texture_size.g) * advance);
vec4 prev_frame;
if (shifted_uv.y < 0.0) {
prev_frame = vec4(0.0, 0.0, 0.0, 0.0);
} else {
prev_frame = texture(screen_texture, shifted_uv);
float center = texture(screen_texture, shifted_uv).b;
float left = texture(screen_texture, shifted_uv + vec2(-pixel_size.x, 0.0)).b;
float right = texture(screen_texture, shifted_uv + vec2(pixel_size.x, 0.0)).b;
float shrink_alpha = step(0.1, center);
if (left * right < 0.1) {
if (texture(noise_texture, UV + TIME * shifted_uv.g).r < shrink * advance) {
shrink_alpha = 0.0;
}
}
prev_frame.rgb *= shrink_alpha;
}
vec2 uv_adjust = UV * ratio;
float brush_dist = distance(uv_adjust, brush_position * ratio);
float brush = step(brush_dist, brush_size / texture_size.r);
COLOR = max(vec4(prev_frame.rgb, water_color.a * step(0.01, prev_frame.b)), vec4(water_color.rgb * brush, water_color.a * brush));
}


