OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / assets / ocean / ocean.gd

416 lines · gd ref:
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()