Skip to content

CompressedSplatPlyWriter.kt

src/jvmMain/kotlin/afield/graphics/scene/CompressedSplatPlyWriter.kt · 378 lines

@file:Suppress("MemberVisibilityCanBePrivate", "unused")

package afield.graphics.scene

import afield.graphics.render.SplatCloudEditTest
import afield.graphics.render.SplatRaster

import afield.graphics.render.SplatCloud
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt

How an edited splat cloud leaves Field, in the PlayCanvas .compressed.ply format it arrived in.

Box code reaches the object below through SplatCloud.write for a file and SplatCloud.toRawSplats for the packed tables a renderer uploads. Both run the same encoder over the same ordering, so what a preview shows and what a save contains are quantized alike. CompressedSplatPly is the reader this inverts.

Quantization is per chunk of 256 splats against that chunk's own bounds, which makes a splat's accuracy a question of how tightly its neighbours in file order bound it. That is what CompressedSplatPlyWriter.write's default Morton sort is for.

object CompressedSplatPlyWriter

Writes PlayCanvas .compressed.ply files — the inverse of CompressedSplatPly.

Field previously had no reason to write these, and CompressedSplatPlyFixture says so in as many words. SplatCloud gave it one: once a cloud can be cropped, filtered, decimated, transformed and merged on the CPU, the result has to be able to leave, and the format it should leave in is the one it arrived in — readable by this tree, by SuperSplat, by the PlayCanvas engine and by splat-transform.

The fixture stays a fixture. It exists to check the reader against the reference encoder and is deliberately optionless — no chunking policy, no Morton sort. This is the feature: it sorts, it takes a cloud rather than a list, and SplatCloudEditTest cross-checks the two against each other on the same input, which is a better arrangement than either being the other's only witness.

Morton order is not decoration

Position, scale and colour are quantized per chunk of 256, against that chunk's own min and max. So the error a splat suffers is set by how tightly its 255 neighbours-in-file-order bound it. Write a cloud in an arbitrary order and every chunk's box is the whole scene, which spends 11 bits on a range 11 bits cannot cover; write it in Morton order and each chunk is a small spatial cell.

SplatCloudEditTest.checkMortonOrderEarnsItsKeep measures this rather than asserting it on faith — the same cloud written both ways, read back, and the position error compared — and it measures the mean, because the worst error belongs to whichever chunk straddles an octant boundary and no amount of sorting can help it. Measured on 8000 splats: 1.02e-4 mean against 2.25e-4 unsorted.

object CompressedSplatPlyWriter {

    private const val CHUNK = CompressedSplatPly.CHUNK_SIZE

const val SCALE_LOG_LIMIT = 20f

Log-scale clamp, matching splat-transform. Beyond this the exp is not worth storing.

fun write(file: File, cloud: SplatCloud, mortonSort: Boolean = true): Int

Write cloud to file.

@param mortonSort reorder into Morton (Z-curve) order first, so each chunk of 256 covers a small spatial cell and the per-chunk quantization stays tight. Off only makes sense when the cloud's existing order matters more than its accuracy — a cloud already packed farthest-first for an unsorted draw, say. @return how many splats were written.

    fun write(file: File, cloud: SplatCloud, mortonSort: Boolean = true): Int {
        require(cloud.count > 0) { "cannot write an empty cloud to $file" }
        val order = if (mortonSort) mortonOrder(cloud) else IntArray(cloud.count) { it }
        val chunkCount = (cloud.count + CHUNK - 1) / CHUNK

        val chunkProperties =
            CompressedSplatPly.requiredChunkProperties + CompressedSplatPly.colorChunkProperties
        val header = buildString {
            append("ply\n")
            append("format binary_little_endian 1.0\n")
            append("comment Generated by Field CompressedSplatPlyWriter\n")
            append("element chunk $chunkCount\n")
            chunkProperties.forEach { append("property float $it\n") }
            append("element vertex ${cloud.count}\n")
            CompressedSplatPly.vertexProperties.forEach { append("property uint $it\n") }
            append("end_header\n")
        }

        val chunkData = ByteBuffer
            .allocate(chunkCount * chunkProperties.size * 4).order(ByteOrder.LITTLE_ENDIAN)
        val vertexData = ByteBuffer.allocate(cloud.count * 16).order(ByteOrder.LITTLE_ENDIAN)
        encodeInto(cloud, order, chunkData, vertexData)

        file.outputStream().buffered().use { stream ->
            stream.write(header.toByteArray())
            stream.write(chunkData.array())
            stream.write(vertexData.array())
        }
        return cloud.count
    }

fun quantize(cloud: SplatCloud, mortonSort: Boolean = true): CompressedSplatPly.RawSplats

Quantize cloud into the same payload CompressedSplatPly.readRaw would hand back after a round trip through write, without touching the disk.

This is what makes an edited cloud renderable immediately: packInto and therefore SplatRaster and a live GPU Cloud.reupload all consume CompressedSplatPly.RawSplats, and going through a temporary file to get one would be absurd. It is the same encoder write uses, so a preview is not a different picture from the save.

