171 lines
7.3 KiB
GDScript
171 lines
7.3 KiB
GDScript
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 },
|
|
# Loose aggregate — track ballast, a gravel path. Transmission is close to
|
|
# concrete's, because what a ray actually passes through is the bed under
|
|
# it, but REFLECTION is the opposite: a surface of loose stone scatters
|
|
# rather than reflects, so it returns little and returns it dull. Standing
|
|
# on the ballast should sound noticeably deader than standing on the road.
|
|
"gravel": { "trans_db": -17.0, "trans_cutoff": 500.0, "refl_db": -15.0, "refl_cutoff": 1800.0 },
|
|
# Turf and bare earth: the strongest absorbers in the set.
|
|
"grass": { "trans_db": -19.0, "trans_cutoff": 400.0, "refl_db": -18.0, "refl_cutoff": 1200.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"):
|
|
# Validated, not trusted. A level builder can set this meta to anything,
|
|
# and an unknown value used to reach `MATERIALS[mat]` directly and take
|
|
# the whole audio path down with it — a map tagged a slab "gravel"
|
|
# before gravel existed here, and every hitscan impact on it threw from
|
|
# inside occlusion(). A surface with an unrecognised material should
|
|
# sound like concrete, not stop the game making sound.
|
|
var tagged: String = str(node.get_meta("acoustic_material"))
|
|
if MATERIALS.has(tagged):
|
|
return tagged
|
|
push_warning("Acoustics: unknown acoustic_material '%s' on '%s'; using concrete."
|
|
% [tagged, node.name])
|
|
node.set_meta("acoustic_material", "concrete") # cache, warn once
|
|
return "concrete"
|
|
|
|
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)
|