Sound spatialization and propagation library.
No external dependencies for the main library, only Tests and Examples (TBD) require a few external libraries.
JPL Spatial library is used as a sound spatialization solution in Hazel Engine.
If you have access to Hazel Engine code, you can check how JPL Spatial is integrated on dev or audio branch.
There is aslo JPL Spatial Application, showcasing and visualizing features of JPL Spatial.
JPL Spatial implementation of VBAP/MDAP handles source elevation and height channels.
Supported source channel layouts
- Mono
- Stereo
- LCR
- Quad
- Surround 4.1
- Surround 5.0
- Surround 5.1
- Surround 6.0
- Surround 6.1
- Surround 7.0
- Surround 7.1
- Octagonal
Supported target/output channel layouts
- Stereo
- LCR
- Quad
- Surround 4.1
- Surround 5.0
- Surround 6.0
- Surround 5.1
- Surround 6.1
- Surround 7.0
- Surround 7.1
- Octagonal
- Surround 5.0.2
- Surround 5.1.2
- Surround 5.0.4
- Surround 5.1.4
- Surround 7.0.2
- Surround 7.1.2
- Surround 7.0.4
- Surround 7.1.4
- Surround 7.0.6
- Surround 7.1.6
- Surround 9.0.4
- Surround 9.1.4
- Surround 9.0.6
- Surround 9.1.6
VBAPanner2D
VBAPanner3D (layouts with top channels)
(as per Dolby Atmos surround, but LFE is always 4th channel)
Where possible and reasonable std::pmr containers are used.
Polymorphic memory resource can be provided to JPL Spatial containers and classes.
Fast simd math library is provideded and used extensively to speed up computation where reasonable.
Decisions to sacrifice code readability in favor of performance are driven by benchmarks.
- JPL Spatial implements interface to use you own Ray Tracer and Vec3 type (though minimal Vec3 class provided)
- In the future, JPL Spatial's own Ray Tracer may be implemented
- Interpolating Fractional Delay Lines
- 4th order Linkwitz-Riley Crossover
- Filter Delay Network (FDN) Reverb
- SIMD math library
- Acoustic Materials definition and a list of common materials to be used for acoustics simulation
- Air Absorption utilities for pure-tone and broadband estimation
- Channel Map utility for handling different kinds of channel layouts
- ...a bunch of other utilities (math, algorithms, containers, etc.)
-
Specular Early Reflections are traced using Image Source method and panned by VBAP (single directoin per ER)
-
Late Reverberation Time can be estimated with provided Eyring or Sabine equations based on environment properties.
- renders propagation delay and doppler effect with Interpolating Delay Lines
- propagation filtering with 4-band Crossover Filter (mainly air absorption)
- panning handled by MDAP
- can render any arbitrary number of early reflection paths, which can safely change dynamically
- each ER path is rendered as a Tap of Interpolated Delay Line
- each ER has 4-band Crossover Filter to process propagation filtering (reflected surface absorption & air absorption)
- each ER is panned with VBAP
- renders late reverberation with 16th order FDN
- the FDN is using 4-band Crossover Filter as decay filter
- *Reverberation timeT (RT60) can be set in the same 4 frequency bands used throughout JPL Spatial
Example of integrating vairous components of JPL Spatial are provided as various Services API
- Spatial Manager (top level interface managing Sources and Services)
- Panning Service
- Direct Path Service (for now handles just distance and angle attenuation)
Utilities provided for creative distance attenuation use-cases.
- Custom Function
- Curves
- Predefined models:
- Inverse
- Linear
- Exponential
- Windows (Desktop) x64/ARM64
- Linux (tested on Ubuntu) x64/ARM64
- macOS x64/ARM64
Details
Initializing VBAPPanner, SourceLayout and querying target channel gains for a source direction:
#include <JPLSpatial/ChannelMap.h>
#include <JPLSpatial/Panning/VBAPanning2D.h>
#include <array>
...
using PannerType = typename JPL::VBAPanner2D<>;
using SourceLayout = typename PannerType::SourceLayoutType;
using ChannelGains = std::array<float, 2>
const auto targetChannelMap = JPL::ChannelMap::FromChannelMask(JPL::ChannelMask::Stereo)
const auto sourceChannelMap = JPL::ChannelMap::FromChannelMask(JPL::ChannelMask::Mono)
PannerType panner;
panner.InitializeLUT(targetChannelMap);
SourceLayout sourceLayout;
panner.InitializeSourceLayout(sourceChannelMap, sourceLayout)
...
// `outGains` is going to be filled with the computed panning gains
// based on input parameters
void GetChannelGains(
const SourceLayout& sourceLayout,
Vec3 sourceDirection,
float focus, float spread
ChannelGains& outGains)
{
if (panner.IsInitialized())
{
typename PannerType::PanUpdateData positionData
{
.SourceDirection = sourceDirection,
.Focus = focus,
.Spread = spread
};
panner.ProcessVBAPData(
sourceLayout,
positionData,
outGains);
}
}
Details
A typical SpatialManager workflow wires together high-level constructs such as SourceInitParameters for allocating the source,
Position for spatial placement, and an AttenuationCurve for distance rolloff.
#include <JPLSpatial/ChannelMap.h>
#include <JPLSpatial/Math/MinimalVec3.h>
#include <JPLSpatial/SpatialManager.h>
using Vec3 = JPL::MinimalVec3;
using Spatializer = JPL::Spatial::SpatialManager<Vec3>;
Spatializer spatializer;
const auto targetChannels = JPL::ChannelMap::FromChannelMask(JPL::ChannelMask::Quad);
SourceInitParameters initParams{
.NumChannels = 1,
.NumTargetChannels = targetChannels.GetNumChannels(),
.PanParameters = { .Focus = 0.0f, .Spread = 1.0f }
};
const SourceId source = spatializer.CreateSource(initParams);
Position<Vec3> sourcePosition{
.Location = Vec3(0.0f, 0.0f, -5.0f),
.Orientation = Orientation<Vec3>::Identity()
};
spatializer.SetSourcePosition(source, sourcePosition);
auto* curve = new AttenuationCurve();
curve->Points = {
{.Distance = 0.0f, .Value = 1.0f, .FunctionType = Curve::EType::Linear},
{.Distance = 10.0f, .Value = 0.5f, .FunctionType = Curve::EType::Linear}
};
curve->SortPoints();
const auto curveHandle = spatializer.GetDirectPathService().AssignAttenuationCurve(
spatializer.GetDirectEffectHandle(source), curve);
spatializer.AdvanceSimulation();
const float distanceAttenuation = spatializer.GetDistanceAttenuation(source, curveHandle);
const auto channelGains = spatializer.GetChannelGains(source, targetChannels);Once AdvanceSimulation has processed the scene, GetLastUpdatedSource exposes which sources were touched, and the cached
results retrieved through GetDistanceAttenuation and GetChannelGains can be fed directly into the audio mix for the
current frame.
Details
When working directly with the panning layer you start by creating the source and target ChannelMap objects that describe each layout you want to support. Those maps are passed to InitializePanningEffect, which returns a PanEffectHandle representing the source's cached panning state. Hold on to that handle for subsequent updates, and query the cached gains after evaluation through GetChannelGainsFor. See PanningService.h and the sequence in PanningServiceTest for a full example.
A typical update loop mirrors the sequence covered in PanningServiceTest: set the focus/spread shaping via SetPanningEffectParameters (or adjust spread alone with SetPanningEffectSpread) and then call EvaluateDirection with the latest Position to refresh the cached gain buffers. This flow keeps directional data and spread control in sync before the gains are read back for mixing.
Remember to release handles that are no longer needed by calling ReleasePanningEffect; consult Services/PanningService.h for the full API surface, including helpers for advanced caching scenarios.
Details
DirectPathService owns the distance and cone attenuation caches that the high-level SpatialManager queries every update frame. A typical low-level setup is:
#include <JPLSpatial/DistanceAttenuation.h>
#include <JPLSpatial/Math/Math.h>
#include <JPLSpatial/Math/MinimalVec3.h>
#include <JPLSpatial/Services/DirectPathService.h>
using DirectPath = JPL::DirectPathService<>;
using Vec3 = JPL::MinimalVec3;
DirectPath directPath;
JPL::DirectEffectInitParameters initParams{
.BaseCurve = nullptr,
.AttenuationCone = {.InnerAngle = JPL::Math::ToRadians(60.0f), .OuterAngle = JPL::Math::ToRadians(120.0f)}
};
JPL::DirectEffectHandle handle = directPath.InitializeDirrectEffect(initParams);
auto* curve = new JPL::AttenuationCurve();
curve->Points = {
{.Distance = 0.0f, .Value = 1.0f, .FunctionType = JPL::Curve::EType::Linear},
{.Distance = 20.0f, .Value = 0.25f, .FunctionType = JPL::Curve::EType::Linear}
};
curve->SortPoints();
auto curveRef = directPath.AssignAttenuationCurve(handle, curve);
// Immediate evaluation against a single curve handle
const float preview = DirectPath::EvaluateDistance(5.0f, curveRef);
// Frame update path: evaluate and cache
JPL::Position<Vec3> source{{10.0f, 0.0f, -10.0f}, JPL::Orientation<Vec3>::IdentityForward()};
JPL::Position<Vec3> listener{{0.0f, 0.0f, 0.0f}, JPL::Orientation<Vec3>::IdentityForward()};
const auto directPathResult = DirectPath::ProcessDirectPath(source, listener);
directPath.EvaluateDistance(handle, directPathResult.Distance);
directPath.EvaluateDirection(handle, directPathResult.DirectionDot);
const float cachedDistanceFactor = directPath.GetDistanceAttenuation(handle, curveRef);
const float cachedConeFactor = directPath.GetDirectionAttenuation(handle);ProcessDirectPath returns both DirectionDot (listener-forward alignment) and InvDirectionDot (source-forward alignment) so you can decide whether to reuse the listener-facing or source-facing cosine in subsequent frames—the DirectPathService API documents these fields, and DirectPathServiceTest exercises scenarios such as a listener standing behind a source and validates the expected values. Once the per-frame EvaluateDistance/EvaluateDirection calls run, the cached values retrieved via GetDistanceAttenuation/GetDirectionAttenuation stay valid until the next evaluation, letting you keep the mixing hot-path free of curve traversals.
In JPL Spatial the coordinate system is right-handed and uses a Y-up axis.
- Positive X-axis: to the right
- Positive vlaues of Y-axis: upwards
- Negative vlaues of Z-axis: forwwrd
Values passed to JPL Spatial have to be converted accordingly if the coordinate system they came from doesn't match the above.
- Spatialization - source code for the library
- SpatializationTests - a set of tests to validate the behavior of the features and interfaces
- docs - so far non-functioning auto-generated documentation
- build - build scripts; running
cmake_vs2026_cl_x64.batwill create VS 2026 solution - cmake - cmake utilities
As much of the library as possible is header-only.
JPL Spatial library is structured in a few hierarchical layers:
- SpatialManager
- Services
- Low level features
- Services
..any layer can be used on its own for a more manual control.
- SpatialManager.h - top level interface that manages all the library services on the sound source level.
- Services - each service handles a specific feature set, relevant data and updates, and serve as a higher level interfaces for low level features.
- PanningService.h - VBAP/MDAP Panning, Virtual Sources
- DirectPathService.h - Distance and Angle based Attenuation
- ..others.
- Services - each service handles a specific feature set, relevant data and updates, and serve as a higher level interfaces for low level features.
- "Low level" features and utilities that can be used on their own:
- ChannelMap.h
- VBAP.h
- DistanceAttenuation.h
- Panning/VBAPEx.h
- Most of the things annotated in code.
- For more examples check out tests.
- For an example of integrating Services take a look at SpatialManager.h
- For an example of integrating JPL Spatial in an application see JPL Spatial Application
- To build the library, run appropriate build script in
buildfolder. - Some includes can be used as is as a single header include in your project.
- Depends only on the standard template library.
- Tests fetch
glmto validateglm::vec3type working with library's interfaces - Compiles with Visual Studio 2022 and Visual Studio 2026, other compiles haven't been tested.
- Uses C++20
JPL Spatial is going to be updated as the need for more features matches my time availability to work on them.
Warning
- API may change
- Things may get added, removed, and restructured
The project is distributed under the ISC license.

