@nickpaustian/stelligra
v0.1.0
Published
A Gaussian splat renderer for three.js: PLY and SOG loaders, one draw call for every splat, a radial-distance sort in a worker (JS or WASM), slices that stream in and out under a GPU budget, and meshes for the simple case
Readme
@nickpaustian/stelligra
A Gaussian splat renderer for three.js. It loads .ply (INRIA) and .sog (WebP bundle) splat files, parses them in a worker into GPU-ready arrays with covariances precomputed, keeps every splat in textures and draws them all in one call, and sorts them back-to-front in a worker by radial distance from the camera, which stays correct while the camera turns. Scenes that stream, an octree of slices under a GPU budget, are the design centre; a single splat file is the simple case on top of it. three.js is a peer dependency.
import * as THREE from "three"
import { SplatLoader, SplatMesh, SplatRenderer } from "@nickpaustian/stelligra"
const splats = new SplatRenderer({ shDegree: 2, sortFrustumCull: true })
scene.add(splats)
const loader = new SplatLoader({ parseMode: "gaussianSplat", useWorker: true })
const mesh = new SplatMesh(await loader.loadFromURL("/scene.ply"))
mesh.rotation.x = Math.PI
splats.add(mesh)
renderer.setAnimationLoop(() => {
splats.update(camera, renderer)
renderer.render(scene, camera)
})Loading
new SplatLoader({ parseMode: "gaussianSplat" | "pointCloud", useWorker }) with loadFromURL, loadFromFile and loadFromBuffer(buffer, "ply" | "sog"). The result is GaussianSplatData: positions, RGBA colours (opacity through its sigmoid), the upper triangle of each 3D covariance (from scale and rotation, so the GPU never sees quaternions), optional spherical harmonics and bounds. parsePLYAsGaussianSplat, parseSOGBundle and friends are exported for use without the loader.
Rendering
SplatRenderer is a THREE.Mesh at the world origin, added to the scene once. update(camera, renderer) each frame uploads anything pending and asks the worker for a sort when the camera has moved (or every eighth frame regardless, to catch what changed). Options: shDegree (a compile-time define), splatScale, maxScreenSpaceSplatSize, antialiased, sortFrustumCull with sortCullBaseAngle (cull to the frustum before sorting, with a margin widened by how fast the camera is turning), minResidentSliceLevel, isCameraDirty (skip sorts when nothing moved), log (capacity events only: reserved, grown, exhausted; silent by default) and sortBackend.
Slices
The renderer is a slot allocator over one capacity of splats. reserveCapacity(n) sets the budget (see probeGPUCapacity below), or leave it and the textures grow as needed. allocateSlice(count, level, onEvict) returns an id for a slot range that never moves; uploadSlice(id, data, worldMatrix?) takes compact positions, colours, covariances and optional SH, bakes them through the matrix and shows the slice on the next update; uploadPreScattered(id, center, colour, cov, boundsMin, boundsMax, renderer) takes texture-ready arrays, as a decode worker produces them, and uploads at once. showSlice and hideSlice flip a dead bit without touching the GPU; evictSlice frees the range and freeSlice forgets it; getLRUAllocations(forLevel) lists eviction candidates, hidden first, then by an age, size and level score; when a budgeted allocation does not fit, the allocator evicts along that list itself and returns −1 only if the budget is truly exhausted. Slices at or below minResidentSliceLevel and protected ones are never offered.
Meshes
A SplatMesh holds GaussianSplatData and a transform and owns nothing on the GPU. add(mesh) bakes its world transform into one slice, refreshMesh(mesh) re-bakes after a move, remove(mesh) or mesh.dispose() frees it. Everything registered sorts together in the one draw, so overlapping meshes composite correctly.
The sort
A worker keeps the positions resident, receives slot ranges as they are uploaded and the dead mask only when it changes, and returns a transferred index buffer. The key is distance from the camera, bucketed into 65536 bins between the nearest and furthest visible splat, in one counting pass. Two backends run that algorithm: js (default) and wasm, a Rust build of the same loop (packages/sort-worker-wasm), chosen with sortBackend or setSortBackend at any time; activeSortBackend says which is running (WASM falls back to JS if it cannot load). benchmark(camera, renderer, iterations) times the sort in the worker and reports the backend. lastSortMs is the last sort's time.
GPU budget
probeGPUCapacity(renderer, { log, shDegree, candidates, maxAgeDays, force }) finds how many splats the device can hold: for each size, ascending, it allocates the renderer's textures (centre, colour, covariance, and SH when shDegree is above zero) and writes to them, so the memory has to be real, and skips sizes whose staging copy would not fit a quarter of navigator.deviceMemory. Progress is written to localStorage before each attempt, so a size that crashes the page is skipped on the next load instead of crashing again; a finished result is kept per device (the unmasked renderer name and the texture limit) for thirty days. bytesPerSplat(shDegree) and heapCeiling(deviceMemoryGB, shDegree) are the sums behind it. Pass the result to reserveCapacity.
SOG decoding in a worker
@nickpaustian/stelligra/sog exports the WASM SOG decoder (packages/sog-wasm, Rust): init(wasmUrl) then decode_sog(bytes, worldMatrix) returns one packed buffer of texture-ready center, colour and covariance arrays plus bounds, for uploadPreScattered. It is meant for a worker that decodes slices off the main thread.
Building the WASM
The sort and the decoder are Rust crates. npm run build:wasm at the repo root rebuilds both (needs cargo, the wasm32-unknown-unknown target, wasm-pack, and optionally wasm-opt); the sort is embedded as base64 in SortWasm.ts and the decoder's glue and binary land in src/wasm/sog. Neither is needed to build or use the package as published.
