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
+111
View File
@@ -0,0 +1,111 @@
extends SceneTree
## Headless test for the Acoustics system:
## godot --headless --path . -s res://debug/acoustics_test.gd
## Builds walls of different materials between a listener and a source and
## checks occlusion/classification/reflection math behaves as designed.
var _fails := 0
var _frames := 0
var _level: Node3D
func _check(cond: bool, msg: String) -> void:
if cond:
print(" OK: ", msg)
else:
_fails += 1
print(" FAIL: ", msg)
func _wall(pos: Vector3, size: Vector3, acoustic: String) -> StaticBody3D:
var body := StaticBody3D.new()
body.name = "Wall_" + acoustic
body.set_meta("acoustic_material", acoustic)
body.collision_layer = 1
var shape := CollisionShape3D.new()
shape.shape = BoxShape3D.new()
shape.shape.size = size
body.add_child(shape)
_level.add_child(body)
body.position = pos
return body
func _initialize() -> void:
_level = Node3D.new()
root.add_child(_level)
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 5:
return false # let physics register the bodies
if _frames == 5:
print("=== Acoustics tests ===")
var world := _level.get_world_3d()
var listener := Vector3(0, 1, 0)
var source := Vector3(0, 1, -10)
# No wall: clear line
var occ := Acoustics.occlusion(world, listener, source)
_check(occ.surfaces == 0 and occ.db == 0.0, "clear line of sight -> no occlusion")
# Wooden wall between
var w := _wall(Vector3(0, 1, -5), Vector3(6, 4, 0.4), "wood")
return false
elif _frames == 10:
var world := _level.get_world_3d()
var occ := Acoustics.occlusion(world, Vector3(0, 1, 0), Vector3(0, 1, -10))
_check(occ.surfaces == 1, "wooden wall -> 1 occluding surface")
_check(absf(occ.db - Acoustics.MATERIALS.wood.trans_db) < 0.01,
"wood transmission loss applied (%.1f dB)" % occ.db)
_check(occ.cutoff == Acoustics.MATERIALS.wood.trans_cutoff,
"wood lowpass cutoff (%.0f Hz)" % occ.cutoff)
# Swap to concrete: heavier loss, deeper muffle
for c in _level.get_children():
c.set_meta("acoustic_material", "concrete")
var occ2 := Acoustics.occlusion(world, Vector3(0, 1, 0), Vector3(0, 1, -10))
_check(occ2.db < occ.db, "concrete muffles more than wood (%.1f < %.1f dB)" % [occ2.db, occ.db])
_check(occ2.cutoff < occ.cutoff, "concrete cuts more highs (%.0f < %.0f Hz)" % [occ2.cutoff, occ.cutoff])
# Metal: between wood and concrete in loss, more highs than concrete
for c in _level.get_children():
c.set_meta("acoustic_material", "metal")
var occ3 := Acoustics.occlusion(world, Vector3(0, 1, 0), Vector3(0, 1, -10))
_check(occ3.cutoff > occ2.cutoff, "metal passes more highs than concrete")
# Second wall stacks
_wall(Vector3(0, 1, -7), Vector3(6, 4, 0.4), "wood")
return false
elif _frames == 15:
var world := _level.get_world_3d()
var occ := Acoustics.occlusion(world, Vector3(0, 1, 0), Vector3(0, 1, -10))
_check(occ.surfaces == 2, "two walls -> 2 occluding surfaces (got %d)" % occ.surfaces)
# Reflections: a big wall beside the source, far enough that the
# bounce path is >10m longer than the direct path (>30ms later —
# closer echoes perceptually merge and are correctly skipped).
_wall(Vector3(15, 1, -10), Vector3(0.4, 8, 10), "metal")
return false
elif _frames == 20:
var world := _level.get_world_3d()
var refls := Acoustics.reflections(world, Vector3(0, 1, -10), Vector3(0, 1, -2))
_check(refls.size() >= 1, "side wall produces a reflection (got %d)" % refls.size())
if refls.size() >= 1:
_check(refls[0].delay > 0.0 and refls[0].delay <= 0.35,
"reflection delay in range (%.0f ms)" % (refls[0].delay * 1000.0))
_check(refls[0].cutoff == Acoustics.MATERIALS.metal.refl_cutoff,
"reflection carries metal filter")
# Classification fallbacks
var crate := StaticBody3D.new()
crate.name = "wooden_crate_3"
_check(Acoustics.classify(crate) == "wood", "name-based classify: crate -> wood")
crate.free()
print("=== Acoustics results: %s ===" % ("ALL PASSED" if _fails == 0 else "%d FAILED" % _fails))
return true
return false
+1
View File
@@ -0,0 +1 @@
uid://bjfe6cnowval
+2 -1
View File
@@ -42,9 +42,10 @@ func _build_dust2_layout() -> void:
var floor_mat = LevelMaterials.tinted(Color(0.7, 0.65, 0.55)) # Dusty ground
var box_mat = LevelMaterials.tinted(Color(0.4, 0.3, 0.2), true) # Wood crates
# Root CSG
# Root CSG — sandstone: the whole map reads acoustically as brick
var root_csg = CSGCombiner3D.new()
root_csg.use_collision = true
root_csg.set_meta("acoustic_material", "brick")
add_child(root_csg)
# Huge solid block representing the world bounds
+24 -21
View File
@@ -70,10 +70,13 @@ func _build_geometry() -> void:
# ── Utility ───────────────────────────────────────────────────────────────────
func _box_static(pos: Vector3, size: Vector3, color: Color, node_name: String = "") -> StaticBody3D:
func _box_static(pos: Vector3, size: Vector3, color: Color, node_name: String = "",
acoustic: String = "") -> StaticBody3D:
var body := StaticBody3D.new()
body.name = node_name if not node_name.is_empty() else "Static_%s" % str(pos)
body.position = pos
if acoustic != "":
body.set_meta("acoustic_material", acoustic)
add_child(body)
var shape := CollisionShape3D.new()
shape.shape = BoxShape3D.new()
@@ -114,13 +117,13 @@ func _build_walls_arena() -> void:
var wall_color := Color(0.35, 0.28, 0.22)
var wall_height := 10.0
# North
_box_static(Vector3(0, wall_height * 0.5, -40), Vector3(80, wall_height, 0.5), wall_color, "Wall_North")
_box_static(Vector3(0, wall_height * 0.5, -40), Vector3(80, wall_height, 0.5), wall_color, "Wall_North", "brick")
# South
_box_static(Vector3(0, wall_height * 0.5, 40), Vector3(80, wall_height, 0.5), wall_color, "Wall_South")
_box_static(Vector3(0, wall_height * 0.5, 40), Vector3(80, wall_height, 0.5), wall_color, "Wall_South", "brick")
# East
_box_static(Vector3(40, wall_height * 0.5, 0), Vector3(0.5, wall_height, 80), wall_color, "Wall_East")
_box_static(Vector3(40, wall_height * 0.5, 0), Vector3(0.5, wall_height, 80), wall_color, "Wall_East", "brick")
# West
_box_static(Vector3(-40, wall_height * 0.5, 0), Vector3(0.5, wall_height, 80), wall_color, "Wall_West")
_box_static(Vector3(-40, wall_height * 0.5, 0), Vector3(0.5, wall_height, 80), wall_color, "Wall_West", "brick")
# ── Ramps ─────────────────────────────────────────────────────────────────────
@@ -157,25 +160,25 @@ func _build_platforms() -> void:
var plat_color := Color(0.2, 0.45, 0.55) # Teal
# Low platforms (jump height test)
_box_static(Vector3(10, 0.6, -20), Vector3(4, 1.2, 4), plat_color, "Plat_Low_1")
_box_static(Vector3(16, 0.6, -20), Vector3(4, 1.2, 4), plat_color, "Plat_Low_2")
_box_static(Vector3(22, 0.6, -20), Vector3(4, 1.2, 4), plat_color, "Plat_Low_3")
_box_static(Vector3(10, 0.6, -20), Vector3(4, 1.2, 4), plat_color, "Plat_Low_1", "wood")
_box_static(Vector3(16, 0.6, -20), Vector3(4, 1.2, 4), plat_color, "Plat_Low_2", "wood")
_box_static(Vector3(22, 0.6, -20), Vector3(4, 1.2, 4), plat_color, "Plat_Low_3", "wood")
# Medium platforms (double jump required)
var med_color := Color(0.25, 0.5, 0.4)
_box_static(Vector3(10, 2.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_1")
_box_static(Vector3(16, 3.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_2")
_box_static(Vector3(22, 4.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_3")
_box_static(Vector3(28, 5.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_4")
_box_static(Vector3(10, 2.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_1", "wood")
_box_static(Vector3(16, 3.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_2", "wood")
_box_static(Vector3(22, 4.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_3", "wood")
_box_static(Vector3(28, 5.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_4", "wood")
# High tower
_box_static(Vector3(28, 4.0, -20), Vector3(5, 8.0, 5), Color(0.3, 0.3, 0.5), "Tower_1")
# Floating platforms (dash required)
var dash_color := Color(0.6, 0.2, 0.5) # Purple
_box_static(Vector3(10, 5.0, -35), Vector3(2.5, 0.3, 2.5), dash_color, "Plat_Dash_1")
_box_static(Vector3(18, 5.0, -35), Vector3(2.5, 0.3, 2.5), dash_color, "Plat_Dash_2")
_box_static(Vector3(26, 5.0, -35), Vector3(2.5, 0.3, 2.5), dash_color, "Plat_Dash_3")
_box_static(Vector3(10, 5.0, -35), Vector3(2.5, 0.3, 2.5), dash_color, "Plat_Dash_1", "metal")
_box_static(Vector3(18, 5.0, -35), Vector3(2.5, 0.3, 2.5), dash_color, "Plat_Dash_2", "metal")
_box_static(Vector3(26, 5.0, -35), Vector3(2.5, 0.3, 2.5), dash_color, "Plat_Dash_3", "metal")
# ── Wall Run Corridor ────────────────────────────────────────────────────────
@@ -185,13 +188,13 @@ func _build_wall_run_corridor() -> void:
# Two parallel walls — spaced for wall running
# Left wall
_box_static(Vector3(-5, 5, 15), Vector3(0.5, 10, 25), wall_color, "WallRun_Left")
_box_static(Vector3(-5, 5, 15), Vector3(0.5, 10, 25), wall_color, "WallRun_Left", "metal")
# Right wall
_box_static(Vector3(5, 5, 15), Vector3(0.5, 10, 25), wall_color, "WallRun_Right")
_box_static(Vector3(5, 5, 15), Vector3(0.5, 10, 25), wall_color, "WallRun_Right", "metal")
# Obstacles in corridor to jump over
_box_static(Vector3(0, 0.75, 10), Vector3(10, 1.5, 1), Color(0.6, 0.3, 0.3), "Obstacle_1")
_box_static(Vector3(0, 0.75, 20), Vector3(10, 1.5, 1), Color(0.6, 0.3, 0.3), "Obstacle_2")
_box_static(Vector3(0, 0.75, 10), Vector3(10, 1.5, 1), Color(0.6, 0.3, 0.3), "Obstacle_1", "wood")
_box_static(Vector3(0, 0.75, 20), Vector3(10, 1.5, 1), Color(0.6, 0.3, 0.3), "Obstacle_2", "wood")
# Gap in floor (forces wall run)
# Just raise the floor slightly on each side with a gap
@@ -209,8 +212,8 @@ func _build_speed_corridor() -> void:
var corridor_color := Color(0.2, 0.35, 0.2) # Dark green
# Side rails
_box_static(Vector3(30, 1.5, 0), Vector3(0.3, 3, 60), corridor_color, "SpeedRail_Left")
_box_static(Vector3(38, 1.5, 0), Vector3(0.3, 3, 60), corridor_color, "SpeedRail_Right")
_box_static(Vector3(30, 1.5, 0), Vector3(0.3, 3, 60), corridor_color, "SpeedRail_Left", "metal")
_box_static(Vector3(38, 1.5, 0), Vector3(0.3, 3, 60), corridor_color, "SpeedRail_Right", "metal")
# Speed bumps (small obstacles to hop over)
for i in range(-25, 26, 10):