Probabilistic Robotics
Chapter 19PART VMapping and SLAMDifficulty: IntermediateEstimated reading time: 55 min

Modern Map Representations

Octrees, TSDFs, distance fields, meshes, and radiance fields — five ways to store what the robot saw, and one recursive estimator hiding inside every one of them.

We show that under certain assumptions, this isosurface is optimal in the least squares sense.
Brian Curless and Marc LevoyA Volumetric Method for Building Complex Models from Range Images, SIGGRAPH 1996

In this chapter

Chapter 13 built a map that answers exactly one question — is this cell occupied? — at exactly one resolution, in memory proportional to the floor area whether or not anything interesting is there. That map got us through localization, SLAM, and loop closure. It is about to fail us.

The planner in Chapter 20 does not want to know whether a cell is occupied. It wants to know how far away the nearest obstacle is, and which direction is away from it, at a hundred thousand query points per second. The controller in Chapter 23 wants the same thing, differentiated. A renderer wants a surface. An operator wants a picture. Each of these is a different question, and a representation is nothing more than a decision about which question gets to be O(1)O(1).

This chapter walks the modern menagerie — octrees, truncated signed distance fields, Euclidean distance fields, meshes, radiance fields — and lands one punchline: every serious map representation is a recursive estimator wearing a costume. The octree runs Chapter 13's log-odds filter per node. TSDF fusion's "weighted running average" is a scalar information filter for a static state, with a Kalman gain you can read off the code. Even NeRF-style mapping is argmaxθp(zθ,x)\arg\max_\theta p(z \mid \theta, x) with a differentiable renderer standing in for the measurement model. Learn the costumes; the actor underneath has not changed since Chapter 5.

The map that cannot answer the question

Here is the concrete failure. Rusty has a 5 cm occupancy grid of the Apartment — 43,200 cells, 43 kB, every one of them a well-calibrated posterior over occupancy. A planner asks: what is the clearance at (7.3,4.1)(7.3, 4.1)?

The grid cannot answer. It can tell you about the cell containing that point, and about any other cell you name, but "distance to the nearest occupied cell" is not stored anywhere — it has to be searched for, ring by expanding ring, until an occupied cell turns up. Measured on this chapter's Apartment log, that search costs about 16 µs per query against 0.3 µs for an occupancy lookup: a factor of fifty, paid on every one of the millions of collision checks a sampling planner performs.

So store the distance instead of the bit. That single decision is what the rest of the chapter falls out of.

Watch one lap before reading on. Three things are happening, and each is a section of this chapter.

The field is signed, and truncated. Blue is positive — free space in front of a surface — and orange is negative, behind it. Neither extends far: past τ=15\tau = 15 cm from the nearest observed surface the field simply stops being maintained, because a projective range measurement stops being a good estimate of true distance out there. Truncation is not a memory optimization. It is an honesty policy.

The surface is not stored; it is extracted. The purple contour is the zero level set {x:D(x)=0}\{\mathbf{x} : D(\mathbf{x}) = 0\}, pulled out of the numbers by marching squares every few frames at sub-cell accuracy. Nothing in the field is "a wall". Walls are where the estimate changes sign.

Every cell is running a filter. Click any cell and the inspector shows three numbers: the prior Dt1D_{t-1}, the projective sample dtd_t this scan delivered, and the fused posterior DtD_t — with the gain KtK_t that produced it. That gain is Chapter 6's Kalman gain, and the weight WW beside it is Chapter 6's information Ω\Omega. This is the chapter's thesis, and it is visible before we prove it.

Now press move the chair and watch the ghost. At Wmax=64W_{\max} = 64 the abandoned chair loses half its error only every 45 re-observations, so it is still there most of a lap later; drag the slider to 4 and the half-life falls to three, the ghost evaporates — and the rest of the map turns visibly noisy. That trade has a closed form, derived in §3.3, and it is the same trade Chapter 6 called the noise ratio.

Building intuition: four maps, one log

