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
}
}