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:
co-authored by
Claude Fable 5
parent
44460b52ae
commit
a51595e3b9
@@ -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
|
||||
Reference in New Issue
Block a user