Anchor beetles to generated tree surfaces

This commit is contained in:
Alexander Sellite 2026-08-24 10:36:45 -04:00
parent f1c952f5d6
commit 5ed4cccf76
18 changed files with 446 additions and 71 deletions

View file

@ -10,6 +10,8 @@ catch_data = ExtResource("2_catch")
required_tool_id = &"crab_net"
spawn_anchor_set_id = &"starter_reachable_tree_trunks"
population = 3
spawn_anchor_occupancy_ratio = 0.35
maximum_anchor_population = 64
requires_sneaking = false
movement_speed = 0.0
roam_radius = 0.1

View file

@ -18,6 +18,11 @@ enum PresentationMode {
@export var spawn_anchor_set_id: StringName
@export_range(-100.0, 100.0, 0.01) var minimum_surface_y: float = 0.08
@export_range(0, 64, 1) var population: int = 0
## Anchored gatherables can scale with authored/generated attachment geometry.
## Zero preserves the fixed population above.
@export_range(0.0, 1.0, 0.01) var spawn_anchor_occupancy_ratio := 0.0
## Zero leaves the anchor count as the only ceiling.
@export_range(0, 256, 1) var maximum_anchor_population := 0
@export var presentation_mode: PresentationMode = PresentationMode.VISIBLE_CREATURE
@export var requires_sneaking: bool = true
@export_range(0.0, 120.0, 0.1) var active_lifetime_seconds: float = 0.0
@ -78,6 +83,10 @@ func is_valid() -> bool:
== FishDataType.CollectionMethod.DIGGING
)
and population > 0
and (
maximum_anchor_population == 0
or maximum_anchor_population >= population
)
and movement_parameters_valid
and _quality_multipliers_are_valid(
quality_movement_speed_multipliers
@ -102,6 +111,21 @@ func is_stationary_spawn() -> bool:
return is_stationary_hotspot() or not spawn_anchor_set_id.is_empty()
func target_population_for_anchor_count(anchor_count: int) -> int:
if spawn_anchor_set_id.is_empty() or spawn_anchor_occupancy_ratio <= 0.0:
return population
if anchor_count <= 0:
return 0
var target := maxi(
population,
ceili(float(anchor_count) * spawn_anchor_occupancy_ratio),
)
target = mini(target, anchor_count)
if maximum_anchor_population > 0:
target = mini(target, maximum_anchor_population)
return target
func can_be_scared() -> bool:
return not is_stationary_spawn() and scare_radius > 0.0

View file

@ -294,7 +294,7 @@ func _begin_population_if_ready() -> void:
_current_season()
):
_cache_spawn_surface(entry)
for _spawn_index: int in entry.population:
for _spawn_index: int in _target_population(entry):
_spawn_entity(entry)
@ -1383,11 +1383,22 @@ func _reconcile_seasonal_population() -> void:
for entry: GatherableDataType in _catalog.get_available_entries(season):
_cache_spawn_surface(entry)
var current_population: int = _population_for_type(entry.type_id)
while current_population < entry.population:
var target_population := _target_population(entry)
while current_population < target_population:
_spawn_entity(entry)
current_population += 1
func _target_population(entry: GatherableDataType) -> int:
if entry == null or entry.spawn_anchor_set_id.is_empty():
return entry.population if entry != null else 0
var anchors: PackedVector3Array = _spawn_anchor_positions.get(
entry.type_id,
PackedVector3Array(),
)
return entry.target_population_for_anchor_count(anchors.size())
func _population_for_type(type_id: StringName) -> int:
var count: int = 0
for state: Dictionary in _entities.values():

View file

