feat(foliage): add recovering grass trails and bush contact

This commit is contained in:
Rijad Zuzo
2026-09-05 19:30:46 +02:00
parent da3bed67a0
commit 7899d36f03
21 changed files with 673 additions and 18 deletions
+43
View File
@@ -49,6 +49,49 @@ values; viewport scale restoration is guarded by the current quality owner so a
second world cannot clobber the active owner. Compatibility/mobile feature
selection never forces unsupported volumetric fog on.
## Grass trails and bush contact
`GrassInteractionController` samples the existing `grass_interactors` group:
player, NPCs, hostile creatures and animals. The player keeps one slot; other
visible actors are ranked by camera distance within the loaded actor container.
High updates at 0.08 seconds with eight actors; Balanced uses 0.12 seconds and
four. Low stops updates and immediately releases both grass and bushes.
`FoliageTrailMap` maintains a disposable 128×128 RGBA float texture covering a
64 m square around the camera. Swept footprints part grass to either side and
lower its tips; the field recovers over eight seconds after contact. Sparse
history is clipped to the moving window. The 256 KiB texture is reused with
[`ImageTexture.update`](https://docs.godotengine.org/en/stable/classes/class_imagetexture.html#class-imagetexture-method-update),
at most 3.125 MiB/s of texture data at the High update rate. The vertex shader
adds one field lookup per grass vertex; there are no blade collision bodies,
per-blade scripts, render targets or added grass draw calls.
`StylizedBerryPatch` shares the field and contact positions between its leaf
and berry MultiMeshes. Both bend in world space around the planted bush root,
including under rotated/scaled parents, and spring back faster than grass.
The five village berry placeholders now use this same amount-aware presentation
as the existing riverbank bush. Contact does not change berries, resource
amounts, navigation, targets or collision geometry.
The controller clears history after `SimulationManager.state_restored`, on
quality changes and on world disposal. Missing/recreated actors, teleports over
3 m between samples and update gaps over 0.5 seconds start a new footprint rather
than a connecting trail. Actor foot height gates deformation so bridge traffic
does not flatten grass below. Trails are visual only and never enter saves.
The exported actor, primary actor, foliage and simulation paths can be rebound
when placing the controller in another loaded scene container.
`tests/foliage_interaction_test.gd` checks sweep direction, recovery, teleport
rejection, camera movement, world isolation, restore/quality reset, the primary
actor slot and shared leaf/fruit bindings. Run `tools/capture_foliage.gd` with
the native renderer for before/contact/trail/recovery images and controller
timing in `docs/baselines/foliage_*`. That capture freezes simulation and wind, and moves
the player presentation through the actual meadow, checking the state checksum
is unchanged. CPU update timing is not a GPU or weak-PC frame-rate guarantee.
Native Metal and Compatibility both render the effect; existing particle-shader
shutdown warnings (and four Compatibility texture leaks) also reproduce in the
untouched pre-art baseline. The headless quality gate retains its exact allowlist.
## Runtime hot paths already bounded
The woodland storybook art pass adds portable Blender animal meshes and an
Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

@@ -0,0 +1,13 @@
{
"active_trail_texels": 145,
"controller_update_p50_ms": 0.307,
"controller_update_p95_ms": 0.338,
"draw_calls": 582.0,
"note": "CPU timing includes scanning, field painting and material uploads; not isolated GPU frame time.",
"profile": "High",
"renderer": "metal",
"resolution": "1600x900",
"simulation_checksum_unchanged": true,
"trail_texture_bytes": 262144,
"workload": "Actual Jajce meadow; 8 nearest actors, player presentation follows an 11 m sweep"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

+210
View File
@@ -0,0 +1,210 @@
extends SceneTree
const Controller := preload("res://world/jajce/GrassInteractionController.gd")
var failures: Array[String] = []
class RestoreSource:
extends Node
signal state_restored
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_check_trail_lifecycle()
await _check_loaded_binding()
if failures.is_empty():
print("Foliage interaction checks passed")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _check_trail_lifecycle() -> void:
var field := FoliageTrailMap.new()
var positions: Dictionary[int, Vector3] = {1: Vector3(0.25, 0, 0.25)}
field.advance(0.08, Vector3.ZERO, positions)
positions[1] = Vector3(2.25, 0, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
_check(_sample(field, Vector2(1.25, 0.25)).b > 0.9, "Walking should leave a continuous trail")
_check(
_sample(field, Vector2(1.25, 0.75)).g > 0 and _sample(field, Vector2(1.25, -0.25)).g < 0,
"The two sides of a trail should bend away from the travelled line"
)
positions[1] = Vector3(18.25, 0, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
_check(_sample(field, Vector2(10.25, 0.25)).b == 0, "Teleports must not paint connecting lines")
_check(
_sample(field, Vector2(18.25, 0.25)).b > 0.9,
"Teleported actors still press their destination"
)
field.advance(0.08, Vector3(1, 0, 1), {})
_check(
_sample(field, Vector2(1.25, 0.25)).b > 0.8,
"Camera movement must keep trails in world space"
)
field.advance(4.0, Vector3.ZERO, {})
var recovering := _sample(field, Vector2(1.25, 0.25)).b
_check(recovering > 0.1 and recovering < 0.6, "Abandoned trails should gradually recover")
field.advance(4.1, Vector3.ZERO, {})
_check(
(field.get("_cells") as Dictionary).is_empty(),
"Recovered trails should release sparse history"
)
positions[1] = Vector3(0.25, 6, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
var elevated := _sample(field, Vector2(0.25, 0.25))
_check(
is_equal_approx(elevated.a / elevated.b, 6.0),
"Foot height must reach the shader for bridge rejection"
)
field.advance(0.08, Vector3(300, 0, 300), {})
_check((field.get("_cells") as Dictionary).is_empty(), "Off-window trails must be discarded")
positions[1] = Vector3(2.25, 0, 0.25)
field.advance(0.08, Vector3.ZERO, positions)
_check(
_sample(field, Vector2(1.25, 0.25)).b == 0,
"Re-entering actors must not connect to stale positions"
)
field.clear()
_check(_sample(field, Vector2(2.25, 0.25)).b == 0, "Reset must clear the uploaded field")
_check(
(field.get("_previous_positions") as Dictionary).is_empty(),
"Reset must discard actor history"
)
func _check_loaded_binding() -> void:
var stage := Node3D.new()
root.add_child(stage)
var camera := Camera3D.new()
stage.add_child(camera)
camera.current = true
var simulation := RestoreSource.new()
simulation.name = "SimulationManager"
stage.add_child(simulation)
var world := Node3D.new()
stage.add_child(world)
var terrain := Node3D.new()
world.add_child(terrain)
var patch := (
load("res://world/jajce/StylizedBerryPatch.tscn").instantiate() as StylizedBerryPatch
)
world.add_child(patch)
patch.scale = Vector3(1.2, 0.8, 1.1)
patch.rotation.y = 0.6
var controller := Controller.new()
controller.grass_material = (
load("res://world/jajce/materials/cozy_grass_material.tres").duplicate(true)
)
terrain.add_child(controller)
var foreign := Node3D.new()
root.add_child(foreign)
foreign.add_to_group("grass_interactors")
var actors: Array[Node3D] = []
for index in 12:
var actor := Node3D.new()
stage.add_child(actor)
actor.position.x = 1.0 + index
actor.add_to_group("grass_interactors")
actors.append(actor)
await process_frame
controller.set_process(false)
controller.call("_refresh_interactors", 0.08)
var material := controller.grass_material
var positions: PackedVector3Array = material.get_shader_parameter("interactor_positions")
_check(
int(material.get_shader_parameter("interactor_count")) == 8,
"Contact work must respect the eight-actor cap"
)
_check(
positions[0] == actors[0].position,
"A closer actor in another scene container must be rejected"
)
_check(
bool(material.get_shader_parameter("foliage_trails_enabled")),
"Loaded grass must enable its trail field"
)
var leaves := patch.get_node("Leaves") as MultiMeshInstance3D
var berries := patch.get_node("Berries") as MultiMeshInstance3D
var leaf_material := leaves.multimesh.mesh.surface_get_material(0) as ShaderMaterial
var berry_material := berries.multimesh.mesh.surface_get_material(0) as ShaderMaterial
_check(
(
(
leaf_material.get_shader_parameter("foliage_trail_map")
== material.get_shader_parameter("foliage_trail_map")
)
and (
berry_material.get_shader_parameter("foliage_trail_map")
== material.get_shader_parameter("foliage_trail_map")
)
),
"Bush leaves, berries and meadow must use the same world-local footprint field"
)
var visible_berries := patch.get_visible_berry_count()
actors[0].hide()
controller.primary_interactor_path = controller.get_path_to(actors.back())
controller.max_interactors = 4
controller.call("_refresh_interactors", 0.12)
positions = material.get_shader_parameter("interactor_positions")
_check(
(
int(material.get_shader_parameter("interactor_count")) == 4
and positions[0].x == 12
and positions[1].x == 2
),
"Balanced must reserve the primary actor slot, cap actors and ignore hidden visuals"
)
simulation.state_restored.emit()
_check(
not bool(material.get_shader_parameter("foliage_trails_enabled")),
"Successful restores must clear visual trails immediately"
)
controller.set_interaction_enabled(false)
_check(not controller.is_processing(), "Low quality must stop interaction updates")
_check(
not bool(leaf_material.get_shader_parameter("foliage_trails_enabled")),
"Low quality must release bushes as well as grass"
)
_check(
int(material.get_shader_parameter("interactor_count")) == 0,
"Low quality must clear direct contacts"
)
_check(
patch.get_visible_berry_count() == visible_berries,
"Contact bending must not change resource presentation amounts"
)
controller.set_interaction_enabled(true)
controller.call("_refresh_interactors", 0.08)
_check(
bool(berry_material.get_shader_parameter("foliage_trails_enabled")),
"Bush interaction must resume after Low quality"
)
var other_field := FoliageTrailMap.new()
_check(
other_field.texture != material.get_shader_parameter("foliage_trail_map"),
"Each world must own a distinct trail texture"
)
foreign.free()
stage.free()
await process_frame
func _sample(field: FoliageTrailMap, position: Vector2) -> Color:
var image := field.get("_image") as Image
var pixel := Vector2i(
(position - Vector2(field.world_rect.x, field.world_rect.y)) / field.CELL_SIZE
)
return image.get_pixelv(pixel)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
+1
View File
@@ -0,0 +1 @@
uid://clvjg2p81ay3b
+106
View File
@@ -0,0 +1,106 @@
extends SceneTree
## Native renderer review of a scripted walk through the actual loaded meadow.
## The capture moves presentation only and verifies the simulation checksum.
const OUTPUT := "res://docs/baselines/foliage_"
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1600, 900)
var main := load("res://main.tscn").instantiate() as Node3D
main.get_node("SimulationManager").set("debug_logs", false)
root.add_child(main)
await process_frame
var manager := main.get_node("SimulationManager")
manager.set_process(false)
var checksum: String = manager.get_state_checksum()
var world := main.get_node("JajceWorld") as JajceWorld
world.apply_presentation_quality(JajceWorld.PresentationQuality.HIGH)
var controller := world.grass_controller
controller.set_process(false)
var cycle := world.get_node("DayNightCycle")
cycle.set_process(false)
cycle.call("_apply_light_rotation", 0.4)
cycle.call("_apply_environment", 0.4)
for layer in main.get_children():
if layer is CanvasLayer:
layer.hide()
for actor in get_nodes_in_group("grass_interactors"):
actor.set_physics_process(false)
actor.set_process(false)
var player := main.get_node("Player") as CharacterBody3D
player.get_node("Visual/Sword").hide()
main.get_node("CameraRig").set_physics_process(false)
var camera := root.get_camera_3d()
camera.reparent(main)
camera.global_position = Vector3(16, 7.5, 23)
camera.look_at(Vector3(11, 0.6, 10.5))
camera.fov = 48
player.position = Vector3(5, 0, 10.85)
player.get_node("Visual").rotation.y = -PI / 2.0
for _frame in 90:
await process_frame
# Freeze wind and particles so the four review frames isolate contact deformation.
Engine.time_scale = 0.0
var demo := main.get_node("DemoController")
if demo.get("debug_overlay_visible"):
demo.call("toggle_debug_overlay")
for layer in main.get_children():
if layer is CanvasLayer:
layer.hide()
controller.call("reset_interaction")
await _save("before.png")
var update_times: Array[float] = []
for step in 61:
player.position.x = 5.0 + step * 0.18
player.position.y = world.terrain.data.get_height(player.position) + 0.02
var start := Time.get_ticks_usec()
controller.call("_refresh_interactors", 0.08)
update_times.append(float(Time.get_ticks_usec() - start) / 1000.0)
for _frame in 5:
await process_frame
if step == 37:
await _save("contact.png")
await _save("trail.png")
var active_cells := (controller.get("_trail_map").get("_cells") as Dictionary).size()
var draws := Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)
for _step in 110:
controller.call("_refresh_interactors", 0.08)
for _frame in 5:
await process_frame
await _save("recovered.png")
update_times.sort()
var report := {
"renderer": RenderingServer.get_current_rendering_driver_name(),
"resolution": "1600x900",
"profile": "High",
"workload":
"Actual Jajce meadow; 8 nearest actors, player presentation follows an 11 m sweep",
"controller_update_p50_ms": update_times[30],
"controller_update_p95_ms": update_times[57],
"active_trail_texels": active_cells,
"trail_texture_bytes": 128 * 128 * 16,
"draw_calls": draws,
"simulation_checksum_unchanged": manager.get_state_checksum() == checksum,
"note":
"CPU timing includes scanning, field painting and material uploads; not isolated GPU frame time."
}
var file := FileAccess.open(OUTPUT + "render_metrics.json", FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t") + "\n")
print(JSON.stringify(report))
assert(manager.get_state_checksum() == checksum)
main.free()
Engine.time_scale = 1.0
await process_frame
quit(0)
func _save(filename: String) -> void:
await RenderingServer.frame_post_draw
var capture := root.get_texture().get_image()
assert(not capture.is_empty())
assert(capture.save_png(OUTPUT + filename) == OK)
+1
View File
@@ -0,0 +1 @@
uid://bqd5coilksebi
+98
View File
@@ -0,0 +1,98 @@
class_name FoliageTrailMap
extends RefCounted
## Disposable, camera-local footprint field. No per-blade nodes or physics.
## RG = weighted bend direction, B = pressure, A = weighted actor foot height.
const RESOLUTION := 128
const CELL_SIZE := 0.5
const WORLD_SIZE := RESOLUTION * CELL_SIZE
const TRAIL_RADIUS := 0.95
const RECOVERY_SECONDS := 8.0
const MAX_SEGMENT_LENGTH := 3.0
var texture: ImageTexture
var world_rect := Vector4.ZERO
var _image: Image
var _origin := Vector2i.ZERO
var _cells: Dictionary[Vector2i, Color] = {}
var _previous_positions: Dictionary[int, Vector3] = {}
func _init() -> void:
_image = Image.create(RESOLUTION, RESOLUTION, false, Image.FORMAT_RGBAF)
_image.fill(Color(0, 0, 0, 0))
texture = ImageTexture.create_from_image(_image)
func clear() -> void:
_cells.clear()
_previous_positions.clear()
_image.fill(Color(0, 0, 0, 0))
texture.update(_image)
func advance(delta: float, center: Vector3, positions: Dictionary[int, Vector3]) -> void:
var next_origin := _world_cell(Vector2(center.x, center.z)) - Vector2i.ONE * (RESOLUTION / 2)
var dirty := not _cells.is_empty() or next_origin != _origin
_origin = next_origin
world_rect = Vector4(_origin.x * CELL_SIZE, _origin.y * CELL_SIZE, WORLD_SIZE, WORLD_SIZE)
_decay_and_clip(maxf(delta, 0.0))
for actor_id in positions:
var end := positions[actor_id]
var start: Vector3 = _previous_positions.get(actor_id, end)
# A load, teleport, spawn, or skipped actor must never draw a connecting scar.
if start.distance_to(end) > MAX_SEGMENT_LENGTH or delta > 0.5:
start = end
_stamp_segment(start, end)
_previous_positions = positions.duplicate()
if not dirty and _cells.is_empty():
return
_image.fill(Color(0, 0, 0, 0))
for cell in _cells:
var pixel := cell - _origin
_image.set_pixelv(pixel, _cells[cell])
texture.update(_image)
func _decay_and_clip(delta: float) -> void:
for cell in _cells.keys():
var value := _cells[cell]
var pressure := maxf(0.0, value.b - delta / RECOVERY_SECONDS)
if pressure <= 0.001 or not _contains(cell):
_cells.erase(cell)
else:
_cells[cell] = value * (pressure / value.b)
func _stamp_segment(start: Vector3, end: Vector3) -> void:
var a := Vector2(start.x, start.z)
var b := Vector2(end.x, end.z)
var line := b - a
var length_squared := line.length_squared()
var radius := Vector2.ONE * TRAIL_RADIUS
var first := _world_cell(a.min(b) - radius).max(_origin)
var last := _world_cell(a.max(b) + radius).min(_origin + Vector2i.ONE * (RESOLUTION - 1))
for y in range(first.y, last.y + 1):
for x in range(first.x, last.x + 1):
var cell := Vector2i(x, y)
var point := (Vector2(cell) + Vector2.ONE * 0.5) * CELL_SIZE
var along := clampf((point - a).dot(line) / maxf(length_squared, 0.0001), 0.0, 1.0)
var offset := point - a - line * along
var pressure := 1.0 - smoothstep(0.16, TRAIL_RADIUS, offset.length())
var existing: Color = _cells.get(cell, Color(0, 0, 0, 0))
if pressure <= 0.001 or pressure < existing.b:
continue
# A swept capsule parts blades to both sides of the travelled line.
var away := offset.normalized()
var height := lerpf(start.y, end.y, along)
_cells[cell] = Color(away.x, away.y, 1.0, height) * pressure
func _world_cell(position: Vector2) -> Vector2i:
return Vector2i(floori(position.x / CELL_SIZE), floori(position.y / CELL_SIZE))
func _contains(cell: Vector2i) -> bool:
var pixel := cell - _origin
return pixel.x >= 0 and pixel.y >= 0 and pixel.x < RESOLUTION and pixel.y < RESOLUTION
+1
View File
@@ -0,0 +1 @@
uid://6llj4y8dy0jm
+74 -7
View File
@@ -7,14 +7,40 @@ const CLEARING_LIMIT := 4
@export_range(1, SHADER_INTERACTOR_LIMIT, 1) var max_interactors := 8
@export_range(0.02, 0.5, 0.01) var update_interval := 0.08
@export var path_clearings: Array[NodePath] = []
@export var actor_root_path := NodePath("../../..")
@export var foliage_root_path := NodePath("../..")
@export var simulation_manager_path := NodePath("../../../SimulationManager")
@export var primary_interactor_path := NodePath("../../../Player")
var _elapsed := 0.0
var _trail_map := FoliageTrailMap.new()
func _ready() -> void:
# JajceWorld first isolates its mutable materials in the parent's _ready().
call_deferred("_configure_path_clearings")
_refresh_interactors()
call_deferred("_configure_interaction")
func _configure_interaction() -> void:
_configure_path_clearings()
var simulation := get_node_or_null(simulation_manager_path)
if simulation != null and simulation.has_signal("state_restored"):
simulation.connect("state_restored", reset_interaction)
if is_processing():
_refresh_interactors(0.0)
func set_interaction_enabled(enabled: bool) -> void:
set_process(enabled)
reset_interaction()
_elapsed = update_interval
func reset_interaction() -> void:
_trail_map.clear()
if grass_material != null:
grass_material.set_shader_parameter("interactor_count", 0)
_bind_trail_materials(false)
func _configure_path_clearings() -> void:
@@ -44,35 +70,76 @@ func _process(delta: float) -> void:
_elapsed += delta
if _elapsed < update_interval:
return
_refresh_interactors(_elapsed)
_elapsed = 0.0
_refresh_interactors()
func _refresh_interactors() -> void:
func _refresh_interactors(delta: float = 0.08) -> void:
if grass_material == null:
return
var camera := get_viewport().get_camera_3d()
var origin := camera.global_position if camera != null else Vector3.ZERO
var actor_root := get_node_or_null(actor_root_path)
var primary := get_node_or_null(primary_interactor_path)
var nearest: Array[Node3D] = []
var limit := clampi(max_interactors, 1, SHADER_INTERACTOR_LIMIT)
for value in get_tree().get_nodes_in_group("grass_interactors"):
var candidate := value as Node3D
if candidate == null:
if (
candidate == null
or candidate.is_queued_for_deletion()
or not candidate.is_visible_in_tree()
or candidate.get_viewport() != get_viewport()
or (actor_root != null and not actor_root.is_ancestor_of(candidate))
):
continue
var insert_at := nearest.size()
var candidate_distance := origin.distance_squared_to(candidate.global_position)
for index in nearest.size():
if candidate_distance < origin.distance_squared_to(nearest[index].global_position):
if (
candidate == primary
or (
nearest[index] != primary
and (
candidate_distance
< origin.distance_squared_to(nearest[index].global_position)
)
)
):
insert_at = index
break
nearest.insert(insert_at, candidate)
if nearest.size() > max_interactors:
if nearest.size() > limit:
nearest.pop_back()
var positions := PackedVector3Array()
positions.resize(SHADER_INTERACTOR_LIMIT)
for index in SHADER_INTERACTOR_LIMIT:
positions[index] = Vector3(0.0, -1000.0, 0.0)
var trail_positions: Dictionary[int, Vector3] = {}
for index in nearest.size():
positions[index] = nearest[index].global_position
trail_positions[nearest[index].get_instance_id()] = positions[index]
grass_material.set_shader_parameter("interactor_count", nearest.size())
grass_material.set_shader_parameter("interactor_positions", positions)
_trail_map.advance(delta, origin, trail_positions)
_bind_trail_materials(true)
func _bind_trail_materials(enabled: bool) -> void:
if grass_material != null:
grass_material.set_shader_parameter("foliage_trail_map", _trail_map.texture)
grass_material.set_shader_parameter("foliage_trail_rect", _trail_map.world_rect)
grass_material.set_shader_parameter("foliage_trails_enabled", enabled)
var foliage_root := get_node_or_null(foliage_root_path)
if foliage_root == null:
return
for value in get_tree().get_nodes_in_group("interactive_foliage"):
var patch := value as StylizedBerryPatch
if patch != null and foliage_root.is_ancestor_of(patch):
patch.set_contact_field(_trail_map.texture, _trail_map.world_rect, enabled)
if grass_material != null:
patch.set_contact_actors(
grass_material.get_shader_parameter("interactor_positions"),
int(grass_material.get_shader_parameter("interactor_count")) if enabled else 0
)
+29
View File
@@ -33,6 +33,7 @@
[ext_resource type="ArrayMesh" path="res://assets/storybook/meadow_grass.res" id="31_meadow"]
[ext_resource type="Shader" path="res://world/jajce/materials/storybook_sky.gdshader" id="32_sky"]
[ext_resource type="PackedScene" path="res://world/jajce/StylizedBerryPatch.tscn" id="33_berry"]
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
_shader_parameters = {
@@ -499,6 +500,11 @@ safety_risk = 0.05
comfort_distance = 18.0
discovery_priority = 1.0
[node name="MeshInstance3D" parent="WorldObjects/ResourceNodes/BerryBush_01/Visual"]
visible = false
[node name="BerryPatchPresentation" parent="WorldObjects/ResourceNodes/BerryBush_01/Visual" instance=ExtResource("33_berry")]
[node name="BerryBush_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-10, 0, -4)
node_id = &"berry_bush_02"
@@ -508,6 +514,12 @@ safety_risk = 0.08
comfort_distance = 20.0
discovery_priority = 0.5
[node name="MeshInstance3D" parent="WorldObjects/ResourceNodes/BerryBush_02/Visual"]
visible = false
[node name="BerryPatchPresentation" parent="WorldObjects/ResourceNodes/BerryBush_02/Visual" instance=ExtResource("33_berry")]
rotation_degrees = Vector3(0, 38, 0)
[node name="BerryPatch_River_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(12, 0, 10)
node_id = &"berry_patch_river_01"
@@ -518,6 +530,12 @@ safety_risk = 0.18
comfort_distance = 22.0
discovery_priority = 0.25
[node name="MeshInstance3D" parent="WorldObjects/ResourceNodes/BerryPatch_River_01/Visual"]
visible = false
[node name="BerryPatchPresentation" parent="WorldObjects/ResourceNodes/BerryPatch_River_01/Visual" instance=ExtResource("33_berry")]
scale = Vector3(1.15, 1.15, 1.15)
[node name="AnimalCamp_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-12, 0, 8)
node_id = &"animal_camp_01"
@@ -536,6 +554,12 @@ safety_risk = 0.12
comfort_distance = 24.0
discovery_priority = 0.1
[node name="MeshInstance3D" parent="WorldObjects/ResourceNodes/BerryPatch_South_01/Visual"]
visible = false
[node name="BerryPatchPresentation" parent="WorldObjects/ResourceNodes/BerryPatch_South_01/Visual" instance=ExtResource("33_berry")]
rotation_degrees = Vector3(0, -24, 0)
[node name="AnimalCamp_River_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(20, 0, 16)
node_id = &"animal_camp_river_01"
@@ -626,6 +650,11 @@ safety_risk = 0.1
comfort_distance = 20.0
discovery_priority = 0.45
[node name="MeshInstance3D" parent="WorldObjects/ResourceNodes/BerryPatch_Mill_01/Visual"]
visible = false
[node name="BerryPatchPresentation" parent="WorldObjects/ResourceNodes/BerryPatch_Mill_01/Visual" instance=ExtResource("33_berry")]
[node name="ResourceClusters" type="Node3D" parent="WorldObjects"]
[node name="RiverbankResourceCluster" parent="WorldObjects/ResourceClusters" instance=ExtResource("26_resource_cluster")]
+26 -3
View File
@@ -29,11 +29,28 @@ const BERRY_POSITIONS := [
func _ready() -> void:
add_to_group("interactive_foliage")
_build_leaves()
_build_berries()
super()
func set_contact_field(texture: Texture2D, world_rect: Vector4, enabled: bool) -> void:
for child in [$Leaves, $Berries]:
var material := (child as MultiMeshInstance3D).multimesh.mesh.material as ShaderMaterial
material.set_shader_parameter("foliage_trail_map", texture)
material.set_shader_parameter("foliage_trail_rect", world_rect)
material.set_shader_parameter("foliage_trails_enabled", enabled)
material.set_shader_parameter("contact_root", global_position)
func set_contact_actors(positions: PackedVector3Array, count: int) -> void:
for child in [$Leaves, $Berries]:
var material := (child as MultiMeshInstance3D).multimesh.mesh.material as ShaderMaterial
material.set_shader_parameter("interactor_count", count)
material.set_shader_parameter("interactor_positions", positions)
func get_presentation_instance_count() -> int:
return LEAF_POSITIONS.size() + BERRY_POSITIONS.size()
@@ -75,6 +92,7 @@ func _build_leaves() -> void:
material.set_shader_parameter("wind_strength", 0.045)
material.set_shader_parameter("wind_speed", 0.48)
material.set_shader_parameter("wind_phase", 1.7)
material.set_shader_parameter("contact_strength", 0.55)
mesh.material = material
var transforms: Array[Transform3D] = []
for index in LEAF_POSITIONS.size():
@@ -90,9 +108,14 @@ func _build_berries() -> void:
mesh.height = 0.21
mesh.radial_segments = 8
mesh.rings = 4
var material := StandardMaterial3D.new()
material.albedo_color = Color("#8f3148")
material.roughness = 0.78
var material := ShaderMaterial.new()
material.shader = WIND_SHADER
material.set_shader_parameter("albedo", Color("#8f3148"))
material.set_shader_parameter("roughness", 0.78)
material.set_shader_parameter("wind_strength", 0.012)
material.set_shader_parameter("wind_speed", 0.48)
material.set_shader_parameter("wind_phase", 1.7)
material.set_shader_parameter("contact_strength", 0.55)
mesh.material = material
var transforms: Array[Transform3D] = []
for position in BERRY_POSITIONS:
+1 -2
View File
@@ -252,8 +252,7 @@ func _apply_grass_quality(
grass_field.set_physics_process(physics_enabled)
grass_controller.set("max_interactors", maxi(max_interactors, 1))
grass_controller.set("update_interval", update_interval)
grass_controller.set_process(controller_enabled)
grass_controller.set("_elapsed", update_interval)
grass_controller.call("set_interaction_enabled", controller_enabled)
var particles: Array = grass_field.get("particle_nodes")
for value in particles:
+16 -5
View File
@@ -2,6 +2,8 @@
shader_type spatial;
render_mode skip_vertex_transform, cull_disabled, specular_disabled;
#include "foliage_trail.gdshaderinc"
const int MAX_INTERACTORS = 8;
uniform vec2 wind_direction = vec2(1.0, 0.7);
uniform vec3 base_color : source_color = vec3(0.23, 0.40, 0.22);
@@ -9,7 +11,7 @@ uniform vec3 tip_color : source_color = vec3(0.53, 0.67, 0.35);
uniform float wind_amount : hint_range(0.0, 1.0) = 0.24;
uniform int interactor_count : hint_range(0, 8) = 0;
uniform vec3 interactor_positions[MAX_INTERACTORS];
uniform float interaction_radius : hint_range(0.25, 4.0) = 1.8;
uniform float interaction_radius : hint_range(0.25, 4.0) = 1.1;
uniform float interaction_strength : hint_range(0.0, 2.0) = 0.55;
uniform int clearing_count = 0;
uniform vec4 clearing_segments[4];
@@ -36,7 +38,10 @@ void vertex() {
vec3 world_vertex = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
float phase = dot(anchor.xz, vec2(0.38, 0.29));
float gust = sin(TIME * 1.15 + phase) * 0.65 + sin(TIME * 0.58 + phase * 0.42) * 0.35;
world_vertex.xz += normalize(wind_direction) * gust * wind_amount * tip_weight;
vec3 trail = sample_foliage_trail(anchor);
world_vertex.xz += normalize(wind_direction) * gust * wind_amount * tip_weight * (1.0 - trail.z * 0.75);
vec2 contact_bend = trail.xy * 0.72;
float pressure = trail.z;
shade_variation = sin(anchor.x * 0.30 + anchor.z * 0.21) * 0.5 + 0.5;
pressed_weight = 0.0;
for (int index = 0; index < MAX_INTERACTORS; index++) {
@@ -44,11 +49,17 @@ void vertex() {
vec2 offset = anchor.xz - interactor_positions[index].xz;
float distance_to_actor = length(offset);
float influence = 1.0 - smoothstep(0.25, interaction_radius, distance_to_actor);
influence *= 1.0 - smoothstep(0.55, 1.35, abs(anchor.y - interactor_positions[index].y));
vec2 away = offset / max(distance_to_actor, 0.08);
world_vertex.xz += away * influence * interaction_strength * tip_weight;
world_vertex.y -= influence * 0.18 * tip_weight;
pressed_weight = max(pressed_weight, influence);
if (influence > pressure) {
contact_bend = away * influence * interaction_strength;
pressure = influence;
}
}
world_vertex.xz += contact_bend * tip_weight;
// Lower relative to the blade's own root so short tufts stay above the ground.
world_vertex.y -= max(world_vertex.y - anchor.y, 0.0) * pressure * 0.78 * blade_height;
pressed_weight = pressure;
VERTEX = (VIEW_MATRIX * vec4(world_vertex, 1.0)).xyz;
NORMAL = normalize((VIEW_MATRIX * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
}
@@ -12,5 +12,5 @@ shader_parameter/tip_color = Color(0.53, 0.67, 0.35, 1)
shader_parameter/wind_amount = 0.24
shader_parameter/interactor_count = 0
shader_parameter/interactor_positions = PackedVector3Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
shader_parameter/interaction_radius = 1.8
shader_parameter/interaction_radius = 1.1
shader_parameter/interaction_strength = 0.55
@@ -0,0 +1,16 @@
// Per-world data texture, deliberately not a global shader parameter.
uniform bool foliage_trails_enabled = false;
uniform sampler2D foliage_trail_map : filter_linear, repeat_disable;
uniform vec4 foliage_trail_rect = vec4(0.0, 0.0, 64.0, 64.0);
vec3 sample_foliage_trail(vec3 ground_position) {
if (!foliage_trails_enabled) { return vec3(0.0); }
vec2 uv = (ground_position.xz - foliage_trail_rect.xy) / foliage_trail_rect.zw;
if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) { return vec3(0.0); }
vec4 trail = textureLod(foliage_trail_map, uv, 0.0);
float foot_height = trail.a / max(trail.b, 0.0001);
// Walkers on a bridge or above a slope do not press plants underneath them.
float same_level = 1.0 - smoothstep(0.55, 1.35, abs(ground_position.y - foot_height));
float edge = smoothstep(0.0, 0.025, min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y)));
return trail.rgb * same_level * edge;
}
@@ -0,0 +1 @@
uid://b6e1cvtl55oyv
+36
View File
@@ -1,6 +1,8 @@
shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;
#include "foliage_trail.gdshaderinc"
uniform vec2 wind_direction = vec2(0.94, 0.34);
uniform float wind_strength : hint_range(0.0, 0.2) = 0.07;
uniform float wind_speed : hint_range(0.0, 2.0) = 0.55;
@@ -10,6 +12,10 @@ uniform float flutter_strength : hint_range(0.0, 0.05) = 0.012;
uniform float wind_phase : hint_range(0.0, 6.2832) = 0.0;
uniform vec3 albedo : source_color = vec3(0.19, 0.38, 0.17);
uniform float roughness : hint_range(0.0, 1.0) = 0.9;
uniform vec3 contact_root = vec3(0.0);
uniform float contact_strength = 0.0;
uniform int interactor_count = 0;
uniform vec3 interactor_positions[8];
varying vec3 paint_position;
void vertex() {
@@ -28,6 +34,36 @@ void vertex() {
VERTEX.xz += direction * bend * height_factor;
VERTEX.xz += crosswind * flutter * height_factor;
if (foliage_trails_enabled && contact_strength > 0.0) {
// One bend for the whole bush, including fruit; its base remains planted.
vec3 contact = sample_foliage_trail(contact_root);
vec3 edge_contact = sample_foliage_trail(contact_root + vec3(0.45, 0.0, 0.0));
if (edge_contact.z > contact.z) { contact = edge_contact; }
edge_contact = sample_foliage_trail(contact_root - vec3(0.45, 0.0, 0.0));
if (edge_contact.z > contact.z) { contact = edge_contact; }
edge_contact = sample_foliage_trail(contact_root + vec3(0.0, 0.0, 0.45));
if (edge_contact.z > contact.z) { contact = edge_contact; }
edge_contact = sample_foliage_trail(contact_root - vec3(0.0, 0.0, 0.45));
if (edge_contact.z > contact.z) { contact = edge_contact; }
vec3 world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
float rooted = smoothstep(0.0, 1.25, world_position.y - contact_root.y);
// Bushes spring back faster than the flattened meadow trail.
float spring = pow(contact.z, 5.0);
vec2 bend_direction = contact.xy / max(length(contact.xy), 0.001);
for (int index = 0; index < 8; index++) {
if (index >= interactor_count) { break; }
vec2 offset = contact_root.xz - interactor_positions[index].xz;
float distance_to_actor = length(offset);
float influence = 1.0 - smoothstep(0.25, 1.8, distance_to_actor);
influence *= 1.0 - smoothstep(0.55, 1.35, abs(contact_root.y - interactor_positions[index].y));
if (influence > spring) {
spring = influence;
bend_direction = offset / max(distance_to_actor, 0.001);
}
}
vec3 displacement = vec3(bend_direction.x, -0.18, bend_direction.y) * spring * contact_strength * rooted;
VERTEX += (inverse(MODEL_MATRIX) * vec4(displacement, 0.0)).xyz;
}
}
void fragment() {