Skip to content

SplatFusedPackTest.kt

src/jvmMain/kotlin/afield/graphics/render/SplatFusedPackTest.kt · 418 lines

package afield.graphics.render

import afield.graphics.scene.PlyFloatTable

import afield.graphics.scene.CompressedSplatPly
import afield.graphics.scene.CompressedSplatPlyFixture
import afield.graphics.scene.readCompressedSplatPly
import afield.graphics.scene.fabricated.GaussianSplatRenderer
import afield.math.linalg.Matrix4f
import afield.math.linalg.Vector3f
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder

FIELD_TEST id: four.splat-fused-pack.smoke runner: jvm-main entrypoint: afield.graphics.render.SplatFusedPackTestKt tags: [smoke, four, ply, splat, io, performance] timeoutSec: 120 expect: exitCode: 0 stdoutContains: - "fused pack tests PASSED"

Tests GaussianSplatRenderer.packInto — the fused compressed-file-to-GPU-layout path — against GaussianSplatRenderer.pack over the PlyFloatTable, which is the same computation the slow way.

The two are not bit-identical. The compressed format stores colour as linear 0..1 and opacity post-sigmoid, which is exactly what the shader consumes, so the fused path skips the f_dc = (c - 0.5) / SH_C0 and logit round trips that the table path performs and undoes. Every other quantity — position, scale, rotation, covariance — is computed by the same expressions in the same order and to the same spelling, so the tolerances below are tight enough that a swapped field or a wrong bit shift cannot pass.

object SplatFusedPackTest

The cases behind the four.splat-fused-pack.smoke test; main runs them all.

object SplatFusedPackTest {

    private val testDir = File(System.getProperty("java.io.tmpdir"), "splat_fused_test")
    private const val STRIDE = GaussianSplatRenderer.SPLAT_FLOAT_STRIDE

Per-field tolerance, indexed by position within a splat's 16 floats.

    private fun tolerance(field: Int): Float = when (field) {
        0, 1, 2 -> 0f                 // position: identical expressions, so identical bits
        3 -> 3e-6f                    // alpha: the sigmoid/logit round trip the fused path skips
        4, 5, 6 -> 2e-5f              // colour: the f_dc round trip, magnified by 1/SH_C0 and back
        7, 14, 15 -> 0f               // padding
        else -> 3e-5f                 // covariance: pack renormalises an already-unit quaternion
    }

fun runTests(): Boolean

    fun runTests(): Boolean {
        var passed = true
        testDir.mkdirs()
        println("Fused splat pack tests")
        println("=".repeat(60))

        val splats = CompressedSplatPlyFixture.let { _ -> syntheticSplats(600) }
        val file = File(testDir, "fused_600.compressed.ply")
        CompressedSplatPlyFixture.write(file, splats, withChunkColors = true)
        val noColorFile = File(testDir, "fused_600_nocolor.compressed.ply")
        CompressedSplatPlyFixture.write(noColorFile, splats, withChunkColors = false)

        passed = check1(file) && passed
        passed = check1(noColorFile, "12-property chunks") && passed
        passed = checkMultiSource(file) && passed
        passed = checkSparseSourceIdentity(file) && passed
        passed = checkSort(file) && passed
        passed = checkTransforms(file) && passed
        passed = checkCapacityGuard(file) && passed
        report(file)

        println("=".repeat(60))
        println(if (passed) "fused pack tests PASSED" else "fused pack tests FAILED")
        return passed
    }

The core claim: fused output equals the table path, field by field, splat by splat.

