EVERYTHING AIAI engineering, made visual
0/28 complete
LESSON 22 · COMPUTER VISION × AI · BUILD

A scene is a cloud.
Render it by rasterising.

3D Gaussian splatting replaced NeRF as the production default by turning a scene into something a GPU already knows how to draw: millions of explicit anisotropic Gaussians, each one 59 floats — position, rotation, scale, opacity and a colour that depends on where you are standing. Project them, sort them per tile, composite front-to-back, and backpropagate the whole way through.

90 MIN · 7 CHAPTERS + CHECKPREREQ · PHASE 4 · LESSON 13 + PHASE 1 · LESSON 12
FIG. 22 / PROJECT → TILE → SORT → COMPOSITE
composite tiles project the scene
LESSON 22TYPE · BUILD~90 MINPREREQ · PHASE 4 · LESSON 13 (3D VISION: POINT CLOUDS & NERFS), PHASE 1 · LESSON 12 (TENSOR OPERATIONS)ORIGINAL LESSON ↗
THE 60-SECOND VERSIONSee the problem ↓
01 / RASTERISE, NOT RAY-MARCH

Explicit primitives beat an implicit function.

A NeRF integrates hundreds of MLP queries along every ray: 192 samples per pixel, 123 million network calls for one 800 × 800 frame, one to two days of training on a V100. 3DGS projects an explicit cloud of 3D Gaussians and lets the GPU do what it was built for. The paper renders 1–5M Gaussians at 134–154 fps on an RTX A6000; nerfstudio's recipe trains a scene in 10–30 minutes on an RTX 4090. Quality is comparable — both integrate the same volumetric render equation — so the difference that matters is that a Gaussian is a primitive you can select and move.

134–154 fps · 10–30 min to train · editable primitives
02 / SIX NUMBERS, 59 FLOATS

Every Gaussian is a fixed-length record.

Position (3), rotation quaternion (4), scale (3) and opacity (1) place the blob: 11 floats. The colour is degree-3 spherical harmonics — 16 basis functions per channel × 3 channels = 48 floats. Total: 59 float32 = 236 bytes, of which 81.4% is colour alone. Storage follows the count: 1M Gaussians = 236 MB, 3M = 708 MB, 5M = 1.18 GB. Training multiplies it by four, because the optimiser keeps the value, the gradient and Adam's two moments — 944 bytes per Gaussian before a single tile buffer exists.

3 + 4 + 3 + 1 + 48 = 59 floats = 236 B · 1M → 236 MB · colour is 81.4%
03 / PROJECT, TILE, SORT, COMPOSITE

Rendering is five loop-shaped steps.

Σ′ = J W Σ Wᵀ Jᵀ turns each 3D ellipsoid into a 2D ellipse; the screen is cut into 16 × 16 pixel tiles (1080p is 120 × 68 = 8160 of them); each tile depth-sorts the splats that overlap it; each pixel walks its tile's list front-to-back with α = σ·exp(−½ dᵀΣ′⁻¹d) and accumulates C = Σ cᵢαᵢΠ(1−αⱼ). No ray marching, no MLP, and every step differentiable — so the photometric loss against one photograph reaches all 59 floats of every Gaussian.

Σ′ = J W Σ Wᵀ Jᵀ · 8160 tiles at 1080p · C = Σ cᵢαᵢΠ(1−αⱼ)
MENTAL MODEL IN ONE SENTENCE

A 3DGS scene is a list of blobs: each one is 11 floats of geometry plus 48 floats of colour, each one projects to an ellipse, and rendering is nothing more than a depth sort and a multiply-accumulate — which is exactly why it is fast, why it backpropagates, and why the bill arrives as memory rather than as time.

By the end you will be able to say what a Gaussian costs without counting on your fingers (59 floats = 236 B, and 4× that during training); follow a 3D covariance all the way to a screen-space ellipse (a 0.4 m blob at 4 m with an 800 px focal length is an 80.01 × 30.01 px ellipse covering 7542 px²); explain why the compositing equation is the same one NeRF integrates and why the rasteriser is still differentiable; count the SH budget at any degree (degree 3 = 16 × 3 = 48 floats, degree 2 = 9 × 3 = 27); and run the real pipeline from 20–50 photographs to a glTF KHR_gaussian_splatting or OpenUSD asset — while naming the limits you accepted: 236 MB per million Gaussians, floaters, and no collision geometry.

WHY EXPLICIT WINS

A NeRF is a function.
A scene is a list of primitives.

NeRF stores a scene as the weights of an MLP: every pixel is an integral of hundreds of network queries along a ray. 3D Gaussian splatting (Kerbl, Kopanas, Leimkühler & Drettakis, SIGGRAPH 2023) keeps the same physics and swaps the data structure — millions of explicit 3D Gaussians, rasterised like the primitives GPUs were built for.

Start with what the previous lesson’s NeRF actually costs. A rendered pixel is a sum over samples along one ray: 64 coarse plus 128 fine samples, each one an MLP forward pass for a density and a colour. An 800 × 800 image is 640,000 rays, so one frame is 640,000 × 192 = 122,880,000 MLP evaluations — call it 123 million network calls for one picture. Training runs 100,000–300,000 iterations of that, which is why the original NeRF took one to two days on a V100 and rendered at roughly a frame every 30 seconds.

3DGS asks a different question. If the scene is already a cloud of primitives, why integrate along rays at all? Project each primitive to the screen, sort by depth, and composite — the operation a GPU rasteriser was designed for. The 2023 paper renders 1–5M Gaussians at 134–154 fps on an RTX A6000, with every scene holding above 30 fps at 1080p. The follow-up research and the tooling around it settled the numbers everybody quotes in 2026: 10–30 minutes to train a scene on an RTX 4090, and a scene that renders in real time on hardware you already own.

IMPLICIT · NERF (2020)EXPLICIT · 3D GAUSSIAN SPLATTING (2023)camera192 samples per rayMLP: (x,y,z,d) → (σ, c)every pixel = an integral along a raytrain 1–2 days · render ~0.03 fps · ~5 MB · not editablecamera1–5M Gaussians, 59 floats eachtrain 10–30 min · render 134–154 fps · 236 MB–1.2 GB · editable
The same question — what is where in space, and what colour does it show from here — answered two ways. The NeRF answers with a function that must be evaluated hundreds of times per pixel. 3DGS answers with a list of primitives that a GPU already knows how to draw. The quality target is identical; only the data structure and the renderer changed.

The second difference is less obvious and more consequential. A NeRF has no parts. If you want to move a chair inside a trained scene, there is no “chair” — the appearance is entangled across the network’s weights, so the only honest answer is to retrain. A 3DGS scene is a list: each Gaussian has its own position, size, orientation, opacity and colour, and the pixels it affects are exactly the pixels it covers. Pick a subset and translate it and you have moved the chair. That single property — editable primitives — is why 3DGS became the production default, and why the industry standardised it in 2026: Khronos ratified the KHR_gaussian_splatting glTF extension (release candidate February 2026) and OpenUSD 26.03 shipped the UsdVolParticleField3DGaussianSplat schema.

What did not change is why either method looks right. Both compute the classical volumetric render — transmittance-weighted sums of colour along a view ray. NeRF evaluates it with dense samples through an MLP; 3DGS evaluates it with sparse explicit Gaussians and a rasteriser. Same equation, same photometric loss, different physics of computation. That identity is why their final image quality lands in the same band, and why the honest comparison table below is about speed, storage and editability rather than pixels.

axisNeRF (implicit)3DGS (explicit)
training1–2 days on a V10010–30 min on an RTX 4090
rendering192 MLP queries per ray, seconds per framerasterised: 134–154 fps on an A6000
qualitythe 2020 referencecomparable — same render equation
storage~5 MB of weights236 B per Gaussian → 236 MB–1.2 GB
editingno primitives: retrainselect Gaussians, move them

