Builtin Library
readme.md
gaussianSplats
Renders Gaussian splat captures — photographic reconstructions of real places, stored as a cloud of oriented 3D Gaussians rather than as triangles.
Add the component, point it at a capture, and the cloud draws:
local id = entity.spawn("scan").id
entity(id).component.add("GaussianSplat", { source = "captures/room.gaussianSplat" })
Dropping a .spz (Niantic) or .ply (3DGS) into the world imports it into a
.gaussianSplat asset — that asset is what source names. The entity's
Transform places, rotates and scales the cloud like any other renderable.
What is in the package
| Asset | Role |
|---|---|
GaussianSplat.component | The authoring surface — which capture, at what opacity and size. |
gaussianSplats.module | Entity-id-keyed registry the render feature reads each frame. |
splat_cull.computeShader | Per splat: frustum cull, then file into a log-spaced depth bucket. |
The radix passes that turn those buckets into a draw order are the shared ones
in systems/gpuSort — radix_hist, radix_scan and radix_scatter — which
read keys and a permutation and know nothing of what the keys describe.
The gaussianSplats render feature owns every GPU buffer and drives the
shaders. It lives outside the package, alongside the engine's other render
features, because that is where the renderer discovers them.
Why there is a sort every frame
Gaussians composite with alpha blending, and alpha blending is order dependent: the same cloud drawn near-to-far and far-to-near produces different pixels, and only the far-to-near one is correct. The correct order changes whenever the camera moves, so it cannot be baked into the capture.
The depth key is a bucket index. Buckets are spaced logarithmically between the
component's near and far, so each spans a constant ratio of depth — near
geometry, where a mis-ordered pair reads worst, gets the same relative precision
as far geometry.
Splats are ordered on that key by a stable LSD radix sort, eight bits per pass. Each pass ranks a splat among the earlier splats of its own 256-wide block that share its digit, and adds that rank to the scanned population of its (digit, block) pair. A rank is a count over the block's own keys, so it depends on nothing but the capture and the camera: the same camera produces the same permutation, and therefore the same pixels. Splats sharing a digit keep their input order, so the passes compose into a total order on (bucket, splat index) and no two splats ever contend for a slot.
buckets on the component sets how finely depth is resolved. 4096 is the
default; the sort takes one more pass for every 256× of key range.
Bounding what a distant cloud costs
minPixels drops a splat whose whole footprint projects to less than that many
pixels of radius. Distance shrinks the projection, so a far cloud resolves to
progressively fewer splats while a near one keeps every splat it has. 0 — the
default — draws every splat.
Memory
A splat costs 24 bytes: position (3×f32), log scale (3×u8), orientation (4×snorm8), and colour + opacity (4×u8). Captures that carry higher-order spherical harmonics add a second pool of quantized coefficients — 3, 6 or 12 u32 words per splat at degree 1, 2 and 3.
A 1.9M-splat capture is therefore ~44 MB resident with no SH.
The sort adds 16 bytes per splat on top of that: a depth key, the two permutation buffers it ping-pongs through, and the digit histogram — which, at a 256-wide radix over 256-splat blocks, is exactly one u32 per splat.
Guide
guides { path = "topics/gaussian-splats" } covers capturing, importing,
placing, and tuning a cloud.