Geometry.kt
src/jvmMain/kotlin/afield/audio/spatial/steamaudio/Geometry.kt · 471 lines
package afield.audio.spatial.steamaudio
import afield.audio.spatial.steamaudio.raw.IPLBox
import afield.audio.spatial.steamaudio.raw.IPLCoordinateSpace3
import afield.audio.spatial.steamaudio.raw.IPLMatrix4x4
import afield.audio.spatial.steamaudio.raw.IPLSphere
import afield.audio.spatial.steamaudio.raw.IPLVector3
import java.lang.foreign.MemorySegment
import java.lang.foreign.SegmentAllocator
import java.lang.foreign.ValueLayout
public data class Vector3
A point or direction in 3D space.
Steam Audio uses a right-handed coordinate system: +x right, +y up, -z forward.
This is an ordinary class rather than a native view, so it allocates. On the audio thread,
prefer the (x, y, z) overloads on the parameter types (for example
BinauralEffectParams.setDirection) which write straight through to native memory.
Being a data class, equality is exact per component, which carries the usual floating-point
caveats: Vector3(0f, -0.0f, 0f) is not equal to ZERO even though the two are numerically
the same, and any vector containing NaN is not equal to itself. Cross products in
particular produce -0.0f readily. Compare computed vectors with a tolerance rather than
with ==.
public data class Vector3(
public val x: Float,
public val y: Float,
public val z: Float,
) {
public operator fun plus(other: Vector3): Vector3
public operator fun plus(other: Vector3): Vector3 = Vector3(x + other.x, y + other.y, z + other.z)
public operator fun minus(other: Vector3): Vector3
public operator fun minus(other: Vector3): Vector3 = Vector3(x - other.x, y - other.y, z - other.z)
public operator fun times(scalar: Float): Vector3 = Vector3(x * scalar, y * scalar, z * scalar)
public operator fun unaryMinus(): Vector3 = Vector3(-x, -y, -z)
public val length: Float get() = kotlin.math.sqrt(x * x + y * y + z * z)
Euclidean length.
public fun normalized(): Vector3
This vector scaled to unit length, or ZERO if it has no length.
public fun normalized(): Vector3 {
val len = length
return if (len == 0f) ZERO else Vector3(x / len, y / len, z / len)
}
public infix fun dot(other: Vector3): Float = x * other.x + y * other.y + z * other.z
public infix fun cross(other: Vector3): Vector3
public infix fun cross(other: Vector3): Vector3 = Vector3(
y * other.z - z * other.y,
z * other.x - x * other.z,
x * other.y - y * other.x,
)
internal fun writeTo(segment: MemorySegment)
Writes this vector into an IPLVector3 at segment.
internal fun writeTo(segment: MemorySegment) {
IPLVector3.x(segment, x)
IPLVector3.y(segment, y)
IPLVector3.z(segment, z)
}
internal fun allocate(allocator: SegmentAllocator): MemorySegment
Allocates a new IPLVector3 holding this vector.
internal fun allocate(allocator: SegmentAllocator): MemorySegment =
IPLVector3.allocate(allocator).also(::writeTo)
public companion object
public val ZERO: Vector3 = Vector3(0f, 0f, 0f)
public val RIGHT: Vector3 = Vector3(1f, 0f, 0f)
public val UP: Vector3 = Vector3(0f, 1f, 0f)
public val FORWARD: Vector3 = Vector3(0f, 0f, -1f)
Steam Audio's forward direction, which is -z.
internal fun readFrom(segment: MemorySegment): Vector3
internal fun readFrom(segment: MemorySegment): Vector3 = Vector3(
IPLVector3.x(segment),
IPLVector3.y(segment),
IPLVector3.z(segment),
)
}
}
public class Matrix4x4(elements: FloatArray)
A 4x4 transform matrix, stored row-major to match IPLMatrix4x4.
elements is 16 floats in row-major order; elements[row * 4 + column].
public class Matrix4x4(elements: FloatArray) {
public val elements: FloatArray
public val elements: FloatArray = elements.copyOf().also {
require(it.size == 16) { "a 4x4 matrix needs 16 elements, got ${it.size}" }
}
public operator fun get(row: Int, column: Int): Float = elements[row * 4 + column]
public operator fun times(other: Matrix4x4): Matrix4x4
Matrix product: this * other.
Applied to a column vector, other acts first. So
translation(...) * rotationY(...) rotates and then translates, which is the usual
order for placing an object.
public operator fun times(other: Matrix4x4): Matrix4x4 {
val result = FloatArray(16)
for (row in 0 until 4) {
for (column in 0 until 4) {
var sum = 0f
for (k in 0 until 4) {
sum += elements[row * 4 + k] * other.elements[k * 4 + column]
}
result[row * 4 + column] = sum
}
}
return Matrix4x4(result)
}
public fun transformPoint(point: Vector3): Vector3
Transforms a point, applying the translation column.
public fun transformPoint(point: Vector3): Vector3 = Vector3(
get(0, 0) * point.x + get(0, 1) * point.y + get(0, 2) * point.z + get(0, 3),
get(1, 0) * point.x + get(1, 1) * point.y + get(1, 2) * point.z + get(1, 3),
get(2, 0) * point.x + get(2, 1) * point.y + get(2, 2) * point.z + get(2, 3),
)
internal fun writeTo(segment: MemorySegment)
internal fun writeTo(segment: MemorySegment) {
MemorySegment.copy(
elements, 0,
IPLMatrix4x4.elements(segment), ValueLayout.JAVA_FLOAT,
0L, 16,
)
}
internal fun allocate(allocator: SegmentAllocator): MemorySegment
internal fun allocate(allocator: SegmentAllocator): MemorySegment =
IPLMatrix4x4.allocate(allocator).also(::writeTo)
override fun equals(other: Any?): Boolean
override fun equals(other: Any?): Boolean =
this === other || (other is Matrix4x4 && elements.contentEquals(other.elements))
override fun hashCode(): Int = elements.contentHashCode()
override fun toString(): String
override fun toString(): String =
(0 until 4).joinToString(prefix = "Matrix4x4[", postfix = "]", separator = "; ") { row ->
(0 until 4).joinToString(" ") { column -> get(row, column).toString() }
}
public companion object
public val IDENTITY: Matrix4x4
public val IDENTITY: Matrix4x4 = Matrix4x4(
floatArrayOf(
1f, 0f, 0f, 0f,
0f, 1f, 0f, 0f,
0f, 0f, 1f, 0f,
0f, 0f, 0f, 1f,
),
)
public fun translation(x: Float, y: Float, z: Float): Matrix4x4
A translation matrix.
public fun translation(x: Float, y: Float, z: Float): Matrix4x4 = Matrix4x4(
floatArrayOf(
1f, 0f, 0f, x,
0f, 1f, 0f, y,
0f, 0f, 1f, z,
0f, 0f, 0f, 1f,
),
)
public fun scale(factor: Float): Matrix4x4 = scale(factor, factor, factor)
A uniform scale matrix.
public fun scale(x: Float, y: Float, z: Float): Matrix4x4
A non-uniform scale matrix.
public fun scale(x: Float, y: Float, z: Float): Matrix4x4 = Matrix4x4(
floatArrayOf(
x, 0f, 0f, 0f,
0f, y, 0f, 0f,
0f, 0f, z, 0f,
0f, 0f, 0f, 1f,
),
)
public fun rotationY(radians: Float): Matrix4x4
A rotation about the y axis, in radians.
public fun rotationY(radians: Float): Matrix4x4 {
val c = kotlin.math.cos(radians)
val s = kotlin.math.sin(radians)
return Matrix4x4(
floatArrayOf(
c, 0f, s, 0f,
0f, 1f, 0f, 0f,
-s, 0f, c, 0f,
0f, 0f, 0f, 1f,
),
)
}
public fun rotationX(radians: Float): Matrix4x4
A rotation about the x axis, in radians.
public fun rotationX(radians: Float): Matrix4x4 {
val c = kotlin.math.cos(radians)
val s = kotlin.math.sin(radians)
return Matrix4x4(
floatArrayOf(
1f, 0f, 0f, 0f,
0f, c, -s, 0f,
0f, s, c, 0f,
0f, 0f, 0f, 1f,
),
)
}
public fun rotationZ(radians: Float): Matrix4x4
A rotation about the z axis, in radians.
public fun rotationZ(radians: Float): Matrix4x4 {
val c = kotlin.math.cos(radians)
val s = kotlin.math.sin(radians)
return Matrix4x4(
floatArrayOf(
c, -s, 0f, 0f,
s, c, 0f, 0f,
0f, 0f, 1f, 0f,
0f, 0f, 0f, 1f,
),
)
}
internal fun readFrom(segment: MemorySegment): Matrix4x4
internal fun readFrom(segment: MemorySegment): Matrix4x4 {
val out = FloatArray(16)
MemorySegment.copy(
IPLMatrix4x4.elements(segment), ValueLayout.JAVA_FLOAT, 0L,
out, 0, 16,
)
return Matrix4x4(out)
}
}
}
public data class Box
An axis-aligned bounding box.
public data class Box(
public val min: Vector3,
public val max: Vector3,
) {
public val center: Vector3 get() = (min + max) * 0.5f
internal fun writeTo(segment: MemorySegment)
internal fun writeTo(segment: MemorySegment) {
min.writeTo(IPLBox.minCoordinates(segment))
max.writeTo(IPLBox.maxCoordinates(segment))
}
internal fun allocate(allocator: SegmentAllocator): MemorySegment
internal fun allocate(allocator: SegmentAllocator): MemorySegment =
IPLBox.allocate(allocator).also(::writeTo)
internal companion object
fun readFrom(segment: MemorySegment): Box
fun readFrom(segment: MemorySegment): Box = Box(
Vector3.readFrom(IPLBox.minCoordinates(segment)),
Vector3.readFrom(IPLBox.maxCoordinates(segment)),
)
}
}
public data class Sphere
A sphere, used both for probes and for baked-data influence regions.
public data class Sphere(
public val center: Vector3,
public val radius: Float,
) {
internal fun writeTo(segment: MemorySegment)
internal fun writeTo(segment: MemorySegment) {
center.writeTo(IPLSphere.center(segment))
IPLSphere.radius(segment, radius)
}
internal fun allocate(allocator: SegmentAllocator): MemorySegment
internal fun allocate(allocator: SegmentAllocator): MemorySegment =
IPLSphere.allocate(allocator).also(::writeTo)
internal companion object
fun readFrom(segment: MemorySegment): Sphere
fun readFrom(segment: MemorySegment): Sphere = Sphere(
Vector3.readFrom(IPLSphere.center(segment)),
IPLSphere.radius(segment),
)
}
}
public data class CoordinateSpace3
A position and orientation: three orthonormal axes plus an origin.
Used for listeners, directional sources, and Ambisonics rotation.
public data class CoordinateSpace3(
public val right: Vector3,
public val up: Vector3,
public val ahead: Vector3,
public val origin: Vector3,
) {
internal fun writeTo(segment: MemorySegment)
internal fun writeTo(segment: MemorySegment) {
right.writeTo(IPLCoordinateSpace3.right(segment))
up.writeTo(IPLCoordinateSpace3.up(segment))
ahead.writeTo(IPLCoordinateSpace3.ahead(segment))
origin.writeTo(IPLCoordinateSpace3.origin(segment))
}
internal fun allocate(allocator: SegmentAllocator): MemorySegment
internal fun allocate(allocator: SegmentAllocator): MemorySegment =
IPLCoordinateSpace3.allocate(allocator).also(::writeTo)
public companion object
public companion object {
public val IDENTITY: CoordinateSpace3
The identity frame: axes aligned to the world, origin at zero.
public val IDENTITY: CoordinateSpace3 = CoordinateSpace3(
right = Vector3.RIGHT,
up = Vector3.UP,
ahead = Vector3.FORWARD,
origin = Vector3.ZERO,
)
public fun lookingAt
Builds a right-handed frame from an ahead direction and an up hint, both of
which are normalised. up is re-orthogonalised against ahead.
public fun lookingAt(
origin: Vector3,
ahead: Vector3,
up: Vector3 = Vector3.UP,
): CoordinateSpace3 {
val forward = ahead.normalized()
// right = forward x up, NOT up x forward. Steam Audio's frame has +x right with
// -z ahead and +y up, and only this order reproduces that: (0,0,-1) x (0,1,0) is
// (1,0,0), matching [IDENTITY]. The other order yields (-1,0,0), which silently
// swaps the listener's ears -- audible, wrong, and easy to miss.
val right = (forward cross up).normalized()
val trueUp = (right cross forward).normalized()
return CoordinateSpace3(right = right, up = trueUp, ahead = forward, origin = origin)
}
internal fun readFrom(segment: MemorySegment): CoordinateSpace3
internal fun readFrom(segment: MemorySegment): CoordinateSpace3 = CoordinateSpace3(
Vector3.readFrom(IPLCoordinateSpace3.right(segment)),
Vector3.readFrom(IPLCoordinateSpace3.up(segment)),
Vector3.readFrom(IPLCoordinateSpace3.ahead(segment)),
Vector3.readFrom(IPLCoordinateSpace3.origin(segment)),
)
}
}
public data class Ray
A ray: an origin and a direction.
public data class Ray(
public val origin: Vector3,
public val direction: Vector3,
)
public data class Material
An acoustic material: per-band absorption and transmission, plus a scattering coefficient.
public data class Material(
/** Fraction of energy absorbed per frequency band. One entry per [SteamAudio.NUM_BANDS]. */
public val absorption: FloatArray,
/** Fraction of energy scattered (rather than reflected specularly) on reflection. */
public val scattering: Float,
/** Fraction of energy transmitted through the surface, per frequency band. */
public val transmission: FloatArray,
) {
init
init {
require(absorption.size == SteamAudio.NUM_BANDS) {
"absorption needs ${SteamAudio.NUM_BANDS} bands, got ${absorption.size}"
}
require(transmission.size == SteamAudio.NUM_BANDS) {
"transmission needs ${SteamAudio.NUM_BANDS} bands, got ${transmission.size}"
}
}
override fun equals(other: Any?): Boolean
override fun equals(other: Any?): Boolean = this === other || (
other is Material &&
absorption.contentEquals(other.absorption) &&
scattering == other.scattering &&
transmission.contentEquals(other.transmission)
)
override fun hashCode(): Int
override fun hashCode(): Int {
var result = absorption.contentHashCode()
result = 31 * result + scattering.hashCode()
result = 31 * result + transmission.contentHashCode()
return result
}
override fun toString(): String
override fun toString(): String =
"Material(absorption=${absorption.toList()}, scattering=$scattering, " +
"transmission=${transmission.toList()})"
public companion object
public companion object {
Builds a material from per-band values, padding or truncating to SteamAudio.NUM_BANDS.
The presets below are quoted for the 3-band configuration the shipped binaries use (low, mid, high). This adapts them if the native library was built with octave bands.
private fun of(
absorption: FloatArray,
scattering: Float,
transmission: FloatArray,
): Material {
fun fit(values: FloatArray) = FloatArray(SteamAudio.NUM_BANDS) { band ->
values[band.coerceAtMost(values.lastIndex)]
}
return Material(fit(absorption), scattering, fit(transmission))
}
public val GENERIC: Material
A generic moderately absorbent material, handy as a placeholder.
public val GENERIC: Material = of(
absorption = floatArrayOf(0.10f, 0.20f, 0.30f),
scattering = 0.05f,
transmission = floatArrayOf(0.100f, 0.050f, 0.030f),
)
public val ROCK: Material
Rock. Absorbs little, so it rings -- this is the surface that makes a cave a cave.
public val ROCK: Material = of(
absorption = floatArrayOf(0.13f, 0.20f, 0.24f),
scattering = 0.05f,
transmission = floatArrayOf(0.015f, 0.002f, 0.001f),
)
public val BRICK: Material
Brick. Slightly more absorbent than rock, still a live surface.
public val BRICK: Material = of(
absorption = floatArrayOf(0.03f, 0.04f, 0.07f),
scattering = 0.05f,
transmission = floatArrayOf(0.015f, 0.015f, 0.015f),
)
public val CONCRETE: Material
Concrete. Very reflective, especially when smooth.
public val CONCRETE: Material = of(
absorption = floatArrayOf(0.05f, 0.07f, 0.08f),
scattering = 0.05f,
transmission = floatArrayOf(0.015f, 0.002f, 0.001f),
)
public val METAL: Material
Metal. Bright and highly reflective.
public val METAL: Material = of(
absorption = floatArrayOf(0.20f, 0.07f, 0.06f),
scattering = 0.05f,
transmission = floatArrayOf(0.200f, 0.025f, 0.010f),
)
public val WOOD: Material
Wood. Absorbs the low end noticeably.
public val WOOD: Material = of(
absorption = floatArrayOf(0.11f, 0.07f, 0.06f),
scattering = 0.05f,
transmission = floatArrayOf(0.070f, 0.014f, 0.005f),
)
public val GLASS: Material
Glass. Reflective and almost acoustically transparent at low frequencies.
public val GLASS: Material = of(
absorption = floatArrayOf(0.06f, 0.03f, 0.02f),
scattering = 0.05f,
transmission = floatArrayOf(0.060f, 0.044f, 0.011f),
)
public val CARPET: Material
Carpet. Absorbs the top end hard; use it to deaden a space.
public val CARPET: Material = of(
absorption = floatArrayOf(0.24f, 0.69f, 0.73f),
scattering = 0.05f,
transmission = floatArrayOf(0.020f, 0.005f, 0.003f),
)
public val CURTAIN: Material
Heavy curtain. Strongly absorbent across the band -- a good "acoustic shadow" plane for swinging into a space to kill its reverb.
public val CURTAIN: Material = of(
absorption = floatArrayOf(0.14f, 0.35f, 0.55f),
scattering = 0.05f,
transmission = floatArrayOf(0.120f, 0.100f, 0.100f),
)
public val ANECHOIC: Material
Fully absorbing, for testing: whatever hits this surface does not come back.
public val ANECHOIC: Material = of(
absorption = floatArrayOf(1.0f, 1.0f, 1.0f),
scattering = 0.0f,
transmission = floatArrayOf(0.0f, 0.0f, 0.0f),
)
}
}