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