272 lines
9.0 KiB
GDScript
272 lines
9.0 KiB
GDScript
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
|