PulsarPulsar/ Blog
← Back to blog

rust

Quasar: A Lock-Free Spatial Audio Engine in Rust

How we built a modular spatial audio engine where arbitrary multi-channel sources route through a patch bay into positioned world emitters, spatialized per-listener through a zero-alloc DSP pipeline without a single mutex on the audio thread.

··21 min read

The Cathedral Problem

A 60-meter Gothic cathedral. Stone floor, vaulted ceiling, eight speakers bolted to the walls. You load an 8-channel audio file. Channel 3 is Back Left in the WAV. The cathedral sends that channel to the Sub/LFE speaker. Channel 6, Side Left in the file, goes to Back Left on the device. No two mappings line up.

The listener walks through the nave. Position changes. Heading changes. The spatialization has to track both in real time. Occlusion from the stone columns. Early reflections off the floor. Reverb from the volume of the space. All of it computed at 48 kHz with a 256-sample buffer.

Five milliseconds and change before the buffer underruns. In that window, no allocations. No mutexes. No file I/O. No ray tracing through a BVH with 10,000 triangles.

Quasar is the engine we built for this. It runs inside the Pulsar game engine. The design separates three concerns. Sources are raw multi-channel audio. Scene outputs are positioned world-space emitters. Listeners have a position, a heading, and a physical speaker layout. The only connection between them is an explicit pull: ChannelPull(source_id, channel, gain_db). You wire them up at startup. You rewire them at runtime. The audio thread never notices.


The Architecture in One Diagram

Three registries. Sources hold multichannel audio data. Scene outputs hold world positions and a list of pulls. Listeners hold a position, a heading, and a physical output layout. The spatial compute phase resolves every pair of scene output and listener. The audio thread renders the result through a zero-alloc DSP pipeline. Every layer is independently hot-swappable. Add a source mid-scene. Remove a scene output. Rewire a pull. No glitch.


The Scene Pipeline

The first version of Quasar took a flat list of SpatialQuery pairs. One source position, one listener position, one set of SpatialCoefficients back. The audio thread consumed those coefficients through a fixed AudioNodeGraph. One input buffer per source. One output buffer. No concept of physical speaker layouts. No listener position. No concept of pulling individual channels from a multi-channel source into a specific world emitter.

The scene pipeline replaced all of that with three phases.

Phase one runs on the API thread. You load sources. You create scene outputs. You add listeners. You connect pulls:

Phase two runs on the compute thread at 15-30 Hz. update_scene_spatial() iterates every scene output and every listener. For each pair, it builds a SpatialQuery and sends it through the hybrid sampler. The sampler returns a SpatialQueryResult: direct path parameters, early reflections, late reverb estimate. Those get published into the lock-free triple buffer.

Phase three runs on the audio thread at 48 kHz. process_audio_scene() takes one source buffer per loaded source and one output buffer per listener. The patch bay sums channel pulls into mono per scene output. The spatial DSP chain processes each mono buffer: occlusion, early reflections, late reverb. The listener decode stage runs VBAP to map the spatialized mono onto the listener's physical speaker layout.

No allocation. No locking. The buffer sizes are fixed at compile time. The scratch buffers are sized to the maximum number of scene outputs. Everything exists before the first callback fires.


Live Remapping

A scene output is a position and a list of pulls. A pull is three values: a source ID, a channel index, and a gain in dB. Change the position, the compute thread resolves it on the next frame. Change the pull list, the engine rebuilds the patch bay at the next config opportunity. No DSP graph tear-down. No delay line flush. No pop.

Two calls to disconnect, two calls to connect. The aux channels swap. The cathedral's speaker layout changes without stopping audio. The patch bay rebuilds, the crossfaders smooth the transition over 15 milliseconds, and the listener hears a seamless rewire.

This extends to everything. Add a listener mid-scene. Remove a scene output. Change a pull's gain. None of it requires the audio thread to stop. None of it produces a click. The triple buffer decouples the config path from the real-time path by design.


The Triple Buffer

Three threads touch Quasar's state. The game thread owns the scene and the listener positions. The compute thread runs spatial queries. The audio thread processes samples. The boundary between the compute thread and the audio thread is the lock-free contract.

ParameterTripleBuffer holds three slots of SpatialCoefficients behind UnsafeCell. Three atomic index pointers track which slot is the write slot, which is the staging slot, and which is the read slot. A monotonically increasing version counter lets the consumer detect new data without a lock.

The producer calls begin_write(). This returns a &mut SpatialCoefficients pointing at whatever slot write_index owns at that moment. The producer mutates the coefficients. It calls end_write(). That stamps the version counter into the slot and atomically swaps write_index with staging_index. After the swap, the slot the producer was just writing to becomes the staging slot. Available for the consumer to claim on its next update().

The consumer path is update() then read(). update() atomically swaps staging_index with read_index. This claims whatever the producer has published since the last read. read() returns a &SpatialCoefficients pointing at the now-stable read slot.

Three slots, three indices, always pointing to distinct buffers. The producer and consumer never touch the same slot at the same time. No mutexes. No atomics on the hot path except the two swaps.

The coefficients themselves carry everything the audio thread needs:

Band8 is the universal frequency representation. Eight floats covering the standard octave bands from 62.5 Hz to 8 kHz. Every spatial parameter in the engine is frequency-dependent. Direct attenuation. Reverb time. Material absorption. The audio thread receives these as pre-computed coefficients. It never runs a ray intersection. It never evaluates a material formula. All of that work happens on the compute thread.

Between the triple buffer read and the DSP graph sits an EqualPowerCrossfader. The compute thread publishes new coefficients. The crossfader blends from the old values to the new values over a configurable window. Typically 10-20 milliseconds. The blend uses cosine and sine trajectories:

g0(t)=cos(πt2),g1(t)=sin(πt2)g_0(t) = \cos\left(\frac{\pi t}{2}\right),\qquad g_1(t) = \sin\left(\frac{\pi t}{2}\right)

Constant power means g02+g12=1g_0^2 + g_1^2 = 1. No volume dip. No volume spike. Just a smooth transition.


The Hybrid Sampler

HybridProbeSampler sits between the compute backend and the engine. It dispatches each spatial query according to the active strategy. Three strategies exist. Each one trades accuracy for cost in a different way.

BakedOnly samples a probe grid at the listener position. It computes inverse-distance attenuation from the source. It returns a SpatialQueryResult with no early reflections. Zero ray intersections. Zero material evaluations. The cheapest path. Good for static environments where the acoustics are pre-baked and nothing moves.

RealTimeOnly delegates to the IAcousticComputeBackend trait. The backend traces rays through the scene. It evaluates material absorption at each hit. It computes specular reflections. It estimates statistical reverb from the room geometry. This path supports dynamic geometry. Moving walls. Collapsing structures. Anything changes the acoustic environment frame to frame.

HybridBlend calls the real-time backend for direct path and early reflections. Then it overlays the late reverb T60 from the probe grid. Direct path and early reflections are where dynamic behavior matters most. A door opening changes the direct path instantly. The listener hears the difference. The late reverb tail is less position-sensitive within a room. A baked T60 from a probe grid is nearly indistinguishable from a real-time estimate. Sampling it costs nothing.

The compute thread calls resolve() for each active source at 15-30 Hz. It publishes the result through the triple buffer. The audio thread reads the latest coefficients and renders the next block. No coordination. No waiting.


The Baked Path: Probe Grids

Nebula is the companion baking tool. It takes a static scene, places acoustic probes at regular intervals, and bakes impulse responses at each probe using path tracing. The output is a set of AcousticProbe points. Position. Per-band RT60. A time-series of 8-band energy samples.

Quasar consumes this data through AcousticProbeGrid. The grid is a 3D axis-aligned structure with grid_origin, grid_spacing, and grid_dims. Probes are stored in a flat Vec<AcousticProbe> in row-major order. X varies fastest, then y, then z:

index=zdydx+ydx+x\text{index} = z \cdot d_y \cdot d_x + y \cdot d_x + x

Given a listener position inside the grid, cell_index() computes the enclosing cell [cx, cy, cz] and returns the eight corner probe indices. trilinear_interpolate() computes fractional weights wx, wy, wz within the cell and blends the eight corner values.

