Skip to content

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),
        )
    }
}