ritual.sh/content/blog/tiddler-dev-blog-2-creating-a-water-shader/index.md
2026-02-15 15:02:26 +00:00

8.3 KiB
Raw Permalink Blame History

title date tags draft
Tiddler Dev Blog #2 - Creating a Water Shader 2026-02-16
gamedev
godot
tiddler
false

I should preface this blog post by saying that prior to this I had no real knowledge of shaders in Godot, or shaders at all. Every solution I came up with here was through trial and error and almost certainly is not best practise and should not be taken as such. I am just writing it down here so I can remember what I did next time... Do not take any of this advise and do not use these methods unless you understand them better than me.


Creating half decent water effects in a 2D pixel art game presented many challenges. For my fishing game Tiddler, I wanted water that felt alive with animated waves, highlights, and most importantly, reflections of the player. Here's how I did battle with many of those things, but first here is what I ultimately came up with:

{{< video src="no_shader.webm" caption="Without shader" >}}

{{< video src="yes_shader.webm" caption="With shader" >}}

First Challenge: Reflecting the Player in Water

My initial thought on this was simple, just use Godot's SCREEN_TEXTURE to capture whatever's on screen and flip it vertically in the shader. That sounded right to me! It was not...

I honestly don't know the exact reason I couldn't get it to work this way. The issue seemed to be that SCREEN_TEXTURE was capturing the previously rendered nodes but not anything with transparency on it. I saw in the documentation this could be resolved using a BackBufferCopy node but honestly after hours of messing with this particular method I decided there had to be a simpler way.

I looked at various solutions online for this one but did not understand many of them, and they didn't seem to work in my particular workflow and how my scenes were setup.

{{< video src="oh_god_no.webm" caption="A very early attempt..." >}}

My Solution: A Dedicated Reflection Viewport

I finally worked out that I could seperate the reflection rendering into its own pipeline. I created a ReflectionViewportManager that:

  1. Creates a separate SubViewport matching the game world resolution (640×360)
  2. Duplicates the player node into this viewport
  3. Syncs the duplicate with the original player every frame for animations and position
  4. Flips the duplicate vertically to create the mirror effect
  5. Positions it below the player to simulate a planar reflection

This is my code for syncing up the locations:

func _process(_delta: float) -> void:
    # Sync reflection camera with main camera
    if main_camera and reflection_camera:
        reflection_camera.global_position = main_camera.global_position
        reflection_camera.zoom = main_camera.zoom

    # Sync all duplicate nodes with originals
    for original in reflected_nodes:
        var duplicate = reflected_nodes[original]
        if is_instance_valid(original) and is_instance_valid(duplicate):
            # Sync horizontal position
            duplicate.global_position.x = original.global_position.x

            # Mirror the vertical position
            var player_y = original.global_position.y
            var reflection_offset = 30.0  # Distance to drop down the reflection
            duplicate.global_position.y = player_y + reflection_offset

            # Flip the duplicate vertically to create mirror effect
            duplicate.scale.x = original.scale.x
            duplicate.scale.y = -abs(original.scale.y)

The water shader then samples this reflection viewport as a texture uniform and blends it with the water at the appropriate screen position, adding wave distortion for a slight rippling effect.

By rendering the player to a separate viewport, we get a clean texture that's already been rasterized. The water shader can then sample it like any other texture with no timing issues, and no circular dependencies.

There is probably a far more efficient way of doing this, but I was just happy to get this part working after many hours.

Tile Boundary Artifacts

The second major issue appeared when I implemented edge detection for wave highlights. The water in my game initially used a TileMapLayer, and I wanted to add subtle white highlights along the edges of the water to simulate wave crests and shoreline foam.

I tried implementing edge detection by checking adjacent pixels, which seemed straightforward since my water lives on its own layer. However, the edge detection went completely haywire.

Instead of detecting the actual edges in the water pattern, the shader was picking up tile boundaries - the seams where one tile in the atlas met another. This created a grid-like pattern of highlights that looked completely unnatural.

I believe the issue is how TileMaps work in Godot. Even though tiles appear seamless on screen, they're actually separate rectangles in a texture atlas. When the shader samples neighboring pixels for edge detection using UV coordinates, it's sampling from the atlas space, not screen space. A simple UV + texel_size might land on:

  • A completely different tile in the atlas
  • Transparent padding between tiles
  • An entirely unrelated part of the texture

I tried as many different ways of detecting the edges of the tiles as I could including alpha, specific colour checking, blue vs less blue, etc. None of them would work for me.

{{< video src="artifacts.webm" caption="This was an attempt at making the artifacts at least not look as bad, you can still see many vertical lines. I did like how I was able to get the horizontal ones swirly." >}}

This made any kind of spatial analysis quite broken when working with TileMaps. If this is an incorrect assumption on my part then someone please tell me, but this is the only reason I could find why my edge detection was failing.

My Solution: Baking the Water Layer to a Solid Texture

After trying various workarounds with alpha checking and color-based detection, I realised the fundamental problem couldn't be solved by me while using a TileMapLayer. My solution was to pre-render the water tiles into a single, solid texture.

The process is actually relatively straightforward:

  1. Design the water layout using the TileMapLayer in the editor
  2. Export the water layer to create a single PNG texture
  3. Replace the TileMapLayer with a Sprite2D using the baked texture
  4. Apply the water shader to the sprite instead of the tile layer

I wrote a simple script that would take the contents of a given TileMapLayer and turn it into a .res texture that I could then simple apply to a Sprite in the scene.

Why this works:

  • Neighboring pixels are actually adjacent on screen
  • Edge detection can sample UV coordinates normally
  • No tile boundaries or atlas padding to worry about
  • Color-based detection now works reliably

The edge detection shader checks neighboring pixels for the transition from water (blue) to land (not blue):

// Check if center pixel is blue (water)
float center_blueness = original_color.b - (original_color.r + original_color.g) * 0.5;
float is_center_water = step(0.1, center_blueness);

// Sample far neighbors (3 pixels away) to detect shoreline
vec4 far_r = texture(TEXTURE, UV + vec2(texel_size.x * 3.0, 0.0));
// ... check if neighbors are land (not blue) and solid (alpha > 0.5)

This works perfectly on the baked texture because UV coordinates map directly to screen space. I was so happy when I found this solution, the lines showing up at tile edges were absolutely wrecking my head for hours.

Lessons Learned

For reflection systems:

  • Synchronisation can be surprisingly simple
  • ViewportTextures are your friend for these kinds of effects

For TileMap shader effects:

  • Bake TileMaps to textures when you need spatial analysis (edge detection, gradients, neighbor sampling)
  • I couldn't get TileMap UV coordinates to work right
  • It's pretty simple to make TileMapLayers to single textures!
  • Color-based detection (water vs land) is more robust than luminance-based it seems

The full shader code is around 300 lines and handles wave animation, reflection blending, edge detection, foam generation, and twinkle effects—all in a single pass. It's hard to know what affect this will have on performance, it makes zero change on my RX 7900 XTX. I have added a toggle to turn the shader off for anyone on lower hardware, though.