diff --git a/docs/FEATURE_PRESENTATION_PERFORMANCE.md b/docs/FEATURE_PRESENTATION_PERFORMANCE.md index d54e7ed..4f7dffb 100644 --- a/docs/FEATURE_PRESENTATION_PERFORMANCE.md +++ b/docs/FEATURE_PRESENTATION_PERFORMANCE.md @@ -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 diff --git a/docs/baselines/foliage_before.png b/docs/baselines/foliage_before.png new file mode 100644 index 0000000..6328823 Binary files /dev/null and b/docs/baselines/foliage_before.png differ diff --git a/docs/baselines/foliage_contact.png b/docs/baselines/foliage_contact.png new file mode 100644 index 0000000..37b471d Binary files /dev/null and b/docs/baselines/foliage_contact.png differ diff --git a/docs/baselines/foliage_recovered.png b/docs/baselines/foliage_recovered.png new file mode 100644 index 0000000..007e5b3 Binary files /dev/null and b/docs/baselines/foliage_recovered.png differ diff --git a/docs/baselines/foliage_render_metrics.json b/docs/baselines/foliage_render_metrics.json new file mode 100644 index 0000000..ecfd260 --- /dev/null +++ b/docs/baselines/foliage_render_metrics.json @@ -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" +} diff --git a/docs/baselines/foliage_trail.png b/docs/baselines/foliage_trail.png new file mode 100644 index 0000000..3c3dc20 Binary files /dev/null and b/docs/baselines/foliage_trail.png differ diff --git a/tests/foliage_interaction_test.gd b/tests/foliage_interaction_test.gd new file mode 100644 index 0000000..be2e9f7 --- /dev/null +++ b/tests/foliage_interaction_test.gd @@ -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) diff --git a/tests/foliage_interaction_test.gd.uid b/tests/foliage_interaction_test.gd.uid new file mode 100644 index 0000000..16169c4 --- /dev/null +++ b/tests/foliage_interaction_test.gd.uid @@ -0,0 +1 @@ +uid://clvjg2p81ay3b diff --git a/tools/capture_foliage.gd b/tools/capture_foliage.gd new file mode 100644 index 0000000..3a0c21a --- /dev/null +++ b/tools/capture_foliage.gd @@ -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) diff --git a/tools/capture_foliage.gd.uid b/tools/capture_foliage.gd.uid new file mode 100644 index 0000000..4b65dbe --- /dev/null +++ b/tools/capture_foliage.gd.uid @@ -0,0 +1 @@ +uid://bqd5coilksebi diff --git a/world/jajce/FoliageTrailMap.gd b/world/jajce/FoliageTrailMap.gd new file mode 100644 index 0000000..7d936bd --- /dev/null +++ b/world/jajce/FoliageTrailMap.gd @@ -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 diff --git a/world/jajce/FoliageTrailMap.gd.uid b/world/jajce/FoliageTrailMap.gd.uid new file mode 100644 index 0000000..86cb15a --- /dev/null +++ b/world/jajce/FoliageTrailMap.gd.uid @@ -0,0 +1 @@ +uid://6llj4y8dy0jm diff --git a/world/jajce/GrassInteractionController.gd b/world/jajce/GrassInteractionController.gd index ef5b1b6..d221bac 100644 --- a/world/jajce/GrassInteractionController.gd +++ b/world/jajce/GrassInteractionController.gd @@ -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 + ) diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index 69ad6fe..3857bb8 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -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")] diff --git a/world/jajce/StylizedBerryPatch.gd b/world/jajce/StylizedBerryPatch.gd index b39d216..aca4a5e 100644 --- a/world/jajce/StylizedBerryPatch.gd +++ b/world/jajce/StylizedBerryPatch.gd @@ -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: diff --git a/world/jajce/jajce_world.gd b/world/jajce/jajce_world.gd index 7fec388..42155a3 100644 --- a/world/jajce/jajce_world.gd +++ b/world/jajce/jajce_world.gd @@ -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: diff --git a/world/jajce/materials/cozy_grass.gdshader b/world/jajce/materials/cozy_grass.gdshader index 80a5ea8..7f385cc 100644 --- a/world/jajce/materials/cozy_grass.gdshader +++ b/world/jajce/materials/cozy_grass.gdshader @@ -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); } diff --git a/world/jajce/materials/cozy_grass_material.tres b/world/jajce/materials/cozy_grass_material.tres index 64402f4..257eaf0 100644 --- a/world/jajce/materials/cozy_grass_material.tres +++ b/world/jajce/materials/cozy_grass_material.tres @@ -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 diff --git a/world/jajce/materials/foliage_trail.gdshaderinc b/world/jajce/materials/foliage_trail.gdshaderinc new file mode 100644 index 0000000..bf21a5f --- /dev/null +++ b/world/jajce/materials/foliage_trail.gdshaderinc @@ -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; +} diff --git a/world/jajce/materials/foliage_trail.gdshaderinc.uid b/world/jajce/materials/foliage_trail.gdshaderinc.uid new file mode 100644 index 0000000..bad4ea7 --- /dev/null +++ b/world/jajce/materials/foliage_trail.gdshaderinc.uid @@ -0,0 +1 @@ +uid://b6e1cvtl55oyv diff --git a/world/jajce/materials/wind.gdshader b/world/jajce/materials/wind.gdshader index c708dbd..8872aef 100644 --- a/world/jajce/materials/wind.gdshader +++ b/world/jajce/materials/wind.gdshader @@ -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() {