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>
380 lines
15 KiB
GDScript
380 lines
15 KiB
GDScript
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, 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.
|
|
|
|
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")
|
|
const CORAL_SHADER = preload("res://game/world/sites/seabed_coral.gdshader")
|
|
|
|
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
|
|
# 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)
|
|
# 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),
|
|
]
|
|
|
|
|
|
## 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 variants := PackedInt32Array()
|
|
|
|
|
|
var _field: TerrainHeightField
|
|
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, Kind.CORAL_HEAD: 0}
|
|
var _samples := {Kind.SEAGRASS: [], Kind.SEA_FAN: [], Kind.CORAL_HEAD: []}
|
|
|
|
|
|
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)
|
|
_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:
|
|
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, 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 ""
|
|
|
|
|
|
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, Kind.CORAL_HEAD: 0}
|
|
_samples = {Kind.SEAGRASS: [], Kind.SEA_FAN: [], Kind.CORAL_HEAD: []}
|
|
|
|
|
|
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], corals: 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)
|
|
_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),
|
|
## 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 _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
|
|
var multimesh := MultiMesh.new()
|
|
multimesh.transform_format = MultiMesh.TRANSFORM_3D
|
|
multimesh.use_colors = true
|
|
multimesh.use_custom_data = true
|
|
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])
|
|
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", "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, _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 = (
|
|
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
|