GeometryBenchmark.kt
src/jvmMain/kotlin/afield/examples/steamaudio/GeometryBenchmark.kt · 614 lines
package afield.examples.steamaudio
import afield.audio.spatial.steamaudio.AudioSettings
import afield.audio.spatial.steamaudio.Box
import afield.audio.spatial.steamaudio.CoordinateSpace3
import afield.audio.spatial.steamaudio.InstancedMesh
import afield.audio.spatial.steamaudio.Material
import afield.audio.spatial.steamaudio.Matrix4x4
import afield.audio.spatial.steamaudio.ReflectionEffectType
import afield.audio.spatial.steamaudio.Scene
import afield.audio.spatial.steamaudio.SimulationFlags
import afield.audio.spatial.steamaudio.SimulationInputs
import afield.audio.spatial.steamaudio.SimulationSettings
import afield.audio.spatial.steamaudio.SimulationSharedInputs
import afield.audio.spatial.steamaudio.Simulator
import afield.audio.spatial.steamaudio.Source
import afield.audio.spatial.steamaudio.StaticMesh
import afield.audio.spatial.steamaudio.SteamAudio
import afield.audio.spatial.steamaudio.SteamAudioContext
import afield.audio.spatial.steamaudio.Triangle
import afield.audio.spatial.steamaudio.Vector3
import java.lang.foreign.Arena
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
Measures what geometry actually costs, so a scene mixing static and dynamic parts can be arranged on numbers rather than guesswork.
The question this answers: when most geometry is static and only a few plane segments move, where should the static geometry live?
- Flat: static meshes added directly to the scene that also holds the moving instances.
Every
Scene.committhen rebuilds an acceleration structure over all the static triangles, even though none of them moved. - Nested: the static geometry goes into its own sub-scene, committed once, and is instanced into the top-level scene with an identity transform. The top-level scene's structure is then built over a handful of instance bounding boxes, so committing after a dynamic move should be near-free regardless of how much static geometry there is.
Both arrangements are measured at several triangle counts, along with the cost of a reflection simulation run, since that is the other half of the per-frame budget.
private const val SAMPLING_RATE = 48_000
private const val FRAME_SIZE = 512
fun main()
body — 130 lines, from line 48
fun main() {
println("Steam Audio ${SteamAudio.VERSION} - geometry cost benchmark")
println(" ${Runtime.getRuntime().availableProcessors()} cores available")
println()
SteamAudioContext().use { context ->
val triangleCounts = intArrayOf(1_200, 4_800, 19_200, 76_800)
println("=".repeat(78))
println("Scene.commit() cost after moving 4 dynamic panels")
println("=".repeat(78))
println()
println(" static tris flat commit nested commit speedup initial build")
println(" " + "-".repeat(74))
val results = mutableListOf<Triple<Int, Double, Double>>()
for (target in triangleCounts) {
val flat = measureFlat(context, target)
val nested = measureNested(context, target)
results += Triple(target, flat.commitMillis, nested.commitMillis)
println(
" %11d %8.2f ms %9.2f ms %6.1fx %7.1f ms".format(
target,
flat.commitMillis,
nested.commitMillis,
flat.commitMillis / nested.commitMillis,
nested.buildMillis,
),
)
}
println()
println(" Budget context: at 60 Hz a frame is 16.7 ms; at 15 Hz a simulation tick is 66.7 ms.")
println()
println("=".repeat(78))
println("Simulator.runReflections() cost")
println("=".repeat(78))
println()
println(" rays bounces static tris per run at 15 Hz")
println(" " + "-".repeat(64))
for ((rays, bounces) in listOf(2048 to 16, 4096 to 16, 8192 to 32, 16384 to 32)) {
val millis = measureReflections(context, 19_200, rays, bounces)
val duty = millis / (1000.0 / 15.0) * 100.0
println(
" %7d %7d %11d %6.1f ms %5.1f%% of one core-set".format(
rays, bounces, 19_200, millis, duty,
),
)
}
println()
println("=".repeat(78))
println("Making geometry appear and disappear: two techniques")
println("=".repeat(78))
println()
println(" Geometry that is only present some of the time can be toggled either by")
println(" add/remove + commit, or by moving an instance out of the way. These are not")
println(" equivalent in cost.")
println()
println(" static tris addTo/removeFrom + commit instance move + commit")
println(" " + "-".repeat(68))
for (target in triangleCounts) {
val toggle = measureToggle(context, target)
val move = measureInstanceMove(context, target)
println(
" %11d %21.3f ms %18.3f ms".format(target, toggle, move),
)
}
println()
println("=".repeat(78))
println("Is commit() lazy? runReflections with and without geometry changes")
println("=".repeat(78))
println()
println(" If commit() defers its work, a run that follows a geometry change will")
println(" cost more than a run that follows no change. Same scene, same rays;")
println(" the only difference is whether geometry moved beforehand.")
println()
println(" static tris run (no change) run (after move+commit) difference")
println(" " + "-".repeat(72))
for (target in triangleCounts) {
val (steady, afterChange) = measureLaziness(context, target)
println(
" %11d %13.1f ms %20.1f ms %+8.1f ms".format(
target, steady, afterChange, afterChange - steady,
),
)
}
println()
println("=".repeat(78))
println("What the numbers say")
println("=".repeat(78))
val worst = results.last()
println(
"""
|
| 1. Committing after a geometry change is cheap and does NOT scale with the
| amount of static geometry: ${"%.2f".format(worst.second)} ms flat versus ${"%.2f".format(worst.third)} ms nested at
| ${worst.first} static triangles, and the figure falls rather than rises as
| triangles increase. add/remove and instance-move are both at the timer floor.
|
| 2. That work is not merely deferred into the next ray query either: a
| reflection run following a move+commit costs the same as one following no
| change (differences above are noise, and one is negative). So the
| acceleration structure really is maintained incrementally.
|
| 3. Therefore a scene mixing mostly-static geometry with a few moving
| segments needs NO special arrangement. Put static geometry straight into
| the scene; move the dynamic pieces as instanced meshes. Nesting static
| geometry in a sub-scene is worth doing for reuse -- instancing one room
| shape many times -- but it is not a commit-cost optimisation.
|
| 4. runReflections is the only per-frame cost that matters, and it is driven
| by rays and bounces, not by triangle count: at 4096 rays / 16 bounces it
| runs 10-15 ms across a 64x range of geometry size. Budget the ray count.
|
| 5. The one cost that does scale with geometry is the initial StaticMesh
| build and first commit (1.8 ms at 1200 triangles to ~52 ms at 76800).
| Pay it at load time, not when an area becomes visible.
|
""".trimMargin(),
)
}
}
private class Timing(val buildMillis: Double, val commitMillis: Double)
Static geometry added directly to the scene that also holds the moving instances.
private fun measureFlat(context: SteamAudioContext, targetTriangles: Int): Timing {
Scene(context).use { scene ->
val closeables = mutableListOf<AutoCloseable>()
val buildStart = System.nanoTime()
val mesh = tessellatedShell(scene, targetTriangles)
mesh.addTo(scene)
closeables += mesh
scene.commit()
val buildMillis = (System.nanoTime() - buildStart) / 1e6
// Four moving panels, as instances of a small sub-scene.
val panelScene = Scene(context)
closeables += panelScene
val panel = StaticMesh.plane(
panelScene,
Vector3(-2f, -2f, 0f), Vector3(2f, -2f, 0f),
Vector3(2f, 2f, 0f), Vector3(-2f, 2f, 0f),
Material.CONCRETE,
)
panel.addTo(panelScene)
panelScene.commit()
closeables += panel
val panels = List(4) { index ->
val instance = InstancedMesh(scene, panelScene, panelAt(index, 0.0))
instance.addTo(scene)
closeables += instance
instance
}
scene.commit()
val commitMillis = timeCommits(scene, panels)
closeables.reversed().forEach { it.close() }
return Timing(buildMillis, commitMillis)
}
}
Static geometry in its own sub-scene, instanced into the top-level scene.
source — 44 lines, from line 219
private fun measureNested(context: SteamAudioContext, targetTriangles: Int): Timing {
Scene(context).use { scene ->
val closeables = mutableListOf<AutoCloseable>()
val buildStart = System.nanoTime()
val staticScene = Scene(context)
closeables += staticScene
val mesh = tessellatedShell(staticScene, targetTriangles)
mesh.addTo(staticScene)
closeables += mesh
// Built once. This is the expensive part, and it never runs again.
staticScene.commit()
val staticInstance = InstancedMesh(scene, staticScene, Matrix4x4.IDENTITY)
staticInstance.addTo(scene)
closeables += staticInstance
scene.commit()
val buildMillis = (System.nanoTime() - buildStart) / 1e6
val panelScene = Scene(context)
closeables += panelScene
val panel = StaticMesh.plane(
panelScene,
Vector3(-2f, -2f, 0f), Vector3(2f, -2f, 0f),
Vector3(2f, 2f, 0f), Vector3(-2f, 2f, 0f),
Material.CONCRETE,
)
panel.addTo(panelScene)
panelScene.commit()
closeables += panel
val panels = List(4) { index ->
val instance = InstancedMesh(scene, panelScene, panelAt(index, 0.0))
instance.addTo(scene)
closeables += instance
instance
}
scene.commit()
val commitMillis = timeCommits(scene, panels)
closeables.reversed().forEach { it.close() }
return Timing(buildMillis, commitMillis)
}
}
Moves the panels and commits, repeatedly, returning the median cost of one move+commit.
source — 92 lines, from line 265
private fun timeCommits(scene: Scene, panels: List<InstancedMesh>): Double {
val warmup = 5
val runs = 25
val samples = DoubleArray(runs)
for (iteration in 0 until warmup + runs) {
val phase = iteration * 0.13
val start = System.nanoTime()
panels.forEachIndexed { index, instance ->
instance.updateTransform(scene, panelAt(index, phase))
}
scene.commit()
val elapsed = (System.nanoTime() - start) / 1e6
if (iteration >= warmup) samples[iteration - warmup] = elapsed
}
samples.sort()
return samples[samples.size / 2]
}
private fun measureReflections(
context: SteamAudioContext,
targetTriangles: Int,
rays: Int,
bounces: Int,
): Double {
val settings = SimulationSettings(
flags = SimulationFlags.DIRECT or SimulationFlags.REFLECTIONS,
reflectionType = ReflectionEffectType.HYBRID,
maxNumRays = rays,
numDiffuseSamples = 1024,
maxDuration = 3.0f,
maxOrder = 1,
maxNumSources = 4,
samplingRate = SAMPLING_RATE,
frameSize = FRAME_SIZE,
)
Scene(context).use { scene ->
val closeables = mutableListOf<AutoCloseable>()
val mesh = tessellatedShell(scene, targetTriangles)
mesh.addTo(scene)
closeables += mesh
scene.commit()
Simulator(context, settings).use { simulator ->
simulator.setScene(scene)
simulator.commit()
Source(simulator, SimulationFlags.DIRECT or SimulationFlags.REFLECTIONS).use { probe ->
probe.addTo(simulator)
simulator.commit()
Arena.ofConfined().use { arena ->
val inputs = SimulationInputs(arena).apply {
this.source = CoordinateSpace3.lookingAt(
origin = Vector3(0f, 1.6f, -5f),
ahead = Vector3(0f, 0f, -1f),
)
}
val shared = SimulationSharedInputs(arena).apply {
listener = CoordinateSpace3.lookingAt(
origin = Vector3(0f, 1.6f, 6f),
ahead = Vector3(0f, 0f, -1f),
)
numRays = rays
numBounces = bounces
duration = 3.0f
order = 1
}
val flags = SimulationFlags.DIRECT or SimulationFlags.REFLECTIONS
probe.setInputs(flags, inputs)
simulator.setSharedInputs(flags, shared)
val warmup = 3
val runs = 9
val samples = DoubleArray(runs)
for (iteration in 0 until warmup + runs) {
val start = System.nanoTime()
simulator.runReflections()
val elapsed = (System.nanoTime() - start) / 1e6
if (iteration >= warmup) samples[iteration - warmup] = elapsed
}
samples.sort()
closeables.reversed().forEach { it.close() }
return samples[samples.size / 2]
}
}
}
}
}
Measures whether commit() work is deferred into the next ray query.
Returns (median run with no geometry change, median run after a move + commit).
source — 87 lines, from line 363
private fun measureLaziness(context: SteamAudioContext, targetTriangles: Int): Pair<Double, Double> {
val settings = SimulationSettings(
flags = SimulationFlags.DIRECT or SimulationFlags.REFLECTIONS,
reflectionType = ReflectionEffectType.HYBRID,
maxNumRays = 4096,
numDiffuseSamples = 1024,
maxDuration = 3.0f,
maxOrder = 1,
maxNumSources = 4,
samplingRate = SAMPLING_RATE,
frameSize = FRAME_SIZE,
)
Scene(context).use { scene ->
val shell = tessellatedShell(scene, targetTriangles)
shell.addTo(scene)
scene.commit()
val panelScene = Scene(context)
val panel = StaticMesh.plane(
panelScene,
Vector3(-2f, -2f, 0f), Vector3(2f, -2f, 0f),
Vector3(2f, 2f, 0f), Vector3(-2f, 2f, 0f),
Material.CONCRETE,
)
panel.addTo(panelScene)
panelScene.commit()
val instance = InstancedMesh(scene, panelScene, Matrix4x4.IDENTITY)
instance.addTo(scene)
scene.commit()
Simulator(context, settings).use { simulator ->
simulator.setScene(scene)
simulator.commit()
Source(simulator, SimulationFlags.DIRECT or SimulationFlags.REFLECTIONS).use { probe ->
probe.addTo(simulator)
simulator.commit()
Arena.ofConfined().use { arena ->
val flags = SimulationFlags.DIRECT or SimulationFlags.REFLECTIONS
val inputs = SimulationInputs(arena).apply {
this.source = CoordinateSpace3.lookingAt(
Vector3(0f, 1.6f, -5f), Vector3(0f, 0f, -1f),
)
}
val shared = SimulationSharedInputs(arena).apply {
listener = CoordinateSpace3.lookingAt(
Vector3(0f, 1.6f, 6f), Vector3(0f, 0f, -1f),
)
numRays = 4096
numBounces = 16
duration = 3.0f
order = 1
}
probe.setInputs(flags, inputs)
simulator.setSharedInputs(flags, shared)
// Warm up thoroughly so neither figure includes first-run costs.
repeat(4) { simulator.runReflections() }
val runs = 9
val steady = DoubleArray(runs)
for (i in 0 until runs) {
val t = System.nanoTime()
simulator.runReflections()
steady[i] = (System.nanoTime() - t) / 1e6
}
val changed = DoubleArray(runs)
for (i in 0 until runs) {
instance.updateTransform(
scene,
Matrix4x4.translation(0.1f * i, 1.6f, 0.5f * i),
)
scene.commit()
val t = System.nanoTime()
simulator.runReflections()
changed[i] = (System.nanoTime() - t) / 1e6
}
steady.sort(); changed.sort()
instance.close(); panel.close(); panelScene.close(); shell.close()
return steady[runs / 2] to changed[runs / 2]
}
}
}
}
}
Cost of toggling a static mesh in and out of a committed scene.
This is the "geometry appears when visible" path if implemented with add/remove.
private fun measureToggle(context: SteamAudioContext, targetTriangles: Int): Double {
Scene(context).use { scene ->
val shell = tessellatedShell(scene, targetTriangles)
shell.addTo(scene)
scene.commit()
// A second mesh, toggled in and out.
val toggleScene = Scene(context)
val extra = StaticMesh.plane(
scene,
Vector3(-3f, -2f, 0f), Vector3(3f, -2f, 0f),
Vector3(3f, 4f, 0f), Vector3(-3f, 4f, 0f),
Material.CONCRETE,
)
val warmup = 4
val runs = 20
val samples = DoubleArray(runs)
for (iteration in 0 until warmup + runs) {
val start = System.nanoTime()
if (iteration % 2 == 0) extra.addTo(scene) else extra.removeFrom(scene)
scene.commit()
val elapsed = (System.nanoTime() - start) / 1e6
if (iteration >= warmup) samples[iteration - warmup] = elapsed
}
samples.sort()
extra.close()
toggleScene.close()
return samples[samples.size / 2]
}
}
Cost of hiding geometry by moving an instance instead of removing it.
source — 44 lines, from line 489
private fun measureInstanceMove(context: SteamAudioContext, targetTriangles: Int): Double {
Scene(context).use { scene ->
val shell = tessellatedShell(scene, targetTriangles)
shell.addTo(scene)
scene.commit()
val panelScene = Scene(context)
val panel = StaticMesh.plane(
panelScene,
Vector3(-3f, -2f, 0f), Vector3(3f, -2f, 0f),
Vector3(3f, 4f, 0f), Vector3(-3f, 4f, 0f),
Material.CONCRETE,
)
panel.addTo(panelScene)
panelScene.commit()
val instance = InstancedMesh(scene, panelScene, Matrix4x4.IDENTITY)
instance.addTo(scene)
scene.commit()
val warmup = 4
val runs = 20
val samples = DoubleArray(runs)
for (iteration in 0 until warmup + runs) {
// Alternate between in place and parked far outside the scene.
val transform = if (iteration % 2 == 0) {
Matrix4x4.translation(0f, 0f, 0f)
} else {
Matrix4x4.translation(0f, 0f, 10_000f)
}
val start = System.nanoTime()
instance.updateTransform(scene, transform)
scene.commit()
val elapsed = (System.nanoTime() - start) / 1e6
if (iteration >= warmup) samples[iteration - warmup] = elapsed
}
samples.sort()
instance.close()
panel.close()
panelScene.close()
shell.close()
return samples[samples.size / 2]
}
}
Where a benchmark panel sits at a given animation phase.
private fun panelAt(index: Int, phase: Double): Matrix4x4 {
val angle = index * PI / 2.0 + phase
val radius = 5.0 + 1.5 * sin(phase * 1.7)
return Matrix4x4.translation(
(radius * sin(angle)).toFloat(),
1.6f,
(radius * cos(angle)).toFloat(),
)
}
A closed box whose faces are tessellated into roughly targetTriangles triangles.
Stands in for a real environment's static geometry: enough triangles to make the acceleration-structure build cost realistic.
source — 63 lines, from line 551
private fun tessellatedShell(scene: Scene, targetTriangles: Int): StaticMesh {
// 6 faces, each an n x n grid of quads, each quad 2 triangles.
val n = kotlin.math.sqrt(targetTriangles / 12.0).toInt().coerceAtLeast(1)
val bounds = Box(Vector3(-10f, -2f, -13f), Vector3(10f, 7f, 13f))
val vertices = mutableListOf<Vector3>()
val triangles = mutableListOf<Triangle>()
// Each face is generated independently; vertices are not shared between faces, which keeps
// the generator simple and costs only memory.
fun face(
origin: Vector3,
edgeU: Vector3,
edgeV: Vector3,
) {
val base = vertices.size
for (v in 0..n) {
for (u in 0..n) {
val fu = u.toFloat() / n
val fv = v.toFloat() / n
vertices += Vector3(
origin.x + edgeU.x * fu + edgeV.x * fv,
origin.y + edgeU.y * fu + edgeV.y * fv,
origin.z + edgeU.z * fu + edgeV.z * fv,
)
}
}
val stride = n + 1
for (v in 0 until n) {
for (u in 0 until n) {
val a = base + v * stride + u
val b = a + 1
val c = a + stride + 1
val d = a + stride
// Wound so the normals face inward, making this a room rather than a block.
triangles += Triangle(a, c, b)
triangles += Triangle(a, d, c)
}
}
}
val min = bounds.min
val size = Vector3(
bounds.max.x - bounds.min.x,
bounds.max.y - bounds.min.y,
bounds.max.z - bounds.min.z,
)
face(min, Vector3(size.x, 0f, 0f), Vector3(0f, size.y, 0f)) // -z
face(Vector3(min.x, min.y, bounds.max.z), Vector3(size.x, 0f, 0f), Vector3(0f, size.y, 0f)) // +z
face(min, Vector3(0f, 0f, size.z), Vector3(0f, size.y, 0f)) // -x
face(Vector3(bounds.max.x, min.y, min.z), Vector3(0f, 0f, size.z), Vector3(0f, size.y, 0f)) // +x
face(min, Vector3(size.x, 0f, 0f), Vector3(0f, 0f, size.z)) // -y
face(Vector3(min.x, bounds.max.y, min.z), Vector3(size.x, 0f, 0f), Vector3(0f, 0f, size.z)) // +y
return StaticMesh(
scene = scene,
vertices = vertices,
triangles = triangles,
materials = listOf(Material.ROCK),
materialIndices = IntArray(triangles.size),
)
}