Skip to content

Geometry2D.kt

src/jvmMain/kotlin/afield/audio/spatial/steamaudio/Geometry2D.kt · 560 lines

package afield.audio.spatial.steamaudio

Puts a 2D game's walls, sources and listener into Steam Audio's 3D simulation: World2D fixes the axis mapping and wall height, and WallSegment, WallSet, MovableWall and SharedWalls turn line segments into scene geometry.

import kotlin.math.atan2
import kotlin.math.hypot

public data class World2D

Maps a 2D game world onto the 3D world Steam Audio simulates in.

Steam Audio is always a 3D simulation -- there is no 2D mode -- so a 2D game's geometry has to be given a height. This type fixes the convention, so walls, sources and the listener all agree.

The game frame this assumes

The standard 2D convention: geometry lies in the xy-plane, +x is right and +y is up the screen, +z points out of the screen, and the listener translates in xy and rotates about z. Both ears therefore lie in the plane of the geometry, and the top of the head points along +z. The frame is right-handed: x cross y = z.

The mapping, and why it is not the obvious one

Game (gx, gy, gz) becomes world (gx, gz, -gy).

Steam Audio's frame is +x right, +y up, -z ahead. The game's up axis is +z, so that has to become world +y -- which leaves only one choice for the remaining axis if the mapping is to stay right-handed: game +y must map to world -z, not +z.

The obvious-looking mapping (gx, gy) -> (gx, height, gy) is orientation-reversing under these conventions, and it mirrors the listener's ears. That failure is quiet: levels, reverb and distance all stay correct, and only left and right are swapped. World2DHandednessTest exists to catch exactly this.

So:

game world
+x right +x
+y up the screen -z (ahead, in Steam Audio terms)
+z out of the screen +y (up)

A listener facing game +y has a world ahead of (0, 0, -1). Use listenerFacing and point rather than converting by hand.

Why walls need a height

A 2D game's walls are line segments. Extruded along game +z they become quads -- but quads alone form an open-ended box, and rays escape through the gap, which quietly destroys the reverb. So WallSet caps the space at both ends by default. If a room sounds far deader than its materials suggest, a missing cap is the first thing to check.

One consequence worth designing around

With both ears in the plane of the geometry, every source is at zero elevation. The HRTF's elevation dimension goes unused, and front/back confusion becomes the dominant localisation error: a source directly ahead and one directly behind produce almost identical interaural time and level differences, which is why the cave demo measures roughly 0 dB for both. Real listeners resolve this with small involuntary head movements. If the player's facing turns in discrete steps, or the camera is fixed, that cue is absent and sources behind the listener will often be heard as in front.

Practical mitigations: let facing rotate smoothly rather than snapping, lean on distinguishing reverb and occlusion rather than direction alone, and consider a deliberate timbral cue for what is behind.

@property floorDepth game z of one cap -- the "floor" of the extruded space. @property ceilingDepth game z of the other cap. The gap between the two is the acoustic thickness of the space, and it matters: a taller extrusion reverberates longer than a shallow one of the same footprint. @property earDepth game z at which the listener's ears and the sources sit, normally midway.

