Sorting#

Gaussian splats are semi-transparent and must be blended back to front, so every render needs them in order. Radix computes a sort key per splat in the projection, then sorts the keys either on the GPU in the same frame (WebGPU) or on a worker a few frames later (the WebGL2 path). Everything one renderer draws is sorted together, so overlapping objects blend correctly.

Code: src/core/sort.ts (SortKeys), src/gpu/sortKey.tsl.ts (GpuSortKeys), src/wgpu/WgpuSplatSorter.ts, WgpuRadixSort.ts, WgpuCompact.ts, src/worker/splatSort.ts (sortSplatKeys), and src/render/SplatView.ts.

// The WebGL2 path, also on WebGPU (to compare)
splatRenderer.sortMode = "cpu";
// The exact float order (a 4th GPU pass)
splatRenderer.sortBits = 32;
// The CPU sort drops splats well outside the view
splatRenderer.objectParams.sortGuard = 0.5;

Sort keys#

Each splat's key is its distance from the head as bits, arranged so that ascending keys run back to front. Distances are measured from the head, not along the view direction, so one order stays valid for every view direction from the same position, and both eyes of an XR pair share one sort.

sortBitsGPU radix passesCPU sortOrders splats apart byUse it for
162one counting passabout 0.1% of their distance; a clamped window of 6 exponent bits (2^-32 to 2^32) and 10 mantissa bitsthe cheapest sort: 35-55% faster than 24 bits
24 (default)3two passes of 11 and 13 bitsabout 0.0015% of their distancea balance of quality and speed
324two passes of 16 bitsthe exact float32 orderexact references

The GPU sort#

With sortMode: "gpu" (WebGPU's default), each render runs, after the projection, in one compute pass:

  1. Compaction (WgpuCompact): drops invalid keys and keeps the rest in input order. With the GPU sort, splats culled in every eye get the invalid key, so they are neither sorted nor drawn.
  2. Radix sort (WgpuRadixSort.sortIndirect): a stable LSD sort with 8-bit digits, sized on the GPU from the compacted count.
  3. Draw arguments: a one-thread kernel writes [6 × 1024, ceil(visible / 1024), 0, 0, 0] for the indexed indirect draw.

Nothing is read back, so each frame is drawn in order for the current camera, with any number of cameras. WgpuSplatSorter combines the three (WebGPU compute); splatRenderer.readSortedCount() reads the visible count back for stats.

Sorting more keys than the device's storage binding limit allows (by default 256 MiB, or 64M keys) throws an error naming the limit; createRenderer({ limits: { maxStorageBufferBindingSize: "adapter" } }) asks for all the adapter has (The renderer).

The CPU sort#

WebGL2 has no compute shaders. With sortMode: "cpu":

  1. A keys pass (a cached program that writes only sort keys: a fragment pass on WebGL2, compute on WebGPU) computes each splat's key for the view's collection and camera.
  2. The keys are read back asynchronously.
  3. A worker sorts them (sortSplatKeys, running SortKeys.sort).
  4. The view applies the order, and its renders draw it until a newer one is applied.

The order arrives a few frames late. It still draws correctly, because a late order indexes the same splats of the same collection: while the camera moves, the blend order is only slightly out of date. A new collection shows once a sort of it has landed. autoSort runs this loop for each view (Views and CPU orders).

sortSplatKeys(keys, { bits, ordering?, worker? }) is the worker sort on its own (Workers). SortKeys.sort({ keys, count, bits, ordering? }) is the sort itself, on the current thread: stable, invalid keys last, returning { ordering, visible }.

sortGuard#

The CPU sort's order lags the camera, so its keys cannot cull by the view: a splat off screen now may be on screen when the order is drawn. objectParams.sortGuard adds a guard band: splats farther outside the head's view than this (in NDC beyond the edges, plus each splat's extent) get the invalid key, so fewer splats are sorted and drawn.

SortKeys#

MemberWhat it does
SortKeys.compute(distance, bits)the key of a distance (>= 0)
SortKeys.invalid(bits), SortKeys.validMax(bits)the invalid key (all ones), and the largest valid key
SortKeys.sort({ keys, count, bits, ordering? })stable ascending sort into ordering (default: a new array of a whole capacity); returns { ordering, visible }
SortKeys.KEYSthe sortKeys bundle: { key: "unorm8x4" }, not serialized
SortKeys.KEY16_LO, KEY16_HIthe float bit range 16-bit keys keep

SortKeyBits is 16 | 24 | 32. Draw orders are ArrayData.INDICES lists.

Costs#

At 10M splats on an Apple M3 Max, the GPU radix sort takes about 3, 4.7, and 7 ms at 16, 24, and 32 bits; compaction under 1 ms. The CPU path's readback and worker sort take tens of milliseconds at millions of splats, but they run asynchronously while frames keep rendering (Performance).

Source: docs/sorting.md