Taos Engine Photos → 3D

From flat photographs to a place you can fly through

How a computer rebuilds a real place out of soft colored dots called Gaussian Splats

step 0 error 100%
Watch it learn. Every dot is one blob. They start as gray fog in the wrong places and, step by step, drift and recolor until — with nothing to go on but "how wrong does this look?" — they resolve into a scene.

Ordinary computer graphics goes one way: build a 3D model, and the computer makes a picture of it. Gaussian splatting does it backward — pictures in, 3D out. Nobody hands over a model, so the computer has to discover one that would have produced the photos you gave it.

FIRST HALF

Structure from Motion

Recover where each photo's camera was, and a few 3D points it was able to calculate from the photos and camera poses.

HANDOFF

Poses + point cloud

Exact cameras and a sparse 3D point cloud become the starting scaffold to solve for the final 3D scene.

SECOND HALF

Splat training

Grow and polish blobs until they reproduce every input photo. From these blobs, you can generate new views of the scene that were not in the original set.


Groundwork · assume nothing
Before anything else

What a computer actually has to work with

A photo is a grid of numbers

To a computer a photograph is not a scene — it's a rectangular grid of tiny squares called pixels. Each pixel holds three numbers: how much red, green, and blue it shows, each from 0 (none) to 255 (full). A 1920×1080 photo is just 1920 × 1080 × 3 ≈ 6.2 million numbers. Nothing in the image is labeled; there is no "building" or "sky", only numbers.

Interactive · a photo is a grid of numbers
the image — each square is one pixel; hover to move the box
those pixels as their red / green / blue numbers
Zoom in far enough and a photo is just a grid of little squares, and each square is only three numbers — how much red, green and blue it holds (0–255). Move the box over the image to read the exact values it stands for. There is no "sky" or "sun" in there, only numbers.

Many algorithms don't care about color, only brightness, so they first collapse the three channels into one grayscale value per pixel — a weighted average that matches how the eye perceives lightness:

The math — brightness of a pixel
gray = 0.299 R + 0.587 G + 0.114 B

Green is weighted most because our eyes are most sensitive to it. The result is one number per pixel — call it I(x, y), the image's brightness at column x, row y.

Interactive · the same pixels as one brightness number
the image — hover to move the box
one brightness value per pixel (0.299R + 0.587G + 0.114B)
Collapsing each pixel's three color numbers with the weighted average above leaves a single brightness value — the grayscale image the feature-finding steps actually work on. Each cell is shaded by that value and labeled with it.

The useful information is in the changes

A patch of clear sky is thousands of nearly-identical numbers. It's useless for matching photos, because every sky pixel looks like every other — you could never tell which sky pixel in photo A is the "same" one in photo B. What is useful is where the numbers change: an edge, a corner, a speckle of texture. The change from one pixel to its neighbor is called the gradient, and almost every step in this guide is, underneath, a hunt for informative gradients.

The math — the gradient (local change)
gx = I(x+1,y) − I(x−1,y)2     gy = I(x,y+1) − I(x,y−1)2
  • gxhow fast brightness changes going right — just "the pixel to my right minus the pixel to my left." Big where there's a vertical edge, zero on a flat wall.
  • magnitude√(gx² + gy²) is "how much edge is here"; its direction, atan2(gy, gx) — the compass angle of the arrow (gx, gy) — is "which way the edge runs." These two numbers appear again and again.
Interactive · where the information lives
the image (brightness)
strength of change — edges above 10%
10%
The right panel is the gradient magnitude — how fast brightness changes at each pixel. Flat regions (sky, walls) stay black no matter the threshold: there is nothing there to match. Raise the slider and only the strongest edges survive. All the matchable information is in the bright pixels.

Gradient descent: guess, measure, nudge, repeat

Both Structure from Motion (finding the cameras) and splat training (building the scene) work from the same idea: make a guess, measure how wrong it is (the "error" or "loss"), and adjust the guess so the error gets a little smaller. Repeat thousands of times and the guess gradually becomes a solution. This is gradient descent.

The picture to keep in mind: you're standing on a hillside in fog, and the height of the ground is the error. You can't see the valley, but you can feel which way the ground slopes under your feet. Step downhill a little, feel again, step again. "Rolling the error downhill" is the one engine that drives everything in this guide — bundle adjustment later in the first half, and the whole training loop in the second.

Note the word gradient is doing double duty. A moment ago it meant how much brightness changes from one pixel to the next. Here it means how much the error changes when you change one of your guessed numbers a hair. It's the same idea — "if I move this input a little, how much and which way does the output move?" — just applied to a different input. We'll also need a word for a number's share of the gradient: how much this number is responsible for the error, and which way it should move. We'll call that its blame.

Everywhere you see the word "nudge," it means: compute the direction that lowers the error (the gradient of the error), and move a small step along it. The art is in defining a good error and computing its gradient efficiently — which is most of what the code does.

First Half · Structure from Motion
The goal

Recover where every photo was taken from

Before you can rebuild a scene from photos you must know, for each photo, the exact position and orientation of the camera that took it — its pose — plus the camera's focal length (how zoomed-in it is). Get these wrong and everything downstream is built on sand. But a photo doesn't record its own pose; you only have a pile of overlapping pictures. Recovering the poses and a rough 3D point cloud purely from the pictures is Structure from Motion (SfM).

It works because the same physical thing — a corner, a sign, a rooftop — appears in many photos, and its position in each one constrains how the cameras must be arranged. The pipeline is: find distinctive spots → match them across photos → solve the two-view geometry → grow to all photos → polish everything jointly. We'll build each stage from scratch.

Interactive · what "success" looks like — drag to orbit
the recovered cameras (pyramids) around the sparse 3D points (dots)
This is the end result this half is after: for every photo, the camera's exact position and angle in space — each little pyramid is one photo's recovered viewpoint, its base the image plane — together with a sparse cloud of 3D points found along the way. How the pyramids get placed is the rest of this half; this is just what the answer looks like. (Drag to spin it.)
Stage 1 · Features

Finding distinctive spots that survive a change of viewpoint

You need points you can recognize again in another photo taken from a different angle, distance, and rotation. The gold-standard method is SIFT (Scale-Invariant Feature Transform). It does two jobs: find good points (the detector) and describe each so it can be matched (the descriptor).

Why "scale" is the hard part, and how blur solves it

The same corner is a handful of pixels in a far photo and hundreds in a near one. If you looked for corners at a single, fixed pixel size you'd find it in one photo and miss it in the other. The trick is to look at the image at many levels of blur at once. Blurring throws away fine detail; a heavily blurred image only shows large structures. So a stack of increasingly blurred copies is really a stack of the image "seen at different sizes." SIFT searches that whole stack, so a feature is found at whatever blur level (= size) it lives.

Blurring is done with a Gaussian blur: replace each pixel with a weighted average of its neighbors, weights following the bell curve (near neighbors count more). Blur a bit, blur more, and once the image is blurry enough you can safely halve its resolution and keep going cheaply — each halving is called an octave.

src/splats/sfm/sift.ts — separable Gaussian blurTypeScript
// A Gaussian blur is "average each pixel with its neighbors, bell-curve weighted."
// Done in two 1-D passes (horizontal then vertical) — far cheaper than one 2-D pass.
function gaussianBlur(src, w, h, sigma) {
  const r = Math.ceil(sigma * 3);                  // how far the bell reaches
  const k = new Float32Array(2*r + 1);           // the weights
  for (let i = -r; i <= r; i++) k[i+r] = Math.exp(-(i*i) / (2*sigma*sigma));
  normalize(k);                                // weights sum to 1 (no brightening)
  // ...convolve rows with k, then columns with k...
}
Interactive · Gaussian blur (real, computed live)
original
blurred at σ = 2.0  (radius 6 px)
2.0
Drag σ up and detail vanishes — small things disappear first, then large ones. Blurring is really "throw away detail below this size," which is why a stack of increasing blurs is a stack of the image seen at increasing sizes. This is the exact separable Gaussian blur from the code above, running on the pixels in your browser.

Difference-of-Gaussians: turning blur into a blob detector

Now the key idea. Subtract two adjacent blur levels, pixel by pixel. In a flat region the two blurs are identical, so the difference is zero. But wherever there's a blob of roughly the size that sits between those two blur levels, the difference lights up. This subtraction, the Difference-of-Gaussians (DoG), is a cheap blob detector, and it responds most strongly at the scale matching a feature's size — exactly what we want.

A candidate feature is a pixel that is a peak in this DoG stack — brighter (or darker) than all 26 of its neighbors: the 8 around it at the same blur level, plus the 9 directly above and 9 below in the next blur levels. Being a peak across scale as well as space is what makes the detection size-independent.

sift.ts — DoG peaks across scaleTypeScript
// D[s] = blur[s+1] − blur[s]  (Difference-of-Gaussians at scale s)
for (let i = 0; i < ow*oh; i++) d[i] = G[s+1][i] - G[s][i];

// a candidate is a pixel bigger/smaller than all 26 neighbors in 3D (x, y, scale)
let isMax = true, isMin = true;
for (let dy = -1; dy <= 1; dy++)
  for (let dx = -1; dx <= 1; dx++)
    for (const dd of [D[s-1], D[s], D[s+1]]) {   // scale below, same, above
      const u = dd[neighbor];
      if (u >= v) isMax = false;
      if (u <= v) isMin = false;
    }