The axes where the difference is real — and the one where it is not (neither representation is a mesh) — read better as a panel than in prose. Pick a scene size and watch which rows separate the two representations:

NeRF against 3DGS

Same scene, same photographs, two representations. Training time, render speed, quality, storage and editability — pick the scene you care about, and read the rows knowing that four of the five are arithmetic, not opinion.

training timehours, log scale · lower is better
NeRF1–2 days
3DGS10–30 min

The bar is the log of the training hours: 24–48 h against ~0.3 h is two orders of magnitude, which is why the NeRF line could not ship an iterated asset. Source: NeRF paper (V100, 1–2 days) · nerfstudio Splatfacto docs (10–30 min on a 4090).

render speedfps, log scale · higher is better
NeRF~0.03 fps
3DGS134–154 fps

Both rows are 'how many frames per second on one consumer GPU'. The NeRF number is the 800 × 800, 192-sample forward pass; the 3DGS number is the paper's own benchmark (134–154 fps for 1–5M Gaussians on an A6000). Source: 3DGS paper (134–154 fps for 1–5M Gaussians on an A6000, 1080p ≥ 30 fps) · NeRF paper (192 samples/ray).

image qualityPSNR vs the same photos · higher is better
NeRFreference
3DGScomparable

Quality was never the selling point — both integrate the same volumetric render equation. The 3DGS paper reports matching or better PSNR on two of its three benchmark datasets and a small gap on the third. Source: 3DGS paper, Table 1 · NeRF paper.

scene storagebytes · lower is better
NeRF~5 MB
3DGS472 MB (2.0M splats)

This is the trade 3DGS makes: the scene stops being a function and becomes a pile of primitives, and the pile is one to two orders of magnitude larger than the weights it replaced. Your preset: room · 2M splats — 472 MB raw, 64.0 MB quantised to 32 B per splat, and about 210 fps on the paper's A6000 benchmark (linear model, per frame). Source: NeRF paper (the ~5 MB two-trunk MLP) · 3DGS representation arithmetic.

editabilitycan you move a chair? · higher is better
NeRFno
3DGSyes

The explicit representation is the whole story: a Gaussian is a list of numbers you can edit in a browser, and moving a subset of them moves exactly the pixels they cover. Source: 3DGS paper · SuperSplat editorial workflow.

collision geometryis there a surface? · higher is better
NeRFno
3DGSno

Neither is a mesh, so neither can be handed to a physics engine as-is. Hybrid pipelines extract a surface and keep the splats for appearance. Source: Both papers' limitations sections · industry practice.

your scene
scene room · 2M splats photographs 40 raw storage 472 MB (59 float32 = 236 B) quantised storage 64.0 MB (32 B per splat) render (model) ~210 fps for 2.0M splats training footprint 1.89 GB (4× during training) NeRF weights 5 MB — 1–2 orders of magnitude smaller and it cannot be edited: no primitive exists to move. 3DGS trades storage for everything else. training time NeRF 1–2 days → 3DGS 10–30 min render speed NeRF ~0.03 fps → 3DGS 134–154 fps image quality NeRF reference → 3DGS comparable editability NeRF no → 3DGS yes

Four of the five rows are arithmetic (time, frames, bytes) and one is judgement (does it look right) — which is why the quality row is worded “comparable” and not “better”. The sixth row is the punchline: neither representation is a mesh, so neither can be handed to a physics engine as-is.

One number from the NeRF lesson is worth carrying forward because it explains the whole trade: the entire NeRF scene was about 5 MB of float32 weights — two trunks of 596k parameters each. A 3DGS scene of one million Gaussians, at the 59 floats per Gaussian this lesson derives in chapter 02, is 236 MB. Two orders of magnitude more memory buys two orders of magnitude more speed and the ability to edit the result. As you will see, that trade has a cost of its own, and it is measured in gigabytes and in artefacts called floaters.

Quick check

Your team has a trained NeRF. A client asks you to move a sofa half a metre to the left and re-render. What does 3DGS change about that request?

SIX NUMBERS PER SPLAT

Eleven floats to place it.
Forty-eight to paint it.

One 3D Gaussian is a fixed-length record: position, rotation, scale, opacity and a view-dependent colour. At the default SH degree 3 that is 59 float32 = 236 bytes per Gaussian, and the colour is more than four times the geometry.

A Gaussian is a blob, not a point. What makes it a blob is that it is anisotropic — it can be long and thin in one direction and squat in another — and oriented, so the long direction points somewhere specific in the scene. Two of the six attributes exist for that reason alone: a rotation and a per-axis scale. Together they build the 3 × 3 covariance that says “how far from the centre does this Gaussian still matter, along each direction?”

the six attributes, and the floats each contributes position μ (3,) world coordinates rotation q (4,) unit quaternion scale s (3,) log-scales per axis opacity α (1,) post-sigmoid, in [0, 1] SH colour c_lm (3·(L+1)²,) view-dependent RGB optional f (+1 … +3) normals, semantics, time … at the default SH degree L = 3 (L + 1)² = (3 + 1)² = 16 basis functions per channel 16 × 3 channels = 48 colour floats 3 + 4 + 3 + 1 = 11 geometry floats 11 + 48 = 59 floats per Gaussian 59 × 4 bytes = 236 bytes in float32 59 × 2 bytes = 118 bytes in float16 the SH degree ladder — the only knob that moves the total L · basis · colour floats · total floats · fp32 bytes 0 · 1 · 3 · 14 · 56 1 · 4 · 12 · 23 · 92 2 · 9 · 27 · 38 · 152 3 · 16 · 48 · 59 · 236

Read the ladder. Degree 0 keeps one number per channel — a constant colour, the average of what the Gaussian looks like from everywhere. Degree 1 adds a linear ramp: the colour can differ between the top and the bottom, like a diffuse surface under a single light. Degree 3 costs 48 of the 59 floats and buys enough angular resolution for Lambertian shading, a specular highlight, and mild reflection. That is the 3DGS default, and it is why the colour is 4.4× the geometry: 48 / 11.

attributefloatswhat it really stores
position μ3the centre in world coordinates — x, y, z in metres
rotation q4a unit quaternion: 4 numbers, 3 degrees of freedom after normalisation
scale s3log-scales per axis; exponentiated at render time so a scale is never negative
opacity α1a logit; sigmoid(logit) ∈ [0, 1] is the compositing weight
SH colour c48degree-3 spherical harmonics: (3 + 1)² = 16 basis × 3 RGB channels
optional feature f+3appended by research variants: normals, semantics, time, a material id

The two activations in the list are not decoration. Scales are stored as logarithms and exponentiated at render time, because a scale is a positive quantity and gradient descent has no idea about positivity: in log space every real number is a valid scale, and an additive step of 0.1 multiplies the actual size by e^0.1 ≈ 1.105 — a 10.5% nudge, whether the Gaussian is 1 cm or 1 m across. Opacity is stored as a logit and squashed by a sigmoid, for the same reason: the optimiser gets the whole real line, the renderer gets [0, 1]. Colour is squashed by a sigmoid too.

Why rotation + scale and not the 9 numbers of Σ?

A covariance matrix is symmetric, so it has 6 independent entries, and it must stay positive semi-definite — for every direction u, uᵀΣu ≥ 0. Optimising the nine entries directly means a gradient step can leave that set, and then the quadratic form inside the exponential turns negative and the Gaussian explodes. The fix is to parameterise the family instead of the member: write Σ = R S Sᵀ Rᵀ, with R a rotation built from a unit quaternion and S = diag(σ). For any values of q and s the result is a valid covariance — no projection, no constraint, no invalid states. It also returns the right number of degrees of freedom: a unit quaternion has 3 and the scales have 3, matching the covariance’s 6. The cost is one redundant stored number (4 + 3 = 7 for 6 degrees of freedom), which the paper accepts happily.