The interpolation_quality field peaks at 1.0 at the center of a cell and falls to 0.0 at the edges. If the listener is near a cell boundary, the interpolation is less reliable. The engine can boost the blend rate or fall back to a nearest-probe sample.

The nebula import bridge lives behind the nebula-import feature flag. It deserializes bincode-format bake files and transposes the 8-band impulse response data into Vec<Band8>. For irregularly-spaced probe sets, the grid dimensions are set to [n, 1, 1] and the sampler falls back to nearest-probe lookup.


The Compute Backends

IAcousticComputeBackend defines the interface any real-time backend must implement:

Three implementations exist.

HardwareAcceleratorStub returns dummy coefficients. Inverse-distance attenuation, no early reflections, a fixed 0.5-second RT60. The engine compiles and runs without any real backend selected. Useful for testing. Useful for platforms where no compute backend is available yet.

CpuSimdComputeBackend is the production CPU path. It builds a BVH from the scene's triangle mesh using the surface area heuristic.

cost=1.0+left_areai+right_area(ni)n\text{cost} = 1.0 + \frac{\text{left\_area} \cdot i + \text{right\_area} \cdot (n - i)}{n}

Leaf nodes hold up to 4 triangles. Internal nodes store an AABB, child pointers, and the split axis. The BVH traversal is standard. Test the AABB. Recurse into children if hit. Return the closest intersection.

Ray-triangle intersection uses Mller-Trumbore. query_spatial processes sources in parallel with rayon::par_iter(). For each source it casts a shadow ray from the listener to the source for occlusion testing. Then it traces recursive specular reflections up to order 3 for early reflections. The late reverb estimate uses Sabine and Eyring statistical formulas:

αˉ[b]=α[b]AAtotal\bar\alpha[b] = \frac{\sum \alpha[b] \cdot A_\triangle}{A_\text{total}} T60,S[b]=0.161VSαˉ[b]T_{60,S}[b] = \frac{0.161 \cdot V}{S \cdot \bar\alpha[b]} T60,E[b]=0.161VSln(1αˉ[b])T_{60,E}[b] = \frac{0.161 \cdot V}{-S \cdot \ln(1 - \bar\alpha[b])} T60[b]=min(T60,S,T60,E),  clamped to [0.1,10.0]T_{60}[b] = \min(T_{60,S},\, T_{60,E}),\;\text{clamped to }[0.1,\, 10.0]

Air absorption follows ISO 9613-1. Oxygen and nitrogen relaxation frequencies are computed from temperature and humidity. Per-band attenuation is eα[b]de^{-\alpha[b] \cdot d}.

WgpuComputeBackend dispatches the same ray tracing work to the GPU through WGSL compute shaders. The dispatch layout is one workgroup per source-listener pair. 64 threads per workgroup. Each thread traces one stochastic ray per iteration. Accumulated reflection energy and per-band absorption go into a shared output buffer.

The WGSL shader contains a full Mller-Trumbore implementation and a PCG random number generator:

The output uses double-buffered staging. Two output buffers and two staging buffers. Toggled atomically. The CPU reads the previous frame's results while the GPU processes the current frame. Material evaluation runs on the GPU side through a switch on model_id, with identical formulas to the CPU evaluators.


The Material System

Materials in Quasar are not hardcoded structs. They are dynamic physical transfer functions composed from a MaterialModelId and a raw byte-aligned MaterialParameterBuffer. The same byte buffer can be cast to a typed struct on the CPU via bytemuck or blitted directly to a GPU storage buffer.

Three material models are built in.

Tabular (model ID 1) is a lookup table with 24 f32 values. Absorption, scattering, and transmission for each of the 8 octave bands. 96 bytes total. No computation. A direct read. Good for artist-authored materials where the acoustic properties are measured or tuned by hand.

Delany-Bazley (model ID 2) implements the empirical porous absorber model. Parameters are flow resistivity in Rayls/m and thickness in meters. Flow resistivity typically ranges from 1,000 to 100,000. Thickness ranges from centimeters to tens of centimeters. For each octave band frequency, the model computes complex characteristic impedance and propagation constant. Then it derives surface impedance. Then absorption from the reflection coefficient:

