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
+150
View File
@@ -0,0 +1,150 @@
extends Object
class_name Acoustics
## Material-aware sound propagation.
##
## Occlusion: a ray from the listener to the source collects up to
## MAX_SURFACES occluding bodies; each surface's material adds transmission
## loss and lowers the lowpass cutoff. A gunshot through a wooden wall stays
## warm and fairly loud; through concrete it becomes a deep muffled thump;
## metal sits in between with more highs; glass barely muffles.
##
## Reflections: for loud one-shots (gunshots, explosions) rays fan out from
## the source to find nearby big surfaces; each close wall plays a quiet,
## delayed, material-filtered copy from the mirror point, so shots audibly
## slap back off structures and players can triangulate.
##
## Surfaces are classified by (in order):
## 1. `acoustic_material` meta on the collider (set by level builders)
## 2. node-name keywords (crate/wood, metal/steel, glass, brick/sand)
## 3. mesh material inference (metallic > 0.5 → metal)
## 4. default: concrete
const MAX_SURFACES := 3
const ENV_COLLISION_MASK := 1 # environment layer only (no players/projectiles)
const SPEED_OF_SOUND := 343.0
## Per-material acoustics:
## trans_db — transmission loss through one surface (dB)
## trans_cutoff — lowpass cutoff after passing through (Hz)
## refl_db — loss on reflection (dB)
## refl_cutoff — lowpass on the reflected copy (Hz)
const MATERIALS := {
"wood": { "trans_db": -8.0, "trans_cutoff": 1400.0, "refl_db": -11.0, "refl_cutoff": 2600.0 },
"brick": { "trans_db": -15.0, "trans_cutoff": 600.0, "refl_db": -8.0, "refl_cutoff": 3200.0 },
"concrete": { "trans_db": -18.0, "trans_cutoff": 450.0, "refl_db": -7.0, "refl_cutoff": 3600.0 },
"metal": { "trans_db": -11.0, "trans_cutoff": 1000.0, "refl_db": -4.0, "refl_cutoff": 6000.0 },
"glass": { "trans_db": -5.0, "trans_cutoff": 2600.0, "refl_db": -9.0, "refl_cutoff": 4200.0 },
}
const OPEN_CUTOFF := 20500.0
const DEFAULT_FILTER_DB := -24.0
# Horizontal fan + up, used to probe for reflecting walls around a source.
const REFLECT_DIRS: Array[Vector3] = [
Vector3(1, 0.05, 0), Vector3(-1, 0.05, 0), Vector3(0, 0.05, 1), Vector3(0, 0.05, -1),
Vector3(0.7, 0.05, 0.7), Vector3(-0.7, 0.05, 0.7), Vector3(0.7, 0.05, -0.7), Vector3(-0.7, 0.05, -0.7),
]
const REFLECT_RANGE := 22.0
const MAX_REFLECTIONS := 2
## Classify what a surface is made of, caching the answer on the node.
static func classify(collider: Object) -> String:
if collider == null or not (collider is Node):
return "concrete"
var node := collider as Node
if node.has_meta("acoustic_material"):
return node.get_meta("acoustic_material")
var mat := "concrete"
var n := node.name.to_lower()
if n.contains("wood") or n.contains("crate") or n.contains("plank") or n.contains("ramp"):
mat = "wood"
elif n.contains("metal") or n.contains("steel") or n.contains("pipe") or n.contains("container"):
mat = "metal"
elif n.contains("glass") or n.contains("window"):
mat = "glass"
elif n.contains("brick") or n.contains("sand") or n.contains("stone"):
mat = "brick"
else:
# Infer metal from a shiny mesh material if one is attached.
var mi := node.find_children("*", "MeshInstance3D", false, false)
if mi.size() > 0 and mi[0].mesh:
var m = mi[0].get_active_material(0)
if m is BaseMaterial3D and m.metallic > 0.5:
mat = "metal"
node.set_meta("acoustic_material", mat) # cache
return mat
## Accumulated occlusion between listener and source.
## Returns { "db": float, "cutoff": float, "surfaces": int }.
static func occlusion(world: World3D, listener: Vector3, source: Vector3) -> Dictionary:
var out := { "db": 0.0, "cutoff": OPEN_CUTOFF, "surfaces": 0 }
if world == null:
return out
var space := world.direct_space_state
if space == null:
return out
var dir := (source - listener)
if dir.length_squared() < 0.25:
return out
var from := listener
var excluded: Array[RID] = []
for i in MAX_SURFACES:
var q := PhysicsRayQueryParameters3D.create(from, source, ENV_COLLISION_MASK)
q.exclude = excluded
var hit := space.intersect_ray(q)
if hit.is_empty():
break
var mat: String = classify(hit.collider)
var props: Dictionary = MATERIALS[mat]
out.db += props.trans_db
out.cutoff = minf(out.cutoff, props.trans_cutoff)
out.surfaces += 1
excluded.append(hit.rid)
from = hit.position + dir.normalized() * 0.01
return out
## Apply occlusion to a positional player via its distance-attenuation filter
## (cutoff muffles, filter_db deepens the cut). Resets cleanly when clear.
static func apply_occlusion(p: AudioStreamPlayer3D, occ: Dictionary) -> void:
if occ.surfaces == 0:
p.attenuation_filter_cutoff_hz = OPEN_CUTOFF
p.attenuation_filter_db = DEFAULT_FILTER_DB
else:
p.attenuation_filter_cutoff_hz = occ.cutoff
# The filter's shelf reduction carries the transmission loss.
p.attenuation_filter_db = clampf(DEFAULT_FILTER_DB + occ.db * 2.0, -80.0, -24.0)
## Find up to MAX_REFLECTIONS nearby surfaces that would bounce a loud sound
## toward the listener. Returns [{ "point", "delay", "db", "cutoff" }].
static func reflections(world: World3D, source: Vector3, listener: Vector3) -> Array:
var found: Array = []
if world == null or world.direct_space_state == null:
return found
var space := world.direct_space_state
var direct := source.distance_to(listener)
for d in REFLECT_DIRS:
var q := PhysicsRayQueryParameters3D.create(source, source + d * REFLECT_RANGE, ENV_COLLISION_MASK)
var hit := space.intersect_ray(q)
if hit.is_empty():
continue
var point: Vector3 = hit.position
var path := source.distance_to(point) + point.distance_to(listener)
var delay := (path - direct) / SPEED_OF_SOUND
# Too-early reflections merge with the direct sound; too-late is noise.
if delay < 0.03 or delay > 0.35:
continue
var props: Dictionary = MATERIALS[classify(hit.collider)]
found.append({
"point": point + hit.normal * 0.2,
"delay": delay,
"db": props.refl_db,
"cutoff": props.refl_cutoff,
})
found.sort_custom(func(a, b): return a.delay < b.delay)
return found.slice(0, MAX_REFLECTIONS)
+1
View File
@@ -0,0 +1 @@
uid://bnukbpqgnl4j
+77 -1
View File
@@ -40,6 +40,20 @@ const BUS_HINTS := {
"reload": "Weapons",
}
## weapon_name (as broadcast in fire RPCs) -> fire sound id.
const WEAPON_SOUND_IDS := {
"AK-47": "ak47_fire", "M4": "m4_fire", "MP7": "mp7_fire",
"DMR": "dmr_fire", "AWP": "awp_fire",
"Double Barrel Shotgun": "shotgun_fire", "Nail Gun": "nailgun_fire",
"Plasma Gun": "plasma_fire", "Rocket Launcher": "rocket_fire",
"Rocket Swarm": "swarm_fire", "Mortar": "mortar_fire",
"Knife": "knife_swing",
}
## Sounds loud enough to slap back off nearby walls (early reflections).
const OCCLUSION_TICK := 0.12
var _occlusion_accum := 0.0
func _ready() -> void:
_setup_buses()
@@ -48,6 +62,33 @@ func _ready() -> void:
print("AudioManager: %d sounds registered" % _library.size())
func _physics_process(delta: float) -> void:
# Re-filter playing positional sounds as the listener/source move so
# walking behind a wall audibly muffles loops and long tails too.
_occlusion_accum += delta
if _occlusion_accum < OCCLUSION_TICK:
return
_occlusion_accum = 0.0
var cam := get_viewport().get_camera_3d()
if cam == null:
return
var listener := cam.global_position
for p in _pool_3d:
if p.playing and not p.get_meta("is_reflection", false):
Acoustics.apply_occlusion(p, Acoustics.occlusion(
cam.get_world_3d(), listener, p.global_position))
# External looping players (projectile flight sounds, etc.) opt in by
# joining this group.
for node in get_tree().get_nodes_in_group("acoustic_occluded"):
if node is AudioStreamPlayer3D and node.playing and node.is_inside_tree():
Acoustics.apply_occlusion(node, Acoustics.occlusion(
cam.get_world_3d(), listener, node.global_position))
func weapon_sound_id(weapon_name: String) -> String:
return WEAPON_SOUND_IDS.get(weapon_name, "")
# ── Bus layout ────────────────────────────────────────────────────────────────
func _setup_buses() -> void:
@@ -156,7 +197,9 @@ func _build_pools() -> void:
_pool_2d.append(p)
## Play a positional sound. Safe to call every frame — players are pooled.
## Play a positional sound with material-aware occlusion; loud sounds also
## spawn early reflections off nearby surfaces so they can be located.
## Safe to call every frame — players are pooled.
func play_3d(id: String, global_pos: Vector3, volume_db_offset: float = 0.0,
pitch_scale: float = 1.0) -> AudioStreamPlayer3D:
var entry = _library.get(id)
@@ -169,10 +212,43 @@ func play_3d(id: String, global_pos: Vector3, volume_db_offset: float = 0.0,
player.volume_db = entry["volume_db"] + volume_db_offset
player.pitch_scale = pitch_scale * randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"])
player.global_position = global_pos
player.set_meta("is_reflection", false)
var cam := get_viewport().get_camera_3d()
if cam:
var occ := Acoustics.occlusion(cam.get_world_3d(), cam.global_position, global_pos)
Acoustics.apply_occlusion(player, occ)
if _is_loud(id):
_spawn_reflections(player.stream, global_pos, cam, entry, volume_db_offset)
else:
Acoustics.apply_occlusion(player, { "db": 0.0, "cutoff": Acoustics.OPEN_CUTOFF, "surfaces": 0 })
player.play()
return player
func _is_loud(id: String) -> bool:
return id.findn("fire") != -1 or id.findn("explosion") != -1
## Delayed, filtered copies of a loud sound from nearby wall mirror points.
func _spawn_reflections(stream: AudioStream, source: Vector3, cam: Camera3D,
entry: Dictionary, volume_db_offset: float) -> void:
var refls := Acoustics.reflections(cam.get_world_3d(), source, cam.global_position)
for r in refls:
get_tree().create_timer(r.delay).timeout.connect(func():
var p := _pool_3d[_next_3d]
_next_3d = (_next_3d + 1) % POOL_SIZE_3D
p.stream = stream
p.bus = entry["bus"]
p.volume_db = entry["volume_db"] + volume_db_offset + r.db
p.pitch_scale = randf_range(0.97, 1.0)
p.global_position = r.point
p.set_meta("is_reflection", true)
p.attenuation_filter_cutoff_hz = r.cutoff
p.attenuation_filter_db = Acoustics.DEFAULT_FILTER_DB
p.play()
)
## Play a non-positional sound (UI clicks, local confirms).
func play_ui(id: String, volume_db_offset: float = 0.0) -> AudioStreamPlayer:
var entry = _library.get(id)