    private fun check1(file: File, label: String = "18-property chunks"): Boolean {
        print("Fused == pack ($label)... ")
        return guard {
            val reference = GaussianSplatRenderer.pack(readCompressedSplatPly(file.absolutePath))
            val raw = CompressedSplatPly.readRaw(file.absolutePath)
            check(raw.count == reference.count) { "readRaw saw ${raw.count}, pack saw ${reference.count}" }

            val dest = floatBuffer(raw.count)
            val frame = GaussianSplatRenderer.packInto(raw, dest)
            check(frame.count == reference.count) { "packInto wrote ${frame.count}" }
            check(frame.spans?.single()?.offset == 0) { "single source should span from 0" }

            var worst = 0f; var worstAt = -1
            for (i in 0 until raw.count * STRIDE) {
                val a = dest.get(i)
                val b = reference.floats[i]
                val diff = Math.abs(a - b)
                val scale = maxOf(1f, Math.abs(b))
                val relative = diff / scale
                if (relative > tolerance(i % STRIDE)) {
                    error("splat ${i / STRIDE} field ${i % STRIDE}: fused $a vs pack $b (rel $relative)")
                }
                if (relative > worst) { worst = relative; worstAt = i }
            }
            check(near(frame.boundsMin, reference.boundsMin)) { "boundsMin ${frame.boundsMin} vs ${reference.boundsMin}" }
            check(near(frame.boundsMax, reference.boundsMax)) { "boundsMax ${frame.boundsMax} vs ${reference.boundsMax}" }
            "worst %.2e at field %d".format(worst, if (worstAt < 0) -1 else worstAt % STRIDE)
        }
    }

Fifteen files into one buffer: the streaming shape.

    private fun checkMultiSource(file: File): Boolean {
        print("One destination, 15 sources... ")
        return guard {
            val raw = CompressedSplatPly.readRaw(file.absolutePath)
            val sources = List(15) { GaussianSplatRenderer.SplatSource(raw) }
            val total = raw.count * 15
            val dest = floatBuffer(total)
            val frame = GaussianSplatRenderer.packInto(sources, dest)
            check(frame.count == total) { "expected $total splats, got ${frame.count}" }
            val spans = requireNotNull(frame.spans) { "an unsorted pack should report spans" }
            check(spans.size == 15) { "expected 15 spans" }
            spans.forEachIndexed { index, span ->
                check(span.offset == index * raw.count) { "span $index at ${span.offset}" }
                check(span.count == raw.count) { "span $index count ${span.count}" }
            }
            // Every slot is byte-identical to the single-source pack of the same file, EXCEPT the
            // source-index float, which is the whole point of it being there.
            val one = floatBuffer(raw.count)
            GaussianSplatRenderer.packInto(raw, one)
            val sourceField = GaussianSplatRenderer.SPLAT_SOURCE_FLOAT
            for (slot in 0 until 15) {
                val base = slot * raw.count * STRIDE
                for (i in 0 until raw.count * STRIDE) {
                    if (i % STRIDE == sourceField) {
                        check(dest.get(base + i) == slot.toFloat()) {
                            "slot $slot splat ${i / STRIDE} carries source ${dest.get(base + i)}"
                        }
                    } else {
                        check(dest.get(base + i) == one.get(i)) { "slot $slot diverged at float $i" }
                    }
                }
            }
            "15 x ${raw.count} = $total, slots identical and each carries its source index"
        }
    }

Sparse residency must not renumber cells: palette IDs remain their original 0..14 values.

