Adding Native Gaussian Splatting Support to ThreeJS

bhouston1 pts0 comments

A Minimal, TSL-Native Gaussian Splat Renderer for Three.jsSkip to content<br>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.

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:

FormatExtensionClassGraphDECO/INRIA 3DGS PLY.plyExisting PLYLoader, plus a createGaussianSplatGeometryFromPLYGeometry conversion helper (reads scale_*, rot_*, f_dc_*,...

splat gaussian gaussiansplatmesh sort covariance three

Related Articles