Skip to content

CompressedSplatPly.kt

src/jvmMain/kotlin/afield/graphics/scene/CompressedSplatPly.kt · 643 lines

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

package afield.graphics.scene

import afield.graphics.render.before
import afield.graphics.render.parameters

import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import kotlin.math.ln
import kotlin.math.sqrt

Reads PlayCanvas's quantized .compressed.ply splat files back into ordinary per-splat floats.

Nothing upstream of this file has to know the format exists. readPlyAsFloatTable recognises the schema from the file's own header and routes here, and what comes back carries the columns an uncompressed 3D Gaussian splatting PLY would have given, in the same conventions. The unpacking itself is CompressedSplatPly: the per-chunk ranges, the bit layouts, and the inverse of each quantization. readRaw hands back the packed words instead, for a tool that wants to repack a file rather than draw it.

object CompressedSplatPly

The PlayCanvas compressed PLY — the .compressed.ply that splat-transform writes and that SuperSplat and the PlayCanvas engine read back.

It is an ordinary binary_little_endian PLY whose payload happens to be quantized. Splats are sorted into Morton order and cut into chunks of CHUNK_SIZE; a chunk element records, as floats, the min and max of each chunk's own positions, log-scales and colours; a vertex element then spends exactly four 32-bit words per splat holding position, rotation, scale and colour normalised into that chunk's range. An optional sh element carries the higher spherical-harmonic bands at a byte per coefficient. The result is roughly an order of magnitude smaller than the float-per-property PLY a 3DGS trainer emits.

readCompressedSplatPly undoes all of that and returns the same PlyFloatTable the uncompressed file would have produced — x/y/z, f_dc_0..2, opacity, rot_0..3, scale_0..2, in the same conventions (log scales, logit opacity, SH-DC colour, rot_0 real) — so GaussianSplatRenderer and gaussianSplats need to know nothing about any of this. readPlyAsFloatTable delegates here whenever it recognises the schema, and the recognition is content-based: .compressed.ply in the name is a convention, not the signal.

The lossy part happened when the file was written. Positions get 11/10/11 bits, but per chunk, so 256 neighbouring splats share a tight bounding box and the absolute error stays small; rotations get 10 bits per component with the largest dropped and rebuilt; colour and opacity get 8 bits each. Reading is exact — every value below is the inverse of the packing, not an approximation of it.

object CompressedSplatPly {

const val CHUNK_SIZE = 256

Splats per chunk. Fixed by the format, not a tunable.

const val SH_C0 = 0.28209479177387814f

The DC term of the real spherical-harmonic basis, as 3DGS and splat-transform use it.

const val OPACITY_LOGIT_LIMIT = 30f

Opacity is stored as an 8-bit sigmoid value, so 0 and 255 invert to a logit of -infinity and +infinity. The uncompressed PLY column this reader emits is the logit, and callers downstream push it back through a sigmoid — an infinity there is either a NaN or, in GaussianSplatRenderer's case, a non-finite guard that lands on 0.5 and turns a solid splat translucent. Clamping to ±30 costs nothing: sigmoid(30f) is 1 and sigmoid(-30f) is 9.4e-14, both of which round-trip to the byte they came from.

val requiredChunkProperties

Chunk properties that every compressed PLY has, in the order the format declares them.

    val requiredChunkProperties = listOf(
        "min_x", "min_y", "min_z",
        "max_x", "max_y", "max_z",
        "min_scale_x", "min_scale_y", "min_scale_z",
        "max_scale_x", "max_scale_y", "max_scale_z"
    )

val colorChunkProperties = listOf("min_r", "min_g", "min_b", "max_r", "max_g", "max_b")

Per-chunk colour range. Older files omit these six and store colour as a plain unorm instead of a lerp between them, so they are optional — but all six or none.

val vertexProperties

The four packed 32-bit words that make up a splat.