    fun quantize(cloud: SplatCloud, mortonSort: Boolean = true): CompressedSplatPly.RawSplats {
        require(cloud.count > 0) { "cannot quantize an empty cloud" }
        val order = if (mortonSort) mortonOrder(cloud) else IntArray(cloud.count) { it }
        val chunkCount = (cloud.count + CHUNK - 1) / CHUNK
        val chunkProperties =
            CompressedSplatPly.requiredChunkProperties + CompressedSplatPly.colorChunkProperties
        val chunkData = ByteBuffer
            .allocate(chunkCount * chunkProperties.size * 4).order(ByteOrder.LITTLE_ENDIAN)
        val vertexData = ByteBuffer.allocate(cloud.count * 16).order(ByteOrder.LITTLE_ENDIAN)
        encodeInto(cloud, order, chunkData, vertexData)

        // chunkData is already in requiredChunkProperties + colorChunkProperties order, which is
        // exactly the 18-per-chunk layout RawSplats.chunkRanges uses.
        val ranges = FloatArray(chunkCount * 18)
        chunkData.rewind()
        for (index in ranges.indices) ranges[index] = chunkData.getFloat(index * 4)
        val packed = IntArray(cloud.count * 4)
        vertexData.rewind()
        for (index in packed.indices) packed[index] = vertexData.getInt(index * 4)
        return CompressedSplatPly.RawSplats(
            chunkRanges = ranges, packed = packed, count = cloud.count, hasChunkColors = true
        )
    }

The encoder: splat-transform's CompressedChunk.pack, over a SplatCloud

source — 81 lines, from line 140
    private fun encodeInto(
        cloud: SplatCloud,
        order: IntArray,
        chunkData: ByteBuffer,
        vertexData: ByteBuffer
    ) {
        val chunkCount = (cloud.count + CHUNK - 1) / CHUNK
        for (chunkIndex in 0 until chunkCount) {
            val start = chunkIndex * CHUNK
            val end = min(cloud.count, start + CHUNK)

            // Ranges over the chunk's real members only. The reference pads a partial chunk by
            // repeating its last splat, which cannot move a min or a max because the repeat is itself
            // real data -- so measuring over the members directly gives identical ranges.
            var minX = Float.MAX_VALUE; var maxX = -Float.MAX_VALUE
            var minY = Float.MAX_VALUE; var maxY = -Float.MAX_VALUE
            var minZ = Float.MAX_VALUE; var maxZ = -Float.MAX_VALUE
            var minSx = Float.MAX_VALUE; var maxSx = -Float.MAX_VALUE
            var minSy = Float.MAX_VALUE; var maxSy = -Float.MAX_VALUE
            var minSz = Float.MAX_VALUE; var maxSz = -Float.MAX_VALUE
            var minR = Float.MAX_VALUE; var maxR = -Float.MAX_VALUE
            var minG = Float.MAX_VALUE; var maxG = -Float.MAX_VALUE
            var minB = Float.MAX_VALUE; var maxB = -Float.MAX_VALUE

            for (slot in start until end) {
                val splat = order[slot]
                minX = min(minX, cloud.positions[splat * 3]); maxX = max(maxX, cloud.positions[splat * 3])
                minY = min(minY, cloud.positions[splat * 3 + 1]); maxY = max(maxY, cloud.positions[splat * 3 + 1])
                minZ = min(minZ, cloud.positions[splat * 3 + 2]); maxZ = max(maxZ, cloud.positions[splat * 3 + 2])
                val lx = logClamped(cloud.scales[splat * 3])
                val ly = logClamped(cloud.scales[splat * 3 + 1])
                val lz = logClamped(cloud.scales[splat * 3 + 2])
                minSx = min(minSx, lx); maxSx = max(maxSx, lx)
                minSy = min(minSy, ly); maxSy = max(maxSy, ly)
                minSz = min(minSz, lz); maxSz = max(maxSz, lz)
                minR = min(minR, cloud.colors[splat * 3]); maxR = max(maxR, cloud.colors[splat * 3])
                minG = min(minG, cloud.colors[splat * 3 + 1]); maxG = max(maxG, cloud.colors[splat * 3 + 1])
                minB = min(minB, cloud.colors[splat * 3 + 2]); maxB = max(maxB, cloud.colors[splat * 3 + 2])
            }

            // requiredChunkProperties order, then colorChunkProperties order.
            chunkData.putFloat(minX).putFloat(minY).putFloat(minZ)
            chunkData.putFloat(maxX).putFloat(maxY).putFloat(maxZ)
            chunkData.putFloat(minSx).putFloat(minSy).putFloat(minSz)
            chunkData.putFloat(maxSx).putFloat(maxSy).putFloat(maxSz)
            chunkData.putFloat(minR).putFloat(minG).putFloat(minB)
            chunkData.putFloat(maxR).putFloat(maxG).putFloat(maxB)

            for (slot in start until end) {
                val splat = order[slot]
                vertexData.putInt(
                    pack111011(
                        normalize(cloud.positions[splat * 3], minX, maxX),
                        normalize(cloud.positions[splat * 3 + 1], minY, maxY),
                        normalize(cloud.positions[splat * 3 + 2], minZ, maxZ)
                    )
                )
                vertexData.putInt(
                    packRotation(
                        cloud.rotations[splat * 4], cloud.rotations[splat * 4 + 1],
                        cloud.rotations[splat * 4 + 2], cloud.rotations[splat * 4 + 3]
                    )
                )
                vertexData.putInt(
                    pack111011(
                        normalize(logClamped(cloud.scales[splat * 3]), minSx, maxSx),
                        normalize(logClamped(cloud.scales[splat * 3 + 1]), minSy, maxSy),
                        normalize(logClamped(cloud.scales[splat * 3 + 2]), minSz, maxSz)
                    )
                )
                vertexData.putInt(
                    pack8888(
                        normalize(cloud.colors[splat * 3], minR, maxR),
                        normalize(cloud.colors[splat * 3 + 1], minG, maxG),
                        normalize(cloud.colors[splat * 3 + 2], minB, maxB),
                        cloud.alphas[splat].coerceIn(0f, 1f)
                    )
                )
            }
        }
    }

const val MORTON_BITS = 13

Bits per axis in the Morton grid. See mortonOrder for why it is 13 and not 21.

Bits of splat index carried in the low end of a Morton sort key.

