238 lines
8.6 KiB
GDScript
238 lines
8.6 KiB
GDScript
extends Node3D
|
|
## Weather visuals and sound, driven by weather.gd (the model).
|
|
##
|
|
## - Rain and snow are GPU particles emitted above the camera in world space.
|
|
## A heightfield collider that follows the camera stops them on roofs and on
|
|
## the ground, so it doesn't rain indoors.
|
|
## - Ground wetness, settled snow and wind strength are global shader uniforms
|
|
## (weather_wet, weather_snow, weather_wind) read by the terrain, grass,
|
|
## foliage and rock shaders.
|
|
## - Rain and wind are synthesised (filtered noise). Thunder is recorded
|
|
## (CC0, 0.6.2: assets/audio/sfx/thunder, Sfx "thunder_far"/"thunder_near"):
|
|
## after each flash in a storm, usually a distant roll a few seconds later,
|
|
## sometimes a nearer crack sooner. Quieter under a roof. (A strike on a
|
|
## tree near you cracks from the tree itself: hazards.gd.)
|
|
##
|
|
## Client-side only: in M2 the server runs weather.gd and never this.
|
|
|
|
const Campfire = preload("res://scripts/building/campfire.gd")
|
|
|
|
var weather: Node
|
|
var player: Node3D ## for shelter muffling (survival.roofed)
|
|
|
|
var _rain: GPUParticles3D
|
|
var _snow: GPUParticles3D
|
|
var _rain_pm: ParticleProcessMaterial
|
|
var _snow_pm: ParticleProcessMaterial
|
|
var _field: GPUParticlesCollisionHeightField3D
|
|
|
|
# Audio
|
|
const MIX_RATE := 22050.0
|
|
var _audio: AudioStreamPlayer
|
|
var _playback: AudioStreamGeneratorPlayback
|
|
var _rng := RandomNumberGenerator.new()
|
|
var _rain_lp := 0.0
|
|
var _rain_hp := 0.0
|
|
var _brown := 0.0
|
|
var _gust_t := 0.0
|
|
var _thunder_delay := -1.0
|
|
var _thunder_id := ""
|
|
var _thunder: AudioStreamPlayer
|
|
const NEAR_CHANCE := 0.3 ## of a storm's flashes, the share that are close
|
|
var _seen_strikes := 0
|
|
var _rain_amp := 0.0
|
|
var _wind_amp := 0.0
|
|
var _muffle := 0.0
|
|
|
|
func setup(w: Node, p: Node3D) -> void:
|
|
weather = w
|
|
player = p
|
|
_seen_strikes = weather.lightning_count
|
|
_rain_pm = ParticleProcessMaterial.new()
|
|
_rain = _make_particles(7000, 1.5, _rain_pm, _streak_mesh())
|
|
_rain_pm.emission_box_extents = Vector3(20, 1, 20)
|
|
_rain_pm.initial_velocity_min = 13.0
|
|
_rain_pm.initial_velocity_max = 16.0
|
|
_rain_pm.gravity = Vector3(0, -6, 0)
|
|
_rain_pm.particle_flag_align_y = true
|
|
_rain_pm.spread = 2.0
|
|
|
|
_snow_pm = ParticleProcessMaterial.new()
|
|
_snow = _make_particles(5000, 9.0, _snow_pm, _flake_mesh())
|
|
_snow_pm.emission_box_extents = Vector3(18, 1, 18)
|
|
_snow_pm.initial_velocity_min = 0.8
|
|
_snow_pm.initial_velocity_max = 1.4
|
|
_snow_pm.gravity = Vector3(0, -0.6, 0)
|
|
_snow_pm.spread = 15.0
|
|
_snow_pm.turbulence_enabled = true
|
|
_snow_pm.turbulence_noise_strength = 1.2
|
|
_snow_pm.turbulence_noise_scale = 4.0
|
|
_snow_pm.turbulence_influence_min = 0.03
|
|
_snow_pm.turbulence_influence_max = 0.08
|
|
|
|
_field = GPUParticlesCollisionHeightField3D.new()
|
|
_field.size = Vector3(48, 48, 48)
|
|
_field.resolution = GPUParticlesCollisionHeightField3D.RESOLUTION_512
|
|
_field.update_mode = GPUParticlesCollisionHeightField3D.UPDATE_MODE_ALWAYS
|
|
_field.follow_camera_enabled = true
|
|
add_child(_field)
|
|
|
|
if DisplayServer.get_name() != "headless":
|
|
var gen := AudioStreamGenerator.new()
|
|
gen.mix_rate = MIX_RATE
|
|
gen.buffer_length = 0.2
|
|
_audio = AudioStreamPlayer.new()
|
|
_audio.stream = gen
|
|
_audio.volume_db = -4.0
|
|
add_child(_audio)
|
|
_audio.play()
|
|
_playback = _audio.get_stream_playback()
|
|
_thunder = AudioStreamPlayer.new()
|
|
_thunder.name = "Thunder"
|
|
_thunder.bus = "SFX" if AudioServer.get_bus_index("SFX") >= 0 else "Master"
|
|
add_child(_thunder)
|
|
|
|
func _make_particles(amount: int, life: float, pm: ParticleProcessMaterial, mesh: Mesh) -> GPUParticles3D:
|
|
var p := GPUParticles3D.new()
|
|
p.amount = amount
|
|
p.lifetime = life
|
|
p.preprocess = life
|
|
p.local_coords = false
|
|
p.visibility_aabb = AABB(Vector3(-30, -40, -30), Vector3(60, 60, 60))
|
|
p.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
p.amount_ratio = 0.0
|
|
p.emitting = false
|
|
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
|
pm.direction = Vector3.DOWN
|
|
pm.collision_mode = ParticleProcessMaterial.COLLISION_HIDE_ON_CONTACT
|
|
pm.collision_use_scale = false
|
|
p.collision_base_size = 0.05
|
|
p.process_material = pm
|
|
p.draw_pass_1 = mesh
|
|
add_child(p)
|
|
return p
|
|
|
|
## Two crossed thin quads along +Y, so a streak reads from any side once the
|
|
## particle is aligned to its velocity.
|
|
static func _streak_mesh() -> ArrayMesh:
|
|
var st := SurfaceTool.new()
|
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
|
var w := 0.012
|
|
var h := 0.32
|
|
for axis in [Vector3.RIGHT, Vector3.BACK]:
|
|
var a: Vector3 = axis * w
|
|
var quad := [-a + Vector3(0, -h, 0), a + Vector3(0, -h, 0), a + Vector3(0, h, 0), -a + Vector3(0, h, 0)]
|
|
for i in [0, 1, 2, 0, 2, 3]:
|
|
st.set_uv(Vector2(0.5, 1.0 if quad[i].y < 0 else 0.0))
|
|
st.add_vertex(quad[i])
|
|
var m := StandardMaterial3D.new()
|
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
m.albedo_color = Color(0.72, 0.78, 0.88, 0.22)
|
|
m.disable_receive_shadows = true
|
|
st.set_material(m)
|
|
return st.commit()
|
|
|
|
static func _flake_mesh() -> QuadMesh:
|
|
var q := QuadMesh.new()
|
|
q.size = Vector2.ONE * 0.07
|
|
var m := StandardMaterial3D.new()
|
|
m.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
|
|
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
m.albedo_texture = Campfire._soft_dot()
|
|
m.albedo_color = Color(1, 1, 1, 0.9)
|
|
m.emission_enabled = true
|
|
m.emission = Color(0.25, 0.27, 0.3) # stays visible against a dark sky
|
|
m.disable_receive_shadows = true
|
|
q.material = m
|
|
return q
|
|
|
|
func _process(delta: float) -> void:
|
|
if weather == null:
|
|
return
|
|
var cam := get_viewport().get_camera_3d()
|
|
if cam == null:
|
|
return
|
|
var cp := cam.global_position
|
|
var fwd := -cam.global_basis.z
|
|
fwd.y = 0.0
|
|
var snow: bool = weather.snows_at(cp)
|
|
var amount: float = weather.precip if weather.precip > 0.05 else 0.0
|
|
var wind_v: Vector3 = Vector3(0.8, 0.0, 0.3) * float(weather.wind)
|
|
# Emit a little ahead of the camera, so the view is full and the area
|
|
# behind doesn't waste particles.
|
|
_rain.global_position = cp + fwd.normalized() * 5.0 + Vector3(0, 13, 0)
|
|
_snow.global_position = cp + fwd.normalized() * 3.0 + Vector3(0, 9, 0)
|
|
_set_amount(_rain, 0.0 if snow else amount)
|
|
_set_amount(_snow, amount if snow else 0.0)
|
|
_rain_pm.direction = (Vector3.DOWN + wind_v * 0.25).normalized()
|
|
_snow_pm.gravity = Vector3(0, -0.6, 0) + wind_v * 1.5
|
|
_field.visible = amount > 0.0
|
|
|
|
RenderingServer.global_shader_parameter_set("weather_wet", weather.ground_wet)
|
|
RenderingServer.global_shader_parameter_set("weather_snow", weather.snow_cover)
|
|
RenderingServer.global_shader_parameter_set("weather_wind", lerpf(0.7, 3.0, weather.wind))
|
|
|
|
var roofed: bool = player != null and player.survival.roofed
|
|
var sheltered: bool = player != null and player.survival.sheltered
|
|
_muffle = move_toward(_muffle, 1.0 if roofed else 0.0, delta * 3.0)
|
|
_rain_amp = weather.rain_at(cp) * 0.30 * (1.0 - 0.45 * _muffle)
|
|
_wind_amp = weather.wind * weather.wind * 0.22 * (0.35 if sheltered else 1.0)
|
|
if weather.lightning_count != _seen_strikes:
|
|
_seen_strikes = weather.lightning_count
|
|
lightning()
|
|
if _thunder_delay >= 0.0:
|
|
_thunder_delay -= delta
|
|
if _thunder_delay < 0.0:
|
|
_thunder_now()
|
|
_fill_audio()
|
|
|
|
## A flash in the sky: its thunder follows, after the time the sound takes
|
|
## to come (a near one sooner, a distant one later).
|
|
func lightning() -> void:
|
|
var near := _rng.randf() < NEAR_CHANCE
|
|
_thunder_id = "thunder_near" if near else "thunder_far"
|
|
_thunder_delay = _rng.randf_range(0.3, 0.9) if near else _rng.randf_range(1.5, 4.0)
|
|
|
|
func _thunder_now() -> void:
|
|
Sfx.log_heard(_thunder_id)
|
|
if _thunder == null:
|
|
return
|
|
var st: AudioStream = Sfx.stream_for(_thunder_id)
|
|
if st == null:
|
|
return
|
|
_thunder.stream = st
|
|
_thunder.volume_db = Sfx.db_for(_thunder_id) - 8.0 * _muffle
|
|
_thunder.pitch_scale = _rng.randf_range(0.9, 1.05)
|
|
_thunder.play()
|
|
|
|
func _set_amount(p: GPUParticles3D, a: float) -> void:
|
|
p.amount_ratio = a
|
|
p.emitting = a > 0.0
|
|
|
|
## Synthesises as many audio frames as the generator can take.
|
|
func _fill_audio() -> void:
|
|
if _playback == null:
|
|
return
|
|
var n := _playback.get_frames_available()
|
|
if n <= 0:
|
|
return
|
|
var rain_cut := lerpf(0.55, 0.12, _muffle)
|
|
var buf := PackedVector2Array()
|
|
buf.resize(n)
|
|
for i in range(n):
|
|
var white := _rng.randf() * 2.0 - 1.0
|
|
# Rain: band-limited hiss (low-pass, minus a slower low-pass).
|
|
_rain_lp += rain_cut * (white - _rain_lp)
|
|
_rain_hp += 0.02 * (_rain_lp - _rain_hp)
|
|
var rain := (_rain_lp - _rain_hp) * _rain_amp
|
|
# Wind: brown noise, swelling in slow gusts.
|
|
_brown = clampf(_brown * 0.998 + white * 0.03, -1.0, 1.0)
|
|
_gust_t += 1.0 / MIX_RATE
|
|
var gust := 0.6 + 0.4 * sin(_gust_t * 0.7) * sin(_gust_t * 0.23 + 1.0)
|
|
var wind_s := _brown * _wind_amp * gust * 2.0
|
|
var s := clampf(rain + wind_s, -1.0, 1.0)
|
|
buf[i] = Vector2(s, s)
|
|
_playback.push_buffer(buf)
|