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
+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() {