Expand generated terrain and forest variation

This commit is contained in:
Alexander Sellite 2026-08-21 16:38:30 -04:00
parent 779e3d983e
commit b2752b1074
25 changed files with 898 additions and 145 deletions

View file

@ -6,9 +6,9 @@
[sub_resource type="Resource" id="Rule_sand_tree"]
script = ExtResource("2_rule")
procedural_group = &"sand_tree"
placement_chance = 4200
adjacency_bonus = 1200
maximum_density = 5000
placement_chance = 2200
adjacency_bonus = 900
maximum_density = 2800
minimum_placements = 1
allowed_prop_ids = PackedStringArray("prop_palm")

View file

@ -11,7 +11,7 @@ adjacency_bonus = 1200
maximum_density = 8500
minimum_placements = 2
placement_attempts_per_chunk = 3
allowed_prop_ids = PackedStringArray("prop_tree_1", "prop_tree_2", "prop_tree_3")
allowed_prop_ids = PackedStringArray("prop_tree_large", "prop_tree_1", "prop_tree_2", "prop_tree_3")
[sub_resource type="Resource" id="Rule_grass_detail"]
script = ExtResource("2_rule")

View file

@ -11,7 +11,7 @@ adjacency_bonus = 1200
maximum_density = 8500
minimum_placements = 2
placement_attempts_per_chunk = 3
allowed_prop_ids = PackedStringArray("prop_pine")
allowed_prop_ids = PackedStringArray("prop_pine_large", "prop_pine")
[sub_resource type="Resource" id="Rule_grass_detail"]
script = ExtResource("2_rule")

View file

