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)