class_name Ocean extends MeshInstance3D
## The near layer of the ocean: an FFT wave field on a camera-following sheet.
##
## LAYER 1 OF TWO. This produces local wave detail from a JONSWAP spectrum with
## fixed parameters. The intended layer 2 is a coarse global ocean/tidal
## simulation that DRIVES those parameters -- wind speed and direction, fetch,
## swell, and the tide height itself -- per region and over time. Nothing here
## bakes them: the cascade parameters have setters that regenerate the spectrum,
## and `water_y` is a uniform rather than a constant, so layer 2 plugs in
## without a rewrite.
##
## MESH: A WARPED GRID OVER THE WAVE ZONE ONLY, WITH A FLAT PLATE BEYOND.
## Vertex k sits at E*(exp(a*u) - 1)/(exp(a) - 1), dense at the centre.
##
## ⚠ ONE GRID CANNOT SPAN 2 m TO 300 km. The first version tried, with E =
## 300 km and a = 9.3 over 256 cells, and the cell sizes come out at 2 m at the
## camera, 9 m at 100 m, and 74 m at 1 km. The smallest cascade tile is 50 m, so
## beyond about 100 m the mesh had no vertices to displace and the sea was a
## mirror -- the displacement was being applied correctly to a grid that could
## not represent it. It removed the near/far seam by making the near field
## useless.
##
## So the sheet covers the wave zone (default 6 km) and the map keeps a flat
## plate out to the horizon behind it. Sized from the arithmetic rather than by
## eye: E*a/n_half is the rim cell and E*a/(n_half*(exp(a)-1)) the centre one,
## which for the defaults below is ~1 m at the camera and ~200 m at the rim.
##
## UV is world-space (see the shader), so warping and camera-following do not
## disturb the wave field -- the waves stay put in the world while the sheet
## slides under them.
const CascadeParams := preload("res://assets/ocean/wave_cascade_parameters.gd")
@export var extent := 6000.0 ## Half-width of the sheet, metres.
@export var resolution := 320 ## Cells per side.
@export var warp := 5.3 ## Higher = more vertices near the camera.
@export var water_y := 700.0 :
set(value):
water_y = value
if _mat: _mat.set_shader_parameter(&'water_y', water_y)
@export var map_size := 256
## Snap step, metres. The sheet follows the camera but lands on a lattice, so
## vertices do not slide continuously through the wave field and shimmer.
@export var snap_step := 1.0
## Half-width of the flat plate carrying the sea to the horizon behind the
## wave sheet, metres.
@export var far_extent := 300000.0
## How far below the waterline the plate sits. Must exceed the deepest wave
## trough (~4.7 m at default cascades) or troughs reveal it; at 6 km an 8 m
## drop subtends about 0.06 deg.
@export var far_drop := 8.0
## See map_dc_erosion.gd's OCEAN_DEBUG. Applied to both surfaces.
@export var debug_channel := 0
## Master switch for the submerged volume pass. Off means you can see the
## seabed from under the waterline without absorption eating it.
@export var underwater_enabled := true
## True while the camera is under water_y AND the volume pass is on -- i.e.
## while absorption limits what can be seen. Decided against the still
## waterline, like the pass itself.
signal submerged_changed(is_submerged: bool)
var submerged := false
## Transmittance below which a surface is treated as invisible. The pass blends
## scene * t over the inscatter colour, so 0.5% is under 2 of 255 levels even on
## a full-white surface.
const VISIBLE_T := 0.005
var wave_generator
var _mat: ShaderMaterial
var _params: Array[WaveCascadeParameters] = []
var _displacement_maps := Texture2DArrayRD.new()
var _normal_maps := Texture2DArrayRD.new()
var _published := false
var _far: MeshInstance3D = null
var _under: MeshInstance3D = null
var _under_mat: ShaderMaterial = null
var _far_manual_visible := true
var _far_mat: ShaderMaterial = null
var _ticks := 0
func _ready() -> void:
OceanGlobals.ensure()
mesh = _build_warped_grid()
# NO extra_cull_margin. It was `extent` (6 km) on top of the mesh's custom AABB,
# which already spans the whole sheet (±extent) and ±1 km vertically against waves
# of a few metres (_build_warped_grid). The margin grew the box 6 km in EVERY
# direction, so a camera 6 km down was still "inside" it: the sheet was never
# culled underwater and its vertex shader (cascade loop, depth_prepass_alpha, so
# drawn twice) cost ~13 of 16.4 ms GPU at the seabed, where it cannot be seen
# (hyper-games #108 perf, 2026-10-07, GPU Trace + BENCH_HIDE=SeaLevel).
extra_cull_margin = 0.0
cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_mat = ShaderMaterial.new()
_mat.shader = load("res://assets/ocean/shaders/ocean.gdshader")
_mat.set_shader_parameter(&'water_y', water_y)
_mat.set_shader_parameter(&'ocean_debug', debug_channel)
material_override = _mat
_build_far_plate()
_build_underwater()
_build_fetch_bake()
_params = _default_cascades()
wave_generator = load("res://assets/ocean/wave_generator.gd").new()
wave_generator.map_size = map_size
add_child(wave_generator)
_update_scales()
func _default_cascades() -> Array[WaveCascadeParameters]:
# Three cascades spanning metres to hundreds of metres. Layer 2 replaces
# these wholesale; they are a plausible open-ocean starting point, not a
# tuned result.
var out: Array[WaveCascadeParameters] = []
for spec in [[Vector2(50, 50), 1.0, 1.0], [Vector2(200, 200), 0.6, 0.6],
[Vector2(800, 800), 0.4, 0.4]]:
var p := CascadeParams.new()
p.tile_length = spec[0]
p.displacement_scale = spec[1]
p.normal_scale = spec[2]
p.wind_speed = 20.0
p.wind_direction = 30.0
p.fetch_length = 550.0
p.time = 120.0 + PI * out.size()
out.append(p)
return out
func _build_warped_grid() -> ArrayMesh:
var n := resolution
var verts := PackedVector3Array()
var idx := PackedInt32Array()
var denom := exp(warp) - 1.0
var coord := PackedFloat32Array()
coord.resize(n + 1)
for i in range(n + 1):
var u := 2.0 * float(i) / float(n) - 1.0 # -1 .. 1
coord[i] = signf(u) * extent * (exp(warp * absf(u)) - 1.0) / denom
verts.resize((n + 1) * (n + 1))
for j in range(n + 1):
for i in range(n + 1):
verts[j * (n + 1) + i] = Vector3(coord[i], 0.0, coord[j])
for j in range(n):
for i in range(n):
var a := j * (n + 1) + i
var b := a + 1
var c := a + (n + 1)
var d := c + 1
idx.append_array([a, c, b, b, c, d])
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = verts
arrays[Mesh.ARRAY_INDEX] = idx
var m := ArrayMesh.new()
m.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
# The AABB cannot be derived from a mesh the shader displaces and that we
# move every frame; give it the whole sheet or it culls itself away.
m.custom_aabb = AABB(Vector3(-extent, -1000.0, -extent),
Vector3(extent * 2.0, 2000.0, extent * 2.0))
return m
## The horizon. Same shader, same colour, same foam response -- only without
## the displacement four vertices cannot carry.
func _build_far_plate() -> void:
var pm := PlaneMesh.new()
pm.size = Vector2(far_extent * 2.0, far_extent * 2.0)
_far = MeshInstance3D.new()
_far.name = "SeaHorizon"
_far.mesh = pm
_far.extra_cull_margin = far_extent
_far.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_far_mat = ShaderMaterial.new()
_far_mat.shader = load("res://assets/ocean/shaders/ocean.gdshader")
_far_mat.set_shader_parameter(&'flat_plate', true)
_far_mat.set_shader_parameter(&'water_y', water_y)
_far_mat.set_shader_parameter(&'ocean_debug', debug_channel)
_far.material_override = _far_mat
add_child(_far)
_far.position = Vector3(0.0, -far_drop, 0.0)
## The submerged volume pass. A fullscreen quad that stays with the camera and
## draws only when the camera is under water_y.
func _build_underwater() -> void:
var q := QuadMesh.new()
q.size = Vector2(2, 2)
_under = MeshInstance3D.new()
_under.name = "Underwater"
_under.mesh = q
_under.extra_cull_margin = 1e6
_under.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
# After the water surface and after Sky3D's AtmFog, which is also blended.
_under.sorting_offset = -1000.0
_under_mat = ShaderMaterial.new()
_under_mat.shader = load("res://assets/ocean/shaders/underwater.gdshader")
_under_mat.render_priority = 127
_under.material_override = _under_mat
add_child(_under)
## ── FETCH, BAKED (fetch_bake.gdshader) ─────────────────────────────────────
## The fetch ring cost four evaluate_density per vertex per frame: ~10 of 13.8 ms GPU
## at 1080p with the sea in view (hyper-games #108 perf, 2026-10-07). It varies over
## kilometres and only with the terrain, so it is baked into a camera-centred grid and
## re-baked when the camera has moved FETCH_RECENTER. Two grids, alternating: a bake
## renders into the idle one, and the ocean switches texture AND rect together the
## next frame, so the two never disagree for a frame.
## ⚠ Terrain edits do not re-bake. Fetch is kilometre-scale; a few carves do not move it.
@export var fetch_bake_enabled := true
const FETCH_BAKE_RES := 128
## The grid spans the sheet (±extent) plus this, so every vertex stays inside it
## between bakes: ~109 m texels for a quantity that varies over 1.5 km.
const FETCH_RECENTER := 1000.0
var _bake_vp: Array[SubViewport] = []
var _bake_mat: Array[ShaderMaterial] = []
var _bake_active := -1
var _bake_pending := -1
var _bake_pending_origin := Vector2.ZERO
var _bake_center := Vector2(INF, INF)
func _build_fetch_bake() -> void:
var sh: Shader = load("res://assets/ocean/shaders/fetch_bake.gdshader")
for i in 2:
var vp := SubViewport.new()
vp.name = "FetchBake%d" % i
vp.size = Vector2i(FETCH_BAKE_RES, FETCH_BAKE_RES)
vp.disable_3d = true
vp.transparent_bg = false
vp.render_target_update_mode = SubViewport.UPDATE_DISABLED
var rect := ColorRect.new()
rect.size = Vector2(FETCH_BAKE_RES, FETCH_BAKE_RES)
var m := ShaderMaterial.new()
m.shader = sh
rect.material = m
vp.add_child(rect)
add_child(vp)
_bake_vp.append(vp)
_bake_mat.append(m)
func _fetch_bake_size() -> float:
return 2.0 * (extent + FETCH_RECENTER)
## A shader parameter of the sheet's material, or its shader default when unset.
func _mat_param(p: StringName) -> Variant:
var v: Variant = _mat.get_shader_parameter(p)
if v == null:
v = RenderingServer.shader_get_parameter_default(_mat.shader.get_rid(), p)
return v
func _update_fetch_bake(cam_pos: Vector3) -> void:
if not fetch_bake_enabled or _bake_vp.is_empty():
return
# Swap in the grid rendered last frame: texture and rect in the same frame.
if _bake_pending >= 0:
_bake_active = _bake_pending
_bake_pending = -1
_mat.set_shader_parameter(&'fetch_tex', _bake_vp[_bake_active].get_texture())
_mat.set_shader_parameter(&'fetch_rect', Vector3(_bake_pending_origin.x,
_bake_pending_origin.y, _fetch_bake_size()))
_mat.set_shader_parameter(&'fetch_baked', true)
var c := Vector2(cam_pos.x, cam_pos.z)
if _bake_pending >= 0 or c.distance_to(_bake_center) < FETCH_RECENTER:
return
_bake_center = c
var i := 1 if _bake_active == 0 else 0
var size := _fetch_bake_size()
var origin := c - Vector2(size, size) * 0.5
var m := _bake_mat[i]
m.set_shader_parameter(&'bake_origin', origin)
m.set_shader_parameter(&'bake_size', size)
m.set_shader_parameter(&'water_y', water_y)
for p: StringName in [&'fetch_radius', &'fetch_open_depth', &'depth_query_footprint']:
m.set_shader_parameter(p, _mat_param(p))
_bake_vp[i].render_target_update_mode = SubViewport.UPDATE_ONCE
_bake_pending = i
_bake_pending_origin = origin
func _update_scales() -> void:
# ⚠ SIZED TO THE SHADER'S ARRAY, NOT THE CASCADE COUNT. The shader declares
# `uniform vec4 map_scales[MAX_CASCADES]` with MAX_CASCADES = 8; handing
# Godot a shorter array can leave the uniform unset, and an unset map_scales
# is all zeros -- every cascade then samples texel (0,0) forever and the sea
# is a perfectly flat mirror, which is indistinguishable from "the FFT is
# not running".
var scales: PackedVector4Array
scales.resize(8)
for i in 8:
scales[i] = Vector4.ZERO
for i in _params.size():
var p := _params[i]
var uv_scale := Vector2.ONE / p.tile_length
scales[i] = Vector4(uv_scale.x, uv_scale.y, p.displacement_scale, p.normal_scale)
_mat.set_shader_parameter(&'map_scales', scales)
if _far_mat: _far_mat.set_shader_parameter(&'map_scales', scales)
func _process(delta: float) -> void:
var cam := get_viewport().get_camera_3d()
if cam:
_update_uv_origin(cam.global_position)
_update_fetch_bake(cam.global_position)
# Submerged is decided against the still waterline, not the displaced
# surface: a wave passing overhead should not flicker the whole screen.
var sub := underwater_enabled and cam.global_position.y < water_y
if _under_mat:
_under_mat.set_shader_parameter(&'submerged', 1.0 if sub else 0.0)
if _under:
_under.global_position = cam.global_position
# Hidden above water: it wrote ALPHA 0 there, but still made the
# renderer copy the screen texture every frame for nothing.
_under.visible = sub
if sub != submerged:
submerged = sub
submerged_changed.emit(sub)
# HIDE THE HORIZON PLATE WHEN SUBMERGED. It sits far_drop below the
# waterline so wave troughs never reveal it, which is invisible from
# above but reads as a SECOND SURFACE from below -- the sheet overhead
# and the plate 8 m under it, with the "ocean" a thin gap between.
# Absorption limits underwater visibility to a few hundred metres, so
# the plate contributes nothing down there anyway.
if _far and _far_manual_visible:
_far.visible = cam.global_position.y >= water_y
# Follow in XZ only; Y is the tide line, which layer 2 will drive.
global_position = Vector3(
snappedf(cam.global_position.x, snap_step), water_y,
snappedf(cam.global_position.z, snap_step))
if wave_generator == null:
return
wave_generator.update(delta, _params)
_ticks += 1
if _ticks == 120 and not _published:
# A flat sea looks identical whether the FFT is running or not, so say
# so out loud rather than leave it to a screenshot.
push_warning("[Ocean] 120 frames and the maps were never published — "
+ "num_cascades is still 0 and the water will be a flat mirror.")
if not _published and wave_generator.descriptors.has(&'displacement_map'):
_publish_maps()
## Whole number of tiles to subtract from world XZ before it becomes a texture
## coordinate. Must be an exact multiple of EVERY cascade's tile length or the
## wave field jumps as the camera crosses a boundary; the largest tile works
## when the others divide it (50/200/800 all divide 800).
func _update_uv_origin(cam_pos: Vector3) -> void:
if _mat == null:
return
var t := 0.0
for p in _params:
t = maxf(t, maxf(p.tile_length.x, p.tile_length.y))
if t <= 0.0:
return
var o := Vector2(floorf(cam_pos.x / t) * t, floorf(cam_pos.z / t) * t)
_mat.set_shader_parameter(&'uv_origin', o)
if _far_mat: _far_mat.set_shader_parameter(&'uv_origin', o)
func _publish_maps() -> void:
_displacement_maps.texture_rd_rid = wave_generator.descriptors[&'displacement_map'].rid
_normal_maps.texture_rd_rid = wave_generator.descriptors[&'normal_map'].rid
RenderingServer.global_shader_parameter_set(&'num_cascades', _params.size())
RenderingServer.global_shader_parameter_set(&'displacements', _displacement_maps)
RenderingServer.global_shader_parameter_set(&'normals', _normal_maps)
RenderingServer.global_shader_parameter_set(&'water_color', Color(0.1, 0.15, 0.18).srgb_to_linear())
RenderingServer.global_shader_parameter_set(&'foam_color', Color(0.73, 0.67, 0.62).srgb_to_linear())
_published = true
print("[Ocean] published: %d cascades, map %d^2, tiles %s"
% [_params.size(), map_size,
str(_params.map(func(p): return p.tile_length.x))])
## ── VISIBILITY, for inspecting what the water is hiding ────────────────────
## The seabed is under the ocean by definition, so anything volumetric down
## there is unviewable without a way to switch the water off.
func set_surface_visible(v: bool) -> void:
visible = v
func set_horizon_visible(v: bool) -> void:
# Remembered, because _process re-decides plate visibility every frame from
# the camera's depth and would immediately undo a manual hide.
_far_manual_visible = v
if _far: _far.visible = v
func set_underwater_enabled(v: bool) -> void:
underwater_enabled = v
# _process re-decides the quad's visibility (and `submerged`) next frame.
if _under and not v: _under.visible = false
## How far a submerged camera can see, metres: the distance at which the
## CLEAREST channel's transmittance falls to VISIBLE_T, capped at the pass's
## max_path (it treats anything farther -- and the sky -- as exactly max_path, so
## geometry past it can only differ by exp(-k_min * max_path), 0.8% at defaults).
## Read from the material so a retuned water stays honest.
func visibility_distance() -> float:
var kv: Variant = _under_param(&'extinction')
var mp: Variant = _under_param(&'max_path')
# No shader information (headless: the dummy renderer has no parameter defaults):
# report no limit, so nothing is clamped on a guess.
if not (kv is Vector3) or mp == null:
return INF
var k: Vector3 = kv
var k_min := minf(k.x, minf(k.y, k.z))
var max_path := float(mp)
if k_min <= 0.0:
return max_path
return minf(log(1.0 / VISIBLE_T) / k_min, max_path)
## A shader parameter's value, or the shader's own default when unset.
func _under_param(p: StringName) -> Variant:
var v: Variant = _under_mat.get_shader_parameter(p)
if v == null:
v = RenderingServer.shader_get_parameter_default(_under_mat.shader.get_rid(), p)
return v
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE:
# Drop the RD references before the generator frees the textures.
_displacement_maps.texture_rd_rid = RID()
_normal_maps.texture_rd_rid = RID()