    private fun checkSparseSourceIdentity(file: File): Boolean {
        print("Sparse sources retain palette identity... ")
        return guard {
            val raw = CompressedSplatPly.readRaw(file.absolutePath)
            val sources = listOf(
                GaussianSplatRenderer.SplatSource(raw, sourceIndex = 4),
                GaussianSplatRenderer.SplatSource(raw, sourceIndex = 12)
            )
            fun identities(sort: Vector3f?): Set<Int> {
                val dest = floatBuffer(raw.count * sources.size)
                GaussianSplatRenderer.packInto(sources, dest, sortAlong = sort)
                return (0 until raw.count * sources.size).mapTo(linkedSetOf()) {
                    dest.get(it * STRIDE + GaussianSplatRenderer.SPLAT_SOURCE_FLOAT).toInt()
                }
            }
            check(identities(null) == setOf(4, 12))
            check(identities(Vector3f(0f, 0f, 1f)) == setOf(4, 12))
            "unsorted and sorted IDs are 4,12"
        }
    }

Sorting must put the far splats down first, and must not lose or duplicate any.

source — 45 lines, from line 171
    private fun checkSort(file: File): Boolean {
        print("Back-to-front CPU sort... ")
        return guard {
            val raw = CompressedSplatPly.readRaw(file.absolutePath)
            val sources = listOf(raw, raw, raw).map { GaussianSplatRenderer.SplatSource(it) }
            val total = raw.count * 3
            val unsorted = floatBuffer(total)
            GaussianSplatRenderer.packInto(sources, unsorted)

            val along = Vector3f(0f, 0f, 1f)
            val sorted = floatBuffer(total)
            val frame = GaussianSplatRenderer.packInto(sources, sorted, sortAlong = along)
            check(frame.count == total) { "sorted pack wrote ${frame.count}" }
            check(frame.spans == null) { "a sorted pack interleaves sources, so spans must be null" }

            var previous = Float.POSITIVE_INFINITY
            for (splat in 0 until total) {
                val z = sorted.get(splat * STRIDE + 2)
                check(z <= previous + 1e-6f) { "splat $splat z=$z rose above the previous $previous" }
                previous = z
            }
            // Sorting interleaves the sources, so the source index is the only way a shader can
            // still tell them apart -- check every value is a source that actually contributed.
            val seen = IntArray(sources.size)
            for (splat in 0 until total) {
                val carried = sorted.get(splat * STRIDE + GaussianSplatRenderer.SPLAT_SOURCE_FLOAT)
                val index = carried.toInt()
                check(carried == index.toFloat() && index in sources.indices) {
                    "splat $splat carries source $carried, not one of 0..${sources.size - 1}"
                }
                seen[index]++
            }
            check(seen.all { it == raw.count }) {
                "each source should contribute ${raw.count} splats, got ${seen.toList()}"
            }
            // A sort is a permutation: the multiset of positions must be unchanged.
            check(positionHash(unsorted, total) == positionHash(sorted, total)) {
                "sorting changed the set of splats, it must only reorder them"
            }
            check(near(frame.boundsMin, GaussianSplatRenderer.packInto(sources, unsorted).boundsMin)) {
                "bounds must not depend on ordering"
            }
            "$total splats, monotone in z, permutation verified"
        }
    }

A per-source transform must move the splats as ellipsoids: the centre by the affine map, and the covariance by the matching congruence. Checked against the definition rather than against a second implementation — a translation must leave the covariance alone, a rotation must conjugate it, and a uniform scale must scale it by the square.

source — 74 lines, from line 223
    private fun checkTransforms(file: File): Boolean {
        print("Per-source transforms... ")
        return guard {
            val raw = CompressedSplatPly.readRaw(file.absolutePath)
            val n = raw.count
            val plain = floatBuffer(n)
            GaussianSplatRenderer.packInto(raw, plain)

            // Identity must be indistinguishable from no transform at all.
            val identity = floatBuffer(n)
            GaussianSplatRenderer.packInto(raw, identity, transform = Matrix4f())
            for (i in 0 until n * STRIDE) {
                check(identity.get(i) == plain.get(i)) { "identity transform changed float $i" }
            }

            // Pure translation: centres shift, covariance untouched.
            val shifted = floatBuffer(n)
            GaussianSplatRenderer.packInto(raw, shifted, transform = Matrix4f().translation(3f, -2f, 7f))
            for (splat in 0 until n) {
                val at = splat * STRIDE
                check(Math.abs(shifted.get(at) - (plain.get(at) + 3f)) < 1e-4f) { "splat $splat x" }
                check(Math.abs(shifted.get(at + 1) - (plain.get(at + 1) - 2f)) < 1e-4f) { "splat $splat y" }
                check(Math.abs(shifted.get(at + 2) - (plain.get(at + 2) + 7f)) < 1e-4f) { "splat $splat z" }
                for (field in 8..13) {
                    check(Math.abs(shifted.get(at + field) - plain.get(at + field)) < 1e-9f) {
                        "translation must not touch the covariance, but field $field moved"
                    }
                }
            }

            // Uniform scale: covariance scales by the square, so its trace does too.
            val scaled = floatBuffer(n)
            val k = 2.5f
            GaussianSplatRenderer.packInto(raw, scaled, transform = Matrix4f().scaling(k, k, k))
            for (splat in 0 until n) {
                val at = splat * STRIDE
                val traceBefore = plain.get(at + 8) + plain.get(at + 11) + plain.get(at + 13)
                val traceAfter = scaled.get(at + 8) + scaled.get(at + 11) + scaled.get(at + 13)
                check(Math.abs(traceAfter - traceBefore * k * k) <= 1e-4f * maxOf(1f, traceAfter)) {
                    "splat $splat covariance trace $traceAfter, expected ${traceBefore * k * k}"
                }
                check(Math.abs(scaled.get(at) - plain.get(at) * k) < 1e-4f) { "splat $splat x not scaled" }
            }

            // A rotation preserves the ellipsoid's volume, so det(covariance) is invariant.
            val rotated = floatBuffer(n)
            val turn = rotationMatrix(0.7f, 0.3f, 0.5f, 0.81f)
            GaussianSplatRenderer.packInto(raw, rotated, transform = turn)
            var worstDet = 0f
            for (splat in 0 until n) {
                val a = determinant(plain, splat)
                val b = determinant(rotated, splat)
                val relative = Math.abs(a - b) / maxOf(1e-20f, Math.abs(a))
                if (relative > worstDet) worstDet = relative
                check(relative < 2e-2f) { "splat $splat covariance det $b vs $a under rotation" }
            }

            // Different transforms per source must not bleed into each other.
            val mixed = floatBuffer(n * 2)
            GaussianSplatRenderer.packInto(
                listOf(
                    GaussianSplatRenderer.SplatSource(raw, Matrix4f().translation(10f, 0f, 0f)),
                    GaussianSplatRenderer.SplatSource(raw, Matrix4f().translation(-10f, 0f, 0f))
                ),
                mixed
            )
            for (splat in 0 until n) {
                val a = mixed.get(splat * STRIDE)
                val b = mixed.get((n + splat) * STRIDE)
                check(Math.abs((a - b) - 20f) < 1e-3f) { "per-source transforms bled: $a vs $b" }
            }
            "identity/translate/scale/rotate hold; worst det drift %.1e".format(worstDet)
        }
    }

A rotation matrix from a quaternion, built here rather than via Matrix4f.rotation so the test does not depend on a Quaternionf conversion it is not trying to exercise. Matrix4f is column-major with m<column><row>, so each set group of four is a COLUMN.