public data class World2D(
    public val floorDepth: Float = -1.75f,
    public val ceilingDepth: Float = 1.75f,
    public val earDepth: Float = 0f,
) {

init

    init {
        require(ceilingDepth > floorDepth) {
            "ceilingDepth ($ceilingDepth) must be greater than floorDepth ($floorDepth)"
        }
        require(earDepth in floorDepth..ceilingDepth) {
            "earDepth ($earDepth) must lie between floorDepth and ceilingDepth"
        }
    }

public val thickness: Float get() = ceilingDepth - floorDepth

The acoustic thickness of the extruded space.

internal val floorWorldY: Float get() = floorDepth

World y of the floor cap.

internal val ceilingWorldY: Float get() = ceilingDepth

World y of the ceiling cap.

public fun point(x: Float, y: Float): Vector3 = Vector3(x, earDepth, -y)

Converts a 2D game point to a world position at earDepth.

public fun point(x: Float, y: Float, z: Float): Vector3 = Vector3(x, z, -y)

Converts a game point with an explicit z to a world position.

public fun listenerFacing

Builds a listener (or directional source) frame from a 2D position and a 2D facing direction, both in game coordinates.

facingX and facingY need not be normalised. A facing of (0, 1) -- up the screen -- becomes a world ahead of (0, 0, -1), which is Steam Audio's own forward.

Use this rather than assembling a CoordinateSpace3 by hand; the sign that makes it right-handed is not the one you would guess.

    public fun listenerFacing(
        x: Float,
        y: Float,
        facingX: Float,
        facingY: Float,
    ): CoordinateSpace3 {
        val length = hypot(facingX, facingY)
        require(length > 0f) { "facing direction cannot be zero" }
        return CoordinateSpace3.lookingAt(
            origin = point(x, y),
            // Game +y maps to world -z; see the mapping table above.
            ahead = Vector3(facingX / length, 0f, -facingY / length),
            up = Vector3.UP,
        )
    }

public fun segmentTransform

The transform placing a wall quad of quadLength -- centred on the origin and running along world +x, as MovableWall builds it -- onto the game segment (x0, y0)-(x1, y1).

Lives here, and is called by MovableWall.moveTo rather than reimplemented there, because the signs in it are precisely what mirrors a scene when they are wrong, and World2DHandednessTest asserts them. A second copy is a second place for them to drift, and a test asserting the copy proves nothing about the code that runs -- which is how the mapping and the transform came to disagree in the first place.

    public fun segmentTransform(
        x0: Float,
        y0: Float,
        x1: Float,
        y1: Float,
        quadLength: Float,
    ): Matrix4x4 {
        val segmentLength = hypot(x1 - x0, y1 - y0)
        require(segmentLength > 0f) { "a wall segment needs non-zero length" }
        require(quadLength > 0f) { "quadLength must be positive, got $quadLength" }

        // Scale the unit quad to the segment's length, rotate it about the vertical (world y)
        // axis, then translate to the segment's midpoint under the game-to-world mapping.
        // rotationY(angle) sends world +x to (cos, 0, -sin), which is exactly the image of the
        // game direction (cos, sin), so the angle needs no sign correction.
        val angle = atan2(y1 - y0, x1 - x0)
        val midpoint = point((x0 + x1) * 0.5f, (y0 + y1) * 0.5f, 0f)
        return Matrix4x4.translation(midpoint.x, midpoint.y, midpoint.z) *
            Matrix4x4.rotationY(angle) *
            Matrix4x4.scale(segmentLength / quadLength, 1f, 1f)
    }

public fun listenerAtAngle(x: Float, y: Float, radians: Float): CoordinateSpace3

A listener frame from a facing angle in radians, measured counter-clockwise from game +x -- the natural reading of a rotation about +z.

    public fun listenerAtAngle(x: Float, y: Float, radians: Float): CoordinateSpace3 =
        listenerFacing(x, y, kotlin.math.cos(radians), kotlin.math.sin(radians))
}

public data class WallSegment

A wall as a 2D line segment, the geometric primitive of a 2D game.

Extruded to a vertical quad between the world's floor and ceiling when built into a scene.

public data class WallSegment(
    public val x0: Float,
    public val y0: Float,
    public val x1: Float,
    public val y1: Float,
    public val material: Material = Material.ROCK,
) {

public val length: Float get() = hypot(x1 - x0, y1 - y0)

Length of the segment in the 2D plane.

public val midX: Float get() = (x0 + x1) * 0.5f

Midpoint in the 2D plane.

public val midY: Float get() = (y0 + y1) * 0.5f

Midpoint in the 2D plane.

public val angle: Float get() = atan2(y1 - y0, x1 - x0)

Angle of the segment, counter-clockwise from game +x, in radians.

init

    init {
        require(length > 0f) { "a wall segment needs non-zero length: ($x0,$y0)-($x1,$y1)" }
    }
}

public class WallSet

Builds acoustic geometry from 2D wall segments.

The typical arrangement for a 2D game with enclosed areas:

val world = World2D(floorHeight = 0f, ceilingHeight = 3.5f, earHeight = 1.6f)

// Static walls: built once when the area loads, not when it becomes visible.
val walls = WallSet(world, area.wallSegments.map { WallSegment(it.x0, it.y0, it.x1, it.y1, Material.ROCK) })
val mesh = walls.build(scene, bounds = area.bounds)
mesh.addTo(scene)
scene.commit()

