Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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

AssetRole
GaussianSplat.componentThe authoring surface — which capture, at what opacity and size.
gaussianSplats.moduleEntity-id-keyed registry the render feature reads each frame.
splat_cull.computeShaderPer 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.