shader_type spatial;
render_mode world_vertex_coords, shadows_disabled, cull_disabled, depth_prepass_alpha;
// ⚠ depth_prepass_alpha IS LOAD-BEARING, because of the waterline discard.
// Godot leaves a shader that discards OUT of the depth prepass unless it opts
// in, and Sky3D's AtmFog reconstructs distance from DEPTH_TEXTURE. With the
// water missing from that texture AtmFog read the FAR PLANE where the sea was
// and painted full-distance atmospheric scatter over it -- the sea came out
// uniformly white with no wave shading at all, while the sky stayed crisp. It
// looked like the ocean shader was broken; nothing was wrong with it, another
// shader was drawing on top.
// PORTED from 2Retr0/GodotOceanWaves (assets/shaders/spatial/water.gdshader).
// Lighting model follows the 2019 GDC talk "Wakes, Explosions and Lighting:
// Interactive Water Simulation in Atlas".
//
// Local changes are marked `// hyper-tps:`. The lighting maths is otherwise
// untouched, deliberately -- see the note on smith_masking_shadowing.
#define MAX_CASCADES 8
#define REFLECTANCE 0.02 // Reflectance from air to water (eta=1.33).
// hyper-tps: THE SEABED, for shore behaviour. Same text the mesher compiles.
#include "res://shaders/terrain_density.gdshaderinc"
global uniform vec4 water_color : source_color;
global uniform vec4 foam_color : source_color;
uniform float roughness : hint_range(0.0, 1.0) = 0.4;
uniform float normal_strength : hint_range(0.0, 1.0) = 1.0;
// 0 = shade normally. 1 = world normal as RGB. 2 = foam_factor. 3 = wave
// height. 4 = water depth. 5 = fresnel. 6 = fetch ring average (baked or not). Judging "the water looks wrong" as one
// number is hopeless; these separate the terms that make it up.
uniform int ocean_debug = 0;
// The far plate runs THIS SAME SHADER so the two surfaces cannot differ in
// colour, fresnel or foam -- a StandardMaterial3D behind the sheet put a
// visible tonal step at the sheet's rim. The plate is four vertices spanning
// 600 km, so it can carry neither vertex displacement nor a per-vertex depth
// (interpolating depth from four corners across 600 km is noise); it keeps the
// per-FRAGMENT normal mapping, which is what makes distant water read as water.
uniform bool flat_plate = false;
group_uniforms cascade_data;
uniform vec4 map_scales[MAX_CASCADES];
global uniform uint num_cascades;
global uniform sampler2DArray displacements;
global uniform sampler2DArray normals : hint_normal;
group_uniforms scale;
// hyper-tps: WAS HARDCODED 150.0 / 0.007 / 0.0075 / 0.0175, tuned for a demo
// where the camera walks. Ours flies at 20 km and sprints at 19843 m/s, so
// every one of these is a uniform now.
uniform float displacement_fade_start = 800.0;
uniform float displacement_fade_rate = 0.0008;
uniform float foam_fade_rate = 0.0008;
uniform float normal_fade_rate = 0.0006;
group_uniforms shore;
// hyper-tps: a tidal simulator moves this, so it is a uniform, not a constant.
uniform float water_y = 700.0;
// Depth at which waves stop feeling the bottom. Measured: the surf zone runs
// ~1460 m from waterline to 20 m depth (median of 59 transects), so there is
// room for this to mean something. See dc-terrain/tests/test_shore_profile.cpp.
uniform float shore_full_depth : hint_range(1.0, 200.0) = 20.0;
// Green's law: wave height goes as depth^(-1/4) as a wave shoals, so amplitude
// RISES before the bottom kills it. That non-monotonic shape is what reads as
// water running up a beach rather than being switched off at the shore.
uniform float shoal_gain : hint_range(0.0, 3.0) = 1.0;
uniform float shoal_max : hint_range(1.0, 4.0) = 1.8;
// ⚠ A DEPTH, NOT A WIDTH. This is compared against depth in metres, and the
// first version called it a width and defaulted it to 40 -- reading as "40 m of
// foam along the water's edge". The shore profile says 20 m of depth is 1460 m
// OFFSHORE, so 40 m of depth is several kilometres out: v_shore_foam sat near 1
// across whole bays, foam_factor clamped to 1, and ALBEDO became foam_color
// over the entire sea. 1 m of depth is roughly 70 m of beach on that profile.
uniform float shore_foam_depth : hint_range(0.05, 10.0) = 1.0;
// ── FETCH ───────────────────────────────────────────────────────────────────
// Wave size is set by how far wind has blown over open water. A lake is choppy,
// an ocean has swell, and waves grow as you leave a coast -- all one parameter.
// JONSWAP takes it as fetch_length, but that is baked into the SPECTRUM: one
// global value for the whole tiling field, so it cannot vary by location.
//
// Amplitude can, the same way shore attenuation already does. This estimates
// local fetch by sampling the seabed on a ring: all deep means open ocean, any
// land or shallows means shelter. A fetch estimate wants kilometre-scale
// seabed, not metre-scale erosion, so the samples use a deliberately COARSE
// footprint -- nyquist_octaves then drops most of the octaves and these cost a
// fraction of a full evaluation each.
//
// ⚠ A STAND-IN FOR LAYER 2. A global ocean/tidal simulation would supply real
// fetch and wind direction per region; this is the local approximation until it
// exists, and it deliberately reads only the terrain.
uniform float fetch_radius : hint_range(0.0, 20000.0) = 1500.0;
uniform float fetch_open_depth : hint_range(1.0, 200.0) = 30.0;
uniform float fetch_min : hint_range(0.0, 1.0) = 0.25;
// BAKED FETCH (fetch_bake.gdshader, driven by Ocean._process): the ring below, once
// per texel of a camera-centred grid instead of four evaluate_density per vertex per
// frame (~10 of 13.8 ms GPU, 2026-10-07). false = the original per-vertex ring, kept
// as the reference; ocean_debug 6 shows the value in both modes.
uniform bool fetch_baked = false;
uniform sampler2D fetch_tex : filter_linear, repeat_disable;
uniform vec3 fetch_rect = vec3(0.0, 0.0, 1.0); // origin x, origin z, size (m)
// ── REFRACTION ──────────────────────────────────────────────────────────────
// Waves slow as they shoal (c = sqrt(g*d)), so crests bend until they run
// parallel to the depth contours and arrive very nearly perpendicular to the
// shore. Real coastlines are why swell wraps into a bay instead of crossing it
// at the wind's angle.
//
// The spectrum cannot be rotated per location -- it is one global FFT for the
// whole tiling field. The SAMPLING FRAME can be. Rotating world XZ about
// uv_origin before it becomes a texture coordinate turns the crests, and the
// rotation blends in only where the water is shallow enough to refract, so
// deep water keeps the wind direction it was generated with.
//
// -grad(depth) points toward shallower water, i.e. the way a wave travels as it
// comes ashore. It is FREE: evaluate_density already computes that gradient for
// the depth query and it was being discarded.
//
// ⚠ The field shears where the rotation varies across a wavelength. Depth
// gradients near a coast turn slowly, so this holds; it would not on a sharp
// bathymetric step.
// ⚠ DEFAULTS OFF. The first implementation multiplied atan2's ABSOLUTE angle
// by the blend amount, which does not rotate toward anything and jumps by
// 2*pi*amt across atan2's branch cut -- drawing concentric swirls across the
// whole sea. The maths below is correct, but it is off until it has been looked
// at rather than on and wrong.
uniform float refract_strength : hint_range(0.0, 1.0) = 0.0;
// Direction the spectrum was generated for, degrees, matching the cascades'
// wind_direction. Refraction rotates FROM this TOWARD the shore.
uniform float wave_direction_deg = 30.0;
uniform float refract_depth : hint_range(1.0, 500.0) = 60.0;
// ⚠ THE GRADIENT FOR REFRACTION MUST BE COARSE. Sampled at the depth query's
// own footprint (0.5 m near the camera) the seabed's erosion detail dominates,
// normalize() of that gives a rotation that varies violently between adjacent
// vertices, and the sampling frame shears into garbage along the entire
// coastline -- exactly where refraction is strongest. Waves refract off the
// large-scale shore, not off gravel, so this samples it at a scale comparable
// to a wavelength.
uniform float refract_footprint : hint_range(1.0, 2000.0) = 250.0;
// Band around the waterline, in metres of depth, where the fragment re-derives
// its own depth instead of interpolating the vertex one. See fragment().
uniform float shore_refine_band : hint_range(0.0, 200.0) = 25.0;
// Floor on the footprint passed to evaluate_density for DEPTH queries. Depth
// drives wave attenuation and the waterline, neither of which needs metre-scale
// erosion detail -- and the footprint is what nyquist_octaves turns into an
// octave count, so raising this is a direct cut to the most expensive thing the
// water does. 0.5 asks for essentially every octave.
// Vertex-side: drives attenuation, shoaling and foam, none of which resolve
// below tens of metres. 25 m drops most octaves in the five depth queries each
// vertex now makes (one depth, four fetch). FPS went 60 -> 27 as those were
// added, and this is the direct lever on them.
uniform float depth_query_footprint : hint_range(0.5, 200.0) = 25.0;
// Fragment-side, for the waterline discard, which DOES need precision -- it
// decides the shoreline to the pixel and a coarse sample moves the coast by
// metres. Only fragments within shore_refine_band pay it.
uniform float waterline_footprint : hint_range(0.1, 50.0) = 0.5;
// UV IS WORLD XZ REBASED BY uv_origin, SET ONCE PER FRAME ON THE CPU.
//
// Rebasing keeps the texture coordinate small: raw world XZ at this map's
// shoreline is ~640 km, which for the 50 m cascade is 12800 in texture space
// where a float32 ULP is a quarter of a texel. uv_origin is a whole number of
// tiles, so the wave phase is bit-exact -- the field tiles with period `tile`,
// and subtracting k*tile changes nothing.
//
// ⚠ IT MUST BE APPLIED IN vertex() AND ONLY THERE. VERTEX is world-space in
// the vertex function (render_mode world_vertex_coords) but VIEW-space in
// fragment(); computing UV from VERTEX.xz in the fragment feeds view-space
// coordinates into the wave lookup, which is what "close up is broken" was.
uniform vec2 uv_origin = vec2(0.0);
// Rotate a rebased world offset by (cos, sin). About uv_origin, which is within
// a few tiles of the camera, so the rotated offset stays small.
vec2 refract_uv(vec2 p, vec2 cs) {
return vec2(p.x * cs.x - p.y * cs.y, p.x * cs.y + p.y * cs.x);
}
varying float wave_height;
varying float foam_factor;
varying float fresnel;
varying float v_depth;
varying float v_shore_foam;
// (cos, sin) of the local refraction angle; identity in deep water.
varying vec2 v_refract;
// The fetch ring average (0..1) before fetch_min is applied: ocean_debug 6.
varying float v_fetch;
// hyper-tps: depth of water under a world XZ, in metres. proc_density returns
// pos.y - query_y, i.e. surface_height - y, so evaluating AT the waterline
// gives H - water_y and the depth is just its negation. One tap.
float water_depth_at(vec2 wxz, float footprint) {
float ridge = 0.0;
vec2 grad = vec2(0.0);
return -evaluate_density(wxz.x, water_y, wxz.y, footprint, ridge, grad);
}
// Depth AND its horizontal gradient. evaluate_density already computes the
// analytic slope and hands it back through an inout; every caller so far threw
// it away. depth = water_y - surface, so d(depth)/dp = -d(surface)/dp.
float water_depth_grad(vec2 wxz, float footprint, out vec2 ddepth) {
float ridge = 0.0;
vec2 grad = vec2(0.0);
float d = -evaluate_density(wxz.x, water_y, wxz.y, footprint, ridge, grad);
ddepth = -grad;
return d;
}
void vertex() {
v_refract = vec2(1.0, 0.0);
v_fetch = 1.0;
UV = VERTEX.xz - uv_origin;
float cam_dist = length(VERTEX.xz - CAMERA_POSITION_WORLD.xz);
float distance_factor = min(exp(-(cam_dist - displacement_fade_start) * displacement_fade_rate), 1.0);
// hyper-tps: SHORE. Without attenuation the displaced sheet drives straight
// through the beach and out the other side; this is what makes a coastline
// possible at all. The shoaling term above it is what stops that reading as
// the waves simply being switched off.
float shore_amp = 1.0;
if (flat_plate) {
v_depth = 1e6; // open water: no shore terms on the plate
v_shore_foam = 0.0;
} else {
float depth = water_depth_at(VERTEX.xz, max(cam_dist * 0.001, depth_query_footprint));
float shore_att = smoothstep(0.0, shore_full_depth, depth);
// Toward shore, and only where it is shallow enough to matter. The
// gradient comes from a SEPARATE, coarse sample -- see refract_footprint.
vec2 ddepth = vec2(0.0);
if (refract_strength > 0.0) {
water_depth_grad(VERTEX.xz, refract_footprint, ddepth);
}
if (length(ddepth) > 1e-9) {
vec2 toward_shore = normalize(-ddepth);
float amt = (1.0 - smoothstep(0.0, refract_depth, depth)) * refract_strength;
// Interpolate the DIRECTION, then read the rotation off it. Angles
// cannot be blended here: atan2 wraps, and the wrap is a 2*pi step
// in the middle of the sea.
float wd = radians(wave_direction_deg);
vec2 wind_dir = vec2(cos(wd), sin(wd));
vec2 target = normalize(mix(wind_dir, toward_shore, amt));
// The rotation carrying wind_dir onto target, as (cos, sin).
v_refract = vec2(dot(wind_dir, target),
wind_dir.x * target.y - wind_dir.y * target.x);
}
// Four points on a ring, coarse: enough to tell a bay from open sea.
float fetch = 0.0;
if (fetch_radius > 1.0 && fetch_baked) {
v_fetch = textureLod(fetch_tex, (VERTEX.xz - fetch_rect.xy) / fetch_rect.z, 0.0).r;
fetch = mix(fetch_min, 1.0, v_fetch);
} else if (fetch_radius > 1.0) {
float fp_coarse = max(fetch_radius * 0.125, depth_query_footprint);
fetch += smoothstep(0.0, fetch_open_depth, water_depth_at(VERTEX.xz + vec2( fetch_radius, 0.0), fp_coarse));
fetch += smoothstep(0.0, fetch_open_depth, water_depth_at(VERTEX.xz + vec2(-fetch_radius, 0.0), fp_coarse));
fetch += smoothstep(0.0, fetch_open_depth, water_depth_at(VERTEX.xz + vec2(0.0, fetch_radius), fp_coarse));
fetch += smoothstep(0.0, fetch_open_depth, water_depth_at(VERTEX.xz + vec2(0.0, -fetch_radius), fp_coarse));
v_fetch = fetch * 0.25;
fetch = mix(fetch_min, 1.0, v_fetch);
} else {
fetch = 1.0;
}
float shoal = clamp(pow(max(depth, 0.05) / shore_full_depth, -0.25), 1.0, shoal_max);
shore_amp = shore_att * mix(1.0, shoal, shoal_gain) * fetch;
v_depth = depth;
v_shore_foam = 1.0 - smoothstep(0.0, shore_foam_depth, depth);
}
vec3 displacement = vec3(0);
for (uint i = 0U; i < num_cascades; ++i) {
vec4 scales = map_scales[i];
displacement += texture(displacements, vec3(refract_uv(UV, v_refract) * scales.xy, float(i))).xyz * scales.z;
}
if (!flat_plate) {
VERTEX += displacement * distance_factor * shore_amp;
}
wave_height = flat_plate ? 0.0 : displacement.y * shore_amp;
}
/** Filter weights for a cubic B-spline. */
vec4 cubic_weights(float a) {
float a2 = a*a;
float a3 = a2*a;
float w0 =-a3 + a2*3.0 - a*3.0 + 1.0;
float w1 = a3*3.0 - a2*6.0 + 4.0;
float w2 =-a3*3.0 + a2*3.0 + a*3.0 + 1.0;
float w3 = a3;
return vec4(w0, w1, w2, w3) / 6.0;
}
/** Bicubic B-spline filtering.
* Source: https://developer.nvidia.com/gpugems/gpugems2/part-iii-high-quality-rendering/chapter-20-fast-third-order-texture-filtering */
vec4 texture_bicubic(in sampler2DArray sampler, in vec3 uvw) {
vec2 dims = vec2(textureSize(sampler, 0).xy);
vec2 dims_inv = 1.0 / dims;
uvw.xy = uvw.xy*dims + 0.5;
vec2 fuv = fract(uvw.xy);
vec4 wx = cubic_weights(fuv.x);
vec4 wy = cubic_weights(fuv.y);
vec4 g = vec4(wx.xz + wx.yw, wy.xz + wy.yw);
vec4 h = (vec4(wx.yw, wy.yw) / g + vec2(-1.5, 0.5).xyxy + floor(uvw.xy).xxyy)*dims_inv.xxyy;
vec2 w = g.xz / (g.xz + g.yw);
return mix(
mix(texture(sampler, vec3(h.yw, uvw.z)), texture(sampler, vec3(h.xw, uvw.z)), w.x),
mix(texture(sampler, vec3(h.yz, uvw.z)), texture(sampler, vec3(h.xz, uvw.z)), w.x), w.y);
}
void fragment() {
// ⚠ Z-FIGHTING AT THE WATERLINE, and the shore attenuation makes it worse
// rather than better. Attenuation flattens the sheet to EXACTLY water_y as
// depth goes to zero, and the measured shore profile is 1 m of depth per
// ~70 m of beach -- a 1.4% slope. Two near-horizontal surfaces a few
// centimetres apart over a band tens of metres wide is the coplanar case
// depth buffers cannot resolve.
//
// So do not draw water where there is none. v_depth alone is not enough to
// decide that: it is interpolated across cells metres wide, so the waterline
// would be a polygon edge. Near the crossing the fragment re-derives depth
// from the field itself -- one evaluation, and only for fragments already
// within shore_refine_band of the shore, which is a thin sliver of screen.
if (!flat_plate) {
float d = v_depth;
if (abs(d) < shore_refine_band) {
// pn_out_ridge / pn_out_grad are inout, so they need real locals --
// a literal is "A constant value cannot be passed for the 'inout'
// parameter", and passing d itself would clobber the answer.
float r = 0.0;
vec2 g = vec2(0.0);
// World XZ from UV (world minus uv_origin), NOT from VERTEX --
// which is view-space in this function.
vec2 wxz = UV + uv_origin;
float fp = max(length(VERTEX.xz) * 0.001, waterline_footprint);
d = -evaluate_density(wxz.x, water_y, wxz.y, fp, r, g);
}
if (d <= 0.0) {
discard;
}
}
float map_size = float(textureSize(normals, 0).x);
// ⚠ VERTEX IS VIEW-SPACE HERE, so length(VERTEX.xz) already IS the
// horizontal camera distance. world_vertex_coords applies to vertex() only.
// This was "corrected" to length(VERTEX.xz - CAMERA_POSITION_WORLD.xz),
// which subtracts a world position from a view position and evaluates to
// roughly |camera position|: at 640 km from origin exp(-dist*rate) is 0, so
// the gradient collapsed to its 0.015 floor and foam to zero EVERYWHERE.
// The sea went flat in proportion to distance from the origin -- which is
// not a precision problem, it is this line.
float dist = length(VERTEX.xz);
vec3 gradient = vec3(0);
for (uint i = 0U; i < num_cascades; ++i) {
vec4 scales = map_scales[i];
vec3 coords = vec3(refract_uv(UV, v_refract) * scales.xy, float(i));
float ppm = map_size * min(scales.x, scales.y); // pixels per metre
gradient += mix(texture_bicubic(normals, coords), texture(normals, coords), min(1.0, ppm*0.1)).xyw * vec3(scales.ww, 1.0);
}
foam_factor = smoothstep(0.0, 1.0, gradient.z*0.75) * exp(-dist*foam_fade_rate);
// hyper-tps: breakers and the shoreline band. A wave breaks where its crest
// stops clearing the bottom, which is exactly wave_height against depth; the
// second term is the static line of foam along the water's edge.
float breaker = smoothstep(0.0, 1.0, wave_height - v_depth);
foam_factor = clamp(max(foam_factor, max(breaker, v_shore_foam)), 0.0, 1.0);
ALBEDO = mix(water_color.rgb, foam_color.rgb, foam_factor);
gradient *= mix(0.015, normal_strength, exp(-dist*normal_fade_rate));
NORMAL = (VIEW_MATRIX * vec4(normalize(vec3(-gradient.x, 1.0, -gradient.y)), 0.0)).xyz;
fresnel = mix(pow(1.0 - dot(VIEW, NORMAL), 5.0*exp(-2.69*roughness)) / (1.0 + 22.7*pow(roughness, 1.5)), 1.0, REFLECTANCE);
ROUGHNESS = (1.0 - fresnel) * foam_factor + 0.4;
if (ocean_debug != 0) {
vec3 wn = normalize(vec3(-gradient.x, 1.0, -gradient.y));
if (ocean_debug == 1) ALBEDO = wn * 0.5 + 0.5;
else if (ocean_debug == 2) ALBEDO = vec3(foam_factor);
else if (ocean_debug == 3) ALBEDO = vec3(clamp(wave_height * 0.15 + 0.5, 0.0, 1.0));
else if (ocean_debug == 4) ALBEDO = vec3(clamp(v_depth / 200.0, 0.0, 1.0));
else if (ocean_debug == 6) ALBEDO = vec3(v_fetch); // baked vs per-vertex A/B
else ALBEDO = vec3(fresnel);
ROUGHNESS = 1.0;
METALLIC = 0.0;
SPECULAR = 0.0;
EMISSION = ALBEDO; // unlit, so light() cannot recolour the readout
}
}
// ⚠ CALLED WITH ITS ARGUMENTS TRANSPOSED against this declaration, and
// light_mask/view_mask take dot_nv/dot_nl the opposite way round from their
// names. That is how upstream ships and how it was tuned, so it is ported
// unchanged rather than "fixed" into a different-looking ocean.
float smith_masking_shadowing(in float cos_theta, in float alpha) {
float a = cos_theta / (alpha * sqrt(1.0 - cos_theta*cos_theta));
float a_sq = a*a;
return a < 1.6 ? (1.0 - 1.259*a + 0.396*a_sq) / (3.535*a + 2.181*a_sq) : 0.0;
}
float ggx_distribution(in float cos_theta, in float alpha) {
float a_sq = alpha*alpha;
float d = 1.0 + (a_sq - 1.0) * cos_theta * cos_theta;
return a_sq / (PI * d*d);
}
void light() {
vec3 halfway = normalize(LIGHT + VIEW);
float dot_nl = max(dot(NORMAL, LIGHT), 2e-5);
float dot_nv = max(dot(NORMAL, VIEW), 2e-5);
float light_mask = smith_masking_shadowing(roughness, dot_nv);
float view_mask = smith_masking_shadowing(roughness, dot_nl);
float microfacet_distribution = ggx_distribution(dot(NORMAL, halfway), roughness);
float geometric_attenuation = 1.0 / (1.0 + light_mask + view_mask);
SPECULAR_LIGHT += fresnel * microfacet_distribution * geometric_attenuation / (4.0 * dot_nv + 0.1) * ATTENUATION;
const vec3 sss_modifier = vec3(0.9,1.15,0.85);
float sss_height = 1.0*max(0.0, wave_height + 2.5) * pow(max(dot(LIGHT, -VIEW), 0.0), 4.0) * pow(0.5 - 0.5 * dot(LIGHT, NORMAL), 3.0);
float sss_near = 0.5*pow(dot_nv, 2.0);
float lambertian = 0.5*dot_nl;
DIFFUSE_LIGHT += mix((sss_height + sss_near) * sss_modifier / (1.0 + light_mask) + lambertian, foam_color.rgb, foam_factor) * (1.0 - fresnel) * ATTENUATION * LIGHT_COLOR;
}