class_name WindGustField extends Node3D const GUST_SHADER := preload("res://world/jajce/materials/wind_gust.gdshader") const RIBBON_SEGMENTS := 18 const STROKE_COLORS := [ Color(0.76, 0.93, 0.74, 0.28), Color(1.0, 0.82, 0.52, 0.22), Color(0.64, 0.88, 0.92, 0.2), ] @export var terrain_path: NodePath = ^"../../TerrainRoot/Terrain3D" @export var wind_direction := Vector3(0.94, 0.0, 0.34) @export var view_spawn_extents := Vector2(8.5, 5.5) @export_range(5.0, 12.0, 0.1) var interval_min := 6.0 @export_range(5.0, 12.0, 0.1) var interval_max := 10.0 @export_range(1.0, 3.0, 0.1) var duration_min := 1.8 @export_range(1.0, 3.0, 0.1) var duration_max := 2.35 @export_range(1, 3, 1) var stroke_count_min := 2 @export_range(1, 3, 1) var stroke_count_max := 3 @export_range(0.5, 3.0, 0.1) var hover_height := 1.25 @export var deterministic_seed := 20260711 var _random := RandomNumberGenerator.new() var _terrain: Node var _time_until_next_gust := 0.0 var _active_burst: Dictionary = {} func _ready() -> void: _random.seed = deterministic_seed _terrain = get_node_or_null(terrain_path) _time_until_next_gust = _random.randf_range(2.0, 4.0) func _process(delta: float) -> void: _time_until_next_gust -= delta if not _active_burst.is_empty(): _update_active_burst(delta) if _active_burst.is_empty() and _time_until_next_gust <= 0.0: _trigger_gust_at_world(_random_world_origin()) func trigger_gust_at( local_origin: Vector3, replace_active: bool = false, initial_progress: float = 0.0 ) -> bool: return _trigger_gust_at_world(to_global(local_origin), replace_active, initial_progress) func _trigger_gust_at_world( world_origin: Vector3, replace_active: bool = false, initial_progress: float = 0.0 ) -> bool: if not _active_burst.is_empty(): if not replace_active: return false _clear_active_burst(true) var burst := Node3D.new() burst.name = "WindGustBurst" add_child(burst) var origin := world_origin var origin_ground_height := _sample_terrain_height(origin) origin.y = origin_ground_height + hover_height + _random.randf_range(0.15, 0.55) burst.global_position = origin var duration := _random.randf_range(duration_min, duration_max) var stroke_count := _random.randi_range(stroke_count_min, stroke_count_max) var strokes: Array[Dictionary] = [] var direction := wind_direction.normalized() var crosswind := Vector3.UP.cross(direction).normalized() for stroke_index in stroke_count: var stroke := MeshInstance3D.new() stroke.name = "Stroke_%02d" % (stroke_index + 1) stroke.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF var length := _random.randf_range(5.0, 7.8) * (1.0 - stroke_index * 0.08) var width := _random.randf_range(0.2, 0.32) var curve := _random.randf_range(0.18, 0.42) var phase := _random.randf_range(-0.7, 0.7) stroke.mesh = _create_ribbon_mesh( length, width, curve, phase, direction, origin, origin_ground_height ) stroke.position = ( crosswind * (float(stroke_index) - float(stroke_count - 1) * 0.5) * 0.62 + Vector3.UP * stroke_index * 0.12 - direction * stroke_index * 0.25 ) var material := ShaderMaterial.new() material.shader = GUST_SHADER material.set_shader_parameter("tint", STROKE_COLORS[stroke_index]) material.set_shader_parameter("progress", 0.0) stroke.material_override = material burst.add_child(stroke) ( strokes . append( { "material": material, "delay": float(stroke_index) * 0.11, } ) ) _active_burst = { "node": burst, "age": duration * clampf(initial_progress, 0.0, 0.95), "duration": duration, "drift_speed": _random.randf_range(0.35, 0.65), "strokes": strokes, } _apply_stroke_progress() _time_until_next_gust = _random.randf_range(interval_min, interval_max) return true func get_active_stroke_count() -> int: if _active_burst.is_empty(): return 0 return (_active_burst["strokes"] as Array).size() func _update_active_burst(delta: float) -> void: var burst := _active_burst["node"] as Node3D if burst == null or not is_instance_valid(burst): _active_burst.clear() return var age := float(_active_burst["age"]) + delta var duration := float(_active_burst["duration"]) _active_burst["age"] = age burst.global_position += ( wind_direction.normalized() * float(_active_burst["drift_speed"]) * delta ) var desired_height := _sample_terrain_height(burst.global_position) + hover_height burst.global_position.y = lerpf(burst.global_position.y, desired_height, minf(delta * 2.0, 1.0)) _apply_stroke_progress() if age >= duration: _clear_active_burst() func _apply_stroke_progress() -> void: var age := float(_active_burst["age"]) var duration := float(_active_burst["duration"]) for stroke_data in _active_burst["strokes"]: var delay := float(stroke_data["delay"]) var stroke_progress := clampf((age - delay) / maxf(duration - delay, 0.01), 0.0, 1.0) var material := stroke_data["material"] as ShaderMaterial material.set_shader_parameter("progress", stroke_progress) func _clear_active_burst(free_immediately: bool = false) -> void: if _active_burst.is_empty(): return var burst := _active_burst.get("node") as Node3D if burst != null and is_instance_valid(burst): if free_immediately: burst.free() else: burst.queue_free() _active_burst.clear() func _random_world_origin() -> Vector3: var camera := get_viewport().get_camera_3d() if camera == null: return to_global( Vector3( _random.randf_range(-view_spawn_extents.x, view_spawn_extents.x), 0.0, _random.randf_range(-view_spawn_extents.y, view_spawn_extents.y), ) ) var center := _camera_ground_focus(camera) var camera_right := Vector3(camera.global_basis.x.x, 0.0, camera.global_basis.x.z).normalized() var camera_forward := ( Vector3(-camera.global_basis.z.x, 0.0, -camera.global_basis.z.z).normalized() ) return ( center + camera_right * _random.randf_range(-view_spawn_extents.x, view_spawn_extents.x) + camera_forward * _random.randf_range(-view_spawn_extents.y, view_spawn_extents.y) ) func _camera_ground_focus(camera: Camera3D) -> Vector3: var ray_origin := camera.global_position var ray_direction := -camera.global_basis.z.normalized() if ray_direction.y >= -0.01: return Vector3(ray_origin.x, _sample_terrain_height(ray_origin), ray_origin.z) var ground_height := _sample_terrain_height(global_position) var focus := global_position for _iteration in 3: var distance := (ground_height - ray_origin.y) / ray_direction.y if distance <= 0.0: break focus = ray_origin + ray_direction * distance ground_height = _sample_terrain_height(focus) focus.y = ground_height return focus func _sample_terrain_height(world_position: Vector3) -> float: if _terrain == null or not is_instance_valid(_terrain) or _terrain.get("data") == null: return global_position.y var terrain_height: float = _terrain.data.get_height(world_position) return global_position.y if is_nan(terrain_height) else terrain_height func _create_ribbon_mesh( length: float, base_width: float, curve_amount: float, phase: float, direction: Vector3, world_origin: Vector3, origin_ground_height: float ) -> ArrayMesh: var centers := PackedVector3Array() var crosswind := Vector3.UP.cross(direction).normalized() for segment_index in range(RIBBON_SEGMENTS + 1): var t := float(segment_index) / RIBBON_SEGMENTS var envelope := sin(t * PI) var sweep := sin(t * TAU * 1.15 + phase) * curve_amount * envelope var lift := envelope * 0.12 + sin(t * TAU + phase) * envelope * 0.035 var horizontal_offset := direction * ((t - 0.5) * length) + crosswind * sweep var terrain_offset := ( _sample_terrain_height(world_origin + horizontal_offset) - origin_ground_height ) centers.append(horizontal_offset + Vector3.UP * (terrain_offset + lift)) var vertices := PackedVector3Array() var uvs := PackedVector2Array() var indices := PackedInt32Array() for segment_index in range(RIBBON_SEGMENTS + 1): var t := float(segment_index) / RIBBON_SEGMENTS var previous := centers[maxi(segment_index - 1, 0)] var following := centers[mini(segment_index + 1, RIBBON_SEGMENTS)] var tangent := (following - previous).normalized() var side := Vector3.UP.cross(tangent).normalized() var taper := pow(maxf(sin(t * PI), 0.0), 0.72) var brush_belly := 1.0 + maxf(1.0 - absf(t - 0.34) / 0.34, 0.0) * 0.28 var half_width := base_width * taper * brush_belly * 0.5 vertices.append(centers[segment_index] - side * half_width) vertices.append(centers[segment_index] + side * half_width) uvs.append(Vector2(t, 0.0)) uvs.append(Vector2(t, 1.0)) if segment_index >= RIBBON_SEGMENTS: continue var vertex_index := segment_index * 2 ( indices . append_array( PackedInt32Array( [ vertex_index, vertex_index + 1, vertex_index + 2, vertex_index + 1, vertex_index + 3, vertex_index + 2, ] ) ) ) var arrays := [] arrays.resize(Mesh.ARRAY_MAX) arrays[Mesh.ARRAY_VERTEX] = vertices arrays[Mesh.ARRAY_TEX_UV] = uvs arrays[Mesh.ARRAY_INDEX] = indices var mesh := ArrayMesh.new() mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) return mesh