Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Voxel> = 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<Voxel> =
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package link.socket.phosphor.math

import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt

/**
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)))
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading