all lessons / computer_vision_3d / 10 · Gaussian Splatting lesson 10 / 13

3D Gaussian Splatting

Lesson 09's NeRF is beautiful and slow: an implicit radiance field queried by a neural network hundreds of times per ray, so a single frame takes seconds. This lesson keeps the one idea that made NeRF trainable — differentiable front-to-back compositing — but swaps the representation out from under it. Instead of an implicit field you march, we place explicit primitives you rasterize. That single swap buys real-time rendering (100+ FPS) and easy editing, and in doing so it resolves the explicit-vs-implicit tension from lesson 01 decisively in favor of speed.

The plan
Six moves. (1) Restate NeRF's cost precisely and set the target: explicit primitives + rasterization + differentiability = real-time. (2) Define the primitive — a 3D Gaussian with mean, covariance, opacity, and view-dependent color — and the one non-obvious detail: how to parameterize the covariance so it stays a valid shape under gradient descent (a direct payoff of lesson 02's quaternions). (3) Splat each 3D Gaussian to a 2D one via the EWA covariance transform, and be honest that the projection Jacobian is only a local approximation. (4) Tile-based rasterization: sort by depth, blend front-to-back — the same compositing equation as NeRF, but over a handful of splats instead of hundreds of MLP queries. That is the entire real-time story. (5) Optimize with a photometric loss and grow/prune the millions of Gaussians with adaptive density control, initialized from the SfM point cloud (CV lesson 05). (6) A head-to-head NeRF vs 3DGS table and when to pick which.

1 · Why NeRF is slow, and what we want instead

Recall the NeRF forward pass from lesson 09. To color one pixel you cast a ray, pick tens to low-hundreds of sample points along it, and at every sample evaluate an MLP Fθ(x, dir) → (color, density) to get the emitted color and volume density; then you integrate those samples front-to-back. Multiply out the arithmetic: (pixels per frame) × (samples per ray) × (one full MLP forward per sample). For a 800×800 image at ~192 samples/ray that is on the order of 108 network evaluations per frame. Even with the hash-grid acceleration of Instant-NGP, the representation is implicit — there is no geometry to draw, only a field to interrogate sample-by-sample — so rendering is fundamentally ray marching through a network. That is why vanilla NeRF renders in seconds per frame and trains in hours.

Now write down what we actually want for a product that has to render and be edited interactively:

explicit primitives  +  rasterization  +  differentiability  =  100+ FPS .

Each term is doing work. Explicit primitives means the scene is a list of things with coordinates — no per-sample network to evaluate. Rasterization means we project those primitives to the screen and let the GPU's native strength (drawing lots of small overlapping shapes) do the rendering, instead of marching rays. Differentiability means we keep the property from lesson 01 §4 that made the whole inverse-rendering loop work: gradients flow from the image error back into the primitives, so we can still fit the scene to photos by gradient descent.

The reframe — lesson 01's dichotomy, resolved
Lesson 01 laid out the explicit/implicit axis and refused to declare a winner, because the right choice comes from the constraint. Here the constraint is explicit: real-time rendering and editing. Given that, you want an explicit representation (fast to draw, easy to move around) that is also soft and differentiable (so it still optimizes like a field). 3D Gaussian Splatting (3DGS) is exactly that hybrid — the one lesson 01 flagged as the deliberate middle of the zoo. This whole lesson is that abstract trade-off cashed out into a concrete method.

2 · The primitive — a 3D Gaussian

A 3DGS scene is a set of a few million 3D Gaussians, each a small anisotropic blob. One Gaussian carries four things:

The blob's spatial influence at a point x is an unnormalized Gaussian centered at μ with covariance Σ:

G(x) = \exp\!\big(-\tfrac{1}{2}\,(x-μ)ᵀ\,Σ-1\,(x-μ)\big) .

