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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjfe6cnowval
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bnukbpqgnl4j
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -500,6 +501,14 @@ func rpc_play_fire_effects(origin: Vector3, target_or_dir: Vector3, _weapon_name
|
||||
# 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
|
||||
|
||||
@@ -72,6 +72,7 @@ func _setup_effects() -> void:
|
||||
flight_sound.max_db = 0.0
|
||||
flight_sound.max_distance = 45.0
|
||||
flight_sound.volume_db = -8.0
|
||||
flight_sound.add_to_group("acoustic_occluded") # muffles behind walls
|
||||
add_child(flight_sound)
|
||||
flight_sound.play()
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ func _start_homing_effects() -> void:
|
||||
flight_sound.max_db = 0.0
|
||||
flight_sound.max_distance = 45.0
|
||||
flight_sound.volume_db = -10.0
|
||||
flight_sound.add_to_group("acoustic_occluded") # muffles behind walls
|
||||
add_child(flight_sound)
|
||||
flight_sound.play()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user