E=ρ0fRsZc=Z0(1+0.0571E0.754j0.087E0.732)k=ωc0(1+0.0978E0.700j0.189E0.595)Zs=jZccot(kd)R=ZsZ0Zs+Z0α=1R2\begin{aligned} E &= \frac{\rho_0 \cdot f}{R_s} \\ Z_c &= Z_0 \left(1 + 0.0571 E^{-0.754} - j \cdot 0.087 \, E^{-0.732}\right) \\ k &= \frac{\omega}{c_0} \left(1 + 0.0978 E^{-0.700} - j \cdot 0.189 \, E^{-0.595}\right) \\ Z_s &= -j \, Z_c \, \cot(k \cdot d) \\ R &= \frac{Z_s - Z_0}{Z_s + Z_0} \\ \alpha &= 1 - |R|^2 \end{aligned}

The complex cotangent is computed manually. The WGSL shader has the same arithmetic. A 5 cm panel with 20,000 Rayls/m absorbs mostly high frequencies. A 10 cm panel with 10,000 Rayls/m absorbs across the full spectrum.

Resonant Panel (model ID 3) models membrane absorbers as mass-spring systems. Parameters are panel mass in kg/m and cavity depth in meters. Panel mass is typically 1-20 kg/m. Cavity depth is typically 0.02-0.5 meters.

f0=c02πρ0md,α(f)=0.951+Q2(ff0f0f)2f_0 = \frac{c_0}{2\pi} \sqrt{\frac{\rho_0}{m \cdot d}},\qquad \alpha(f) = \frac{0.95}{1 + Q^2 \left(\frac{f}{f_0} - \frac{f_0}{f}\right)^2}

Thin plywood with an air gap. Narrow-band absorption centered at the resonant frequency.

AcousticMaterialRegistry manages instances at runtime. Each instance is a (model_id, parameter_buffer) pair stored in a Vec behind an RwLock. Instances are referenced by a u32 handle, the index into the vector. The registry supports hot-swapping. Updating an instance's parameter buffer takes effect immediately without rebuilding any acceleration structure.

The next query_spatial() call uses the new parameter values. The BVH does not need to be rebuilt.


The DSP Graph

The audio thread runs the DSP pipeline for every 256-sample block. Every node pre-allocates its state at construction time. The hot path, the process() method called for every block, never calls alloc. It never takes a lock. It never touches the heap.

AudioBuffer is a fixed-size inline array:

32 channels at 256 samples each. 32 KiB total. On the stack. No allocation at construction. No allocation at copy. No allocation at clear. The graph owns scratch buffers sized to the maximum number of scene outputs, allocated once at graph construction.

The pipeline per block works in five stages.

Stage one publishes the latest triple-buffer coefficients. An atomic swap, zero copy.

Stage two smooths per-pair coefficients through equal-power crossfaders. The compute thread may have published new data since the last block. The crossfader blends from the old coefficients to the new ones over the configured fade window.

Stage three runs the patch bay. Each scene output has a list of pulls. The patch bay iterates those pulls. For each pull it multiplies the source channel samples by the linear gain and accumulates into the scene output's mono buffer. Out-of-range source indices and channel indices are silently skipped. Defensive. No panic.

Stage four is the spatial render. Run once per scene output. Reference listener is listener zero. The occlusion node applies per-band attenuation and fractional delay. Eight biquad filters per channel, one per octave band. A Hermite-interpolating delay line for the direct path delay. The per-band attenuation is converted to lowpass cutoff:

fc[b]=fcentre[b]attenuation[b]+20Hzf_c[b] = f_{\text{centre}}[b] \cdot \sqrt{\text{attenuation}[b]} + 20\,\text{Hz}

Lower attenuation means more occlusion. The cutoff shifts lower. High frequencies roll off.

The early reflection node implements a multi-tap delay. Input audio is downmixed to mono and pushed through a shared delay line. Each early reflection from the spatial query becomes a tap with a fractional delay and a stereo pan:

pan=azimuthπ,θ=π2(pan+1)0.5\text{pan} = \frac{\text{azimuth}}{\pi},\qquad \theta = \frac{\pi}{2}(\text{pan} + 1) \cdot 0.5 gL=cos(θ),gR=sin(θ)g_L = \cos(\theta),\qquad g_R = \sin(\theta)

The late reverb node is the FDN. 16 delay lines with pairwise coprime lengths spanning 2 ms to 73 ms at 48 kHz:

Each delay line has a 0.5 Hz sinusoidal LFO adding +/- 2 samples of delay modulation. This smooths out metallic resonances. Each line also has a one-pole lowpass damping filter:

damping=exp(3.0T60fs)\text{damping} = \exp\left(-\frac{3.0}{\overline{T}_{60} \cdot f_s}\right)

The feedback matrix is a Householder reflection:

This matrix is orthogonal. HHT=IHH^T = I. Energy is preserved in the feedback loop. Combined with a loop gain of 0.85, below unity, the FDN produces a dense reverb tail that decays smoothly without ringing.

Stage five is the listener decode. VBAP maps the spatialized mono onto the listener's physical speaker layout. The listener's heading rotates the world-space azimuth so a source the listener faces comes from the front speakers regardless of world rotation.

Each scene output's mono is decoded onto every connected speaker. The VBAP gains are computed fresh per block. The speaker positions come from the listener's physical_layout field. Stereo. 5.1. 7.1.4. Quad. Custom. HRTF falls back to stereo for now.


What It Costs

On a Ryzen 9 7950X, the CPU SIMD backend resolves one source against a 10,000-triangle scene in about 0.2 ms. Three bounces of specular reflections. At 30 Hz with 64 sources, the compute budget is roughly 12.8 ms per frame. Headroom remains for BVH updates when geometry changes.

The GPU backend dispatches 256 rays per source-listener pair in a single workgroup. 64 threads per workgroup. Each thread traces 4 rays. The dispatch for 32 sources produces 32 workgroups. Negligible utilization on any modern GPU. At 256 sources with 16 rays each, a single dispatch of 64 workgroups completes in about 0.1-0.3 ms on an RTX 4090.

The audio thread processes the full DSP chain for one scene output in approximately 0.015 ms per 256-sample block. Directivity, occlusion, early reflections, FDN reverb, stereo decode. At 8 outputs per listener, the total DSP cost per block is about 0.12 ms. Headroom of about 5.2 ms in the 5.33 ms budget.

The FDN reverb uses 16 delay lines of up to 3491 samples each. About 218 KB of state per channel. With pre-delay and the Hermite-interpolating delay lines for early reflections, the total DSP memory per output is roughly 256 KB. All pre-allocated.


The Pattern

Sources hold multichannel audio data. Scene outputs hold world positions and a list of pulls. Listeners hold a position, a heading, and a physical speaker layout. The only connection between them is an explicit pull. Not a bus assignment. Not a send level. Not an audio graph connection. A pull.

The cathedral demo has 8 speakers and the WAV file has 8 channels. Channel 3 in the WAV goes to physical output 5 on the device. One ChannelPull per output. When the G key is pressed, two disconnect_pull calls and two connect_pull calls swap the aux channels. No clicks. No pops. No DSP graph rebuild.

This extends beyond simple remapping. Add a second listener with a different physical layout. The same scene outputs are decoded twice. Once for the stereo headphones. Once for the 5.1 speakers. The compute thread resolves each pair independently. The audio thread writes each listener's output buffer separately. The pull list is shared. The spatialization is per-listener.

Load a second source mid-scene. A stereo dialog track alongside the 8-channel ambient bed. Create a new scene output for the dialog source. Pull both channels into it. Position it near the altar. The compute thread starts resolving it on the next frame. The patch bay includes it in the next mix. No interruption.

The decoupling matters in practice. The demo cathedral has 60 meters of nave, 12 columns, 8 speakers, and one listener walking through it. The engine renders spatial audio for every speaker on every frame. It never allocates. It never locks. It never drops a sample.

Everything is at github.com/Far-Beyond-Pulsar/Quasar.