Skip to content

SplatSet.kt

src/jvmMain/kotlin/afield/graphics/scene/fabricated/SplatSet.kt · 257 lines

package afield.graphics.scene.fabricated

import afield.graphics.scene.GltfSupport
import afield.graphics.scene.PlyFloatTable
import afield.graphics.scene.readPlyAsFloatTable
import afield.math.linalg.Matrix4f
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector4f
import java.io.File
import kotlin.math.sqrt

The CPU side of a splat cloud: how one is built, where it can be loaded from, and how it is framed before a camera looks at it.

SplatSet keeps the splats in a flat FloatArray laid out the way the GPU reads them, and grows as push appends. Its companion loads a cloud instead of building one: fromPly reads a Gaussian-splat capture, decoding the log scales, logit opacity and spherical-harmonic colour those files store, and fromGltf turns the vertices of a mesh into discs facing along their normals, sized from how densely they cover the surface.

fit is the framing a SplatShader uses: a median centre and a 90th-percentile radius, so a handful of stray splats cannot decide where the camera points. A cloud that arrives in metres needs fitted before an insert written for a unit-sized cloud reads on it as it does on ProceduralSplats.

class SplatSet(initialCapacity: Int = 1024)

Gaussian splats on the CPU in their parametric form — centre, radii, rotation, colour and opacity — rather than the covariance GaussianSplatRenderer draws from.

This is the form a SplatShader insert edits: scales and quat only mean something while the splat still has them, so a set is uploaded as is and the covariance is built on the GPU after the insert has run. Each splat is GaussianSplatRenderer.SPLAT_FLOAT_STRIDE floats, four vec4s:

(centre.xyz, opacity)  (r, g, b, 0)  (scale.xyz, 0)  (quat.x, quat.y, quat.z, quat.w)

which is what #include "splat/insert" reads. Colours are what the renderer will encode for display (it raises them to colorGamma before blending, and the target encodes them back), so a colour written here is the colour you see where a splat is opaque.

Build one with push (or take one of ProceduralSplats), load one with fromPly or fromGltf. fit is the demo's robust framing: the median centre and the 90th-percentile radius, which a few far-flung splats cannot drag around.

class SplatSet(initialCapacity: Int = 1024) {

var floats: FloatArray

The packed splats; only the first count × 16 floats are live.

    var floats: FloatArray = FloatArray(initialCapacity.coerceAtLeast(1) * STRIDE)
        private set

var count: Int

How many splats have been pushed.

    var count: Int = 0
        private set

fun push

Append one splat. qx..qw need not be normalised; a zero quaternion means no rotation.

    fun push(
        x: Double, y: Double, z: Double,
        sx: Double, sy: Double, sz: Double,
        qx: Double, qy: Double, qz: Double, qw: Double,
        opacity: Double,
        r: Double, g: Double, b: Double
    ): SplatSet {
        if ((count + 1) * STRIDE > floats.size) floats = floats.copyOf(floats.size * 2)
        val o = count * STRIDE
        floats[o + 0] = x.toFloat(); floats[o + 1] = y.toFloat(); floats[o + 2] = z.toFloat()
        floats[o + 3] = opacity.toFloat()
        floats[o + 4] = r.toFloat(); floats[o + 5] = g.toFloat(); floats[o + 6] = b.toFloat()
        floats[o + 7] = 0f
        floats[o + 8] = sx.toFloat(); floats[o + 9] = sy.toFloat(); floats[o + 10] = sz.toFloat()
        floats[o + 11] = 0f
        floats[o + 12] = qx.toFloat(); floats[o + 13] = qy.toFloat(); floats[o + 14] = qz.toFloat()
        floats[o + 15] = qw.toFloat()
        count++
        return this
    }

fun centre(index: Int): Vector3f

The centre of splat index.

    fun centre(index: Int): Vector3f {
        val o = index * STRIDE
        return Vector3f(floats[o], floats[o + 1], floats[o + 2])
    }

fun bounds(): Pair<Vector3f, Vector3f>

The smallest box holding every splat centre; both corners are zero for an empty set.