// Moving segments: one instance each, transform updated per frame.
val door = MovableWall(scene, world, length = 2.0f, material = Material.WOOD)
door.moveTo(x0, y0, x1, y1)
scene.commit()          // once per frame, covering every moved segment

Measured cost, for context: an enclosed area of a few hundred segments is a few hundred triangles, where the initial build is under 2 ms and per-frame commits are at the timer floor. The per-frame budget in a 2D game goes almost entirely to Simulator.runReflections, not to geometry.

public class WallSet(
    /** The 2D-to-3D convention to build against. */
    public val world: World2D,
    /** The wall segments making up this area. */
    public val segments: List<WallSegment>,
) {

public fun build

Builds the walls -- and, unless disabled, a floor and ceiling -- as a single static mesh.

@param bounds the 2D extent to cap with a floor and ceiling, as (minX, minY, maxX, maxY). Required when capped is true. If omitted, it is derived from the segments' own extent, expanded slightly. @param capped add a floor and ceiling. Leave this on. Without caps the space is an open-topped box and rays escape vertically, which makes a reverberant room sound dead. @param doubleSided emit both windings for each wall, so segments reflect from either side. Correct for a 2D game, where "inside" and "outside" of a segment is rarely meaningful. @param floorMaterial material for the floor and ceiling caps; defaults to the material of the first segment.

body — 79 lines, from line 246
    public fun build(
        scene: Scene,
        bounds: FloatArray? = null,
        capped: Boolean = true,
        doubleSided: Boolean = true,
        floorMaterial: Material? = null,
    ): StaticMesh {
        require(segments.isNotEmpty()) { "a WallSet needs at least one segment" }

        val vertices = ArrayList<Vector3>(segments.size * 4 + 8)
        val triangles = ArrayList<Triangle>(segments.size * 4 + 4)
        val materials = ArrayList<Material>()
        val materialIndices = ArrayList<Int>(triangles.size)

        // Distinct materials, so each triangle can index one rather than duplicating.
        val materialIndexOf = HashMap<Material, Int>()
        fun materialIndex(material: Material): Int = materialIndexOf.getOrPut(material) {
            materials += material
            materials.size - 1
        }

        val floor = world.floorWorldY
        val ceiling = world.ceilingWorldY

        for (segment in segments) {
            val base = vertices.size
            // Game (x, y) maps to world (x, z, -y); the quad spans game z from floor to ceiling.
            vertices += Vector3(segment.x0, floor, -segment.y0)
            vertices += Vector3(segment.x1, floor, -segment.y1)
            vertices += Vector3(segment.x1, ceiling, -segment.y1)
            vertices += Vector3(segment.x0, ceiling, -segment.y0)

            val index = materialIndex(segment.material)
            triangles += Triangle(base, base + 1, base + 2)
            materialIndices += index
            triangles += Triangle(base, base + 2, base + 3)
            materialIndices += index
            if (doubleSided) {
                triangles += Triangle(base + 2, base + 1, base)
                materialIndices += index
                triangles += Triangle(base + 3, base + 2, base)
                materialIndices += index
            }
        }

        if (capped) {
            val extent = bounds ?: deriveBounds()
            require(extent.size == 4) { "bounds must be (minX, minY, maxX, maxY)" }
            val (minX, minY, maxX, maxY) = extent
            val capMaterial = floorMaterial ?: segments.first().material
            val capIndex = materialIndex(capMaterial)

            // Both caps are emitted double-sided. Getting a cap's winding backwards makes it
            // invisible to reflections from inside, which is the same failure as omitting it.
            for (capY in floatArrayOf(floor, ceiling)) {
                val base = vertices.size
                vertices += Vector3(minX, capY, -minY)
                vertices += Vector3(maxX, capY, -minY)
                vertices += Vector3(maxX, capY, -maxY)
                vertices += Vector3(minX, capY, -maxY)
                triangles += Triangle(base, base + 1, base + 2)
                materialIndices += capIndex
                triangles += Triangle(base, base + 2, base + 3)
                materialIndices += capIndex
                triangles += Triangle(base + 2, base + 1, base)
                materialIndices += capIndex
                triangles += Triangle(base + 3, base + 2, base)
                materialIndices += capIndex
            }
        }

        return StaticMesh(
            scene = scene,
            vertices = vertices,
            triangles = triangles,
            materials = materials,
            materialIndices = materialIndices.toIntArray(),
        )
    }

public fun triangleCount(capped: Boolean = true, doubleSided: Boolean = true): Int

Number of triangles build will produce, for budgeting.

    public fun triangleCount(capped: Boolean = true, doubleSided: Boolean = true): Int =
        segments.size * (if (doubleSided) 4 else 2) + (if (capped) 8 else 0)

The 2D extent of the segments, expanded slightly so caps fully enclose the walls.

    private fun deriveBounds(): FloatArray {
        var minX = Float.MAX_VALUE
        var minY = Float.MAX_VALUE
        var maxX = -Float.MAX_VALUE
        var maxY = -Float.MAX_VALUE
        for (segment in segments) {
            minX = minOf(minX, segment.x0, segment.x1)
            minY = minOf(minY, segment.y0, segment.y1)
            maxX = maxOf(maxX, segment.x0, segment.x1)
            maxY = maxOf(maxY, segment.y0, segment.y1)
        }
        val margin = 0.05f
        return floatArrayOf(minX - margin, minY - margin, maxX + margin, maxY + margin)
    }

    private operator fun FloatArray.component1() = this[0]
    private operator fun FloatArray.component2() = this[1]
    private operator fun FloatArray.component3() = this[2]
    private operator fun FloatArray.component4() = this[3]
}

