Projection#

This page explains how a SplatRenderer turns splats into what it draws: the methods, their costs, and the code. All of it is internal to render/. Apps choose a method and params on a renderer, its views, and its objects (Rendering, Params).

Code: src/render/SplatProjection.ts (one object per method), projections.tsl.ts (the four methods), projection.tsl.ts (their shared parts), splatCamera.ts (the camera as uniforms), splatTransform.tsl.ts (each object's transforms), splatParams.ts, SplatProjector.ts (the programs and the dispatch), splatDraw.ts (the draw material), and gpu/sortKey.tsl.ts (GpuSortKeys, in gpu/ because wgpu/ reads the keys too). All of it is TSL, so it runs on both backends.

The projection turns each splat into a few uint32 words, holding what the draw needs to place and shade a quad for every eye, plus one sort key. It is a writer body like any other, so GpuArrayWriter (a fragment pass, both backends) and WgpuArrayWriter (compute, WebGPU) can both run it, with the same bits. SplatProjector builds one program per generator; the program generates each splat, transforms it to head space (applying the modifier), and projects it.

// What SplatRenderer does on each render (internal functions)

// Its settings, with the render's view's over them
const settings = splatRenderer.settingsOf(view);

// The head plus 1 eye, or 2 eyes from an XR ArrayCamera
SplatCamera.set(renderer, camera);

const projection = SplatProjection.get(settings.method);
const arrays = new GpuArrays({
  renderer,
  bundles: projection.outputs(SplatCamera.eyes),
  storage: true,
});

projectCollection({
  renderer,
  settings,
  collection: settings.collection,
  arrays,
  pass: "full",
  cullKeys: true,
});

Generate and project in one pass#

A generator is a splat reader or a TSL function that computes splat index in the shader (Generators and modifiers). Generation and projection run in one kernel, with no intermediate splat arrays:

const ring: SplatGenerator = {
  generateSplat: (i) =>
    GpuSplats.Splat(
      centerOf(i),
      vec3(0.01),
      vec4(0, 0, 0, 1),
      colorOf(i),
      float(1),
      uint(i),
    ),
};

splatRenderer.collection = new SplatCollection([
  { generator: ring, count, matrixWorld: ringObject.matrixWorld },
]);

Each method reads one splat form (projection.input, "cov" or "splat"): ewa reads a covariance, and the others read scales and a rotation. When the generator's form differs, the program converts (GpuSplats.toCov / fromCov), so a projection only sees one kind of splat node.

Methods#

MethodHow it rasterizes a splatCostChoose it for
ewa (default)classic 3DGS / EWA splatting: each eye gets a 2D Gaussian from the projection's Jacobian at the splat's centerthe cheapestmost scenes; the look 3DGS captures were trained for
3dguta 2D bound from the unscented transform of 7 sigma points, then the 3D Gaussian evaluated at its maximum along each pixel's raywithin 25% of ewa from outside an object; 4-25x slower inside a scenelarge or close splats and wide fields of view, where ewa's linearization at the center is inaccurate
htgsthe exact outline of the ellipsoid (a dual quadric), then the same ray evaluationlike 3dgut, slower inside scenesthe tightest exact bounds
raygsa 3D quad facing the eye in the splat's whitened frame, with the density interpolated across itwithin 10% of ewaray-evaluated look at nearly ewa's cost

Each method has four steps: project (the words of one splat), look (rgb, opacity, and whether it is culled), vertex (one quad corner from the words), and density (per fragment). They share the LoD opacity stretch, depth of field, culling, boundFromCov, the anti-aliasing filter, and screen clipping. ewa projects covariances directly; the ray methods need a rotation and scales, so they diagonalize each cov splat once.

SplatProjection: one object per method#

SplatProjection.get(method) returns the object for a method (SplatProjection.METHODS lists them):

The camera: head and eyes#

Every splat is read once and projected for all eyes; the loop over the eyes unrolls in JavaScript when the shader is built.

Words#

The words of one splat (the head words, then each eye's) fill the uint32x4 channels w0, w1, and w2 of the bundle projected; the last channel is uint32x2 when only 1 or 2 words remain. In the table, semicolons separate words:

MethodHead wordsWords per eyeMonoStereo
ewanonendc 2x16; depth20 and angle12; rgb; opacity8 and 2x12 log2 scales4 words, 16 B8 words, 32 B
3dgut, htgscenter direction; center distance; rgb; quat; 3 half log scales, opacity8 and the center direction's low bitsbound ndc 2x16; angle10 and 2x11 log2 scales8 words, 32 B10 words, 40 B
raygscenter direction; center distance; rgb; opacity8 and the center direction's low bitsframe quat; float32 c²; 2 half log axis lengths7 words, 32 B10 words, 40 B

Sort keys#

The bundle sortKeys (SortKeys.KEYS) holds one RGBA8 texel per splat: a back-to-front key from the splat's distance to the head (GpuSortKeys.compute, then pack). The width (16, 24, or 32 bits) is a uniform. cullKeys (a uniform) chooses which splats get the invalid key. For the GPU sort, which compacts invalid keys away, these are the splats culled in every eye. For the CPU sort, whose order arrives late, they are only invisible splats and splats outside sortGuard, and the draw culls per eye. More: Sorting.

The draw#

Params#

SplatParams holds the defaults and the uniform nodes every program and draw reads (SplatParams.nodes, set before each dispatch and draw). SplatViewParams (maxStdDev, minAlpha, falloff) are read by the draw too, so there is one set per render. SplatObjectParams (lodInflate, blurAmount, preBlurAmount, clipXY, minPixelRadius, maxPixelRadius, minContribution, focalDistance, apertureTan, and sortGuard) are set per object. Renderers, views, and objects hold plain values. Every param and its default: Params.

With lodInflate, the projection grows a stretched splat (an opacity above 1) and stores opacity 1; without it, the stretched opacity is stored as is, and the draw treats any stored opacity above 1 as a stretch.

Source: docs/projection.md