@ -31,6 +31,7 @@ const OCEAN_POOL: FishPool = preload(
const WATER_HEIGHT := -0.25
const PROP_EDGE_MARGIN := 2.2
const PROP_PLACEMENT_ATTEMPTS := 12
const PROP_CLUSTER_PLACEMENT_ATTEMPTS := 10
const PROP_MINIMUM_GROUND_CLEARANCE := 0.05
const PROP_CHANCE_SCALE := 10000
const PROP_SELECTION_WEIGHT_SCALE := 1000
@ -268,6 +269,7 @@ func _on_generation_completed(summary: Dictionary) -> void:
record.get("position", Vector3.ZERO),
coordinate,
biome.stable_id,
int(record.get("ocean_facing_edges", 0)),
random,
terrain_triangles,
):
@ -445,6 +447,7 @@ func _maybe_add_prop(
chunk_center: Vector3,
chunk_coordinate: Vector2i,
biome_id: StringName,
ocean_facing_edges: int,
random: RandomNumberGenerator,
terrain_triangles: Array[PackedVector3Array],
) -> bool:
@ -460,6 +463,7 @@ func _maybe_add_prop(
chunk_center,
chunk_coordinate,
biome_id,
ocean_facing_edges,
random,
terrain_triangles,
)
@ -470,19 +474,26 @@ func _add_prop(
chunk_center: Vector3,
chunk_coordinate: Vector2i,
biome_id: StringName,
ocean_facing_edges: int,
random: RandomNumberGenerator,
terrain_triangles: Array[PackedVector3Array],
) -> bool:
if definition == null or definition.packed_scene == null:
return false
var visual_scale := random.randf_range(
definition.minimum_visual_scale,
definition.maximum_visual_scale,
var cluster_size := random.randi_range(
definition.minimum_cluster_size,
definition.maximum_cluster_size,
)
var scaled_clearance := definition.clearance_radius * visual_scale
var visual_scales := _prop_visual_scales(
definition,
cluster_size,
random,
)
var primary_scale := visual_scales[0]
var scaled_clearance := definition.clearance_radius * primary_scale
var placement := Vector3.ZERO
var found_surface := false
for attempt: int in PROP_PLACEMENT_ATTEMPTS:
for _attempt: int in PROP_PLACEMENT_ATTEMPTS:
var offset := Vector3(
random.randf_range(-PROP_EDGE_MARGIN, PROP_EDGE_MARGIN),
0.0,
@ -507,6 +518,118 @@ func _add_prop(
break
if not found_surface:
return false
var cluster_id := _decorations.get_child_count()
if not _instantiate_prop(
definition,
placement,
primary_scale,
_prop_yaw(definition, ocean_facing_edges, placement, random),
chunk_coordinate,
biome_id,
cluster_id,
0,
random,
):
return false
if cluster_size <= 1:
return true
var cluster_angle := random.randf_range(-PI, PI)
var companion_count := cluster_size - 1
var chunk_half_extent := _generator.catalog.chunk_size * 0.5 - 0.25
for member_index: int in range(1, cluster_size):
var member_scale := visual_scales[member_index]
var member_clearance := definition.clearance_radius * member_scale
for _attempt: int in PROP_CLUSTER_PLACEMENT_ATTEMPTS:
var sector_angle := (
cluster_angle
+ TAU * float(member_index - 1) / float(companion_count)
+ random.randf_range(-0.45, 0.45)
)
var radius := random.randf_range(
definition.minimum_cluster_radius,
definition.maximum_cluster_radius,
)
var candidate := placement + Vector3(
cos(sector_angle) * radius,
0.0,
sin(sector_angle) * radius,
)
if (
absf(candidate.x - chunk_center.x) > chunk_half_extent
or absf(candidate.z - chunk_center.z) > chunk_half_extent
):
continue
var surface_height := _surface_height_at(
candidate,
terrain_triangles,
)
if (
surface_height <= WATER_HEIGHT + PROP_MINIMUM_GROUND_CLEARANCE
or not _has_prop_clearance(candidate, member_clearance)
):
continue
candidate.y = surface_height
if _instantiate_prop(
definition,
candidate,
member_scale,
_prop_yaw(
definition,
ocean_facing_edges,
candidate,
random,
),
chunk_coordinate,
biome_id,
cluster_id,
member_index,
random,
):
break
return true
func _prop_visual_scales(
definition: TerrainPropDefinition,
count: int,
random: RandomNumberGenerator,
) -> Array[float]:
var result: Array[float] = []
if count <= 1:
result.append(
random.randf_range(
definition.minimum_visual_scale,
definition.maximum_visual_scale,
)
)
return result
for index: int in count:
var descending_band := count - index - 1
var band_minimum := lerpf(
definition.minimum_visual_scale,
definition.maximum_visual_scale,
float(descending_band) / float(count),
)
var band_maximum := lerpf(
definition.minimum_visual_scale,
definition.maximum_visual_scale,
float(descending_band + 1) / float(count),
)
result.append(random.randf_range(band_minimum, band_maximum))
return result
func _instantiate_prop(
definition: TerrainPropDefinition,
placement: Vector3,
visual_scale: float,
yaw: float,
chunk_coordinate: Vector2i,
biome_id: StringName,
cluster_id: int,
cluster_member_index: int,
random: RandomNumberGenerator,
) -> bool:
var packed_instance := definition.packed_scene.instantiate()
var visual_root := packed_instance as Node3D
if visual_root == null:
@ -522,8 +645,10 @@ func _add_prop(
prop.set_meta(&"terrain_chunk_coordinate", chunk_coordinate)
prop.set_meta(&"terrain_biome_id", biome_id)
prop.set_meta(&"terrain_prop_visual_scale", visual_scale)
prop.set_meta(&"terrain_prop_cluster_id", cluster_id)
prop.set_meta(&"terrain_prop_cluster_member_index", cluster_member_index)
prop.position = placement
prop.rotation.y = random.randf_range(-PI, PI)
prop.rotation.y = yaw
_decorations.add_child(prop)
visual_root.name = "Visual"
prop.add_child(visual_root)
@ -533,7 +658,9 @@ func _add_prop(
_apply_prop_material_variant(visual_root, definition, random)
_add_prop_collision(prop, definition, visual_scale)
_placed_prop_positions.append(placement)
_placed_prop_clearance_radii.append(scaled_clearance)
_placed_prop_clearance_radii.append(
definition.clearance_radius * visual_scale
)
_placed_prop_groups.append(definition.procedural_group)
if definition.gatherable_anchor_height > 0.0:
var anchor := Marker3D.new()
@ -547,6 +674,46 @@ func _add_prop(
return true
func _prop_yaw(
definition: TerrainPropDefinition,
ocean_facing_edges: int,
placement: Vector3,
random: RandomNumberGenerator,
) -> float:
if not definition.prefer_ocean_facing:
return random.randf_range(-PI, PI)
var ocean_direction := Vector2.ZERO
for edge_value: int in TerrainChunkTopology.Edge.values():
if (ocean_facing_edges & (1 << edge_value)) == 0:
continue
var edge_normal := TerrainChunkTopology.edge_normal(
edge_value as TerrainChunkTopology.Edge
)
ocean_direction += Vector2(edge_normal.x, edge_normal.z)
if ocean_direction.is_zero_approx():
ocean_direction = _nearest_ocean_direction(placement)
var local_direction := definition.local_overhang_direction.normalized()
var target_angle := atan2(ocean_direction.x, ocean_direction.y)
var local_angle := atan2(local_direction.x, local_direction.y)
var spread := deg_to_rad(definition.ocean_facing_spread_degrees)
return (
target_angle
- local_angle
+ random.randf_range(-spread, spread)
)
func _nearest_ocean_direction(position: Vector3) -> Vector2:
var half_extents := get_playable_half_extents()
var distance_x := half_extents.x - absf(position.x)
var distance_z := half_extents.y - absf(position.z)
var direction_x := Vector2.RIGHT if position.x >= 0.0 else Vector2.LEFT
var direction_z := Vector2.DOWN if position.z >= 0.0 else Vector2.UP
if absf(distance_x - distance_z) <= _generator.catalog.chunk_size * 0.35:
return (direction_x + direction_z).normalized()
return direction_x if distance_x < distance_z else direction_z
func _validate_prop_catalog() -> void:
if prop_catalog == null:
push_error("Generated world terrain prop catalog is unavailable.")
@ -776,6 +943,7 @@ func _ensure_minimum_props(
record.get("position", Vector3.ZERO),
coordinate,
biome.stable_id,
int(record.get("ocean_facing_edges", 0)),
random,
terrain_triangles,
):
@ -819,17 +987,32 @@ func _apply_prop_material_variant(
definition: TerrainPropDefinition,
random: RandomNumberGenerator,
) -> void:
if (
definition.material_variants.is_empty()
or definition.variant_material_slot_names.is_empty()
):
return
var variant := definition.material_variants[
random.randi_range(0, definition.material_variants.size() - 1)
]
_apply_material_variant_to_meshes(
_apply_random_material_variant(
root_node,
definition.variant_material_slot_names,
definition.material_variants,
random,
)
_apply_random_material_variant(
root_node,
definition.secondary_variant_material_slot_names,
definition.secondary_material_variants,
random,
)
func _apply_random_material_variant(
root_node: Node,
target_material_names: PackedStringArray,
variants: Array[Material],
random: RandomNumberGenerator,
) -> void:
if variants.is_empty() or target_material_names.is_empty():
return
var variant := variants[random.randi_range(0, variants.size() - 1)]
_apply_material_variant_to_meshes(
root_node,
target_material_names,
variant,
)

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bsislktcsck1y"
path="res://.godot/imported/prop_pine_large.glb-97f0e7e162a6a624faaf2cc023211d3e.scn"
[deps]
source_file="res://world/generation/props/assets/prop_pine_large.glb"
dest_files=["res://.godot/imported/prop_pine_large.glb-97f0e7e162a6a624faaf2cc023211d3e.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://c46jcb2vo2fd"
path="res://.godot/imported/prop_tree_large.glb-0a4ab94061e4876be025fa3d63aa7bd8.scn"
[deps]
source_file="res://world/generation/props/assets/prop_tree_large.glb"
dest_files=["res://.godot/imported/prop_tree_large.glb-0a4ab94061e4876be025fa3d63aa7bd8.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

View file

@ -11,6 +11,15 @@ packed_scene = ExtResource("2_scene")
procedural_group = &"sand_tree"
required_chunk_tags = PackedStringArray("sand")
minimum_spawn_chunk_distance = 2
clearance_radius = 2.0
clearance_radius = 0.65
minimum_visual_scale = 0.5
maximum_visual_scale = 1.0
minimum_cluster_size = 2
maximum_cluster_size = 3
minimum_cluster_radius = 1.4
maximum_cluster_radius = 3.0
prefer_ocean_facing = true
local_overhang_direction = Vector2(-0.883, 0.469)
ocean_facing_spread_degrees = 55.0
collision_radius = 0.4
collision_height = 6.0

View file

@ -6,15 +6,15 @@
[resource]
script = ExtResource("1_definition")
stable_id = &"prop_pine"
label = "pine"
label = "small pine"
packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass")
selection_weight = 0.7
minimum_spawn_chunk_distance = 2
clearance_radius = 1.55
minimum_visual_scale = 0.75
maximum_visual_scale = 1.22
minimum_visual_scale = 0.65
maximum_visual_scale = 1.2
collision_radius = 0.5
collision_height = 4.0
gatherable_anchor_height = 2.15

View file

@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_pine_large.glb" id="2_scene"]
[resource]
script = ExtResource("1_definition")
stable_id = &"prop_pine_large"
label = "large pine"
packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass")
selection_weight = 3.0
minimum_spawn_chunk_distance = 2
clearance_radius = 1.65
minimum_visual_scale = 0.75
maximum_visual_scale = 1.2
collision_radius = 0.65
collision_height = 9.5
gatherable_anchor_height = 2.15

View file

@ -1,24 +1,30 @@
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=6 format=3]
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=9 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_1.glb" id="2_scene"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_light.tres" id="6_wood_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_mid.tres" id="7_wood_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_dark.tres" id="8_wood_dark"]
[resource]
script = ExtResource("1_definition")
stable_id = &"prop_tree_1"
label = "small tree"
label = "small deciduous tree"
packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass")
selection_weight = 1.0
minimum_spawn_chunk_distance = 2
clearance_radius = 1.3
minimum_visual_scale = 0.75
maximum_visual_scale = 1.15
minimum_visual_scale = 0.65
maximum_visual_scale = 1.2
variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark")
material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")])
secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", "wood_mid")
secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")])
collision_radius = 0.4
collision_height = 3.2
gatherable_anchor_height = 2.15

View file

@ -1,24 +1,30 @@
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=6 format=3]
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=9 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_2.glb" id="2_scene"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_light.tres" id="6_wood_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_mid.tres" id="7_wood_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_dark.tres" id="8_wood_dark"]
[resource]
script = ExtResource("1_definition")
stable_id = &"prop_tree_2"
label = "medium tree"
label = "medium deciduous tree"
packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass")
selection_weight = 1.0
minimum_spawn_chunk_distance = 2
clearance_radius = 1.5
minimum_visual_scale = 0.8
maximum_visual_scale = 1.18
minimum_visual_scale = 0.7
maximum_visual_scale = 1.2
variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark")
material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")])
secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", "wood_mid")
secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")])
collision_radius = 0.5
collision_height = 3.8
gatherable_anchor_height = 2.15

View file

@ -1,24 +1,30 @@
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=6 format=3]
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=9 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_3.glb" id="2_scene"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_light.tres" id="6_wood_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_mid.tres" id="7_wood_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_dark.tres" id="8_wood_dark"]
[resource]
script = ExtResource("1_definition")
stable_id = &"prop_tree_3"
label = "large tree"
label = "decorative deciduous tree"
packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass")
selection_weight = 1.0
minimum_spawn_chunk_distance = 2
clearance_radius = 1.8
minimum_visual_scale = 0.85
maximum_visual_scale = 1.15
minimum_visual_scale = 0.7
maximum_visual_scale = 1.2
variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark")
material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")])
secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", "wood_mid")
secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")])
collision_radius = 0.55
collision_height = 4.2
gatherable_anchor_height = 2.15

View file

@ -0,0 +1,30 @@
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=9 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_large.glb" id="2_scene"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_light.tres" id="6_wood_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_mid.tres" id="7_wood_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_dark.tres" id="8_wood_dark"]
[resource]
script = ExtResource("1_definition")
stable_id = &"prop_tree_large"
label = "large deciduous tree"
packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass")
selection_weight = 12.0
minimum_spawn_chunk_distance = 2
clearance_radius = 2.5
minimum_visual_scale = 0.75
maximum_visual_scale = 1.2
variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark")
material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")])
secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", "wood_mid")
secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")])
collision_radius = 0.85
collision_height = 7.5
gatherable_anchor_height = 2.15

View file

@ -0,0 +1,9 @@
[gd_resource type="StandardMaterial3D" format=3]
[resource]
resource_name = "wood_variant_dark"
albedo_color = Color(0.163, 0.139, 0.0254, 1)
metallic = 0.0
roughness = 1.0
shading_mode = 1
texture_filter = 0

View file

@ -0,0 +1,9 @@
[gd_resource type="StandardMaterial3D" format=3]
[resource]
resource_name = "wood_variant_light"
albedo_color = Color(0.644, 0.563, 0.356, 1)
metallic = 0.0
roughness = 1.0
shading_mode = 1
texture_filter = 0

View file

@ -0,0 +1,9 @@
[gd_resource type="StandardMaterial3D" format=3]
[resource]
resource_name = "wood_variant_mid"
albedo_color = Color(0.333, 0.271, 0.114, 1)
metallic = 0.0
roughness = 1.0
shading_mode = 1
texture_filter = 0

View file

@ -1,4 +1,4 @@
[gd_resource type="Resource" script_class="TerrainPropCatalog" load_steps=16 format=3]
[gd_resource type="Resource" script_class="TerrainPropCatalog" load_steps=18 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_catalog.gd" id="1_catalog"]
[ext_resource type="Resource" path="res://world/generation/props/definitions/prop_bridge.tres" id="2_bridge"]
@ -15,7 +15,9 @@
[ext_resource type="Resource" path="res://world/generation/props/definitions/prop_tree_2.tres" id="13_tree_2"]
[ext_resource type="Resource" path="res://world/generation/props/definitions/prop_tree_3.tres" id="14_tree_3"]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="15_definition_script"]
[ext_resource type="Resource" path="res://world/generation/props/definitions/prop_tree_large.tres" id="16_tree_large"]
[ext_resource type="Resource" path="res://world/generation/props/definitions/prop_pine_large.tres" id="17_pine_large"]
[resource]
script = ExtResource("1_catalog")
definitions = Array[ExtResource("15_definition_script")]([ExtResource("2_bridge"), ExtResource("3_dock"), ExtResource("4_log"), ExtResource("5_post_1"), ExtResource("6_post_2"), ExtResource("7_mushroom"), ExtResource("8_palm"), ExtResource("9_pine"), ExtResource("10_timber_1"), ExtResource("11_timber_2"), ExtResource("12_tree_1"), ExtResource("13_tree_2"), ExtResource("14_tree_3")])
definitions = Array[ExtResource("15_definition_script")]([ExtResource("2_bridge"), ExtResource("3_dock"), ExtResource("4_log"), ExtResource("5_post_1"), ExtResource("6_post_2"), ExtResource("7_mushroom"), ExtResource("8_palm"), ExtResource("9_pine"), ExtResource("10_timber_1"), ExtResource("11_timber_2"), ExtResource("12_tree_1"), ExtResource("13_tree_2"), ExtResource("14_tree_3"), ExtResource("16_tree_large"), ExtResource("17_pine_large")])

View file

@ -34,6 +34,11 @@ const MAX_PACKED_SOLVER_VARIANTS := 62
@export var elevated_cliff_edge_chunk_id: StringName = &"chunk_0011"
@export var elevated_cliff_ramp_chunk_id: StringName = &"chunk_0012"
@export_range(0.0, 1.0, 0.05) var elevated_cliff_ramp_chance := 0.75
## A smaller third tier reuses the existing corner geometry at the authored
## two-meter level interval. It is nested into the lower cliff assembly rather
## than occupying or replacing additional terrain-grid cells.
@export_range(0.0, 1.0, 0.05) var elevated_cliff_third_level_chance := 0.7
@export_range(0.1, 10.0, 0.1) var elevated_cliff_level_height := 2.0
@export_group("")
@export_range(1.0, 100.0, 1.0) var required_chunk_weight_multiplier := 64.0
@export_range(1.0, 4.0, 0.05) var preferred_neighbor_weight_multiplier := 1.6
@ -68,6 +73,7 @@ var _generated_chunks: Node3D
var _backtrack_count := 0
var _elevated_feature_center := Vector2i(-1, -1)
var _elevated_feature_reserved_grass_indices: Dictionary[int, bool] = {}
var _stacked_elevated_placements: Array[Dictionary] = []
func _ready() -> void:
@ -100,6 +106,9 @@ func generate() -> bool:
if not _apply_elevated_feature():
_assign_placements(previous_placements)
return false
if not _configure_stacked_elevated_feature():
_assign_placements(previous_placements)
return false
var solution_root := _build_solution_root()
if solution_root == null:
_assign_placements(previous_placements)
@ -138,6 +147,9 @@ func generate_from_placement_keys(keys: PackedStringArray) -> bool:
previous_placements.assign(_placements)
_assign_placements(resolved)
_backtrack_count = 0
if not _configure_stacked_elevated_feature():
_assign_placements(previous_placements)
return false
var solution_root := _build_solution_root()
if solution_root == null:
_assign_placements(previous_placements)
@ -1184,6 +1196,7 @@ func _candidate_can_occupy_cell(
func _prepare_elevated_feature_region() -> bool:
_elevated_feature_center = Vector2i(-1, -1)
_elevated_feature_reserved_grass_indices.clear()
_stacked_elevated_placements.clear()
if not elevated_cliff_feature_enabled:
_build_grid_solver_caches()
return true
@ -1439,6 +1452,65 @@ func _apply_elevated_feature() -> bool:
return true
func _configure_stacked_elevated_feature() -> bool:
_stacked_elevated_placements.clear()
if (
not elevated_cliff_feature_enabled
or elevated_cliff_third_level_chance <= 0.0
):
return true
var top_coordinate := Vector2i(-1, -1)
for index: int in _placements.size():
var placement := _placements[index]
if (
placement != null
and placement.definition.stable_id == elevated_cliff_top_chunk_id
):
top_coordinate = Vector2i(index % grid_size.x, index / grid_size.x)
break
if top_coordinate.x < 0:
return true
var stack_random := RandomNumberGenerator.new()
stack_random.seed = generation_seed ^ 0x7312DC11F
if stack_random.randf() >= elevated_cliff_third_level_chance:
return true
var corner_definition := catalog.definition_for_id(
elevated_cliff_corner_chunk_id
)
if corner_definition == null:
push_error(
"Stacked cliff feature references missing corner chunk %s."
% elevated_cliff_corner_chunk_id
)
return false
# Four nearly half-cell-offset corners form a closed 2x2 ring. A slight
# inward overlap keeps the authored curved feet fully seated on the lower
# plateau instead of exposing a hairline gap at its outermost vertices.
var specs: Array[Dictionary] = [
{"offset": Vector2(-0.45, -0.45), "turns": 2},
{"offset": Vector2(0.45, -0.45), "turns": 1},
{"offset": Vector2(-0.45, 0.45), "turns": 3},
{"offset": Vector2(0.45, 0.45), "turns": 0},
]
for spec: Dictionary in specs:
var variant := _authored_variant(
corner_definition,
int(spec["turns"]),
)
if variant == null:
push_error(
"Stacked cliff feature cannot resolve %s rotation %d."
% [elevated_cliff_corner_chunk_id, spec["turns"]]
)
return false
_stacked_elevated_placements.append({
"support_coordinate": top_coordinate,
"offset": spec["offset"],
"variant": variant,
})
return true
func _variant_respects_ocean_boundary(
variant: TerrainChunkVariant,
coordinate: Vector2i,
@ -2040,9 +2112,74 @@ func _build_solution_root() -> Node3D:
_add_collision(chunk_root, variant.definition)
if show_chunk_labels:
_add_chunk_label(chunk_root, variant)
if not _add_stacked_elevated_chunks(solution_root):
solution_root.free()
return null
return solution_root
func _add_stacked_elevated_chunks(solution_root: Node3D) -> bool:
for index: int in _stacked_elevated_placements.size():
var record := _stacked_elevated_placements[index]
var variant := record.get("variant") as TerrainChunkVariant
var support_coordinate: Vector2i = record.get(
"support_coordinate",
Vector2i(-1, -1),
)
var support_root := _find_chunk_root_for_coordinate(
solution_root,
support_coordinate,
)
if variant == null or support_root == null:
push_error("Stacked cliff feature lost its supporting terrain cell.")
return false
var stacked_root := variant.definition.packed_scene.instantiate() as Node3D
if stacked_root == null:
push_error(
"%s does not instantiate as a stacked Node3D."
% variant.stable_key()
)
return false
stacked_root.name = "Stacked_%s_%d" % [
variant.stable_key().replace("@", "r"),
index,
]
var offset: Vector2 = record.get("offset", Vector2.ZERO)
stacked_root.position = Vector3(
offset.x * catalog.chunk_size,
elevated_cliff_level_height,
offset.y * catalog.chunk_size,
)
stacked_root.rotation.y = variant.rotation_radians()
stacked_root.set_meta(&"terrain_stacked_elevation", true)
stacked_root.set_meta(
&"terrain_chunk_id",
variant.definition.stable_id,
)
stacked_root.set_meta(
&"terrain_chunk_coordinate",
support_coordinate,
)
support_root.add_child(stacked_root)
if build_collision:
_add_collision(stacked_root, variant.definition)
if show_chunk_labels:
_add_chunk_label(stacked_root, variant)
return true
func _find_chunk_root_for_coordinate(
solution_root: Node3D,
coordinate: Vector2i,
) -> Node3D:
for child: Node in solution_root.get_children():
if child.get_meta(&"terrain_chunk_coordinate", Vector2i(-1, -1)) == (
coordinate
):
return child as Node3D
return null
func _instantiate_chunk_root(
definition: TerrainChunkDefinition,
) -> Node3D:
@ -2171,6 +2308,7 @@ func _build_summary() -> Dictionary:
"counts": counts,
"variant_counts": variant_counts,
"placements": placement_keys(),
"stacked_elevated_placements": stacked_elevated_placement_keys(),
"layout_fingerprint": placement_fingerprint(),
}
@ -2214,9 +2352,29 @@ func placement_fingerprint() -> String:
"chunk_size:%.6f" % (catalog.chunk_size if catalog != null else 0.0),
])
fingerprint_input.append_array(placement_keys())
fingerprint_input.append_array(stacked_elevated_placement_keys())
return "\n".join(fingerprint_input).sha256_text()
func stacked_elevated_placement_keys() -> PackedStringArray:
var result := PackedStringArray()
for record: Dictionary in _stacked_elevated_placements:
var variant := record.get("variant") as TerrainChunkVariant
var offset: Vector2 = record.get("offset", Vector2.ZERO)
if variant == null:
continue
result.append(
"%s@%.2f,%.2f,+%.2fm"
% [
variant.stable_key(),
offset.x,
offset.y,
elevated_cliff_level_height,
]
)
return result
func placement_records() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for index: int in _placements.size():
@ -2276,4 +2434,18 @@ func get_primary_terrain_meshes() -> Array[MeshInstance3D]:
)
if primary_mesh != null and primary_mesh.mesh != null:
result.append(primary_mesh)
for child: Node in chunk_root.get_children():
if not bool(child.get_meta(&"terrain_stacked_elevation", false)):
continue
var definition := catalog.definition_for_id(
StringName(child.get_meta(&"terrain_chunk_id", &""))
)
if definition == null:
continue
var stacked_mesh := TerrainChunkAnalyzer.find_primary_mesh(
child,
definition.primary_mesh_name,
)
if stacked_mesh != null and stacked_mesh.mesh != null:
result.append(stacked_mesh)
return result

