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