Before any mathematics, the comparison the whole chapter exists to make. The same 200-scan lap, with the same poses (optimized by Chapter 16's pose graph, so this is a mapping chapter and not a SLAM chapter), fused into four representations at once, with meters underneath measuring what each one actually costs.

The ranking is not stable. That is the point.

  • Occupancy probes — "is this point occupied?" The flat grid wins: one multiply, one array index. The quadtree pays for eight pointer hops down its levels and comes second by a hair. Both are so cheap that the difference rarely decides an architecture.
  • Distance probes — "how far to the nearest obstacle?" The ESDF wins by sixty times over the grid's ring search and a hundred and seventy times over brute-force distance to the extracted segments. This is the query workload that reorganizes modern mapping stacks.
  • Surface extraction — "give me the boundary as a polyline." The mesh wins trivially: it is already the answer. Everything else has to run marching squares first, which costs 15 ms on this grid.

And memory tells a third story that contradicts the first two. The quadtree, the representation whose entire pitch is compression, uses five times more memory than the flat grid it was supposed to beat. That is not a bug in the implementation, and §3.1 derives exactly when it is true.

Notation for this chapter

Notation used in this chapter
SymbolMeaning
ϕ(x)\phi(\mathbf{x})Signed distance to the nearest surface. Negative inside obstacles, positive in free space, zero on the surface.
τ\tauTruncation distance. The field is only maintained where |φ| ≤ τ.
Φ(x)\Phi(\mathbf{x})The truncated signed distance, clamp(φ(x), −τ, +τ) — the quantity a TSDF actually estimates.
Dt(v),  Wt(v)D_t(v),\; W_t(v)Fused distance estimate and accumulated weight of voxel v after t scans. The filter state.
dt(v),  wt(v)d_t(v),\; w_t(v)One scan's projective distance observation of voxel v, and the weight (inverse variance) it carries.
WmaxW_{\max}Weight clamp. The fading-memory knob: bounds how much evidence a voxel may bank.
Kt=wt/(Wt1+wt)K_t = w_t / (W_{t-1} + w_t)The fusion gain. Identical in form and in content to Chapter 6's Kalman gain.
esdf(x)\mathrm{esdf}(\mathbf{x})True Euclidean distance from x to the extracted surface — defined everywhere, not just near it.
min,  max\ell_{\min},\; \ell_{\max}OctoMap's clamping bounds on log odds. Without them nothing in a tree ever merges.
σ(x),  T(s),  αi\sigma(\mathbf{x}),\; T(s),\; \alpha_iVolume density, transmittance along a ray, and the alpha of one sample — the radiance-field vocabulary.

One deliberate exception to the book's color code lives in this chapter, as it did in Chapter 13. The signed distance field is drawn with a diverging ramp — orange for negative (behind a surface), through transparent at zero, to blue for positive (free) — because the sign is the meaning, and a sequential ramp would hide the only feature the reader is meant to look for. Every other element keeps its role: measurements green, extracted posterior surfaces purple, ground truth gray dashed.

The mathematics

3.1 Hierarchy: the octree is Chapter 13's filter plus a compression theorem

Start with the representation that changes the least. An octree (in 2-D, a quadtree) subdivides space recursively; each leaf stores exactly what a grid cell stored, the log odds i\ell_i of occupancy, and each leaf runs exactly the recursion of Chapter 13, with one addition:

t,i=clamp(t1,i+inverse_sensor_model(mi,xt,zt)0,  min,max)\htmlClass{term-posterior}{\ell_{t,i}} = \mathrm{clamp}\Bigl( \htmlClass{term-prior}{\ell_{t-1,i}} + \htmlClass{term-measurement}{\mathrm{inverse\_sensor\_model}(m_i, x_t, z_t)} - \ell_0,\; \ell_{\min},\, \ell_{\max}\Bigr)

The clamp came into Chapter 13 as a patch for the "stubborn map" problem: an unclamped cell that has seen forty consistent readings needs forty contradicting ones to change its mind. Hornung et al. (2013) noticed that the same patch is what makes the tree work, and that observation is the whole of OctoMap.

DerivationD1 — clamping makes pruning lossless, and pruning makes the tree worth having

Statement. With clamped log odds, four sibling leaves holding identical values may be replaced by their parent with no change to any query the tree can answer. The resulting node count is proportional to the surface measure of the environment rather than its volume, and the memory ratio against a dense array of bb-byte cells is

octree bytesdense bytes  =  bnodecdSrV,cd=2d12d11,\frac{\text{octree bytes}}{\text{dense bytes}} \;=\; \frac{b_{\text{node}}\, c_d\, S\, r}{V}, \qquad c_d = \frac{2^{d-1}}{2^{d-1}-1},

for surface measure SS (length in 2-D, area in 3-D), resolution rr, bounded volume VV, and bnodeb_{\text{node}} bytes per node.

Step 1 — the merge is information-preserving. A query descends to a leaf and returns its value. If all four children of a node hold the same \ell, then every point in the parent's region returns the same number whether the children exist or not, and the parent stores that number. Nothing that can be asked can distinguish the two trees. If a later scan touches one child, the node splits again and copies the parent's value into all four children — which restores exactly the state the merge discarded, because that state was four copies of one number.

Step 2 — clamping is what makes agreement common. Without a clamp, two neighboring free cells observed a different number of times hold different log odds forever, and four siblings essentially never agree. With the clamp, free space saturates at min\ell_{\min} after a handful of misses — OctoMap's published defaults are pmiss=0.4p_{\text{miss}} = 0.4 with p[0.12,0.97]p \in [0.12, 0.97], so ln(0.12/0.88)/ln(0.4/0.6)=5\lceil \ln(0.12/0.88) / \ln(0.4/0.6) \rceil = 5 misses saturate a cell — and from then on entire subtrees hold one identical number. Saturation is not a rounding artifact here; it is the compression mechanism.

Step 3 — count the nodes. At leaf resolution, the cells the tree must keep refined are those straddling a surface: roughly S/rd1S / r^{d-1} of them. One level up, the surface still needs refining but each node is 2d2^{d} times larger and covers 2d12^{d-1} times more surface, so the count falls by 2d12^{d-1}. Summing the geometric series gives cdS/rd1c_d \, S / r^{d-1} total nodes with cd=2d1/(2d11)c_d = 2^{d-1}/(2^{d-1}-1): c2=2c_2 = 2 in the plane, c3=4/3c_3 = 4/3 in space. The dense baseline is V/rdV/r^d cells. Divide, multiply by bytes, and Step 1's ratio follows. \blacksquare

Where the crossover is. Set the ratio to 1 and solve for the resolution:

r=VbnodecdSr^\star = \frac{V}{b_{\text{node}}\, c_d\, S}

Rusty's Apartment is V=108V = 108 m² of floor with S=180.6S = 180.6 m of (two-sided, 30 cm thick) wall surface, and this book's quadtree node is 20 bytes — one f32 value plus four u32 child indices. So r=108/(202180.6)=1.5r^\star = 108 / (20 \cdot 2 \cdot 180.6) = 1.5 cm. Above 1.5 cm resolution the quadtree loses to a byte-per-cell array, which is why w19.2's memory meter reads 239 kB against the grid's 43 kB at 5 cm. (The tree is built on a 16 m power-of-two root, so its leaves are actually 6.25 cm — a detail that makes this comparison kinder to the tree, not harsher, and one the formula handles: r=0.0625r = 0.0625 predicts a ratio of 4.2.) The measured 5.5 is worse still, for a reason worth knowing: the tree also refines along the boundary between free and unknown space — the frontier that Chapter 24 will hunt for — and that boundary is a surface too.

The formula also says exactly when octrees do win: rr^\star grows with V/SV/S, and in three dimensions volume grows as the cube of scale while surface grows only as the square. Take a drone mapping a 200 × 200 × 60 m block of airspace at 10 cm — V=2.4×106V = 2.4 \times 10^6 m³ against maybe S=6×104S = 6 \times 10^4 m² of ground and façades, with a 36-byte node (a value plus eight child indices). Then r=83r^\star = 83 cm, the working resolution is eight times finer than that, and the octree wins by roughly 8×8\times: 288 MB against a dense array's 2.4 GB, which is not a saving so much as the difference between running and not running.

There is a second effect the formula does not capture, and in practice it is the larger one. A dense array must allocate its whole bounding box before it knows anything; a tree stores never- observed space as a single node and grows only where scans arrive. A robot inside a building observes a shell, and the box around that shell is mostly nothing. The Apartment at 5 cm in the plane — small, fully traversed, coarse relative to rr^\star — is precisely the regime where neither effect pays. This book shows you a quadtree that loses because a chapter that only showed you the wins would not be teaching you how to choose.

Algorithmoctree_insert_scan(tree, x_t, z_t)CostO(B · L/r · log(L/r)) for B beams of range L; the prune pass is amortized O(nodes)
In
the tree, the (known) pose, the scan
Out
the updated, pruned tree
  1. for each beam kk in ztz_t do
  2.     C\mathcal{C} \leftarrow cells traversed from xtx_t to the return, by integer ray traversal
  3.     for each cell iCi \in \mathcal{C} not yet touched by this scan do
  4.         δocc\delta \leftarrow \ell_{occ} if riztk<α/2\lvert r_i - z_t^k \rvert \lt \alpha/2, else free\ell_{free} if ri<ztkr_i \lt z_t^k, else skip
  5.         descend to depth dmaxd_{\max}, splitting nodes on the way, and set clamp(+δ0)\ell \leftarrow \mathrm{clamp}(\ell + \delta - \ell_0)
  6.     endfor
  7. endfor
  8. prune(tree): bottom-up, merge any four leaves holding identical \ell
  9. return tree

Line 8 is the only line Chapter 13 did not already have. Line 3's "not yet touched by this scan" is not an optimization either: cells near the sensor lie on many beams, and counting them once per beam would fabricate evidence the scan does not contain — the same per-beam independence lie the Chapter 10 beam model told, arriving here in a new outfit.

3.2 Distance, not occupancy

Now change the state. Instead of a bit per cell, store a number: the signed distance to the nearest surface,

ϕ(x)={+minsSxsx outsideminsSxsx inside\phi(\mathbf{x}) = \begin{cases} +\min_{\mathbf{s} \in \mathcal{S}} \norm{\mathbf{x} - \mathbf{s}} & \mathbf{x} \text{ outside} \\ -\min_{\mathbf{s} \in \mathcal{S}} \norm{\mathbf{x} - \mathbf{s}} & \mathbf{x} \text{ inside} \end{cases}

with the surface recovered as the zero level set S={x:ϕ(x)=0}\mathcal{S} = \{\mathbf{x} : \phi(\mathbf{x}) = 0\}. This buys sub-cell surface accuracy for free — a cell holding ϕ=0.017\phi = 0.017 m says the wall is 1.7 cm away, information a binary cell cannot express at any resolution — and it makes surface extraction a root-finding problem instead of a segmentation problem.

It also creates a problem. A range sensor does not measure ϕ\phi. Standing at xtx_t and firing a beam that returns at range zz, the natural estimate for a point at arc length ss along that beam is the projective distance d=zsd = z - s, which equals the true signed distance only where the beam meets the surface head-on. If the surface normal makes an angle θ\theta with the beam, then ϕ=(zs)cosθ\phi = (z - s)\cos\theta, so the projective estimate is too large by 1/cosθ1/\cos\theta and diverges at grazing incidence.

The bias vanishes exactly where we use the field. At s=zs = z the projective observation is zero whatever θ\theta is, so the root of the linear model is right even when its slope is wrong. That is why TSDF reconstructions are sharper than the projective approximation deserves — and why everything else about the field needs fixing before a planner can use it (§3.4).

The standard fix for the divergence is to define the estimand as the truncated signed distance Φ(x)=clamp(ϕ(x),τ,+τ)\Phi(\mathbf{x}) = \mathrm{clamp}(\phi(\mathbf{x}), -\tau, +\tau) and to fit it only where dτ\lvert d \rvert \le \tau: near the surface, where the approximation holds. Everything else is declared out of scope. Choose τ\tau at two to four cells and never smaller than the thinnest structure you must reconstruct — a wall thinner than 2τ2\tau gets observed from both sides, and the two negative claims cancel into nothing.

3.3 The centerpiece: TSDF fusion is a per-voxel information filter

Curless and Levoy's fusion rule, written the way graphics writes it, is a running weighted average:

Dt=Wt1Dt1+wtdtWt1+wt,Wt=min(Wt1+wt,  Wmax)D_t = \frac{W_{t-1} D_{t-1} + w_t d_t}{W_{t-1} + w_t}, \qquad W_t = \min(W_{t-1} + w_t,\; W_{\max})

Two lines of code, no probability anywhere in sight. It is a Bayes filter, and here is the proof.

DerivationD2 — the weighted average is the scalar information filter for a static state

Model. Voxel vv has an unknown, static truncated signed distance Φ(v)\Phi(v). Scan tt delivers a noisy projective observation of it,

dt(v)=Φ(v)+εt,εtN ⁣(0,  σ2wt(v))\htmlClass{term-measurement}{d_t(v)} = \Phi(v) + \varepsilon_t, \qquad \varepsilon_t \sim \Normal\!\left(0,\; \frac{\sigma^2}{w_t(v)}\right)

so the weight wtw_t is an inverse variance: a beam we trust twice as much gets twice the weight. The prior is flat.

Step 1 — Gaussian likelihoods multiply into weighted least squares. With independent observations,

logp(d1:tΦ)=k=1twk(dkΦ)22σ2+const-\log p(d_{1:t} \mid \Phi) = \sum_{k=1}^{t} \frac{w_k \, (d_k - \Phi)^2}{2\sigma^2} + \text{const}

Differentiate, set to zero, and the MAP estimate is the weighted mean — the same argument Chapter 15 used to turn a factor graph into least squares, here with a one-dimensional state and no Jacobians to compute:

Φ^t=kwkdkkwk,Ωt=kwkσ2=Wtσ2\htmlClass{term-posterior}{\hat{\Phi}_t} = \frac{\sum_k w_k d_k}{\sum_k w_k}, \qquad \htmlClass{term-posterior}{\Omega_t} = \frac{\sum_k w_k}{\sigma^2} = \frac{W_t}{\sigma^2}

The posterior precision is the accumulated weight, up to the constant σ2\sigma^2. That is Chapter 6's information matrix Ω\Omega, in one dimension, and its additivity — Ωt=Ωt1+wt/σ2\Omega_t = \Omega_{t-1} + w_t/\sigma^2 — is the information filter's update rule verbatim.

Step 2 — make it recursive. Split the newest term out of both sums:

Φ^t=Wt1Φ^t1+wtdtWt1+wt=Φ^t1+wtWt1+wtKt(dtΦ^t1)\hat{\Phi}_t = \frac{W_{t-1}\hat{\Phi}_{t-1} + w_t d_t}{W_{t-1} + w_t} = \htmlClass{term-prior}{\hat{\Phi}_{t-1}} + \underbrace{\frac{w_t}{W_{t-1}+w_t}}_{K_t} \bigl(\htmlClass{term-measurement}{d_t} - \htmlClass{term-prior}{\hat{\Phi}_{t-1}}\bigr)

Prior, plus gain times innovation. This is line for line the scalar Kalman correction.

Step 3 — check the gain against Chapter 6. For a static state the prediction step is the identity, so Σt=Σt1=σ2/Wt1\Sigma_t^- = \Sigma_{t-1} = \sigma^2/W_{t-1}, and the measurement variance is Rt=σ2/wtR_t = \sigma^2/w_t. Then

Kt=ΣtΣt+Rt=σ2/Wt1σ2/Wt1+σ2/wt=wtWt1+wt  K_t = \frac{\Sigma_t^-}{\Sigma_t^- + R_t} = \frac{\sigma^2/W_{t-1}}{\sigma^2/W_{t-1} + \sigma^2/w_t} = \frac{w_t}{W_{t-1} + w_t} \;\checkmark

Two consequences fall straight out. A voxel with Wt1=0W_{t-1} = 0 has infinite variance, so K=1K = 1 and the first observation replaces the prior entirely — which is why an unobserved voxel needs no special case in the code. And as WW \to \infty, K0K \to 0: the estimate stops listening, exactly like a Kalman filter whose covariance has collapsed.

Step 4 — the clamp is exponential forgetting. Once Wt1=WmaxW_{t-1} = W_{\max} the gain freezes at K=w/(Wmax+w)K = w/(W_{\max}+w) and the recursion becomes an exponentially weighted moving average. Writing et=DtΦnewe_t = D_t - \Phi^{\text{new}} for the error after the world changes,

et=(1K)et1=λet1,λ=WmaxWmax+we_t = (1-K)\,e_{t-1} = \lambda\, e_{t-1}, \qquad \lambda = \frac{W_{\max}}{W_{\max} + w}

with half-life n1/2=ln2/ln(1+w/Wmax)0.69Wmax/wn_{1/2} = \ln 2 / \ln(1 + w/W_{\max}) \approx 0.69\, W_{\max}/w. At Wmax=16W_{\max} = 16 and unit weights a stale observation is half-forgotten after 11 scans; at Wmax=128W_{\max} = 128, after 89. That is the chair's ghost in w19.1, and it is now a number you can predict before you drag the slider.

Step 5 — what the model hides. Three lies, stated rather than buried.

  1. Per-voxel independence. Each voxel is estimated alone, exactly as in Chapter 13. Neighboring voxels observe the same wall through the same beam and their errors are anything but independent. The consequence is the same as it was there: the field is overconfident, and the extracted surface is smoother than the evidence justifies.
  2. The observation is biased. dt=zsd_t = z - s is projective, not Euclidean (§3.2). The truncation τ\tau is what keeps the bias bounded, and Voxblox's weighting — w1/z2w \propto 1/z^2 for range noise, times a cosθ\cos\theta term for incidence — is variance modeling in exactly the sense of Chapter 10, not a heuristic.
  3. Gaussian noise. A beam that hits glass, or a person walking past, is not N(0,σ2/w)\Normal(0,\sigma^2/w) about the wall behind them. Nothing in this recursion is robust; production systems add space-carving and dynamic-object filters on top, and this book adds none of them here.
Algorithmtsdf_integrate(D, W, x_t, z_t)CostO(B · τ/r) with carving off — only the truncation band is touched; O(B · L/r) with carving on
In
the field (D, W), the pose, the range scan
Out
the updated field
  1. for each beam kk with range zz and bearing θk\theta_k do
  2.     for s=sstarts = s_{\text{start}} to z+τz + \tau step r/2r/2 do
  3.         vv \leftarrow voxel at xt+s(cosθk,sinθk)x_t + s\,(\cos\theta_k, \sin\theta_k); skip if already visited this scan
  4.         dzsd \leftarrow z - s; if d<τd \lt -\tau then continue
  5.         wwbeamw \leftarrow w_{\text{beam}} in front of the surface, tapering linearly to 0 at d=τd = -\tau
  6.         Kw/(W(v)+w)K \leftarrow w / (W(v) + w)
  7.         D(v)D(v)+K(clamp(d,τ,τ)D(v))D(v) \leftarrow D(v) + K\,\bigl(\mathrm{clamp}(d, -\tau, \tau) - D(v)\bigr)
  8.         W(v)min(W(v)+w,  Wmax)W(v) \leftarrow \min(W(v) + w,\; W_{\max})
  9.     endfor
  10. endfor

Line 2's sstarts_{\text{start}} is the one real decision. Start at zτz - \tau and the cost is O(Bτ/r)O(B\tau/r) — measured at 0.11 ms per scan on this chapter's log — but the field never learns that the corridor is empty, only that the walls are where they are. Start at the sensor and you also carve free space, at O(BL/r)O(B L/r) and a measured 0.54 ms per scan, five times dearer for a contour that is identical to within five segments out of 2,236. You pay that factor of five for one thing only: knowing the difference between free and unknown. Chapter 24 needs it; a pure reconstruction pipeline does not.

A worked example you can check by hand

One voxel, τ=0.20\tau = 0.20 m, Wmax=4W_{\max} = 4, unit-weight scans except where noted. The voxel starts unobserved: W0=0W_0 = 0.

scandtd_twtw_tKt=wt/(Wt1+wt)K_t = w_t/(W_{t-1}+w_t)DtD_tWtW_t
10.100.1011/1=11/1 = 10.1000.1001
20.060.0611/2=0.51/2 = 0.50.0800.0802
30.000.0022/4=0.52/4 = 0.50.0400.0404
40.000.0011/5=0.21/5 = 0.20.0320.0324 (clamped)
50.000.0010.20.20.02560.02564
60.000.0010.20.20.020480.020484

Four things are checkable with a pencil, and each one is a claim from D2.

Row 1: the gain is exactly one. No prior information means no reason to keep the prior. The +τ+\tau that unobserved cells are initialized to never enters the answer.

Row 3: the recursion equals the batch estimate. The weighted mean of everything so far is (10.10+10.06+20.00)/4=0.16/4=0.040(1 \cdot 0.10 + 1 \cdot 0.06 + 2 \cdot 0.00)/4 = 0.16/4 = 0.040, which is what the recursion produced. Up to the clamp, the filter is not approximating the batch solution — it is the batch solution.

Row 4: the clamp breaks that equality on purpose. The batch mean would now be 0.16/5=0.0320.16/5 = 0.032… which is also what the table says, coincidentally, because WW saturated on this exact step. Row 5 is where they part: batch says 0.16/6=0.02670.16/6 = 0.0267, the clamped filter says 0.02560.0256.

Rows 4–6: geometric decay at rate λ=0.8\lambda = 0.8. 0.0400.0320.02560.020480.040 \to 0.032 \to 0.0256 \to 0.02048, each 0.8×0.8\times the last, and λ=Wmax/(Wmax+w)=4/5\lambda = W_{\max}/(W_{\max}+w) = 4/5 as D2's Step 4 promised. The half-life is ln2/ln1.25=3.1\ln 2/\ln 1.25 = 3.1 scans.

crates/ch19_maps/tests/worked_example.rs
use approx::assert_relative_eq;
use ch19_maps::tsdf::{Tsdf2, Tsdf2Config};

/// The chapter's worked table, pinned. If this test fails the book is wrong.
#[test]
fn worked_example_ch19_voxel_filter() {
    let mut f = Tsdf2::new(Tsdf2Config {
        cells: (1, 1),
        resolution: 1.0,
        origin: nalgebra::Point2::origin(),
        truncation: 0.20,
        w_max: 4.0,
    });

    // (observation, weight, expected gain, expected D, expected W)
    let script = [
        (0.10, 1.0, 1.0, 0.100_00, 1.0),
        (0.06, 1.0, 0.5, 0.080_00, 2.0),
        (0.00, 2.0, 0.5, 0.040_00, 4.0),
        (0.00, 1.0, 0.2, 0.032_00, 4.0), // clamp engages: W stays at W_max
        (0.00, 1.0, 0.2, 0.025_60, 4.0),
        (0.00, 1.0, 0.2, 0.020_48, 4.0),
    ];

    for (d, w, want_k, want_d, want_w) in script {
        let ev = f.fuse((0, 0), d, w);
        assert_relative_eq!(ev.gain, want_k, epsilon = 1e-9);
        assert_relative_eq!(ev.after, want_d, epsilon = 1e-9);
        assert_relative_eq!(ev.weight_after, want_w, epsilon = 1e-9);
    }

    // Up to the clamp, recursive == batch. This is D2 Step 1 and Step 2 agreeing.
    let batch = (1.0 * 0.10 + 1.0 * 0.06 + 2.0 * 0.00) / 4.0;
    assert_relative_eq!(batch, 0.040, epsilon = 1e-12);

    // After the clamp, the error decays geometrically at λ = W_max/(W_max + w).
    let lambda = 4.0 / 5.0;
    assert_relative_eq!(0.032 * lambda, 0.025_60, epsilon = 1e-9);
    assert_relative_eq!(0.025_60 * lambda, 0.020_48, epsilon = 1e-9);
}

The TypeScript port in lib/mapping/tsdf.ts runs the identical arithmetic, which is why the voxel inspector in w19.1 prints these gains when you click a cell: the table, the Rust test, and the number under your cursor are the same computation three times.

3.4 From the field to a surface, and from the field to a plan

A TSDF is not directly usable by anything. Two extraction steps make it so, and they answer two different questions.

Marching squares answers where is the boundary? Walk the dual grid; at each cell, the signs of the four corner samples select one of 16 cases; on every edge whose endpoints disagree, place a crossing by linear interpolation,

t=DaDaDb,p=ca+t(cbca)t = \frac{D_a}{D_a - D_b}, \qquad \mathbf{p} = \mathbf{c}_a + t\,(\mathbf{c}_b - \mathbf{c}_a)

which is exact for the piecewise-linear field the samples define — not an approximation of it. Two of the sixteen cases (the diagonal ones, 0101 and 1010) are genuinely ambiguous: two crossings on four edges are consistent with two different topologies, and the table must simply pick one. In three dimensions that same ambiguity is what puts holes in a naive marching-cubes mesh, and the literature since Lorensen and Cline (1987) is largely about disambiguating it.

Algorithmmarching_squares(D, W)CostO(cells), embarrassingly parallel — no cell needs any other cell's result
In
the fused field and its weights
Out
a set of line segments approximating {D = 0}
  1. for each cell (i,j)(i,j) of the dual grid do
  2.     if any of the four corners has W<WminW \lt W_{\min} then continue   (never observed: no opinion)
  3.     coden=032n[Dn<0]\text{code} \leftarrow \sum_{n=0}^{3} 2^n \,[\,D_n \lt 0\,]
  4.     for each edge pair in TABLE[code]\texttt{TABLE}[\text{code}] do
  5.         emit the segment joining the two interpolated crossings
  6.     endfor
  7. endfor

Line 2 is the honest line. A cell with no evidence has no sign, and extracting a surface from it is inventing geometry — the failure mode that makes unvalidated reconstructions look confident in exactly the places they are guessing.

The distance transform answers how far to the boundary, from anywhere? This is the query the TSDF conspicuously cannot serve: past τ\tau its value is a constant, and a constant has no gradient.

That widget is the section in one image. Toggle it off and the rings stop at 30 cm, the arrows vanish, and the probe freezes: a truncated field is not a distance field with smaller numbers in it, it is a distance field with a hole where the planner lives.

DerivationD3 — the exact Euclidean distance transform in two linear passes

Statement. For samples f:{0,,n1}R{}f : \{0,\dots,n-1\} \to \R \cup \{\infty\}, the sampled distance transform

DTf(q)=minp[(qp)2+f(p)]\mathrm{DT}_f(q) = \min_{p} \bigl[\, (q - p)^2 + f(p) \,\bigr]

can be computed for all qq in Θ(n)\Theta(n) time, and the two-dimensional transform in Θ(widthheight)\Theta(\text{width} \cdot \text{height}) by running the one-dimensional version once per row and once per column.

Step 1 — the definition is a lower envelope. Each sample pp contributes an upward parabola y=(qp)2+f(p)y = (q-p)^2 + f(p), rooted at q=pq = p and shifted up by f(p)f(p). All parabolas have the same curvature, so any two intersect exactly once, at

s=(f(p)+p2)(f(p)+p2)2p2ps = \frac{\bigl(f(p) + p^2\bigr) - \bigl(f(p') + p'^2\bigr)}{2p - 2p'}

DTf\mathrm{DT}_f is their pointwise minimum: the lower envelope.

Step 2 — build the envelope in one sweep. Scan pp left to right maintaining a stack of the parabolas currently on the envelope and the abscissae where consecutive ones cross. Adding a new parabola: compute where it crosses the top of the stack; if that crossing lies left of the previous one, the stack's top parabola is now entirely hidden — pop it and retry. Each index is pushed once and popped at most once, so the whole pass is Θ(n)\Theta(n), not O(nlogn)O(n\log n) and certainly not the O(n2)O(n^2) the definition suggests. A second left-to-right sweep evaluates the envelope at each qq.

Step 3 — separability. In two dimensions the squared Euclidean distance splits:

(qxpx)2+(qypy)2+f(p)=(qxpx)2+[(qypy)2+f(p)a 1-D transform down a column](q_x - p_x)^2 + (q_y - p_y)^2 + f(p) = (q_x - p_x)^2 + \Bigl[\underbrace{(q_y - p_y)^2 + f(p)}_{\text{a 1-D transform down a column}}\Bigr]

so transforming every row and then every column of the result is exact, not an approximation. \blacksquare

Step 4 — seeding it from a TSDF instead of from a mask. The classical transform starts from a binary mask: f=0f = 0 on obstacles, \infty elsewhere, giving distances quantized to whole cells. A TSDF can do better. A cell holding D=0.017D = 0.017 m is 0.0170.017 m from the surface, so seed it with f=(D/r)2f = (D/r)^2 in squared-cell units and the transform starts from the sub-cell offset. The result is accurate to a fraction of a cell where a mask-seeded transform is accurate to a whole one — the same trick Voxblox uses, and the reason the ESDF's median error against brute-force distance-to-contour on this chapter's map is 0.6 of a cell.

Algorithmesdf_from_tsdf(D, W)CostΘ(n) for n cells — two sweeps per row, two per column
In
the fused field, its weights, seed band and minimum weight
Out
esdf: signed Euclidean distance to the extracted surface, plus O(1) query and gradient
  1. for each cell vv do
  2.     f(v)(D(v)/r)2f(v) \leftarrow (D(v)/r)^2 if W(v)WminW(v) \ge W_{\min} and D(v)\lvert D(v)\rvert \le band, else \infty
  3. endfor
  4. gg \leftarrow distance_transform_1d over each row of ff
  5. gg \leftarrow distance_transform_1d over each column of gg
  6. esdf(v)sign(v)g(v)  r\mathrm{esdf}(v) \leftarrow \mathrm{sign}(v)\,\sqrt{g(v)}\;r, with the sign taken from D(v)D(v) where the TSDF has an opinion
  7. return esdf

Two properties make the result worth its 173 kB. Away from the medial axis a true distance field satisfies the eikonal equation esdf=1\norm{\nabla\,\mathrm{esdf}} = 1, so its gradient is a pure direction — "this way is away from the nearest obstacle" — with no magnitude to tune. The widget's meter reads a median d=0.93\norm{\nabla d} = 0.93 over a grid of probes, against 0.010.01 for the raw TSDF: the shortfall from 1 is discretization plus the medial-axis points where the gradient genuinely does vanish. And both the value and the gradient are O(1)O(1) bilinear reads. That pair, (d,d)(d, \nabla d) in constant time, is the contract Chapter 20 and Chapter 23 are written against.

The honesty note this field deserves: the transform measures distance to the extracted contour, and marching squares refuses to emit a contour where a cell has unobserved corners. Where those two disagree — the lip of a doorway seen from one side only — the ESDF reports a distance that is too small. Measured on the Apartment at 5 cm: median error 3.0 cm, 99th percentile 7.5 cm, worst case 28 cm, and the worst case is conservative. For a collision cost, being wrong in the safe direction is the correct way to be wrong; for a reconstruction metric it would be a bug. The representation is not neutral about what it is for.

Implementation in Rust

Three types, one trait, and a benchmark harness that is also the chapter's argument.

The trait first, because it is the thesis in code: four representations, four different answers to the same four questions, and a None where a representation genuinely cannot answer in constant time. Nothing is faked — a grid returning a searched distance would hide the whole point.

crates/ch19_maps/src/gallery.rs
use nalgebra::Point2;
use crate::{Scan, Se2};

/// The chapter's thesis as an interface: a map is what it can answer cheaply.
pub trait MapRepr {
    /// Fold one scan in, with a known pose (Chapter 16's optimized graph).
    fn integrate_scan(&mut self, pose: &Se2, scan: &Scan);

    /// P(occupied) at a point. `None` = never observed, which is *not* 0.5.
    fn occupancy(&self, p: Point2<f64>) -> Option<f64>;

    /// Distance to the nearest surface, in O(1). `None` = this representation
    /// cannot answer without a search, and saying so is the honest API.
    fn distance(&self, p: Point2<f64>) -> Option<f64>;

    /// Measured, not estimated: what the harness reports in w19.2.
    fn memory_bytes(&self) -> usize;
}

Then the field itself. Two f32 planes — one for the estimate, one for its accumulated precision — and a fuse that is six lines because D2 says it should be six lines.

crates/ch19_maps/src/tsdf.rs
use nalgebra::{Point2, Vector2};
use ndarray::Array2;

/// A 2-D truncated signed distance field: Curless & Levoy (1996), read as a
/// per-voxel information filter (Chapter 19, D2).
pub struct Tsdf2 {
    /// D_t(v): fused signed distance in metres. Unobserved cells hold +τ.
    d: Array2<f32>,
    /// W_t(v): accumulated weight = accumulated precision, up to σ².
    w: Array2<f32>,
    resolution: f64,
    origin: Point2<f64>,
    truncation: f64,
    /// The fading-memory knob. `f32::INFINITY` recovers the batch estimator.
    w_max: f32,
}

/// One fusion event, returned so a widget (or a test) can inspect the filter.
#[derive(Copy, Clone, Debug)]
pub struct FusionEvent {
    pub before: f64,
    pub weight_before: f64,
    pub observation: f64,
    pub after: f64,
    pub weight_after: f64,
    /// K_t = w_t / (W_{t−1} + w_t) — Chapter 6's Kalman gain, verbatim.
    pub gain: f64,
}

impl Tsdf2 {
    #[inline]
    fn cell_center(&self, ij: (usize, usize)) -> Point2<f64> {
        Point2::new(
            self.origin.x + (ij.0 as f64 + 0.5) * self.resolution,
            self.origin.y + (ij.1 as f64 + 0.5) * self.resolution,
        )
    }

    /// The whole chapter in six lines: precision-weighted averaging, clamped.
    ///
    /// W = 0 gives K = 1, so an unobserved voxel adopts its first observation
    /// outright — no special case, just infinite prior variance doing its job.
    pub fn fuse(&mut self, ij: (usize, usize), d_obs: f64, w_obs: f64) -> FusionEvent {
        let before = self.d[ij] as f64;
        let weight_before = self.w[ij] as f64;
        let denom = weight_before + w_obs;
        let gain = if denom > 0.0 { w_obs / denom } else { 1.0 };
        let after = before + gain * (d_obs - before);

        self.d[ij] = after as f32;
        self.w[ij] = denom.min(self.w_max as f64) as f32;

        FusionEvent { before, weight_before, observation: d_obs,
                      after, weight_after: self.w[ij] as f64, gain }
    }

    /// `tsdf_integrate`: march each beam through the truncation band.
    ///
    /// The visited set is per *scan*, not per beam. Cells near the sensor sit on
    /// many beams' paths, and fusing them once per beam would manufacture
    /// evidence the scan does not contain.
    pub fn integrate(&mut self, pose: &Se2, scan: &Scan, carve: bool) {
        let tau = self.truncation;
        let step = self.resolution * 0.5;
        let mut visited = std::collections::HashSet::new();

        for (&z, &bearing) in scan.ranges.iter().zip(scan.bearings.iter()) {
            let hit = z < scan.max_range;
            let dir = Vector2::new((pose.theta + bearing).cos(), (pose.theta + bearing).sin());
            let s0 = if carve { step * 0.5 } else { (z - tau).max(step * 0.5) };
            let s1 = if hit { z + tau } else { scan.max_range };

            let mut s = s0;
            while s <= s1 {
                let p = pose.translation() + s * dir;
                let Some(ij) = self.world_to_cell(p) else { s += step; continue };
                if !visited.insert(ij) { s += step; continue }

                let sdf = if hit { z - s } else { tau };
                if sdf >= -tau {
                    // Weight tapers to zero behind the surface: a beam says
                    // progressively less about what lies past what it hit.
                    let w = if sdf >= 0.0 { 1.0 } else { 1.0 + sdf / tau };
                    if w > 0.0 {
                        self.fuse(ij, sdf.clamp(-tau, tau), w);
                    }
                }
                s += step;
            }
        }
    }
}

Then the distance transform. The inner loop is the one piece of this chapter that looks like nothing else in the book — no probability, no geometry, just a stack of parabolas — and it is worth typing out once in your life.

crates/ch19_maps/src/esdf.rs
use ndarray::{Array2, Axis};

const BIG: f64 = 1e12;

/// `dt_1d` — Felzenszwalb & Huttenlocher's lower envelope of parabolas.
///
/// Every index is pushed once and popped at most once, so this is Θ(n). The
/// arithmetic is exact in f64 for grids far larger than any robot needs.
fn distance_transform_1d(f: &[f64], out: &mut [f64]) {
    let n = f.len();
    let mut v = vec![0usize; n];     // parabolas currently on the envelope
    let mut z = vec![0.0f64; n + 1]; // where consecutive ones cross
    let mut k = 0usize;
    v[0] = 0;
    // The sentinel −∞ is not decoration: it is what stops `k` underflowing in
    // the pop loop below, since no finite crossing can be left of it.
    z[0] = -BIG;
    z[1] = BIG;

    for q in 1..n {
        let mut s = intersect(f, q, v[k]);
        while s <= z[k] {
            k -= 1;                   // the top parabola is now fully hidden
            s = intersect(f, q, v[k]);
        }
        k += 1;
        v[k] = q;
        z[k] = s;
        z[k + 1] = BIG;
    }

    k = 0;
    for q in 0..n {
        while z[k + 1] < q as f64 {
            k += 1;
        }
        let dq = q as f64 - v[k] as f64;
        out[q] = dq * dq + f[v[k]];
    }
}

#[inline]
fn intersect(f: &[f64], p: usize, q: usize) -> f64 {
    let (pf, qf) = (p as f64, q as f64);
    ((f[p] + pf * pf) - (f[q] + qf * qf)) / (2.0 * pf - 2.0 * qf)
}

/// `esdf_from_tsdf` — seed with the TSDF's own sub-cell offsets, then sweep.
///
/// Seeding with (D/r)² rather than with a binary mask is what buys sub-cell
/// accuracy: a cell holding D = 1.7 cm is 1.7 cm from the surface, not 0.
pub fn esdf_from_tsdf(tsdf: &Tsdf2, min_weight: f32, band_cells: f64) -> Esdf2 {
    let r = tsdf.resolution();
    let band = band_cells * r;
    let mut f = Array2::from_elem(tsdf.dim(), BIG);

    for (ij, &d) in tsdf.values().indexed_iter() {
        if tsdf.weight(ij) < min_weight || (d as f64).abs() > band {
            continue;
        }
        let sub = d as f64 / r;
        f[ij] = sub * sub;
    }

    sweep(&mut f, Axis(0));  // rows …
    sweep(&mut f, Axis(1));  // … then columns: separability makes this exact
    Esdf2::from_squared(f, tsdf, r)
}

impl Esdf2 {
    /// The Chapter 20 / 23 contract: distance and gradient, both O(1).
    pub fn distance(&self, p: Point2<f64>) -> f64 { self.bilinear(p) }

    /// Away from the medial axis ‖∇d‖ = 1, so this is a pure direction.
    pub fn gradient(&self, p: Point2<f64>) -> Vector2<f64> {
        let h = self.resolution;
        Vector2::new(
            (self.bilinear(p + Vector2::x() * h) - self.bilinear(p - Vector2::x() * h)) / (2.0 * h),
            (self.bilinear(p + Vector2::y() * h) - self.bilinear(p - Vector2::y() * h)) / (2.0 * h),
        )
    }
}

Finally the cross-check, because a distance field that is subtly wrong is worse than no distance field at all. parry2d will compute exact point-to-polyline distance with a BVH; the ESDF must agree with it.

crates/ch19_maps/tests/esdf_vs_parry.rs
use parry2d::query::PointQuery;
use parry2d::shape::Polyline;

/// The ESDF approximates distance-to-contour. `parry2d` computes it exactly.
/// Where they disagree by more than a cell, the ESDF must be *conservative* —
/// too small — because a planner that overestimates clearance kills robots.
#[test]
fn esdf_matches_parry_within_a_cell_and_errs_safe() {
    let (tsdf, contour) = crate::fixtures::apartment_5cm();     // 200 scans, seed 0xC0FFEE
    let esdf = ch19_maps::esdf::esdf_from_tsdf(&tsdf, 0.5, 1.5);
    let mesh = Polyline::new(contour.vertices, Some(contour.indices));

    let mut worst = 0.0f32;
    for p in crate::fixtures::observed_free_probes(4_000) {
        let ours = esdf.distance(p.cast::<f64>()) as f32;
        let truth = mesh.distance_to_local_point(&p, /* solid = */ false);
        let err = ours - truth;
        assert!(err < 0.05, "ESDF overestimated clearance by {err} m at {p}");
        worst = worst.max(err.abs());
    }
    // Measured: median 0.030 m, p99 0.075 m, max 0.282 m at one doorway lip.
    assert!(worst < 0.30);
}

The frontier: rendering as a measurement model

Everything so far estimates geometry from ranges. The last five years of mapping research have been about estimating geometry and appearance from images, using neural radiance fields (Mildenhall et al., ECCV 2020) and 3D Gaussian splatting (Kerbl et al., SIGGRAPH 2023). The literature reads like a different subject. It is not.

DerivationD4 — a differentiable renderer is a measurement model, so fitting one is MAP estimation

Step 1 — the forward model. A radiance field is a function Fθ(x,d)(c,σ)F_\theta(\mathbf{x}, \mathbf{d}) \to (\mathbf{c}, \sigma) giving color and volume density at a point. Along a camera ray r(s)\mathbf{r}(s) the rendered color is the volume rendering integral

C^(r)=0T(s)σ(r(s))c(r(s),d)ds,T(s)=exp(0sσ(r(u))du)\hat{C}(\mathbf{r}) = \int_0^\infty T(s)\, \sigma(\mathbf{r}(s))\, \mathbf{c}(\mathbf{r}(s), \mathbf{d})\, ds, \qquad T(s) = \exp\left(-\int_0^s \sigma(\mathbf{r}(u))\,du\right)

T(s)T(s) is transmittance: the probability that a photon reaches ss without being absorbed. Read that way, the integral is an expectation over "where the ray terminates" — the same object a LiDAR beam model integrates over in Chapter 10.

Step 2 — discretize. With piecewise-constant density on samples of width δi\delta_i,

αi=1eσiδi,Ti=j<i(1αj),C^=iTiαici\alpha_i = 1 - e^{-\sigma_i \delta_i}, \qquad T_i = \prod_{j<i}(1 - \alpha_j), \qquad \hat{C} = \sum_i T_i\, \alpha_i\, \mathbf{c}_i

which is exactly alpha compositing. The depth version, which the 1-D toy below renders, replaces ci\mathbf{c}_i by the sample distance tit_i and adds a background term for rays that get through: z^=iTiαiti+Tntfar\hat{z} = \sum_i T_i \alpha_i t_i + T_n\, t_{\text{far}}.

Step 3 — put noise on it and it becomes a likelihood. Assume Gaussian photometric noise on each pixel. Then

logp(Itθ,Tcw,t)    ItI^(θ,Tcw,t)2-\log \htmlClass{term-measurement}{p(I_t \mid \theta, T_{cw,t})} \;\propto\; \bigl\lVert I_t - \hat{I}(\theta, T_{cw,t}) \bigr\rVert^2

and the map is

θ^=argmaxθtp(Itθ,Tcw,t)  p(θ)\htmlClass{term-posterior}{\hat{\theta}} = \arg\max_\theta \prod_t p(I_t \mid \theta, T_{cw,t})\; \htmlClass{term-prior}{p(\theta)}

— MAP estimation with a rendering measurement model. Same ηp(zm,x)p(m)\eta\,p(z \mid m, x)\,p(m) as Chapter 13. The difference is entirely in how it is optimized: no closed-form inverse sensor model exists, so the gradient is taken through the renderer instead. The inverse sensor model is computed by autodiff.

Step 4 — the same residual, optimized over poses, is tracking. Freeze θ\theta and minimize the photometric residual over Tcw,tT_{cw,t} and you have scan-to-map matching with images instead of scans — Chapter 16's ICP objective with a different sensor. Every NeRF-SLAM and 3DGS-SLAM system is some interleaving of these two minimizations.

Step 5 — read the gradient and the failure modes fall out. For the 1-D depth renderer the derivative is available in closed form,

z^σk=δ[Tk+1tk(i>kTiαiti+Tntfar)]\frac{\partial \hat{z}}{\partial \sigma_k} = \delta \Bigl[\, T_{k+1} t_k - \Bigl(\textstyle\sum_{i>k} T_i \alpha_i t_i + T_n t_{\text{far}}\Bigr) \Bigr]

"adding density here pulls the predicted depth toward tkt_k and away from everything behind it." Two facts follow immediately. Behind an opaque region T0T \approx 0, so occluded geometry receives no gradient at all — these optimizers refine geometry they roughly have and cannot invent geometry they do not. And the derivative peaks at the current surface, which is why initialization and coarse-to-fine scheduling matter so much in practice.

A quick number for how non-opaque "opaque" is. Take one bin at t=3t = 3 m with σδ=5\sigma\delta = 5, so α=1e5=0.9933\alpha = 1 - e^{-5} = 0.9933, and a far plane at 10 m. The rendered depth is 0.99333+0.006710=3.0470.9933 \cdot 3 + 0.0067 \cdot 10 = 3.047 m — 4.7 cm too far, from a wall the model considers solid. Depth from a radiance field is biased by construction, and the bias shrinks only exponentially in σδ\sigma\delta. This is why NeRF-SLAM systems that need metric geometry add depth supervision rather than trusting rendered depth.

The toy is 80 bins, ten rays, and plain gradient descent — no network, no rasterizer, a couple of hundred lines — and it reproduces the three behaviors that dominate the literature. Density sharpens only where transmittance survives. The fitted residual falls to the noise floor while the held-out rays sometimes do not, which is the geometry–appearance ambiguity in its simplest possible form. And on an unlucky initialization the field parks density in the wrong place and stays there, because Step 5's gradient cannot see past an opaque bin it invented.

Why this book does not canonize a system. Between 2023 and 2026 the NeRF/3DGS-SLAM field produced dozens of systems, each state of the art for about a quarter. The mechanism — D4 — is stable; the systems are not. Tosi et al.'s survey (arXiv:2402.13255) is the map of that territory and is updated; this chapter teaches the mechanism and points there. What is genuinely open, and worth knowing you are choosing when you adopt one of these maps: there is no closed-form posterior uncertainty, so nothing downstream can weight the map's own error the way an ESDF's weight field lets you.

Putting it together: the artifact Part VI consumes

Everything above, measured on one log — Rusty's 200-scan lap of the Apartment at 5 cm, seed 0xC0FFEE, poses from Chapter 16's optimized graph — with the port that runs the widgets on this page.

representationmemoryupdate / scanoccupancy probedistance probe
flat occupancy grid, 1 B/cell43.2 kB2.03 ms317 ns15.9 µs (ring search)
log-odds quadtree, 20 B/node, 6.25 cm leaves239 kB (11,957 nodes)0.45 ms429 nssame search, tree-shaped
TSDF, f32 DD and WW346 kB0.52 ms (0.11 without carving)422 ns (sign of DD)— truncated at τ\tau
ESDF, f32+173 kB+13.4 ms (full rebuild)254 ns
extracted contour, 2,236 segments35.8 kB15.5 ms (marching squares)O(F)O(F) crossing count45 µs brute force

Read the bold entries: four different winners in four columns. The cheapest map on the page is the extracted contour, which cannot answer either probe cheaply. The fastest to update is the quadtree, which costs five times the memory of the flat grid it was supposed to compress. The fastest occupancy lookup is the flat grid, which cannot answer a distance query without searching. And the query that actually runs a planner belongs to the distance field, which costs twelve times the grid's memory to buy a sixtyfold speedup on it. There is no row that dominates, and there was never going to be.

The handoff. What Part VI receives from this chapter is one file — esdf.bin, 173 kB, a f32 plane plus an origin and a resolution — and one guarantee: (d,d)(d, \nabla d) in O(1)O(1), eikonal to within about 7% away from the medial axis, and conservative where it is wrong. Chapter 20 uses it as a collision oracle, Chapter 23 differentiates it as an obstacle cost, and Chapter 24 uses the weight plane WW — the thing that makes this a filter rather than a picture — to find frontiers.

The honesty note this chapter owes you. Per-voxel independence is Chapter 13's structural lie, and every representation here inherits it: OctoMap estimates each node alone, TSDF fusion estimates each voxel alone, and neither has a way to say "these two cells are both wrong in the same direction because the pose was off." The principled alternative is a correlated field — Gaussian process implicit surfaces, and in particular the log-GP formulation of Wu et al. (2021), which recovers a genuine Euclidean distance field with calibrated uncertainty by solving the eikonal equation in the transformed space. It costs a dense covariance and is currently practical at room scale rather than building scale. When someone makes it cheap, this chapter changes.

Exercises

  1. Foundation exerciseDifficulty 2 of 3Finish D2: the steady-state variance of a clamped voxel

    D2 stops after showing that the clamp turns fusion into an exponentially weighted average with forgetting factor λ=Wmax/(Wmax+w)\lambda = W_{\max}/(W_{\max}+w). Complete it. (a) Show that the fused estimate under repeated unit-weight observations of a static truth has steady-state variance Var[D]=σ2/(2Wmax+w)\Var[D_\infty] = \sigma^2 / (2W_{\max} + w). (b) Compare with the unclamped filter's σ2/Wt\sigma^2/W_t and state, in one sentence, what the clamp costs you and what it buys. (c) At which WmaxW_{\max} does a WmaxW_{\max}-clamped voxel have the same variance as an unclamped voxel after 50 unit-weight scans?

    Hint Write the recursion as Dt=(1K)Dt1+KdtD_t = (1-K)D_{t-1} + K d_t with KK constant, take variances of both sides assuming independent observations, and solve the resulting fixed point V=(1K)2V+K2σ2/wV = (1-K)^2 V + K^2 \sigma^2/w.

  2. Foundation exerciseDifficulty 2 of 3Where hierarchy pays

    Using D1's ratio bnodecdSr/Vb_{\text{node}} c_d S r / V: (a) reproduce the Apartment's crossover r=1.5r^\star = 1.5 cm from S=180.6S = 180.6 m, V=108V = 108 m², bnode=20b_{\text{node}} = 20 B; (b) redo it in three dimensions for a 3 m ceiling, where a node needs eight child indices; (c) find the environment shape (as a ratio V/SV/S) at which a 5 cm octree first beats a dense byte array, and name a real robot workspace that qualifies.

  3. Foundation exerciseDifficulty 3 of 3Why the zero crossing survives a biased observation

    §3.2 claims the projective observation d=zsd = z - s is biased by 1/cosθ1/\cos\theta away from the surface but unbiased at it. (a) Prove the claim for a single planar surface and a single beam. (b) Now fuse two beams meeting the same plane at θ1=0\theta_1 = 0 and θ2=60°\theta_2 = 60° with equal weights, and find the zero crossing of the fused linear field along a line perpendicular to the surface. Is it still exact? (c) What does your answer predict about reconstruction error on walls seen only at a grazing angle — and how does Voxblox's cosθ\cos\theta weight term address it?

  4. Conceptual exerciseDifficulty 1 of 3Predict the ghost, then watch it

    Using λ=Wmax/(Wmax+w)\lambda = W_{\max}/(W_{\max}+w) from D2, predict how many re-observations it takes for the teleported chair's ghost to lose half its distance error at Wmax{4,24,64}W_{\max} \in \{4, 24, 64\}. Now open w19.1, click the chair's cell to inspect it, press move the chair, and count. Where does your prediction fail, and why? (The weight a carving update contributes is not 1.)

  5. Conceptual exerciseDifficulty 2 of 3Make each representation win

    In w19.2, find a query workload under which each of the four panes is the winner at some point, and write a one-sentence description of a real robot task with that workload. One pane never takes the green frame under any of the three workloads: name it, and say — using D1, and the cost of descending a tree instead of indexing an array — exactly what it is paying for and what it would take to make that payment worthwhile.

  6. Practical exerciseDifficulty 2 of 3A fifth representation

    Implement MapRepr for a k-d tree over raw scan endpoints and add it to the gallery harness. It should win the distance query at low point counts and lose it as the cloud grows. Report the crossover against the ESDF, and then answer the real question: why do production systems keep point clouds as the log and never as the primary map?

  7. Practical exerciseDifficulty 3 of 3Two channels beat one

    Extend the 1-D renderer from depth-only to depth plus reflectance. Show that on the seed where the depth-only fit parks density in the wrong place (w19.4, re-roll until the held-out error stays high while the training residual collapses), the two-channel version escapes — and explain which term of D4 Step 5's gradient the extra channel repaired. Then find a seed where two channels are still not enough, and say what a third would have to measure.

References

  1. Curless, B. and Levoy, M. (1996) A Volumetric Method for Building Complex Models from Range Images. SIGGRAPH '96, 303–312.doi:10.1145/237170.237269 (opens in a new tab)

    The origin of TSDF fusion, and the source of this chapter's epigraph. Appendix A sketches the least-squares optimality that D2 turns into a recursive filter.

  2. Lorensen, W. E. and Cline, H. E. (1987) Marching Cubes: A High Resolution 3D Surface Construction Algorithm. SIGGRAPH '87, Computer Graphics 21(4), 163–169.doi:10.1145/37401.37422 (opens in a new tab)

    The extraction step of §3.4, in three dimensions. The ambiguous cases this chapter dodges in 2-D are the reason the follow-up literature exists.

  3. Felzenszwalb, P. F. and Huttenlocher, D. P. (2012) Distance Transforms of Sampled Functions. Theory of Computing 8(19), 415–428.link to Distance Transforms of Sampled Functions (opens in a new tab)

    D3's lower-envelope algorithm, with the amortized Θ(n) argument. The clearest exposition of the trick that makes ESDFs affordable.

  4. Hornung, A., Wurm, K. M., Bennewitz, M., Stachniss, C., and Burgard, W. (2013) OctoMap: An Efficient Probabilistic 3D Mapping Framework Based on Octrees. Autonomous Robots 34(3), 189–206.doi:10.1007/s10514-012-9321-0 (opens in a new tab)

    The hierarchical representation of §3.1, including the clamping-and-pruning argument D1 formalizes and the node layout the memory bound counts.

  5. Oleynikova, H., Taylor, Z., Fehr, M., Siegwart, R., and Nieto, J. (2017) Voxblox: Incremental 3D Euclidean Signed Distance Fields for On-Board MAV Planning. IEEE/RSJ IROS 2017, 1366–1373.doi:10.1109/IROS.2017.8202315 (opens in a new tab)

    The system that made ESDF-from-TSDF standard practice in robotics, and the source of the weighting choices (inverse-square range, incidence angle) that D2 Step 5 reads as variance modeling.

  6. Reijgwart, V., Cadena, C., Siegwart, R., and Ott, L. (2023) Efficient Volumetric Mapping of Multi-Scale Environments Using Wavelet-Based Compression. Robotics: Science and Systems XIX.doi:10.15607/RSS.2023.XIX.065 (opens in a new tab)

    Where §3.1 goes next: wavelets instead of a plain octree, keeping multi-resolution queries while fixing the memory constant that makes this chapter's quadtree lose.

  7. Millane, A., Oleynikova, H., Wirbel, E., Steiner, R., Ramasamy, V., Tingdahl, D., and Siegwart, R. (2024) nvblox: GPU-Accelerated Incremental Signed Distance Field Mapping. IEEE ICRA 2024.link to nvblox: GPU-Accelerated Incremental Signed Distance Field Mapping (opens in a new tab)

    The production path for everything in §3.3 and §3.4: the same recursion, moved to the GPU, with measured speedups of two orders of magnitude on ESDF construction.

  8. Tosi, F., Zhang, Y., Gong, Z., Sandström, E., Mattoccia, S., Oswald, M. R., and Poggi, M. (2024) How NeRFs and 3D Gaussian Splatting are Reshaping SLAM: a Survey. arXiv:2402.13255.link to How NeRFs and 3D Gaussian Splatting are Reshaping SLAM: a Survey (opens in a new tab)

    The frontier map for this chapter's last section. Read it instead of any single system paper: it is maintained, and it states each system's assumptions in the terms D4 uses.