feat: material-aware sound occlusion + early reflections for localization

New Acoustics system (globals/acoustics.gd):
- Occlusion: rays from listener to source collect up to 3 occluding bodies;
  each surface's material adds transmission loss and lowers the lowpass
  cutoff. A gunshot through wood stays warm (-8dB, 1.4kHz), brick muffles
  hard (-15dB, 600Hz), concrete harder (-18dB, 450Hz), metal keeps more
  highs (-11dB, 1kHz), glass barely muffles (-5dB, 2.6kHz). Applied via
  each AudioStreamPlayer3D's attenuation filter.
- Early reflections: loud sounds (gunfire, explosions) probe 8 directions
  for nearby walls; each close surface plays a quiet, delayed (path/343ms),
  material-filtered copy from the mirror point — shots audibly slap back
  off structures so players can triangulate. Sub-30ms echoes are skipped
  (they perceptually merge with the direct sound).
- Surface classification: builder-set acoustic_material meta -> node-name
  keywords -> metallic-material inference -> concrete default; cached.

Integration:
- AudioManager.play_3d applies occlusion at fire time and re-filters all
  playing positional sounds every 0.12s (walking behind a wall mid-sound
  audibly muffles it); flight loops opt in via "acoustic_occluded" group
- Remote players' gunshots were entirely SILENT — rpc_play_fire_effects now
  plays the right weapon's shot at the shooter's position, occluded and
  reflected like everything else
- Remote players' footsteps were also silent — now positional + occluded,
  cadence scaled to their speed
- Test level tagged: brick arena walls, wooden platforms/obstacles, metal
  wall-run corridor/rails/dash platforms; dust2 sandstone reads as brick

Tests: debug/acoustics_test.gd (12 checks: per-material loss ordering,
multi-wall stacking, reflection delay/filter, name classification) — all
pass; movement 11/11; smoke 0 failures.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-17 23:48:38 -04:00
co-authored by Claude Fable 5
parent 44460b52ae
commit a51595e3b9
10 changed files with 391 additions and 24 deletions
+23 -1
View File
@@ -38,6 +38,7 @@ var ragdoll_instance: Node3D
var footstep_player: AudioStreamPlayer
var footstep_streams: Array = []
var land_player: AudioStreamPlayer
var _remote_footstep_timer: float = 0.0
var dash_player: AudioStreamPlayer
var recent_attackers: Dictionary = {} # attacker_id: timestamp
var hit_player: AudioStreamPlayer
@@ -499,7 +500,15 @@ func server_play_fire_effects(origin: Vector3, target_or_dir: Vector3, weapon_na
func rpc_play_fire_effects(origin: Vector3, target_or_dir: Vector3, _weapon_name: String, is_hitscan: bool) -> void:
# Local player already spawned effects locally with 0 latency
if is_multiplayer_authority(): return
# Positional gunshot for the remote shooter — occlusion + reflections via
# AudioManager make it locatable ("that came from behind the wooden wall").
var am = get_tree().root.get_node_or_null("AudioManager")
if am:
var sid: String = am.weapon_sound_id(_weapon_name)
if sid != "" and am.has_sound(sid):
am.play_3d(sid, origin)
if is_hitscan:
# Spawn a standalone tracer directly into the scene
_spawn_remote_tracer(origin, target_or_dir)
@@ -978,6 +987,19 @@ func _process(delta: float) -> void:
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
visual.set_weapon(synced_weapon_path)
visual.set_meta("current_weapon_path", synced_weapon_path)
# Positional footsteps for remote players (their movement states don't
# run here). Occlusion via AudioManager makes them read through walls
# correctly — the classic "someone's above me on the wooden platform".
if not is_dead and synced_movement_state == "ground" and synced_movement_speed > 1.0:
_remote_footstep_timer -= delta
if _remote_footstep_timer <= 0.0:
var am = get_tree().root.get_node_or_null("AudioManager")
if am and am.has_sound("footstep"):
am.play_3d("footstep", global_position + Vector3(0, -0.8, 0), -8.0)
_remote_footstep_timer = maxf(0.2, 3.0 / synced_movement_speed)
else:
_remote_footstep_timer = 0.0
return
# Update UI