Skip to content

Back to Blog Listing

A Minimal, TSL-Native Gaussian Splat Renderer for Three.js

A walkthrough of the Gaussian Splat renderer and loaders I contributed to Three.js, a small WebGPU/TSL implementation with a reusable GPU counting sort, loaders for PLY, SPLAT, SPZ, KSPLAT and glTF, and where it sits relative to Spark and PlayCanvas's SOG format.

Ben Houston9 min read

Gaussian Splatting has become a standard rendering primitive. In the last year it landed as a native capability in glTF (KHR_gaussian_splatting) and USD, and shipped in Babylon.js, Apple's RealityKit, NVIDIA Omniverse, and high-end offline renderers like V-Ray and Arnold. Three.js didn't have a native implementation, so I built one and submitted it as PR #33950.

I also had a more personal reason. I needed Gaussian Splat support in Land of Assets, and doing the work natively in Three.js, instead of pulling in a third-party renderer, made that integration simpler.

Try the live GitHack example below, orbit the camera and switch between the Lion and Millipede splats (GitHack is a bit slow since it loads the live changes from Github):

Gaussian splats, briefly#

A Gaussian splat scene replaces the mesh with a cloud of oriented, colored 3D Gaussians, reconstructed from a set of photos or, increasingly, a video scan. Each Gaussian carries a center (its position), a covariance (an orientation and set of dimensions, so it can be a thin oriented disc, an elongated blob, or anything between), and a color with opacity.

To render one, you project its 3D covariance into a 2D screen-space ellipse, evaluate a Gaussian falloff across that ellipse, and alpha-blend it with everything behind it, back to front. Do that for a few hundred thousand to a few million Gaussians per frame and you get photographic renders of real captured scenes, without a single triangle.

The current implementation stores a flat color and opacity per splat, equivalent to degree-0 spherical harmonics (SH0). That's enough to get a splat looking right from any single angle, but colors don't shift with viewing direction the way real specular and view-dependent surfaces do. Higher-order SH (SH1, SH2, SH3) would capture that view dependence.

Lion Gaussian splat rendered in Three.js

Three Architectural Layers#

The PR splits into three layers, each with a narrow job:

  • A plain BufferGeometry serves as the data container: position, a 6-float covariance attribute (the upper triangle of a symmetric 3×3 matrix), and an rgba8 color attribute. There's no bespoke "splat data" class, so it composes with everything else in Three.js that already knows how to work with geometries.
  • Loaders that all target that same BufferGeometry shape, regardless of source format (covered in detail below).
  • GaussianSplatMesh, the renderer, is a WebGPU/TSL NodeMaterial.

GaussianSplatMesh's vertex node does the real work. For each splat it takes the 3D covariance, transforms it into view space, and projects it through the Jacobian of the perspective projection to get a 2D screen-space covariance. From there it computes eigenvalues and axes to get an ellipse, and expands an instanced quad to cover it. The fragment node evaluates the Gaussian density across that quad, discards anything outside a small radius, and alpha-blends the rest. All of this is written in TSL, so it runs unmodified on both the WebGPU and WebGL backends of WebGPURenderer.

A GPU-Based O(N) Approximate Painter's Sort#

Splats need back-to-front ordering for alpha blending to look right, the same rule behind the "painter's algorithm." An exact GPU sort would cost too much to run at that rate, and an approximate order, close enough for overlapping splats to blend correctly, is enough.

Two things this sort does not do: it doesn't touch the splat data, and it doesn't run every frame. It sorts a separate array of per-splat indices into the existing center/covariance/color buffers, so GaussianSplatMesh only ever moves a uint per splat, not the full payload. And GaussianSplatMesh only re-sorts when the camera's position or view direction has moved past a small threshold since the last sort, doing a fresh full sort each time rather than an incremental update.

The algorithm is a counting sort: quantize each splat's depth into one of a few thousand bins, then run four compute passes (reset, histogram, prefix sum, scatter) to bucket every index by bin. It's the same building block behind a full radix sort, and with enough bins it's indistinguishable from an exact sort. There's also a CPU fallback running the same four steps in plain JavaScript for the WebGL backend of WebGPURenderer, where compute shaders aren't available.

This all lives in its own class, CountingSort, not in GaussianSplatMesh. GaussianSplatMesh hands it a function mapping a splat to its depth bin and gets an index array back, without knowing whether that ran on the GPU or the CPU fallback. Keeping it separate keeps that complexity out of GaussianSplatMesh and makes it reusable elsewhere.

Supported loaders#

The loaders all produce the same position / covariance / color BufferGeometry, so GaussianSplatMesh doesn't care which one produced its input:

FormatExtensionClass
GraphDECO/INRIA 3DGS PLY.plyExisting PLYLoader, plus a createGaussianSplatGeometryFromPLYGeometry conversion helper (reads scale_*, rot_*, f_dc_*, opacity properties)
Early GS3D fixed-width format.splatSPLATLoader
Niantic/Scaniverse compressed format.spzSPZLoader
GaussianSplat3D's compressed format.ksplatKSPLATLoader
glTF Gaussian splat extensionKHR_gaussian_splattingGLTFGaussianSplatLoaderExtension

The PLY case is worth calling out: rather than writing a standalone PLY parser for splats, I reused the existing PLYLoader (which already parses arbitrary vertex properties into a generic BufferGeometry) and wrote a small conversion helper on top. That helper does the splat-specific math: turning per-vertex scale and quaternion rotation into a packed covariance matrix, and running opacity and color values through a sigmoid and an SH0-to-linear conversion.

The glTF extension is opt-in rather than built into the core GLTFLoader. GaussianSplatMesh is WebGPU/TSL-only, and making GLTFLoader aware of it by default would have pulled a TSL dependency into every project that uses GLTFLoader, even ones that never touch splats. Registering GLTFGaussianSplatLoaderExtension by hand keeps that dependency opt-in.

One aside on tooling: I found the Lion and Millipede source scans on PlayCanvas's SuperSplat site as PLY captures, then used Splatware to convert them to .spz and .splat for testing those loaders. Both are solid, browser-based tools for sourcing and converting 3DGS scenes.

Millipede Gaussian splat rendered in Three.js

Comparing this to Spark and PlayCanvas#

Spark, built by World Labs, and PlayCanvas's pipeline behind SuperSplat both predate this work and are more feature-complete. The Three.js implementation stays narrower in scope: TSL-first and WebGPU-native rather than raw WebGL/GLSL, with no WASM, no web workers, and no large external dependencies beyond loaders, one mesh class, and one sort utility. That trade buys a small, readable implementation that lives naturally inside Three.js and is easy to extend, at the cost of some advanced capabilities other libraries already ship.

The clearest gap is streaming and level-of-detail, and Spark and PlayCanvas have both solved it:

  • Spark 2.0 ships a real LoD/streaming system: SplatMesh LoD trees, a virtual paging system, and a purpose-built .RAD format that organizes a scene into 64K-splat chunks. Those chunks are spatially partitioned, compressed per property, and randomly seekable over HTTP range requests, so a coarse version of a huge scene appears almost instantly and refines as the camera moves.
  • PlayCanvas's SOG format (Spatially Ordered Gaussians, the format behind SuperSplat) takes a similar approach from a different angle: a compressed, GPU-ready, Morton-ordered format, with a "Streamed SOG" variant that splits a scene into a spatial tree of chunks at multiple LOD levels for progressive streaming of very large scenes.

Neither capability exists in this implementation today. If streaming and LOD get added later, adopting or interoperating with an existing chunked, seekable format like .RAD or Streamed SOG is a more realistic path than retrofitting that structure onto .ply, .splat, .spz, or .ksplat.

Future work#

  • Higher-order spherical harmonics The current renderer only stores flat, degree-0 color (SH0) per splat. Adding SH1–SH3 support for view-dependent color is a natural extension of the current BufferGeometry-based container: add more color channels and evaluate them against view direction in the fragment node, without touching the loader contract or the sort.

  • Compressed attributes / streaming metadata There's room to shrink the per-splat data footprint beyond what the current loaders do, something I called out as a natural extension point in the original PR description.

  • Incremental / progressive loading None of the formats supported so far (.ply, .splat, .spz, .ksplat, or glTF's KHR_gaussian_splatting) support loading a splat cloud coarse to fine for incremental refinement today. One exception: the .spz spec reserves an optional per-splat LOD field, but the current loader only parses far enough to skip past it. It isn't exposed or usable yet.

  • Spatial partitioning for scene-scale streaming This is a distinct, bigger capability than refining one splat cloud's level of detail. Chunking a scene spatially (the way Spark's .RAD and PlayCanvas's Streamed SOG both do) lets a viewer walk a spatial tree, show a coarse whole-scene proxy almost instantly, and page in only the chunks and detail levels near the camera. That unlocks scenes larger than fit in memory or GPU budget at once, like city-scale captures or multi-room walkthroughs, rather than single-object splats like the Lion or Millipede demos above. If this gets tackled, interoperating with an existing chunked, seekable format is more realistic than inventing a new one from scratch.