if (!isMax && !isMin) continue;                     // not a peak → discard
Interactive · Difference-of-Gaussians blob detector
blur A  (σ = 2.0)
blur B  (σ = 3.2)
A − B → blobs found: 0
2.0 amber = brighter-than-surround, blue = darker; rings mark detected blob centers at this scale.
Subtracting two nearby blur levels cancels flat areas (they're identical) and lights up wherever a blob of this size sits. Slide the scale: small σ finds windows and fine texture, large σ finds the sun and big shapes. A blob is a peak that is stronger than all its neighbors — including its neighbors in scale — which is why searching across σ makes detection size-independent.

Refining and cleaning up the candidates

Raw peaks are only pixel-accurate and include many junk points. SIFT applies three fixes, in order:

  1. Sub-pixel localization. The true peak usually lies between pixels. Fit a little 3D parabola to the DoG values around the peak and jump to its exact top (the math name for that fit is a second-order Taylor expansion). This also gives a refined contrast value at the true peak.
  2. Contrast rejection. If that refined peak is too faint (below a threshold of about 0.007), it's noise, not structure — drop it.
  3. Edge rejection. DoG also fires along straight edges, but an edge point slides freely along the edge, so it can't be pinned down and matched. The fix uses the local curvature. Along an edge the surface curves sharply one way and barely the other; on a corner it curves both ways. The ratio of the two curvatures is captured by the 2×2 Hessian (the matrix of second derivatives). If curvature is lopsided — the ratio exceeds ~10 — it's an edge, so discard it.
The math — the edge test
keep only if   trace(H)2det(H)  <  (r+1)2r,   with r = 10
  • Hthe 2×2 matrix of the DoG's second derivatives at the point — how it curves in x, in y, and diagonally.
  • trace, dettrace = sum of the two curvatures, det = their product. Their combination equals (curvature ratio + 1)²⁄ratio, which blows up when one curvature dwarfs the other — i.e. on an edge.
src/splats/sfm/sift.ts — the 2×2 Hessian edge testTypeScript
// second derivatives of the DoG at the candidate = the 2x2 Hessian H
const dxx = Dbm[ii + 1]  + Dbm[ii - 1]  - 2 * Dbm[ii];   // curvature in x
const dyy = Dbm[ii + ow] + Dbm[ii - ow] - 2 * Dbm[ii];   // curvature in y
const dxy = (Dbm[ii+ow+1] - Dbm[ii+ow-1] - Dbm[ii-ow+1] + Dbm[ii-ow-1]) / 4;
//         [ dxx  dxy ]
//   H  =  [ dxy  dyy ]     trace = dxx + dyy,   det = dxx*dyy - dxy^2
const tr = dxx + dyy;
const dt = dxx * dyy - dxy * dxy;
// an edge curves sharply one way, barely the other, so trace^2/det blows up;
// a corner curves both ways, so the ratio stays small. Keep only corners
// that are also above the contrast floor. (contrast comes from the sub-pixel fit.)
const EDGE_R = 10;
if (dt > 0 &&
    tr * tr / dt < (EDGE_R + 1) * (EDGE_R + 1) / EDGE_R &&
    Math.abs(contrast) >= PEAK_THRESH) {
  ok = true;   // survives -> becomes a keypoint
}
Interactive · keep corners, reject edges (the Hessian test)
the image (brightness)
● corner — kept    ● edge — rejected  ·  r = 10.0
10.0 low r is strict (only clean corners survive); high r tolerates edge-like points.
Each spot is judged by the curvature ratio trace²/det of its 2×2 Hessian. Corners curve sharply both ways (small ratio → amber, kept); edges curve one way only (large ratio → teal, rejected); flat regions have no curvature and never become candidates (they stay dark). Slide r to watch the cutoff move — this is the exact edge test from the code above, run on every pixel.

Giving each point an orientation (so rotation stops mattering)

The same corner photographed with a tilted camera is rotated. To match it anyway, SIFT gives every surviving point its own reference direction, then describes it relative to that direction — so the description reads the same however the photo is spun. The direction is found by making a histogram: look at all the gradients in a small circle around the point, and tally their directions into 36 bins (one per 10°), each vote weighted by the gradient's strength and its distance from the center. The tallest bin is the point's orientation.

The math — the orientation histogram

Every pixel in a small disc around the point casts a vote for a direction. The vote's direction is the gradient's angle; its weight is the gradient's strength times how close the pixel is to the center. Votes land in 36 bins (one per 10°):

m = √(gx2 + gy2)    θ = atan2(gy, gx)    bin = round( θ2π × 36 )
  • mthe gradient's strength — strong edges vote loudly, flat areas barely at all.
  • θthe gradient's direction, which decides the bin.
  • weighteach vote is also scaled by exp(−dist2/2σ2), so nearby pixels count more than far ones.

The tallest bin (after a little smoothing) is the point's orientation. Any other bin within 80% of the tallest spawns a second keypoint at that angle — two features at the same spot, differently oriented. Because the 128-number descriptor below is measured relative to this direction, the fingerprint reads the same whether or not the photo is rotated.

src/splats/sfm/sift.ts — orientation histogram (36 bins)TypeScript
const hist = new Float32Array(36);
// vote each nearby gradient into a direction bin, weighted by strength x nearness
for (each pixel (dx, dy) in a disc around the point) {
  const gx = (L[i + 1]  - L[i - 1])  / 2;
  const gy = (L[i + ow] - L[i - ow]) / 2;
  const mag = Math.hypot(gx, gy);
  const ang = Math.atan2(gy, gx);
  const wgt = Math.exp(-(dx*dx + dy*dy) / (2*sig*sig));   // nearer = heavier
  let bin = Math.round(ang / (2*Math.PI) * 36);
  bin = ((bin % 36) + 36) % 36;
  hist[bin] += mag * wgt;
}
// ...smooth the histogram, then take the peak(s):
const hmax = Math.max(...hist);
for (each bin b that is a local max && hist[b] >= 0.8 * hmax)
  addOrientation( parabolicPeak(b) );   // the dominant angle, plus any within 80%
Interactive · giving a point its orientation — hover the image
the image — hover a spot; the circle is the analysis window, the arrow its chosen direction
gradient directions around that spot (36 bins) · dominant —
Each spoke is one of the 36 direction bins; its length is how much gradient points that way (strength × nearness). The longest spoke — the amber arrow — becomes the point's orientation; any spoke within 80% of it (also amber) makes a second keypoint. The descriptor is then read relative to this arrow, so it comes out the same however the photo is rotated. Hover a flat area and the rose collapses; hover an edge or corner and it spikes.

The 128-number fingerprint

Finally, the descriptor. Around the point (rotated to line up with its orientation) lay a 4×4 grid of cells. In each cell, build a small 8-direction gradient histogram. That's 4 × 4 × 8 = 128 numbers — the fingerprint. Three touches make it robust:

  • Trilinear spreading: each gradient contributes a little to neighboring cells and bins, so a feature landing near a cell boundary doesn't flip the fingerprint.
  • Normalization: divide by the overall magnitude (so brighter/darker lighting doesn't matter), clamp any single value to 0.2 and re-normalize (so one glaring gradient can't dominate).
  • RootSIFT: take the square root of each value. This quietly makes comparing two fingerprints behave better, so strong edges don't drown out subtle texture.
sift.ts — the descriptor cell histogram + normalizationTypeScript
// 4×4 cells × 8 directions = 128. Each gradient is spread (trilinear) into
// neighboring cells/bins so the fingerprint is stable near boundaries.
acc[(vb*DW + ub)*DB + obn] += wgt * wu * wv * wo;

// normalize: L2 → clamp each to 0.2 → L2 again → RootSIFT (L1-normalize, sqrt)
for (i) acc[i] = Math.min(0.2, acc[i] * inv);      // no single spike dominates
for (i) out[i] = Math.sqrt(acc[i] / l1) * 512;    // RootSIFT, stored as bytes 0–255
Interactive · the 128-number fingerprint (and why rotating doesn't break it)
the patch — 4×4 grid + orientation arrow (amber)
4×4 cells × 8 directions — the visual descriptor
the 128-number fingerprint (flattened, normalized)  ·  match vs reference: 100%
0° align ON → fingerprint holds as you rotate; OFF → it scrambles.
The fingerprint is 16 little cells, each a star summarizing which way edges point inside it — 16×8 = 128 numbers. Because the grid is first turned to line up with the feature's own orientation (the amber arrow), the same spot yields the same fingerprint even when the photo is rotated. Turn align off and rotate: the grid no longer follows the feature, the stars and bars reshuffle, and the match percentage falls — which is exactly why the orientation step matters.

Output of Stage 1: for each photo, a few thousand points, each a pixel location plus a 128-byte fingerprint that is stable across size, rotation, and lighting.

Stage 2 · Matching

Deciding which spots are the same across two photos

Two photos, each with a few thousand fingerprints. Which point in photo A is the same physical thing as which point in photo B? The answer for a given A-point is simply its nearest neighbor in B — the B-fingerprint with the smallest total difference. "Difference" is squared Euclidean distance: subtract the two 128-number fingerprints element by element, square, and sum.

The math — distance, and the two acceptance tests
dist(a, b) = Σk=1..128 (ak − bk)2
  • Σk"add up, for every k from 1 to 128" — a for-loop that accumulates. You'll see Σ throughout; it always means a sum over the subscript.
  • ratio testkeep the match only if the best distance is under 0.8× the second-best. A true match is decisively closer than any impostor; a point that has two near-equal candidates is ambiguous, so reject it. This one test removes most bad matches.
  • cross-checkkeep it only if A's best in B also has B's best in A — the two photos must agree both directions (a "mutual" match).

Comparing every fingerprint in A against every one in B, for every pair of photos, is the single most expensive part of SfM — thousands of pairs, millions of 128-number comparisons each. Running it on the graphics card can make quick work of it. The GPU has thousands of tiny cores; the job is split so one core handles one A-fingerprint and streams B's fingerprints past it in blocks (a block is copied into fast on-chip "workgroup memory" that all cores in a group share, so it's read once and reused by 64 queries at a time).

src/splats/sfm/gpu_match.ts — WGSL compute shader (one core per query)WGSL
// tileB is 64 B-fingerprints copied into shared on-chip memory,
// reused by all 64 query cores in this workgroup.
for (var bi = 0u; bi < tEnd; bi++) {
  var s = 0u;
  for (var k = 0u; k < 32u; k++) {          // 128 bytes packed into 32 words
    let x = da[k]; let y = tileB[bi*32u + k];
    let d0 = i32(x & 0xFFu) - i32(y & 0xFFu);  // difference of one byte
    let d1 = i32((x >> 8u) & 0xFFu) - i32((y >> 8u) & 0xFFu);
    s = s + u32(d0*d0 + d1*d1 + ...);        // squared, summed
  }
  if (s < best) { second = best; best = s; bestIdx = bBase + bi; }
  else if (s < second) { second = s; }        // track 2nd best for the ratio test
}
// best & second are returned; the CPU applies the ratio test + cross-check.

A run that would take minutes of ordinary code finishes in about a second. Output: for each pair of photos, a clean list of "this point here = that point there."

Interactive · matching the same spots across two photos
photo A  |  photo B — lines join accepted matches  ·  —
0.80 ● correct   ● wrong
Each dot in photo A takes its 128-number fingerprint and finds the closest fingerprint in photo B. The match is accepted only if that best is clearly better than the second-best (the ratio test — slider) and, with the mutual check on, if B picks A back. Strict settings keep mostly correct matches and reject the repeated windows — they look alike, so no candidate wins clearly. Loosen the ratio and wrong matches and window mix-ups slip in (their lines cross at odd angles). The corners and unique spots match cleanly; the ambiguous texture is what gets dropped.
Stage 3 · Camera geometry

The pinhole camera, and turning matches into 3D

To reason about geometry we need a model of a camera. The pinhole model is enough: light from a 3D point travels in a straight line through a single point (the lens center) onto the flat sensor. A 3D point in the camera's own frame — measured from the camera's position, with z pointing straight out of the lens — p = (x, y, z), lands on the image at

The math — projection, and "normalized" coordinates
pixel = ( f xz + cx,   f yz + cy )
  • ffocal length in pixels — how zoomed in the lens is. Larger f = narrower view.
  • cx, cythe image center (the sensor's middle in pixels).
  • ÷ zthe "perspective divide": things farther away (bigger z) appear smaller. This one division is what makes far things small.

Undo the focal and center and you get normalized coordinates: x/z, y/z — a direction out of the lens, independent of zoom. All the geometry below works in these clean direction-coordinates, so it doesn't care what camera took the photo.

Two views: the essential matrix, and why rays must meet

Take a point seen in both photos. From camera 1 it defines a ray out into space; from camera 2, another ray. Since both rays pass through the same physical point, they must intersect. Two arbitrary lines in space usually miss each other — they only meet if the two cameras are arranged just right. That "arranged just right" is captured by a single 3×3 table of numbers, the essential matrix E, which secretly encodes the rotation and the direction of travel between the two cameras.

The math — the epipolar constraint
x2T E x1 = 0,    E = [t]× R
  • x1, x2the point's normalized direction in each camera.
  • Tthe small T means transpose: the vector or matrix flipped so rows become columns. It changes no numbers; it only makes the shapes line up so the multiplication is legal. You'll see it on many formulas from here on.
  • R, tthe rotation and translation from camera 1 to camera 2 — the answer we want. [t]× is a matrix that performs "cross product with t" (the cross product of two 3D arrows is a third arrow perpendicular to both).
  • = 0reads "the rays meet." It's one equation per match, and it is linear in the 9 unknown entries of E: each unknown is just multiplied by a known number and the results added — no squares, no products of unknowns. Linear systems are the easy kind to solve.

Because each match gives one linear equation and E has 9 entries, 8 matches are enough to solve for E. (Why not 9? Multiplying all 9 entries by the same number leaves "= 0" still true, so the overall scale is free and only 8 numbers actually need pinning down.) Stack the 8+ equations into a matrix A and find the E that best satisfies "A E = 0." With noisy matches no E satisfies it exactly, so we want the one A shrinks closest to zero — found as the smallest eigenvector of ATA. A real essential matrix must also have a specific shape (two equal non-zero "strengths" and one zero), so the raw solution is snapped to the nearest valid one.

Eigenvector, in one line: feed a matrix a direction and it generally stretches it and turns it. A few special directions only get stretched, never turned — those are its eigenvectors, and the stretch factor for each is its eigenvalue. The "smallest" eigenvector is the direction the matrix shrinks the most, which is the closest thing to a solution of "A E = 0" when none exists. The code finds them with a small, standard routine; you never need to compute one by hand.
Interactive · one point in A becomes a whole line in B
photo A — move the cursor here; every dot is a 3D corner
photo B — the match must lie on the matching-colored epipolar line (× = epipole)
0.80 42° Turning camera B moves the epipole — where every line meets.
The two photos see one townscape from viewpoints a baseline apart. A point in photo A doesn't fix its match — it only constrains it to a line in photo B, the image of the ray through that pixel (x₂TEx₁=0). Each colored dot in A projects to a line of the same color in B, and its true partner sits exactly on it; matching just searches along that line. All the lines meet at the epipole, the image of the other camera — turn camera B and watch it slide into view.

Coping with wrong matches: RANSAC

Even after the ratio and cross-check tests, some matches are wrong (repeated textures, moving objects). A single bad match can wreck a least-squares solve — the standard "best fit" that minimizes the sum of squared misses, and which treats every input as trustworthy, so one liar drags the whole answer toward itself. The defense is RANSAC: instead of trusting all matches, repeatedly pick a random minimal set (8), solve E from just those, and count how many of all matches agree with it. Keep the E with the biggest agreement, and treat the disagreers as outliers to discard. "Agreement" is measured by the Sampson distance — essentially how far a match is from satisfying x2TE x1 = 0. RANSAC even adjusts how many random tries it needs based on how clean the data looks.

src/splats/sfm/geometry.ts — 8-point solve + RANSAC + decomposeTypeScript
// Each match adds one row (its epipolar equation) to the 9×9 system AᵀA.
for (const idx of indices) {
  a[0]=x2*x1; a[1]=x2*y1; a[2]=x2; a[3]=y2*x1; ...          // the 9 coefficients
  for (i) for (j) AtA[i*9+j] += a[i]*a[j];
}
const { vecs } = jacobiEigen(AtA, 9);
return enforceEssential(matCol(vecs, 9, 8));    // smallest eigenvector → E → snap to valid

// RANSAC: many random 8-match samples; keep the E with the most inliers,
// where an inlier's Sampson distance is below a pixel-scaled threshold.
for (let it = 0; it < iters; it++) {
  const est = estimateE(x1s, x2s, random8());
  let count = 0;
  for (i) if (sampsonError2(est.E, x1s[i], x2s[i]) < th2) count++;
  if (count > bestCount) { best = est; bestCount = count;
    iters = adaptiveIterationCount(count / n); }        // stop early if data is clean
}
Interactive · RANSAC — a robust fit the outliers can't corrupt
● least-squares (all points)    ● RANSAC best  ·  iteration 0 · best inliers 0 / 0
4.0
A handful of wrong matches would drag an ordinary least-squares fit (teal) far off the true line. RANSAC instead guesses a line from just two random points, counts how many others fall within a threshold band, and keeps whichever line the most points agree with (amber). Because the model is built from a tiny sample and scored by consensus, the outliers never get a vote — step through it and watch the amber line lock onto the inliers while teal stays bent toward the noise.

From E the relative pose is recovered by decomposition: the math yields four possible (R, t) combinations, of which exactly one puts the triangulated points in front of both cameras (you can't photograph what's behind you). That "in front of both" check — cheirality — picks the true pose.

Stage 3, continued

Triangulation: where the rays cross is a 3D point

With the two cameras arranged, each matched point's two rays cross at one place — its 3D location. This is triangulation, the same depth trick two eyes use. Each camera contributes the statement "the 3D point X, projected through me, lands where I see it" — which is two straight-line (linear) equations in X's three unknowns. Two cameras give four equations for three unknowns; the best-fit X is, again, the smallest-eigenvector solution of the stacked system (the Direct Linear Transform).

camera 1(R₁, t₁) camera 2(R₂, t₂) 3D point X
geometry.ts — triangulation (Direct Linear Transform)TypeScript
export function triangulateN(cams, obs) {
  for (let k = 0; k < cams.length; k++) {
    const { R, t } = cams[k]; const [u, v] = obs[k];
    // two rows per view: (u·row3 − row1)·X = 0 ,  (v·row3 − row2)·X = 0
    row[0] = u*R[6]-R[0]; row[1] = u*R[7]-R[1]; row[2] = u*R[8]-R[2]; row[3] = u*t[2]-t[0]; addRow();
    row[0] = v*R[6]-R[3]; row[1] = v*R[7]-R[4]; row[2] = v*R[8]-R[5]; row[3] = v*t[2]-t[1]; addRow();
  }
  return smallestEigenvector(AtA);   // X as 4 numbers (homogeneous): divide the first 3 by the 4th for x,y,z
}

A guard rejects points with too little parallax — if the two rays are nearly parallel (the cameras barely moved relative to the point's distance), the crossing is wildly uncertain, so that point is dropped.

Interactive · pinhole projection & triangulation — drag the point
bird's-eye view (x–z plane) · two cameras shoot rays that cross at the 3D point · shaded = depth uncertainty
3.3 1.20 1.4°
A single camera only fixes the direction to a point — every point along one ray projects to the same spot, so distance is unknown. Two cameras that see the same point shoot two rays that cross exactly at its 3D location. Widen the baseline and the rays meet at a healthier angle, shrinking the uncertainty region; slide them close together and the near-parallel rays smear it long in depth — wide baseline means sharp depth.
Stage 4 · Grow

From two photos to all of them

Two photos give a seed reconstruction. To fold in the rest, the pieces are stitched together with tracks and grown one camera at a time.

  1. Build tracks. The same point may be matched across many photo pairs: A↔B, B↔C, C↔D. A "union-find" data structure chains these into a single track — "these observations, in these five photos, are all the same physical point." Tracks are what tie the whole reconstruction together.
  2. Pick the starting pair. Choose two photos that share many tracks and have healthy parallax (a wide enough baseline for reliable triangulation), and seed the reconstruction from them.
  3. Register the next camera (PnP). A new photo already sees tracks whose 3D positions are known. Given several "this 3D point appears at this pixel" pairs, solving for that one camera's pose is the Perspective-n-Point problem — a direct linear solve for a rough pose, wrapped in RANSAC (against wrong matches) and then refined by gradient descent to minimize its reprojection error.
  4. Triangulate more, repeat. The newly-posed camera lets fresh tracks be triangulated, which lets the next camera register, and so on until every photo is placed.
src/splats/sfm/sfm.ts — registering a new camera by PnPTypeScript
// gather this image's features that hit already-triangulated tracks:
//   obj = known 3D points,  im = where they appear in the new photo
const pnp = pnpRansac(obj, im, threshold, rng);   // robust rough pose
if (pnp) poses[img] = refinePose(pnp.R, pnp.t, obj, im, 10); // polish by gradient descent (Levenberg–Marquardt, explained in Stage 5)
// then triangulate every fresh track this camera newly sees.
Registering cameras one at a time lets tiny errors accumulate, so the trajectory slowly bends. Periodically, and at the end, that drift is removed by a joint polish of everything at once — bundle adjustment, next.
Stage 5 · Polish

Bundle adjustment: nudge every camera and point together

This is the first place the "roll a number downhill" engine appears in full. Define one error: for every sighting (a 3D point seen in a photo), how many pixels away does the point project from where it's actually observed? Sum that over all sightings. Then adjust all camera poses, all 3D points, and the shared focal length and lens distortion together, a little at a time, to shrink that sum. This joint polish is bundle adjustment.

The math — the reprojection error and how it's minimized
minimize   Σsightings ij   ρ( ‖ project(cami, Xj) − observedij ‖2 )
  • projectp = Ri Xj + ti, then the pinhole divide, then a lens-distortion factor D = 1 + k1r2 + k2r4 that bends straight lines the way real lenses do.
  • ‖·‖2squared pixel miss.
  • ρa robust Huber wrapper: it counts small misses in full but softens huge ones, so one wild sighting can't dominate — the same forgiveness the splat loss uses later.

With thousands of cameras and hundreds of thousands of points, this is solved not by brute force but by two standard tools:

  • Levenberg–Marquardt — a careful gradient-descent step: near the answer it takes big confident steps, far away it takes small cautious ones, adjusting its "damping" each round based on whether the last step helped.
  • The Schur complement — a bookkeeping trick. Because each 3D point only touches the cameras that see it, the equations have a special block structure. The huge number of point-unknowns can be eliminated in bulk, leaving a much smaller system in just the camera-unknowns to solve directly, then the points are recovered by back-substitution.
src/splats/sfm/ba.ts — the projection model, and one LM roundTypeScript
// project a point through a camera, with radial lens distortion
const xp = x/z, yp = y/z;                       // perspective divide → normalized
const r2 = xp*xp + yp*yp;
const D  = 1 + k1*r2 + k2*r2*r2;                // lens distortion
predicted = { u: f*xp*D + cx, v: f*yp*D + cy };

// each round: measure how every pixel miss responds to every unknown (the
// "normal equations", JᵀJ Δ = −Jᵀr — the standard least-squares step), Huber-weight
// big residuals, damp (Levenberg–Marquardt), Schur-eliminate the point block,
// solve for the camera step, apply it, and keep it only if the total error fell.
The handoff to the Second Half · Splat Training

SfM outputs exactly what the trainer needs: the exact pose of every photo, the shared focal length + lens distortion (so photos can be straightened to a clean pinhole), and a sparse 3D point cloud. The trainer never again wonders where a photo was taken — only what was there — and those sparse points become the very first splats.


Second Half · Splat Training
The representation

A scene made of fuzzy 3D blobs

SfM handed us posed cameras and a sparse point cloud. Now we reconstruct the look of the scene — not as surfaces, but as a cloud of soft, semi-transparent, colored blobs called Gaussian splats. Enough of them, overlapping, melt into what reads as solid geometry. The reason blobs are used instead of triangles is decisive: every property of a blob is a plain number you can nudge, and nudging it changes the rendered image smoothly. Smoothness is exactly what gradient descent needs.

  • Where it is
    a point in space. 3 numbers — position
  • How big & how stretched
    a size along each of its own three axes; can be a speck or a long thin smear. 3 numbers — scale
  • Which way it's tilted
    its orientation in space. 4 numbers — a quaternion
  • What color it is
    red, green, blue, plus optional extras for shine. 3 (+) numbers
  • How see-through it is
    solid to invisible. 1 number — opacity

What a blob is, mathematically

A blob is a 3D Gaussian — the bell curve, in three dimensions. It's brightest (densest) at its center and fades smoothly outward in every direction. Its full size, stretch, and tilt are bundled into one object called the covariance, which is built from the scale and rotation:

The math — the blob and its covariance
G(x) = exp( −½ (x−μ)T Σ−1 (x−μ) ),    Σ = R S ST RT
  • μthe center (position).
  • Sa diagonal matrix holding the three sizes — zeros everywhere except the three sizes down the diagonal, so it stretches each of the blob's own axes independently.
  • Rthe rotation (tilt), so the stretched blob points the right way.
  • Σ= R S ST RT combines size and tilt into one 3×3. Read "Σ−1" as the recipe for how fast the bell falls off in each direction. (One warning: by long convention the covariance is written with the same Greek letter Σ as the summation sign. When Σ has a subscript like Σk it's a sum; when it stands alone like this, it's the covariance.)

Two encoding tricks that keep the numbers legal

The trainer never stores size or tilt directly. Sizes are stored as their logarithm: a log can be any number, positive or negative, but its exponential — the actual size — is always positive. So however wildly a size gets nudged, it can never become a negative or zero size. And tilt is stored as a quaternion, four numbers describing a rotation without the "gimbal lock" jams that plague plain angles; it's turned into the rotation matrix R on the fly. Color and opacity are likewise squeezed through a sigmoid (an S-curve mapping any number into the range 0–1) so they can never leave "valid color" territory.

src/splats/training/gs_train_shaders.ts — quaternion → rotation → covariance (WGSL)WGSL
let q = normalize(quat);                    // (w,x,y,z), unit length = a valid rotation
// rotation matrix rows from the quaternion
g.r0 = vec3f(1 - 2*(qy*qy+qz*qz), 2*(qx*qy-qw*qz), 2*(qx*qz+qw*qy));
g.r1 = vec3f(2*(qx*qy+qw*qz), 1 - 2*(qx*qx+qz*qz), 2*(qy*qz-qw*qx));
g.r2 = vec3f(2*(qx*qz-qw*qy), 2*(qy*qz+qw*qx), 1 - 2*(qx*qx+qy*qy));
g.s  = exp(clamp(logScale, -12, 6));         // sizes are stored as logs → always > 0
// M = R·diag(s);  covariance Σ = M·Mᵀ  (its six unique entries)
let m0 = g.r0*g.s; let m1 = g.r1*g.s; let m2 = g.r2*g.s;
g.s00 = dot(m0,m0); g.s01 = dot(m0,m1); g.s02 = dot(m0,m2);
g.s11 = dot(m1,m1); g.s12 = dot(m1,m2); g.s22 = dot(m2,m2);
The memory layout you'd allocate

Every blob is 16 floats, packed end to end so the GPU streams millions at once:

  • 0–2position (x, y, z)
  • 3–5log-scale (sx, sy, sz)
  • 6–9quaternion (w, x, y, z)
  • 10–12color logits (r, g, b) → sigmoid → 0–1
  • 13opacity logit → sigmoid → 0–1
  • 14–15spare (padding to 16)

View-dependent color adds a few extra numbers per blob in a separate buffer (covered later). The first blobs are placed on SfM's sparse cloud and colored from the photos, so the first render is a blurry sketch, not noise.


Where the first blobs come from — initialization

Before the loop can run even once, every blob needs starting numbers. Random noise would take forever to sort itself out, so the trainer seeds the cloud from SfM's handoff: each sparse 3D point becomes a blob, placed where the point is and colored the color the photos saw there. Everything else is set by simple rules chosen so the very first render is a blurry sketch of the scene, not static — a huge head start.

The math — the starting numbers
log-scale = log( clamp( 0.7 · dnearest,   r·3e-4,   r·0.05 ) )
logit(p) = log p1 − p    color = logit(rgb)    opacity = logit(0.35)
  • dnearestdistance to the closest other point (found with a coarse spatial grid, not a full search).
  • rthe scene radius — the 90th-percentile distance of the points from their center; it also scales the position learning rate. Sizes are held between r·3e-4 and r·0.05.
  • logitthe inverse of the sigmoid; it turns a 0–1 value back into the raw stored number the shader will later squeeze through the sigmoid.
src/splats/training/gs_trainer.ts — initGaussiansFromPoints() (TypeScript)TypeScript
const logit = (p) => Math.log(p / (1 - p));
for (let i = 0; i < nPoints; i++) {
  const b = out * 16;                              // this blob's slot (16 floats)
  data[b+0] = px; data[b+1] = py; data[b+2] = pz;   // position = the SfM point
  const s = clamp(0.7 * nnDist(i), r*3e-4, r*0.05); // ~gap to nearest neighbor
  data[b+3] = data[b+4] = data[b+5] = Math.log(s);  // isotropic log-scale
  data[b+6] = 1;                                    // identity quaternion (w,x,y,z)
  data[b+10] = logit(clamp(cr, 0.02, 0.98));        // color as logits
  data[b+11] = logit(clamp(cg, 0.02, 0.98));
  data[b+12] = logit(clamp(cb, 0.02, 0.98));
  data[b+13] = logit(0.35);                         // start semi-transparent
}

The loop · step 1

Rendering: turning blobs into a picture

To score a guess we must draw the current blob cloud through one of the known cameras. This is the forward render: one projection step that flattens every blob onto the screen, followed by five tiling stages that make painting them fast. First the per-blob geometry, then the tiling.

Flattening a 3D blob into a 2D oval (EWA projection)

EWA stands for Elliptical Weighted Average — the name of the 2001 splatting technique whose projection step this is. The name matters less than the mechanic below.

Move the blob into the camera's frame (rotate and translate by the camera pose), then flatten to the image. A blob doesn't flatten to a circle — a tilted 3D ellipsoid becomes a tilted 2D ellipse, and it's stretched by perspective (nearer parts loom larger). Capturing that stretch exactly is impossible with a straight projection, so we use the local linear approximation of the projection at the blob's center — its Jacobian J, the 2×3 table of "how a nudge in each 3D direction moves the 2D image point." Push the 3D covariance through the camera rotation W and this Jacobian and you get the 2D covariance of the flattened oval.

The math — the projected 2D covariance
J = [ f/z,   0,   −f x/z2 ]
[ 0,   f/z,   −f y/z2 ]
    V = J W Σ WT JT
  • Jthe projection's local linear map. The −f x/z2 terms are the perspective stretch — they grow as z shrinks, so close blobs are stretched more.
  • Wthe camera's rotation (world → camera).
  • Vthe 2×2 covariance of the flattened oval on screen.

Inverting that 2×2 gives the conic (A, B, C) — three numbers that say how fast the oval fades as you step d pixels from its center. (A matrix inverse is the "undo" of a matrix, like 1/x for a number; here it flips "how spread out the oval is" into "how quickly it fades.") A blob's contribution at a pixel is its opacity times that falloff. (A small "compensation" factor, from a refinement called Mip-Splatting, keeps very tiny blobs from adding spurious energy.)

gs_train_shaders.ts — project (EWA) and the conic (WGSL)WGSL
let f = cam.proj.x; let iz = 1.0 / g.pc.z;
// rows of T = J·W, folded together
g.t0 = (f*iz)*cam.R0.xyz + (-f*g.pc.x*iz*iz)*cam.R2.xyz;
g.t1 = (f*iz)*cam.R1.xyz + (-f*g.pc.y*iz*iz)*cam.R2.xyz;
// V = T·Σ·Tᵀ  → the 2×2 screen covariance (va, vb, vc)
g.va = dot(g.t0, Sigma·t0); g.vb = dot(g.t1, Sigma·t0); g.vc = dot(g.t1, Sigma·t1);
// invert V → conic (A,B,C); opacity via sigmoid; cull if it can't reach a pixel
proj[b+3] = vc*inv; proj[b+4] = -vb*inv; proj[b+5] = va*inv;   // A, B, C
let opa = 1.0 / (1.0 + exp(-logitOpacity));
The math — the tiny-blob compensation (Mip-Splatting)

A blob whose oval is smaller than a pixel falls between the sample points and flickers or vanishes as the camera moves. The fix: quietly widen every oval by a fixed half-pixel — add 0.3 to each diagonal entry of the 2D covariance — so it always covers at least one sample. But a wider bell is a shorter bell for the same amount of ink, so widening would dim the blob. To cancel that, multiply its opacity by exactly the brightness the widening cost:

V′ = V + 0.3·I     comp = √det Vdet V′     opacityeff = opacity · comp
  • V′the dilated 2D covariance (only the two diagonal terms grow); it's what actually gets inverted into the conic.
  • detthe determinant: for a 2×2 covariance it's (the oval's area)², so the square root of the ratio is simply "old area ÷ new area" — how much the widening diluted the blob.
  • comp≤ 1, the compensation. It restores the peak height the dilation flattened, so a blob's total contributed light is unchanged — the dilation stabilizes tiny blobs without letting them glow. It's carried alongside the conic and, in the backward pass, collects its own blame back onto opacity and the covariance.
Interactive · one splat = a 3D ellipsoid flattened to a 2D oval
the 3D blob — ● sx  ● sy  ● sz · drag to orbit
as the camera sees it — the projected 2D covariance the shader fills
1.50 0.60 0.95 35° -40°
A splat isn't a ball — it's a stretched, tilted ellipsoid, fully described by a scale S and a rotation R baked into the covariance Σ = R S ST RT. Rendering just flattens that 3D ellipsoid, seen from the current camera, into the 2D oval the pixel shader actually fills. Orbit the blob or drag the sliders and watch the oval on the right morph to match.

Why tiling, and the five stages

A photo has millions of pixels; the cloud has millions of blobs. Testing every pixel against every blob is hopeless. Instead the screen is cut into 16×16-pixel tiles, and each pixel only ever considers the blobs that actually land on its tile — already sorted near-to-far. Building those per-tile lists is a classic parallel pattern:

  1. Count. Each blob figures out which tiles its oval covers and adds 1 to each of those tiles' counters (an atomic add, since many blobs hit the same tile at once).
  2. Scan. A running total (prefix sum) of the tile counts reserves an exact, tightly-packed slot range for every tile in one big list — no wasted space, no fixed per-tile cap.
  3. Scatter. Each blob writes its ID (paired with its depth) into every tile it covers, using each tile's reserved range.
  4. Sort. Within each tile, sort the blobs by depth, near to far. Small tiles are sorted entirely in fast on-chip memory with a bitonic sort (a sorting network that's efficient for parallel hardware); big ones spill to a global sort.
  5. Composite. Each pixel walks its tile's sorted blobs front-to-back, blending — the actual painting.
The math — the sort key, and why the bits sort themselves

To order a tile's blobs near-to-far you need one sortable number per blob. The blob's camera-space depth already is that number — and here's the trick: a positive floating-point number's raw 32 bits, read as if they were a plain unsigned integer, sort in the exact same order as the numbers themselves. So there's nothing to convert; the depth's bit-pattern is the key.

key = bits(depth)     entry = ( key, blobID )     tile t's slice = [ start[t], start[t+1] )
  • bits(depth)the depth reinterpreted as a 32-bit unsigned integer. Valid because every blob that survives is in front of the camera (depth > 0), and positive floats are monotonic in their bit pattern.
  • start[·]the running totals from the Scan step. Each tile owns an exact, tightly-packed slice of one big entry list; a sentinel closes the last tile. So there's no giant "tile+depth" key — the tile is which slice an entry lives in, and the sort only has to order each slice by depth.

Each tile's slice is then sorted by a bitonic sort — a fixed network of compare-and-swap steps with no branches, which is what parallel hardware runs fastest; small slices sort entirely in on-chip memory, padded up to a power of two.

Under the hood — the exact plumbing of the five stages

Everything the five stages need, pinned down. First, how big a blob's footprint is (the Count and Scatter stages bin every tile inside this radius):

gs_train_shaders.ts — the footprint radius, and the constantsWGSL
// how far from its center can this blob still tint a pixel? (in pixels)
let ad = va + 0.3;  let cd = vc + 0.3;              // Mip-dilated 2D covariance
let detVd = ad*cd - vb*vb;
let mid = 0.5*(ad + cd);
let disc = sqrt(max(mid*mid - detVd, 0.0));      // eigenvalues = mid ± disc  (the oval's long and short radii, squared)
let eMax = min(E_CUT, log(max(opacity*comp / A_MIN, 1.0001)));
let rad  = sqrt(2.0*eMax) * sqrt(mid + disc);     // σ-extent along the long axis
// then file this blob into every tile whose square meets [center ± rad].

const GS_TILE     = 16;        // tile is 16×16 px
const E_CUT       = 4.5;       // drop a pixel past this falloff (~3σ)
const A_MIN       = 0.0039;    // alpha floor; below this a blob is skipped
const SHARED_SORT = 2048;      // segments ≤ this sort in on-chip memory
const ENTRIES_CAP = 12000000;  // global (blob,tile) budget
the two standard GPU primitives, in pseudocodepseudocode
// SCAN — exclusive prefix sum of the per-tile counts → each tile's start offset.
// (Real code does this as a parallel two-level scan in one workgroup; the result is:)
var acc = 0;
for (t in 0 .. numTiles) {
  tileStart[t] = acc;                    // tile t's segment begins here
  acc += paddedSize(tileCount[t]);       // pad big segments up to a power of two
}
tileStart[numTiles] = acc;               // sentinel closes the last segment

// BITONIC SORT — order one tile's segment [s, e) by key ascending (one workgroup/tile).
// A branch-free sorting network: build bitonic runs, then repeatedly merge them.
for (k = 2; k <= segCap; k <<= 1) {        // run length being merged
  for (j = k >> 1; j > 0; j >>= 1) {       // compare distance
    for each slot i in the segment (parallel) {
      let l = i ^ j;                       // its partner
      if (l > i) {
        let asc = (i & k) == 0;            // this block sorts ascending?
        let hi  = key[i] > key[l] || (key[i]==key[l] && id[i] > id[l]);
        if (hi == asc) { swap(i, l); }     // compare-exchange
      }
    }
  }
}
// padding slots hold key = 0xFFFFFFFF so they sink to the back and the composite stops at them.
Interactive · the tiled rasterizer — binning + per-tile depth sort
screen cut into tiles · hover a blob to see the tiles it touches, or a tile to see its sorted list
per-tile list (front → back)
64 avg blobs/tile: —
Rather than test every pixel against every blob, the rasterizer cuts the screen into square tiles. Each blob is filed into every tile its footprint overlaps — hover one to see it tint many tiles amber. Then each tile sorts only its own short list of blobs by depth and blends them front→back — hover a tile to read that list. Bigger tiles mean fewer, longer lists; smaller tiles mean more, shorter ones. That per-tile locality is what makes the rasterizer fast.

The painting itself: front-to-back alpha compositing

Walk the sorted blobs from nearest to farthest, tracking one number, the transmittance T — "how much light is still getting through." It starts at 1 (nothing blocking) and drops as each layer covers more. Each blob adds its color scaled by how solid it is here and by how much light is still available. Once T is nearly zero the rest is hidden and the loop stops early.

The math — the composite
αi = opacity · exp( −½(A dx2 + C dy2) − B dx dy )
color = Σi ci αi Ti,    Ti = Πj<i(1 − αj)
  • dthe pixel's offset from the blob's center; the exp is the oval's soft falloff.
  • Tithe running "light left." A blob behind solid ones (T≈0) contributes almost nothing — hence early stopping.
  • Πj<ithe multiply-together cousin of Σ: take (1 − αj) for every blob j in front of this one and multiply them all. In the code it's just the running T *= 1 - alpha.
gs_train_shaders.ts — the per-pixel composite loop (WGSL)WGSL
var T = 1.0; var Crgb = vec3f(0.0);
for (var k = segStart; k < segEnd; k++) {          // this tile's blobs, near → far
  let d = px - vec2f(proj[b], proj[b+1]);           // pixel minus blob center
  let e = 0.5*(A*d.x*d.x + C*d.y*d.y) + B*d.x*d.y;   // oval falloff exponent
  let alpha = min(0.99, opacity * comp * exp(-e));
  if (alpha < A_MIN) { continue; }
  Crgb += T * alpha * color;                        // blend this layer in
  T *= 1.0 - alpha;                                 // less light gets through now
  if (T < 1e-4) { break; }                         // fully hidden → stop
}
Front-to-back compositing: each see-through layer tints what's behind it. Across thousands of layers per pixel, soft dots become a solid-looking surface.
Interactive · front-to-back alpha compositing
the ray, front → back  ·  each blob's α
what the pixel accumulates  ·  final T = —
Marching front to back, each blob tints what is behind it by its own opacity α, while the transmittance T — the light still getting through — keeps shrinking, so far-back blobs barely register and the leftover T is the page showing through. Because each (1−α) multiplies in sequence, Reverse order changes the result — which is exactly why splats must be depth-sorted.
The loop · step 2

The loss: measuring how wrong the render is

Compare the rendered image to the real photo, pixel by pixel, and reduce the whole thing to one number. Per pixel, the "difference" r is the rendered color minus the photo's color. You could square it (the classic mean-squared error), but squaring punishes a few freak pixels — a moving car, glare, a blur — so hard that the cloud would contort to chase them. The trainer uses the Charbonnier loss instead: it acts like the plain absolute difference for ordinary pixels but grows only gently for huge outliers.

The math — Charbonnier loss and its slope
loss = √(r2 + δ2) − δ,     ∂loss∂r = r√(r2 + δ2)
  • rrendered color minus photo color at a pixel.
  • δa small constant (0.03). For small r the loss ≈ |r|; for huge r it flattens.
  • ∂loss/∂rthe slope of the loss with respect to r. (The ∂ symbol is read "the change in … per change in …" — ∂loss/∂r asks: if r moves a hair, how much does the loss move? Every gradient on this page is written this way.) It's one number per pixel saying "which way, and how hard, to fix this pixel." This is the seed the next step propagates backward. Notice it's always between −1 and 1, so no pixel can shove too hard.
gs_train_shaders.ts — loss + its gradient (WGSL)WGSL
let err  = (gain * C + bias) - target;          // with per-photo exposure correction
const DELTA = 0.03;
let root = sqrt(err*err + DELTA*DELTA);
let loss = (root.x + root.y + root.z) - 3.0*DELTA;
let gC   = gain * (err / root);                  // slope handed to backprop
Interactive · how hard a pixel error is punished
how much a pixel error r is punished
0.03 gray = squared error (blows up), amber = Charbonnier (this trainer), dashed = absolute error.
Squared error punishes a big freak pixel enormously, so the cloud would contort to chase one moving car. Charbonnier hugs the gentle absolute-error line for large errors but stays smooth and rounded near zero (unlike absolute error, which has a sharp kink the optimizer dislikes). Drag δ: it sets how wide that smooth rounded bottom is.
Quality is usually reported as PSNR (peak signal-to-noise ratio, in decibels) — a log-scaled restatement of mean-squared error, so higher is better. Around 30 dB is a faithful reconstruction of real footage; the synthetic scenes reach the high 30s.
The loop · step 3

Backpropagation: turning one error into millions of nudges

We have one loss number and its slope at each pixel. But you can't nudge a blob with a pixel's slope — you need, for every one of a blob's 14 stored numbers, "if I changed you a hair, how would the loss move?" Getting that for millions of blobs sounds impossible, but there's a mechanical answer: run the render backward. This is reverse-mode automatic differentiation, the same idea that trains neural networks, and it's just the chain rule applied with care.

The chain rule, in one breath

If A feeds into B feeds into the loss, then "how the loss depends on A" = "how the loss depends on B" × "how B depends on A." So if you know the sensitivity at the output and the small local relationship at each step, you can multiply your way backward from the loss to every input. The forward render is a chain: blob numbers → 3D covariance → 2D oval → per-pixel contribution → blended color → loss. Backprop walks that chain in reverse, carrying a "sensitivity" and multiplying by each step's local relationship.

The clever bit: a single backward sweep through the blend

The compositing has an awkward property: a blob affects a pixel two ways — it adds its own color, and it hides some of everything behind it. To get a blob's blame you'd naively need to know "everything behind it," which changes at every blob. The trick is to sweep the sorted list back-to-front, accumulating a running sum S of "everything already behind this point." Then each blob's blame comes out from a short formula — no search, no second pass.

The math — blame for a blob's solidity
∂color∂αi = ci Ti − Si1 − αi
  • ci Tiwhat this blob adds to the pixel (its color, scaled by available light).
  • Si ⁄ (1−αi)what it hides: everything behind it, which it would reveal if it faded. The subtraction is the tension between the two.

From that one number the chain rule cascades further: onto the blob's screen position and oval shape (the conic), then — because the conic came from the 3D covariance which came from scale and quaternion — back onto its raw stored numbers. That last hop, conic → 2D covariance → 3D covariance → scale & quaternion, is a stack of matrix-calculus derivatives; the code calls it the "chain" pass.

gs_train_shaders.ts — backward sweep + the chain pass (WGSL)WGSL
// back-to-front through the tile; S = "everything behind this blob so far"
var S = bg * T;
for (var kk = last; kk > segStart; kk--) {
  let Tb = Ta / (1.0 - alpha);                    // light in front of this blob
  let gcolor = gC * (alpha * Tb);                 // blame for its color
  var galpha = dot(gC, c*Tb - S/(1.0 - alpha));   // blame for its solidity
  // → blame for screen position & conic (A,B,C), accumulated with atomic adds
  S += c * alpha * Tb; Ta = Tb;
}
// the "chain" pass then flows conic → 2D cov → 3D cov → scale & quaternion,
// producing the gradient on each blob's actual stored 16 numbers.

One WebGPU reality. The trainer fuses the forward paint and the backward sweep into one shader pass, so each tile's blob list is touched once. (Thousands of pixels blame the same blob at the same instant; how those blames are summed safely — the atomAdd calls and the constant 16384 you'll see below — is covered at the end of this section.)

From a pixel's blame to the oval's shape and place

The sweep above hands us, for each blob at each pixel, two blames: how its color should change and how its solidity (its alpha) should change. Color is easy — it flows straight to the blob's RGB. Alpha is the interesting one, because alpha was built from several things and the blame has to be split among them. Recall how a pixel's alpha came to be: the blob has a solid opacity, and that opacity is scaled down by how far the pixel sits from the oval's center — far pixels get almost none. "How far, oval-shaped" is one number e, computed from the three conic numbers (A, B, C) that define the oval and the pixel's offset (dx, dy) from the center:

The math — splitting alpha's blame
e = ½(A dx2 + C dy2) + B dx dy     α = opacity · comp · exp(−e)
let ga = gα · α  (the blame after passing back through exp, since ∂α/∂e = −α)
  • ∂L/∂A, ∂B, ∂C= −ga·½dx2,  −ga·dx dy,  −ga·½dy2. Each conic number's blame is just how strongly it fed into e at this pixel.
  • ∂L/∂(center)= ga·(A dx+B dy,  B dx+C dy) — which way to slide the oval on screen.
  • ∂L/∂opacity= ga·(1−opacity) — the sigmoid that keeps opacity in 0–1 is already folded in here.
  • ∂L/∂comp= gα·opacity·exp(−e) — the tiny-blob compensation gets its share too.
gs_train_shaders.ts — makeRenderSrc: alpha's blame → conic, center, opacity (WGSL)WGSL
let ga = galpha * araw;                          // through exp: ∂α/∂e = −α
let gmean = ga * vec2f(cA*d.x + cB*d.y, cB*d.x + cC*d.y);
atomAdd (0u, gmean.x); atomAdd(1u, gmean.y);      // → oval center (screen)
atomAddC(2u, -ga * 0.5 * d.x*d.x);                // → conic A  (px²-scaled, see below)
atomAddC(3u, -ga * d.x*d.y);                       // → conic B
atomAddC(4u, -ga * 0.5 * d.y*d.y);                // → conic C
atomAdd (5u, galpha * opa * G);                    // → comp
atomAdd (6u, ga * (1.0 - opa));                    // → opacity logit
atomAdd (7u, gcolor.r); atomAdd(8u, gcolor.g); atomAdd(9u, gcolor.b);

The covariance chain: oval → 3D ellipsoid → size & tilt

Now the hard hop. A, B, C describe the flat 2D oval, but a blob doesn't store an oval — it stores a 3D size and tilt. In the forward render those became a 3D covariance Σ, which the camera flattened into a 2D covariance V, which was inverted to get the conic. To assign blame we walk that same chain in reverse, one link at a time. Each link is a small matrix-calculus rule; you don't need to derive them, only to see that each is mechanical.

The math — three links, reversed
1. conic → V.  The conic is V's inverse. Pushing blame back through an inverse:   ∂L/∂V = − V−1 G V−1
2. V → Σ.  Forward was V = T Σ TT with T = J W.   ∂L/∂Σ = TT H T   and   ∂L/∂T = 2 H T Σ
3. Σ → size & tilt.  Forward was Σ = M MT, M = R·diag(s).   ∂L/∂M = 2 (∂L/∂Σ) M
  • G, Hthe blame collected on the conic (step 1's input) and on V (step 2's input), each written as a small symmetric 2×2 (the off-diagonal B is halved so the matrix is symmetric).
  • J, Wthe projection Jacobian and camera rotation from the EWA step — the exact "flatten to screen" map, reused here.
  • ∂L/∂Tblame on the flatten-map itself; this is what carries blame onto the blob's position and depth (next section), because T depends on where the blob is.
  • M = R·diag(s)rotation times the three sizes. From ∂L/∂M the size blame is ∂L/∂sk = Σi ∂M[i][k]·R[i][k] — for each of the three sizes, sum down its column (then ×s for the log), and the tilt blame ∂L/∂R = ∂L/∂M·diag(s) flows through the quaternion→R formula to the four quaternion numbers.
gs_train_shaders.ts — makeChainSrc: conic → V → Σ → scale & quaternion (WGSL, trimmed)WGSL
// 1) conic → V:  dV = −Vinv · G · Vinv   (kA,kB,kC = the conic = Vinv)
let g11 = gp[2]; let g12 = 0.5*gp[3]; let g22 = gp[4];   // symmetric conic-grad
// P = Vinv·G·Vinv, then dV = −P (off-diagonals summed)
var gva = -p00; var gvb = -(p01 + p10); var gvc = -p11;

// 2) V → Σ:  dΣ = Tᵀ·H·T   (rows of T are g.t0, g.t1 = J·W)
let h11 = gva; let h12 = 0.5*gvb; let h22 = gvc;
let u0 = h11*g.t0 + h12*g.t1;  let u1 = h12*g.t0 + h22*g.t1;
let dS00 = g.t0.x*u0.x + g.t1.x*u1.x;  // …the six unique entries of dΣ…
//    dT = 2·H·T·Σ  → feeds the position/depth gradient (next section)

// 3) Σ → M → size & tilt:  dM = 2·dΣ·M   (m0,m1,m2 = rows of M = R·diag(s))
let dM0 = 2.0*(dS00*m0 + dS01*m1 + dS02*m2);   // (dM1, dM2 likewise)
let dsv = vec3f(dM0.x*g.r0.x + dM1.x*g.r1.x + dM2.x*g.r2.x, /* y */, /* z */);
gradF[b+3u] = dsv.x*g.s.x + AREG*(ls.x - meanLogScale);   // log-scale grad (+aniso reg)
// tilt: dR = dM·diag(s) → gq = (∂R/∂q · dR) → project off the norm:
let gqr = (gq - g.q*dot(g.q, gq)) / ql;      // grad of the normalized quaternion
gradF[b+6u]=gqr.x; gradF[b+7u]=gqr.y; gradF[b+8u]=gqr.z; gradF[b+9u]=gqr.w;
The math — the tilt blame, written out (∂R/∂q)

The one step above that says "flows through the quaternion→R formula" is the only place you'd otherwise have to derive something, so here it is in full. The rotation matrix R is a fixed formula in q = (w,x,y,z) (the same one from the representation section). Each quaternion number shows up in several entries of R, so its blame is the sum, over those entries, of the entry's blame times how that number enters it. With dR = ∂L/∂R = ∂L/∂M·diag(s) (rows dR0, dR1, dR2):

  • ∂L/∂w= 2( −z·dR0.y + y·dR0.z + z·dR1.x − x·dR1.z − y·dR2.x + x·dR2.y )
  • ∂L/∂x= 2( y·dR0.y + z·dR0.z + y·dR1.x − 2x·dR1.y − w·dR1.z + z·dR2.x + w·dR2.y − 2x·dR2.z )
  • ∂L/∂y= 2( −2y·dR0.x + x·dR0.y + w·dR0.z + x·dR1.x + z·dR1.z − w·dR2.x + z·dR2.y − 2y·dR2.z )
  • ∂L/∂z= 2( −2z·dR0.x − w·dR0.y + x·dR0.z + w·dR1.x − 2z·dR1.y + y·dR1.z + x·dR2.x + y·dR2.y )

Finally, the shader uses the normalized quaternion but stores a raw one, so the raw blame drops its length-changing part (a nudge that only rescales q doesn't rotate anything): gqraw = (gq − q·(q·gq)) ⁄ |q|.

gs_train_shaders.ts — makeChainSrc: the explicit quaternion gradient (WGSL)WGSL
let dR0 = dM0 * g.s;  let dR1 = dM1 * g.s;  let dR2 = dM2 * g.s;   // dR = dM·diag(s)
let gw = 2.0*(-qz*dR0.y + qy*dR0.z + qz*dR1.x - qx*dR1.z - qy*dR2.x + qx*dR2.y);
let gx = 2.0*( qy*dR0.y + qz*dR0.z + qy*dR1.x - 2*qx*dR1.y - qw*dR1.z + qz*dR2.x + qw*dR2.y - 2*qx*dR2.z);
let gy = 2.0*(-2*qy*dR0.x + qx*dR0.y + qw*dR0.z + qx*dR1.x + qz*dR1.z - qw*dR2.x + qz*dR2.y - 2*qy*dR2.z);
let gz = 2.0*(-2*qz*dR0.x - qw*dR0.y + qx*dR0.z + qw*dR1.x - 2*qz*dR1.y + qy*dR1.z + qx*dR2.x + qy*dR2.y);
let gq  = vec4f(gw, gx, gy, gz);
let gqr = (gq - g.q*dot(g.q, gq)) / ql;         // project off the length change; ql = |q_raw|
gradF[b+6u]=gqr.x; gradF[b+7u]=gqr.y; gradF[b+8u]=gqr.z; gradF[b+9u]=gqr.w;

Where the position gradient comes from

A blob's position collects blame from two directions. The obvious one: moving the blob slides its oval's center across the screen. The subtle one: moving the blob — especially nearer or farther — changes how the projection stretches its oval, because the flatten-map T depends on depth. That second path is why depth has such a strong pull: the projection carries 1/z and 1/z2 terms, so z shows up with a steep slope.

The math — the two paths into position
center path:  ∂L/∂x += gcx·f/z,    ∂L/∂z += −f(gcxx + gcyy)/z2
stretch path:  the −f x/z2 entries of J contribute a z-blame proportional to 2f x/z3 (from ∂L/∂T)
then rotate camera→world:  ∂L/∂pworld = WT · (both paths, summed)
  • gcx, gcythe oval-center blame from the first section; f the focal length, z the blob's depth in the camera.
  • WTthe camera rotation, transposed — undoing the world→camera turn so the blame lands on the world-space position the blob actually stores. (When shine is on, a third small term arrives through the view direction — see the next section.)

Making the parallel sums exact: fixed-point, and the conic trick

Thousands of pixels blame the same blob at the same instant. Adding all those blames safely means atomic adds — and there's a bonus: integers add in any order with no rounding drift, so the total is bit-for-bit identical however the GPU threads happen to interleave. So each floating-point blame is multiplied by 16384, rounded to an integer, added atomically, and divided back at the end. One wrinkle: the conic blames grow with distance2 and could overflow the integer for a big oval, so each conic blame is pre-divided by (1 + the oval's largest radius) in the paint pass, and multiplied back by the identical factor in the chain pass — a scaling that cancels exactly and keeps every added value near 1.

gs_train_shaders.ts — fixed-point atomics + the conic normalization (WGSL)WGSL
const FIXED = 16384.0;
fn atomAdd(idx: u32, v: f32) {
  atomicAdd(&gradP[idx], i32(clamp(v*FIXED, -1.0e9, 1.0e9)));   // float → int, exact sum
}
// paint pass scales conic blames by cnorm = 1/(1+λmax) of the dilated 2D cov;
// chain pass recomputes the SAME λmax and multiplies it back — cancels exactly:
gp[2] *= (1.0 + lmax); gp[3] *= (1.0 + lmax); gp[4] *= (1.0 + lmax);
// then decode every slot: value = f32(atomicLoad(gradP[k])) / FIXED, and zero it for next step.

Proving the blame is right — the gradient check

Every formula above was derived by hand, and hand-derived gradients are famously easy to get subtly wrong — a missing ½, a dropped term, a sign. There's a brute-force way to check any one of them without trusting the algebra at all: nudge the number and watch the loss. Bump one parameter up by a hair h and re-measure the loss, bump it down by h and re-measure; the loss's true slope is just the difference over 2h. If that measured slope matches the analytic blame, the derivation is right.

The math — the finite-difference check
measured slope ≈ L(p + h) − L(p − h)2h     relative error = | analytic − measured |max(| analytic |, | measured |)
  • ha tiny step. Too big and the secant (the straight line through the two measured points, which is what this slope really is) misses the curve; too small and floating-point noise swamps it — there's a sweet spot in between. The analytic gradient is the tangent: the slope at exactly one point.
  • passrelative error under ~0.1 (10%). The real trainer runs this over 160 random blobs and every parameter type before it trusts a single gradient — it's the unit test for the math on this page.
Interactive · the finite-difference gradient check
loss vs the chosen parameter  ·  ● analytic tangent   ● measured secant
the check  ·  analytic gradient vs brute-force slope
0.010 small h → lines overlap (PASS); large h → teal secant drifts off.
The trainer derives its gradient — the analytic slope of the loss with respect to each opacity (the amber tangent) — by hand. To prove that hand-derived math, it nudges the raw number by a hair and watches how the loss actually moved: (L(p+h)−L(p−h))/2h, the teal secant. If the two slopes agree the analytic gradient is trusted (relative error under 0.1 = PASS); shrink h and they lock together, crank h and finite-difference noise pulls the secant off the tangent. This is exactly how the real trainer verifies its backward pass.
The loop · step 4

Adam: applying the nudges intelligently

Now every one of a blob's numbers has a gradient — the direction that lowers the loss. The dumbest update is "subtract a little of the gradient" (plain gradient descent). It works, but badly: some numbers (position) need tiny careful steps, others (opacity) can move fast; and a number whose gradient keeps flip-flopping should slow down while one heading steadily should speed up. The optimizer that handles all this is Adam, and it keeps two running averages per number.

The math — Adam's update
m ← β1m + (1−β1) g    v ← β2v + (1−β2) g2
number ← number − lr · m̂√v̂ + ε
  • gthis number's gradient (its blame) from step 3.
  • mmomentum: a running average of recent gradients (β1 = 0.9, i.e. 90% memory). Steady directions build up; flip-flopping cancels.
  • va running average of gradient size squared (β2 = 0.999). Dividing by √v auto-shrinks steps for jittery numbers and enlarges them for calm ones — a self-tuning step size per number.
  • lrthe base step size, set per kind of number (below). Only the position rate is eased down over training; the rest stay fixed.
  • m̂, v̂tiny startup corrections (m and v begin at 0, which would make the first steps too small).
gs_train_shaders.ts — ADAM_SRC (WGSL), one number per GPU threadWGSL
var m = b1*mBuf[j] + (1.0-b1)*g;               // momentum
var v = b2*vBuf[j] + (1.0-b2)*g*g;             // gradient-size memory
mBuf[j] = m; vBuf[j] = v;
let mh = m / (1.0 - pow(b1, t));               // startup bias correction
let vh = v / (1.0 - pow(b2, t));
params[j] = params[j] - lr * mh / (sqrt(vh) + eps);
// opacity also gets a gentle pull toward semi-transparent (a "regularizer":
// an extra small term in the loss that discourages a bad habit, here hoarding opacity),
// and scales/logits are clamped so they can't run off to absurd values.
The base step sizes you'd set (relative to scene size r)
  • position3e-4 · r  — slowest; sliding a blob is a drastic change
  • log-scale5e-3
  • quaternion1e-3
  • color1.5e-2 — brisk
  • opacity2.5e-2 — briskest; cheap and reversible

The position rate eases toward zero over the run (next box): bold moves early to rough in the scene, fine ones late to polish. The other rates stay constant. Betas 0.9 / 0.999 are the standard Adam defaults; the stabilizing ε is 1e-15, and the spherical-harmonic "shine" numbers (introduced below) learn at the color rate ÷ 20 (1.25e-4).

The math — how the position step fades

Only the position step size shrinks over training — a blob still sliding late in the run is usually chasing one photo's noise (overfitting: memorizing the quirks of the photos it was shown instead of learning the scene they share). It follows a power curve: the full base rate at the start, easing down to 1% of it by three-quarters of the way through, then holding that floor for the final polish. Scale, tilt, color, and opacity rates stay constant.

lrpos(t) = lrbase · 0.01progress     progress = min( 1, t − start0.75 · span )
  • tthe current step; span = the run's length (horizon − start, e.g. 30 000 steps).
  • 0.01progressa smooth ramp from 1 (full rate) down to 0.01 (a hundredth), reached at 75% of the run, then flat. Feeding new photos later re-bases start to briefly re-warm the positions.

Then the loop returns to step 1 with a different photo. Render → loss → backprop → Adam, about a thousand times a second, for tens of thousands of steps.


Beyond the basic loop

Growing the cloud, adding shine, forgiving the camera

Densification: cloning the busy blobs, recycling the dead

Nudging a fixed set of blobs can't add detail the set can't represent — a fence rail, gravel, foliage — while elsewhere blobs fade to invisible and waste compute. So every couple of thousand steps the trainer reshapes the population — a densification pass. (Papers call this flavor "MCMC-style" after a random-sampling technique it borrows from; the mechanic is what matters.)

  1. Find the dead and the donors. Blobs whose opacity has fallen below ~0.02 are "dead" (contributing nothing); blobs above ~0.4 are healthy "donors."
  2. Relocate the dead. Rather than delete them, teleport dead blobs next to a donor (biased toward the largest donors, which usually carry the remaining blur), so the count stays roughly fixed but effort moves where it's needed.
  3. Split the big ones. A large donor that keeps getting strong "not enough detail" gradients is split: both halves shrink (÷1.6) so the footprint is preserved, and they drift apart to specialize. A freshly reshaped blob has its Adam momentum reset so it can move cleanly.

Growth is front-loaded (generous early, then it stops partway through and the rest is pure polish) and hard-capped so the count can't explode.

src/splats/training/gs_trainer.ts — refine() (TypeScript, runs on the CPU between batches)TypeScript
for (let i = 0; i < n; i++) {
  const o = sigmoid(params[i*16 + 13]);          // this blob's opacity
  if (o < 0.02) dead.push(i);                    // invisible → recycle it
  else if (o > 0.4) donors.push(i);             // healthy → can spawn from it
}
// prefer splitting the biggest donors; both halves shrink so the area is kept
if (doSplit) { const shrink = Math.log(1/1.6);
  for (let k = 3; k <= 5; k++) { params[bi+k] += shrink; params[bd+k] += shrink; }
  resetAdamMoments(bd);                          // donor changed shape → forget its momentum
}
The exact knobs (so you could reproduce it)
  • dead / donoropacity < 0.02 = dead (recycle); > 0.4 = donor (can spawn). Skip the whole pass if fewer than 16 donors.
  • relocate capmove at most 5% of the blobs per pass; dead blobs teleport onto donors (biased to the top quartile by size) with a small positional jitter.
  • splitchance 0.7: both halves shrink ÷1.6, keep the donor's opacity; the reshaped blob's Adam momentum is reset.
  • cloneotherwise: copy shrunk ×0.85, opacity reset to 0.25.
  • grow+15% of the count per pass, front-loaded — it stops at 75% of the run — and hard-capped at maxSplats (600 000).
  • regularizerssmall extra terms in the loss that discourage bad habits: a gentle opacity pull (0.01) toward transparent, so unused blobs fade and become recyclable; and an anisotropy pull (0.005) — anisotropy meaning "stretched much more along one axis than the others" — that keeps blobs from degenerating into needles.

Spherical harmonics: color that changes with viewing angle

A flat-colored blob suits a matte wall, but a metal roof or wet street looks different depending on where you stand. So each blob gets a few extra color numbers that let its color shift smoothly with the viewing direction. The smooth "patterns over directions" they multiply are spherical harmonics — a fixed library of ways a value can vary over a sphere (a constant, then "brighter from above," "brighter from the left," and progressively wigglier patterns). Degree 0 is flat color; each higher degree adds finer directional detail.

The math — directional color
c(dir) = max( 0,   base + Σk shk Yk(dir) )
  • Yk(dir)the k-th fixed directional pattern, evaluated for this viewing direction.
  • shkthis blob's learned amount of that pattern — the extra numbers.

Crucially this shine is unlocked gradually — one degree switched on roughly every 1000 steps — so the cloud is forced to learn what's there before how it glints, and can't cheat a hard photo by mysteriously recoloring a blob.

gs_train_shaders.ts — SH added to the base color, ramped in over training (WGSL)WGSL
let v = normalize(worldPos - cameraPos);        // viewing direction to this blob
var Y = shBasis(v);                              // the fixed directional patterns
let aK = shActiveK();                            // how many are unlocked yet (ramps up)
for (var k = 0u; k < aK; k++) col += sh[..k..] * Y[k];
col = max(col, vec3f(0.0));                       // color can't go negative

So what are those "fixed patterns," concretely? Each Yk is a simple polynomial of the viewing direction v = (x, y, z) (a unit arrow from the blob to the camera). Degree 0 is a constant — the same from every angle. Degree 1 is literally x, y, z — "more toward the right / up / front." Degree 2 and 3 are products like xy, xz, and 2z2−x2−y2 — progressively wigglier ways to vary over a sphere. Each comes with a fixed numeric multiplier.

The math — the basis, and how color is assembled here
Y0 = 0.2821   |   Y1..3 = −0.4886y,  0.4886z,  −0.4886x   |   Y4..8 = 1.0925xy, … , 0.5463(x2−y2)
color(v) = max( 0,   sigmoid(base) + Σk shk · Yk(v) )
  • sigmoid(base)the flat color from the blob's stored RGB logits — this is the degree-0 term in this trainer, a plain 0–1 color, not a raw harmonic coefficient. (The classic (rgb−0.5)/0.2821 form only appears when exporting to a .ply a viewer will read.)
  • shkthe blob's learned weight for pattern k — the "extra" numbers, for degrees ≥ 1. There are K = (deg+1)2−1 of them per color channel (3, 8, or 15 for degree 1, 2, 3), stored channel-major in a separate buffer.
  • max(0, ·)color can't go negative; a channel clamped here simply gets no gradient this step.
gs_train_shaders.ts — the SH basis and its backward (WGSL)WGSL
// the fixed patterns, evaluated for this view direction v=(x,y,z)
Y[0] = -0.4886025 * y;  Y[1] = 0.4886025 * z;  Y[2] = -0.4886025 * x;   // degree 1
Y[3] = 1.0925484 * x*y; Y[4] = -1.0925484 * y*z;                        // degree 2 …
Y[5] = 0.3153916 * (2.0*z*z - x*x - y*y);  Y[6] = -1.0925484 * x*z;
Y[7] = 0.5462742 * (x*x - y*y);                                         // … (degree 3 = Y[8..14])
// backward: each weight's blame is just dL/dcolor · the pattern's value
for (var k = 0u; k < activeK; k++) {
  shGrad[sb + k]          = dRGB.x * Y[k];    // red channel
  shGrad[sb + SHK + k]    = dRGB.y * Y[k];    // green
  shGrad[sb + 2u*SHK + k] = dRGB.z * Y[k];    // blue
}

The backward pass is refreshingly simple: a weight that multiplies a strongly-lit pattern in this view collects proportionally more blame — ∂L/∂shk = dRGB · Yk. (This particular trainer also lets a sliver of blame flow through the view direction back onto the blob's position, which most implementations skip.) And "unlocked gradually" has a precise meaning: only the first activeK = (activeDeg+1)2−1 weights are summed, with activeDeg stepping up by one about every 1000 iterations — inactive patterns contribute nothing and receive no gradient until their band switches on.

Interactive · the spherical-harmonic basis — the fixed patterns a splat's color is built from
each sphere is one basis pattern Yₖ(dir) · ● positive lobe   ● negative lobe  ·  hover a sphere for its formula
0–3 the trainer unlocks one band roughly every 1000 steps — learn the plain color first, the glint later. (Degree 0 is the constant, same-from-every-angle term; in this trainer the base color is stored separately as a sigmoid color, and degree ≥1 are the “extra” view-dependent numbers added on top.)
Each blob stores a few numbers — how much of each of these fixed directional patterns to add — so its color can vary smoothly with viewing angle. The low bands set the broad color; higher degrees add finer directional detail (the sharp shine). The trainer switches them on one band at a time, so it learns the plain color before the shine. Drag to orbit all spheres together.
Interactive · spherical harmonics — view-dependent color (drag the sphere)
the splat — drag to orbit; its color is read off the SH for this viewing direction
the color over all directions (lat/long) · ◯ = where you're looking · view dir —
-2.2 2.2
Instead of one fixed color, each blob stores a handful of numbers — spherical-harmonic coefficients — that describe how its color changes with the direction it's viewed from. Orbit the sphere and its color slides across the map on the right; that view-dependence is what lets splats look shiny and reflective rather than flat. Untick the box to collapse to the single average color: flat, from every angle.

Forgiving the camera and the light

Two last touches for real footage. SfM's poses are excellent but not perfect, so the trainer may nudge each camera's pose by a whisker as it goes (backprop already produces a gradient for the camera, since the render depends on it). And a camera's auto-exposure drifts frame to frame, so the trainer learns a small per-photo brightness gain and bias — the ones you saw in the loss — rather than twisting the blobs to satisfy inconsistent exposures.

The math — the camera and exposure gradients

These come for free: the render depends on the camera pose and on a per-photo exposure, so the same backward sweep produces gradients for them too. The loss compared an exposure-adjusted render to the photo, err = gain·C + bias − photo, which makes the exposure gradients trivial, and the camera gradients reuse the very same camera-space blame dpc that pushed each blob's position.

  • ∂L/∂bias= Σpixels ∂L/∂C     ∂L/∂(log gain) = gain · Σpixels ∂L/∂C · C — one brightness scale and offset per photo.
  • ∂L/∂t= Σ dpc — the camera's position gets the summed, opposite push of all the blobs it saw.
  • ∂L/∂ω= Σ (pcam − t) × dpc — ω is a small rotation of the camera, and this is a rotational torque (× is the cross product): if the blobs all want to slide one way, the camera should turn.
  • ∂L/∂(log f)a single shared focal-length nudge, since f scales every projection alike.

These are held frozen for the first stretch of training — you can't fix a camera against a cloud that's still noise — then switched on with tiny learning rates so poses drift by only a whisker and exposure soaks up brightness differences the blobs shouldn't have to.

gs_train_shaders.ts — exposure & camera-pose gradients (WGSL, trimmed)WGSL
// exposure: err = gain*C + bias - photo;  eg = err/root (the Charbonnier slope)
camAdd(camBase + 6u, dot(eg, C) * gain);      // ∂L/∂(log gain)
camAdd(camBase + 7u, eg.x + eg.y + eg.z);     // ∂L/∂bias
// pose: dpc is the cam-space position blame already computed for the blob
let torque = cross(g.pc - cam.t, dpc);
camAdd(camBase + 0u, torque.x); camAdd(camBase + 1u, torque.y); camAdd(camBase + 2u, torque.z);
camAdd(camBase + 3u, dpc.x); camAdd(camBase + 4u, dpc.y); camAdd(camBase + 5u, dpc.z);   // ∂L/∂t

Finishing

Knowing when to stop, and what you keep

Gains taper off, so the trainer keeps one photo secret — a holdout it never learns from, only scores against. While the holdout score keeps rising the cloud is genuinely learning the scene rather than memorizing the shown photos; when it flattens, training stops on its own. Because that photo was never used, its PSNR is an honest measure of quality on views the model has never seen.

What you keep is the blob cloud — every blob's final position, scale, quaternion, color (with its spherical-harmonic extras), and opacity. Fly a new camera through it in real time, from angles no photo ever captured, and it holds together; or write it to a standard .ply file that any Gaussian-splat viewer can open. Footage in, a place out.

Render (EWA + composite) ScoreCharbonnier Nudge (Adam)per-number Backpropreverse sweep ≈1000×per second

Two solvers, one engine. Bundle adjustment nudges cameras and points to match the photos; training nudges blobs to match the photos. Both just keep rolling "how wrong" downhill.

If you were building it

The whole trainer, assembled

Putting every stage together, a from-scratch implementation is:

  1. Front end (SfM). SIFT features → GPU match → essential-matrix + RANSAC per pair → tracks → incremental PnP → bundle adjustment. Out: poses, focal, distortion, sparse cloud.
  2. Initialize. Straighten photos with the distortion; seed 16-float blobs on the sparse points, colored from the photos; allocate gradient and Adam-moment buffers.
  3. Per step. Pick a photo; project blobs (EWA); tile via count → scan → scatter → sort; composite front-to-back; Charbonnier loss; fused backward sweep + chain pass with fixed-point atomic gradients; Adam update.
  4. Periodically. Every ~2500 steps, relocate dead and split big donors; every ~1000, unlock one more spherical-harmonic degree.
  5. Stop & export. Watch the holdout PSNR; when it plateaus, stop and write the cloud to .ply.

Every arrow above is one of the sections you just read. There is no hidden step and no learned black box — just projection, sorting, blending, a robust error, the chain rule, and Adam, repeated until the picture stops being wrong.

And it isn't hypothetical: a working single-file trainer was built from nothing but this page — the second half, running live in your browser. It skips the SfM front end (a synthetic scene rendered from known cameras stands in for the posed photos) and trains real 16-float blobs with the exact loop above: EWA projection, tile sort, front-to-back compositing, Charbonnier loss, backprop, Adam.

This sample can generate gaussian splats from a video using the described algorithms: https://taosengine.com/samples/splat_video.html


Credits

Credits & references

Code

The code excerpts on this page are from the Taos Engine's port of splat.js (MIT License, © 2026 Stratum1 GmbH), a browser-based structure-from-motion and Gaussian-splat trainer. The structure of the pipeline, the WGSL kernels, and the tuned hyperparameters (learning rates, refinement cadence, regularizers) come from that project, adapted to the Taos Engine.

Papers

  1. B. Kerbl, G. Kopanas, T. Leimkühler, G. Drettakis. “3D Gaussian Splatting for Real-Time Radiance Field Rendering.” ACM Transactions on Graphics 42(4), SIGGRAPH 2023. The blob representation, tiled rasterizer, and training loop.
  2. M. Zwicker, H. Pfister, J. van Baar, M. Gross. “EWA Splatting.” IEEE Transactions on Visualization and Computer Graphics 8(3), 2002. Projecting a 3D Gaussian to a 2D screen-space oval.
  3. Z. Yu, A. Chen, B. Huang, T. Sattler, A. Geiger. “Mip-Splatting: Alias-free 3D Gaussian Splatting.” CVPR 2024. The 0.3-pixel dilation and tiny-blob opacity compensation.
  4. S. Kheradmand, D. Rebain, G. Sharma, W. Sun, Y.-C. Tseng, H. Isack, A. Kar, A. Tagliasacchi, K. M. Yi. “3D Gaussian Splatting as Markov Chain Monte Carlo.” NeurIPS 2024. Relocating dead blobs instead of clone/split/prune densification.
  5. D. P. Kingma, J. Ba. “Adam: A Method for Stochastic Optimization.” ICLR 2015.
  6. P. Charbonnier, L. Blanc-Féraud, G. Aubert, M. Barlaud. “Two Deterministic Half-Quadratic Regularization Algorithms for Computed Imaging.” ICIP 1994. The robust loss.
  7. D. G. Lowe. “Distinctive Image Features from Scale-Invariant Keypoints.” International Journal of Computer Vision 60(2), 2004. SIFT detection and descriptors.
  8. M. A. Fischler, R. C. Bolles. “Random Sample Consensus: A Paradigm for Model Fitting with Applications to Image Analysis and Automated Cartography.” Communications of the ACM 24(6), 1981. RANSAC.
  9. R. I. Hartley. “In Defense of the Eight-Point Algorithm.” IEEE Transactions on Pattern Analysis and Machine Intelligence 19(6), 1997.
  10. R. Hartley, A. Zisserman. Multiple View Geometry in Computer Vision, 2nd ed. Cambridge University Press, 2004. Epipolar geometry, DLT triangulation, camera resectioning.
  11. J. L. Schönberger, J.-M. Frahm. “Structure-from-Motion Revisited.” CVPR 2016. Incremental SfM: registration, triangulation, and bundle adjustment in rounds.
  12. B. Triggs, P. F. McLauchlan, R. I. Hartley, A. W. Fitzgibbon. “Bundle Adjustment — A Modern Synthesis.” Vision Algorithms: Theory and Practice, LNCS 1883, 2000. Sparse Levenberg–Marquardt and the Schur complement.
  13. D. W. Marquardt. “An Algorithm for Least-Squares Estimation of Nonlinear Parameters.” Journal of the Society for Industrial and Applied Mathematics 11(2), 1963.