From 4d67d7d40ee04b142db029a45079dffda02a76b9 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sun, 24 May 2026 16:49:03 -0500 Subject: [PATCH] Add voxel sphere primitive --- .../link/socket/phosphor/field/VoxelSphere.kt | 166 ++++++++++++++++++ .../link/socket/phosphor/math/Vector3.kt | 43 +++++ .../socket/phosphor/field/VoxelSphereTest.kt | 87 +++++++++ .../link/socket/phosphor/math/Vector3Test.kt | 13 ++ 4 files changed, 309 insertions(+) create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/VoxelSphere.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/field/VoxelSphereTest.kt diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/VoxelSphere.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/VoxelSphere.kt new file mode 100644 index 0000000..40dcac4 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/VoxelSphere.kt @@ -0,0 +1,166 @@ +package link.socket.phosphor.field + +import kotlin.math.acos +import kotlin.math.atan2 +import kotlin.math.max +import kotlin.math.sqrt +import link.socket.phosphor.math.Vector3 + +/** + * A single voxel in a sphere lattice. + * + * Values are computed once when the lattice is built so renderers can iterate + * the immutable voxel list without recomputing geometry per frame. + */ +data class Voxel( + val gridX: Int, + val gridY: Int, + val gridZ: Int, + val normalizedPos: Vector3, + val unitDirection: Vector3, + val theta: Float, + val phi: Float, + val distance: Float, + val jitter: Vector3, +) + +/** + * A deterministic sphere-shaped voxel lattice centered on the grid origin. + * + * @property resolution Radius of the integer lattice in grid cells. + */ +class VoxelSphere(val resolution: Int) { + init { + require(resolution >= 0) { "resolution must be >= 0" } + } + + val voxels: List = buildVoxels(resolution) + + val count: Int get() = voxels.size + + val worldScale: Float = TARGET_WORLD_SIZE / max(resolution, 1) + + fun rebuild(newResolution: Int): VoxelSphere = VoxelSphere(newResolution) + + companion object { + private const val TARGET_WORLD_SIZE = 11f + private const val RADIUS_INFLATION = 0.45f + + fun totalCount(resolution: Int): Int { + require(resolution >= 0) { "resolution must be >= 0" } + + var count = 0 + for (x in -resolution..resolution) { + for (y in -resolution..resolution) { + for (z in -resolution..resolution) { + if (isInsideSphere(x, y, z, resolution)) { + count++ + } + } + } + } + return count + } + + private fun buildVoxels(resolution: Int): List = + buildList(totalCount(resolution)) { + val normalizationScale = max(resolution, 1).toFloat() + for (x in -resolution..resolution) { + for (y in -resolution..resolution) { + for (z in -resolution..resolution) { + if (isInsideSphere(x, y, z, resolution)) { + add(createVoxel(x, y, z, normalizationScale)) + } + } + } + } + } + + private fun isInsideSphere( + x: Int, + y: Int, + z: Int, + resolution: Int, + ): Boolean { + val radius = resolution + RADIUS_INFLATION + val distance = sqrt((x * x + y * y + z * z).toFloat()) + return distance <= radius + } + + private fun createVoxel( + x: Int, + y: Int, + z: Int, + normalizationScale: Float, + ): Voxel { + val position = Vector3(x.toFloat(), y.toFloat(), z.toFloat()) + val normalizedPos = position * (1f / normalizationScale) + val distance = position.length() + val unitDirection = normalizedPos.normalized() + val theta = atan2(z.toFloat(), x.toFloat()) + val phi = + if (distance > 0f) { + acos((y / distance).coerceIn(-1f, 1f)) + } else { + 0f + } + + return Voxel( + gridX = x, + gridY = y, + gridZ = z, + normalizedPos = normalizedPos, + unitDirection = unitDirection, + theta = theta, + phi = phi, + distance = distance, + jitter = deterministicJitter(x, y, z), + ) + } + + private fun deterministicJitter( + x: Int, + y: Int, + z: Int, + ): Vector3 = + Vector3( + hashToJitter(x, y, z, 0x21), + hashToJitter(x, y, z, 0x43), + hashToJitter(x, y, z, 0x65), + ) + + private fun hashToJitter( + x: Int, + y: Int, + z: Int, + salt: Int, + ): Float { + var hash = 0x811C9DC5.toInt() + hash = mix(hash, x) + hash = mix(hash, y) + hash = mix(hash, z) + hash = mix(hash, salt) + hash = hash xor (hash ushr 16) + hash *= 0x7FEB352D + hash = hash xor (hash ushr 15) + hash *= 0x846CA68B.toInt() + hash = hash xor (hash ushr 16) + + val normalized = (hash ushr 1) / Int.MAX_VALUE.toFloat() + return normalized - 0.5f + } + + private fun mix( + hash: Int, + value: Int, + ): Int = (hash xor value) * 0x01000193 + } +} + +/** + * Return true when this voxel's outward direction faces a viewer on the +Z axis. + */ +fun Voxel.facingCamera( + orbQuaternion: Vector3, + threshold: Float = 0.15f, +): Boolean = unitDirection.rotatedBy(orbQuaternion).z > threshold diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/math/Vector3.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/math/Vector3.kt index cbb4fe9..d7f15f3 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/math/Vector3.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/math/Vector3.kt @@ -1,5 +1,7 @@ package link.socket.phosphor.math +import kotlin.math.cos +import kotlin.math.sin import kotlin.math.sqrt /** @@ -32,6 +34,47 @@ data class Vector3(val x: Float, val y: Float, val z: Float) { return if (len > 0f) this * (1f / len) else ZERO } + /** + * Rotate this vector by X/Y/Z radians, in that order. + * + * This follows the same axis conventions as [Matrix4.rotateX] and [Matrix4.rotateY]. + */ + fun rotatedBy(rotation: Vector3): Vector3 { + val xRotated = rotateX(rotation.x) + val yRotated = xRotated.rotateY(rotation.y) + return yRotated.rotateZ(rotation.z) + } + + private fun rotateX(radians: Float): Vector3 { + val c = cos(radians) + val s = sin(radians) + return Vector3( + x = x, + y = c * y - s * z, + z = s * y + c * z, + ) + } + + private fun rotateY(radians: Float): Vector3 { + val c = cos(radians) + val s = sin(radians) + return Vector3( + x = c * x + s * z, + y = y, + z = -s * x + c * z, + ) + } + + private fun rotateZ(radians: Float): Vector3 { + val c = cos(radians) + val s = sin(radians) + return Vector3( + x = c * x - s * y, + y = s * x + c * y, + z = z, + ) + } + companion object { val ZERO = Vector3(0f, 0f, 0f) val UP = Vector3(0f, 1f, 0f) diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/field/VoxelSphereTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/field/VoxelSphereTest.kt new file mode 100644 index 0000000..3037114 --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/field/VoxelSphereTest.kt @@ -0,0 +1,87 @@ +package link.socket.phosphor.field + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import link.socket.phosphor.math.Vector3 + +class VoxelSphereTest { + @Test + fun `resolution 7 produces documented voxel count`() { + val sphere = VoxelSphere(7) + + assertEquals(1743, sphere.count) + assertEquals(1743, VoxelSphere.totalCount(7)) + } + + @Test + fun `first voxel normalized position is in unit range`() { + val first = VoxelSphere(7).voxels.first() + + assertTrue(first.normalizedPos.x in -1f..1f) + assertTrue(first.normalizedPos.y in -1f..1f) + assertTrue(first.normalizedPos.z in -1f..1f) + } + + @Test + fun `same resolution produces identical voxels and jitter`() { + val first = VoxelSphere(7) + val second = VoxelSphere(7) + + assertEquals(first.voxels, second.voxels) + first.voxels.zip(second.voxels).forEach { (a, b) -> + assertEquals(a.jitter, b.jitter) + } + } + + @Test + fun `rebuild with same resolution is equivalent`() { + val sphere = VoxelSphere(7) + + assertEquals(VoxelSphere(7).voxels, sphere.rebuild(7).voxels) + } + + @Test + fun `world scale keeps orb size constant across resolutions`() { + assertEquals(11f, VoxelSphere(0).worldScale) + assertEquals(11f, VoxelSphere(1).worldScale) + assertEquals(11f / 7f, VoxelSphere(7).worldScale) + assertEquals(1f, VoxelSphere(11).worldScale) + } + + @Test + fun `jitter components stay in expected range`() { + VoxelSphere(7).voxels.forEach { voxel -> + assertTrue(voxel.jitter.x in -0.5f..0.5f) + assertTrue(voxel.jitter.y in -0.5f..0.5f) + assertTrue(voxel.jitter.z in -0.5f..0.5f) + } + } + + @Test + fun `facing camera uses rotated unit direction`() { + val front = + Voxel( + gridX = 0, + gridY = 0, + gridZ = 1, + normalizedPos = Vector3.FORWARD, + unitDirection = Vector3.FORWARD, + theta = 0f, + phi = 0f, + distance = 1f, + jitter = Vector3.ZERO, + ) + val back = + front.copy( + gridZ = -1, + normalizedPos = -Vector3.FORWARD, + unitDirection = -Vector3.FORWARD, + ) + + assertTrue(front.facingCamera(Vector3.ZERO)) + assertFalse(back.facingCamera(Vector3.ZERO)) + assertFalse(front.facingCamera(Vector3(0f, kotlin.math.PI.toFloat(), 0f))) + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/math/Vector3Test.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/math/Vector3Test.kt index 1ac83ff..3197d08 100644 --- a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/math/Vector3Test.kt +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/math/Vector3Test.kt @@ -1,5 +1,6 @@ package link.socket.phosphor.math +import kotlin.math.PI import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -100,6 +101,18 @@ class Vector3Test { assertEquals(14f, Vector3(1f, 2f, 3f).lengthSquared()) } + @Test + fun `rotatedBy preserves vector for zero rotation`() { + val vector = Vector3(1f, 2f, 3f) + assertApprox(vector, vector.rotatedBy(Vector3.ZERO)) + } + + @Test + fun `rotatedBy follows matrix Y rotation convention`() { + val result = Vector3.FORWARD.rotatedBy(Vector3(0f, (PI / 2).toFloat(), 0f)) + assertApprox(Vector3.RIGHT, result) + } + @Test fun `ZERO has all zero components`() { assertEquals(0f, Vector3.ZERO.x)