    private const val MORTON_INDEX_BITS = 24

fun mortonOrder(cloud: SplatCloud): IntArray

Splat indices in Morton (Z-curve) order over a MORTON_BITS-per-axis grid across the cloud's bounds.

The sort key is the interleaved code in the high bits and the splat index in the low MORTON_INDEX_BITS, so one primitive LongArray sort does the whole job with no boxing and no comparator. That budget is why the grid is 13 bits per axis and not the 21 that would otherwise fit: 3 × 13 + 24 = 63, which is every bit of a non-negative Long.

The first version of this used 21 bits per axis and shifted the 63-bit code left by 24 to make room for the index. That silently discarded the top 24 bits of every code — which are precisely the coarse spatial subdivision, the part that decides which chunk a splat lands in. The result sorted correctly on sub-cell detail and essentially randomly on anything larger, so it looked like a working Morton sort and bought nothing at all. SplatCloudEditTest.checkMortonOrderEarnsItsKeep is what caught it, and the bug was put back afterwards to confirm the diagnosis rather than argue it: with the overflow, mean position error was 2.244e-4 against 2.254e-4 unsorted — a 1.004x improvement, which is none. With 13 bits it is 1.02e-4 against 2.25e-4, or 2.2x tighter.

13 bits is 8192 cells per axis, which is finer than the 11 bits the format will eventually spend on a position and far finer than the resolution a 256-splat chunk can express. The sort only has to get neighbours into the same chunk; the per-chunk quantization does the rest.

Non-finite positions sort to the end rather than poisoning the comparator, so a cloud carrying a few NaNs still writes, with its damage confined to the final chunk. SplatCloud.filtered removes them if you would rather they were gone.

body — 49 lines, from line 255
    fun mortonOrder(cloud: SplatCloud): IntArray {
        var minX = Float.MAX_VALUE; var maxX = -Float.MAX_VALUE
        var minY = Float.MAX_VALUE; var maxY = -Float.MAX_VALUE
        var minZ = Float.MAX_VALUE; var maxZ = -Float.MAX_VALUE
        for (splat in 0 until cloud.count) {
            val x = cloud.positions[splat * 3]
            val y = cloud.positions[splat * 3 + 1]
            val z = cloud.positions[splat * 3 + 2]
            if (!x.isFinite() || !y.isFinite() || !z.isFinite()) continue
            minX = min(minX, x); maxX = max(maxX, x)
            minY = min(minY, y); maxY = max(maxY, y)
            minZ = min(minZ, z); maxZ = max(maxZ, z)
        }
        val spanX = if (maxX > minX) maxX - minX else 1f
        val spanY = if (maxY > minY) maxY - minY else 1f
        val spanZ = if (maxZ > minZ) maxZ - minZ else 1f
        val grid = (1 shl MORTON_BITS) - 1
        // Every code must survive the shift that makes room for the index, or the coarse subdivision
        // is thrown away and the sort silently stops working. See the KDoc.
        require(3 * MORTON_BITS + MORTON_INDEX_BITS <= 63) {
            "a Morton key of ${3 * MORTON_BITS} bits plus $MORTON_INDEX_BITS of index does not fit a Long"
        }
        require(cloud.count <= (1 shl MORTON_INDEX_BITS)) {
            "${cloud.count} splats needs more than $MORTON_INDEX_BITS bits of index in a Morton key"
        }

        // Key in the high bits, index in the low: one primitive sort, no boxing, and stable enough
        // that an unchanged cloud always writes the same bytes.
        val keys = LongArray(cloud.count)
        val unplaceable = (1L shl (3 * MORTON_BITS)) - 1L
        for (splat in 0 until cloud.count) {
            val x = cloud.positions[splat * 3]
            val y = cloud.positions[splat * 3 + 1]
            val z = cloud.positions[splat * 3 + 2]
            val code = if (!x.isFinite() || !y.isFinite() || !z.isFinite()) {
                unplaceable
            } else {
                interleave(
                    (((x - minX) / spanX) * grid).toInt().coerceIn(0, grid),
                    (((y - minY) / spanY) * grid).toInt().coerceIn(0, grid),
                    (((z - minZ) / spanZ) * grid).toInt().coerceIn(0, grid)
                )
            }
            keys[splat] = (code shl MORTON_INDEX_BITS) or splat.toLong()
        }
        java.util.Arrays.sort(keys)
        val indexMask = (1L shl MORTON_INDEX_BITS) - 1L
        return IntArray(cloud.count) { (keys[it] and indexMask).toInt() }
    }

Three MORTON_BITS-bit integers interleaved bit by bit, x in the high position of each triple.