View file

@ -87,6 +87,23 @@ func validation_errors() -> PackedStringArray:
"%s has an inverted visual scale range."
% definition.stable_id
)
if definition.minimum_cluster_size > definition.maximum_cluster_size:
errors.append(
"%s has an inverted cluster size range."
% definition.stable_id
)
if (
definition.maximum_cluster_size > 1
and (
definition.minimum_cluster_radius <= 0.0
or definition.minimum_cluster_radius
> definition.maximum_cluster_radius
)
):
errors.append(
"%s has an invalid loose-cluster radius."
% definition.stable_id
)
if (
not definition.material_variants.is_empty()
and definition.variant_material_slot_names.is_empty()
@ -101,4 +118,26 @@ func validation_errors() -> PackedStringArray:
"%s contains an empty material variant."
% definition.stable_id
)
if (
not definition.secondary_material_variants.is_empty()
and definition.secondary_variant_material_slot_names.is_empty()
):
errors.append(
"%s has secondary variants but no target material slots."
% definition.stable_id
)
for material: Material in definition.secondary_material_variants:
if material == null:
errors.append(
"%s contains an empty secondary material variant."
% definition.stable_id
)
if (
definition.prefer_ocean_facing
and definition.local_overhang_direction.is_zero_approx()
):
errors.append(
"%s prefers the ocean but has no local overhang direction."
% definition.stable_id
)
return errors

