Refresh generated terrain assets and projection

This commit is contained in:
Alexander Sellite 2026-08-24 16:31:55 -04:00
parent 3608be41ab
commit 05838406d0
53 changed files with 319 additions and 51 deletions

View file

@ -481,6 +481,9 @@ func _validate_generated_region(
assert(river_body_count == river_placement_count)
_validate_authored_chunk_surfaces(region, generator)
_validate_projected_terrain_materials(
generator.get_generated_chunks_root()
)
var decorations := region.get_node("Decorations") as Node3D
var anchors := region.get_node(
@ -1147,21 +1150,28 @@ func _validate_tree_gatherable_anchors(
anchors: GatherableAnchorSet3D,
) -> void:
var eligible_props: Dictionary[StringName, Node3D] = {}
var expected_anchor_count := 0
for child: Node in decorations.get_children():
var prop := child as Node3D
if prop == null:
continue
var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &""))
var definition := region.get_prop_catalog().definition_for_id(prop_id)
if definition != null and definition.has_gatherable_surface():
var socket_count := _count_authored_beetle_sockets(prop)
if (
definition != null
and definition.has_gatherable_surface()
and socket_count > 0
):
eligible_props[prop.name] = prop
expected_anchor_count += socket_count
var positions := anchors.get_spawn_positions()
assert(positions.size() == eligible_props.size())
assert(positions.size() == expected_anchor_count)
assert(positions.size() >= 12)
for child: Node in anchors.get_children():
var anchor := child as Marker3D
assert(anchor != null)
assert(bool(anchor.get_meta(&"mesh_surface_sampled", false)))
assert(bool(anchor.get_meta(&"authored_beetle_socket", false)))
var prop_name := StringName(anchor.get_meta(&"terrain_prop_name", &""))
assert(eligible_props.has(prop_name))
var prop: Node3D = eligible_props[prop_name]
@ -1169,14 +1179,17 @@ func _validate_tree_gatherable_anchors(
var definition := region.get_prop_catalog().definition_for_id(prop_id)
assert(definition != null and definition.has_gatherable_surface())
var local_anchor := prop.to_local(anchor.global_position)
assert(
local_anchor.y
>= definition.gatherable_surface_minimum_height - 0.001
)
assert(
local_anchor.y
<= definition.gatherable_surface_maximum_height + 0.001
)
assert(prop_id != &"prop_palm")
assert(local_anchor.y >= 0.25 and local_anchor.y <= 2.0)
func _count_authored_beetle_sockets(root_node: Node) -> int:
var result := 0
if String(root_node.name).to_lower().contains("beetle_socket"):
result += 1
for child: Node in root_node.get_children():
result += _count_authored_beetle_sockets(child)
return result
func _find_mesh_instance(root_node: Node) -> MeshInstance3D:
@ -1357,6 +1370,56 @@ func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray:
return result
func _validate_projected_terrain_materials(root: Node) -> void:
var expected_sizes := {
"grass_lite": 1.75,
"sand": 2.6,
"dirt": 2.5,
}
var surface_counts := {
"grass_lite": 0,
"sand": 0,
"dirt": 0,
}
_validate_projected_material_node(root, expected_sizes, surface_counts)
for material_name: String in expected_sizes:
assert(surface_counts[material_name] > 0)
func _validate_projected_material_node(
root: Node,
expected_sizes: Dictionary,
surface_counts: Dictionary,
) -> void:
var mesh_instance := root as MeshInstance3D
if mesh_instance != null and mesh_instance.mesh != null:
for surface_index: int in mesh_instance.mesh.get_surface_count():
var material := mesh_instance.get_active_material(surface_index)
if material == null or not expected_sizes.has(material.resource_name):
continue
var shader_material := material as ShaderMaterial
assert(shader_material != null and shader_material.shader != null)
assert(
shader_material.shader.resource_path
== "res://world/materials/terrain_surface_projection.gdshader"
)
assert(
is_equal_approx(
float(shader_material.get_shader_parameter(
&"tile_world_size"
)),
float(expected_sizes[material.resource_name]),
)
)
surface_counts[material.resource_name] += 1
for child: Node in root.get_children():
_validate_projected_material_node(
child,
expected_sizes,
surface_counts,
)
func _validate_pond_collision(
region: GeneratedWorldRegion,
pond: MeshInstance3D,

View file

@ -10,10 +10,11 @@ Run through Blender rather than a standalone Python interpreter:
Collections use ``chunk_####_description`` and contain one primary terrain
mesh named ``chunk_####``. Additional production objects may live in the same
collection. Reusable procedural props use individual ``prop_description``
mesh objects. They may be arranged anywhere in the source file because each
object's authored origin becomes the exported runtime anchor. A same-named
``prop_description`` collection remains supported for multi-object props.
Unrelated objects and collections are ignored.
mesh objects. Their child empties are exported with them so authored sockets
remain attached to the prop. Props may be arranged anywhere in the source file
because each root object's authored origin becomes the exported runtime anchor.
A same-named ``prop_description`` collection remains supported for multi-object
props. Unrelated objects and collections are ignored.
"""
from __future__ import annotations
@ -43,6 +44,12 @@ PROP_COLLECTION_PATTERN = re.compile(
r"^prop_(?P<label>[a-z0-9]+(?:_[a-z0-9]+)*)$"
)
ALLOWED_OBJECT_TYPES = {"EMPTY", "MESH"}
UPWARD_SURFACE_MATERIALS = {
"dirt",
"grass_lite",
"sand",
"water_reference",
}
class ExportValidationError(RuntimeError):
@ -167,6 +174,33 @@ def _validate_transform(
return problems
def _validate_upward_surface_normals(
mesh_object: bpy.types.Object,
) -> list[str]:
"""Reject exposed terrain materials whose faces point into the ground."""
downward_counts: dict[str, int] = {}
materials = mesh_object.data.materials
for polygon in mesh_object.data.polygons:
if polygon.normal.z >= -0.25 or polygon.material_index >= len(materials):
continue
material = materials[polygon.material_index]
if material is None:
continue
material_name = re.sub(r"\.\d{3}$", "", material.name)
if material_name not in UPWARD_SURFACE_MATERIALS:
continue
downward_counts[material_name] = (
downward_counts.get(material_name, 0) + 1
)
return [
(
f"{count} {material_name} face(s) point downward; "
"terrain surface normals must face up"
)
for material_name, count in sorted(downward_counts.items())
]
def _validate_bounds(
bounds: Bounds,
tolerance: float,
@ -318,6 +352,10 @@ def _discover_chunks(tolerance: float) -> list[ChunkSource]:
errors.append(
f"{collection.name}/{primary_mesh.name}: mesh has no material"
)
errors.extend(
f"{collection.name}/{primary_mesh.name}: {problem}"
for problem in _validate_upward_surface_normals(primary_mesh)
)
chunks.append(
ChunkSource(
@ -522,20 +560,38 @@ def _discover_props(tolerance: float) -> list[PropSource]:
if not collections:
errors.append(f"{item.name}: prop object belongs to no collection")
continue
objects = tuple(
sorted(
(item, *item.children_recursive),
key=lambda value: value.name,
)
)
overlapping_sources = {
claimed_objects[descendant.as_pointer()]
for descendant in objects
if descendant.as_pointer() in claimed_objects
}
if overlapping_sources:
errors.append(
f"{item.name}: child object is already exported by "
f"{', '.join(sorted(overlapping_sources))}"
)
continue
prop, source_errors = _make_prop_source(
stable_id,
match.group("label"),
"object",
item.name,
collections[0],
(item,),
objects,
tolerance,
)
errors.extend(source_errors)
if prop is not None:
props.append(prop)
claimed_ids[stable_id] = item.name
claimed_objects[item.as_pointer()] = item.name
for descendant in objects:
claimed_objects[descendant.as_pointer()] = item.name
if errors:
raise ExportValidationError("\n".join(errors))

View file

@ -9,9 +9,6 @@ const FishingShopInteractionType = preload(
const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd"
)
const MeshSurfaceAnchorSamplerType = preload(
"res://world/generation/mesh_surface_anchor_sampler.gd"
)
const PrecipitationOcclusionType = preload(
"res://world/environment/precipitation_occlusion.gd"
)
@ -699,38 +696,41 @@ func _instantiate_prop(
)
_placed_prop_groups.append(definition.procedural_group)
if definition.has_gatherable_surface():
var surface_sample: Dictionary = (
MeshSurfaceAnchorSamplerType.sample_vertical_surface(
visual_root,
var authored_sockets := _authored_beetle_sockets(visual_root)
for socket: Node3D in authored_sockets:
_add_tree_gatherable_anchor(
prop,
definition.gatherable_surface_material_names,
definition.gatherable_surface_minimum_height,
definition.gatherable_surface_maximum_height,
definition.gatherable_surface_maximum_up_dot,
definition.gatherable_surface_clearance,
random,
)
)
if surface_sample.is_empty():
push_warning(
"No reachable gatherable mesh surface found on %s."
% definition.stable_id
socket.global_position,
definition,
)
return true
func _authored_beetle_sockets(root: Node) -> Array[Node3D]:
var sockets: Array[Node3D] = []
var node_3d := root as Node3D
if (
node_3d != null
and String(node_3d.name).to_lower().contains("beetle_socket")
):
sockets.append(node_3d)
for child: Node in root.get_children():
sockets.append_array(_authored_beetle_sockets(child))
return sockets
func _add_tree_gatherable_anchor(
prop: Node3D,
global_anchor_position: Vector3,
definition: TerrainPropDefinition,
) -> void:
var anchor := Marker3D.new()
anchor.name = "TreeAnchor_%d" % _tree_anchors.get_child_count()
var local_anchor_position: Vector3 = surface_sample["position"]
anchor.position = _tree_anchors.to_local(
prop.to_global(local_anchor_position)
)
anchor.set_meta(
&"terrain_prop_id",
definition.stable_id,
)
anchor.position = _tree_anchors.to_local(global_anchor_position)
anchor.set_meta(&"terrain_prop_id", definition.stable_id)
anchor.set_meta(&"terrain_prop_name", prop.name)
anchor.set_meta(&"mesh_surface_sampled", true)
anchor.set_meta(&"authored_beetle_socket", true)
_tree_anchors.add_child(anchor)
return true
func _prop_yaw(

View file

@ -45,6 +45,32 @@ func matches_above_height(
return _surfaces_match(first_surface, second_surface, tolerance)
## Compares the visible terrain seam at and above a shared waterline while
## deliberately ignoring authored underwater wall depth. Terrain chunks may
## extend different distances below the water without creating a visible gap,
## but their banks still need to meet exactly at the surface.
func matches_at_or_above_height(
other: TerrainChunkEdgeProfile,
minimum_height: float,
tolerance: float,
) -> bool:
if other == null:
return false
var first_surface := _points_at_or_above_height(
_upper_surface_points(),
minimum_height,
tolerance,
)
var second_surface := _points_at_or_above_height(
other._upper_surface_points(),
minimum_height,
tolerance,
)
if first_surface.is_empty() or second_surface.is_empty():
return first_surface.is_empty() and second_surface.is_empty()
return _surfaces_match(first_surface, second_surface, tolerance)
func _surfaces_match(
first_surface: PackedVector2Array,
second_surface: PackedVector2Array,
@ -103,6 +129,18 @@ func _points_above_height(
return result
func _points_at_or_above_height(
surface: PackedVector2Array,
minimum_height: float,
tolerance: float,
) -> PackedVector2Array:
var result := PackedVector2Array()
for point: Vector2 in surface:
if point.y >= minimum_height - tolerance:
result.append(point)
return result
func _upper_surface_points() -> PackedVector2Array:
var result := PackedVector2Array()
for point: Vector2 in points:

View file

@ -19,6 +19,15 @@ const RIVER_FEATURE_CLEARANCE := 1
# Keep packed domain bits below the signed 64-bit sign bit. Catalogs larger
# than this remain correct through the unpacked solver path.
const MAX_PACKED_SOLVER_VARIANTS := 62
const PROJECTED_GRASS_MATERIAL: Material = preload(
"res://world/materials/generated_terrain_grass.tres"
)
const PROJECTED_SAND_MATERIAL: Material = preload(
"res://world/materials/generated_terrain_sand.tres"
)
const PROJECTED_DIRT_MATERIAL: Material = preload(
"res://world/materials/generated_terrain_dirt.tres"
)
@export var catalog: TerrainChunkCatalog
@export var grid_size := Vector2i(7, 7)
@ -292,9 +301,14 @@ func _resolved_layout_validation_error(
east,
TerrainChunkTopology.Edge.WEST,
):
return "cells %d and %d have incompatible east/west edges." % [
return (
"cells %d (%s) and %d (%s) have incompatible "
+ "east/west edges."
) % [
index,
current.stable_key(),
index + 1,
east.stable_key(),
]
if coordinate.y + 1 < grid_size.y:
var south := layout[index + grid_size.x]
@ -304,9 +318,14 @@ func _resolved_layout_validation_error(
south,
TerrainChunkTopology.Edge.NORTH,
):
return "cells %d and %d have incompatible south/north edges." % [
return (
"cells %d (%s) and %d (%s) have incompatible "
+ "south/north edges."
) % [
index,
current.stable_key(),
index + grid_size.x,
south.stable_key(),
]
var neighbor_error := _neighbor_requirement_validation_error(layout)
if not neighbor_error.is_empty():
@ -3427,6 +3446,12 @@ func _calculate_edge_compatibility(
0.0,
profile_tolerance,
)
if not profiles_match and first_has_water and second_has_water:
profiles_match = first.profile(first_edge).matches_at_or_above_height(
second.profile(second_edge),
0.0,
profile_tolerance,
)
if (
not profiles_match
and first_has_water
@ -4072,6 +4097,7 @@ func _add_stacked_elevated_chunks(solution_root: Node3D) -> bool:
&"terrain_chunk_coordinate",
support_coordinate,
)
_apply_projected_surface_materials(stacked_root)
support_root.add_child(stacked_root)
if build_collision:
_add_collision(stacked_root, variant.definition)
@ -4098,12 +4124,14 @@ func _instantiate_chunk_root(
var terrain_visual := definition.packed_scene.instantiate() as Node3D
if terrain_visual == null:
return null
_apply_projected_surface_materials(terrain_visual)
if definition.base_layer_scene == null:
return terrain_visual
var base_layer_visual := definition.base_layer_scene.instantiate() as Node3D
if base_layer_visual == null:
terrain_visual.free()
return null
_apply_projected_surface_materials(base_layer_visual)
var layered_root := Node3D.new()
layered_root.name = "LayeredTerrainChunk"
base_layer_visual.name = "TerrainBaseLayer"
@ -4116,6 +4144,41 @@ func _instantiate_chunk_root(
return layered_root
func _apply_projected_surface_materials(root: Node) -> void:
var mesh_instance := root as MeshInstance3D
if mesh_instance != null and mesh_instance.mesh != null:
for surface_index: int in mesh_instance.mesh.get_surface_count():
var source_material := mesh_instance.get_active_material(surface_index)
var projected_material := _projected_material_for(source_material)
if projected_material != null:
mesh_instance.set_surface_override_material(
surface_index,
projected_material,
)
for child: Node in root.get_children():
_apply_projected_surface_materials(child)
static func _projected_material_for(source_material: Material) -> Material:
if source_material == null:
return null
var material_name := source_material.resource_name
if material_name == "grass_lite" or material_name.begins_with(
"grass_lite."
):
return PROJECTED_GRASS_MATERIAL
if material_name == "sand" or material_name.begins_with("sand."):
return PROJECTED_SAND_MATERIAL
if (
material_name == "dirt"
or material_name.begins_with("dirt.")
or material_name == "water_reference"
or material_name.begins_with("water_reference.")
):
return PROJECTED_DIRT_MATERIAL
return null
func _replace_generated_chunks(solution_root: Node3D) -> void:
_clear_generated_chunks()
_generated_chunks = solution_root

View file

@ -0,0 +1,16 @@
[gd_resource type="ShaderMaterial" load_steps=3 format=3]
[ext_resource type="Shader" uid="uid://d2ju2vpykktuo" path="res://world/materials/terrain_surface_projection.gdshader" id="1_shader"]
[ext_resource type="Texture2D" uid="uid://rmlno7rb2e8p" path="res://world/generation/chunks/assets/chunk_0031_dirt.png" id="2_texture"]
[resource]
resource_name = "dirt"
render_priority = 0
shader = ExtResource("1_shader")
shader_parameter/albedo_texture = ExtResource("2_texture")
shader_parameter/albedo_tint = Color(1, 1, 1, 1)
shader_parameter/tile_world_size = 2.5
shader_parameter/blend_sharpness = 6.0
shader_parameter/top_projection_bias = 2.0
shader_parameter/roughness = 1.0
shader_parameter/specular = 0.5

View file

@ -0,0 +1,16 @@
[gd_resource type="ShaderMaterial" load_steps=3 format=3]
[ext_resource type="Shader" uid="uid://d2ju2vpykktuo" path="res://world/materials/terrain_surface_projection.gdshader" id="1_shader"]
[ext_resource type="Texture2D" uid="uid://doju2lbj2ghwa" path="res://world/generation/chunks/assets/chunk_0000_grass_lite.png" id="2_texture"]
[resource]
resource_name = "grass_lite"
render_priority = 0
shader = ExtResource("1_shader")
shader_parameter/albedo_texture = ExtResource("2_texture")
shader_parameter/albedo_tint = Color(1, 1, 1, 1)
shader_parameter/tile_world_size = 1.75
shader_parameter/blend_sharpness = 6.0
shader_parameter/top_projection_bias = 2.0
shader_parameter/roughness = 1.0
shader_parameter/specular = 0.5

View file

@ -0,0 +1,16 @@
[gd_resource type="ShaderMaterial" load_steps=3 format=3]
[ext_resource type="Shader" uid="uid://d2ju2vpykktuo" path="res://world/materials/terrain_surface_projection.gdshader" id="1_shader"]
[ext_resource type="Texture2D" uid="uid://c3s1ebrqrsylj" path="res://world/generation/chunks/assets/chunk_0001_sand.png" id="2_texture"]
[resource]
resource_name = "sand"
render_priority = 0
shader = ExtResource("1_shader")
shader_parameter/albedo_texture = ExtResource("2_texture")
shader_parameter/albedo_tint = Color(1, 1, 1, 1)
shader_parameter/tile_world_size = 2.6
shader_parameter/blend_sharpness = 6.0
shader_parameter/top_projection_bias = 2.0
shader_parameter/roughness = 1.0
shader_parameter/specular = 0.5