    val vertexProperties = listOf("packed_position", "packed_rotation", "packed_scale", "packed_color")

val validShColumnCounts = setOf(9, 24, 45)

Coefficient counts a valid sh element may have: one, two or three bands beyond DC, times RGB.

val decompressedColumns

Column names of the table readCompressedSplatPly produces, before any f_rest_*.

    val decompressedColumns = listOf(
        "x", "y", "z",
        "f_dc_0", "f_dc_1", "f_dc_2",
        "opacity",
        "rot_0", "rot_1", "rot_2", "rot_3",
        "scale_0", "scale_1", "scale_2"
    )

data class Layout

What a compressed PLY's header says about its payload, once recognised.

    data class Layout(
        val splatCount: Int,
        val chunkCount: Int,
        val hasChunkColors: Boolean,
        /** `f_rest_*` names in the order the `sh` element declares them; empty when there is no `sh`. */
        val shColumns: List<String>
    ) {

val shBands: Int

        val shBands: Int
            get() = when (shColumns.size) {
                9 -> 1
                24 -> 2
                45 -> 3
                else -> 0
            }
    }

sealed interface Recognition

The outcome of looking at a PLY header and asking "is this a compressed splat file?".

    sealed interface Recognition {

data class Compressed(val layout: Layout) : Recognition

It is, and here is what it holds.

data object Plain : Recognition

It is not, and nothing about it suggested it was trying to be. Read it as a normal PLY.

data class Malformed(val reason: String) : Recognition

It carries the packed vertex words, so it is meant to be a compressed PLY, but something else does not add up. Reading it as a normal PLY would coerce 32-bit packed words into floats and hand back plausible-looking nonsense, so callers should surface reason.

Recognition

internal fun recognise(header: PlyTableHeader): Recognition

Decide whether header describes a compressed splat PLY, mirroring splat-transform's own isCompressedPly check: the chunk element's floats, the vertex element's four packed words, the chunk count implied by the splat count, and an optional sh element of 9, 24 or 45 uchar coefficients.

Extra properties and unusual property order are tolerated everywhere except the chunk colour block, which the format treats as all-or-nothing.

body — 102 lines, from line 146
    internal fun recognise(header: PlyTableHeader): Recognition {
        val vertex = header.elements.find { it.name == "vertex" } ?: return Recognition.Plain
        val chunk = header.elements.find { it.name == "chunk" }
        val sh = header.elements.find { it.name == "sh" }

        val packedProperties = vertex.properties.filter { it.name in vertexProperties }
        if (packedProperties.isEmpty()) return Recognition.Plain

        // From here on the file is claiming to be compressed, so every failure is a Malformed with a
        // reason rather than a silent fall-through to the float reader.
        fun bad(reason: String) = Recognition.Malformed(reason)

        val missingPacked = vertexProperties - packedProperties.map { it.name }.toSet()
        if (missingPacked.isNotEmpty()) {
            return bad("the 'vertex' element is missing ${missingPacked.joinToString()}")
        }
        val wrongType = packedProperties.filterNot { it.type in uint32Types }
        if (wrongType.isNotEmpty()) {
            return bad(
                "the packed vertex properties must be uint32, but " +
                    wrongType.joinToString { "${it.name} is '${it.type}'" }
            )
        }
        if (header.format == PlyTableFormat.ASCII) {
            return bad("it is an ascii PLY; the compressed format is only defined for binary payloads")
        }
        if (header.elements.any { element -> element.properties.any { it.isList } }) {
            return bad("it has a list property, which the compressed format never uses")
        }

        chunk ?: return bad("it has no 'chunk' element")

        val chunkNames = chunk.properties.map { it.name }.toSet()
        val missingChunk = requiredChunkProperties.filterNot { it in chunkNames }
        if (missingChunk.isNotEmpty()) {
            return bad("the 'chunk' element is missing ${missingChunk.joinToString()}")
        }
        val presentColors = colorChunkProperties.filter { it in chunkNames }
        if (presentColors.isNotEmpty() && presentColors.size != colorChunkProperties.size) {
            return bad(
                "the 'chunk' element has ${presentColors.joinToString()} but not the rest of " +
                    "${colorChunkProperties.joinToString()}; per-chunk colour is all six or none"
            )
        }
        val nonFloatChunk = chunk.properties.filter {
            it.name in requiredChunkProperties || it.name in colorChunkProperties
        }.filterNot { it.type in float32Types }
        if (nonFloatChunk.isNotEmpty()) {
            return bad(
                "the chunk ranges must be float32, but " +
                    nonFloatChunk.joinToString { "${it.name} is '${it.type}'" }
            )
        }

        val expectedChunks = (vertex.count + CHUNK_SIZE - 1) / CHUNK_SIZE
        if (expectedChunks != chunk.count) {
            return bad(
                "${vertex.count} splats need $expectedChunks chunks of $CHUNK_SIZE, " +
                    "but the file declares ${chunk.count}"
            )
        }

        val shColumns: List<String>
        if (sh == null) {
            shColumns = emptyList()
        } else {
            if (sh.count != vertex.count) {
                return bad("the 'sh' element has ${sh.count} rows but there are ${vertex.count} splats")
            }
            if (sh.properties.size !in validShColumnCounts) {
                return bad(
                    "the 'sh' element has ${sh.properties.size} coefficients; " +
                        "the format allows ${validShColumnCounts.sorted().joinToString()}"
                )
            }
            val notUchar = sh.properties.filterNot { it.type in uint8Types }
            if (notUchar.isNotEmpty()) {
                return bad(
                    "the spherical-harmonic coefficients must be uchar, but " +
                        notUchar.joinToString { "${it.name} is '${it.type}'" }
                )
            }
            val expectedNames = sh.properties.indices.map { "f_rest_$it" }
            val actualNames = sh.properties.map { it.name }
            if (actualNames.toSet() != expectedNames.toSet()) {
                return bad(
                    "the 'sh' element's properties should be ${expectedNames.first()}.." +
                        "${expectedNames.last()}, but they are ${actualNames.joinToString()}"
                )
            }
            shColumns = actualNames
        }

        return Recognition.Compressed(
            Layout(
                splatCount = vertex.count,
                chunkCount = chunk.count,
                hasChunkColors = presentColors.size == colorChunkProperties.size,
                shColumns = shColumns
            )
        )
    }

fun isCompressedSplatPly(path: String): Boolean

True when path is a compressed splat PLY. Reads only the header. A file that is trying to be one and failing counts as one for this purpose — it is certainly not a plain PLY — so use readCompressedSplatPly when you want the reason.

