Callbacks.kt
src/jvmMain/kotlin/afield/audio/spatial/steamaudio/Callbacks.kt · 341 lines
package afield.audio.spatial.steamaudio
Callback interfaces for Steam Audio: PathingVisualizationCallback and ProgressCallback for watching pathing and baking, and CustomSceneCallbacks with HitResult for supplying your own ray tracer in place of Steam Audio's scene.
import afield.audio.spatial.steamaudio.raw.IPLAnyHitCallback
import afield.audio.spatial.steamaudio.raw.IPLBatchedAnyHitCallback
import afield.audio.spatial.steamaudio.raw.IPLBatchedClosestHitCallback
import afield.audio.spatial.steamaudio.raw.IPLClosestHitCallback
import afield.audio.spatial.steamaudio.raw.IPLHit
import afield.audio.spatial.steamaudio.raw.IPLPathingVisualizationCallback
import afield.audio.spatial.steamaudio.raw.IPLProgressCallback
import afield.audio.spatial.steamaudio.raw.IPLRay
import afield.audio.spatial.steamaudio.raw.IPLSceneSettings
import afield.audio.spatial.steamaudio.raw.IPLVector3
import java.lang.foreign.Arena
import java.lang.foreign.MemorySegment
import java.lang.foreign.ValueLayout
public fun interface PathingVisualizationCallback
Receives each probe-to-probe segment the pathing search considered.
Invaluable for a 2D game: draw these on top of the map and the path graph becomes visible, which turns "pathing is silent and I do not know why" into an obvious picture of probes that cannot see each other. Called from the simulation thread during Simulator.runPathing, once per segment examined, so it must be cheap and thread-safe -- push the segments onto a queue for the renderer rather than drawing here.
Coordinates are world-space. In a 2D game, convert back with the inverse of World2D's
mapping: game x is world x, game y is minus world z.
public fun interface PathingVisualizationCallback {
public fun onSegment
@param occluded true when this segment is blocked, so the pair is not mutually visible.
public fun onSegment(
fromX: Float, fromY: Float, fromZ: Float,
toX: Float, toY: Float, toZ: Float,
occluded: Boolean,
)
}
internal object PathingVisualizationCallbacks
fun stub(callback: PathingVisualizationCallback, arena: Arena): MemorySegment
fun stub(callback: PathingVisualizationCallback, arena: Arena): MemorySegment =
IPLPathingVisualizationCallback.allocate(
{ from, to, occluded, _ ->
try {
callback.onSegment(
IPLVector3.x(from), IPLVector3.y(from), IPLVector3.z(from),
IPLVector3.x(to), IPLVector3.y(to), IPLVector3.z(to),
occluded != 0,
)
} catch (_: Throwable) {
// Never propagate across the FFM boundary.
}
},
arena,
)
}
public fun interface ProgressCallback
Reports progress of a long-running operation as a fraction in 0..1.
public fun onProgress(progress: Float)
internal object ProgressCallbacks
internal object ProgressCallbacks {
fun stub(callback: ProgressCallback, arena: Arena): MemorySegment
Builds an upcall stub for callback, valid for as long as arena.
Exceptions must never cross back into native code, so they are swallowed.
fun stub(callback: ProgressCallback, arena: Arena): MemorySegment =
IPLProgressCallback.allocate(
{ progress, _ ->
try {
callback.onProgress(progress)
} catch (_: Throwable) {
// Never propagate across the FFM boundary.
}
},
arena,
)
}
public class HitResult internal constructor(private val segment: MemorySegment)
What a ray hit, for a custom scene.
Fill this in from CustomSceneCallbacks.closestHit. A distance of a negative value, or leaving the hit untouched, signals that the ray hit nothing.
public class HitResult internal constructor(private val segment: MemorySegment) {
public var distance: Float
Distance along the ray to the hit point. Set this negative to report a miss.
public var distance: Float
get() = IPLHit.distance(segment)
set(value) = IPLHit.distance(segment, value)
public var triangleIndex: Int
Index of the triangle that was hit, or -1 if not meaningful.
public var triangleIndex: Int
get() = IPLHit.triangleIndex(segment)
set(value) = IPLHit.triangleIndex(segment, value)
public var objectIndex: Int
Index of the object that was hit, or -1 if not meaningful.
public var objectIndex: Int
get() = IPLHit.objectIndex(segment)
set(value) = IPLHit.objectIndex(segment, value)
public var materialIndex: Int
Index of the material at the hit point, or -1 if not meaningful.
public var materialIndex: Int
get() = IPLHit.materialIndex(segment)
set(value) = IPLHit.materialIndex(segment, value)
public fun setNormal(x: Float, y: Float, z: Float)
Surface normal at the hit point. Should be unit length.
public fun setNormal(x: Float, y: Float, z: Float) {
val normal = IPLHit.normal(segment)
IPLVector3.x(normal, x)
IPLVector3.y(normal, y)
IPLVector3.z(normal, z)
}
public fun setMaterial(material: MemorySegment)
The acoustic material at the hit point.
Steam Audio reads this as a pointer, so the segment must remain valid after the callback returns -- allocate materials once, up front, in a long-lived arena, rather than per hit.
public fun setMaterial(material: MemorySegment) {
IPLHit.material(segment, material)
}
public fun setMiss()
Records a miss.
public fun setMiss() {
distance = -1f
}
}
public interface CustomSceneCallbacks
Ray-cast callbacks for a SceneType.CUSTOM scene, letting Steam Audio query geometry that
lives in your own acceleration structure rather than in a Scene.
Cost
These are the hottest callbacks in the API. Steam Audio calls them from its own ray-tracing worker threads, potentially hundreds of thousands of times per simulation run, and each call is an FFM upcall into the JVM. Consequences worth knowing before you choose this over a default scene:
- Native threads must attach to the JVM the first time they upcall. That cost is paid once per thread, but it is not free, and those threads then participate in safepoints.
- Allocation in these callbacks is fatal to performance. Do not allocate, do not box, do not touch a concurrent collection, and do not log.
- Implement the batched variants (batchedClosestHit and batchedAnyHit) if you use this at all. They amortise the boundary crossing over many rays instead of paying it per ray, which is the difference between viable and not.
- Never throw. Exceptions are swallowed at the boundary and the ray is reported as a miss, which will quietly corrupt the simulation rather than fail loudly.
For a space made of planes, a default Scene with InstancedMesh is almost always the
better choice: Steam Audio's own BVH is fast, and moving an instance is cheap.
public interface CustomSceneCallbacks {
public fun closestHit
Finds the closest hit along a ray, between minDistance and maxDistance.
Write the result into hit, or call HitResult.setMiss.
public fun closestHit(
originX: Float, originY: Float, originZ: Float,
directionX: Float, directionY: Float, directionZ: Float,
minDistance: Float, maxDistance: Float,
hit: HitResult,
)
public fun anyHit
Reports whether a ray hits anything between minDistance and maxDistance.
Cheaper than closestHit because it can stop at the first hit; used for occlusion.
public fun anyHit(
originX: Float, originY: Float, originZ: Float,
directionX: Float, directionY: Float, directionZ: Float,
minDistance: Float, maxDistance: Float,
): Boolean
public fun batchedClosestHit
Batched form of closestHit. Defaults to looping over the single-ray version.
Override this. The default exists so the interface is easy to implement, not because looping is a good idea -- see the cost notes on this interface.
public fun batchedClosestHit(
rayCount: Int,
rays: MemorySegment,
minDistances: MemorySegment,
maxDistances: MemorySegment,
hits: MemorySegment,
) {
val rayLayout = IPLRay.layout()
val hitLayout = IPLHit.layout()
for (index in 0 until rayCount) {
val ray = rays.asSlice(index * rayLayout.byteSize(), rayLayout.byteSize())
val origin = IPLRay.origin(ray)
val direction = IPLRay.direction(ray)
closestHit(
IPLVector3.x(origin), IPLVector3.y(origin), IPLVector3.z(origin),
IPLVector3.x(direction), IPLVector3.y(direction), IPLVector3.z(direction),
minDistances.getAtIndex(ValueLayout.JAVA_FLOAT, index.toLong()),
maxDistances.getAtIndex(ValueLayout.JAVA_FLOAT, index.toLong()),
HitResult(hits.asSlice(index * hitLayout.byteSize(), hitLayout.byteSize())),
)
}
}
public fun batchedAnyHit
Batched form of anyHit. Writes 0 or 1 per ray into occluded.
public fun batchedAnyHit(
rayCount: Int,
rays: MemorySegment,
minDistances: MemorySegment,
maxDistances: MemorySegment,
occluded: MemorySegment,
) {
val rayLayout = IPLRay.layout()
for (index in 0 until rayCount) {
val ray = rays.asSlice(index * rayLayout.byteSize(), rayLayout.byteSize())
val origin = IPLRay.origin(ray)
val direction = IPLRay.direction(ray)
val blocked = anyHit(
IPLVector3.x(origin), IPLVector3.y(origin), IPLVector3.z(origin),
IPLVector3.x(direction), IPLVector3.y(direction), IPLVector3.z(direction),
minDistances.getAtIndex(ValueLayout.JAVA_FLOAT, index.toLong()),
maxDistances.getAtIndex(ValueLayout.JAVA_FLOAT, index.toLong()),
)
occluded.setAtIndex(ValueLayout.JAVA_BYTE, index.toLong(), if (blocked) 1 else 0)
}
}
public companion object
public companion object {
internal fun install
Wires all four callbacks into an IPLSceneSettings, with stubs owned by arena.
body — 98 lines, from line 243
internal fun install(
callbacks: CustomSceneCallbacks,
settings: MemorySegment,
arena: Arena,
) {
IPLSceneSettings.closestHitCallback(
settings,
IPLClosestHitCallback.allocate(
{ ray, minDistance, maxDistance, hit, _ ->
try {
val origin = IPLRay.origin(ray)
val direction = IPLRay.direction(ray)
callbacks.closestHit(
IPLVector3.x(origin), IPLVector3.y(origin), IPLVector3.z(origin),
IPLVector3.x(direction), IPLVector3.y(direction), IPLVector3.z(direction),
minDistance, maxDistance,
HitResult(hit),
)
} catch (_: Throwable) {
IPLHit.distance(hit, -1f)
}
},
arena,
),
)
IPLSceneSettings.anyHitCallback(
settings,
IPLAnyHitCallback.allocate(
{ ray, minDistance, maxDistance, occluded, _ ->
val blocked = try {
val origin = IPLRay.origin(ray)
val direction = IPLRay.direction(ray)
callbacks.anyHit(
IPLVector3.x(origin), IPLVector3.y(origin), IPLVector3.z(origin),
IPLVector3.x(direction), IPLVector3.y(direction), IPLVector3.z(direction),
minDistance, maxDistance,
)
} catch (_: Throwable) {
false
}
occluded.set(ValueLayout.JAVA_BYTE, 0L, if (blocked) 1 else 0)
},
arena,
),
)
IPLSceneSettings.batchedClosestHitCallback(
settings,
IPLBatchedClosestHitCallback.allocate(
{ rayCount, rays, minDistances, maxDistances, hits, _ ->
try {
callbacks.batchedClosestHit(
rayCount,
rays.reinterpret(rayCount.toLong() * IPLRay.layout().byteSize()),
minDistances.reinterpret(rayCount.toLong() * Float.SIZE_BYTES),
maxDistances.reinterpret(rayCount.toLong() * Float.SIZE_BYTES),
hits.reinterpret(rayCount.toLong() * IPLHit.layout().byteSize()),
)
} catch (_: Throwable) {
// Report every ray in the batch as a miss.
val hitLayout = IPLHit.layout()
val all = hits.reinterpret(rayCount.toLong() * hitLayout.byteSize())
for (index in 0 until rayCount) {
IPLHit.distance(
all.asSlice(index * hitLayout.byteSize(), hitLayout.byteSize()),
-1f,
)
}
}
},
arena,
),
)
IPLSceneSettings.batchedAnyHitCallback(
settings,
IPLBatchedAnyHitCallback.allocate(
{ rayCount, rays, minDistances, maxDistances, occluded, _ ->
val out = occluded.reinterpret(rayCount.toLong())
try {
callbacks.batchedAnyHit(
rayCount,
rays.reinterpret(rayCount.toLong() * IPLRay.layout().byteSize()),
minDistances.reinterpret(rayCount.toLong() * Float.SIZE_BYTES),
maxDistances.reinterpret(rayCount.toLong() * Float.SIZE_BYTES),
out,
)
} catch (_: Throwable) {
out.fill(0)
}
},
arena,
),
)
}
}
}