Expand generated terrain with biome-driven props

This commit is contained in:
Alexander Sellite 2026-08-20 00:28:55 -04:00
parent e8888ed05a
commit 1e24fb0bfc
103 changed files with 5427 additions and 351 deletions

View file

@ -9,9 +9,6 @@ const FishingShopInteractionType = preload(
const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd"
)
const PROP_SOURCE: PackedScene = preload(
"res://art/exported/environment/terrain/starter_island.glb"
)
const WATER_BODY_SCENE: PackedScene = preload("res://world/water_body.tscn")
const SALT_WATER_MATERIAL: Material = preload(
"res://world/materials/stylized_water.tres"
@ -25,16 +22,21 @@ const POND_POOL: FishPool = preload(
const OCEAN_POOL: FishPool = preload(
"res://fish/pools/starter_ocean_pool.tres"
)
const TREE_SOURCE_NAME := "tree_2_001"
const PALM_SOURCE_NAME := "plam_tree"
const TREE_RUNTIME_NAME := "regular_tree"
const PALM_RUNTIME_NAME := "palm_tree"
const WATER_HEIGHT := -0.25
const PROP_EDGE_MARGIN := 2.2
const TREE_CHANCE := 0.58
const PALM_CHANCE := 0.42
const PROP_PLACEMENT_ATTEMPTS := 12
const PROP_MINIMUM_GROUND_CLEARANCE := 0.05
const PROP_CHANCE_SCALE := 10000
const PROP_SELECTION_WEIGHT_SCALE := 1000
const PROCEDURAL_PROP_GROUPS: Array[StringName] = [
&"grass_tree",
&"grass_detail",
&"sand_tree",
]
@export var initial_seed := PlayerSaveManager.DEFAULT_WORLD_SEED
@export var prop_catalog: TerrainPropCatalog
@export var biome_catalog: TerrainBiomeCatalog
@onready var _generator: TerrainChunkGenerator = %TerrainChunkGenerator
@onready var _player_spawn: Marker3D = %PlayerSpawn
@ -56,15 +58,17 @@ const PALM_CHANCE := 0.42
var _current_seed := PlayerSaveManager.DEFAULT_WORLD_SEED
var _light_performance_profile := false
var _source_meshes: Dictionary[String, Mesh] = {}
var _source_bases: Dictionary[String, Basis] = {}
var _source_minimum_y: Dictionary[String, float] = {}
var _placed_prop_positions: Array[Vector3] = []
var _placed_prop_clearance_radii: Array[float] = []
var _placed_prop_groups: Array[StringName] = []
var _placed_group_coordinates: Dictionary[StringName, Array] = {}
var _biome_assignments: Dictionary[Vector2i, StringName] = {}
func _ready() -> void:
_generator.generation_completed.connect(_on_generation_completed)
_cache_prop_source(TREE_SOURCE_NAME)
_cache_prop_source(PALM_SOURCE_NAME)
_validate_prop_catalog()
_validate_biome_catalog()
_configure_static_water()
_build_shoreline_reference()
if not generate_world(initial_seed):
@ -107,6 +111,18 @@ func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return _shoreline_reference
func get_prop_catalog() -> TerrainPropCatalog:
return prop_catalog
func get_biome_catalog() -> TerrainBiomeCatalog:
return biome_catalog
func get_biome_at(coordinate: Vector2i) -> StringName:
return _biome_assignments.get(coordinate, &"")
func set_light_performance_profile(enabled: bool) -> void:
_light_performance_profile = enabled
_apply_water_materials()
@ -118,10 +134,9 @@ func get_spawn_surface_triangles(
minimum_up_dot: float = 0.6,
) -> Array[PackedVector3Array]:
var triangles: Array[PackedVector3Array] = []
var root: Node3D = _generator.get_generated_chunks_root()
if root == null or material_names.is_empty():
if material_names.is_empty():
return triangles
for mesh_instance: MeshInstance3D in _collect_mesh_instances(root):
for mesh_instance: MeshInstance3D in _generator.get_primary_terrain_meshes():
var mesh: Mesh = mesh_instance.mesh
if mesh == null:
continue
@ -143,15 +158,31 @@ func get_spawn_surface_triangles(
func _on_generation_completed(summary: Dictionary) -> void:
var records: Array[Dictionary] = _generator.placement_records()
_assign_biomes(records, summary)
var spawn_position := Vector3.ZERO
_clear_children(_decorations)
_clear_children(_tree_anchors)
_placed_prop_positions.clear()
_placed_prop_clearance_radii.clear()
_placed_prop_groups.clear()
_placed_group_coordinates.clear()
var random := RandomNumberGenerator.new()
random.seed = _current_seed ^ 0x5EED71
var grass_centers: Array[Vector3] = []
var sand_centers: Array[Vector3] = []
var tree_count := 0
var palm_count := 0
var terrain_triangles := _terrain_surface_triangles()
var biomes: Array[TerrainBiomeDefinition] = []
if biome_catalog != null:
biomes.assign(biome_catalog.definitions)
var eligible_records: Dictionary[StringName, Array] = {}
var group_counts: Dictionary[StringName, int] = {}
for group: StringName in PROCEDURAL_PROP_GROUPS:
for biome: TerrainBiomeDefinition in biomes:
var rule := biome.prop_rule_for_group(group)
if rule == null:
continue
var group_key := _biome_group_key(biome.stable_id, group)
eligible_records[group_key] = []
group_counts[group_key] = 0
_placed_group_coordinates[group_key] = []
for record: Dictionary in records:
var position: Vector3 = record.get("position", Vector3.ZERO)
var tags: PackedStringArray = record.get(
@ -159,50 +190,124 @@ func _on_generation_completed(summary: Dictionary) -> void:
)
if "spawn" in tags:
spawn_position = position
if "spawn" not in tags and "grass" in tags:
grass_centers.append(position)
if _maybe_add_prop(
TREE_SOURCE_NAME,
TREE_RUNTIME_NAME,
position,
TREE_CHANCE,
random,
true,
):
tree_count += 1
elif "sand" in tags:
sand_centers.append(position)
if _maybe_add_prop(
PALM_SOURCE_NAME,
PALM_RUNTIME_NAME,
position,
PALM_CHANCE,
random,
false,
):
palm_count += 1
if tree_count == 0 and not grass_centers.is_empty():
_add_prop(
TREE_SOURCE_NAME,
TREE_RUNTIME_NAME,
grass_centers[random.randi_range(0, grass_centers.size() - 1)],
random,
true,
)
if palm_count == 0 and not sand_centers.is_empty():
_add_prop(
PALM_SOURCE_NAME,
PALM_RUNTIME_NAME,
sand_centers[random.randi_range(0, sand_centers.size() - 1)],
random,
false,
continue
var coordinate: Vector2i = record.get(
"coordinate",
Vector2i.ZERO,
)
var biome_id: StringName = record.get("biome_id", &"")
if biome_catalog == null:
continue
var biome := biome_catalog.definition_for_id(biome_id)
if biome == null:
continue
for group: StringName in PROCEDURAL_PROP_GROUPS:
var rule := biome.prop_rule_for_group(group)
if rule == null:
continue
var definitions := _spawn_distance_eligible_definitions(
_prop_definitions_for(rule, tags),
coordinate,
)
if definitions.is_empty():
continue
var group_key := _biome_group_key(biome_id, group)
var group_records: Array = eligible_records[group_key]
group_records.append(record)
for group: StringName in PROCEDURAL_PROP_GROUPS:
for biome: TerrainBiomeDefinition in biomes:
var rule := biome.prop_rule_for_group(group)
if rule == null:
continue
var group_key := _biome_group_key(biome.stable_id, group)
var group_records: Array = eligible_records[group_key]
var maximum := _prop_group_maximum(rule, group_records.size())
for record: Dictionary in group_records:
if group_counts.get(group_key, 0) >= maximum:
break
var coordinate: Vector2i = record.get(
"coordinate",
Vector2i.ZERO,
)
var tags: PackedStringArray = record.get(
"tags",
PackedStringArray(),
)
var definitions := _spawn_distance_eligible_definitions(
_prop_definitions_for(rule, tags),
coordinate,
)
if (
definitions.is_empty()
or not _prop_group_roll_succeeds(
rule,
group_key,
coordinate,
random,
)
):
continue
if _maybe_add_prop(
definitions,
record.get("position", Vector3.ZERO),
coordinate,
biome.stable_id,
random,
terrain_triangles,
):
group_counts[group_key] += 1
_record_prop_group_coordinate(
group_key,
coordinate,
)
_ensure_minimum_props(
eligible_records,
group_counts,
random,
terrain_triangles,
)
_place_spawn_amenities(spawn_position)
_configure_fresh_water(records)
_configure_diggable_area()
world_generated.emit(_current_seed, summary)
func _assign_biomes(
records: Array[Dictionary],
summary: Dictionary,
) -> void:
_biome_assignments = TerrainBiomeAssigner.assign(
biome_catalog,
records,
_current_seed,
)
for index: int in records.size():
var record := records[index]
var coordinate: Vector2i = record.get(
"coordinate",
Vector2i.ZERO,
)
record["biome_id"] = _biome_assignments.get(coordinate, &"")
records[index] = record
var generated_chunks := _generator.get_generated_chunks_root()
if generated_chunks != null:
for child: Node in generated_chunks.get_children():
var coordinate: Vector2i = child.get_meta(
&"terrain_chunk_coordinate",
Vector2i.ZERO,
)
child.set_meta(
&"terrain_biome_id",
_biome_assignments.get(coordinate, &""),
)
summary["biome_counts"] = TerrainBiomeAssigner.counts(
_biome_assignments
)
summary["biome_fingerprint"] = TerrainBiomeAssigner.fingerprint(
_biome_assignments
)
func _place_spawn_amenities(center: Vector3) -> void:
_player_spawn.position = center + Vector3(0.0, 0.18, 2.2)
_safe_spawn.position = _player_spawn.position
@ -312,60 +417,410 @@ func _light_water_material(color: Color) -> StandardMaterial3D:
func _maybe_add_prop(
source_name: String,
runtime_name: String,
definitions: Array[TerrainPropDefinition],
chunk_center: Vector3,
chance: float,
chunk_coordinate: Vector2i,
biome_id: StringName,
random: RandomNumberGenerator,
add_anchor: bool,
terrain_triangles: Array[PackedVector3Array],
) -> bool:
if random.randf() > chance or not _source_meshes.has(source_name):
return false
_add_prop(
source_name,
runtime_name,
var definition := _pick_prop_definition(
definitions,
chunk_center,
random,
add_anchor,
)
return true
if definition == null:
return false
return _add_prop(
definition,
chunk_center,
chunk_coordinate,
biome_id,
random,
terrain_triangles,
)
func _add_prop(
source_name: String,
runtime_name: String,
definition: TerrainPropDefinition,
chunk_center: Vector3,
chunk_coordinate: Vector2i,
biome_id: StringName,
random: RandomNumberGenerator,
add_anchor: bool,
) -> void:
if not _source_meshes.has(source_name):
return
terrain_triangles: Array[PackedVector3Array],
) -> bool:
if definition == null or definition.packed_scene == null:
return false
var placement := Vector3.ZERO
var found_surface := false
for attempt: int in PROP_PLACEMENT_ATTEMPTS:
var offset := Vector3(
random.randf_range(-PROP_EDGE_MARGIN, PROP_EDGE_MARGIN),
0.0,
random.randf_range(-PROP_EDGE_MARGIN, PROP_EDGE_MARGIN),
)
placement = chunk_center + offset
var surface_height := _surface_height_at(
placement,
terrain_triangles,
)
if (
surface_height > -INF
and surface_height
> WATER_HEIGHT + PROP_MINIMUM_GROUND_CLEARANCE
and _has_prop_clearance(
placement,
definition.clearance_radius,
)
):
placement.y = surface_height
found_surface = true
break
if not found_surface:
return false
var packed_instance := definition.packed_scene.instantiate()
var visual_root := packed_instance as Node3D
if visual_root == null:
packed_instance.free()
return false
var prop := Node3D.new()
prop.name = "%s_%d" % [runtime_name, _decorations.get_child_count()]
var offset := Vector3(
random.randf_range(-PROP_EDGE_MARGIN, PROP_EDGE_MARGIN),
0.0,
random.randf_range(-PROP_EDGE_MARGIN, PROP_EDGE_MARGIN),
)
prop.position = chunk_center + offset
prop.name = "%s_%d" % [
String(definition.stable_id).trim_prefix("prop_"),
_decorations.get_child_count(),
]
prop.set_meta(&"terrain_prop_id", definition.stable_id)
prop.set_meta(&"terrain_prop_group", definition.procedural_group)
prop.set_meta(&"terrain_chunk_coordinate", chunk_coordinate)
prop.set_meta(&"terrain_biome_id", biome_id)
prop.position = placement
prop.rotation.y = random.randf_range(-PI, PI)
_decorations.add_child(prop)
var visual := MeshInstance3D.new()
visual.name = "Visual"
visual.mesh = _source_meshes[source_name]
visual.basis = _source_bases[source_name]
visual.position.y = -_source_minimum_y.get(source_name, 0.0)
visual.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
prop.add_child(visual)
_add_prop_collision(prop, source_name == TREE_SOURCE_NAME)
if add_anchor:
visual_root.name = "Visual"
prop.add_child(visual_root)
_configure_prop_visuals(visual_root)
_add_prop_collision(prop, definition)
_placed_prop_positions.append(placement)
_placed_prop_clearance_radii.append(definition.clearance_radius)
_placed_prop_groups.append(definition.procedural_group)
if definition.gatherable_anchor_height > 0.0:
var anchor := Marker3D.new()
anchor.name = "TreeAnchor_%d" % _tree_anchors.get_child_count()
anchor.position = prop.position + Vector3(0.0, 2.15, 0.0)
anchor.position = prop.position + Vector3(
0.0,
definition.gatherable_anchor_height,
0.0,
)
_tree_anchors.add_child(anchor)
return true
func _add_prop_collision(prop: Node3D, broad_tree: bool) -> void:
func _validate_prop_catalog() -> void:
if prop_catalog == null:
push_error("Generated world terrain prop catalog is unavailable.")
return
for error: String in prop_catalog.validation_errors():
push_error("Generated world terrain prop catalog: %s" % error)
func _validate_biome_catalog() -> void:
if biome_catalog == null:
push_error("Generated world terrain biome catalog is unavailable.")
return
for error: String in biome_catalog.validation_errors():
push_error("Generated world terrain biome catalog: %s" % error)
if prop_catalog == null:
return
for biome: TerrainBiomeDefinition in biome_catalog.definitions:
if biome == null:
continue
for rule: TerrainBiomePropRule in biome.prop_rules:
if rule == null:
continue
if rule.procedural_group not in PROCEDURAL_PROP_GROUPS:
push_error(
"Biome %s references unsupported prop group %s."
% [biome.stable_id, rule.procedural_group]
)
for prop_id_value: String in rule.allowed_prop_ids:
var prop_id := StringName(prop_id_value)
var definition := prop_catalog.definition_for_id(prop_id)
if definition == null:
push_error(
"Biome %s references missing prop %s."
% [biome.stable_id, prop_id]
)
elif definition.procedural_group != rule.procedural_group:
push_error(
"Biome %s assigns prop %s to the wrong group."
% [biome.stable_id, prop_id]
)
func _prop_definitions_for(
rule: TerrainBiomePropRule,
tags: PackedStringArray,
) -> Array[TerrainPropDefinition]:
if prop_catalog == null or rule == null:
return []
var result: Array[TerrainPropDefinition] = []
for definition: TerrainPropDefinition in (
prop_catalog.procedural_definitions(rule.procedural_group, tags)
):
if rule.allows_prop(definition):
result.append(definition)
return result
func _prop_group_roll_succeeds(
rule: TerrainBiomePropRule,
group_key: StringName,
coordinate: Vector2i,
random: RandomNumberGenerator,
) -> bool:
var threshold := rule.placement_chance
if _group_has_adjacent_placement(group_key, coordinate):
threshold += rule.adjacency_bonus
threshold = mini(threshold, PROP_CHANCE_SCALE)
return random.randi_range(0, PROP_CHANCE_SCALE - 1) < threshold
func _pick_prop_definition(
definitions: Array[TerrainPropDefinition],
chunk_center: Vector3,
random: RandomNumberGenerator,
) -> TerrainPropDefinition:
var total_weight := 0
var weights: Array[int] = []
for definition: TerrainPropDefinition in definitions:
var weight := maxi(
1,
roundi(
definition.selection_weight
* float(PROP_SELECTION_WEIGHT_SCALE)
),
)
if not definition.preferred_nearby_prop_groups.is_empty():
var multiplier := definition.nearby_preference_weight_multiplier
if _has_preferred_prop_near(definition, chunk_center):
weight = maxi(roundi(float(weight) * multiplier), 1)
else:
weight = maxi(roundi(float(weight) / multiplier), 1)
weights.append(weight)
total_weight += weight
if total_weight <= 0:
return null
var roll := random.randi_range(1, total_weight)
for index: int in definitions.size():
roll -= weights[index]
if roll <= 0:
return definitions[index]
return definitions.back() if not definitions.is_empty() else null
func _prop_group_maximum(
rule: TerrainBiomePropRule,
eligible_count: int,
) -> int:
if eligible_count <= 0:
return 0
var maximum := ceili(
float(eligible_count * rule.maximum_density)
/ float(PROP_CHANCE_SCALE)
)
return maxi(maximum, rule.minimum_placements)
func _spawn_distance_eligible_definitions(
definitions: Array[TerrainPropDefinition],
coordinate: Vector2i,
) -> Array[TerrainPropDefinition]:
var result: Array[TerrainPropDefinition] = []
var center := Vector2i(
_generator.grid_size.x / 2,
_generator.grid_size.y / 2,
)
var distance := (
absi(coordinate.x - center.x)
+ absi(coordinate.y - center.y)
)
for definition: TerrainPropDefinition in definitions:
if distance >= definition.minimum_spawn_chunk_distance:
result.append(definition)
return result
func _group_has_adjacent_placement(
group: StringName,
coordinate: Vector2i,
) -> bool:
var coordinates: Array = _placed_group_coordinates.get(group, [])
for coordinate_value: Variant in coordinates:
var other: Vector2i = coordinate_value
if (
absi(coordinate.x - other.x)
+ absi(coordinate.y - other.y)
== 1
):
return true
return false
func _record_prop_group_coordinate(
group: StringName,
coordinate: Vector2i,
) -> void:
var coordinates: Array = _placed_group_coordinates.get(group, [])
coordinates.append(coordinate)
_placed_group_coordinates[group] = coordinates
func _has_preferred_prop_near(
definition: TerrainPropDefinition,
position: Vector3,
) -> bool:
for index: int in _placed_prop_positions.size():
if (
_placed_prop_groups[index]
not in definition.preferred_nearby_prop_groups
):
continue
var other := _placed_prop_positions[index]
if Vector2(position.x - other.x, position.z - other.z).length() <= (
definition.preferred_nearby_radius
):
return true
return false
func _ensure_minimum_props(
eligible_records: Dictionary[StringName, Array],
group_counts: Dictionary[StringName, int],
random: RandomNumberGenerator,
terrain_triangles: Array[PackedVector3Array],
) -> void:
if biome_catalog == null:
return
for group: StringName in PROCEDURAL_PROP_GROUPS:
for biome: TerrainBiomeDefinition in biome_catalog.definitions:
var rule := biome.prop_rule_for_group(group)
if rule == null or rule.minimum_placements <= 0:
continue
var group_key := _biome_group_key(biome.stable_id, group)
var records: Array = eligible_records.get(group_key, [])
if records.is_empty():
continue
var maximum_attempts := maxi(
PROP_PLACEMENT_ATTEMPTS,
records.size() * rule.minimum_placements * 2,
)
for _attempt: int in maximum_attempts:
if (
group_counts.get(group_key, 0)
>= rule.minimum_placements
):
break
var record: Dictionary = records[
random.randi_range(0, records.size() - 1)
]
var tags: PackedStringArray = record.get(
"tags",
PackedStringArray(),
)
var coordinate: Vector2i = record.get(
"coordinate",
Vector2i.ZERO,
)
var definitions := _spawn_distance_eligible_definitions(
_prop_definitions_for(rule, tags),
coordinate,
)
if _maybe_add_prop(
definitions,
record.get("position", Vector3.ZERO),
coordinate,
biome.stable_id,
random,
terrain_triangles,
):
group_counts[group_key] += 1
_record_prop_group_coordinate(
group_key,
coordinate,
)
func _biome_group_key(
biome_id: StringName,
group: StringName,
) -> StringName:
return StringName("%s:%s" % [biome_id, group])
func _has_prop_clearance(position: Vector3, radius: float) -> bool:
for index: int in _placed_prop_positions.size():
var other := _placed_prop_positions[index]
var distance := Vector2(
position.x - other.x,
position.z - other.z,
).length()
if distance < radius + _placed_prop_clearance_radii[index]:
return false
return true
func _configure_prop_visuals(root_node: Node) -> void:
if root_node is GeometryInstance3D:
(root_node as GeometryInstance3D).cast_shadow = (
GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
)
for child: Node in root_node.get_children():
_configure_prop_visuals(child)
func _terrain_surface_triangles() -> Array[PackedVector3Array]:
var triangles: Array[PackedVector3Array] = []
for mesh_instance: MeshInstance3D in _generator.get_primary_terrain_meshes():
if mesh_instance.mesh == null:
continue
for surface_index: int in mesh_instance.mesh.get_surface_count():
_append_surface_triangles(
triangles,
mesh_instance,
mesh_instance.mesh.surface_get_arrays(surface_index),
-INF,
0.35,
)
return triangles
func _surface_height_at(
position: Vector3,
triangles: Array[PackedVector3Array],
) -> float:
var highest := -INF
var segment_start := position + Vector3.UP * 100.0
var segment_end := position + Vector3.DOWN * 100.0
for triangle: PackedVector3Array in triangles:
if triangle.size() != 3:
continue
var hit: Variant = Geometry3D.segment_intersects_triangle(
segment_start,
segment_end,
triangle[0],
triangle[1],
triangle[2],
)
if hit is Vector3:
highest = maxf(highest, (hit as Vector3).y)
return highest
func _add_prop_collision(
prop: Node3D,
definition: TerrainPropDefinition,
) -> void:
if not definition.has_collision():
return
var body := StaticBody3D.new()
body.name = "TrunkCollision"
body.collision_layer = 1
@ -373,44 +828,21 @@ func _add_prop_collision(prop: Node3D, broad_tree: bool) -> void:
prop.add_child(body)
var collision := CollisionShape3D.new()
collision.name = "CollisionShape"
var shape := CylinderShape3D.new()
shape.radius = 0.5 if broad_tree else 0.35
shape.height = 4.2 if broad_tree else 4.8
collision.position.y = shape.height * 0.5
collision.shape = shape
body.add_child(collision)
func _cache_prop_source(source_name: String) -> void:
var source_root := PROP_SOURCE.instantiate()
var source := source_root.find_child(
source_name,
true,
false,
) as MeshInstance3D
if source != null and source.mesh != null:
_source_meshes[source_name] = source.mesh
_source_bases[source_name] = source.transform.basis
_source_minimum_y[source_name] = _minimum_transformed_mesh_y(
source.mesh,
source.transform.basis,
)
if definition.has_box_collision():
var box := BoxShape3D.new()
box.size = definition.collision_box_size
collision.shape = box
collision.position = definition.collision_offset
else:
push_warning("Generated terrain prop source '%s' is unavailable." % source_name)
source_root.free()
func _minimum_transformed_mesh_y(mesh: Mesh, basis: Basis) -> float:
var bounds := mesh.get_aabb()
var minimum_y := INF
for corner_index: int in 8:
var corner := bounds.position + Vector3(
bounds.size.x if (corner_index & 1) != 0 else 0.0,
bounds.size.y if (corner_index & 2) != 0 else 0.0,
bounds.size.z if (corner_index & 4) != 0 else 0.0,
var cylinder := CylinderShape3D.new()
cylinder.radius = definition.collision_radius
cylinder.height = definition.collision_height
collision.shape = cylinder
collision.position = (
definition.collision_offset
+ Vector3.UP * cylinder.height * 0.5
)
minimum_y = minf(minimum_y, (basis * corner).y)
return minimum_y if minimum_y < INF else 0.0
body.add_child(collision)
func _build_shoreline_reference() -> void:
@ -492,15 +924,6 @@ func _append_triangle(
result.append(PackedVector3Array([a, b, c]))
func _collect_mesh_instances(root: Node) -> Array[MeshInstance3D]:
var result: Array[MeshInstance3D] = []
if root is MeshInstance3D:
result.append(root as MeshInstance3D)
for child: Node in root.get_children():
result.append_array(_collect_mesh_instances(child))
return result
func _clear_children(root: Node) -> void:
for child: Node in root.get_children():
root.remove_child(child)