    fun isCompressedSplatPly(path: String): Boolean {
        val file = File(path)
        if (!file.exists()) return false
        return RandomAccessFile(file, "r").use { raf ->
            val header = runCatching { parseFloatTableHeader(raf) }.getOrNull() ?: return@use false
            recognise(header) !is Recognition.Plain
        }
    }

fun rawCount(path: String): Int

Read only the header and return the number of splats, without touching the packed payload.

    fun rawCount(path: String): Int {
        val file = File(path)
        require(file.exists()) { "PLY file not found: $path" }
        return RandomAccessFile(file, "r").use { raf ->
            val header = parseFloatTableHeader(raf)
            when (val recognition = recognise(header)) {
                is Recognition.Compressed -> recognition.layout.splatCount
                is Recognition.Malformed ->
                    error("'$path' looks like a compressed splat PLY but ${recognition.reason}")
                Recognition.Plain ->
                    error("'$path' is not a compressed splat PLY — it has no packed_* vertex properties")
            }
        }
    }

Reading

fun readCompressedSplatPly(path: String, includeSphericalHarmonics: Boolean = false): PlyFloatTable

Read a PlayCanvas compressed PLY and hand back the uncompressed splat table.

@param path the .compressed.ply file. @param includeSphericalHarmonics also decode the sh element into f_rest_* columns. Off by default because it is up to 45 extra floats per splat — three times the size of the table itself — and nothing in Field's splat renderer reads them yet. The bands are still there in the file either way. @throws IllegalStateException if the file is not a compressed splat PLY, saying which part of the schema did not match.