    fun bounds(): Pair<Vector3f, Vector3f> {
        if (count == 0) return Vector3f() to Vector3f()
        val min = FloatArray(3) { Float.POSITIVE_INFINITY }
        val max = FloatArray(3) { Float.NEGATIVE_INFINITY }
        for (i in 0 until count) {
            val o = i * STRIDE
            for (axis in 0..2) {
                val v = floats[o + axis]
                if (!v.isFinite()) continue
                if (v < min[axis]) min[axis] = v
                if (v > max[axis]) max[axis] = v
            }
        }
        return Vector3f(min[0], min[1], min[2]) to Vector3f(max[0], max[1], max[2])
    }

fun fit(): Fit

Where the cloud is and how big, measured so that outliers do not count: Fit.centre is the per-axis median of the splat centres and Fit.radius90 the distance from it that nine splats in ten fall within. Non-finite centres are skipped. An empty set fits the unit ball.

    fun fit(): Fit {
        val xs = ArrayList<Float>(count)
        val ys = ArrayList<Float>(count)
        val zs = ArrayList<Float>(count)
        for (i in 0 until count) {
            val o = i * STRIDE
            val x = floats[o]; val y = floats[o + 1]; val z = floats[o + 2]
            if (!x.isFinite() || !y.isFinite() || !z.isFinite()) continue
            xs += x; ys += y; zs += z
        }
        if (xs.isEmpty()) return Fit(Vector3f(), 1f)
        val centre = Vector3f(percentile(xs.sorted(), 0.5), percentile(ys.sorted(), 0.5), percentile(zs.sorted(), 0.5))
        val distances = xs.indices.map {
            val dx = xs[it] - centre.x; val dy = ys[it] - centre.y; val dz = zs[it] - centre.z
            sqrt(dx * dx + dy * dy + dz * dz)
        }.sorted()
        return Fit(centre, maxOf(percentile(distances, 0.9), 1e-4f))
    }

fun fitted(radius: Double = 1.0): SplatSet

A copy moved and scaled so fit comes out centred on the origin with a 90th-percentile radius of radius: centres and radii both scale, rotations and colours stay.

An insert sees the splats in their own coordinates, so its spatial frequencies (a stripe every 1/38, noise at 3.2 per unit) are written for a cloud about a unit across, as the procedural ones are. A loaded capture or a model in metres is not; fit it first.

    fun fitted(radius: Double = 1.0): SplatSet {
        val f = fit()
        val s = (radius / f.radius90).toFloat()
        val out = SplatSet(count)
        out.floats = floats.copyOf()
        out.count = count
        for (i in 0 until count) {
            val o = i * STRIDE
            out.floats[o] = (floats[o] - f.centre.x) * s
            out.floats[o + 1] = (floats[o + 1] - f.centre.y) * s
            out.floats[o + 2] = (floats[o + 2] - f.centre.z) * s
            for (k in 8..10) out.floats[o + k] = floats[o + k] * s
        }
        return out
    }

data class Fit(val centre: Vector3f, val radius90: Float)

A robust centre and radius for a cloud; see SplatSet.fit.

    data class Fit(val centre: Vector3f, val radius90: Float) {

fun model(radius: Float = 1.08f): Matrix4f

The model matrix that moves centre to the origin and scales radius90 to radius, the demo's framing (1.08, times its size slider).

        fun model(radius: Float = 1.08f): Matrix4f {
            val s = radius / radius90
            return Matrix4f().scale(s).translate(-centre.x, -centre.y, -centre.z)
        }
    }

companion object

