OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / assets / ocean / shaders / ocean.gdshader

419 lines · gdshader ref:
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;
}