Back to Blog
Game DevelopmentAugust 5, 202610 min read

Custom Shaders in Godot. My Experience with 28 Shaders

DR

Doug Ribeiro

10 min read

When I started building my tactical RPG in Godot 4, a 3D world with 2D character sprites, I had a clean mental list of things I did not know how to do. Shaders were at the top of that list. Not near the top. At the very top, underlined twice. I knew shaders were programs that ran on the GPU. I knew they controlled how pixels looked. Beyond that, I had a vague fear of vertex transformations and a working assumption that shaders were for specialists with graphics programming backgrounds, not for application developers who built Angular UIs for a living.

By February 2026, about a year in, the project had 28 custom shaders. Rain with refraction and depth. Snow accumulation on surfaces. Destructible terrain that reveals a scorched subsurface underneath. A dynamic day/night cycle that shifts color temperature across the entire scene. Unit highlight shaders that pulse and indicate movement range. I did not hire a graphics programmer. I learned it myself, mostly through trial and error, Godot documentation, and a lot of ShaderToy browsing.

This post is the honest account of that journey. What was harder than expected, what was surprisingly accessible, and how Godot's shader environment compares to raw GLSL.

Godot's Shader Language vs Raw GLSL

Godot uses its own shader language, which is GLSL-based but not GLSL. It has the same fundamental types (vec2, vec3, vec4, sampler2D, mat4) and the same mathematical functions. But it adds Godot-specific built-ins, hides boilerplate behind a cleaner structure, and integrates directly with the material and rendering pipeline.

For a beginner, this is a genuine advantage. Writing against the graphics API directly, OpenGL or Vulkan, means you manage vertex buffers, uniform buffers, and framebuffers yourself. Godot's shader system handles all of that. You declare a shader_type, implement the fragment() or vertex() functions, and Godot passes you everything you need. You can focus on the visual effect rather than the graphics API plumbing.

The gotcha is that Godot's documentation on shaders is complete but not always beginner-friendly. The reference tells you what every built-in does. It does not always tell you why you would use it or how things interact. I spent a lot of time reading the docs and then reading Godot's own shader source code to understand how built-in materials were implemented before I could extend them.

The First Shader: Unit Highlight

My first real custom shader was a unit highlight. The pulsing glow that indicates a unit is selected in my tactical game. Simple premise: take the unit's sprite, make it glow with a pulsing outline, and change the glow color based on whether the unit is a player unit, an enemy, or an ally.

I expected this to take a day. It took four. Not because the shader code was complex. The final shader is about 30 lines. But because I did not understand Godot's coordinate systems, did not understand how UV sampling worked for outlines, and kept confusing screen space with texture space. The breakthrough came when I understood that an outline effect works by sampling neighboring pixels and checking if any of them are transparent where the current pixel is opaque. That is it. Once I understood that, the implementation was straightforward.

glsl
// Godot Shader Language, unit highlight with pulse
shader_type canvas_item;

uniform vec4 outline_color : source_color = vec4(1.0, 1.0, 0.0, 1.0);
uniform float outline_width : hint_range(0.0, 10.0) = 2.0;
uniform float pulse_speed : hint_range(0.0, 5.0) = 2.0;
uniform float pulse_min : hint_range(0.0, 1.0) = 0.4;

void fragment() {
    vec2 size = outline_width * TEXTURE_PIXEL_SIZE;
    vec4 color = texture(TEXTURE, UV);

    float alpha = 0.0;
    alpha = max(alpha, texture(TEXTURE, UV + vec2(size.x, 0.0)).a);
    alpha = max(alpha, texture(TEXTURE, UV - vec2(size.x, 0.0)).a);
    alpha = max(alpha, texture(TEXTURE, UV + vec2(0.0, size.y)).a);
    alpha = max(alpha, texture(TEXTURE, UV - vec2(0.0, size.y)).a);

    float pulse = pulse_min + (1.0 - pulse_min) *
        (0.5 + 0.5 * sin(TIME * pulse_speed));

    vec4 outline = outline_color * vec4(1.0, 1.0, 1.0, pulse);

    COLOR = mix(outline * (1.0 - color.a), color, color.a);
    COLOR.a = max(color.a, alpha * outline_color.a * pulse);
}

Weather Shaders: Rain and Snow

Weather effects were the first shaders where I felt genuinely proud of the result. My rain shader runs as a full-screen CanvasLayer effect and creates the illusion of rain by layering multiple scrolling noise textures at different speeds and opacities, then applying a refraction pass that slightly distorts the scene beneath.

The rain itself was not the hard part. Scrolling a texture is one of the first things any shader tutorial teaches. The hard part was the refraction, making the rain streaks visibly distort what is behind them, which requires reading from the screen texture at an offset UV position. Godot 4 exposes it through a sampler2D uniform with hint_screen_texture (the old SCREEN_TEXTURE built-in is gone), but the interaction between CanvasLayer z-ordering and when the screen texture is captured is subtle and caused me to spend a full evening with incorrect rendering before I understood the capture order.

Snow was actually easier than rain because I did not need refraction. The snow shader uses a simple particle-in-shader approach. I generate snowflake positions procedurally using sine waves and fractional noise, which gives a natural distribution without needing an actual particle system. The shader exposes wind strength and direction as uniforms, so I can animate the wind through GDScript during weather events.

Snow accumulation, the second snow shader, was more interesting. It reads a custom texture mask that defines where snow can accumulate (flat surfaces, tops of walls) and progressively blends between the base texture and a snow-covered version using a time-driven uniform. Combined with the falling snow shader, the scene transitions from clear to snowy in a way that affects both the atmosphere and the ground.

