Adding Native Gaussian Splatting Support to ThreeJSSkip to contentBen is currently available for contract work for 3D & web solutions — reach out.
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.
I've tried to make the end result as simple as possible to use. For example to load a .spz file and render it as a Gaussian Splat, just do this:
const splatData = await new SPZLoader().loadAsync('lion.spz');<br>const splats = new GaussianSplatMesh(splatData);
scene.add(splats);
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.
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.
O(N) Approximate Sorting#
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.
The four passes are each a short TSL compute shader, dispatched in order from compute():
compute( renderer ) {
renderer.compute( this._resetNode ); // clear the histogram and offset buffers<br>renderer.compute( this._histogramNode ); // bin[i] = binNode(i); histogram[bin]++<br>renderer.compute( this._prefixNode ); // offset[bin] = exclusive prefix sum of histogram<br>renderer.compute( this._scatterNode ); // order[offset[bin[i]]++] = i
The histogram and scatter passes both use atomicAdd so that many GPU threads can safely increment the same bin's counter or claim the same bin's next...