QualityPreset.kt
src/jvmMain/kotlin/afield/audio/spatial/steamaudio/QualityPreset.kt · 153 lines
package afield.audio.spatial.steamaudio
Measured quality presets for Steam Audio reflections and binaural rendering, as QualityPreset.
public data class QualityPreset
A measured set of quality settings, so the trade-offs do not have to be rediscovered.
Every number in the presets below came from a measurement in this repository rather than from
a guess: ./gradlew binauralQuality for the binaural settings, ./gradlew caveDemo for the
per-block reflection cost, ./gradlew geometryBenchmark for the geometry and simulation cost.
Costs, measured
Per-block cost of reflection plus binaural decode, one source, 512-sample blocks at 48 kHz, 3-second reverb, on a 10-core Apple Silicon machine. The block budget is 10667 us.
| configuration | median | p95 | % of budget | sources per core at p95 |
|---|---|---|---|---|
CONVOLUTION, order 2 |
648 us | 1227 us | 11.5% | ~8 |
CONVOLUTION, order 1 |
281 us | 559 us | 5.2% | ~19 |
PARAMETRIC, order 1 |
67 us | 101 us | 0.9% | ~100 |
Those are per source, and a ReflectionMixer lets many sources share one decode, so the
real ceiling is higher than the table suggests. runReflections is a separate budget on the
simulation thread: 10-15 ms at 4096 rays and 16 bounces, 32 ms at 8192/32, 54 ms at
16384/32, and essentially independent of triangle count.
Using a preset
val quality = QualityPreset.HIGHEST
val simulator = Simulator(context, SimulationSettings(
flags = SimulationFlags.DIRECT or SimulationFlags.REFLECTIONS,
reflectionType = quality.reflectionType,
maxOrder = quality.ambisonicOrder,
maxNumRays = quality.numRays,
maxDuration = quality.reverbDuration,
samplingRate = audioSettings.samplingRate,
frameSize = audioSettings.frameSize,
))
val reflection = ReflectionEffect(
context, audioSettings,
type = quality.reflectionType,
duration = quality.reverbDuration,
ambisonicOrder = quality.ambisonicOrder,
)
val decode = AmbisonicsDecodeEffect(
context, audioSettings, hrtf, maxOrder = quality.ambisonicOrder,
)
val binauralParams = BinauralEffectParams(
arena, hrtf, interpolation = quality.hrtfInterpolation,
)
public data class QualityPreset(
/** Which reflection algorithm to render with. */
public val reflectionType: ReflectionEffectType,
/** Ambisonic order for the reflected and pathed buses. */
public val ambisonicOrder: Int,
/** HRTF interpolation for the direct path. */
public val hrtfInterpolation: HrtfInterpolation,
/** Reverb length to simulate and render, in seconds. */
public val reverbDuration: Float,
/** Rays cast per reflection simulation. */
public val numRays: Int,
/** Bounces per ray. */
public val numBounces: Int,
/** Diffuse samples per bounce. */
public val numDiffuseSamples: Int,
/** A one-line description, for logging which preset is active. */
public val description: String,
) {
public val ambisonicChannelCount: Int
Ambisonic channel count for ambisonicOrder.
public val ambisonicChannelCount: Int
get() = SteamAudio.ambisonicChannelCount(ambisonicOrder)
override fun toString(): String
override fun toString(): String =
"QualityPreset($description: $reflectionType, order $ambisonicOrder " +
"($ambisonicChannelCount ch), ${reverbDuration}s, $numRays rays x $numBounces bounces)"
public companion object
public companion object {
public val HIGHEST: QualityPreset
Highest quality: full convolution at Ambisonic order 2.
Why convolution. Measured, ReflectionEffectType.CONVOLUTION is both more
accurate and cheaper than ReflectionEffectType.HYBRID at every reverb duration
from 1 to 10 seconds -- hybrid pays for the convolution and the feedback network
both. ReflectionEffectType.PARAMETRIC is around five times cheaper again but loses
early-reflection detail and can glitch audibly on a foreground source, because its
network is re-driven by reverb times that step at the simulation rate instead of
cross-fading.
Why order 2, with a caveat. Under convolution the whole impulse response stays in
the Ambisonic domain, and raising the order improves the reflected field's directional
spread consistently across every arrival window measured (+6% early, +21% mid, +49%
late, +17% tail). Be aware of how strong that evidence is: the improvement is
consistent in sign but comparable in magnitude to the run-to-run variance of a
stochastic ray tracer, and under HYBRID -- where the parametric tail cannot be
order-dependent -- order 1 and order 2 were not ABX-able. Order 2 also costs 2.3x per
block. It is the right choice when quality is the priority; it is not a free win.
public val HIGHEST: QualityPreset = QualityPreset(
reflectionType = ReflectionEffectType.CONVOLUTION,
ambisonicOrder = 2,
hrtfInterpolation = HrtfInterpolation.BILINEAR,
reverbDuration = 3.0f,
numRays = 8192,
numBounces = 32,
numDiffuseSamples = 1024,
description = "highest",
)
public val BALANCED: QualityPreset
Convolution at order 1: most of the quality for less than half the per-block cost.
5.2% of a 512-sample block budget against 11.5%. Order 1 was not ABX-able against order 2 in a diffuse cave under hybrid reflections, so this is a defensible default for a scene whose reverb is mostly diffuse.
public val BALANCED: QualityPreset = HIGHEST.copy(
ambisonicOrder = 1,
numRays = 4096,
numBounces = 16,
description = "balanced",
)
public val ECONOMY: QualityPreset
Parametric reflections: for distant or background sources, and for platforms where the per-block budget is tight.
Around 0.9% of a block per source. Expect the early reflection detail to go, and expect audible stepping if the source is in the foreground and moving.
public val ECONOMY: QualityPreset = QualityPreset(
reflectionType = ReflectionEffectType.PARAMETRIC,
ambisonicOrder = 1,
hrtfInterpolation = HrtfInterpolation.BILINEAR,
reverbDuration = 2.0f,
numRays = 2048,
numBounces = 16,
numDiffuseSamples = 512,
description = "economy",
)
}
}