    private fun rotationMatrix(qw: Float, qx: Float, qy: Float, qz: Float): Matrix4f {
        val n = kotlin.math.sqrt(qw * qw + qx * qx + qy * qy + qz * qz)
        val w = qw / n; val x = qx / n; val y = qy / n; val z = qz / n
        val r00 = 1 - 2 * (y * y + z * z); val r01 = 2 * (x * y - w * z); val r02 = 2 * (x * z + w * y)
        val r10 = 2 * (x * y + w * z); val r11 = 1 - 2 * (x * x + z * z); val r12 = 2 * (y * z - w * x)
        val r20 = 2 * (x * z - w * y); val r21 = 2 * (y * z + w * x); val r22 = 1 - 2 * (x * x + y * y)
        return Matrix4f().set(
            r00, r10, r20, 0f,
            r01, r11, r21, 0f,
            r02, r12, r22, 0f,
            0f, 0f, 0f, 1f
        )
    }

det of the symmetric covariance stored at fields 8..13 as xx xy xz yy yz zz.

    private fun determinant(buffer: java.nio.FloatBuffer, splat: Int): Float {
        val at = splat * STRIDE
        val xx = buffer.get(at + 8); val xy = buffer.get(at + 9); val xz = buffer.get(at + 10)
        val yy = buffer.get(at + 11); val yz = buffer.get(at + 12); val zz = buffer.get(at + 13)
        return xx * (yy * zz - yz * yz) - xy * (xy * zz - yz * xz) + xz * (xy * yz - yy * xz)
    }

