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. ## - Sound is synthesised: filtered noise for rain and wind, a decaying ## low rumble for thunder. No audio assets, nothing to license. ## ## 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 _brown2 := 0.0 var _gust_t := 0.0 var _thunder_env := 0.0 var _thunder_delay := -1.0 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() 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 _thunder_delay = _rng.randf_range(0.5, 2.5) if _thunder_delay >= 0.0: _thunder_delay -= delta if _thunder_delay < 0.0: _thunder_env = 1.0 _fill_audio() 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) var thunder_decay := exp(-1.0 / (MIX_RATE * 0.9)) 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 # Thunder: a heavier brown rumble with a long decay. var thunder := 0.0 if _thunder_env > 0.001: _brown2 = clampf(_brown2 * 0.9995 + white * 0.02, -1.0, 1.0) thunder = _brown2 * _thunder_env * 2.2 _thunder_env *= thunder_decay var s := clampf(rain + wind_s + thunder, -1.0, 1.0) buf[i] = Vector2(s, s) _playback.push_buffer(buf)