    companion object {

const val STRIDE = GaussianSplatRenderer.SPLAT_FLOAT_STRIDE

Floats per splat.

The demo's percentileFromSorted: index floor((n - 1) p) into the sorted values.

        private fun percentile(sorted: List<Float>, p: Double): Float =
            sorted[((sorted.size - 1) * p).toInt().coerceIn(0, sorted.size - 1)]

fun fromPly(path: String, config: GaussianSplatRenderer.Config = GaussianSplatRenderer.Config()): SplatSet

Load a Gaussian-splat .ply (the 3DGS layout, or PlayCanvas's compressed one), decoded exactly as GaussianSplatRenderer.pack decodes it: log scales, logit opacity and degree-zero spherical-harmonic colour, taken out of their storage forms.

        fun fromPly(path: String, config: GaussianSplatRenderer.Config = GaussianSplatRenderer.Config()): SplatSet =
            GaussianSplatRenderer.parametric(readPlyAsFloatTable(path), config)

fun fromTable(table: PlyFloatTable, config: GaussianSplatRenderer.Config = GaussianSplatRenderer.Config()): SplatSet

fromPly for a table already in memory.

        fun fromTable(table: PlyFloatTable, config: GaussianSplatRenderer.Config = GaussianSplatRenderer.Config()): SplatSet =
            GaussianSplatRenderer.parametric(table, config)

fun fromGltf(path: String, meshes: (name: String) -> Boolean

A mesh as splats: one flat disc per vertex, facing along the vertex normal, the way the demo turns a dropped .glb into a cloud. Every node transform is applied, so the cloud sits where the meshes do. A disc is coloured by the vertex colour where the file has one and by the material's base colour otherwise; textures are not sampled.

The disc radius comes from how densely the vertices cover the surface — the square root of the total triangle area per vertex, times 0.55 — clamped to between 0.08% and 3% of the bounding-box diagonal, and its thickness is an eighth of that, as in the demo. The demo's absolute floors are left out, so a model in millimetres gets the same discs as one in metres. Insert presets are written for a cloud about a unit across, so use fitted.

@param meshes which of the file's meshes to keep, by node name; all of them by default.

body — 60 lines, from line 197
        fun fromGltf(path: String, meshes: (name: String) -> Boolean = { true }): SplatSet {
            require(File(path).exists()) { "glTF file not found: $path" }
            val model = GltfSupport.loadGltfBasic(
                path, GltfSupport.GltfLoadConfig(includeVertexColors = true, loadTextures = false, loadSkins = false)
            )

            class Vertex(val p: Vector3f, val n: Vector3f, val color: Vector4f)
            val vertices = ArrayList<Vertex>()
            var area = 0.0
            for (mesh in model.meshes.filter { meshes(it.name) }) {
                val world = mesh.bindMatrix ?: mesh.bindPose.toMatrix()
                val normalMatrix = Matrix4f(world).invert().transpose()
                val data = mesh.buffers.vertexData.duplicate().order(java.nio.ByteOrder.nativeOrder()).asFloatBuffer()
                val first = vertices.size
                for (i in 0 until mesh.buffers.vertexCount) {
                    val o = i * 12
                    val p = world.transform(Vector4f(data[o], data[o + 1], data[o + 2], 1f))
                    val n = normalMatrix.transform(Vector4f(data[o + 3], data[o + 4], data[o + 5], 0f))
                    val normal = Vector3f(n.x, n.y, n.z).let { if (it.lengthSquared() < 1e-10f) Vector3f(0f, 1f, 0f) else it.normalize() }
                    val base = mesh.material.baseColor
                    // the loader writes white where the file has no COLOR_0, so this is the base colour then
                    val c = Vector4f(data[o + 8] * base.x, data[o + 9] * base.y, data[o + 10] * base.z, data[o + 11] * base.w)
                    vertices += Vertex(Vector3f(p.x, p.y, p.z), normal, c)
                }
                val indices = mesh.buffers.indexData.duplicate()
                var t = 0
                while (t + 2 < mesh.buffers.indexCount) {
                    val a = vertices[first + indices[t]].p
                    val b = vertices[first + indices[t + 1]].p
                    val c = vertices[first + indices[t + 2]].p
                    area += 0.5 * Vector3f(b).sub(a).cross(Vector3f(c).sub(a)).length()
                    t += 3
                }
            }

            val set = SplatSet(vertices.size)
            if (vertices.isEmpty()) return set
            val min = Vector3f(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)
            val max = Vector3f(Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY)
            for (v in vertices) { min.min(v.p); max.max(v.p) }
            val diagonal = maxOf(Vector3f(max).sub(min).length().toDouble(), 0.001)
            val spacing = if (area > 0.0) sqrt(area / vertices.size) else diagonal / maxOf(sqrt(vertices.size.toDouble()), 1.0)
            // the demo also floors these at 0.0016 and 0.0008 in absolute units, which assumes a model
            // about a metre across: on a 7 cm toy that is sixteen times the vertex spacing
            val radius = (spacing * 0.55).coerceIn(diagonal * 0.0008, diagonal * 0.03)
            val thickness = (radius * 0.12).coerceIn(diagonal * 0.00015, radius * 0.42)
            for (v in vertices) {
                val q = ProceduralSplats.quaternionFromUnitVectors(0.0, 0.0, -1.0, v.n.x.toDouble(), v.n.y.toDouble(), v.n.z.toDouble())
                set.push(
                    v.p.x.toDouble(), v.p.y.toDouble(), v.p.z.toDouble(),
                    radius, radius, thickness,
                    q[0], q[1], q[2], q[3],
                    v.color.w.toDouble().coerceIn(0.0, 1.0),
                    v.color.x.toDouble(), v.color.y.toDouble(), v.color.z.toDouble()
                )
            }
            return set
        }
    }
}