    fun readCompressedSplatPly(path: String, includeSphericalHarmonics: Boolean = false): PlyFloatTable {
        val file = File(path)
        require(file.exists()) { "PLY file not found: $path" }
        return RandomAccessFile(file, "r").use { raf ->
            val header = parseFloatTableHeader(raf)
            when (val recognition = recognise(header)) {
                is Recognition.Compressed ->
                    decompress(raf, header, recognition.layout, includeSphericalHarmonics, path)
                is Recognition.Malformed ->
                    error("'$path' looks like a compressed splat PLY but ${recognition.reason}")
                Recognition.Plain ->
                    error(
                        "'$path' is not a compressed splat PLY — it has no packed_position/" +
                            "packed_rotation/packed_scale/packed_color vertex properties. " +
                            "Read it with readPlyAsFloatTable instead."
                    )
            }
        }
    }

class RawSplats

A file's quantized payload, read but not decoded: CHUNK_RANGE_COUNT range floats per chunk (the colour six left zero when the file omits them) and the four packed words per splat, in file order.

This is what a consumer wants when it is going to produce its own vertex layout anyway. readCompressedSplatPly followed by a re-pack walks the data three times and materializes a 14-float table nobody keeps; from here a decoder can go straight to its own layout in one pass. See GaussianSplatRenderer.packInto.