    private fun checkCapacityGuard(file: File): Boolean {
        print("Undersized destination refuses... ")
        return guard {
            val raw = CompressedSplatPly.readRaw(file.absolutePath)
            val tooSmall = floatBuffer(raw.count - 1)
            val message = runCatching { GaussianSplatRenderer.packInto(raw, tooSmall) }
                .exceptionOrNull()?.message ?: error("an undersized destination should have thrown")
            check(message.contains("need ${raw.count}")) { "unhelpful message: $message" }
            "says what it needed"
        }
    }

Throughput, printed not asserted -- a timing floor in a smoke test is a flake generator.

    private fun report(file: File) {
        val raw = CompressedSplatPly.readRaw(file.absolutePath)
        val sources = List(15) { GaussianSplatRenderer.SplatSource(raw) }
        val total = raw.count * 15
        val dest = floatBuffer(total)
        val scratch = GaussianSplatRenderer.PackScratch()
        val along = Vector3f(0f, 0f, 1f)

        fun bench(label: String, body: () -> Unit) {
            repeat(20) { body() }
            val t0 = System.nanoTime()
            repeat(50) { body() }
            val ns = (System.nanoTime() - t0) / 50
            println("  %-38s %6.2f ms/frame  %7.2f M splats/s".format(label, ns / 1e6, total * 1e9 / ns / 1e6))
        }
        println("\n  one frame = 15 sources x ${raw.count} splats = $total")
        bench("fused, unsorted") { GaussianSplatRenderer.packInto(sources, dest, scratch = scratch) }
        bench("fused, CPU back-to-front sort") {
            GaussianSplatRenderer.packInto(sources, dest, sortAlong = along, scratch = scratch)
        }
        val table = readCompressedSplatPly(file.absolutePath)
        bench("table path (decompress once + pack x15)") {
            repeat(15) { GaussianSplatRenderer.pack(table) }
        }
        println()
    }
source — 47 lines, from line 367
    private fun floatBuffer(splats: Int) = ByteBuffer
        .allocateDirect(maxOf(1, splats) * STRIDE * Float.SIZE_BYTES)
        .order(ByteOrder.nativeOrder())
        .asFloatBuffer()

    private fun positionHash(buffer: java.nio.FloatBuffer, count: Int): Long {
        var hash = 0L
        for (splat in 0 until count) {
            for (field in 0..2) {
                hash += java.lang.Float.floatToRawIntBits(buffer.get(splat * STRIDE + field)).toLong() * 31L
            }
        }
        return hash
    }

    private fun near(a: Vector3f, b: Vector3f) =
        Math.abs(a.x - b.x) < 1e-5f && Math.abs(a.y - b.y) < 1e-5f && Math.abs(a.z - b.z) < 1e-5f

    private inline fun guard(body: () -> String): Boolean =
        try {
            println("OK  ${body()}")
            true
        } catch (e: Throwable) {
            println("FAIL: ${e.message}")
            false
        }

    private fun syntheticSplats(count: Int): List<CompressedSplatPlyFixture.Splat> {
        var state = 0x5DEECE66DL
        fun next(): Float {
            state = (state * 6364136223846793005L + 1442695040888963407L) and 0x7FFFFFFFFFFFFFFFL
            return ((state ushr 24) and 0xFFFFFF).toFloat() / 0xFFFFFF.toFloat()
        }
        fun span(low: Float, high: Float) = low + next() * (high - low)
        return (0 until count).map {
            val rot = floatArrayOf(span(-1f, 1f), span(-1f, 1f), span(-1f, 1f), span(-1f, 1f))
            val length = kotlin.math.sqrt(rot.sumOf { (it * it).toDouble() }).toFloat()
            CompressedSplatPlyFixture.Splat(
                x = span(-1f, 1f), y = span(-1f, 1f), z = span(0.5f, 3f),
                fDc = FloatArray(3) { span(-1.5f, 1.5f) },
                opacity = span(-6f, 6f),
                rot = FloatArray(4) { rot[it] / length },
                scale = FloatArray(3) { span(-5f, -2f) }
            )
        }
    }
}

fun main()

fun main() {
    System.exit(if (SplatFusedPackTest.runTests()) 0 else 1)
}