public class MovableWall private constructor

A wall segment that can move, slide, rotate or disappear.

Wraps an InstancedMesh over a unit wall quad, so repositioning it is a transform update rather than a geometry rebuild. Measured at the timer floor even with tens of thousands of static triangles in the same scene.

val shutter = MovableWall(scene, world, length = 3f, material = Material.METAL)
shutter.addTo(scene)
// per frame:
shutter.moveTo(x0, y0, x1, y1)
scene.commit()               // one commit for all moved walls

The unit quad lives in a sub-scene this object owns, so close releases both. Several movable walls of the same length can share one sub-scene -- see sharing -- which is worth doing when there are many.

public class MovableWall private constructor(
    private val world: World2D,
    /** The length the underlying quad was built at. */
    public val length: Float,
    private val instance: InstancedMesh,
    /** Owned only when this wall created its own sub-scene. */
    private val ownedScene: Scene?,
    private val ownedMesh: StaticMesh?,
) : AutoCloseable {

public fun addTo(scene: Scene)

Adds this wall to scene. Takes effect on the next Scene.commit.

    public fun addTo(scene: Scene) {
        instance.addTo(scene)
    }

public fun removeFrom(scene: Scene)

Removes this wall from scene. Takes effect on the next Scene.commit.

    public fun removeFrom(scene: Scene) {
        instance.removeFrom(scene)
    }

public fun moveTo(scene: Scene, x0: Float, y0: Float, x1: Float, y1: Float)

Places this wall along the 2D segment from (x0, y0) to (x1, y1).

The segment's length is allowed to differ from length; the quad is scaled to fit. Takes effect on the next Scene.commit.

    public fun moveTo(scene: Scene, x0: Float, y0: Float, x1: Float, y1: Float) {
        instance.updateTransform(scene, world.segmentTransform(x0, y0, x1, y1, length))
    }

public fun moveTo(scene: Scene, segment: WallSegment)

Places this wall along segment.

    public fun moveTo(scene: Scene, segment: WallSegment) {
        moveTo(scene, segment.x0, segment.y0, segment.x1, segment.y1)
    }

public fun hide(scene: Scene)

Moves this wall far outside the world, so it stops affecting the simulation.

Cheaper and simpler than removing and re-adding, and measured at the same cost as any other transform update. Takes effect on the next Scene.commit.

    public fun hide(scene: Scene) {
        instance.updateTransform(scene, Matrix4x4.translation(0f, -1_000_000f, 0f))
    }

override fun close()

    override fun close() {
        instance.close()
        ownedMesh?.close()
        ownedScene?.close()
    }

public companion object

    public companion object {

public operator fun invoke

Creates a movable wall with its own sub-scene.

@param length the segment length the quad is built at. moveTo scales to fit, so this is only a reference length; 1.0 is a fine default.

        public operator fun invoke(
            scene: Scene,
            world: World2D,
            length: Float = 1.0f,
            material: Material = Material.ROCK,
        ): MovableWall {
            require(length > 0f) { "length must be positive, got $length" }
            val context = SceneContexts.contextOf(scene)
            val subScene = Scene(context)
            try {
                val mesh = unitWall(subScene, world, length, material)
                mesh.addTo(subScene)
                subScene.commit()
                val instance = InstancedMesh(scene, subScene, Matrix4x4.IDENTITY)
                return MovableWall(world, length, instance, subScene, mesh)
            } catch (failure: Throwable) {
                subScene.close()
                throw failure
            }
        }

public fun sharing

Creates several movable walls that share one sub-scene and one quad.

Preferable when there are many movable segments: the geometry exists once and each wall is just a transform. The returned walls must all be closed, and sharedScene must outlive them -- it is returned so the caller can close it last.

        public fun sharing(
            scene: Scene,
            world: World2D,
            count: Int,
            length: Float = 1.0f,
            material: Material = Material.ROCK,
        ): SharedWalls {
            require(count > 0) { "count must be positive, got $count" }
            val context = SceneContexts.contextOf(scene)
            val subScene = Scene(context)
            try {
                val mesh = unitWall(subScene, world, length, material)
                mesh.addTo(subScene)
                subScene.commit()
                val walls = List(count) {
                    MovableWall(
                        world,
                        length,
                        InstancedMesh(scene, subScene, Matrix4x4.IDENTITY),
                        ownedScene = null,
                        ownedMesh = null,
                    )
                }
                return SharedWalls(walls, subScene, mesh)
            } catch (failure: Throwable) {
                subScene.close()
                throw failure
            }
        }

A wall quad of length, centred on the origin and running along +x.

        private fun unitWall(
            scene: Scene,
            world: World2D,
            length: Float,
            material: Material,
        ): StaticMesh {
            val half = length * 0.5f
            return StaticMesh.plane(
                scene,
                Vector3(-half, world.floorWorldY, 0f),
                Vector3(half, world.floorWorldY, 0f),
                Vector3(half, world.ceilingWorldY, 0f),
                Vector3(-half, world.ceilingWorldY, 0f),
                material,
                doubleSided = true,
            )
        }
    }
}

public class SharedWalls internal constructor

A group of MovableWalls sharing one sub-scene.

Close this, not the individual walls: close releases the walls and then the shared geometry in the right order.

public class SharedWalls internal constructor(
    /** The walls, each independently positionable. */
    public val walls: List<MovableWall>,
    private val sharedScene: Scene,
    private val sharedMesh: StaticMesh,
) : AutoCloseable {

public operator fun get(index: Int): MovableWall = walls[index]

The wall at index.

public val size: Int get() = walls.size

How many walls this group holds.

public fun addAllTo(scene: Scene)

Adds every wall to scene.

    public fun addAllTo(scene: Scene) {
        walls.forEach { it.addTo(scene) }
    }

override fun close()

    override fun close() {
        walls.forEach { it.close() }
        sharedMesh.close()
        sharedScene.close()
    }
}

internal object SceneContexts

Recovers the SteamAudioContext a Scene belongs to.

Steam Audio has no API for asking a scene which context created it, so scenes record it here at construction. This exists so that helpers like MovableWall can create a sub-scene without the caller having to pass the context in again.

internal object SceneContexts {
    private val contexts = java.util.Collections.synchronizedMap(
        java.util.WeakHashMap<Scene, SteamAudioContext>(),
    )

fun register(scene: Scene, context: SteamAudioContext)

    fun register(scene: Scene, context: SteamAudioContext) {
        contexts[scene] = context
    }

fun contextOf(scene: Scene): SteamAudioContext

    fun contextOf(scene: Scene): SteamAudioContext = contexts[scene]
        ?: throw IllegalStateException(
            "this Scene was not created through the Kotlin API, so its context is unknown",
        )
}