The quadratic form (x-μ)ᵀ Σ-1 (x-μ) is the squared Mahalanobis distance — the "distance in units of the blob's own spread." It equals 1 on the ellipsoid, so Σ literally is the shape: its eigenvectors are the ellipsoid's axes and the square roots of its eigenvalues are the axis half-lengths.

The covariance must stay valid — enter quaternions (lesson 02)

Here is the subtlety that trips people up. A covariance matrix must be symmetric positive semidefinite (PSD) — otherwise Σ-1 and the quadratic form are meaningless (a negative eigenvalue would make G(x) blow up along some direction instead of decaying). A symmetric 3×3 has 6 independent entries, and it is tempting to just make those 6 numbers learnable and let the optimizer move them. Do not.

The trap — optimizing the 6 raw entries
If you treat Σ's six entries as free parameters, gradient descent will happily push them to a configuration with a negative eigenvalue. The matrix is then indefinite, not a valid covariance: Σ-1 stops being PSD, the "Gaussian" is no longer a decaying bump, and the render (and its gradients) go garbage. This is the exact analog of lesson 02's warning that you cannot let an optimizer roam the 9 entries of a rotation matrix — the valid set is a curved subset you fall off of. Constraining a matrix to stay PSD during optimization is awkward; the fix is to parameterize it so it is PSD by construction.