    private fun interleave(x: Int, y: Int, z: Int): Long {
        var code = 0L
        for (bit in 0 until MORTON_BITS) {
            code = code or
                (((x ushr bit) and 1).toLong() shl (3 * bit + 2)) or
                (((y ushr bit) and 1).toLong() shl (3 * bit + 1)) or
                (((z ushr bit) and 1).toLong() shl (3 * bit))
        }
        return code
    }

Bit packing: the exact inverses of CompressedSplatPly's unpackers

    private fun logClamped(scale: Float): Float =
        SplatCloud.logOf(scale).coerceIn(-SCALE_LOG_LIMIT, SCALE_LOG_LIMIT)

A value's position in [min, max], with the reference's endpoint and degenerate-range handling. A chunk whose range has collapsed stores 0 and decodes to the min, which is the value — that is why a single-splat chunk round-trips exactly.

    private fun normalize(value: Float, min: Float, max: Float): Float = when {
        !value.isFinite() -> 0f
        value <= min -> 0f
        value >= max -> 1f
        max - min < 0.00001f -> 0f
        else -> (value - min) / (max - min)
    }

    private fun packUnorm(value: Float, bits: Int): Int {
        val top = (1 shl bits) - 1
        return floor(value * top + 0.5f).toInt().coerceIn(0, top)
    }

    private fun pack111011(x: Float, y: Float, z: Float): Int =
        (packUnorm(x, 11) shl 21) or (packUnorm(y, 10) shl 11) or packUnorm(z, 11)

    private fun pack8888(x: Float, y: Float, z: Float, w: Float): Int =
        (packUnorm(x, 8) shl 24) or (packUnorm(y, 8) shl 16) or
            (packUnorm(z, 8) shl 8) or packUnorm(w, 8)

The 2,10,10,10 quaternion packing CompressedSplatPly.unpackRotationInto inverts: drop the largest-magnitude component (recoverable from unit length), record its index in the top two bits, negate the whole quaternion first if that component was negative so the dropped one is positive, and store the other three in ascending index order scaled by sqrt(2)/2.

    private fun packRotation(w: Float, x: Float, y: Float, z: Float): Int {
        val q = floatArrayOf(w, x, y, z)
        val length = sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3])
        if (!length.isFinite() || length < 1e-20f) {
            // The identity, packed: largest is index 0 and the other three are zero, which encode to
            // the middle of their 10-bit range.
            return packRotation(1f, 0f, 0f, 0f)
        }
        for (index in 0..3) q[index] /= length
        var largest = 0
        for (index in 1..3) if (kotlin.math.abs(q[index]) > kotlin.math.abs(q[largest])) largest = index
        if (q[largest] < 0f) for (index in 0..3) q[index] = -q[index]

        val norm = sqrt(2f) * 0.5f
        var result = largest
        for (index in 0..3) {
            if (index != largest) result = (result shl 10) or packUnorm(q[index] * norm + 0.5f, 10)
        }
        return result
    }
}