Stylized Water Shader
Clear shallows that fade to deep blue, foam wherever the water touches rocks, posts and the shore, gentle waves and moving ripples, all on one plane mesh.
Drop it into your project
- Copy
stylized_water.gdshader,stylized_water.gdshader.uid,water_material.tres, both normal map PNGs and their.importfiles into your project, for example intores://water/. The.importfiles carry the normal map import settings, and the material finds the shader by the ID in the.uidfile. - Add a
MeshInstance3Dand give it a newPlaneMesh. Set its Size to the area of water you need, then set Subdivide Width and Subdivide Depth so the vertices are about 0.25 m apart. A 48 m plane needs 191, which is what the demo uses. The pitfalls below explain the number. - Drag
water_material.tresinto the MeshInstance3D's Surface Material Override slot. Leave the node's rotation at 0 and its scale at 1, and move it up or down to set the water level. To make the water bigger, change the PlaneMesh Size rather than the node's scale. - Make sure there is opaque geometry under and through the water: a sea floor, a shore, rocks, posts. The depth colour and the foam are measured against those surfaces. Where there is nothing under the water, it shows
deep_color. - Give the scene a
WorldEnvironmentwith a sky and aDirectionalLight3D. The bright streaks on the waves are the sky reflected in them and the sparkles are the sun, so the water needs both to show its ripples. - Tune the look under the material's Shader Parameters. Every water plane that uses the same
.tresshares the settings. For a pond and a sea with different colours, duplicate the material.
Parameters
| Name | Type | Default | What it does |
|---|---|---|---|
shallow_color | vec4 (source_color) | Color(0.24, 0.8, 0.76, 0.45) | Colour of shallow water. Its alpha is how opaque the water is there: at 0.45 you see the sand and rocks under it clearly. |
deep_color | vec4 (source_color) | Color(0.02, 0.17, 0.33, 0.94) | Colour where the water is depth_distance metres deep or more. The high alpha hides the bottom in deep water. |
depth_distance | float (0.1 to 10) | 2.0 | Metres of water, measured straight down, over which the colour goes from shallow_color to deep_color. At 2, water 1 m deep is halfway between the two. |
foam_color | vec4 (source_color) | Color(0.96, 0.98, 1, 1) | Colour of the foam. Lower the alpha for foam you can see through. |
foam_width | float (0 to 1) | 0.2 | Depth in metres that still gets foam. Foam is solid where the water touches something and breaks up into patches towards this depth. On a gentle shore a small value makes a wide band: on the demo's 12° slope, 0.2 reaches roughly a metre out from the waterline. 0 turns foam off. |
wave_height | float (0 to 1) | 0.1 | Highest a crest can rise above the plane, in metres. The four waves share it, so the surface stays within this distance above and below the plane. 0 turns the waves off. |
wave_length | float (0.5 to 20) | 4.0 | Length of the longest wave in metres. The other three are 0.71, 0.53 and 0.37 times as long, so at 4 the shortest is 1.48 m. Shorter waves need more subdivisions. |
wave_speed | float (0 to 2, step 0.01) | 0.5 | Speed of the waves and of the ripple scroll. 0 freezes the water. At 0.5 everything repeats every 4 seconds. |
normal_map_a, normal_map_b | sampler2D (hint_normal) | water_normal_a.png, water_normal_b.png | The two ripple maps. A has long, smooth swells, B has smaller, sharper ripples and tiles twice as often. Any tileable normal map works here. |
normal_scale | float (0 to 2) | 0.25 | Strength of the ripples from the normal maps. 0 leaves only the waves. Higher values give choppier water and more sun glints. |
normal_tile_size | float (0.5 to 50) | 8.0 | Metres of water covered by one tile of normal_map_a. Raise it for bigger ripples or to hide the repeat on a large sea. |
roughness | float (0 to 1) | 0.06 | Roughness of the water. Low values give a small, sharp sun glint and a clear sky reflection. Foam is always drawn rough. |
How it works
The shader does four things on one plane. It finds out how deep the water is at every pixel by reading the depth buffer, and picks a colour from that. It draws foam where the depth is close to zero. It moves the vertices up and down with a few sine waves. And it scrolls two normal maps across the surface for small ripples. The depth part is what most water tutorials skip, so it comes first.
Why the water is transparent
The shader writes ALPHA, and the spatial shader docs say that a material that reads or writes ALPHA goes to the transparent pipeline. Godot draws all opaque objects first and transparent ones after them, so by the time the water is drawn, the sand, rocks and posts are already in the depth buffer and on screen. The blend then mixes the water colour with what is behind it, which is how you see the rocks under the shallows.
The render_mode line spells out the choices. blend_mix is normal transparency. depth_draw_opaque writes depth for opaque surfaces only, so this water writes none. cull_back skips the underside. diffuse_burley and specular_schlick_ggx are Godot's default lighting models, written out so you can see what is in use. The other side of the deal: the docs say transparent materials cannot cast shadows and do not appear in the depth texture. Only opaque things make foam.
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;Reading the depth buffer
Godot 4 gives a shader the depth buffer through a sampler2D uniform with the hint_depth_texture hint. filter_nearest reads one exact depth value instead of blending four, which matters at the edge of a post where the depth jumps. Read it at SCREEN_UV, the position of the current pixel on screen, and you get the depth of the opaque surface behind the water at that pixel.
uniform sampler2D depth_texture : hint_depth_texture, repeat_disable, filter_nearest;
float raw_depth = textureLod(depth_texture, SCREEN_UV, 0.0).r;That value is not a distance. It is the depth after projection, stored backwards, with 1 at the near plane and 0 at the far plane, and it is not linear: most of the range is spent close to the camera. In the demo, the sea floor at the bottom of the screen reads above 0.004 and the shore near the top below 0.002, and the stored values matched between Forward+ and Compatibility. You cannot subtract two of these numbers and get metres, so the shader turns it back into a position first.
From a depth value back to a position
The projection matrix turns a position in view space (metres, relative to the camera) into normalized device coordinates, NDC for short: x and y from -1 to 1 across the screen, plus a depth. INV_PROJECTION_MATRIX goes the other way. Build the NDC point from the screen position and the depth, multiply, then divide by w, the fourth component that perspective leaves behind. The result is the view-space position of the surface behind the water. INV_VIEW_MATRIX then turns it into a world position.
The one trap is the depth range the matrix expects. Forward+ and Mobile run on Vulkan, where NDC depth goes from 0 to 1, so the stored depth goes in as it is. Compatibility runs on OpenGL, where NDC depth goes from -1 to 1, so the depth needs the same * 2.0 - 1.0 as x and y. Godot's advanced post-processing docs give both forms, and the shader preprocessor has had the CURRENT_RENDERER define to choose between them since Godot 4.4. In a test under Compatibility without the branch, 561,987 of 587,751 water pixels landed in the wrong depth band: almost all the water read as less than 0.2 m deep and turned to foam.
#if CURRENT_RENDERER == RENDERER_COMPATIBILITY
vec3 ndc = vec3(SCREEN_UV, raw_depth) * 2.0 - 1.0;
#else
vec3 ndc = vec3(SCREEN_UV * 2.0 - 1.0, raw_depth);
#endif
vec4 behind_view = INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
behind_view.xyz /= behind_view.w;
vec3 behind_world = (INV_VIEW_MATRIX * vec4(behind_view.xyz, 1.0)).xyz;
vec3 surface_world = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
float water_depth = max(surface_world.y - behind_world.y, 0.0);Measuring depth straight down
In fragment(), VERTEX is the position of the water pixel in view space, so the same matrix gives its world position. The shader subtracts the two heights. That is the depth straight down, in metres, which makes depth_distance and foam_width mean the same thing wherever the camera is. The other common choice is the distance along the line of sight, length(behind_view.xyz - VERTEX). It makes water look deeper the flatter you look across it, which some games want, but the foam band then grows and shrinks as the camera moves.
The colour is a straight mix: mix(shallow_color, deep_color, clamp(water_depth / depth_distance, 0.0, 1.0)). Alpha is mixed along with the colour, so shallow water is clear and deep water hides the bottom. Where nothing opaque is behind the water, the depth buffer holds the far plane, the depth comes out huge and the water is simply deep_color.
Foam from depth
Foam uses the same depth. edge is 1 where the water touches something and falls to 0 at foam_width metres deep. A plain edge would give a soft, even band, which looks like a glow rather than foam. So the shader compares it with a threshold made from the ripple normal maps, which changes from place to place and moves with them. Close to the object, edge beats any threshold and the foam is solid. Further out it only wins where the threshold is low, so the band breaks into patches that drift. smoothstep over a narrow range gives the patches a soft edge without blurring them.
float edge = foam_width > 0.0 ? 1.0 - clamp(water_depth / foam_width, 0.0, 1.0) : 0.0;
float threshold = clamp(0.5 + 0.35 * (ripple_a.x + ripple_b.y), 0.05, 0.95);
float foam = smoothstep(threshold - 0.06, threshold + 0.06, edge);Waves in the vertex shader
vertex() adds four sine waves to VERTEX.y. Each has a direction, a length, a share of wave_height and a speed. Their directions differ, so the crests cross each other instead of marching in lines. The phase is worked out from the vertex's world position, through MODEL_MATRIX, so two water planes with the same vertex spacing, placed side by side, line up at the seam.
Moving vertices is only half the job. Godot lights the surface with NORMAL, and a flat plane's normal points straight up whatever you do to its height. Without a new normal, the waves change the outline but not the shading, and they look painted on. The surface is y = h(x, z), and its normal is (-dh/dx, 1, -dh/dz), normalized. The shader takes the slopes analytically: the derivative of a * sin(k * d · p - ω t) is a * cos(...) * k * d, the chain rule. The other way is a finite difference: work out the height again a few centimetres along x and along z and take the differences. That runs the whole sum of sines three times instead of once and its answer depends on the step you pick. The exact slope costs one cos per wave, since the sin is already there.
height += amplitude * sin(phase);
slope += amplitude * cos(phase) * k * WAVE_DIR[i];
// in vertex():
NORMAL = normalize(vec3(-slope.x, 1.0, -slope.y));
TANGENT = normalize(vec3(TANGENT.x, dot(TANGENT.xz, slope), TANGENT.z));
BINORMAL = normalize(vec3(BINORMAL.x, dot(BINORMAL.xz, slope), BINORMAL.z));TANGENT and BINORMAL are tilted too. Normal maps are read in tangent space, the frame made of the tangent, binormal and normal, so if only the normal moved, the ripples would be drawn in a frame that no longer fits the wave. A horizontal direction tilts up by the slope along that direction, which is the dot product in the code.
Two scrolling normal maps
The ripples are two tileable normal maps, sampled at UVs taken from the world position and scrolled in different directions: A to the side, B diagonally at twice the tiling. One map scrolling on its own reads as a pattern sliding across the water. Two crossing ones keep changing shape. The textures use the hint_normal hint and import with Compress > Normal Map set to Enable, which the image import docs say uses RGTC compression. That format keeps only red and green, so the shader only reads .rg.
Red and green hold the slope of the ripple in x and y, from -1 to 1 after * 2.0 - 1.0. Adding the slopes of two maps adds their bumps, which is a simple and good way to blend normal maps. The sum goes into NORMAL_MAP, and the spatial shader docs say Godot rebuilds the blue channel itself. NORMAL_MAP_DEPTH is the strength, so normal_scale goes there, set to 0 under foam so foam stays flat and matte.
A loop with no seam
Every moving part repeats after 2 units of TIME * wave_speed, which is 4 seconds at the default speed. Each wave turns a whole number of half turns per unit, so after 2 units it is back where it started. Each normal map moves half a tile per unit, so after 2 units it has moved a whole tile and looks the same. In the demo, the last frame of the 4 second loop and the first differ about as much as two neighbouring frames do.
The same rule protects you from a jump an hour into play. TIME wraps back to 0 after the number of seconds in Project Settings > Rendering > Limits > Time > Time Rollover Secs, 3600 by default. At that moment the water skips unless 3600 × wave_speed is a whole number of 2 unit cycles, which holds for any speed in steps of 0.01. That is why wave_speed has a 0.01 step in the Inspector. In a test with the rollover set to 8 seconds, speed 0.5 wrapped with no visible change and speed 0.3 jumped.
How it builds on the lighting lesson
The lighting and materials guide sets Roughness, Metallic and Transparency in a StandardMaterial3D's Inspector and shows what each changes. This shader writes the same outputs from code: ALBEDO, ROUGHNESS, SPECULAR, ALPHA. Metallic is left at its default of 0, because water is not a metal. The guide's glossy sample with Roughness 0.15 has a tight highlight. Water at 0.06 is glossier still, which gives the small, sharp sun glint. The guide also sets Reflected Light to the sky so glossy objects have something to reflect, and the same holds here: the bright streaks on the waves are the sky. The one setting the guide keeps off is Transparency, which water needs on.
// Stylized Water
// Depth-tinted water with foam where it meets objects, moving normal maps and
// vertex waves.
// Godot 4.7 · CC0 1.0 · godotlearning.com/shaders/stylized-water
shader_type spatial;
// blend_mix: ALPHA is transparency. depth_draw_opaque: depth is written for
// opaque surfaces only, and writing ALPHA makes this one transparent, so the
// water writes no depth. cull_back: the underside is not drawn. diffuse_burley
// and specular_schlick_ggx are Godot's default lighting models.
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;
// Colour where the water is shallow. Alpha is how opaque it is there.
uniform vec4 shallow_color : source_color = vec4(0.24, 0.8, 0.76, 0.45);
// Colour where the water is depth_distance metres deep or more.
uniform vec4 deep_color : source_color = vec4(0.02, 0.17, 0.33, 0.94);
// Metres of water (measured straight down) over which the colour goes from
// shallow_color to deep_color.
uniform float depth_distance : hint_range(0.1, 10.0) = 2.0;
// Colour of the foam. Alpha is how opaque the foam is.
uniform vec4 foam_color : source_color = vec4(0.96, 0.98, 1.0, 1.0);
// Metres of water depth that still get foam. 0 turns foam off.
uniform float foam_width : hint_range(0.0, 1.0) = 0.2;
// How high a crest can rise above the plane, in metres. 0 turns waves off.
uniform float wave_height : hint_range(0.0, 1.0) = 0.1;
// Length of the longest wave in metres. The other three are shorter.
uniform float wave_length : hint_range(0.5, 20.0) = 4.0;
// Speed of the waves and of the normal map scroll. 0 freezes the water. The
// 0.01 step stops the water jumping when TIME wraps round after an hour.
uniform float wave_speed : hint_range(0.0, 2.0, 0.01) = 0.5;
// Two tileable normal maps, scrolled in different directions and added.
uniform sampler2D normal_map_a : hint_normal, filter_linear_mipmap, repeat_enable;
uniform sampler2D normal_map_b : hint_normal, filter_linear_mipmap, repeat_enable;
// Strength of the small ripples from the normal maps. 0 leaves only the waves.
uniform float normal_scale : hint_range(0.0, 2.0) = 0.25;
// Metres covered by one tile of normal_map_a. normal_map_b tiles twice as often.
uniform float normal_tile_size : hint_range(0.5, 50.0) = 8.0;
// Surface roughness of the water. Low values give a small, sharp sun glint.
uniform float roughness : hint_range(0.0, 1.0) = 0.06;
// The depth buffer of everything opaque drawn before the water.
uniform sampler2D depth_texture : hint_depth_texture, repeat_disable, filter_nearest;
// Four sine waves: direction (unit vector), length as a fraction of
// wave_length, share of wave_height, and how many half turns each makes per
// unit of TIME * wave_speed. Whole numbers of half turns make every wave repeat
// after 2 units, which is 4 seconds at the default wave_speed of 0.5.
const vec2 WAVE_DIR[4] = vec2[4](
vec2(0.958, 0.287), vec2(-0.447, 0.894), vec2(0.6, -0.8), vec2(-0.894, -0.447)
);
const float WAVE_LENGTH[4] = float[4](1.0, 0.71, 0.53, 0.37);
const float WAVE_SHARE[4] = float[4](0.4, 0.27, 0.2, 0.13);
const float WAVE_HALF_TURNS[4] = float[4](1.0, 2.0, 2.0, 3.0);
varying vec2 world_xz;
// Height of the surface at a world position, and its slope along x and z.
vec3 waves(vec2 xz, float t) {
float height = 0.0;
vec2 slope = vec2(0.0);
for (int i = 0; i < 4; i++) {
float k = TAU / (wave_length * WAVE_LENGTH[i]);
float phase = k * dot(WAVE_DIR[i], xz) - PI * WAVE_HALF_TURNS[i] * t;
float amplitude = wave_height * WAVE_SHARE[i];
height += amplitude * sin(phase);
// d/dx and d/dz of amplitude * sin(phase): the chain rule gives
// amplitude * cos(phase) * k * direction.
slope += amplitude * cos(phase) * k * WAVE_DIR[i];
}
return vec3(height, slope);
}
void vertex() {
// Waves are placed in world space, so tiles of water side by side line up.
vec3 world = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
world_xz = world.xz;
vec3 wave = waves(world.xz, TIME * wave_speed);
VERTEX.y += wave.x;
// The surface y = h(x, z) has the normal (-dh/dx, 1, -dh/dz). Tangent and
// binormal tilt by the slope along their own direction, so normal mapping
// follows the waves.
vec2 slope = wave.yz;
NORMAL = normalize(vec3(-slope.x, 1.0, -slope.y));
TANGENT = normalize(vec3(TANGENT.x, dot(TANGENT.xz, slope), TANGENT.z));
BINORMAL = normalize(vec3(BINORMAL.x, dot(BINORMAL.xz, slope), BINORMAL.z));
}
void fragment() {
float t = TIME * wave_speed;
// Ripples: two normal maps scrolling in different directions. Each moves a
// whole number of tiles every 2 units of t, so the scroll loops with the waves.
vec2 uv = world_xz / normal_tile_size;
vec2 uv_a = uv + vec2(0.5, 0.0) * t;
vec2 uv_b = uv * 2.0 + vec2(-0.5, 0.5) * t;
vec2 ripple_a = texture(normal_map_a, uv_a).rg * 2.0 - 1.0;
vec2 ripple_b = texture(normal_map_b, uv_b).rg * 2.0 - 1.0;
// Adding the x and y slopes of two normal maps blends them; Godot rebuilds
// the blue channel from x and y.
vec2 ripple = ripple_a + ripple_b;
ripple /= max(1.0, length(ripple) / 0.95);
// Depth: rebuild the position of whatever opaque surface is behind this
// pixel, then measure how far below the water surface it is.
float raw_depth = textureLod(depth_texture, SCREEN_UV, 0.0).r;
#if CURRENT_RENDERER == RENDERER_COMPATIBILITY
// OpenGL (Compatibility): NDC depth runs from -1 to 1.
vec3 ndc = vec3(SCREEN_UV, raw_depth) * 2.0 - 1.0;
#else
// Vulkan (Forward+ and Mobile): NDC depth runs from 0 to 1.
vec3 ndc = vec3(SCREEN_UV * 2.0 - 1.0, raw_depth);
#endif
vec4 behind_view = INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
behind_view.xyz /= behind_view.w;
vec3 behind_world = (INV_VIEW_MATRIX * vec4(behind_view.xyz, 1.0)).xyz;
vec3 surface_world = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
float water_depth = max(surface_world.y - behind_world.y, 0.0);
// Colour: shallow to deep over depth_distance metres.
vec4 water = mix(shallow_color, deep_color, clamp(water_depth / depth_distance, 0.0, 1.0));
// Foam: 1 where the water touches something, 0 at foam_width metres deep.
// A threshold taken from the ripples breaks the band into moving patches.
float edge = foam_width > 0.0 ? 1.0 - clamp(water_depth / foam_width, 0.0, 1.0) : 0.0;
float threshold = clamp(0.5 + 0.35 * (ripple_a.x + ripple_b.y), 0.05, 0.95);
float foam = smoothstep(threshold - 0.06, threshold + 0.06, edge);
ALBEDO = mix(water.rgb, foam_color.rgb, foam);
ALPHA = mix(water.a, foam_color.a, foam);
ROUGHNESS = mix(roughness, 0.9, foam);
SPECULAR = 0.5;
NORMAL_MAP = vec3(ripple * 0.5 + 0.5, 1.0);
NORMAL_MAP_DEPTH = normal_scale * (1.0 - foam);
}
Pitfalls and renderer notes
- Foam forms around rocks but not around a glass bottle, a particle effect or another water plane. The depth texture only holds opaque surfaces, so anything transparent is invisible to it. Give an object an opaque material, or an opaque stand-in mesh hidden inside it, if the water should foam around it. In a test with
ALPHAremoved so the water itself was opaque, the tint and foam still worked on all three renderers, but nothing under the water showed through. - Smoke, glass or particles near the water show on the wrong side of it. The water writes no depth, so Godot orders it against other transparent objects by their distance from the camera, and a big plane's distance is measured to its centre, far from most of its surface. In a test from the demo's camera, a see-through red quad 1.5 m above the water but 26 m away was drawn under the water, whose centre was 15 m away. A quad 1.2 m under the water and about 10 m away was drawn on top of it. Both happened on all three renderers. The fix is
render_priorityon the materials: the docs say higher priority draws on top. With the water left at 0, setting the above-water quad's material to 1 and the under-water quad's to -1 put both on the right side on all three renderers. Setting the water to -1 fixed the quad above but not the one under.sorting_offseton the quad's node also works, but it has to beat the gap in distance: 20 fixed the 11 m gap and 5 did not. - The water disappears when the camera goes under it.
cull_backskips the underside, so from below you see the sky. Settingcull_disableddraws the underside, but the depth is then measured upwards and clamps to 0, and in a test the whole underside came out as a flat, pale sheet. Under-water views need their own effect, such as a tint or fog that turns on while the camera is below the water level. - The water looks wrong on a rotated or scaled node. The shader works out the waves in world space and writes them into the mesh's own space, and those match only while the node has no rotation and a scale of 1. Resize the water with the PlaneMesh Size, not the node's scale.
- Too few subdivisions. A wave needs several vertices along its length to keep its shape. At the defaults the shortest wave is 1.48 m long, and the demo's 0.25 m spacing gives it about six vertices. In a test with 1 m spacing (47 subdivisions on the 48 m plane) and taller waves, the surface turned into flat-sided lumps and the short waves vanished. If you lengthen
wave_length, you can use fewer subdivisions. - Too many subdivisions. A PlaneMesh with n subdivisions has (n + 1)² quads, so the count grows fast: 47 gives 4,608 triangles, 191 gives 73,728, 479 gives 460,800 and 959 gives 1,843,200. On an RTX 3060 at 1280 by 720, the demo's water cost about 0.23, 0.29, 0.43 and 0.76 ms of GPU time at those four sizes under Forward+, and 0.16, 0.25, 0.37 and 1.40 ms under Compatibility. A phone has far less to spare, so measure on the device. For a big sea, use several water planes side by side with the same vertex spacing. The waves are worked out in world space, so the shared edges match: in a test, two 12 m planes with 0.25 m spacing gave the same wave surface, vertex for vertex, as one 24 m plane. Planes with different spacing do not match. The coarse edge runs straight between its vertices while the fine one follows the waves, and in a test with 0.25 m next to 1 m spacing, thin gaps opened along the seam at the default
wave_height. If you want coarser water far away, put the change of spacing where opaque geometry, such as a shore or a line of rocks, covers the seam. - Very large worlds. Godot uses 32-bit floats, which can step about 1 mm at 10 km from the origin and about 8 mm at 100 km. In a test with the whole demo moved 100 km along x and the waves, ripples and foam turned off, only scattered pixels along the edges of objects changed. Precision is not the first limit for water; the vertex count of a huge plane is.
- Shadows. The docs say transparent materials cannot cast shadows, so the water never shades the sea floor. It does receive them: in a test the jetty's shadow fell on the water on all three renderers. The demo's sun has shadows off for another reason. Under Compatibility, turning them on made the sand and rocks much brighter, with the sand blowing out to white, while Forward+ and Mobile did not change. A grey plane in a minimal scene showed the same, 143 instead of 125 out of 255, only when the light cast shadows and ambient light was on. Check your lighting under Compatibility if you target the web.
- The colours differ under Compatibility. The tint and foam land in the same places on all three renderers, but Compatibility shows deeper blues and a little less of what is under shallow water. The renderer notes below have the numbers. Tune
shallow_coloranddeep_colorwhile running in the renderer you ship with. - The ripples look wrong after you swap in your own normal map. Godot expects OpenGL-style normal maps, with green pointing up. A DirectX-style map from another tool needs Normal Map Invert Y turned on in its import settings. Set Compress > Normal Map to Enable too: with the default Detect, the editor only switches it once it sees the texture used as a normal map.
The demo scene was run under Forward+, Mobile and Compatibility with no errors or warnings. The depth maths has a branch for Compatibility, whose OpenGL depth range differs from the Vulkan one Forward+ and Mobile use. In a test that painted the water in bands at 0.2, 1 and 2 m of depth, the bands matched Forward+ on all but 12 of 587,751 water pixels under Mobile and 22 under Compatibility, so the tint and foam sit in the same places on all three. Mobile also looks the same as Forward+. Compatibility turns the same shader output into slightly different colours: the deep water is a stronger blue, the middle depths are bluer and shapes under shallow water show a little less. The difference stays with the water set to unshaded, so it is not the lighting or the depth read. If you ship a web build, which uses Compatibility, tune the colours while running in Compatibility.
Pairs with
Learn the idea behind it
Questions
How do I make foam where water touches objects in Godot 4?
Read the depth buffer in the water shader with a sampler2D that has the hint_depth_texture hint. Turn the depth behind each water pixel back into a world position with INV_PROJECTION_MATRIX and INV_VIEW_MATRIX, subtract its height from the water surface's height, and draw foam where the difference is small. Write ALPHA so the water is drawn as transparent, after the opaque objects, and you can see the bottom through it. This shader does exactly that, with the code for each step on this page.
Why is my water depth wrong in the Compatibility renderer?
Compatibility runs on OpenGL, where the depth in normalized device coordinates goes from -1 to 1, while Forward+ and Mobile use Vulkan's 0 to 1. Convert the depth with * 2.0 - 1.0 under Compatibility only, using #if CURRENT_RENDERER == RENDERER_COMPATIBILITY. With that branch, this shader put its depth bands in the same places on all three renderers to within 22 pixels.
Does this water shader work on mobile and in web exports?
Yes. The demo runs with no errors under Forward+, Mobile and Compatibility, which is the renderer web exports use. Mobile looks the same as Forward+. Compatibility puts the foam and tint in the same places but shows slightly deeper blues. Cost grows with the number of vertices in the water plane, so keep the subdivisions as low as your waves allow on a phone.
Why does my water plane look flat even though the waves move?
Moving VERTEX.y changes the shape but not the lighting, which follows NORMAL. Work out the slope of your waves in vertex() and set NORMAL to normalize(vec3(-dh/dx, 1.0, -dh/dz)). Tilt TANGENT and BINORMAL by the same slopes if you also use a normal map. Also check the plane has enough subdivisions: a plane with none has only four corners to move.