Muffling Overview

To determine how muffled an emitter is, you must cast occlusion or permeation rays towards it from your main emitter.

var listener = new Emitter()
{
    OcclusionRayCount = 1024,
    OcclusionBounceCount = 8,
    PermeationRayCount = 128,
    PermeationBounceCount = 3,
};

context.AddEmitter(listener);

var target = new Emitter();

context.AddEmitter(target);

// Enable muffling
listener.AddTarget(target);

AddTarget will throw an exception if OcclusionRayCount and PermeationRayCount are 0 on the listener

Occlusion

Occlusion rays bounce around the environment and lose energy based on the materials they hit, and the distance they travel. On each bounce they check for line-of-sight with each of the emitter's targets.

Once line-of-sight is found, the ray stops bouncing. Occlusion rays only find the shortest path to each target - they are designed for speed.

The maximum energy an occlusion ray can have is 1.0, which means it lost no energy before line-of-sight with the target (material absorption is 0, and air absorption is disabled).

The minimum energy it can have is 0.0, which means it did not find line-of-sight with the target.

Permeation

Permeation rays are similar to occlusion rays, but produce more realistic results at the expense of speed. They bounce around the environment and do not lose energy based on materials. Instead, on each bounce they cast a ray directly towards each target, travelling through primitives and losing energy based on:

The maximum energy a permeation ray can have is 1.0 x permeationBounceCount, because it accumulates energy on each bounce.

The minimum energy a permeation ray can have is 0.0, which means the geometry is so thick that no energy permeated through.

Converting Energy to Low Pass Filters

Occlusion and permeation energy is converted to low-frequency and high-frequency filter gains via the Emitter.GainFormula callback. This function is invoked twice, once for low-frequency energy values and once with high-frequency energy values.

Since not all rays will discover an emitter, I recommend setting a threshold (e.g. 15% in the example below) that must be met for an emitter to be at full volume:

listener.GainFormula = (
    bool lowFrequency,
    int occlusionRayCount,
    int permeationRayCount,
    int permeationBounceCount,
    float occlusionEnergy,
    float permeationEnergy
) => 
{
    float gain = 0.0f;

    // Must check if > 0, as occlusion might be disabled but permeation is enabled
    if (occlusionRayCount > 0)
    {
        // 15% of energy is considered enough for the emitter to be at full volume
        float rayThreshold = 0.15f * occlusionRayCount;
        gain += occlusionEnergy / rayThreshold;
    }

    if (permeationRayCount > 0)
    {
        float rayThreshold = 0.15f * permeationRayCount * permeationBounceCount;
        gain += permeationEnergy / rayThreshold;
    }

    return MathF.Min(1, gain);
}

All energy in all occlusion rays is accumulated into an occlusionEnergy field, which is in the range 0.0 to occlusionRayCount.

All energy in all permeation rays is accumulated into an permeationEnergy field, which is in the range 0.0 to permeationRayCount * permeationBounceCount.

Accessing Low Pass Filters

When a target emitter is first raytraced, its low-pass filter can be accessed via a callback:

var target = new Emitter();

target.OnRaytracedByAnotherEmitter = (Emitter other) =>
{
    var filter = other.GetTargetFilter(target);
    
    // These fields contain the results of GainFormula
    //  and range from 0.0f to 1.0f
    var gainLF = filter.gainLF;
    var gainHF = filter.gainLF;
    
    // PSUEDOCODE - apply the gains to a low pass filter
    Godot.ApplyLowPassFilter(sound, gainLF, gainHF);
    
    // Play the sound AFTER setting the filter, so it's muffled
    //  correctly from the beginning
    Godot.PlaySound(sound);
}

context.AddEmitter(target);

Now that the sound is playing, you can update the filter every frame:

if (listener.HasRaytracedTarget(target))
{
    var filter = listener.GetTargetFilter(target);
    
    var gainLF = filter.gainLF;
    var gainHF = filter.gainLF;
    
    // PSEUDOCODE
    Godot.UpdateLowPassFilter(sound, gainLF, gainHF);
};

Optimisations

For short sounds like gunfire and footsteps, I recommend deleting the emitter once it has been raytraced. It's not worth continuously updating an emitter that only plays a short sound:

var target = new Emitter();

target.OnRaytracedByAnotherEmitter = (Emitter Other) => ...

// This is invoked after OnRaytracedByAnotherEmitter()
target.OnRaytracingComplete = () =>
{
    context.RemoveEmitter(target);
};

context.RemoveEmitter(target);

If an entity in your game will play many sounds (e.g. an enemy playing footsteps), I recommend setting the emitter on the entity itself, and re-use it for multiple sounds.

In this case you wouldn't play the sound in the OnRaytracedByAnotherEmitter() callback - instead you'd access the filter via listener.GetTargetFilter(target) each time you play a foostep / gunfire sound.

listener.GetTargetFilter() will throw an exception if you access it before the listener has raytraced the emitter. It's worth waiting for the OnRaytracedByAnotherEmitter() callback to fire first, or check listener.HasRaytracedTarget(target) when playing each footstep sound