The construction factors the covariance into a scale and a rotation. Let S = \mathrm{diag}(sx, sy, sz) be a diagonal scale (the blob's extent along its own three axes) and let R be a rotation matrix built from a unit quaternion q. Define

Σ = R\,S\,Sᵀ\,Rᵀ = R\,(SSᵀ)\,Rᵀ,\qquad SSᵀ = \mathrm{diag}(sx2, sy2, sz2) .

Why this is guaranteed valid: for any vector v, vᵀΣv = vᵀ R S Sᵀ Rᵀ v = ‖Sᵀ Rᵀ v‖² ≥ 0, so Σ is PSD automatically — no constraint to enforce, no re-projection step. The factorization also cleanly separates the two things you want to control independently: S is the shape/size (how stretched the blob is along its own axes) and R is the orientation (which way those axes point). You optimize the three log-scales \log s (log-space keeps them positive) and the four quaternion components (renormalized to unit length each step), and reconstruct Σ from them for rendering.

A concrete payoff of lesson 02
This is exactly the quaternion machinery from lesson 02 earning its keep. Lesson 02 argued quaternions are the right way to store and optimize an orientation — minimal redundancy (4 numbers, 1 unit-norm constraint), no gimbal lock, cheap to renormalize onto the manifold. A Gaussian's orientation is stored as precisely such a quaternion, and the PSD-by-construction trick Σ = RSSᵀRᵀ is the SO(3) "stay on the manifold" idea applied to covariances. If an interviewer asks "why does 3DGS store a quaternion per Gaussian rather than a raw covariance?", the answer is this section: valid-by-construction PSD, and clean scale/orientation separation.

3 · Rendering by splatting — the EWA covariance transform

To draw the scene we must get each 3D Gaussian onto the 2D image. "Splatting" means projecting a 3D Gaussian to a 2D Gaussian in image space and stamping (splatting) that 2D footprint onto the pixels. Two pieces project: the mean and the covariance.

The mean projects like any 3D point — through the world→camera rigid transform and then the pinhole projection of CV lesson 04: μ goes to camera coordinates, then to a pixel via the intrinsics K. Nothing new.

The covariance is the interesting part. A 2D Gaussian in image space also has a 2×2 covariance Σ', and it is obtained by pushing the 3D covariance through the same transformation the mean underwent — but a covariance transforms by sandwiching with the transformation's linear part. The EWA (Elliptical Weighted Average) splatting result is:

Σ' = J\,W\,Σ\,Wᵀ\,Jᵀ ,

where W is the world→camera rotation (the viewing transform's linear part — it reorients the covariance into camera coordinates) and J is the Jacobian of the projective map evaluated at μ. The sandwich form is the standard rule for how a covariance transforms under a linear map y = My: \mathrm{Cov}(y) = M\,\mathrm{Cov}(x)\,Mᵀ. The composition J W is that linear map: first rotate into the camera frame (W), then apply the local linearization of the perspective projection (J).

Why J makes this an approximation
Perspective projection is nonlinear — it divides x and y by depth z (lesson 01's x = f X/Z), so a straight 3D shape does not map to a similarly-shaped 2D one in general. The covariance-sandwich rule, though, only holds exactly for linear maps. J is the local affine approximation of the projection at the point μ: it is the best linear map matching the true projection right at the blob's center, and it drifts from the truth as you move away from μ. So the projected footprint Σ' is only approximately a Gaussian. The error grows for Gaussians that are large (they extend far from μ, where the linearization is stale) or far off the optical axis (where perspective distortion is strongest). This is the same "linearize a nonlinear map with its Jacobian" move as the exp-map linearization in lesson 02 §4 — clean near the point, approximate away from it.

The upshot: after splatting, each visible Gaussian is a 2D elliptical bump on the image with center = projected μ and shape = Σ'. Evaluating that 2D Gaussian at a pixel gives a scalar in [0,1] — the blob's spatial weight at that pixel — which we combine with the blob's opacity next.

4 · Differentiable tile-based rasterization

Now blend the splats into a picture, differentiably and fast. The 3DGS rasterizer does it in three stages:

  1. Tile. Partition the image into small tiles (e.g. 16×16 pixels). For each Gaussian, find which tiles its 2D footprint Σ' overlaps, and add it to those tiles' lists. This turns "which of the millions of Gaussians touch this pixel?" into a cheap local lookup and lets each tile be rasterized independently (great for the GPU).
  2. Sort by depth. Within the visible set, sort the Gaussians front-to-back by their camera-space depth. This is one global per-view sort (a fast GPU radix sort keyed by depth + tile), not a per-pixel sort — every pixel in a tile then walks the same depth-ordered list. Correct compositing requires a consistent front-to-back order, and this sort supplies it.
  3. Blend front-to-back, per pixel. Walk the sorted list and accumulate color with alpha compositing, stopping early once the pixel is opaque (transmittance ≈ 0).

The blend is the alpha-over compositing equation:

C = \sum_{i} ci\,α'i\,\prod_{j<i}\big(1 - α'j\big) ,\qquad α'i = αi\cdot G'i(\text{pixel}) ,

where ci is the Gaussian's SH color for this view, αi is its learned opacity, and G'i(\text{pixel}) is its projected 2D Gaussian evaluated at that pixel. The product \prod_{j<i}(1-α'j) is the accumulated transmittance Ti — the fraction of light that survived all the nearer, partially-opaque blobs — so each blob contributes its color scaled by how visible it still is.

This is the same equation as NeRF — that is the whole trick
Compare this to lesson 09 §3. NeRF's volume-rendering integral, discretized, is C = \sum_i Ti\,αi\,ci with Ti = \prod_{j<i}(1-αj)identical front-to-back alpha compositing. 3DGS did not change how you combine samples along a viewing direction; it changed what the samples are and how you gather them. NeRF's samples are hundreds of MLP evaluations at points marched along a ray. 3DGS's "samples" are a handful of sorted splats that happen to touch the pixel — no network, just a Gaussian evaluated and multiplied. Swap "evaluate the MLP at the next ray sample" for "read the next depth-sorted splat" and you have turned an expensive sequential field-query into a hardware-friendly sorted rasterization. That single swap is the entire real-time story.

Why it is differentiable. Every operation here — the Gaussian G'i, the product αi\cdot G'i, the running transmittance, the weighted sum — is smooth in the Gaussians' parameters. There is no hard coverage test (the discontinuity that lesson 01 §4 warned breaks mesh rasterization); a Gaussian's soft falloff gives every pixel a nonzero gradient with respect to the blob's mean, covariance, opacity, and color. So the rasterizer is a differentiable renderer R in exactly the sense the inverse-rendering loop needs, and it runs on the GPU at interactive rates.

5 · Optimization and adaptive density control

Fitting a scene is the loop from lesson 01, instantiated. Given a set of posed training images, render each view with the rasterizer above and compare to the ground-truth photo with a photometric loss — the same family NeRF used: an L1 term plus a D-SSIM (structural-similarity) term that penalizes local structural error, not just per-pixel color:

\mathcal{L} = (1-λ)\,\mathcal{L}1 + λ\,\mathcal{L}\text{D-SSIM} .

Because the whole forward pass is differentiable, gradients flow to every parameter of every Gaussian: the mean μ, the log-scales, the orientation quaternion, the opacity α, and the SH color coefficients. A plain optimizer (Adam) descends all of them jointly.

But how many Gaussians, and where? You do not know the right count up front, so 3DGS changes the set itself during training — this is adaptive density control, and it is what lets a few million blobs organize into a sharp scene:

The result is an automatic allocation of capacity: dense clusters of tiny Gaussians on detailed, textured surfaces; a few big ones on flat walls; nothing wasted in empty space. This grow/prune behavior is the explicit-representation counterpart of NeRF letting an MLP implicitly distribute its capacity — here the capacity is a literal, editable list of primitives you can add to and delete from.

6 · NeRF vs 3DGS — the comparison

Both fit a scene to posed images by differentiable front-to-back compositing under a photometric loss. Everything else differs, and the differences all trace back to the one choice — implicit field vs explicit primitives:

AxisNeRF (lesson 09)3D Gaussian Splatting (this lesson)
Representationimplicit field (MLP Fθ)explicit primitives (millions of Gaussians)
Renderingray-march + MLP query per samplerasterize + alpha-blend sorted splats
Speedseconds per frame100+ FPS (real-time)
Traininghours → minutes (with hash grids)minutes
Memorysmall — one compact MLPlarge — hundreds of MB of Gaussians
Editabilityhard — geometry is baked into weightseasy — move / delete / recolor blobs directly
Typical artifactsfloaters (spurious semi-transparent haze)popping (order flips) + spiky / needle Gaussians
Interview shortcut — which to pick
Argue from the constraint, never the fashion (lesson 01's rule). Pick 3DGS when you need real-time rendering or interactive editing and can afford the memory — VR/AR walkthroughs, live scene editing, anything that must hit frame rate. Pick a NeRF-style implicit field when you need compactness (a scene in a few MB of weights), a continuous field you can query at arbitrary points (e.g. for downstream geometry or a signed-distance readout), or when memory/bandwidth is the binding limit. Same loss, same compositing math, opposite ends of the storage-vs-speed trade.

The widget below is 3DGS in 2D: a handful of 2D Gaussians, each with a covariance you build from a rotation angle and two scales — the planar version of §2's Σ = RSSᵀRᵀ — rasterized by the exact front-to-back blend of §4. Stretch and rotate one blob to cover a diagonal streak with a single anisotropic Gaussian; reorder the depths and watch which blob wins the pixel ("popping"); dial opacity to feel the difference between hard occlusion and see-through blending.

2D Gaussian splatting — rasterize & composite anisotropic blobs
A small scene of 2D Gaussians. Each has a mean, a covariance Σ = R(θ)\,\mathrm{diag}(sx2, sy2)\,R(θ)ᵀ, a color, an opacity, and a depth. The canvas is rasterized pixel-by-pixel: at each pixel we walk the blobs front-to-back by depth, computing each one's Gaussian weight g = \exp(-\tfrac12\,\text{Mahalanobis}²) and compositing α' = \text{opacity}\cdot g with running transmittance — the same equation as §4. Thin outlines are each blob's ellipse. Try: pick a blob, then stretch sx and rotate θ to blanket a diagonal region with one anisotropic Gaussian (fewer blobs, more coverage); change its depth to reorder the stack and watch it pop in front of / behind its neighbors; drop opacity to blend through instead of occlude.
active Gaussian:
Gaussians
6
Active axis lengths (1σ)
Active depth rank
Mean accumulated opacity
Show the core JS
// Per-Gaussian covariance from rotation θ and scales (s_x, s_y):  Σ = R diag(s_x², s_y²) Rᵀ
// We precompute Σ⁻¹ (a 2×2 inverse) once per Gaussian, not per pixel.
function invCov(sx, sy, th){
  var c = Math.cos(th), s = Math.sin(th);
  // Σ = [[a,b],[b,d]]
  var a = c*c*sx*sx + s*s*sy*sy;
  var d = s*s*sx*sx + c*c*sy*sy;
  var b = c*s*(sx*sx - sy*sy);
  var det = a*d - b*b;
  return { ia: d/det, ib: -b/det, id: a/det };   // Σ⁻¹
}

// RASTERIZE: iterate pixels (coarse step for speed); blend blobs FRONT-TO-BACK by depth.
for (var py = 0; py < H; py += STEP)
for (var px = 0; px < W; px += STEP){
  var T = 1, r = bgR, g = bgG, b = bgB;            // transmittance + accumulated colour
  for (var k = 0; k < order.length; k++){          // order = indices sorted by depth (near→far)
    var G = gauss[order[k]], dx = px - G.mx, dy = py - G.my;
    var m2 = G.ia*dx*dx + 2*G.ib*dx*dy + G.id*dy*dy;   // Mahalanobis²
    var val = Math.exp(-0.5*m2);                        // 2D Gaussian weight ∈ (0,1]
    var aP = G.opacity * val;                           // α′ = opacity · g
    r += T*aP*G.r; g += T*aP*G.g; b += T*aP*G.b;        // C += T·α′·colour
    T *= (1 - aP);                                       // update transmittance
    if (T < 0.003) break;                                // pixel opaque → early out
  }
  fillRect(px, py, STEP, STEP, r, g, b);
}
// Reordering depths changes `order` → a different blob blends on top ("popping").

Where this points next

Both branches of neural rendering — implicit NeRF and explicit 3DGS — now share a spine: fit one scene to its own posed photos by differentiable compositing. But notice what they never do: generalize. Optimize a NeRF or a splat cloud for a room, and you learn that room and nothing else; a new scene means training again from scratch. That is fine for capture, useless for a model that should look at a novel object and infer its 3D shape. Lesson 11 makes the turn to learning across scenes: neural networks that operate directly on 3D data — point clouds (PointNet and permutation invariance), voxels and sparse convolution (the O(N³) escape from lesson 01 §3), and meshes — so a network trained on many objects can predict on ones it has never seen. The representations from lesson 01 return, this time as the inputs and outputs of learned models rather than things you optimize one at a time.

Takeaway
3D Gaussian Splatting keeps NeRF's one essential idea — differentiable front-to-back alpha compositing, C = \sum_i ciα'i\prod_{j<i}(1-α'j) — but replaces the implicit field-you-march with explicit primitives you rasterize. Each primitive is a 3D Gaussian: mean μ, opacity α, view-dependent SH color, and a covariance stored as Σ = RSSᵀRᵀ — PSD by construction, with R a quaternion (lesson 02), so scale and orientation are optimized independently and never leave the valid set. Rendering splats each 3D Gaussian to a 2D one via the EWA transform Σ' = JWΣWᵀJᵀ, where J is the projection Jacobian — a local affine approximation that distorts large or off-axis blobs. A tile-based, depth-sorted rasterizer then blends a handful of splats per pixel instead of hundreds of MLP queries per ray — the single swap that buys real-time speed. Training is a photometric L1 + D-SSIM loss with adaptive density control — seed from the SfM point cloud (CV lesson 05), clone in under-reconstructed regions, split over-large blobs, prune transparent ones. Pick 3DGS for real-time rendering/editing when you can spend the memory; pick implicit NeRF for compactness or a continuous field.

Interview prompts