@ -1131,49 +1131,36 @@ func _validate_tree_gatherable_anchors(
decorations: Node3D,
anchors: GatherableAnchorSet3D,
) -> void:
var eligible_props: Array[Node3D] = []
var eligible_props: Dictionary[StringName, Node3D] = {}
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.gatherable_anchor_height > 0.0:
eligible_props.append(prop)
if definition != null and definition.has_gatherable_surface():
eligible_props[prop.name] = prop
var positions := anchors.get_spawn_positions()
assert(positions.size() == eligible_props.size())
for prop: Node3D in eligible_props:
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)))
var prop_name := StringName(anchor.get_meta(&"terrain_prop_name", &""))
assert(eligible_props.has(prop_name))
var prop: Node3D = eligible_props[prop_name]
var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &""))
var definition := region.get_prop_catalog().definition_for_id(prop_id)
var visual_scale := float(
prop.get_meta(&"terrain_prop_visual_scale", 1.0)
)
var nearest_anchor := Vector3(INF, INF, INF)
var nearest_distance_squared := INF
for position: Vector3 in positions:
var distance_squared := position.distance_squared_to(
prop.global_position
)
if distance_squared < nearest_distance_squared:
nearest_distance_squared = distance_squared
nearest_anchor = position
assert(nearest_anchor.is_finite())
var horizontal_distance := Vector2(
nearest_anchor.x - prop.global_position.x,
nearest_anchor.z - prop.global_position.z,
).length()
assert(definition != null and definition.has_gatherable_surface())
var local_anchor := prop.to_local(anchor.global_position)
assert(
horizontal_distance
>= definition.gatherable_anchor_surface_radius() * visual_scale
local_anchor.y
>= definition.gatherable_surface_minimum_height - 0.001
)
assert(
absf(
nearest_anchor.y
- (
prop.global_position.y
+ definition.gatherable_anchor_height * visual_scale
)
) <= 0.001
local_anchor.y
<= definition.gatherable_surface_maximum_height + 0.001
)

View file

@ -7,6 +7,9 @@ const Gatherables: GatherableCatalog = preload(
"res://gathering/catalog/gatherable_catalog.tres"
)
const FishCatalog: FishPool = preload("res://fish/pools/fish_catalog.tres")
const MeshSurfaceAnchorSamplerType = preload(
"res://world/generation/mesh_surface_anchor_sampler.gd"
)
func _initialize() -> void:
@ -15,6 +18,7 @@ func _initialize() -> void:
func _run() -> void:
_validate_beetle_data()
await _validate_mesh_surface_sampler()
await _validate_tree_anchors()
_validate_anchored_presentation()
_validate_three_dimensional_targeting()
@ -32,11 +36,74 @@ func _validate_beetle_data() -> void:
assert(beetle.required_tool_id == &"crab_net")
assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks")
assert(beetle.population == 3)
assert(is_equal_approx(beetle.spawn_anchor_occupancy_ratio, 0.35))
assert(beetle.maximum_anchor_population == 64)
assert(beetle.target_population_for_anchor_count(8) == 3)
assert(beetle.target_population_for_anchor_count(40) == 14)
assert(beetle.target_population_for_anchor_count(400) == 64)
assert(is_equal_approx(beetle.sprite_pixel_size, 0.005))
assert(beetle.is_stationary_spawn())
assert(not beetle.can_be_scared())
func _validate_mesh_surface_sampler() -> void:
var prop := Node3D.new()
# The accessibility band is local to the planted prop, so the same tree on
# a raised cliff remains reachable from that cliff's walkable surface.
prop.position = Vector3(3.0, 12.0, -4.0)
root.add_child(prop)
var visual := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(2.0, 4.0, 2.0)
var wood := StandardMaterial3D.new()
wood.resource_name = "wood"
box.material = wood
visual.mesh = box
visual.position.y = 2.0
prop.add_child(visual)
await process_frame
var random := RandomNumberGenerator.new()
random.seed = 115
var sample: Dictionary = MeshSurfaceAnchorSamplerType.sample_vertical_surface(
prop,
prop,
PackedStringArray(["wood"]),
0.7,
1.5,
0.35,
0.025,
random,
)
assert(not sample.is_empty())
var surface_position: Vector3 = sample["surface_position"]
var anchor_position: Vector3 = sample["position"]
assert(surface_position.y >= 0.7 and surface_position.y <= 1.5)
var world_anchor_position := prop.to_global(anchor_position)
assert(
world_anchor_position.y >= 12.7
and world_anchor_position.y <= 13.5
)
assert(is_equal_approx(
maxf(absf(surface_position.x), absf(surface_position.z)),
1.0,
))
assert(is_equal_approx(
anchor_position.distance_to(surface_position),
0.025,
))
assert(MeshSurfaceAnchorSamplerType.sample_vertical_surface(
prop,
prop,
PackedStringArray(["leaf"]),
0.7,
1.5,
0.35,
0.025,
random,
).is_empty())
prop.queue_free()
func _validate_tree_anchors() -> void:
var region := StarterIslandScene.instantiate() as WorldRegion
root.add_child(region)

View file