The Day/Night Cycle: 6 Shaders Working Together

My day/night cycle is not a single shader. It is six shaders that coordinate with each other through shared uniforms driven by a central DayCycleManager node. Each shader handles one aspect of the visual shift: overall color temperature, shadow intensity, ambient light color, sky gradient, star visibility, and moon/sun position.

Designing this system taught me the most important shader architecture lesson I know: shaders should be driven by data, not time directly. My early attempt had each shader reading TIME independently and computing the current phase of the day on their own. The result was subtle inconsistency. Each shader carried its own copy of the phase math, so small differences drifted them apart, and none of them followed the game clock: Godot's TIME keeps running while the game is paused and wraps every 3,600 seconds by default.

The correct approach: the DayCycleManager computes a single normalized day_progress value from 0.0 to 1.0 every _process() tick and passes it as a uniform to all six shaders. Every shader uses the same value in the same frame. The visual output is perfectly synchronized.

DayCycleManager (C#)Pushes day_progress uniform (0.0 to 1.0) to all shaders each frame 1 Sky Gradient ShaderInterpolates between dawn, day, dusk, night color palettes 2 Color Temperature ShaderShifts the whole scene from warm dawn through cold night 3 Ambient Light ShaderAdjusts ambient intensity and the ambient light color 4 Character Shadow ShaderShifts shadow direction and softness based on sun angle 5 Star Visibility ShaderFades the star field and moon glow in at night values 6 Sun / Moon Position ShaderPlaces the sun or moon disc for the current day_progress 7

Destructible Environment Shaders

The most technically complex shaders in the project are the destructible environment effects. When a wall takes damage, I want the visible surface to crack, char, and eventually collapse. The shader for this reads a damage value uniform and uses it to blend between three texture layers: intact surface, cracked surface, and scorched subsurface.

The cracking effect uses a Voronoi noise pattern to generate the crack geometry procedurally. As damage increases, the crack threshold lowers and more of the Voronoi cell edges become visible, giving a natural fracturing appearance. The scorched areas use a separate edge-detection pass that highlights the crack edges in orange-red before they fully expose the subsurface texture.

Getting this right required understanding how to blend between texture layers using a mask, how to generate Voronoi noise in a shader (which is more involved than Perlin noise), and how to correctly map the damage value from game logic to the visual blend thresholds. The final shader is about 80 lines and took almost two weeks of spare-time iteration to get to a result I was happy with.

What Was Surprisingly Easy

Palette swapping. I expected replacing one set of colors with another for unit color variations to be complicated. It is not. You create a small palette texture, sample your sprite's color, look up that color's position in the source palette, then return the corresponding color from the target palette. If your sprite is designed with a limited palette (which mine is, by design), the entire swap happens in about 15 lines of shader code.

Dissolve effects were also easier than expected. Sampling a noise texture, comparing the noise value to a dissolve threshold uniform, and discarding the pixel if below the threshold. The dissolve eats the sprite from a noise pattern rather than a simple wipe. Add an edge color at the threshold boundary and you get a burning edge effect for free. I use this for unit death animations.

  • Color grading and LUT application. Straightforward 3D texture lookup
  • Simple outline effects. Neighbor pixel sampling as described above
  • Screen-space scanlines. Repeating pattern on UV.y, nothing more
  • Sprite flashing (for damage feedback). Mix between sprite color and white using a uniform
  • Fog of war reveal. Circle SDF on the fog overlay, smooth edge falloff

What Was Genuinely Hard

Normal mapping for character sprites. Godot supports normal maps on sprites, but making them look correct for 2D characters standing in a 3D world, under one consistent light direction, took a long time to tune. The problem is that normal maps assume a specific lighting model, and the artistic look I wanted was not that model. I ended up writing a custom sprite lighting shader that interprets the normal map differently to produce a more stylized result.

Transparency sorting. When multiple transparent shaders stack on top of each other, rain over a transparent unit highlight over a semi-transparent fog, the renderer can produce incorrect blending depending on draw order. Understanding draw order, CanvasItem z-index for the sprite layer, and when transparency compositing happens correctly took significant debugging time.

Performance optimization. Shaders run on the GPU, so individual shaders are fast. But 28 shaders running simultaneously on a scene with many visible objects started showing performance impact on lower-end hardware. I had to add quality tiers. Simplified versions of weather shaders that disable expensive operations like refraction on low-quality settings. Profiling GPU usage in Godot is less mature than CPU profiling, which made this harder than it should have been.

The Skill Transfer to Web UI Work

Writing shaders changed how I think about visual effects in web UI work. CSS animations, SVG filters, canvas effects. They all operate on the same mathematical primitives as shader code. Lerp, smoothstep, noise functions, UV coordinates. When I see a CSS filter effect now, I automatically think about what the equivalent shader code would look like. When I implement a transition animation on an Angular component, I think about what interpolation curve would give the right feel.

It also gave me a much deeper appreciation for what GPU hardware actually does. Angular developers tend to think of CSS transforms and animations as magic. Knowing that they compile down to GPU operations with the same kind of matrix multiplications and texture sampling I do in shaders makes me a more informed developer when making performance decisions.

Shaders look intimidating from the outside because the vocabulary is unfamiliar. Once you realize they are just functions that transform inputs to pixel colors, and that those inputs are just vectors and textures, the intimidation fades and the creativity starts.

References

GodotShadersGame DevelopmentVisual Effects