    class RawSplats(
        val chunkRanges: FloatArray,
        /** `[position, rotation, scale, colour]` per splat, 4 ints each. */
        val packed: IntArray,
        val count: Int,
        val hasChunkColors: Boolean
    ) {

val chunkCount: Int get() = chunkRanges.size / CHUNK_RANGE_COUNT

fun rangeBase(splat: Int): Int = (splat / CHUNK_SIZE) * CHUNK_RANGE_COUNT

Offset into chunkRanges of the chunk that owns splat.

fun readRaw(path: String): RawSplats

Read path's quantized payload without decoding it: one pass over the file, two arrays out and nothing per-splat but four getInts.

body — 45 lines, from line 341
    fun readRaw(path: String): RawSplats {
        val file = File(path)
        require(file.exists()) { "PLY file not found: $path" }
        return RandomAccessFile(file, "r").use { raf ->
            val header = parseFloatTableHeader(raf)
            val layout = when (val recognition = recognise(header)) {
                is Recognition.Compressed -> recognition.layout
                is Recognition.Malformed ->
                    error("'$path' looks like a compressed splat PLY but ${recognition.reason}")
                Recognition.Plain ->
                    error("'$path' is not a compressed splat PLY — it has no packed_* vertex properties")
            }
            val order =
                if (header.format == PlyTableFormat.BINARY_BIG_ENDIAN) ByteOrder.BIG_ENDIAN
                else ByteOrder.LITTLE_ENDIAN
            val blocks = readElementBlocks(raf, header, setOf("chunk", "vertex"), order, path)
            val vertex = blocks.getValue("vertex")

            // Property offsets resolved once. Doing this by name per splat is what made the old
            // decode-then-pack path spend more time in HashMap than in arithmetic.
            val stride = vertex.stride
            val bytes = vertex.bytes
            val positionAt = vertex.byteOffsets.getValue("packed_position")
            val rotationAt = vertex.byteOffsets.getValue("packed_rotation")
            val scaleAt = vertex.byteOffsets.getValue("packed_scale")
            val colorAt = vertex.byteOffsets.getValue("packed_color")

            val count = layout.splatCount
            val packed = IntArray(count * 4)
            for (splat in 0 until count) {
                val row = splat * stride
                val out = splat * 4
                packed[out] = bytes.getInt(row + positionAt)
                packed[out + 1] = bytes.getInt(row + rotationAt)
                packed[out + 2] = bytes.getInt(row + scaleAt)
                packed[out + 3] = bytes.getInt(row + colorAt)
            }
            RawSplats(
                chunkRanges = chunkRangesOf(blocks.getValue("chunk"), layout),
                packed = packed,
                count = count,
                hasChunkColors = layout.hasChunkColors
            )
        }
    }

internal fun decompress

Unpack an already-open, already-recognised compressed PLY. raf may be positioned anywhere; this seeks for itself.

body — 81 lines, from line 391
    internal fun decompress(
        raf: RandomAccessFile,
        header: PlyTableHeader,
        layout: Layout,
        includeSphericalHarmonics: Boolean,
        path: String
    ): PlyFloatTable {
        val order =
            if (header.format == PlyTableFormat.BINARY_BIG_ENDIAN) ByteOrder.BIG_ENDIAN
            else ByteOrder.LITTLE_ENDIAN

        val shColumns = if (includeSphericalHarmonics) layout.shColumns else emptyList()
        val columns = decompressedColumns + shColumns
        val stride = columns.size
        val offsets = columns.withIndex().associate { (index, name) -> name to index }

        val wanted = buildSet {
            add("chunk")
            add("vertex")
            if (shColumns.isNotEmpty()) add("sh")
        }
        val blocks = readElementBlocks(raf, header, wanted, order, path)
        val chunkBlock = blocks.getValue("chunk")
        val vertexBlock = blocks.getValue("vertex")
        val shBlock = blocks["sh"]

        val count = layout.splatCount
        val outBytes = count.toLong() * stride.toLong() * Float.SIZE_BYTES.toLong()
        require(outBytes <= Int.MAX_VALUE) {
            "'$path' decompresses to $outBytes bytes, more than a single FloatBuffer holds" +
                if (includeSphericalHarmonics) " — try includeSphericalHarmonics = false" else ""
        }
        val out = ByteBuffer.allocateDirect(outBytes.toInt()).order(order).asFloatBuffer()

        val chunkRanges = chunkRangesOf(chunkBlock, layout)

        for (splat in 0 until count) {
            val base = (splat / CHUNK_SIZE) * CHUNK_RANGE_COUNT

            val position = vertexBlock.int(splat, "packed_position")
            out.put(lerp(chunkRanges[base + 0], chunkRanges[base + 3], unpackUnorm(position ushr 21, 11)))
            out.put(lerp(chunkRanges[base + 1], chunkRanges[base + 4], unpackUnorm(position ushr 11, 10)))
            out.put(lerp(chunkRanges[base + 2], chunkRanges[base + 5], unpackUnorm(position, 11)))

            val color = vertexBlock.int(splat, "packed_color")
            val red = unpackUnorm(color ushr 24, 8)
            val green = unpackUnorm(color ushr 16, 8)
            val blue = unpackUnorm(color ushr 8, 8)
            val alpha = unpackUnorm(color, 8)
            if (layout.hasChunkColors) {
                out.put(dcFromColor(lerp(chunkRanges[base + 12], chunkRanges[base + 15], red)))
                out.put(dcFromColor(lerp(chunkRanges[base + 13], chunkRanges[base + 16], green)))
                out.put(dcFromColor(lerp(chunkRanges[base + 14], chunkRanges[base + 17], blue)))
            } else {
                out.put(dcFromColor(red))
                out.put(dcFromColor(green))
                out.put(dcFromColor(blue))
            }
            out.put(logit(alpha))

            val rotation = vertexBlock.int(splat, "packed_rotation")
            unpackRotationInto(rotation, out)

            val scale = vertexBlock.int(splat, "packed_scale")
            out.put(lerp(chunkRanges[base + 6], chunkRanges[base + 9], unpackUnorm(scale ushr 21, 11)))
            out.put(lerp(chunkRanges[base + 7], chunkRanges[base + 10], unpackUnorm(scale ushr 11, 10)))
            out.put(lerp(chunkRanges[base + 8], chunkRanges[base + 11], unpackUnorm(scale, 11)))

            if (shBlock != null) {
                for (column in shColumns) {
                    out.put(unpackShCoefficient(shBlock.byte(splat, column)))
                }
            }
        }

        out.flip()
        require(out.limit() == count * stride) {
            "internal error decompressing '$path': wrote ${out.limit()} floats, expected ${count * stride}"
        }
        return PlyFloatTable(offsets = offsets, stride = stride, buffer = out)
    }

Bit-level inverse of splat-transform's CompressedChunk.pack

fun unpackUnorm(value: Int, bits: Int): Float

Invert packUnorm: take the low bits of value back to 0..1.

    fun unpackUnorm(value: Int, bits: Int): Float {
        val mask = (1 shl bits) - 1
        return (value and mask).toFloat() / mask.toFloat()
    }

fun dcFromColor(color: Float): Float = (color - 0.5f) / SH_C0

A colour channel, as unorm, back to the SH DC coefficient the trainer produced.

fun logit(alpha: Float): Float

A sigmoid'd opacity byte back to its logit, clamped to OPACITY_LOGIT_LIMIT so the 0 and 255 ends stay finite.

    fun logit(alpha: Float): Float {
        if (alpha <= 0f) return -OPACITY_LOGIT_LIMIT
        if (alpha >= 1f) return OPACITY_LOGIT_LIMIT
        val value = -ln(1.0 / alpha - 1.0)
        return value.toFloat().coerceIn(-OPACITY_LOGIT_LIMIT, OPACITY_LOGIT_LIMIT)
    }

fun unpackShCoefficient(byte: Int): Float

An SH coefficient byte back to its float, matching splat-transform's endpoint handling.

    fun unpackShCoefficient(byte: Int): Float {
        val normalized = when (byte) {
            0 -> 0f
            255 -> 1f
            else -> (byte + 0.5f) / 256f
        }
        return (normalized - 0.5f) * 8f
    }

fun unpackRotationInto(value: Int, out: FloatBuffer)

Undo the 2,10,10,10 quaternion packing straight into out as rot_0..rot_3.

The encoder drops whichever component had the largest magnitude — it is recoverable from the unit-length constraint — records that component's index in the top two bits, negating the whole quaternion first if it was negative so the dropped one is positive, and stores the other three in ascending index order scaled by sqrt(2)/2 so that ±1/sqrt(2) fills the range.

    fun unpackRotationInto(value: Int, out: FloatBuffer) {
        val norm = sqrt(2f)
        val a = (unpackUnorm(value ushr 20, 10) - 0.5f) * norm
        val b = (unpackUnorm(value ushr 10, 10) - 0.5f) * norm
        val c = (unpackUnorm(value, 10) - 0.5f) * norm
        val largest = sqrt(maxOf(0f, 1f - (a * a + b * b + c * c)))
        when (value ushr 30) {
            0 -> { out.put(largest); out.put(a); out.put(b); out.put(c) }
            1 -> { out.put(a); out.put(largest); out.put(b); out.put(c) }
            2 -> { out.put(a); out.put(b); out.put(largest); out.put(c) }
            else -> { out.put(a); out.put(b); out.put(c); out.put(largest) }
        }
    }

    private fun lerp(a: Float, b: Float, t: Float): Float = a * (1f - t) + b * t

Generic binary element reading

    private const val CHUNK_RANGE_COUNT = 18

    private val chunkRangeOrder = requiredChunkProperties + colorChunkProperties

    private val float32Types = setOf("float", "float32")
    private val uint32Types = setOf("uint", "uint32")
    private val uint8Types = setOf("uchar", "uint8")

One element's rows, verbatim, plus where each property sits inside a row.

    private class ElementBlock(
        val stride: Int,
        val byteOffsets: Map<String, Int>,
        val bytes: ByteBuffer
    ) {
        fun float(row: Int, property: String): Float =
            bytes.getFloat(row * stride + byteOffsets.getValue(property))

        fun int(row: Int, property: String): Int =
            bytes.getInt(row * stride + byteOffsets.getValue(property))

        fun byte(row: Int, property: String): Int =
            bytes.get(row * stride + byteOffsets.getValue(property)).toInt() and 0xff
    }

The chunk element as a flat CHUNK_RANGE_COUNT-per-chunk array in chunkRangeOrder, so a per-splat decode indexes it arithmetically instead of by name. The colour six read zero when the file has the 12-property chunk layout; nothing consults them in that case.

    private fun chunkRangesOf(chunk: ElementBlock, layout: Layout): FloatArray {
        val byteOffsets = IntArray(CHUNK_RANGE_COUNT) { index ->
            val name = chunkRangeOrder[index]
            if (name in colorChunkProperties && !layout.hasChunkColors) -1
            else chunk.byteOffsets.getValue(name)
        }
        val out = FloatArray(layout.chunkCount * CHUNK_RANGE_COUNT)
        for (chunkIndex in 0 until layout.chunkCount) {
            val base = chunkIndex * CHUNK_RANGE_COUNT
            val row = chunkIndex * chunk.stride
            for (index in 0 until CHUNK_RANGE_COUNT) {
                val at = byteOffsets[index]
                out[base + index] = if (at < 0) 0f else chunk.bytes.getFloat(row + at)
            }
        }
        return out
    }

Read the wanted elements' payloads, skipping the rest by computing their byte length. Every property must be a fixed-size scalar, which recognise has already established.

source — 49 lines, from line 586
    private fun readElementBlocks(
        raf: RandomAccessFile,
        header: PlyTableHeader,
        wanted: Set<String>,
        order: ByteOrder,
        path: String
    ): Map<String, ElementBlock> {
        val blocks = mutableMapOf<String, ElementBlock>()
        var offset = header.dataStart
        for (element in header.elements) {
            var stride = 0
            val byteOffsets = mutableMapOf<String, Int>()
            for (property in element.properties) {
                byteOffsets[property.name] = stride
                stride += scalarSizeBytes(property.type)
            }
            val length = element.count.toLong() * stride.toLong()
            if (element.name in wanted) {
                require(length <= Int.MAX_VALUE) {
                    "'$path' element '${element.name}' is $length bytes, too large to read in one block"
                }
                require(offset + length <= raf.length()) {
                    "'$path' is truncated: element '${element.name}' needs $length bytes at offset " +
                        "$offset but the file is ${raf.length()} bytes"
                }
                raf.seek(offset)
                val bytes = ByteArray(length.toInt())
                raf.readFully(bytes)
                blocks[element.name] = ElementBlock(
                    stride = stride,
                    byteOffsets = byteOffsets,
                    bytes = ByteBuffer.wrap(bytes).order(order)
                )
            }
            offset += length
        }
        val missing = wanted - blocks.keys
        require(missing.isEmpty()) { "'$path' has no ${missing.joinToString()} element" }
        return blocks
    }

    private fun scalarSizeBytes(type: String): Int = when (type) {
        "char", "int8", "uchar", "uint8" -> 1
        "short", "int16", "ushort", "uint16" -> 2
        "int", "int32", "uint", "uint32", "float", "float32" -> 4
        "double", "float64" -> 8
        else -> error("Unsupported PLY property type: $type")
    }
}

fun readCompressedSplatPly(path: String, includeSphericalHarmonics: Boolean = false): PlyFloatTable

Read a PlayCanvas .compressed.ply as the uncompressed splat table it stands for. See CompressedSplatPly for the format and CompressedSplatPly.readCompressedSplatPly for the parameters.

fun readCompressedSplatPly(path: String, includeSphericalHarmonics: Boolean = false): PlyFloatTable =
    CompressedSplatPly.readCompressedSplatPly(path, includeSphericalHarmonics)