View file

@ -21,12 +21,28 @@ extends Resource
## Procedural instances use a deterministic uniform scale in this range.
@export_range(0.1, 4.0, 0.05) var minimum_visual_scale := 1.0
@export_range(0.1, 4.0, 0.05) var maximum_visual_scale := 1.0
## Values above one create a loose group of separately anchored instances.
## Cluster members are distributed across the configured scale range so a
## three-member cluster naturally contains a small, medium, and large prop.
@export_range(1, 4, 1) var minimum_cluster_size := 1
@export_range(1, 4, 1) var maximum_cluster_size := 1
@export_range(0.0, 10.0, 0.05) var minimum_cluster_radius := 0.0
@export_range(0.0, 10.0, 0.05) var maximum_cluster_radius := 0.0
## Presentation-only offset. The prop root remains exactly terrain-aligned.
@export var visual_offset := Vector3.ZERO
## A single material is chosen per prop and applied only to matching imported
## material slots. This keeps trunks untouched while varying foliage.
@export var variant_material_slot_names := PackedStringArray()
@export var material_variants: Array[Material] = []
## An independent second material channel, used when both foliage and wood
## should vary without coupling their color choices.
@export var secondary_variant_material_slot_names := PackedStringArray()
@export var secondary_material_variants: Array[Material] = []
## Some asymmetric props can loosely face an authored ocean edge. The local
## direction describes the model's natural overhang in Godot X/Z space.
@export var prefer_ocean_facing := false
@export var local_overhang_direction := Vector2(0.0, -1.0)
@export_range(0.0, 180.0, 1.0) var ocean_facing_spread_degrees := 45.0
@export_range(0.0, 5.0, 0.05) var collision_radius := 0.0
@export_range(0.0, 20.0, 0.05) var collision_height := 0.0
@export var collision_box_size := Vector3.ZERO