feat(world): seabed flora and encrusting growth on rock
Quality Gate / Quality Gate (pull_request) Failing after 2m58s

SeabedFloraActor scatters seagrass tufts on flat sand in the lit band and
sea fans on rock between 15 and 60 m, deterministically from the site id,
one MultiMesh per tile and kind, with procedural alpha-cut meshes and
textures (SeabedFloraAssets), vertex sway and wrapped lighting, no
collision and no sonar presence. Placement reads the field arrays
directly and runs on the worker thread pool. The seabed shader grows
turf algae on sunlit rock, coralline crusts in patches and encrusting
sponges in the shade, fading out between 40 and 75 m. The water-column
optics move to a shared shader include.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
OlivierandClaude Fable 5.1 committed 2026-10-07 00:44:10 +02:00
1 parent 122f3f5800
commit 0f9918224a
16 files changed
+585 -14

No files matched your search

+286
View File
@@ -0,0 +1,286 @@
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
## collision and no sonar presence. Placement is pure and runs on the worker thread
## pool; the multimeshes are built afterwards on the main thread.
const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd")
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")
enum Kind { SEAGRASS, SEA_FAN }
const TILE_CELLS := 64
# Seagrass: meadows on flat sand, dense to 20 m, gone by 36 m (Caribbean Syringodium
# and Halophila thin out with the light).
const GRASS_SPACING_M := 2.0
const GRASS_DEPTH_FULL_M := 20.0
const GRASS_DEPTH_NONE_M := 36.0
const GRASS_SLOPE_COSINE := 0.9659 # cos(15 deg)
const GRASS_MEADOW_THRESHOLD := 0.12
const GRASS_VISIBILITY_M := 110.0
# Sea fans: on rock from 15 to 60 m, heading across the swell.
const FAN_SPACING_M := 5.0
const FAN_DEPTH_MIN_M := 15.0
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)
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),
]
## Placements of one tile for one kind, computed off the main thread.
class TilePlacements extends RefCounted:
var cells := Rect2i()
var transforms: Array[Transform3D] = []
var colors := PackedColorArray()
var custom := PackedColorArray()
var _field: TerrainHeightField
var _seed := 0
var _assets: SeabedFloraAssets
var _grass_material: ShaderMaterial
var _fan_material: ShaderMaterial
var _meadow_noise: FastNoiseLite
var _fan_noise: FastNoiseLite
var _counts := {Kind.SEAGRASS: 0, Kind.SEA_FAN: 0}
var _samples := {Kind.SEAGRASS: [], Kind.SEA_FAN: []}
func configure(field: TerrainHeightField, seed: int) -> String:
if field == null:
return "flora field is missing"
_clear()
_field = field
_seed = seed
if _assets == null:
_assets = SeabedFloraAssets.build(seed)
_grass_material = _material(_assets.blade_texture)
_fan_material = _material(_assets.fan_texture)
_meadow_noise = _noise(seed + 11, 0.012)
_fan_noise = _noise(seed + 23, 0.03)
var tiles := _tiles()
var grass: Array[TilePlacements] = []
var fans: 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)
var group := WorkerThreadPool.add_group_task(
_place_tile.bind(grass, fans), 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])
return ""
func instance_count(kind: Kind) -> int:
return int(_counts[kind])
## World transforms of the first few instances of a kind, for inspection and tests.
func sample_transforms(kind: Kind) -> Array[Transform3D]:
var result: Array[Transform3D] = []
for transform in _samples[kind]:
result.append(transform)
return result
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: []}
func _tiles() -> Array[Rect2i]:
var tiles: Array[Rect2i] = []
var last_column := _field.columns() - 1
var last_row := _field.rows() - 1
var start_row := 0
while start_row < last_row:
var end_row := mini(start_row + TILE_CELLS, last_row)
var start_column := 0
while start_column < last_column:
var end_column := mini(start_column + TILE_CELLS, last_column)
tiles.append(Rect2i(start_column, start_row, end_column - start_column, end_row - start_row))
start_column = end_column
start_row = end_row
return tiles
func _place_tile(tile_index: int, grass: Array[TilePlacements], fans: Array[TilePlacements]) -> void:
var tile := grass[tile_index].cells
var cell := _field.cell_size_m()
var origin := _field.origin_m()
var x_min := origin.x + float(tile.position.x) * cell
var z_min := origin.y + float(tile.position.y) * cell
var x_max := origin.x + float(tile.end.x) * cell
var z_max := origin.y + float(tile.end.y) * cell
var generator := RandomNumberGenerator.new()
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)
## Cheap rejections first, straight from the field arrays (cell reads, one noise read),
## random draws only for accepted candidates: the draw sequence stays deterministic
## because every decision is.
func _place_grass(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 sand := SonarMaterial.Value.SAND
var slope_limit := (1.0 / (GRASS_SLOPE_COSINE * GRASS_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() * GRASS_SPACING_M
var pz := z + generator.randf() * GRASS_SPACING_M
x += GRASS_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
var depth := -elevations[index]
if depth >= GRASS_DEPTH_NONE_M or materials[index] != sand:
continue
var meadow := _meadow_noise.get_noise_2d(px, pz)
if meadow < GRASS_MEADOW_THRESHOLD:
continue
# Slope by central differences: (dz_dx^2 + dz_dz^2) against tan^2 of the limit.
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 density := 1.0 - smoothstep(GRASS_DEPTH_FULL_M, GRASS_DEPTH_NONE_M, depth)
if generator.randf() > density * clampf((meadow - GRASS_MEADOW_THRESHOLD) * 4.0, 0.3, 1.0):
continue
var yaw := generator.randf() * TAU
var size := generator.randf_range(0.6, 1.1)
var phase := generator.randf()
var tint := generator.randf_range(0.85, 1.15)
var elevation := _field.elevation_at(px, pz)
var basis := Basis(Vector3.UP, yaw).scaled(Vector3(size, size * 0.9, size))
out.transforms.append(Transform3D(basis, Vector3(px, elevation - 0.03, pz)))
out.colors.append(Color(0.9 * tint, 1.0 * tint, 0.85 * tint))
out.custom.append(Color(phase, 1.0, 0.0, 0.0))
z += GRASS_SPACING_M
func _place_fans(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 heading := atan2(-FAN_HEADING.y, FAN_HEADING.x)
var z := z_min
while z < z_max:
var x := x_min
while x < x_max:
var px := x + generator.randf() * FAN_SPACING_M
var pz := z + generator.randf() * FAN_SPACING_M
x += FAN_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 < FAN_DEPTH_MIN_M or depth > FAN_DEPTH_MAX_M:
continue
if generator.randf() > FAN_KEEP_PROBABILITY or _fan_noise.get_noise_2d(px, pz) < -0.1:
continue
var yaw := heading + generator.randf_range(-0.45, 0.45)
var width := generator.randf_range(1.0, 2.4)
var height := generator.randf_range(0.9, 2.0)
var phase := generator.randf()
var color: Color = FAN_COLORS[generator.randi_range(0, FAN_COLORS.size() - 1)]
var elevation := _field.elevation_at(px, pz)
var normal := _field.normal_at_cell(column, row)
# Stand up, leaning a little with the rock face.
var up := (Vector3.UP * 0.6 + normal * 0.4).normalized()
var forward := Vector3(cos(yaw), 0.0, -sin(yaw))
var right := forward.cross(up).normalized()
forward = up.cross(right).normalized()
var basis := Basis(right * width, up * height, forward)
out.transforms.append(Transform3D(basis, Vector3(px, elevation - 0.05, pz)))
out.colors.append(color)
out.custom.append(Color(phase, 0.12, 0.0, 0.0))
z += FAN_SPACING_M
func _add_multimesh(kind: Kind, placements: TilePlacements) -> void:
var count := placements.transforms.size()
if count == 0:
return
var multimesh := MultiMesh.new()
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
multimesh.instance_count = count
for index in count:
multimesh.set_instance_transform(index, placements.transforms[index])
multimesh.set_instance_color(index, placements.colors[index])
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",
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.visibility_range_end_margin = 20.0
instance.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF
instance.cast_shadow = (
GeometryInstance3D.SHADOW_CASTING_SETTING_OFF if kind == Kind.SEAGRASS
else GeometryInstance3D.SHADOW_CASTING_SETTING_ON
)
add_child(instance)
_counts[kind] = int(_counts[kind]) + count
var samples: Array = _samples[kind]
for index in mini(count, 4):
if samples.size() < 16:
samples.append(placements.transforms[index])
func _material(texture: ImageTexture) -> ShaderMaterial:
var material := ShaderMaterial.new()
material.shader = FLORA_SHADER
material.set_shader_parameter(&"albedo_map", texture)
return material
static func _noise(seed: int, frequency: float) -> FastNoiseLite:
var noise := FastNoiseLite.new()
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
noise.seed = seed
noise.frequency = frequency
noise.fractal_octaves = 3
return noise