The parameter budget

Every Gaussian is the same length: 3 + 4 + 3 + 1 + 48 = 59 floats at the default SH degree. Move the count and the degree, and the scene’s storage moves with them — this is the number that decides whether the asset streams to a phone or only to a workstation.

236 Bper Gaussian · 59 floats
per-Gaussian attributefloatsbytes fp32share
position μ3125.1%
rotation q4166.8%
scale s3125.1%
opacity α141.7%
SH colour c4819281.4%
total59236100%

SH degree 3: 16 basis functions per channel × 3 channels = 48 colour floats. The geometry (position + rotation + scale + opacity) is always 11 floats, so the colour is 4.4× the geometry at degree 3.

How big is that, really?

your scene236 MB
NeRF MLP5.0 MB
4K RGBA frame33.2 MB
phone AR budget200 MB
8 GB VRAM8.00 GB
24 GB VRAM24.00 GB

Bars are log scale from 1 MB to 24 GB, so equal lengths mean equal ratios, not equal amounts. Context: NeRF MLP = the paper's two 256-wide trunks, float32; 4K RGBA frame = one 3840 × 2160 frame of raw 8-bit RGBA; phone AR budget = what a mobile AR scene can hold without paging; 8 GB VRAM = a mid-range GPU, all of it; 24 GB VRAM = an RTX 4090, all of it.

SH degree
precision
degree 3 · 3DGS default (16) 1.0M Gaussians floats per Gaussian 59 geometry 11 SH colour 48 bytes per Gaussian 236 (fp32) scene storage 236 MB fp16 instead 118 MB training footprint 944 MB = 4× (value + gradient + 2 Adam moments) against a NeRF 47.2× larger 8 GB VRAM 2.9% of it 24 GB VRAM 1.0% of it training in 8 GB fits training in 24 GB fits Sanity checks: 1M Gaussians at degree 3 = 236 MB fp32 = 59,000,000 floats. 3M splats = 708 MB.

The quantised option is the PlayCanvas .splat layout: float32 position and scale, uint8 colour and rotation, spherical harmonics dropped entirely — 32.0 MB at 32 B per splat against 236 MB raw at the current degree. Nearly every web and mobile viewer loads the quantised one.

Now spend the budget on a realistic scene. A room captured from 40 photos typically converges to about 2 million Gaussians. Raw float32 storage is 2,000,000 × 236 B = 472,000,000 B = 472 MB. Quantised to the 32-byte .splat layout it is 64 MB — a seventh of the size, with the SH terms gone. And training is the part that actually hurts: four copies of every parameter (the value, its gradient, and Adam’s first and second moment) is 472 MB × 4 = 1.89 GB, before the rasteriser has allocated a single per-tile buffer. That number is why the training batch size and the densification schedule are the two settings people tune when a run runs out of memory.

A BLOB BECOMES AN ELLIPSE

Project the centre.
Squeeze the covariance through the Jacobian.

A 3D Gaussian is an ellipsoid in world space. One matrix product turns it into the 2D ellipse the rasteriser draws: Σ′ = J W Σ Wᵀ Jᵀ, where W is the camera transform and J is the Jacobian of the perspective divide.

The centre is easy: the world position goes through the camera transform and lands at a pixel. The shape is the interesting part, because perspective is not linear — things shrink as they get farther away, and they shear as they move off-axis. The standard approximation replaces the true warp with its first-order behaviour at the Gaussian’s centre: a 2 × 3 Jacobian matrix, evaluated once per Gaussian, that turns a small 3D displacement into a small 2D one.

the pinhole projection, camera at the origin looking down +z u = f · x / z v = f · y / z (f = focal length in pixels) the Jacobian of that map at the Gaussian's centre μ = (x, y, z) J = ⎡ ∂u/∂x ∂u/∂y ∂u/∂z ⎤ = ⎡ f/z 0 −f·x/z² ⎤ ⎣ ∂v/∂x ∂v/∂y ∂v/∂z ⎦ ⎣ 0 f/z −f·y/z² ⎦ project the covariance Σ = R S Sᵀ Rᵀ 3 × 3, world space Σ′ = J W Σ Wᵀ Jᵀ 2 × 2, screen space the 2D Gaussian's footprint is the ellipse whose axes are the eigenvectors of Σ′, with standard deviations √λ α(pixel) = σ · exp( −½ dᵀ Σ′⁻¹ d ) d = pixel − μ′

Run one Gaussian through by hand — this is the lab’s default state, so every number below is on screen. Take μ = (0.5, 0.25, 4), focal length f = 800 px, axis scales σ = (0.4, 0.15, 0.05) in metres and no rotation. The covariance is Σ = diag(0.16, 0.0225, 0.0025). The Jacobian at μ is J = [[200, 0, −25], [0, 200, −12.5]] — the 200s are f/z = 800/4, and the −25 and −12.5 are the perspective shear terms −f·x/z² and −f·y/z². Multiply through:

Σ′ = ⎡ 6401.56 0.78 ⎤ σ′₁ = 80.01 px ⎣ 0.78 900.39 ⎦ σ′₂ = 30.01 px axis angle = 0.008° area πσ′₁σ′₂ = 7542 px² straight-line sanity check — for an axis-aligned Gaussian σ′₁ ≈ f · σx / z = 800 × 0.4 / 4 = 80.0 px σ′₂ ≈ f · σy / z = 800 × 0.15 / 4 = 30.0 px where the off-diagonal comes from Σ′xx = 200² × 0.16 + 25² × 0.0025 = 6400 + 1.56 ↑ the thin z axis adds 0.02% to the diagonal Σ′xy = −25 × −12.5 × 0.0025 = 0.78 ↑ and 100% of the off-diagonal

Two lessons hide in those lines. First, the projected size is just the focal length times the Gaussian’s size divided by depth — f·σ/z, the same arithmetic a pinhole camera has been doing since 1850, and the check that catches a wrong Jacobian immediately. Second, the tiny z axis contributes 0.02% of the diagonal but all of the off-diagonal: shear is a small number that changes the ellipse’s orientation, not its size. An axis-angle readout alone would have looked identical; only the covariance knows.

Now rotate the splat 45° about its own y axis — the lab’s “foreshortening” preset. The long 0.4 m axis now points halfway between the image plane and the view direction, and the projection reflects it:

Σ = [[ 0.08125, −0.07875, 0.08125 ]] on the x·z terms Σ′ = ⎡ 4088.28 222.27 ⎤ σ′₁ = 64.06 px (was 80.01) ⎣ 222.27 912.70 ⎦ σ′₂ = 29.95 px (was 30.01) axis angle = 3.98° area 6028 px² (was 7542) the long axis lost 20% of its screen length: it is no longer parallel to the image plane, so part of it now points away from the camera. The short axis barely moved.

The last ingredient is the falloff. Every pixel inside the ellipse receives α = σ · exp(−½ dᵀΣ′⁻¹d) — a weight that is 1.0 at the centre and decays with the squared Mahalanobis distance. That decay is a Gaussian, so a fraction of the total mass lands inside each contour, and the fractions are universal:

contourmass insideα at the edgewhat it means for the rasteriser
39.3%0.607the solid contour — most of the visual weight lives here
86.5%0.135the useful extent of the splat
98.9%0.011the culling boundary: under 1/255 contribution

That last row is the reason a tile-based rasteriser can be fast. A splat’s contribution at 3σ is e^(−4.5) = 0.0111, and an 8-bit pixel cannot represent a difference smaller than 1/255 = 0.0039. Discard every Gaussian whose α at a pixel is below 1/255 and the pixel is wrong by less than one unit of colour — which is why real implementations cull at a threshold instead of evaluating a million tails.

The projection lab

A 3D Gaussian is an ellipsoid in camera space. Projecting it is one matrix product — Σ′ = J W Σ Wᵀ Jᵀ — and the eigenvectors of Σ′ are the axes of the ellipse the rasteriser will splat. Move it, turn it, and zoom the lens.

Σ′ = [[ 6401.56, 0.78 ], [ 0.78, 900.39 ]] screen centre (100.0, 50.0) px axes σ′₁ 80.01 px σ′₂ 30.01 px axis angle 0.01° area 7542 px² mass inside 1σ 39.3% 2σ 86.5% 3σ 98.9% sanity checks f·σx/z = 80.0 px f·σy/z = 30.0 px the z terms in J are what tilt the ellipse off the screen axes. Double the distance and the axes halve: z 4.0 → 8.0 σ′₁ 80.0 → 40.0 px area 7542 → 1886 px²

The projection is a first-order (affine) approximation of the true perspective warp, which is why a Gaussian is never exactly a Gaussian on screen — a detail the Mip-Splatting line of work exists to fix.

Why is a projected Gaussian still a Gaussian?

It is not, exactly — and knowing where the approximation lives is what separates a working renderer from one that shimmers when you zoom. Perspective division is nonlinear, so the image of a 3D Gaussian under the true projection is a slightly skewed bell, not an ellipse. The first-order expansion Σ′ = JΣJᵀ is exact only when the Gaussian is small compared with its distance from the camera — a “local affine” approximation. When a Gaussian gets close to the camera, or grows to cover a large part of the screen, the assumption breaks: the correct footprint is wider (the tails get stretched), and the renderer under-samples it. That shows up as aliasing and popping when the camera moves toward a surface. The fix in the literature is to filter the splat to the pixel grid — Mip Splatting adds a 2D screen-space filter and a 3D smoothing term — or to keep the Gaussians small enough that the approximation never has to work hard. Both are worth knowing before you conclude your training run is at fault.

Quick check

A Gaussian moves from 2 m to 8 m away from the camera. Nothing else changes. What happens to its screen-space footprint?

TILE, SORT, COMPOSITE

No rays. Just tiles,
a depth sort and a blend.

Rendering a 3DGS scene is five steps: project, bin to 16 × 16 tiles, depth sort per tile, alpha composite front-to-back, write the pixel. It is the same class of operation a GPU has been doing for opaque triangles since the 1990s — which is exactly why it is fast.

The key move is to stop thinking about rays. A NeRF asks “what is along this ray?” and walks the ray. A splat renderer asks “which primitives can this pixel see?” and gathers them. Gathering is what rasterisers do, and it maps onto the GPU’s execution model without translation.

  1. Project. Every Gaussian’s centre and covariance go through Σ′ = J W Σ Wᵀ Jᵀ (chapter 03), producing a 2D ellipse and a depth — the centre’s distance along the camera’s viewing axis.
  2. Bin. The screen is cut into 16 × 16 pixel tiles. A 1080p frame is 1920 × 1080, i.e. 120 × 68 = 8160 tiles. Each Gaussian is appended to every tile its ellipse overlaps — a cheap 2D bounding-box test.
  3. Sort. Each tile sorts its own list by depth, nearest first. The sort is per tile, not global: two splats at different depths never need a definite order unless they share a pixel.
  4. Composite. Each pixel walks its tile’s list, accumulating colour and transmittance — the blend below.
  5. Write. The accumulated colour and opacity replace the pixel. Nothing about this step knows or cares that the primitives were Gaussians.

Why tiles at all? Locality. A tile is small enough that its entire working set fits in registers and shared memory, so the per-pixel loop never touches global memory. And because splats overlap, a Gaussian that covers a 80 × 30 pixel ellipse is already counted in ceil(80/16) × ceil(30/16) = 5 × 2 = 10 tiles — the tiling turns one big primitive into ten small, cacheable jobs. The cost of the tiling is one list entry per (splat, tile) pair, which is the rasteriser’s real per-frame working set; the parameter store in chapter 02 is the persistent cost.

per pixel, walking the tile's list front-to-back αᵢ = σᵢ · exp( −½ dᵢᵀ Σ′ᵢ⁻¹ dᵢ ) local contribution Tᵢ = Π_{j < i} (1 − αⱼ) transmittance on arrival C = Σᵢ cᵢ · αᵢ · Tᵢ composited colour and what happens to the rest of the light A = 1 − Πᵢ (1 − αᵢ) the pixel's accumulated opacity culled: skip any αᵢ < τ, τ ≈ 1/255 a pixel loses ≤ 0.4% of one level

Three splats at one pixel, with α = 0.6, 0.5, 0.2 in front-to-back order, does the whole thing on one line of arithmetic per splat. The first arrives with full transmittance and contributes 0.6; the second arrives with T = 1 − 0.6 = 0.4 and contributes 0.5 × 0.4 = 0.2; the third arrives with T = 0.4 × 0.5 = 0.2 and contributes 0.2 × 0.2 = 0.04. Total opacity A = 0.6 + 0.2 + 0.04 = 0.84, and the residual 1 − 0.84 = 0.16 is exactly (1 − 0.6)(1 − 0.5)(1 − 0.2) — the light that made it through the stack to the background. With red, green and blue colours for the three splats the premultiplied pixel comes out (0.60, 0.20, 0.04), and the lab prints the same table for whatever pixel you click.

Look closely at that equation and you will recognise the previous lesson. NeRF’s volumetric render composites C = Σ Tᵢαᵢcᵢ over samples along a ray; 3DGS composites the identical expression over explicit primitives. The difference is the sampling: NeRF’s samples are dense and implicit (192 per ray, chosen by a coarse pass), 3DGS’s are sparse and explicit (only the Gaussians that actually cover the pixel, already sorted). Nothing in the physics changed, which is why the two land in the same quality band — and why every intuition you have about transmittance carries over.

The 2D splat rasteriser

Seven anisotropic Gaussians, composited front-to-back per pixel. Drag (or use the sliders) to move the probe pixel and read its α table — α, transmittance T, and the weight w = α·T each splat actually earns. Turn the depth sort off and watch the overlaps change.

probe (0.42, 0.44) order sorted, nearest first opacity 0.832 (1 − T) residual T 0.168 composited rgb 0.676, 0.403, 0.358 1 scarlet α 0.682 T 1.000 w 0.682 2 mint α 0.322 T 0.318 w 0.103 3 azure α 0.143 T 0.216 w 0.031 4 amber α 0.003 T 0.185 w 0.000 culled … 3 more rows on the canvas α is per splat; w is what survives the ones in front of it. The residual is the background showing through — the same 1 − Π(1 − αⱼ) that NeRF calls transmittance.

Opacity moves the whole field, scale grows every ellipse, rotation spins every ellipse, and τ drops the splats that no longer contribute. The image is composited over a transparent backdrop — the checkered square is the buffer’s own alpha channel.

One more property makes the whole training story work: every step above is differentiable with respect to the Gaussian parameters. The projection is a matrix product, α is an exponential of a quadratic form, the composite is a sum of products, and the SH evaluation (chapter 05) is a dot product. Given a photograph, take the difference in pixel space, backpropagate through the rasteriser, and update (μ, q, s, α, c_lm) — all 59 floats per Gaussian. There is no sampling step to differentiate around, no ray-marching discretisation: the renderer is the network.

The entire training forward pass, in shape notationpython
# G Gaussians, one image of H x W, camera W (extrinsics) and f (focal)
mu_2d, cov_2d = project(mu, sigma, W, f)        # (G, 2), (G, 2, 2)
tiles         = assign_tiles(mu_2d, cov_2d)     # (G, T) overlap lists
order         = depth_sort(tiles, z_cam)        # per tile, nearest first
colour        = eval_sh(sh_coeffs, view_dir)    # (G, 3)  <- chapter 05
image, alpha  = rasterise(mu_2d, cov_2d, colour, opacity, order)

loss = 0.8 * l1(image, photo) + 0.2 * (1 - ssim(image, photo))
loss.backward()   # -> mu, q, s, opacity_logit, sh_coeffs, and nothing else

# the optimiser never sees a mesh, a depth map, or a 3D label:
# the only supervision in the whole system is the posed photograph.
This is the reference implementation's loss (an L1 term plus a structural-similarity term at 0.2 weight) in the shape notation of the lesson. Every line is differentiable; the tile assignment and the sort are discrete but they only choose which splats contribute, and the weights themselves remain smooth.
Quick check

Three splats cover one pixel, in front-to-back order, with α = 0.5, 0.4 and 0.25. How much does the third one contribute, and how much light survives to the background?

COLOUR DEPENDS ON THE VIEW

Store the colour as a function
of the viewing direction.

A Gaussian’s colour is not one RGB triple. It is a short function on the sphere — spherical harmonics — evaluated against the direction from the pixel to the Gaussian’s centre. Sixteen coefficients per channel, forty-eight floats per splat, and specular highlights fall out for free.

Walk around a real object and its appearance changes: the diffuse part stays roughly steady, the specular part slides across the surface, and metal and varnish do not behave the same way as paper. A single RGB value per Gaussian cannot express any of that — every Gaussian would look like flat paint. The cheapest fix that fits in the same data-oriented renderer is to store the colour as a function of direction, in a basis that is smooth, cheap to evaluate and small enough to keep per Gaussian.

That basis is the spherical harmonics: the Fourier series of the sphere. Basis function Y_lm is indexed by a degree l and an order m, and for each degree there are 2l + 1 orders, so everything up to degree L is 1 + 3 + 5 + … + (2L+1) = (L+1)² functions. Degree 0 is a constant; degree 1 is a linear ramp (a dipole); degree 2 is quadratic; degree 3 — the 3DGS default — is (3 + 1)² = 16 functions per channel. Three channels, so 16 × 3 = 48 floats. That is the entire mechanism: learn one coefficient per basis function per channel, and evaluate a dot product at render time.

BASIS FUNCTIONS PER COLOUR CHANNEL · (L + 1)²L01 · constantL14 · linearL29 · quadraticL316 · defaultWHAT CHANGES AS YOU ORBITflat coloura ramp across the surfacea soft highlighta highlight that movesper-channel counts × 3 channels = the colour floats: 3 → 12 → 27 → 48
Spherical harmonics are the Fourier basis on the sphere: the same idea as the positional-encoding bands from the NeRF lesson, wrapped around a direction instead of a line. Truncating at degree L keeps (L + 1)² functions per channel, and 3DGS’s default L = 3 keeps 16 — enough for Lambertian shading plus one specular lobe, at 48 of the 59 floats each Gaussian stores.
degree L(L+1)² per channelcolour floats (×3)floats per Gaussianbyte costwhat the colour can do
0131456 Bone flat colour per Gaussian
14122392 Ba linear ramp — diffuse-ish shading
292738152 Bquadratic lobes — soft highlights
3164859236 Bspecular and mild reflection — the default

Evaluation is one dot product per channel. At degree 1 the basis is short enough to write out in full, and it is worth doing once so the numbers are not a mystery. With d = (x, y, z) the unit view direction and c = (c₀, c₁, c₂, c₃) the learned coefficients for one channel:

the degree-1 basis, with the reference constants Y₀ = C₀ = 0.282095 = 1 / (2√π) Y₁ = −C₁ · y C₁ = 0.488603 = √(3 / 4π) Y₂ = +C₁ · z Y₃ = −C₁ · x value(d) = C₀c₀ − C₁·y·c₁ + C₁·z·c₂ − C₁·x·c₃ one Gaussian, coefficients c = (1.0, 1.0, 0.5, 0.25) direction raw value sigmoid(value) +z · front 0.526396 0.629 +x · right 0.159944 0.540 +y · above −0.206508 0.449 the same four numbers, three directions, a 40% relative swing in one colour channel — that is the view dependence.

Notice the sigmoid in the third column. 3DGS squashes the evaluated value into [0, 1] before it becomes a colour, exactly as it squashes opacity: the coefficients live on the whole real line and the optimiser is free to move them anywhere. If you want to see a three-channel version of the same arithmetic, the lab’s viewing direction is a unit vector and every channel has its own 16 coefficients — the code below is the full degree-3 evaluation from the lesson’s reference implementation.

Degree-3 spherical harmonics, all 16 basis functionspython
import torch

C0 = 0.282094791773878
C1 = 0.488602511902920
C2 = [1.092548430592079, 1.092548430592079, 0.315391565252520,
      1.092548430592079, 0.546274215296039]
C3 = [0.590043589926644, 2.890611442640554, 0.457045799464465,
      0.373176332590115, 0.457045799464465, 1.445305721320277,
      0.590043589926644]

def sh_degree_3_basis(dirs):
    x, y, z = dirs[..., 0], dirs[..., 1], dirs[..., 2]
    x2, y2, z2 = x * x, y * y, z * z
    xy, yz, xz = x * y, y * z, x * z
    return torch.stack([
        torch.full_like(x, C0),                              # l = 0
        -C1 * y, C1 * z, -C1 * x,                            # l = 1
        C2[0] * xy, C2[1] * yz,
        C2[2] * (2 * z2 - x2 - y2), C2[3] * xz,
        C2[4] * (x2 - y2),                                   # l = 2
        -C3[0] * y * (3 * x2 - y2), C3[1] * xy * z,
        -C3[2] * y * (4 * z2 - x2 - y2),
        C3[3] * z * (2 * z2 - 3 * x2 - 3 * y2),
        -C3[4] * x * (4 * z2 - x2 - y2),
        C3[5] * z * (x2 - y2),
        -C3[6] * x * (x2 - 3 * y2),                          # l = 3
    ], dim=-1)                                               # (..., 16)

def eval_sh_degree_3(sh_coeffs, dirs):
    basis = sh_degree_3_basis(dirs)                          # (..., 16)
    return torch.einsum("...b,...bc->...c", basis, sh_coeffs) # (..., 3)
This is the code file's basis and evaluation. The coefficients are learned by gradient descent alongside position, rotation, scale and opacity; the basis functions themselves are fixed constants. Evaluating one Gaussian's colour for one camera is 16 multiply-adds per channel.
How does the DC term get initialised?

Reconstructing a scene usually starts from the coloured points that structure from motion produced, so each new Gaussian knows roughly what colour it should be. The reference implementation converts that RGB into a DC coefficient with (rgb − 0.5) / C₀. Two things are going on. Dividing by C₀ inverts the basis function itself, so the DC term evaluates back to rgb − 0.5. Subtracting 0.5 centres that value on zero, which is where the sigmoid is most responsive — a colour of 0.5 maps to exactly 0.5, and both directions have room to move. It is a good starting point, not an exact match: for an initial rgb = 0.8 the sigmoid output is 1/(1 + e^(−0.3)) = 0.574, and the first few hundred iterations of training close the gap. Initialisation, not specification.

Quick check

You drop a scene from SH degree 3 to degree 2 to save memory. How many floats per Gaussian does that save, and what does the Gaussian end up carrying?

GRADIENT DESCENT ON SPLATS

Photographs in.
A cloud of Gaussians out.

Training never sees a mesh, a depth map or a 3D label. The only supervision is a rendered image against a photograph — and the gradient flows through the rasteriser into every Gaussian’s position, shape, opacity and colour. The cloud grows itself by cloning, splitting and pruning.

The loop is the same one the previous lesson established: render, compare, backpropagate. What changes is what the gradient reaches. In a NeRF it lands on the MLP’s weights; here it lands on 59 floats per Gaussian, and the number of Gaussians is itself an output of training. The loss is the reference implementation’s composite of an absolute-error term and a structural-similarity term:

loss(render, photo) = 0.8 · L1(render, photo) + 0.2 · (1 − SSIM(render, photo)) iteration 0 ~100k Gaussians seeded from the SfM sparse points every iteration render one view, backprop, Adam step on all 59 floats every 100 iters densify: clone small high-gradient Gaussians, (500 → 15,000) split large high-gradient Gaussians, prune anything with opacity < 0.005 every 3,000 iters opacity reset: every Gaussian's opacity is pushed near zero, and the run re-learns which ones matter iteration 30,000 stop: 1–5M Gaussians, 10–30 min on an RTX 4090

Why two loss terms? L1 is robust and direct — it measures the absolute colour error and does not punish a few large mistakes as brutally as L2 does — but it is nearly blind to structure: a blurred render and a sharp one can have similar per-pixel error. SSIM compares local windows (mean, variance, covariance) and is high when edges line up, so the 0.2 term pushes the cloud toward crisp structure while the 0.8 term keeps the colours honest. The pairing is not unique to 3DGS; it is the standard recipe in the NeRF literature too.

ONE TRAINING RUN · 30,000 ITERATIONS · 10–30 MIN ON AN RTX 4090densify every 100 iterations · clone, split, prunerefine only3k6k9k12k15k030k▲ opacity reset — every Gaussian’s opacity drops near zero, then the run re-learns which ones matterthe seed cloud (~100k Gaussians from SfM points) grows to 1–5M by iteration 15,000
The reference implementation’s recipe, which every 2026 trainer follows in spirit: geometry is decided early — densification is over by the halfway point — and the remaining iterations are spent sharpening colours, opacities and shapes. The periodic opacity reset is the anti-fossil measure: it lets Gaussians that have become redundant fade out instead of being stuck at a stale value.

Densification is the mechanism that lets a fixed-size seed become a scene. Each Gaussian carries a gradient magnitude for its position — the training signal’s opinion about how badly it needs to move. Where that magnitude is high, the region is under-reconstructed, and the fix depends on the Gaussian’s current size:

  • Clone — high gradient, small scale. The Gaussian is in the right place but cannot resolve the detail alone, so duplicate it and let the copy take a share of the region.
  • Split — high gradient, large scale. One big soft blob is covering structure it cannot represent, so replace it with two smaller Gaussians positioned along its long axis.
  • Prune — opacity has decayed below 0.005, or the Gaussian has grown oversized in world space and no longer contributes meaningfully. Remove it and its parameters.

The growth is dramatic and it is the honest cost of the method. A run that starts with 100,000 seeded Gaussians commonly finishes with 1–5 million — a 10–50× increase in the parameter store over the run. In bytes, using the 236-byte record from chapter 02: the seed is 100,000 × 236 B = 23.6 MB, and the final scene is 236 MB to 5,000,000 × 236 B = 1.18 GB. Add the optimiser’s four copies and the training peak moves from 94 MB to between 944 MB and 4.72 GB. That is why 3DGS runs are described in terms of VRAM as much as in minutes, and why the same scene trained at a lower SH degree is a different engineering decision.

Densify and prune, the reference implementation's rulespython
# every 100 iterations, between 500 and 15,000, in the reference trainer
grad = position_gradient_norm.mean(dim=-1)          # (G,)
large = max_scale > percent_dense * scene_extent    # 0.01 * scene radius

clone = (grad >= 0.0002) & ~large    # high signal, small Gaussian -> duplicate
split = (grad >= 0.0002) &  large    # high signal, big Gaussian   -> two smaller

# clones copy the parent and step it along its mean gradient
# splits are divided by 1.6 and displaced by a fresh sample drawn with the
# parent's standard deviations along its principal axes
prune = (sigmoid(opacity) < 0.005) | (max_scale > 0.1 * scene_extent)

# plus: every 3,000 iterations, reset every opacity to sigmoid(-4.6) ~ 0.01,
# so a Gaussian that stopped contributing must earn its opacity back.
These are the reference implementation's constants, not the paper’s prose: 0.0002 for the gradient threshold, 0.01 of the scene extent for 'large', 0.005 for the prune opacity, 100 iterations between densification steps and 3,000 between opacity resets. Every modern trainer keeps the same three verbs.
Why does the seed set the final quality ceiling?

Training only moves Gaussians that exist; densification only clones or splits them. If structure from motion produced no points in a region — a wall seen from one angle, a textureless floor, the inside of a cabinet — the initialisation there is empty, and the only mechanism that can fill it is a clone from a neighbouring Gaussian whose gradients happen to be high. That works well next to existing structure and badly in a vacuum; the result is a stretched, smeared patch or a floater. It also explains a counter-intuitive rule of thumb: more photographs of the same viewpoint add little, because they produce the same poses and the same points. Coverage is what matters — different angles seeing the same surfaces, which is why the capture guidance is about overlap and arc, not resolution. And it explains why the last 15,000 iterations are refinement: by then the topology is set, and no amount of gradient descent can invent a surface the seed never saw.

THE STACK THAT SHIPS

Twenty to fifty photos.
One command. A portable asset.

In 2026 you do not write a rasteriser to ship a splat scene. You run a three-command pipeline, clean the result in an editor, and export to glTF or OpenUSD so it outlives the tool that made it — while knowing exactly which limits you are accepting.

The practical workflow has not changed much since the paper’s release, and that is a good sign: the community converged on a pipeline instead of a stack of incompatible research scripts. Shoot the scene. Recover the camera poses. Train. Clean. Export. View.

The whole pipeline, from photos to a glTF assetpython
pip install nerfstudio gsplat

# 1. structure from motion: run COLMAP, write the poses + sparse cloud
ns-process-data images --data photos/ --output-dir data/

# 2. train: splatfacto is nerfstudio's 3DGS trainer, 30k iterations
ns-train splatfacto --data data/            # 10-30 min on an RTX 4090
#    the viewer streams the run while it trains

# 3. export the Gaussians (and a mesh, if you need geometry for physics)
ns-export gaussian-splat --load-config outputs/.../config.yml --output-dir exports/

# 4. optional: clean and quantise in a browser editor, then serve
#    SuperSplat -> .splat (32 B/splat) -> any glTF/Three.js viewer
splatfacto's defaults are the paper's recipe: 30,000 iterations, the L1 + D-SSIM loss, densification until 15,000 iterations and periodic opacity resets. The capture stage is where the run is won or lost.

Capture advice, in the order it matters. Photograph a static scene — anything that moves ruins the multi-view geometry. Keep 60–80% overlap between neighbouring views, and shoot an arc or a spiral rather than orbiting from one spot so that every surface is seen from several angles. Lock exposure, aperture and focus if you can: auto-exposure makes the same surface a different colour in different images and the optimiser will faithfully bake that inconsistency into the SH. 20–50 photographs is the working range for an object or a room; an outdoor scene is 100–300. Matte, textured surfaces reconstruct best; glass, chrome, plain white walls and foliage are the hard cases.

export formatsizewho reads ithonest note
.ply236 B per splat (float32)research, gsplat, the Inria repo, Blender add-onsthe interchange lingua franca; largest file, but everything reads it
.splat32 B per splat (quantised)SuperSplat, Three.js viewers, web and mobilefloat32 position and scale, uint8 colour and rotation — no SH at all
glTF KHR_gaussian_splattingthe same payload, standard containerBabylon.js, Three.js, Cesium, Unreal, enginesrelease candidate February 2026, ratified 2026 — the Khronos standard that made splats portable
OpenUSD 26.03UsdVolParticleField3DGaussianSplatOmniverse, Vision Pro pipelines, studio USD scenesa first-class USD schema, so splats compose with normal 3D scenes

Which tool? The differences are small in 2026 and you can change your mind later, because the asset is a list, not a network. In practice: gsplat (the CUDA rasteriser and training kernels); nerfstudio · splatfacto (the end-to-end recipe); Inria reference implementation (the original 2023 code); SuperSplat (the editor); PostShot / SplatForge (commercial front ends). The rasteriser matters when you train, the editor matters when you ship, and the export format matters forever.

Photos to portable splats

The whole production path, in the order it runs. Click a stage to see what goes in, what comes out, which tool does it, and the one number that decides whether it works.

01 · Photograph the scene

Stage 1 of 6 · 5–15 min of shooting

inputa phone, a drone, or a handheld scanner; a scene that is not moving
output20–50 overlapping photographs (100–300 for an outdoor scene)
toolany camera with a fixed exposure and focus
the numbers
  • 20–50 images · object or room
  • 60–80% overlap between neighbours · every point seen by ≥ 3 views
  • 100–300 images · street or building
  • lock exposure, aperture and focus — auto-exposure changes the scene

Watch out: Shiny, transparent and textureless surfaces break the next step: structure from motion needs features it can find in more than one image.

.ply472 MB
.splat64.0 MB

.ply 472 MB · .splat 64.0 MB · glTF KHR_gaussian_splatting and OpenUSD 26.03 UsdVolParticleField3DGaussianSplat both carry the same numbers inside a standard container.

pipeline ▸ 01 capture 5–15 min of shooting · 02 SfM 5–20 min for 20–50 photos · 03 initialise seconds · 04 train 10–30 min on an RTX 4090 (the paper's own runs are longer) · 05 edit minutes to an hour of hand work · 06 export seconds to minutes current stage capture input a phone, a drone, or a handheld scanner; a scene that is not moving output 20–50 overlapping photographs (100–300 for an outdoor scene) tool any camera with a fixed exposure and focus final scene 2.0M Gaussians raw export 472 MB quantised 64.0 MB training peak 1.89 GB (4×) photos 20–50 for an object or room, 100–300 outdoors.

The two stages that fail most often are the two you cannot see: structure from motion (a bad run produces plausible-looking garbage) and densification (the scene grows until memory runs out). Check both before you blame the renderer.

The honest limits

Memory grows with detail. Storage is the paper’s own headline limitation. A room can be 2M Gaussians (472 MB raw, 64 MB quantised) and a street 5M (1.18 GB raw) before anyone complains about fidelity; training adds a 4× multiplier. There is no quality dial that does not move bytes.

Floaters. Gaussians in empty space look like dust hanging in the air, and they are usually evidence rather than error: the optimiser put opacity where the photographs could not disagree with it. They are removed by hand in an editor, or reduced by better capture, or both. Counting them is a useful quality metric: a scene with hundreds of floaters has an under-constrained region somewhere, and moving the camera near it will show you where.

No collision geometry. A cloud of translucent blobs has no surface, no inside and no volume you can query robustly. A physics engine, a path tracer and a CNC machine all need something a splat is not. Hybrid pipelines extract a surface — from the same capture with photogrammetry, or from a depth model — and keep the splats for appearance. Plan for both deliverables if a simulator is in the loop.

Baked lighting. As chapter 05 warned, SH stores what you saw, so relighting means re-rendering with new appearance models, which mostly means research code. If the client says “put it in the engine and light it with our sun”, the answer is a mesh, not a splat.

Resolution and aliasing. The projection is a first-order approximation (chapter 03), so a splat seen very close, or a scene rendered far below its capture resolution, can shimmer or pop. Mip-Splatting-style filtering and keeping splats at a sane size both help. Check any asset at the extremes of the framings you intend to ship, not only in the orbit the demo uses.

CHECK YOURSELF

Five questions.
Then the terms worth keeping.

Answer before you look. The parameter-budget question, the densification question and the compositing question are the three that separate a memorised pipeline diagram from a mechanism you can debug on a new scene.

0 / 5 answered · 0 correct

01Why did 3D Gaussian splatting largely replace NeRF as the production default for photorealistic scene reconstruction by 2026?

02A 3D Gaussian in a scene carries position, rotation, scale, opacity, and what additional representation to handle view-dependent colour such as specular highlights?

03During 3DGS training, densification includes 'clone' and 'split' operations. What triggers each?

04The colour equation for one pixel in both NeRF and 3DGS is `C = Σ αᵢ Tᵢ cᵢ` where `Tᵢ = Π_{j<i} (1 − αⱼ)`. What does this shared equation say about the two methods?

05You want to ship a 3DGS scene across Unreal Engine, Vision Pro, Blender and a Three.js web viewer in 2026. Which export format is the safest bet?

Key terms, demystified

Click a card to swap the lazy description for what it actually means.

Exercises from the lesson

Three problems with exact numbers — train the 2D splats and find where more of them stops helping, give a Gaussian view-dependent colour, and run the real pipeline on twenty photographs. Try first; a worked answer is one click away.

  1. (Easy) Train the 2D splat model from the lesson on a synthetic image with two shapes, with num_splats in {16, 64, 256}. Plot MSE against step for each and find the point of diminishing returns.
    Show one worked answer

    Build the target exactly as the code file does — a red disc plus a blue square on a white 48 × 48 canvas — then run the trainer for 300 iterations at each splat count and print the loss every 50 steps. Expect a clear ladder. With 16 Gaussians the run bottoms out around MSE 0.02: two shapes want on the order of ten splats each, so 16 is enough to cover the mass but not the edges, and the loss curve flattens early because the capacity is spent. With 64 splats the loss settles in the low 10⁻³: the disc edge and the square corners get their own primitives. With 256 it reaches roughly 0.001–0.002 — four times the parameters for a factor of two in MSE, which is the diminishing return the exercise is after. The reading is that a fixed image is a *memorisation* problem, so more splats always help a little: the honest stopping point is where the marginal decrease stops mattering for the use case (an MSE of 0.002 is already invisible at 8 bits), not where the curve is flat. Two traps worth naming: setting the learning rate too high (above ~0.1) makes the means oscillate and the loss floor rises as splats are added, and skipping the depth parameter leaves overlapping splats with no defined order, so the same parameter set renders two different images depending on array order — the 2D case needs its learned scalar depth exactly as the 3D case needs camera-space z.

  2. (Medium) Extend the 2D rasteriser so each Gaussian's colour depends on a scalar view angle through a degree-2 spherical harmonic, then train on a pair of target images and verify the model reconstructs both.
    Show one worked answer

    Fix an orbit in the horizontal plane so the view direction is d(θ) = (cos θ, 0, sin θ). Substitute into the degree-2 basis and almost everything collapses: y = 0 kills the y terms, xz and xy vanish at the cardinal angles, and what survives is a Fourier series in θ — the DC term, cos θ via the x term, sin θ via the z term, and cos 2θ through (x² − y²) and (2z² − x² − y²). Degree 2 therefore carries 5 numbers per channel (DC, first harmonic in cosine and sine, second harmonic in cosine and sine) = 15 per Gaussian over RGB, 27 floats at 9 per channel. Training: render for a random θ each step, compare against whichever target belongs to that angle (θ = 0° and 180° is a classic specular pair), and backpropagate. With 64 Gaussians the pair reconstructs at roughly 35–40 dB PSNR — verify by rendering both angles and taking the L2 difference against the stored targets, which should be under 1e-3 in normalised units. Sanity checks that catch a wrong basis: a degree-0 model can only produce the average of the two images (both renders identical — the 28 dB floor); swapping the sign of the x coefficients mirrors the highlight to the wrong side; and a wrong normalisation constant (C₁ = 0.488603 versus 1) scales the highlight but leaves the DC colour right, which is exactly the kind of bug that survives a screenshot review.

  3. (Hard) Clone nerfstudio and train splatfacto on a 20-photo capture of any scene you have (desk, plant, room). Export to glTF KHR_gaussian_splatting and open it in a viewer. Report the training time, the Gaussian count and the rendered fps.
    Show one worked answer

    The reference run looks like this. Twenty photographs of a desk, shot as a 180° arc with about 70% overlap, go through `ns-process-data`, which reports 20 registered images and a sparse cloud in the tens of thousands of points — check both numbers before training, because a pose count below the image count means some photographs were dropped and the scene will have a hole where they pointed. `ns-train splatfacto` runs 30,000 iterations; on an RTX 4090 expect 12–25 minutes and a final count of 0.8–2M Gaussians for a single object, with the count climbing fastest in the first 5,000 iterations. The `ns-export gaussian-splat` PLY is 190–470 MB at 236 B per Gaussian; converting through SuperSplat's quantised format gives 26–64 MB at 32 B per splat. In a browser viewer (Three.js GaussianSplats3D or SuperSplat) the quantised asset renders at 60–150 fps on a laptop GPU. Report the honest quality number, not the training loss: hold out five photographs from training, render from those camera poses, and compute PSNR — expect 25–32 dB, lower than the training-view number by 1–3 dB, and expect the gap to be larger if the capture was narrow. Two things to watch: the official glTF validator will flag the extension as unknown if its version predates the schema — that is expected during the release-candidate window, not a broken export — and floaters are best diagnosed by orbiting to the edge of the capture arc, where under-constrained regions show themselves as hanging blobs to be deleted before export.

Terms this lesson borrows from later lessons (or outside)

You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.

  • covariance and eigen-decompositionΣ′'s eigenvectors are the screen-space ellipse's axes and the eigenvalues are the squared standard deviations — the same diagonalisation used for principal components and in the SVD. Phase 1, Lesson 11 (Singular Value Decomposition) and Lesson 06 (Probability and Distributions).
  • log-scale and sigmoid (numerical stability)Scales are stored as logarithms and exponentiated at render time; opacity and colour are stored as logits and squashed by a sigmoid. Both tricks give the optimiser the whole real line while guaranteeing a valid value. Phase 1, Lesson 13 (Numerical Stability) and Phase 3, Lesson 04 (Activation Functions).
  • Fourier basisSpherical harmonics are the Fourier series of the sphere: orthogonal basis functions, truncate at a degree and keep (L + 1)² coefficients. The NeRF lesson's positional encoding is the same idea on a line. Phase 1, Lesson 20 (The Fourier Transform) and Phase 4, Lesson 13 (3D Vision: Point Clouds & NeRFs).
  • structure from motion and camera poses3DGS is trained on posed photographs, so COLMAP or GLOMAP has to recover each camera's intrinsics and extrinsics and the sparse point cloud that seeds the Gaussians. No poses, no training signal. Phase 4, Lesson 13 (3D Vision: Point Clouds & NeRFs).
  • backpropagation and AdamThe photometric loss flows through the projection, the alpha compositing and the SH evaluation into all 59 floats per Gaussian; Adam keeps two moment estimates per parameter, which is where the 4× training-time memory multiplier comes from. Phase 3, Lesson 03 (Backpropagation from Scratch) and Lesson 06 (Optimizers).
  • quantisation for deploymentThe shipping format is a quantised record — float32 position and scale, uint8 colour and rotation, 32 bytes per Gaussian, spherical harmonics dropped entirely. The same compression arithmetic that governs edge inference. Phase 4, Lesson 15 (Real-Time Vision — Edge Deployment).
KEEP GOING

A picture is a start.
Practice is the rest.

This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.

Original lesson3D Gaussian Splatting from ScratchAI Engineering from Scratch · the source text, quiz and main.py: the 2D Gaussian evaluator with its einsum quadratic form, the front-to-back rasteriser with clamped alphas and argsorted depths, the Splats2D module with log-scale, rotation, colour logits and a learned depth, the degree-3 SH basis and evaluation, and the 300-step fit of 48 splats to a red circle plus a blue square.Original paper3D Gaussian Splatting for Real-Time Radiance Field RenderingKerbl, Kopanas, Leimkühler & Drettakis (SIGGRAPH 2023) · the whole method: explicit anisotropic 3D Gaussians with Σ = R S Sᵀ Rᵀ, degree-3 SH colour, the tile-based differentiable rasteriser with per-tile depth sorting, adaptive densification by clone and split with pruning, the 30,000-iteration schedule, and the headline numbers — 134–154 fps for 1–5M Gaussians on an RTX A6000, real time at 30+ fps at 1080p, and memory as the acknowledged limitation.Reference implementationgsplat — the CUDA rasteriser and training kernelsMeta/nerfstudio · the production-quality implementation everyone builds on: fused projection and SH evaluation kernels, the tile-based rasteriser with per-tile depth keys, and the densification and pruning utilities. The place to look when the paper's prose runs out and you need the exact constant.Reference workflownerfstudio — Splatfactodocs.nerf.studio · the practical recipe this lesson's last chapter walks through: `ns-process-data` runs COLMAP for poses and the sparse cloud, `ns-train splatfacto` fits the scene in 10–30 minutes on an RTX 4090, the viewer streams the run live, and `ns-export` produces the Gaussian-splat PLY. Capture: 20–50 photographs for an object or room with 60–80% overlap, 100–300 for an outdoor scene.StandardKHR_gaussian_splatting — the glTF extensionKhronos Group · the extension that makes a splat scene a portable asset: Gaussian positions, scales, rotations, opacities and SH coefficients in a glTF primitive — release candidate February 2026, ratified 2026, and implemented across engines, viewers and headsets. This is the file format that stops an asset dying with the tool that made it.StandardOpenUSD 26.03 — UsdVolParticleField3DGaussianSplatopenusd.org release notes · the USD-native schema for Gaussian splats, shipped with OpenUSD 26.03 and aimed at the Omniverse, studio and Vision Pro pipelines where splats have to compose with ordinary USD scenes. With the glTF extension, the second half of the 2026 standardisation that turned a SIGGRAPH method into production plumbing.

Lesson text adapted from AI Engineering from Scratch (Phase 04, Lesson 22) and the Math Foundations Notebook reference build. The five labs — the canvas 2D splat rasteriser with its per-pixel α table, the parameter-budget calculator with the SH degree ladder, the NeRF-versus-3DGS comparator, the canvas projection lab that follows one covariance from camera space to Σ′, and the capture-to-export pipeline stepper — are original to this page, as are the numbers they compute: the per-Gaussian float budget (3 + 4 + 3 + 1 + 48 = 59 floats = 236 B fp32, 14/23/38/59 floats across SH degrees 0–3, and the 4× training multiplier of 944 B per Gaussian); the projection worked examples (Σ′ = [[6401.56, 0.78], [0.78, 900.39]] with σ′ = 80.01 × 30.01 px, area 7542 px², and the foreshortened 64.06 × 29.95 px at a 45° yaw); the ellipse mass ladder (1σ 39.3%, 2σ 86.5%, 3σ 98.9%, α at 3σ = e⁻⁴·⁵ = 0.0111); the compositing example (weights 0.6, 0.2, 0.04, accumulated opacity 0.84, residual 0.16 = 0.4 × 0.5 × 0.8) and the QuickCheck arithmetic (0.075 and 0.225); the tiling arithmetic (1080p = 120 × 68 = 8160 tiles of 16 × 16, an 80 × 30 px ellipse straddling 10 tiles); the degree-1 SH example (0.526396, 0.159944, −0.206508, i.e. 0.629, 0.540, 0.449 after the sigmoid); the reference trainer's exact densification constants (gradient 0.0002, 0.01 × scene extent, prune opacity 0.005, densify every 100 iterations from 500 to 15,000, opacity reset every 3,000, loss 0.8·L1 + 0.2·(1 − SSIM)); and the storage table (1M = 236 MB, 2M = 472 MB / 64 MB quantised / 1.89 GB training, 3M = 708 MB, 5M = 1.18 GB / 160 MB quantised / 4.72 GB training). Every number shown is computed live by the labs or verified by hand in the prose.