3D vision comes in two flavours. Point clouds are the sensor’s raw output — N unordered points, no grid, no fixed N. NeRFs are the learned volumetric field — a network you can cast rays through to render a scene that no camera ever photographed. Both answer “what is where in space”, and choosing between them decides what the rest of your pipeline can do.
A point cloud, a mesh and a voxel grid store the geometry: you can loop over them, cut them, export them. An SDF and a NeRF store a function: ask for a coordinate and they answer. Ten centimetre voxels over a driving scene are 10⁸ cells = 400 MB per channel — which is why LIDAR stacks keep the raw points instead.
explicit lists · implicit answers · whose tool is next?02 / POINTNET: MAX-POOL THE ORDER AWAY
Same points, any order, same answer.
A shared MLP runs on every point — the same weights, independently — and a max pool over the point axis leaves one fixed-size vector. Max is symmetric, so the output cannot depend on the input order, and it works for 8,000 points or 250,000. 807,626 parameters is the whole classifier; the pool itself has none.
f(P) = max over p in P of MLP(p)03 / NERF: A FUNCTION, CAST BY RAYS
192 queries per ray, no 3D data.
The network maps (x, y, z, direction) to (density, colour). A pixel is 64 coarse samples, 128 fine samples drawn from the coarse weights, and a transmittance-weighted sum: C = Σ Tᵢ(1 − exp(−σᵢδᵢ))cᵢ. Training is that loop against photographs — 20–50 posed views, no geometry, 1–2 days on one V100 for the original.
C = Σᵢ Tᵢ αᵢ cᵢ · αᵢ = 1 − exp(−σᵢδᵢ)
MENTAL MODEL IN ONE SENTENCE
A point cloud is the sensor’s set — N unordered points with no grid — and a NeRF is the network’s function — density and colour for every coordinate — and the one you pick is decided by what the next tool in the pipeline can consume.
By the end you will be able to sort any 3D task into explicit (cloud, mesh, voxel) or implicit (SDF, NeRF) and defend the choice with the representation’s cost; explain why PointNet’s shared MLP plus max pool is permutation-invariant and count its 807,626 parameters; trace a NeRF pixel from camera pose through 64 coarse and 128 fine samples to the transmittance-weighted composite; read the positional encoding (3 + 3 × 2 × 10 = 63 inputs, 512 cycles at the top band, 2 mm detail in a 1 m scene); compute Σw, the leftover transmittance and the depth for a ray by hand; and say out loud what a NeRF cannot give you — a mesh, a relightable material, or a static scene you can edit.
01
TWO FLAVOURS OF 3D
Five representations. Two sides of one line.
Every 3D pipeline answers “what is where in space” with one of five data structures. Three of them list the geometry; two of them only answer questions about it. That single difference decides which tools can consume the result.
A camera gives you a 2D image. A LIDAR gives you a set of 3D points with no ordering. A structure-from-motion pipeline gives you a sparse cloud of keypoints and a camera pose per photo. A NeRF gives you a network that can render a scene it has never seen a photo of. All four are “vision”, and none of them is the dense grid a CNN wants — so before any model, you have to choose a representation.
Explicit representations store the geometry. A point cloud is a list of N points. A mesh is a list of triangles with shared vertices. A voxel grid is a 3D image: a regular lattice of cells, each holding “occupied” or a feature vector. You can iterate over all three, count their elements, cut them, and export them.
Implicit representations store a function. A signed distance field (SDF) takes a point and returns the distance to the nearest surface, negative inside. A NeRF takes a point and a viewing direction and returns a density and a colour. Neither has a list of surfaces: the surface is where the function crosses zero (SDF) or where the density spikes (NeRF). You can ask them anything, but you cannot read them off.
explicit · you can iterate over it implicit · you can only query it
point cloud N × 3 floats + features SDF f(x, y, z) → distance
mesh vertices + triangle indices NeRF f(x, y, z, θ, φ) → (σ, r, g, b)
voxel grid occupancy on a lattice splat a cloud of Gaussians (hybrid)
the same chair, five ways, with the cost of the representation
cloud ~8,000 points × 16 B = 128 KB
mesh 2,000 verts × 24 B + 12,000 indices × 4 B = 96 KB
voxel 256³ cells at 1 byte = 16.8 MB (almost all empty)
SDF a function, or a 256³ lattice of float32 = 0 B or 67 MB
NeRF ~596k weights per trunk, float32, two trunks = 4.8 MB
The costs are not decoration, they are the decision. Voxelising a driving scene at 10 cm resolution over 100 m × 100 m × 10 m is 1000 × 1000 × 100 = 10⁸ cells, which is 400 MB per float32 channel — per frame, at 10 Hz, and almost all of it empty air. That single arithmetic is why autonomous-driving stacks keep the point cloud and run point-cloud networks on it instead of voxelising; voxel grids appear where the data already arrives that way (a CT scanner) or where the scene is small (a tabletop).
The same arithmetic runs the other way for capture. A NeRF’s entire scene is about 5 MB of network weights — two trunks at 596k float32 parameters each — where the paper’s local light-field baseline stores images and poses in the gigabytes (15 GB in its own comparison). Compression is what implicit representations are good at: the scene became a function, and a function is small.
The representation chooser
Pick a task. The answer is decided by two things and nothing else: what the sensor hands you, and what the next tool in the pipeline needs.
the task
self-driving car · obstacle detection at 10 Hz
input a 64-beam LIDAR sweep: ~120,000 unordered points, every 100 ms
deliverable boxes and free-space estimates for the planner, in the same 100 ms
PICK POINT CLOUD · explicit
This is the sensor's native output and the model must handle it directly, because any conversion costs the frame's time budget. PointNet-family networks eat the N × 3 tensor as-is, are permutation-invariant by construction, and tolerate a different N every sweep.
the alternative
Voxelise first: 10 cm voxels over a 100 × 100 × 10 m box is 10^8 cells ≈ 400 MB per channel, per frame — and 99.9% of them are empty air.
what point cloud stores
N unordered (x, y, z) triples, plus optional colour or intensity
cost model ≈ 16 B per point (x, y, z, intensity as float32): a 120,000-point sweep is ~1.9 MB
everyone else PointNet-family nets, ICP registration, clustering, any LIDAR stack
The five representations, and which side of the line they sit on.
representation
explicit / implicit
you can ask it for
point cloud
explicit
PointNet-family nets, ICP registration, clustering, any LIDAR stack
triangle mesh
explicit
GPUs, Blender, game engines, CAD, physics solvers, 3D printers
voxel grid
explicit
3-D convolutions, medical pipelines, occupancy prediction
novel-view renderers, viewers, thumbnails — not an editing or CAD tool
The line that matters is not “old vs new”: it is whether the representation lists its geometry (point cloud, mesh, voxel) or answers questions about it (SDF, NeRF). Explicit formats are what editing tools eat; implicit ones are what sensors and gradient descent produce.
Two more pieces of vocabulary will come up all lesson. View dependence means the colour a point emits depends on the direction you look at it from — exactly what makes a NeRF render a glossy tabletop that brightens as the camera moves, and exactly what a textured mesh cannot do without extra material data. And posed images means photographs plus the camera position and orientation for each one: the one input radiance fields insist on, and the reason a structure-from-motion step runs before any training.
02
N × 3, UNORDERED, ANY N
The sensor hands you a set, not an image.
A point cloud is the raw output of every depth sensor: a list of N points in space with no grid, no connectivity, and no promise that the two clouds you compare have the same number of rows.
Written as a tensor, a cloud is (N, 3) — N points, three coordinates each — often with extra per-point channels like colour, intensity or a surface normal. A 64-beam LIDAR sweep is about 120,000 points; at 16 bytes per point (three float32 coordinates plus intensity) that is 1.9 MB per sweep, or 19 MB/s at 10 Hz. A depth camera gives tens of thousands of points per frame. A structure-from-motion reconstruction gives thousands of sparse keypoints.
None of these are a 2D image, and the reason is not the shape of the tensor — (N, 3) could be reshaped. The reason is that a convolution is defined by a grid: a kernel slides over a regular arrangement, and every output cell sees the same pattern of neighbours. A point cloud has no regular arrangement at all. Its neighbours are whichever points happen to be near in space, and that relationship changes with every sweep.
The same object in three explicit forms. Conversion between them is cheap-looking and expensive in practice: meshing a scan needs surface reconstruction, and voxelising a sweep multiplies the element count by a hundred million — which is why the representation you start from tends to be the one you keep.
Two properties make a cloud awkward for a neural network, and they are worth separating because PointNet solves only one of them.
Permutation invariance. The order of the points carries no information — a LIDAR has no “first” return, and two scans of the same wall can list it in any order. The output for a chair must be identical whether the chair’s 8,000 points arrive shuffled or not. Equivalently: the function you learn must be symmetric in its inputs.
Variable N. One model must handle 8,000 points from a phone and 250,000 from a LIDAR. A fixed-size input layer cannot; an aggregation that reduces N to a fixed vector can.
Quick check
Why isn't 'sort the points, then feed them to a normal network' a fix for the unordered-input problem?
03
MAX-POOL MAKES ORDER IRRELEVANT
One shared MLP. One symmetric pool.
PointNet (Qi et al., 2017) made a neural network work on a raw point cloud with two lines of arithmetic: run the same small MLP on every point, then take the maximum down the point axis. Nothing in that operation can see the order the points arrived in.
The network is this:
f(P) = max over p in P of MLP(p) the whole of PointNet, in one line
shapes, with the source's architecture and a cloud of N points
input (N, 3) N × 3 coordinates
shared MLP (N, 64) → (N, 64) Conv1d(kernel = 1): same weights on every row
shared MLP (N, 128) → (N, 1024) features per point
max over N (1024,) one global feature, whatever N was
head 1024 → 512 → 256 → classes a normal classifier on top
Shared means what it says: one MLP, applied to every point independently, with no weights that depend on a point’s position in the list. In PyTorch it is implemented as a 1×1 convolution over the point axis (or a linear layer applied to the (N, 3) matrix), which is the same arithmetic in a form the GPU likes: Conv1d(3, 64, 1) treats the N points as a length-N “signal” with three channels.
Symmetric means the aggregation gives the same answer whatever order its inputs arrive in. A maximum is symmetric: max(a, b, c) = max(c, a, b). A sum is symmetric too, and both are used in the literature (max is the PointNet choice because it is robust to duplicate points and to outliers, and it keeps the strongest evidence for a feature rather than averaging it away). The result is one fixed-size vector — 1024 numbers — for a cloud of any size, in any order.
Worked check — a 3-point cloud through a 3 → 2 MLP by hand
Take three points, a two-output shared MLP with weights W = [[2, −1, 0.5], [0, 1, 1]] and bias b = [0.5, −1], and ReLU after the linear layer. The same matrix is applied to all three points:
p1 = (1, 0, −1): h = (2·1 − 1·0 + 0.5·(−1) + 0.5, 0·1 + 1·0 + 1·(−1) − 1)
= (2.0, −2.0) → ReLU → (2.0, 0.0)
p2 = (0, 1, 1): h = (0 − 1 + 0.5 + 0.5, 0 + 1 + 1 − 1)
= (0.0, 1.0) → ReLU → (0.0, 1.0)
p3 = (−1, 2, 0): h = (−2 − 2 + 0 + 0.5, 0 + 2 + 0 − 1)
= (−3.5, 1.0) → ReLU → (0.0, 1.0)
max down the columns: ( max(2.0, 0.0, 0.0), max(0.0, 1.0, 1.0) ) = (2.0, 1.0)
feed the points in the order p3, p1, p2 instead:
rows (0.0, 1.0), (2.0, 0.0), (0.0, 1.0) → same column maxima → (2.0, 1.0) ✓
the order changed, the tensor changed, the answer did not:
max is commutative and associative — that is the entire proof
Two details to notice. The max runs down the N axis, not across the feature axis: each of the 1024 features gets the strongest response any single point produced. And the network needs no normalisation of N — a cloud of 16 points and a cloud of 250,000 points both leave the pool with a 1024-vector, because the pool discards the point axis entirely.
The source's PointNet — shared MLPs, a symmetric pool, a headpython
import torch
import torch.nn as nn
class PointNet(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.mlp1 = nn.Sequential(
nn.Conv1d(3, 64, 1), nn.BatchNorm1d(64), nn.ReLU(inplace=True),
nn.Conv1d(64, 64, 1), nn.BatchNorm1d(64), nn.ReLU(inplace=True),
)
self.mlp2 = nn.Sequential(
nn.Conv1d(64, 128, 1), nn.BatchNorm1d(128), nn.ReLU(inplace=True),
nn.Conv1d(128, 1024, 1), nn.BatchNorm1d(1024), nn.ReLU(inplace=True),
)
self.head = nn.Sequential(
nn.Linear(1024, 512), nn.BatchNorm1d(512), nn.ReLU(inplace=True), nn.Dropout(0.3),
nn.Linear(512, 256), nn.BatchNorm1d(256), nn.ReLU(inplace=True), nn.Dropout(0.3),
nn.Linear(256, num_classes),
)
def forward(self, x):
# x: (batch, 3, num_points) — coordinates first for Conv1d
x = self.mlp1(x)
x = self.mlp2(x)
x = torch.max(x, dim=-1)[0] # (batch, 1024) — the symmetric functionreturn self.head(x)
pts = torch.randn(4, 3, 1024) # a batch of four 1,024-point clouds
net = PointNet(num_classes=10)
print(net(pts).shape) # torch.Size([4, 10])
print(sum(p.numel() for p in net.parameters())) # 807,626 — print it, do not guess
'Shared MLP' is literally Conv1d with kernel_size=1: a per-point linear layer whose weights are reused across the point axis. torch.max(x, dim=-1) pools over the points, which is why the output has no N in it.
Where do the parameters live? Exactly in the four convolutions, their batch norms and the three-layer head — the symmetric pool has none, because a max has no weights to learn:
layer arithmetic params
mlp1 Conv1d(3, 64, 1) 3·64 + 64 256
BatchNorm1d(64) 2·64 128
Conv1d(64, 64, 1) 64·64 + 64 4,160
BatchNorm1d(64) 2·64 128
mlp2 Conv1d(64, 128, 1) 64·128 + 128 8,320
BatchNorm1d(128) 2·128 256
Conv1d(128, 1024, 1) 128·1024 + 1024 132,096
BatchNorm1d(1024) 2·1024 2,048
head Linear(1024, 512) 1024·512 + 512 524,800
BatchNorm1d(512) 2·512 1,024
Linear(512, 256) 512·256 + 256 131,328
BatchNorm1d(256) 2·256 512
Linear(256, 10) 256·10 + 10 2,570
total 807,626
the source's comment says "about 1.6M"; count it instead — the number
above is what sum(p.numel() for p in net.parameters()) returns
With 1,024 points per cloud that is tiny by modern standards — a ResNet-18 is 11.7M parameters — and it runs on a CPU in milliseconds. The descendants (PointNet++, Point Transformer, KPConv) add hierarchical sampling, local neighbourhoods and attention, but they all keep the two ingredients: per-point processing with shared weights, and a symmetric reduction to a fixed vector. Whenever you see a point-cloud network, look for those two lines first.
The point-cloud playground
The same 16 points in a new order: watch the rows of the feature matrix change, the pooled max row stay put, and the label never move. That is the entire PointNet trick — a shared per-point MLP plus a symmetric function.
cloud to classify
cloud right-angle corner · 16 × 3 floats
row order 8 7 11 3 14 1 5 12 2 10 … (identity order = 0 1 2 3 4 5 6 7 …)
weights shared: the same 3→8 matrix on every row
pooled [0.363, 0.752, 0.942, 1.751, 1.548, 2.185, 1.973, 0.497]
nearest ref flat plate d=1.902 p=0.004
sphere shell d=1.091 p=0.042
right-angle corner d=0.000 p=0.954
prediction right-angle corner (95.4%)
THE PROOF · same points, two different orders
identity pooled[0] = 0.363461
seed 4242 pooled[0] = 0.363461
max |Δ| over all 8 pooled features = 0.0e+0
max |Δ| over the 3 distances = 0.0e+0
A permutation cannot change a max: the pool sees the same 16 numbers,
just in a different order. The head is a nearest-reference comparison
(NOT a trained classifier) — the order invariance is what transfers.
The head is a teaching model: distances to three reference clouds’ pooled features, not a learned classifier. The “straight line” cloud is outside those three categories, so its nearest reference is a guess — real PointNets learn the head and can only label what they were trained on, too.
04
A NERF IS THE SCENE
The network doesn’t render the scene. The network is the scene.
NeRF (Mildenhall et al., 2020) answers “can a handful of photos become a 3D scene?” with a network that maps a point and a direction to a density and a colour. Rendering a new view is then a loop of ray casts through that network — and training is that loop run backwards.
Keep the interface in your head before any detail: the model is a function F(x, y, z, θ, φ) → (σ, r, g, b). Position and viewing direction in, density and colour out. Density σ says “how much stuff is at this point” — zero in empty air, large inside a surface. Colour is what that stuff looks like from that direction, which is why the direction is in the input: a specular highlight is the same point looking different from two angles.
There is no mesh, no voxel grid, no point list. Ask the network about any coordinate and it answers, whether or not a sample ever landed there before. That property — a continuous function rather than a stored array — is what makes the representation smooth, compact, and differentiable. It is also why the geometry is invisible: to know where a surface is you have to go and find the density.
Rendering one pixel of a new view is five steps:
Cast a ray. From the camera’s position through the pixel, in the direction determined by the pose (a rotation and a translation) and the camera intrinsics. Every pixel of an 800×800 image gets its own ray: 640,000 of them.
Sample along the ray. Take points at distances t₁ … t_N between a near and a far plane. The paper uses 64 coarse samples spread over the whole interval.
Query the network at each point. Encode the coordinates, run the MLP, and read off (σ, c). The coarse pass costs 64 queries per ray.
Sample again where it matters. The coarse weights say where the interesting volume sits, so a second pass draws 128 fine samples from that distribution — 192 queries per ray in total.
Composite. Combine the samples into one RGB value with the volumetric rendering equation (next chapter), then compare it with the pixel in the training photo. Backprop through all five steps updates the MLP. There is no 3D supervision anywhere.
one image, with the paper's sample budget
rays 800 × 800 = 640,000 rays
queries/ray 64 coarse + 128 fine = 192
MLP calls 640,000 × 192 = 122,880,000 (123 million)
at 1–2 days of training for 100–300k iterations, and ~30 s to render one
image, because every one of those calls is a forward pass through the MLP
one training step (the paper's batch of 4,096 rays)
coarse 4,096 × 64 = 262,144 samples
fine 4,096 × 128 = 524,288 samples
total = 786,432 samples (forward + backward)
the entire scene, stored
trunk 8 layers × 256 wide + skip + heads = 595,844 weights
two trunks coarse and fine, float32 = 1,191,688 weights
bytes 1,191,688 × 4 = 4,766,752 B ≈ 4.8 MB
the paper says 5 MB
That last table is worth pausing on, because it is the punchline of the whole representation. The scene — every surface, every colour, every glossy highlight — fits in 4.8 MB of weights, and those weights are the only thing you store. A single 800×800 photo is 1.9 MB raw; a hundred of them, plus their poses, is the 15 GB light-field baseline the paper compares against. The compression ratio is not a trick: the network is a compact function that happens to agree with the photographs.
The ray-casting visualizer
Move the camera, pick a pixel, change the sample budget: the cross-section shows the pose and the samples, the panel shows the render, and the readout below is the composite arithmetic for that one pixel. Watch the running colour patch fill the ray from front to back.
camera d 3.2 · yaw 0° · pose t = (0.00, 0.00, 3.20)
R rows [1.000, 0.000, 0.000]
[0.000, 1.000, 0.000]
[0.000, 0.000, 1.000]
ray o = (0.00, 0.00, 3.20)
d = (0.011, -0.010, -1.000)
sampling t ∈ [1.60, 5.00] δ_coarse = 0.0531
64 coarse + 128 fine = 192 queries on this ray
composite for this pixel (samples with w > 0.01, 2 shown)
pass t σ δ α T w
coarse 2.583 0.407 0.0310 0.0125 0.9912 0.0124
fine 2.645 0.719 0.0158 0.0113 0.9601 0.0108
C = Σ wᵢcᵢ + (Σ leftover)·bg = rgb(147, 215, 218)
Σw 0.8075 ← light that hit something
background 0.1925 ← T after the last sample
depth Σwᵢtᵢ = 2.469 m
coarse mass 2.58–3.54 world units
fine samples 128, 114 land inside the coarse mass window
vs the 1024-sample reference
pixel Δrgb = 0.051 levels · Δdepth = 0.0078 m
image mean 0.032 levels (0.012%)
Sample count buys accuracy here; the fine pass buys placement. This toy scene is smooth and only 3.4 units deep, so a uniform grid is already excellent — in a real room the ray crosses metres of empty air and the surface occupies a fraction of it, which is exactly why NeRF spends 128 of its 192 queries inside the coarse pass’s mass window.
A tiny NeRF — MLP, positional encoding, volumetric renderpython
import torch
import torch.nn as nn
def positional_encoding(x, L=10):
"""(..., D) -> (..., D * 2 * L): sin/cos of 2^l π x for l = 0…L−1."""
freqs = 2.0 ** torch.arange(L, dtype=x.dtype, device=x.device)
args = x.unsqueeze(-1) * freqs * 3.141592653589793
sinc = torch.cat([args.sin(), args.cos()], dim=-1)
return sinc.reshape(*x.shape[:-1], -1)
class TinyNeRF(nn.Module):
"""The real thing is 8 layers of width 256 with a skip; this is enough to
show the shape of the computation."""def __init__(self, L_pos=10, L_dir=4, hidden=128):
super().__init__()
pos_dim, dir_dim = 3 * 2 * L_pos, 3 * 2 * L_dir
self.trunk = nn.Sequential(
nn.Linear(pos_dim, hidden), nn.ReLU(inplace=True),
nn.Linear(hidden, hidden), nn.ReLU(inplace=True),
nn.Linear(hidden, hidden), nn.ReLU(inplace=True),
nn.Linear(hidden, hidden), nn.ReLU(inplace=True),
)
self.sigma = nn.Linear(hidden, 1) # density: one number
self.color = nn.Sequential( # colour: view-dependent
nn.Linear(hidden + dir_dim, hidden // 2), nn.ReLU(inplace=True),
nn.Linear(hidden // 2, 3), nn.Sigmoid(),
)
def forward(self, x, d):
h = self.trunk(positional_encoding(x, 10))
sigma = torch.relu(self.sigma(h)).squeeze(-1) # σ ≥ 0
rgb = self.color(torch.cat([h, positional_encoding(d, 4)], dim=-1))
return sigma, rgb
nerf = TinyNeRF()
sigma, rgb = nerf(torch.randn(128, 3), torch.randn(128, 3))
print(sigma.shape, rgb.shape) # torch.Size([128]) torch.Size([128, 3])
The density head comes off the trunk before the direction is added: density is a property of the point, colour is a property of the point as seen from somewhere. That split is what makes the shading view-dependent.
Quick check
An 800×800 NeRF render uses 64 coarse and 128 fine samples per ray. Roughly how many forward passes through the network does one image need, and why does that number explain the original's ~30-second render time?
05
TEN BANDS OF SIN AND COS
Without this, every NeRF renders blurry.
Feed a coordinate straight into an MLP and it can only build smooth shapes: ReLU networks are spectrally biased towards low frequencies. NeRF fixes it by lifting each coordinate into sin/cos waves at ten increasing frequencies before the first layer.
The fix is four lines of code and one idea: do not hand the network raw coordinates. Encode each coordinate x as
γ(u) = ( sin(2⁰πu), cos(2⁰πu), sin(2¹πu), cos(2¹πu), …, sin(2^{L−1}πu), cos(2^{L−1}πu) )
band l completes 2^l cycles across the normalised scene — ten octaves, 1 to 512
one coordinate in → 2L numbers out (L = 10: 20 numbers per axis)
NeRF's exact input sizes
position (x, y, z) + γ(x, y, z) = 3 + 3 × 2 × 10 = 3 + 60 = 63 inputs
direction d + γ(d) = 3 + 3 × 2 × 4 = 3 + 24 = 27 inputs
colour head trunk feature 256 ⊕ γ(d) 27 = 283 inputs → 128 → 3
top band at L = 10: band 9, 512 cycles across the scene's width
a 1 m capture resolves detail at 1 m / 512 = 2.0 mm
a 20 m capture resolves 20 m / 512 = 3.9 cm
Why does this work? The claim is not that the MLP cannot represent a sharp edge — with huge weights it eventually can, and the paper’s ablation shows the no-encoding variant is not broken, it is mush: it scores several dB worse and its surfaces and textures dissolve. An MLP with ReLU units composes piecewise-linear functions, and to make a feature at frequency k out of the first layer you need weights of size about k. Frequencies that high are hard to reach, slow to learn, and unstable; frequencies that are already in the input cost one multiply.
The encoding also fixes the opposite problem on purpose: the raw coordinate is kept alongside its encoding (the + 3 in the sizes above), so the network gets both the smooth low-frequency signal and every octave above it. What it learns is which combination of bands belongs at which location, which is exactly the kind of thing gradient descent is good at.
Worked check — γ(u) for one coordinate, and what the fast bands buy
u = 0.1, L = 3 (bands 1, 2 and 4 cycles across the scene)
l = 0: 2⁰π·0.1 = 0.314 rad → sin = 0.3090, cos = 0.9511 1 cycle
l = 1: 2¹π·0.1 = 0.628 rad → sin = 0.5878, cos = 0.8090 2 cycles
l = 2: 2²π·0.1 = 1.257 rad → sin = 0.9511, cos = 0.3090 4 cycles
γ(0.1) = (0.3090, 0.9511, 0.5878, 0.8090, 0.9511, 0.3090) 6 numbers
now nudge the point by 0.0001 of the scene's width — one tenth of a millimetre
in a 1 m capture — and compare the first and last bands at L = 10:
l = 0 moves by |Δ| = 0.000314 the low band barely notices
l = 9 moves by |Δ| = 0.160676 the top band moves a hundred-plus times more
ratio 0.160676 / 0.000314 ≈ 511 ← exactly 2^9, the top band's cycle count
that ratio is the whole point of the encoding: two points a fraction of a
millimetre apart are distinguishable in the 20-number fingerprint, and the
network can read the difference in one linear layer.
The bands are octaves: 1, 2, 4, 8, … cycles across the scene. Frequencies between two bands are not handed over directly — the MLP composes them out of products (sin at 1× times sin at 8× contains 7× and 9×), which is why ten octaves are enough for a scene whose finest texture sits around 1/512 of its width. The practical floor is a little coarser than the top band suggests: two samples per cycle is the Nyquist limit, so detail below about 1/256 of the scene is where bands stop agreeing with each other. That is the honest version of “detail down to 2 mm in a 1 m scene” — 2 mm is the band, 4 mm is the comfortable resolution.
Normalisation matters for the same reason. The bands are fixed in normalised coordinates, so the whole scene has to be mapped into the cube first (NeRF scales the scene and the camera poses into a cube of side 2 around the origin). Capture in millimetres instead and every band above l = 0 wraps thousands of times per metre: the encoding becomes noise the network can neither use nor ignore. Scaling — not adding bands — is the fix, and the paper’s own ablation finds diminishing returns from raising the position bands past 10.
The positional-encoding explorer
One coordinate, lifted into sin/cos bands: each band is twice as fast as the one below it. Add bands and the reconstruction of a scene profile sharpens — the residual is measured, not asserted.
profile components
bands L 6
available 1, 2, 4, 8, 16, 32 cycles across the scene (u ∈ [−1, 1])
top band 32 cycles → smallest detail 3.125e-2
position input 3 × 2 × 10 + 3 = 63 numbers (L = 10)
direction in 3 × 2 × 4 + 3 = 27 numbers (L = 4)
colour head 256 + 27 = 283 inputs
γ(u* = 0.24)
sin/cos pairs 0.68 0.73 1.00 0.06 0.13 -0.99 -0.25 0.97 -0.48 0.88 -0.84 0.54
band cycles 2^0=1 2^1=2 2^2=4 2^3=8 2^4=16 2^5=32
reconstruction of the scene profile
smooth shape 1× present in the bands → represented
texture 8× present in the bands → represented
fine detail 64× faster than every band → invisible to the encoding
target peak 1.218
fit peak 1.044
residual rms 0.1273
captured 82.48% of the signal energy
verdict ridged: at least one active component is faster than every band
NeRF's own numbers: L = 10 for (x, y, z) — 63 inputs; L = 4
for the viewing direction — 27 inputs. Without any of this the MLP is
spectrally biased to smooth functions and the scene renders blurry.
The bands are octaves (1, 2, 4, 8, …), so a component at exactly 8× appears the moment L reaches 4 — and a component at 64× stays invisible until L reaches 7. Frequencies between two bands are not directly available: an MLP composes them out of products of the bands it was given, which is why 10 octaves are enough for a scene whose finest texture is around 1/512 of its extent.
Quick check
You capture a 20 m wide outdoor scene with L = 10 position bands. What is the finest detail the encoding makes directly available to the MLP?
06
VOLUMETRIC RENDERING BY HAND
Every pixel is a weighted sum of everything along one ray.
The rendering equation is five symbols long and it is the same one a CT scanner uses. NeRF’s contribution is not the equation — it is making it differentiable, so the density field that feeds it can be learned from photographs.
March along a ray and you pass through empty air (low density) and through surfaces (high density). Two quantities describe what happens: the opacity of a sample, and the transmittance — how much light is still left by the time the ray reaches it.
opacity αᵢ = 1 − exp(−σᵢ δᵢ) a sample's absorption
transmittance Tᵢ = exp(−Σ_{j<i} σⱼ δⱼ) (1 − α) compounded back to the start
= Π_{j<i} (1 − αⱼ)
weight wᵢ = Tᵢ αᵢ this sample's share of the pixel
pixel C = Σᵢ wᵢ cᵢ + T_after · background what is left over shines through
depth D = Σᵢ wᵢ tᵢ the weights' centre of mass
Read the four lines in order and the picture is simple. A sample’s opacity grows with its density and with its thickness δ — doubling either doubles the optical depth σδ. Transmittance is the product of everything that survived before it, so a dense surface early in the ray starves everything behind it. The weight is the two multiplied: a sample contributes only if light reached it and it absorbs something. Whatever transmittance is left at the end shows through as the background, and the inner product of the weights with the sample positions is the scene’s depth — a free by-product of the same arithmetic.
Just as important: this is differentiable. Every operation is a multiply, an exponential and a sum — no argmax, no sorting of surfaces, no discrete choice of which triangle a ray hits. That is what lets a photometric loss on a rendered pixel flow all the way back into the MLP’s weights, and it is the reason the whole representation is learnable.
Worked check — three samples, one pixel, exact numbers
three samples on a ray, spaced δ = 0.5 m apart, at t = 2.0, 2.5, 3.0
densities σ = (0.0, 2.0, 4.0) empty, thin, thick
colours c = (—, red, red) the two hits share a colour for now
step by step
i = 1: α₁ = 1 − exp(−0.0 × 0.5) = 0.0000 T₁ = 1.0000 w₁ = 0.0000
i = 2: α₂ = 1 − exp(−2.0 × 0.5) = 0.6321 T₂ = 1.0000 w₂ = 0.6321
i = 3: α₃ = 1 − exp(−4.0 × 0.5) = 0.8647 T₃ = 0.3679 w₃ = 0.3181
Σw = 0.6321 + 0.3181 = 0.9502
leftover transmittance: 1 − 0.9502 = 0.0498
and the same number from the optical depth: Σσδ = 0 + 1.0 + 2.0 = 3.0
exp(−3.0) = 0.0498 ✓
the pixel, with red hits and a grey background
C = 0.6321 · red + 0.3181 · red + 0.0498 · grey
= 0.9502 · red + 0.0498 · grey a 95/5 blend
depth D = 0.6321 × 2.5 + 0.3181 × 3.0 = 2.534 m (times the weights, not the samples)
swap the third sample's colour to blue and the pixel becomes a real mixture:
C = 0.6321 · red + 0.3181 · blue + 0.0498 · grey the front sample dominates,
because it ate most of the light before the blue one was reached.
Two properties of this arithmetic are worth noticing before you trust it. First, the weights look like a probability distribution over the ray — they are non-negative and they cannot exceed 1 in total — but the leftover is meaningful: Σw = 0.9502 says 5% of the light came from behind. A closed surface should give Σw → 1; if your NeRF reports Σw = 0.6 on a wall that should be solid, the density field is too thin in that region, and that is a training problem, not a rendering one. Second, the depth is a weighted average and inherits the same bias: where density ramps up gradually, the surface estimate sits in the middle of the ramp, slightly in front of or behind the true edge.
The two bar charts are the same arithmetic from both sides: as transmittance decays, the weights of the samples behind the surface collapse to zero. A ray that stops at a solid surface has Σw = 1 and a sharp depth; the ray in this figure still lets about a fifth of the light through, which is exactly the situation in the lesson’s two-sphere scene.
Volumetric rendering in the source's own codepython
def volumetric_render(sigma, rgb, t_vals):
"""
sigma: (..., N_samples) density at each sample
rgb: (..., N_samples, 3) colour at each sample
t_vals: (N_samples,) distances along the ray
"""# spacing between neighbours; the last sample gets a huge delta so it# absorbs whatever is left (1e10 × anything = an infinite optical depth)
delta = torch.cat([t_vals[1:] - t_vals[:-1], torch.full_like(t_vals[:1], 1e10)])
alpha = 1.0 - torch.exp(-sigma * delta) # opacity
trans = torch.cumprod( # transmittance
torch.cat([torch.ones_like(alpha[..., :1]), 1.0 - alpha + 1e-10], dim=-1),
dim=-1,
)[..., :-1]
weights = alpha * trans # wᵢ = Tᵢ αᵢ
rendered = (weights.unsqueeze(-1) * rgb).sum(dim=-2) # the pixel
depth = (weights * t_vals).sum(dim=-1) # Σ wᵢ tᵢreturn rendered, depth, weights
t_vals = torch.linspace(2.0, 6.0, 64) # 64 coarse samples over 4 metres
sigma = torch.rand(64) * 0.5# (a trained network supplies these)
rgb = torch.rand(64, 3)
rendered, depth, weights = volumetric_render(sigma, rgb, t_vals)
print(rendered.tolist(), depth.item(), weights.sum().item())
cumprod of (1 − α) is the discretised transmittance, and the 1e-10 keeps the product from collapsing to a hard zero. The huge final delta is how the implementation guarantees that anything not absorbed in the sampled range is absorbed at the end — the same job the background term does in the lab above.
The lab in chapter 4 prints this table for whichever pixel you pick, live. Set its sample budget to 16 coarse / 0 fine (the two sliders under the pixel controls) and its column and row to 24 / 16 — the centre pixel, whose ray goes through the big sphere only. The readout then gives Σw = 0.8106 on the sphere, a leftover of 0.1894, and a depth of 2.473 m. Now move to 27 / 20, where the ray clips the near sphere and the far one:
pixel (27, 20) · 16 coarse samples · δ = 0.2125 m
near sphere (3 samples carrying weight) w = 0.1772 + 0.1980 + 0.0745 = 0.4497
far sphere (5 samples carrying weight) w = 0.0768 + 0.1187 + 0.0968 + 0.0614 + 0.0230 = 0.3767
background that survived 1 − 0.4497 − 0.3767 = 0.1736
C = 0.4497 · near-colour + 0.3767 · far-colour + 0.1736 · background
depth = Σ wᵢtᵢ = 2.105 m — between the two surfaces, as the weights say
and against a 1,024-sample reference (depth 2.099 m, colour 196,194,171)
8 coarse: depth 2.124 (Δ 2.5 cm) colour 1.25 of 255 levels off
16 coarse: depth 2.105 (Δ 0.6 cm) colour 0.44 levels
64 coarse: depth 2.099 (Δ 0.0 cm) colour 0.04 levels
64 + 128: depth 2.106 (Δ 0.7 cm) colour 0.20 levels
the depth converges with sample count; the colour was almost right at 8
samples, because a colour is an average over the ray and a depth is a location
Change the sample budget and watch which numbers move — the table above is exactly what the lab prints as you drag the sliders. At 8 coarse samples the pixel colour is already within 1.25 levels of a 1,024-sample reference while the depth is off by 2.5 cm; at 64 coarse the depth is right to a millimetre. A colour is an average over the whole ray, so it is forgiving; a depth is a location, so it is not. That asymmetry is worth remembering the first time a NeRF render looks perfect while its extracted geometry is blobby.
Quick check
A ray through your scene gives Σwᵢ = 0.83. What does the missing 0.17 mean, and what would you check?
07
TRAINING, LIMITS, REPLACEMENTS
Photographs in, function out. And a list of what it cannot do.
Training a NeRF needs no 3D data at all — only posed photographs and a differentiable renderer. The limits come from the same place: what you get out is exactly what you put in, plus a network.
The training loop is the rendering loop run against a known answer. Sample rays through the camera that took photo k, composite each one into a pixel, compare with that photo’s pixel, take the squared error, backprop through the whole composite into the MLP. The paper’s loss is the L2 error summed over a batch of rays for both the coarse and the fine prediction — nothing else, no depth, no masks, no geometry:
L = Σ_{r ∈ R} ( ‖C_coarse(r) − C(r)‖² + ‖C_fine(r) − C(r)‖² )
R a batch of rays: 4,096 per step in the paper
C(r) the pixel in the training photograph
C_coarse the 64-sample composite, C_fine the 192-sample one
iterations 100,000 – 300,000 → 1–2 days on one V100
gradients flow through α, T, w and the MLP — no 3D supervision, ever
the data budget, in the paper's own numbers
100 views the synthetic Blender scenes (perfectly posed, no noise)
20–50 views a typical handheld object or room capture, 60–80% overlap
(the paper's ablation: 25 input views beat baselines trained on 100)
100–300 views a large outdoor scene, where every wall needs its own coverage
“Posed” is the load-bearing word. The network never learns where a camera was — it is told, because a rendered ray has to be shot from the right place with the right direction to match a pixel in the photograph. That pose usually comes out of a structure-from-motion pipeline (COLMAP is the default), which also gives you a sparse point cloud for free. If the poses are wrong, the loss cannot go down no matter how long you train, and the symptom is a blurry blob rather than an error message.
The rest of the capture discipline is photographic rather than neural: keep the exposure and white balance fixed (a radiance field bakes in whatever brightness it saw from each direction, so auto-exposure becomes a shading artefact), avoid motion blur, get 60–80% overlap between neighbouring shots, and mask out anything that moves — a person walking through frame during a 20-minute capture becomes a ghost that no amount of training removes, because no single 3D scene explains those pixels.
The practical recipe — nerfstudio, end to endbash
# 0. install (one GPU, CUDA; see the docs for the exact wheels)
pip install nerfstudio
# 1. photos -> posed images. COLMAP runs here, and its sparse cloud is kept.
ns-process-data images --data ~/capture/raw --output-dir ~/capture/processed
# (~30 photos of an object, 60-80% overlap, fixed exposure, no motion blur)# 2. fit the scene. The viewer streams while it trains.
ns-train nerfacto --data ~/capture/processed
# 3. look at it, from anywhere: the viewer runs during and after training.
ns-viewer --load-config outputs/.../config.yml
# 4. export. A mesh, a point cloud or a Gaussian-splat PLY, depending on the# tool that has to consume it next.
ns-export poisson --load-config outputs/.../config.yml --output-dir exports/
ns-export gaussian-splat --load-config outputs/.../config.yml --output-dir exports/
# the 5-second version, for when you just want to see it work:# instant-ngp's GUI: drag a data/nerf folder in, watch the fox appear.
Two commands matter more than the rest: ns-process-data (poses — everything downstream depends on it) and the export step (the deliverable is whatever the *next* tool needs, and a NeRF is not a mesh).
So what can a NeRF not do?
It is not a mesh. There is no surface list to export. Marching cubes over a sampled density grid produces one, and for clean scenes it is decent — but it is a guess at the geometry, with the view-dependent colour impossible to split back into material and lighting. Anything that must be edited, rigged, collided with or relit wants triangles.
It is static. The network encodes one scene at one moment. A moving person, a flickering fire or a camera that changes exposure is not represented — the loss simply cannot fit it.
It bakes the lighting. Colour is a function of direction, not of material. Change the light and the learned field is wrong everywhere.
It is slow, and it is per scene. The original takes 1–2 days to train and ~30 seconds to render a frame, and a new scene means training from scratch — there is no pretrained NeRF that knows about your room. (Generalising models exist, but they trade away the per-scene quality that made NeRF famous.)
It struggles with what the sensor struggles with: transparent and mirror-like surfaces, textureless white walls, and very thin structures (wires, railings) that most rays miss entirely.
The lineage after 2020 attacked the first and fourth items in that list.instant-ngp (2022) replaced the MLP’s input with a multi-resolution hash grid and a tiny network, so the same loss trains in seconds to minutes — the repo’s headline is a NeRF of a fox in under five seconds on an RTX 3090. Mip-NeRF 360 fixed aliasing and unbounded scenes (a room plus everything outside the window). And 3D Gaussian splatting (2023) stopped treating the scene as a function at all: millions of explicit 3D Gaussians with opacity and view-dependent colour, rasterised instead of ray-marched, training in tens of minutes and rendering in real time at 1080p. Almost every radiance-field product in 2026 is splatting underneath; the NeRF vocabulary — radiance, transmittance, novel views, posed images — is what you use to think about it.
NeRF vs the alternatives
Pick the constraint your project actually has. The board reorders itself, and the row detail names what you give up by choosing it.
method
kind
training
rendering
mesh export
storage
implicit
1–2 days · 100–300k iterations on one V100
~30 s for one 800×800 image (123M MLP calls)
only by marching cubes on the density — coarse
≈ 5 MB (two MLP trunks, 596k weights each)
hybrid (implicit + a data structure)
the repo's headline: a fox in under 5 s on an RTX 3090
interactive — tens of frames per second
yes, in the GUI (marching cubes on the hash grid)
hash grid + tiny MLP: tens to hundreds of MB
hybrid (implicit + a data structure)
tens of minutes on a recent GPU (docs' V100 run: ~3 h for nerfacto-big)
interactive viewer during training and after
yes: ns-export poisson / marching cubes
hundreds of MB
explicit
30–60 min in the paper; a few minutes with the fastest variants
real time: ≥ 30 fps at 1080p, 100+ fps at lower resolutions
not natively — a separate extraction step
0.5–5M Gaussians × 236 B = 118 MB–1.2 GB
explicit
minutes to hours of CPU/GPU reconstruction
real time (the GPU's rasteriser, no learning)
yes, natively — a textured triangle mesh
megabytes
explicit
minutes per scan; needs the hardware
real time
yes — registration and meshing are the whole pipeline
megabytes to gigabytes depending on density
Sources: original NeRF paper (training, rendering, the ~5 MB scene), the instant-ngp repository (its 5-second fox headline), the nerfstudio documentation (its own V100 run and the capture advice), the 3D Gaussian Splatting paper (≥ 30 fps at 1080p, competitive training times), and the standard photogrammetry/scanning pipelines. Storage for 3DGS is computed from the paper’s representation: 59 float32 per Gaussian = 236 B.
your constraint
constraint must export an editable mesh
Only the explicit routes hand you triangles. NeRFs and splats can be converted, but the conversion is a lossy guess: marching cubes on a density field, or a splat-to-mesh extraction that has to invent a surface.
ranking
→ 1. photogrammetry → mesh minutes to hours of CPU/GPU reconstruction
2. laser / structured-light scan minutes per scan; needs the hardware
3. nerfstudio / nerfacto tens of minutes on a recent GPU (docs' V100 run: ~3 h for nerfacto-big)
selected original NeRF (2020) · implicit
training 1–2 days · 100–300k iterations on one V100
rendering ~30 s for one 800×800 image (123M MLP calls)
mesh export only by marching cubes on the density — coarse
view-dep yes: colour is a function of the ray direction
storage ≈ 5 MB (two MLP trunks, 596k weights each)
best for understanding the paradigm; small objects; research
on this board — not in the top three for this constraint
The pattern behind the ranking: explicit representations are what other tools consume, implicit ones are what training produces. Production pipelines happily mix them — COLMAP’s sparse cloud seeds 3DGS, and a trained splat is often converted to a mesh right before it ships.
08
CHECK YOURSELF
Six questions. Then the terms worth keeping.
Answer before you look. The permutation question and the “client wants a mesh” question are the two that separate having read this chapter from being able to choose a representation in a kickoff meeting.
0 / 6 answered · 0 correct
01Why can't a plain CNN process a point cloud directly?
02What exactly makes PointNet permutation-invariant over the input points?
03A vanilla NeRF MLP fed raw (x, y, z) coordinates produces blurry results. What fixes it?
04How is a NeRF pixel computed?
05Your capture works, but the client needs to edit the asset in Blender and relight it. Why is a NeRF the wrong deliverable?
06Why has 3D Gaussian splatting largely replaced NeRF in production?
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 — prove permutation invariance and explain why sorting is not the fix, write a ray generator and check one pixel by hand, and train a TinyNeRF on a synthetic turntable with a reported PSNR. Try first; a worked answer is one click away.
Show that PointNet is permutation-invariant. Run the same cloud through the source's network twice, once with the points shuffled, and verify the outputs are identical up to floating-point noise. Then explain why a sorted-points baseline would not be a drop-in fix.Show one worked answer
Build the network from the lesson's code and compare: pts = torch.randn(1, 3, 512); idx = torch.randperm(512); shuffle the point axis (dim=-1 for the (N, 3, n_points) convention) and compare net(pts) with net(pts[:, :, idx]). Expect max|Δ| ≈ 1e-7 or exactly 0 — the arithmetic is the same max over the same 512 rows in a different order, so there is nothing else for floating point to disagree about. The max pool is the whole proof: max is commutative and associative, and every point passes through the identical MLP weights. A sorted-points baseline (sort the N points lexicographically by (x, y, z), then feed the sorted matrix to a CNN or an MLP) does make the input order-independent, but it fails as a shape descriptor: sort is discontinuous (a 1 mm nudge can swap two points and rewrite the whole ordering), it destroys locality — the first row and the last row are neighbours in space only by accident — and it defines an arbitrary convention rather than learning one. Symmetric aggregation is the version that survives contact with real scans.
Write a minimal ray generator: given camera intrinsics (focal length, image H × W) and a pose, return ray origins and directions for every pixel. Check it with a specific pixel and pose by hand.Show one worked answer
Work in the camera frame first, then rotate. With a pinhole and focal length f, the direction for pixel (u, v) measured from the principal point is d_cam = ((u − cx)/f, (v − cy)/f, 1), and the ray is o = t, d = R · d_cam with (R, t) the camera-to-world pose — the same R and t the training images come with. Worked check: W = 800, H = 800, f = 1111 px (a ~40° horizontal field of view: 2·atan(400/1111) = 39.6°), pixel (700, 200). With cx = cy = 400: d_cam = ((700 − 400)/1111, (200 − 400)/1111, 1) = (0.270, −0.180, 1). Normalise if you want unit directions: |d| = sqrt(0.0729 + 0.0324 + 1) = 1.0516, so d̂ = (0.2568, −0.1712, 0.9509). With the pose R = Ry(30°) and t = (2, 0, 2) (a camera 2 m out at 30°, looking back at the origin), the world ray is o = (2, 0, 2) and d = Ry(30°)·(0.270, −0.180, 1) = (0.270·cos30 + 1·sin30, −0.180, −0.270·sin30 + 1·cos30) = (0.7338, −0.180, 0.7310). Sanity checks worth automating: the centre pixel (400, 400) must give exactly −t's direction after rotation (the camera looks at the origin by construction), the four corners must be the extreme directions, and a 90° field of view on a square image must give a half-angle of 45° — atan(400/1111) = 19.8° per side, i.e. 39.6° horizontal, which is the 36–40° window the NeRF datasets use.
Train a TinyNeRF on a synthetic turntable of a coloured cube. Report the rendering loss at epochs 1, 10 and 100, and say at which epoch the views become recognisable.Show one worked answer
The dataset is the hard part, and it is only 20 lines of code: place a cube at the origin, sample 20–50 evenly spaced camera poses on a circle at a fixed elevation (60° apart is 6 views; 100 views is a turntable), and for each pose render the cube with a brute-force ray tracer — for each pixel, intersect the ray with the 12 triangles (or with the 6 planes and keep the nearest front-facing hit), return the face colour, and stop. Store (RGB image, 4×4 pose) pairs; no depth, no mesh, no normals. Then train exactly as the lesson describes: sample 1,024 rays per step, for each ray take the 64 coarse samples, query (σ, c), composite, and take an L2 loss against the image pixel. Expect the coarse anatomy to appear within a few hundred steps (the mean colour of the scene), a recognisable cube by roughly step 1,000–5,000 (edges smeared, faces the right colours), and clean edges and specular-free faces by 10,000–50,000. Report PSNR, not raw L2, so the numbers are comparable to the literature: the synthetic Blender scenes in the paper reach 31.0 dB after 100–300k iterations on a V100, and a TinyNeRF at 40,000 steps on one modern GPU lands in the mid-20s dB — visibly right, visibly soft. Two traps to expect: no positional encoding means the cube never sharpens no matter how long you train, and a random 70/30 split of *rays* looks excellent while a held-out-pose split shows the blur, because neighbouring rays are almost the same ray.
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.
max pooling — The 2×2 window operation from every CNN, used here as a *symmetric function over a set* rather than over a grid: max over the N points instead of max over the 2×2 neighbourhood. Phase 4, Lesson 03.
multilayer perceptron and ReLU — The PointNet trunk, the NeRF density field and the classification head are all MLPs — linear layers with ReLU, trained by backpropagation. The spectral bias that makes NeRF blurry without positional encoding is a property of exactly this unit. Phase 3, Lessons 01–03.
tensor shapes and broadcasting — The whole lesson is shape arithmetic: (N, 3) points in, (1024,) global feature out; 192 samples per ray × 4,096 rays in a batch. NeRF's coordinates arrive as (N_rays, N_samples, 3). Phase 1, Lesson 12.
Fourier features — The sin/cos lift of a coordinate — the same construction as transformer positional encoding and diffusion timestep embeddings. Phase 3, Lesson 09 (attention) and Phase 4, Lesson 10 (diffusion time conditioning).
backpropagation through a renderer — The photometric loss is differentiable end to end: gradients flow through the compositing sum and into the MLP. Nothing special is needed beyond autograd over ordinary arithmetic. Phase 3, Lesson 03.
camera poses and structure from motion — NeRF's inputs are images *with known camera poses*. Producing those poses from unordered photos is COLMAP's job (structure from motion); its sparse point cloud is also the initialisation for 3D Gaussian splatting. Phase 4, Lesson 22.
3D Gaussian splatting in depth — Lesson 22 builds it from scratch: anisotropic covariance, spherical harmonics, tiles and the rasteriser. Here it appears only as the production successor to pure NeRFs. Phase 4, Lesson 22.
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.
Lesson text adapted from AI Engineering from Scratch (Phase 04, Lesson 13) and the Math Foundations Notebook reference build. The five labs (the canvas ray-casting visualizer with the live composite table, the canvas point-cloud playground, the representation chooser, the canvas positional-encoding explorer, and the NeRF-vs-alternatives board) are original to this page, as is the arithmetic they compute: the representation cost table (10⁸ voxels = 400 MB per channel; 8,000 points = 128 KB; a 4.8 MB NeRF), the exact PointNet parameter count (807,626, against the source comment's ~1.6M) with its layer table, the NeRF shape arithmetic (63 position inputs, 27 direction inputs, 283 colour-head inputs, 595,844 weights per trunk, 122.9M MLP calls for one 800×800 render, 786,432 samples per 4,096-ray batch), the two-band positional-encoding check (Δ at l=0 is 0.000314 versus 0.160676 at l=9 for a 0.0001 move), the volumetric-rendering worked example (σ = 0, 2, 4 at δ = 0.5 → α = 0, 0.6321, 0.8647, Σw = 0.9502 = 1 − e⁻³, depth 2.534 m) and the two-sphere pixel table (0.4497 / 0.3767 / 0.1736 at pixel 27, 20), and the 3D Gaussian storage arithmetic (59 float32 = 236 B per Gaussian). Every number shown is computed live by the labs or verified by hand in the prose.