@ -73,6 +73,8 @@ func _validate_catalog_statuses() -> void:
assert(beetle.catch_data.collection_method == FishData.CollectionMethod.NET)
assert(beetle.required_tool_id == &"crab_net")
assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks")
assert(is_equal_approx(beetle.spawn_anchor_occupancy_ratio, 0.35))
assert(beetle.maximum_anchor_population == 64)
assert(beetle.is_stationary_spawn())
assert(not beetle.is_stationary_hotspot())
assert(not beetle.requires_sneaking)

View file

@ -9,6 +9,9 @@ const FishingShopInteractionType = preload(
const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd"
)
const MeshSurfaceAnchorSamplerType = preload(
"res://world/generation/mesh_surface_anchor_sampler.gd"
)
const WATER_BODY_SCENE: PackedScene = preload("res://world/water_body.tscn")
const SALT_WATER_MATERIAL: Material = preload(
"res://world/materials/stylized_water.tres"
@ -35,7 +38,6 @@ const PROP_CLUSTER_PLACEMENT_ATTEMPTS := 10
const PROP_MINIMUM_GROUND_CLEARANCE := 0.05
const PROP_CHANCE_SCALE := 10000
const PROP_SELECTION_WEIGHT_SCALE := 1000
const GATHERABLE_ANCHOR_SURFACE_CLEARANCE := 0.02
const PROCEDURAL_PROP_GROUPS: Array[StringName] = [
&"grass_tree",
&"grass_detail",
@ -681,25 +683,37 @@ func _instantiate_prop(
definition.clearance_radius * visual_scale
)
_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()
var local_anchor_position := (
definition.collision_offset * visual_scale
+ Vector3(
0.0,
definition.gatherable_anchor_height * visual_scale,
-(
definition.gatherable_anchor_surface_radius()
* visual_scale
+ GATHERABLE_ANCHOR_SURFACE_CLEARANCE
),
if definition.has_gatherable_surface():
var surface_sample: Dictionary = (
MeshSurfaceAnchorSamplerType.sample_vertical_surface(
visual_root,
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,
)
)
anchor.position = prop.position + local_anchor_position.rotated(
Vector3.UP,
yaw,
if surface_sample.is_empty():
push_warning(
"No reachable gatherable mesh surface found on %s."
% definition.stable_id
)
return true
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.set_meta(&"terrain_prop_name", prop.name)
anchor.set_meta(&"mesh_surface_sampled", true)
_tree_anchors.add_child(anchor)
return true

View file

@ -0,0 +1,260 @@
class_name MeshSurfaceAnchorSampler
extends RefCounted
const HEIGHT_EPSILON := 0.0001
static func sample_vertical_surface(
visual_root: Node3D,
relative_root: Node3D,
material_names: PackedStringArray,
minimum_height: float,
maximum_height: float,
maximum_up_dot: float,
clearance: float,
random: RandomNumberGenerator,
) -> Dictionary:
if (
visual_root == null
or relative_root == null
or material_names.is_empty()
or maximum_height <= minimum_height
or random == null
):
return {}
var candidates: Array[Dictionary] = []
_collect_candidates(
visual_root,
relative_root,
material_names,
minimum_height,
maximum_height,
clampf(maximum_up_dot, 0.0, 1.0),
candidates,
)
while not candidates.is_empty():
var candidate_index := _weighted_candidate_index(candidates, random)
var candidate: Dictionary = candidates[candidate_index]
candidates.remove_at(candidate_index)
var point := _sample_triangle_height_slice(
candidate["a"],
candidate["b"],
candidate["c"],
minimum_height,
maximum_height,
random,
)
if not point.is_finite():
continue
var normal: Vector3 = candidate["normal"]
var surface_normal := Vector3(normal.x, 0.0, normal.z).normalized()
if surface_normal.is_zero_approx():
continue
return {
"position": point + surface_normal * maxf(clearance, 0.0),
"surface_position": point,
"surface_normal": surface_normal,
}
return {}
static func _collect_candidates(
node: Node,
relative_root: Node3D,
material_names: PackedStringArray,
minimum_height: float,
maximum_height: float,
maximum_up_dot: float,
candidates: Array[Dictionary],
) -> void:
var mesh_instance := node as MeshInstance3D
if mesh_instance != null and mesh_instance.mesh != null:
_collect_mesh_candidates(
mesh_instance,
relative_root,
material_names,
minimum_height,
maximum_height,
maximum_up_dot,
candidates,
)
for child: Node in node.get_children():
_collect_candidates(
child,
relative_root,
material_names,
minimum_height,
maximum_height,
maximum_up_dot,
candidates,
)
static func _collect_mesh_candidates(
mesh_instance: MeshInstance3D,
relative_root: Node3D,
material_names: PackedStringArray,
minimum_height: float,
maximum_height: float,
maximum_up_dot: float,
candidates: Array[Dictionary],
) -> void:
var mesh := mesh_instance.mesh
var to_relative := (
relative_root.global_transform.affine_inverse()
* mesh_instance.global_transform
)
for surface_index: int in mesh.get_surface_count():
if (
mesh is ArrayMesh
and (mesh as ArrayMesh).surface_get_primitive_type(surface_index)
!= Mesh.PRIMITIVE_TRIANGLES
):
continue
var material := mesh.surface_get_material(surface_index)
if not _material_matches(material, material_names):
continue
var arrays := mesh.surface_get_arrays(surface_index)
var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
if indices.is_empty():
for vertex_index: int in range(0, vertices.size() - 2, 3):
_add_triangle_candidate(
to_relative * vertices[vertex_index],
to_relative * vertices[vertex_index + 1],
to_relative * vertices[vertex_index + 2],
minimum_height,
maximum_height,
maximum_up_dot,
candidates,
)
continue
for index_offset: int in range(0, indices.size() - 2, 3):
_add_triangle_candidate(
to_relative * vertices[indices[index_offset]],
to_relative * vertices[indices[index_offset + 1]],
to_relative * vertices[indices[index_offset + 2]],
minimum_height,
maximum_height,
maximum_up_dot,
candidates,
)
static func _material_matches(
material: Material,
material_names: PackedStringArray,
) -> bool:
if material == null:
return false
var candidate_name := material.resource_name.to_lower()
for configured_name: String in material_names:
if candidate_name == configured_name.to_lower():
return true
return false
static func _add_triangle_candidate(
a: Vector3,
b: Vector3,
c: Vector3,
minimum_height: float,
maximum_height: float,
maximum_up_dot: float,
candidates: Array[Dictionary],
) -> void:
var cross := (b - a).cross(c - a)
var doubled_area := cross.length()
if doubled_area <= HEIGHT_EPSILON:
return
var normal := cross / doubled_area
if absf(normal.dot(Vector3.UP)) > maximum_up_dot:
return
var triangle_minimum := minf(a.y, minf(b.y, c.y))
var triangle_maximum := maxf(a.y, maxf(b.y, c.y))
var overlap := (
minf(triangle_maximum, maximum_height)
- maxf(triangle_minimum, minimum_height)
)
if overlap <= HEIGHT_EPSILON:
return
candidates.append({
"a": a,
"b": b,
"c": c,
"normal": normal,
"weight": doubled_area * 0.5 * overlap,
})
static func _weighted_candidate_index(
candidates: Array[Dictionary],
random: RandomNumberGenerator,
) -> int:
var total_weight := 0.0
for candidate: Dictionary in candidates:
total_weight += float(candidate.get("weight", 0.0))
if total_weight <= 0.0:
return random.randi_range(0, candidates.size() - 1)
var roll := random.randf() * total_weight
var cumulative := 0.0
for index: int in candidates.size():
cumulative += float(candidates[index].get("weight", 0.0))
if roll <= cumulative:
return index
return candidates.size() - 1
static func _sample_triangle_height_slice(
a: Vector3,
b: Vector3,
c: Vector3,
minimum_height: float,
maximum_height: float,
random: RandomNumberGenerator,
) -> Vector3:
var slice_minimum := maxf(minimum_height, minf(a.y, minf(b.y, c.y)))
var slice_maximum := minf(maximum_height, maxf(a.y, maxf(b.y, c.y)))
if slice_maximum - slice_minimum <= HEIGHT_EPSILON:
return Vector3(INF, INF, INF)
var target_height := random.randf_range(slice_minimum, slice_maximum)
var intersections := PackedVector3Array()
_append_edge_intersection(a, b, target_height, intersections)
_append_edge_intersection(b, c, target_height, intersections)
_append_edge_intersection(c, a, target_height, intersections)
if intersections.size() < 2:
return Vector3(INF, INF, INF)
var first := intersections[0]
var second := intersections[1]
var greatest_distance := first.distance_squared_to(second)
for first_index: int in intersections.size():
for second_index: int in range(first_index + 1, intersections.size()):
var distance := intersections[first_index].distance_squared_to(
intersections[second_index]
)
if distance > greatest_distance:
greatest_distance = distance
first = intersections[first_index]
second = intersections[second_index]
return first.lerp(second, random.randf())
static func _append_edge_intersection(
a: Vector3,
b: Vector3,
height: float,
intersections: PackedVector3Array,
) -> void:
var minimum := minf(a.y, b.y)
var maximum := maxf(a.y, b.y)
if height < minimum - HEIGHT_EPSILON or height > maximum + HEIGHT_EPSILON:
return
var height_delta := b.y - a.y
if absf(height_delta) <= HEIGHT_EPSILON:
return
var weight := clampf((height - a.y) / height_delta, 0.0, 1.0)
var point := a.lerp(b, weight)
for existing: Vector3 in intersections:
if existing.distance_squared_to(point) <= HEIGHT_EPSILON * HEIGHT_EPSILON:
return
intersections.append(point)

View file

@ -0,0 +1 @@
uid://dlen7b42nxpjv

View file

@ -23,3 +23,4 @@ local_overhang_direction = Vector2(-0.883, 0.469)
ocean_facing_spread_degrees = 55.0
collision_radius = 0.4
collision_height = 6.0
gatherable_surface_material_names = PackedStringArray("wood_light")

View file

@ -17,4 +17,4 @@ minimum_visual_scale = 0.65
maximum_visual_scale = 1.2
collision_radius = 0.5
collision_height = 4.0
gatherable_anchor_height = 2.15
gatherable_surface_material_names = PackedStringArray("wood")

View file

@ -17,4 +17,4 @@ minimum_visual_scale = 0.75
maximum_visual_scale = 1.2
collision_radius = 0.65
collision_height = 9.5
gatherable_anchor_height = 2.15
gatherable_surface_material_names = PackedStringArray("wood")

View file

@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light",
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
gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark")

View file

@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light",
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
gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark")

View file

@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light",
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
gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark")

View file

@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light",
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
gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark")

View file

@ -74,11 +74,12 @@ func validation_errors() -> PackedStringArray:
% definition.stable_id
)
if (
definition.gatherable_anchor_height > 0.0
and definition.gatherable_anchor_surface_radius() <= 0.0
not definition.gatherable_surface_material_names.is_empty()
and definition.gatherable_surface_maximum_height
<= definition.gatherable_surface_minimum_height
):
errors.append(
"%s is gatherable but has no trunk-surface radius."
"%s has an invalid gatherable-surface height band."
% definition.stable_id
)
if (

View file

@ -47,11 +47,18 @@ extends Resource
@export_range(0.0, 20.0, 0.05) var collision_height := 0.0
@export var collision_box_size := Vector3.ZERO
@export var collision_offset := Vector3.ZERO
## Values above zero add this prop to the tree-gathering anchor set.
@export_range(0.0, 20.0, 0.05) var gatherable_anchor_height := 0.0
## Optional distance from the prop origin to the visible trunk surface. A
## zero value derives the distance from the authored collision shape.
@export_range(0.0, 5.0, 0.05) var gatherable_anchor_radius := 0.0
@export_category("Gatherable Surface")
## Non-empty values explicitly designate this prop's matching mesh surfaces as
## valid attachment geometry. Unlisted props and materials are never sampled.
@export var gatherable_surface_material_names := PackedStringArray()
## Accessibility band measured upward from this prop's planted origin. It
## follows the tree onto hills/cliffs while keeping anchors within net reach.
@export_range(0.0, 20.0, 0.05) var gatherable_surface_minimum_height := 0.7
@export_range(0.0, 20.0, 0.05) var gatherable_surface_maximum_height := 1.5
## Reject upward-facing branches and foliage so attachments favor trunk-like
## faces. Zero accepts only vertical faces; one accepts every orientation.
@export_range(0.0, 1.0, 0.05) var gatherable_surface_maximum_up_dot := 0.35
@export_range(0.0, 0.25, 0.005) var gatherable_surface_clearance := 0.025
func supports_chunk_tags(chunk_tags: PackedStringArray) -> bool:
@ -77,14 +84,12 @@ func has_box_collision() -> bool:
)
func gatherable_anchor_surface_radius() -> float:
if gatherable_anchor_radius > 0.0:
return gatherable_anchor_radius
if has_cylinder_collision():
return collision_radius
if has_box_collision():
return maxf(collision_box_size.x, collision_box_size.z) * 0.5
return 0.0
func has_gatherable_surface() -> bool:
return (
not gatherable_surface_material_names.is_empty()
and gatherable_surface_maximum_height
> gatherable_surface_minimum_height
)
func is_procedural() -> bool: