feat(world): volumetric coral heads on patch reefs
Quality Gate / Quality Gate (pull_request) Failing after 3m18s

Three procedural coral head meshes (boulder, brain-ridged, lobed domes on
a flat base, vertex-shaded crevices) are scattered on gentle rock between
12 and 40 m, clustered by noise, one MultiMesh per tile and variant, with
an opaque shader under the shared water column. The coral assertions
run on the Banc du Vigilant, whose patch reef lies in the lit band.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
OlivierandClaude Fable 5.1 committed 2026-10-07 08:22:09 +02:00
1 parent 0f9918224a
commit 48e054557c
12 files changed
+293 -24

No files matched your search

+113 -20
View File
@@ -2,8 +2,9 @@ class_name SeabedFloraActor
extends Node3D
## Scatters the seabed flora of a site from its height field, deterministically from a
## seed: seagrass tufts on gently sloping sand in the lit band, sea fans on rock. One
## MultiMeshInstance3D per terrain tile and per kind, with a visibility range, no
## seed: seagrass tufts on gently sloping sand in the lit band, sea fans on rock, and
## massive coral heads on the gentle rock of patch reefs. One MultiMeshInstance3D per
## terrain tile and per kind (per variant for corals), with a visibility range, no
## collision and no sonar presence. Placement is pure and runs on the worker thread
## pool; the multimeshes are built afterwards on the main thread.
@@ -11,8 +12,9 @@ const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
const SeabedFloraAssets = preload("res://game/world/sites/seabed_flora_assets.gd")
const FLORA_SHADER = preload("res://game/world/sites/seabed_flora.gdshader")
const CORAL_SHADER = preload("res://game/world/sites/seabed_coral.gdshader")
enum Kind { SEAGRASS, SEA_FAN }
enum Kind { SEAGRASS, SEA_FAN, CORAL_HEAD }
const TILE_CELLS := 64
# Seagrass: meadows on flat sand, dense to 20 m, gone by 36 m (Caribbean Syringodium
@@ -30,6 +32,19 @@ const FAN_DEPTH_MAX_M := 60.0
const FAN_KEEP_PROBABILITY := 0.45
const FAN_VISIBILITY_M := 170.0
const FAN_HEADING := Vector2(0.8, 0.6)
# Coral heads: patch reefs are the gentle (< 30 deg) rock of the sunlit band, 12 to
# 40 m, clustered by noise; heads are 0.6 to 2.2 m across.
const CORAL_SPACING_M := 3.0
const CORAL_DEPTH_MIN_M := 12.0
const CORAL_DEPTH_FADE_START_M := 30.0
const CORAL_DEPTH_MAX_M := 40.0
const CORAL_SLOPE_COSINE := 0.866 # cos(30 deg)
const CORAL_REEF_THRESHOLD := 0.05
const CORAL_KEEP_PROBABILITY := 0.55
const CORAL_VISIBILITY_M := 150.0
const CORAL_COLORS: Array[Color] = [
Color(0.72, 0.58, 0.3), Color(0.5, 0.52, 0.28), Color(0.68, 0.48, 0.36), Color(0.62, 0.6, 0.42),
]
const FAN_COLORS: Array[Color] = [
Color(0.55, 0.35, 0.62), Color(0.85, 0.42, 0.2), Color(0.8, 0.7, 0.3), Color(0.6, 0.3, 0.45),
]
@@ -41,6 +56,7 @@ class TilePlacements extends RefCounted:
var transforms: Array[Transform3D] = []
var colors := PackedColorArray()
var custom := PackedColorArray()
var variants := PackedInt32Array()
var _field: TerrainHeightField
@@ -48,10 +64,12 @@ var _seed := 0
var _assets: SeabedFloraAssets
var _grass_material: ShaderMaterial
var _fan_material: ShaderMaterial
var _coral_material: ShaderMaterial
var _reef_noise: FastNoiseLite
var _meadow_noise: FastNoiseLite
var _fan_noise: FastNoiseLite
var _counts := {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0}
var _samples := {Kind.SEAGRASS: [], Kind.SEA_FAN: []}
var _counts := {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0, Kind.CORAL_HEAD: 0}
var _samples := {Kind.SEAGRASS: [], Kind.SEA_FAN: [], Kind.CORAL_HEAD: []}
func configure(field: TerrainHeightField, seed: int) -> String:
@@ -64,25 +82,28 @@ func configure(field: TerrainHeightField, seed: int) -> String:
_assets = SeabedFloraAssets.build(seed)
_grass_material = _material(_assets.blade_texture)
_fan_material = _material(_assets.fan_texture)
_coral_material = ShaderMaterial.new()
_coral_material.shader = CORAL_SHADER
_meadow_noise = _noise(seed + 11, 0.012)
_fan_noise = _noise(seed + 23, 0.03)
_reef_noise = _noise(seed + 37, 0.02)
var tiles := _tiles()
var grass: Array[TilePlacements] = []
var fans: Array[TilePlacements] = []
var corals: Array[TilePlacements] = []
for tile in tiles:
var grass_tile := TilePlacements.new()
grass_tile.cells = tile
grass.append(grass_tile)
var fan_tile := TilePlacements.new()
fan_tile.cells = tile
fans.append(fan_tile)
for list: Array[TilePlacements] in [grass, fans, corals]:
var placements := TilePlacements.new()
placements.cells = tile
list.append(placements)
var group := WorkerThreadPool.add_group_task(
_place_tile.bind(grass, fans), tiles.size(), -1, true, "seabed flora"
_place_tile.bind(grass, fans, corals), tiles.size(), -1, true, "seabed flora"
)
WorkerThreadPool.wait_for_group_task_completion(group)
for index in tiles.size():
_add_multimesh(Kind.SEAGRASS, grass[index])
_add_multimesh(Kind.SEA_FAN, fans[index])
_add_coral_multimeshes(corals[index])
return ""
@@ -102,8 +123,8 @@ func _clear() -> void:
for child in get_children():
remove_child(child)
child.free()
_counts = {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0}
_samples = {Kind.SEAGRASS: [], Kind.SEA_FAN: []}
_counts = {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0, Kind.CORAL_HEAD: 0}
_samples = {Kind.SEAGRASS: [], Kind.SEA_FAN: [], Kind.CORAL_HEAD: []}
func _tiles() -> Array[Rect2i]:
@@ -122,7 +143,7 @@ func _tiles() -> Array[Rect2i]:
return tiles
func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[TilePlacements]) -> void:
func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[TilePlacements], corals: Array[TilePlacements]) -> void:
var tile := grass[tile_index].cells
var cell := _field.cell_size_m()
var origin := _field.origin_m()
@@ -134,6 +155,7 @@ func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[Tile
generator.seed = _seed * 7919 + tile_index
_place_grass(grass[tile_index], generator, x_min, x_max, z_min, z_max)
_place_fans(fans[tile_index], generator, x_min, x_max, z_min, z_max)
_place_corals(corals[tile_index], generator, x_min, x_max, z_min, z_max)
## Cheap rejections first, straight from the field arrays (cell reads, one noise read),
@@ -233,7 +255,72 @@ func _place_fans(out: TilePlacements, generator: RandomNumberGenerator, x_min: f
z += FAN_SPACING_M
func _add_multimesh(kind: Kind, placements: TilePlacements) -> void:
func _place_corals(out: TilePlacements, generator: RandomNumberGenerator, x_min: float, x_max: float, z_min: float, z_max: float) -> void:
var origin := _field.origin_m()
var cell := _field.cell_size_m()
var columns := _field.columns()
var rows := _field.rows()
var elevations := _field.elevations_m()
var materials := _field.materials()
var rock := SonarMaterial.Value.ROCK
var slope_limit := (1.0 / (CORAL_SLOPE_COSINE * CORAL_SLOPE_COSINE) - 1.0) * (2.0 * cell) * (2.0 * cell)
var z := z_min
while z < z_max:
var x := x_min
while x < x_max:
var px := x + generator.randf() * CORAL_SPACING_M
var pz := z + generator.randf() * CORAL_SPACING_M
x += CORAL_SPACING_M
var column := int(roundf((px - origin.x) / cell))
var row := int(roundf((pz - origin.y) / cell))
if px >= x_max or pz >= z_max or column < 1 or row < 1 or column >= columns - 1 or row >= rows - 1:
continue
var index := row * columns + column
if materials[index] != rock:
continue
var depth := -elevations[index]
if depth < CORAL_DEPTH_MIN_M or depth > CORAL_DEPTH_MAX_M:
continue
var rise_x := elevations[index + 1] - elevations[index - 1]
var rise_z := elevations[index + columns] - elevations[index - columns]
if rise_x * rise_x + rise_z * rise_z > slope_limit:
continue
var reef := _reef_noise.get_noise_2d(px, pz)
if reef < CORAL_REEF_THRESHOLD:
continue
var density := (1.0 - smoothstep(CORAL_DEPTH_FADE_START_M, CORAL_DEPTH_MAX_M, depth)) * CORAL_KEEP_PROBABILITY
if generator.randf() > density * clampf((reef - CORAL_REEF_THRESHOLD) * 5.0, 0.4, 1.0):
continue
var yaw := generator.randf() * TAU
var radius := generator.randf_range(0.35, 1.3)
var squash := generator.randf_range(0.7, 1.15)
var phase := generator.randf()
var variant := generator.randi_range(0, _assets.coral_meshes.size() - 1)
var color: Color = CORAL_COLORS[generator.randi_range(0, CORAL_COLORS.size() - 1)]
var elevation := _field.elevation_at(px, pz)
var basis := Basis(Vector3.UP, yaw).scaled(Vector3(radius, radius * squash, radius))
out.transforms.append(Transform3D(basis, Vector3(px, elevation - 0.08, pz)))
out.colors.append(color)
out.custom.append(Color(phase, 0.0, 0.0, 0.0))
out.variants.append(variant)
z += CORAL_SPACING_M
## Coral heads of one tile, one MultiMesh per mesh variant.
func _add_coral_multimeshes(placements: TilePlacements) -> void:
for variant in _assets.coral_meshes.size():
var subset := TilePlacements.new()
subset.cells = placements.cells
for index in placements.transforms.size():
if placements.variants[index] != variant:
continue
subset.transforms.append(placements.transforms[index])
subset.colors.append(placements.colors[index])
subset.custom.append(placements.custom[index])
_add_multimesh(Kind.CORAL_HEAD, subset, variant)
func _add_multimesh(kind: Kind, placements: TilePlacements, variant: int = 0) -> void:
var count := placements.transforms.size()
if count == 0:
return
@@ -241,7 +328,13 @@ func _add_multimesh(kind: Kind, placements: TilePlacements) -> void:
multimesh.transform_format = MultiMesh.TRANSFORM_3D
multimesh.use_colors = true
multimesh.use_custom_data = true
multimesh.mesh = _assets.tuft_mesh if kind == Kind.SEAGRASS else _assets.fan_mesh
match kind:
Kind.SEAGRASS:
multimesh.mesh = _assets.tuft_mesh
Kind.SEA_FAN:
multimesh.mesh = _assets.fan_mesh
Kind.CORAL_HEAD:
multimesh.mesh = _assets.coral_meshes[variant]
multimesh.instance_count = count
for index in count:
multimesh.set_instance_transform(index, placements.transforms[index])
@@ -249,13 +342,13 @@ func _add_multimesh(kind: Kind, placements: TilePlacements) -> void:
multimesh.set_instance_custom_data(index, placements.custom[index])
var instance := MultiMeshInstance3D.new()
instance.name = "%s_%d_%d" % [
"Seagrass" if kind == Kind.SEAGRASS else "SeaFans",
["Seagrass", "SeaFans", "Corals%d" % variant][kind],
placements.cells.position.x / TILE_CELLS,
placements.cells.position.y / TILE_CELLS,
]
instance.multimesh = multimesh
instance.material_override = _grass_material if kind == Kind.SEAGRASS else _fan_material
instance.visibility_range_end = GRASS_VISIBILITY_M if kind == Kind.SEAGRASS else FAN_VISIBILITY_M
instance.material_override = [_grass_material, _fan_material, _coral_material][kind]
instance.visibility_range_end = [GRASS_VISIBILITY_M, FAN_VISIBILITY_M, CORAL_VISIBILITY_M][kind]
instance.visibility_range_end_margin = 20.0
instance.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF
instance.cast_shadow = (