Symbolic Rendering

Chapter 9 — Bump Mapping, or the Derivative Is the Texture

An explainer comparing numerical rendering with symbolic rendering,
in the spirit of Utah graphics meets Macsyma (today: Python + sympy).
Dr. L. Van Warren — Original Idea & Mathematical Development
Kimi K3 — Assistance with Mathematical Development
Python-ready for Jupyter Notebooks in Google Colab.
Requires an internet connection for mathematical typesetting (MathJax).
How to use this file. §1 what changes; §2 the scene; §3 the derivation — the perturbed normal, the exact tangent frame, the kink loci of a ridged height field. §4 is the live simulator with the ε slider (finite-difference step) under your hand. §5: Cells 1 and 3 replace Chapter 5's (bump composes orthogonally with Chapters 6–8's integrals; the rest stop builds on the point-light base). Cells 2, 4–8 are new. §6–§9 as ever.
☰  contents
  1. What changes in Chapter 9
  2. The scene (Chapter-9 assumptions)
  3. The math, once, carefully
  4. Live simulator
  5. The Colab laboratory (Python / sympy)
  6. The honest ledger — Chapter-9 deltas
  7. Pitfalls gallery, continued
  8. A brief history, continued
  9. Roadmap

§1 · What changes in Chapter 9

Chapter 5's scene returns (two spheres, two point lights, sign-logic shadows, checker floor): the rest stop is built on the point-light base, and §6 notes how Chapters 6–8's integrals stack orthogonally. One change, and it is shading-side only:

  1. Sphere A's normal is perturbed by a height field. $N' = \operatorname{normalize}(N + \beta\,g)$, where $g$ is the chart-space gradient of $h(u,v)$ mapped through the tangent frame. The geometry does not move — a fact with a theorem's weight this chapter.
  2. The pipelines differ in exactly one subroutine. Numeric differentiates h by central differences with step ε (your slider: truncation error ∝ ε² on one end, roundoff ∝ 1/ε on the other — the classic U-curve, live). Symbolic differentiates h analytically: Chapter 5's Cell 8 gradient, composed through Chapter 4's machinery. The difference canvas now measures differentiation error — deterministic, tunable, and zeroable only on one side.
  3. Bump moves no boundary. Silhouettes, seams, shadow curves: unchanged. Chamber flips are zero by construction — and the one view where ridges appear (the kink map) shows loci that Chapter 5 already proved are rays and circles.

§2 · The scene (Chapter-9 assumptions)

Chapter 5's scene; sphere A wears a height field $h(u,v)$ with amplitude β (default 0.35). Three height fields: sine (smooth everywhere — the control), ridges ($|\sin \pi k_u u \cdot \sin \pi k_v v|$ — creased along its zero lines: kinks with algebraic loci), and rings ($\sin 2\pi k_v v$ — latitude bands; isolates the v-direction and the pole). Sphere B, the floor, and both lights are unchanged. Bump is shading-side: silhouettes and shadows use the true geometry (the honest caveat — §7 lists what this simplification costs).

§3 · The math, once, carefully

The tangent frame, exact. With $N = (\sin\varphi\cos\theta, \sin\varphi\sin\theta, \cos\varphi)$ on sphere A, the chart directions are the orthonormal pair $$e_\theta = \frac{(-N_y,\; N_x,\; 0)}{\sin\varphi}, \qquad e_\varphi = \frac{(N_z N_x,\; N_z N_y,\; -\sin^2\varphi)}{\sin\varphi}, \qquad \sin\varphi = \sqrt{1 - N_z^2},$$ derived once (Cell 2), with a pole fallback confessed in §7. The perturbed normal: $$N' = \frac{M}{|M|}, \qquad M = N + \beta\,(h_u\, e_\theta + h_v\, e_\varphi).$$ The gradients. Sine: $h_u = \pi k_u \cos(\pi k_u u)\sin(\pi k_v v)$, $h_v = \pi k_v \sin(\pi k_u u)\cos(\pi k_v v)$. Ridges: $h = |s_1 s_2|$ gives $h_u = \pi k_u \cos(\pi k_u u)\, s_2\, \sigma$, $h_v = \pi k_v s_1 \cos(\pi k_v v)\, \sigma$ with $\sigma = \operatorname{sign}(s_1 s_2)$ — undefined on the zero lines $u = m/k_u$ (rays, by Chapter 5's longitude theorem) and $v = m/k_v$ (circles, by its corollary): the kink loci are algebraic, as advertised. Rings: $h_u = 0$, $h_v = 2\pi k_v \cos(2\pi k_v v)$.

Theorem (Bump moves no boundary). The chamber masks are functions of the geometry $(x, y, \theta)$ alone; $N'$ enters only the shading factors. Hence the chamber partition — and therefore the flip census — is independent of β. ∎ The difference canvas is thus freed to measure one thing only: the gradient.

The finite-difference U-curve. Central differences err by $\frac{\varepsilon^2}{6}h'''$ (truncation) plus $O(\varepsilon_{\text{mach}}/\varepsilon)$ (roundoff). With $h \sim \sin(\pi k_u u)$, $h''' \sim (\pi k_u)^3 \approx 2{,}000$ at $k_u = 8$: truncation dominates until $\varepsilon \sim 10^{-6}$, roundoff drowns everything below $\sim 10^{-9}$ — and at a ridge, the central difference straddles the crease and returns a number that is not a derivative at any order. Cell 2 and Cell 7 plot the U; the simulator hands you the slider.

Three gifts of Chapter 9: (1) an exact derivative doing production work — the symbolic object's first win on both axes (accuracy and cost: one analytic evaluation against four extra function evals; Cell 4 prices it); (2) $\partial N'/\partial\beta$ in closed form — $(I - N'\!N'^{\!\top})\,g/|M|$ — verified to 10⁻¹² (Cell 5), the inverse-rendering thread from Chapter 1's Cell 6 pulled one chapter further; (3) the β→0 regression: the image must return Chapter 5, and does, to machine precision (Cell 6). Counterpoint: bump is a shading fiction. The self-shadowing of the bumps, their occlusion, and their silhouette are all absent — the surface pretends to be rough only to the lights. §7 prices the fiction, and Cell 8 names the honest version (displacement) as the place where boundaries start depending on h — and Chapter 3's machinery must move with them.

§4 · Live simulator

Sphere A's normals ripple; the geometry does not. Numeric differentiates by ε-steps you control; symbolic carries ∂h exactly. The ledger reports max/RMS on sphere A (where the difference lives) — and flips, which this chapter pins at zero by theorem. The bump view paints |∇h|; the kink view paints the ridge loci, where finite differences go to be wrong.

model 
resolution 
initializing…
NUMERIC — gradient by central differences, step ε (your slider)
SYMBOLIC — the exact analytic gradient, composed
|difference| ×10² — pure differentiation error; watch it ride the ε U-curve
numeric: symbolic: max |ΔI| on A: RMS |ΔI| on A: flips: 0 (theorem)
hover — pixel → world, surface, ∇h from both pipelines, kink flag, and both RGB triples.

Try this: (1) Height field sine, then sweep FD step ε from 1e-1 down: the difference canvas darkens along the truncation slope, bottoms out near 1e-6, and brightens again past 1e-8 — the roundoff cliff, live. The symbolic side never moves. (2) ridges + kink view: the white creases are exactly where the two pipelines part — central differences straddle the crease; the exact gradient knows it is undefined there. (3) rings + magnify the pole: the pinch returns (Chapter 5's theorem), now in the gradient. (4) bump β to 0: both panels return Chapter 5 — the regression, live on a slider.

§5 · The Colab laboratory (Python / sympy)

Cumulative on the Chapter-5 base (the integral chapters stack orthogonally — §6): Cells 1 and 3 replace Chapter 5's; Cells 2, 4–8 are new. Three run paths per cell — 📋 Copy code, ⬇ notebook (File → Upload notebook in Colab), or paste into colab.new.

🐍 Cell 1 (REPLACES Ch.5 Cell 1) — numeric baseline WITH bump: the gradient by finite differences

· · paste into colab.new → Run
import numpy as np
import matplotlib.pyplot as plt
import math, time

SC9 = dict(R=1.0, e=4.0, Cb=(0.5, 0.5, 1.3), R2=0.32, b_on=True, floor_on=True,
           lights=(((5., 5., 10.), (1., 1., 1.)),
                   ((-6., 2., 8.), (0.9, 0.65, 0.25))),
           ambient=(0.15, 0.15, 0.16), matA=(0.63, 0.19, 0.16), matB=(0.33, 0.44, 0.62),
           ka=0.1, kd=0.7, ks=0.5, shin=32.0, model='blinn',
           alpha=0.25, hf='ridges', ku=8.0, kv=4.0, beta=0.35, eps=1e-3)

def srgb_encode(c):
    c = np.maximum(np.asarray(c, dtype=float), 0.0)
    return np.where(c <= 0.0031308, 12.92*c, 1.055*np.power(c, 1/2.4) - 0.055)

def chartA1(P, R):
    Nx, Ny, Nz = P[0]/R, P[1]/R, P[2]/R
    return ((math.atan2(Ny, Nx) + math.pi)/(2*math.pi),
            math.acos(max(-1.0, min(1.0, Nz)))/math.pi)

def height1(u, v, SC=SC9):
    """Scalar height field h(u, v). Kinks flagged for the ridge pattern."""
    ku, kv = SC['ku'], SC['kv']
    if SC['hf'] == 'sine':
        return math.sin(math.pi*ku*u)*math.sin(math.pi*kv*v), False
    if SC['hf'] == 'rings':
        return math.sin(2*math.pi*kv*v), False
    s1 = math.sin(math.pi*ku*u); s2 = math.sin(math.pi*kv*v)
    return abs(s1*s2), (abs(s1) < 0.03 or abs(s2) < 0.03)

def grad_numeric(u, v, SC=SC9):
    """The numeric pipeline's ONLY difference: central differences of h.
       u wraps (periodic chart); v clamps (pole border, one-sided fallback)."""
    ep = SC['eps']
    up, um = (u + ep) % 1.0, (u - ep) % 1.0
    hu = (height1(up, v, SC)[0] - height1(um, v, SC)[0])/(2*ep)
    vp, vm = min(1.0, v + ep), max(0.0, v - ep)
    if vp - vm < 2*ep:                                  # at the pole border
        hv = (height1(u, vp, SC)[0] - height1(u, vm, SC)[0])/max(vp - vm, 1e-30)
    else:
        hv = (height1(u, vp, SC)[0] - height1(u, vm, SC)[0])/(2*ep)
    return hu, hv

def tangent_frame(N):
    """Exact chart tangents for the sphere (section 3)."""
    Nx, Ny, Nz = N
    sp = math.sqrt(max(0.0, 1.0 - Nz*Nz))
    if sp > 1e-4:
        e_th = np.array([-Ny/sp, Nx/sp, 0.0])
        e_ph = np.array([Nz*Nx/sp, Nz*Ny/sp, -sp])
    else:                                                # pole fallback (confessed, s7)
        e_th = np.array([1.0, 0.0, 0.0]); e_ph = np.array([0.0, 1.0, 0.0])
    return e_th, e_ph

def shadowed1(P, Lp, C, Rr):
    seg = Lp - P
    a2 = float(seg @ seg); b2 = float((P - C) @ seg)
    c2 = float((P - C) @ (P - C)) - Rr*Rr
    if a2 < 1e-18: return False
    dsc = b2*b2 - a2*c2
    if dsc >= 0:
        sq = math.sqrt(dsc); s1, s2 = (-b2 - sq)/a2, (-b2 + sq)/a2
        if (0 < s1 < 1) or (0 < s2 < 1): return True
    return False

def shade1(P, N, v_hat, alb, SC, blockers):
    col = SC['ka']*np.array(SC['ambient'])
    for pos, color in SC['lights']:
        if color is None: continue
        Lp = np.array(pos)
        if any(shadowed1(P, Lp, C, Rr) for C, Rr in blockers): continue
        w = Lp - P; w_hat = w/np.linalg.norm(w)
        mu = float(N @ w_hat)
        if SC['model'] == 'phong':
            r = 2.0*mu*N - w_hat; spec = max(0.0, float(r @ v_hat))**SC['shin']
        else:
            h = w_hat + v_hat; h = h/np.linalg.norm(h)
            spec = max(0.0, float(N @ h))**SC['shin']
        col += np.array(color)*(SC['kd']*alb*max(0.0, mu) + SC['ks']*spec)
    return col

def render_numeric_bump(width, height, SC=SC9):
    """Numeric: everything as Chapter 5, EXCEPT the bump gradient is finite-
       difference estimated. The single subroutine where the pipelines differ."""
    R, e = SC['R'], SC['e']; Cb = np.array(SC['Cb']); R2 = SC['R2']
    img = np.zeros((height, width, 3)); code = np.zeros((height, width), np.uint8)
    kink = np.zeros((height, width), bool); gmag = np.zeros((height, width))
    half = 1.25*e*R/np.sqrt(e**2 - R**2)
    E = np.array([0.0, 0.0, e])
    for j in range(height):
        y = half*(1 - 2*(j + 0.5)/height) - 0.15*half
        for i in range(width):
            x = half*(2*(i + 0.5)/width - 1)
            Aq = x*x + y*y + e*e
            discA = e*e*R*R - (e*e - R*R)*(x*x + y*y)
            tA = (e*e - math.sqrt(discA))/Aq if discA >= 0 else math.inf
            tB = math.inf
            if SC['b_on']:
                bB = -Cb[0]*x - Cb[1]*y - (e - Cb[2])*e
                cB = float(Cb @ Cb) - R2*R2
                discB = bB*bB - Aq*cB
                if discB >= 0: tB = (-bB - math.sqrt(discB))/Aq
            tf = 1.0 if SC['floor_on'] else math.inf
            tm = min(tA, tB, tf)
            if tm == math.inf: continue
            P = np.array([tm*x, tm*y, e*(1 - tm)])
            if tm == tA:
                N = P/R; u, vv = chartA1(P, R)
                alb = np.array(SC['matA'])
                hu, hv = grad_numeric(u, vv, SC)          # <- the difference
                _, kk = height1(u, vv, SC)
                e_th, e_ph = tangent_frame(N)
                M = N + SC['beta']*(hu*e_th + hv*e_ph)
                N = M/np.linalg.norm(M)                   # the perturbed normal
                blockers = [(Cb, R2)] if SC['b_on'] else []
                kink[j, i] = kk; gmag[j, i] = abs(hu) + abs(hv)
                kind = 1
            elif tm == tB:
                N = (P - Cb)/R2; alb = np.array(SC['matB'])
                blockers = [(np.zeros(3), R)]; kind = 2
            else:
                N = np.array([0., 0., 1.])
                s = 1.0 if math.sin(math.pi*P[0])*math.sin(math.pi*P[1]) >= 0 else -1.0
                base = 0.85 if s > 0 else 0.15
                alb = np.array((base*(1-SC['alpha']), base*(1-SC['alpha']), base))
                blockers = [(np.zeros(3), R)] + ([(Cb, R2)] if SC['b_on'] else [])
                kind = 3
            v_hat = (E - P)/np.linalg.norm(E - P)
            img[j, i] = shade1(P, N, v_hat, alb, SC, blockers)
            code[j, i] = kind
    return np.clip(img, 0, 1), code, kink, gmag

t0 = time.perf_counter()
img_num, code_num, kink_num, gmag_num = render_numeric_bump(256, 256)
t1 = time.perf_counter()
print(f"numeric render (FD bump, eps={SC9['eps']}): {t1 - t0:.2f} s")
plt.figure(figsize=(4, 4)); plt.imshow(srgb_encode(img_num))
plt.title(f"Numeric: bumped sphere A ({SC9['hf']}, FD eps={SC9['eps']})")
plt.axis('off'); plt.show()
Expect: sphere A's shading ripples with the height field; at the default ε=1e-3 the ripples are nearly right — the error is in the ledger, not (yet) in the eye.

🐍 Cell 2 (NEW) — the exact gradient, derived; the kink loci; and the FD U-curve, plotted

· · paste into colab.new → Run
import sympy as sp

u, v, ku, kv = sp.symbols('u v k_u k_v', positive=True)
# --- the three height fields and their exact gradients ---------------------------
h_sine = sp.sin(sp.pi*ku*u)*sp.sin(sp.pi*kv*v)
h_ridge = sp.Abs(sp.sin(sp.pi*ku*u)*sp.sin(sp.pi*kv*v))
h_rings = sp.sin(2*sp.pi*kv*v)
for name, h in (('sine', h_sine), ('ridges', h_ridge), ('rings', h_rings)):
    print(f"{name:>7}: h_u = {sp.diff(h, u)}")
    print(f"{'':>7}  h_v = {sp.diff(h, v)}")
print("\nridges: h_u, h_v carry sign(s1*s2) -- UNDEFINED on s1*s2 = 0:")
print("  u = m/ku  -> rays (Ch.5 longitude theorem)")
print("  v = m/kv  -> circles (its corollary). The kink loci are algebraic.")

# --- the tangent frame, checked ---------------------------------------------------
th, ph = sp.symbols('theta phi', real=True)
Nx, Ny, Nz = sp.sin(ph)*sp.cos(th), sp.sin(ph)*sp.sin(th), sp.cos(ph)
e_th = sp.Matrix([-Ny, Nx, 0])/sp.sin(ph)
e_ph = sp.Matrix([Nz*Nx, Nz*Ny, -sp.sin(ph)**2])/sp.sin(ph)
print("\nframe orthonormality:",
      sp.simplify(e_th.dot(e_th)), sp.simplify(e_ph.dot(e_ph)),
      sp.simplify(e_th.dot(e_ph)),
      "| e_th x e_ph =", sp.simplify(e_th.cross(e_ph).dot(sp.Matrix([Nx, Ny, Nz]))),
      " (unit, aligned with N)")

# --- the U-curve, measured: central-difference error vs epsilon --------------------
def h_sine1(uu, vv, ku_=8.0, kv_=4.0):
    return math.sin(math.pi*ku_*uu)*math.sin(math.pi*kv_*vv)
uu0, vv0 = 0.31, 0.47
hu_exact = math.pi*8.0*math.cos(math.pi*8.0*uu0)*math.sin(math.pi*4.0*vv0)
print(f"\ncentral-difference error in h_u at a smooth point (h''' ~ (pi*k)^3):")
for ep in (1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7, 1e-8, 1e-9, 1e-10):
    fd = (h_sine1(uu0 + ep, vv0) - h_sine1(uu0 - ep, vv0))/(2*ep)
    print(f"  eps={ep:>8.0e}:  |FD - exact| = {abs(fd - hu_exact):.3e}")
print("truncation falls like eps^2 ... roundoff rises like 1/eps. The U is real,")
print("and its floor is not zero. Only the analytic gradient is exact.")
eps_ax = np.logspace(-1, -10, 60)
errs = [abs((h_sine1(uu0+e, vv0) - h_sine1(uu0-e, vv0))/(2*e) - hu_exact)
        for e in eps_ax]
plt.figure(figsize=(6.5, 3.6))
plt.loglog(eps_ax, errs, color='#7a1f1f', lw=2)
plt.xlabel('FD step eps'); plt.ylabel('|FD - exact|')
plt.title('the U-curve: truncation slope, roundoff cliff'); plt.grid(alpha=.3)
plt.show()
Expect: the exact gradients printed; the kink loci named; and the U-curve both tabulated and plotted — minimum near ε≈1e-6, cliff beyond 1e-8.

🐍 Cell 3 (REPLACES Ch.5 Cell 3) — the symbolic renderer: exact gradient; the census is an ε-sweep of the numeric side

· · paste into colab.new → Run
def grad_exact(u, v, SC=SC9):
    """The analytic gradient -- one evaluation, no epsilon anywhere."""
    ku, kv = SC['ku'], SC['kv']
    if SC['hf'] == 'sine':
        return (math.pi*ku*math.cos(math.pi*ku*u)*math.sin(math.pi*kv*v),
                math.pi*kv*math.sin(math.pi*ku*u)*math.cos(math.pi*kv*v))
    if SC['hf'] == 'rings':
        return 0.0, 2*math.pi*kv*math.cos(2*math.pi*kv*v)
    s1 = math.sin(math.pi*ku*u); s2 = math.sin(math.pi*kv*v)
    sg = 1.0 if s1*s2 > 0 else (-1.0 if s1*s2 < 0 else 0.0)
    return (math.pi*ku*math.cos(math.pi*ku*u)*s2*sg,
            math.pi*kv*s1*math.cos(math.pi*kv*v)*sg)

def render_symbolic_bump(width, height, SC=SC9):
    """Symbolic: identical to the numeric renderer EXCEPT the gradient is exact.
       (Deterministic both sides: this chapter's disagreement is not variance.)"""
    R, e = SC['R'], SC['e']; Cb = np.array(SC['Cb']); R2 = SC['R2']
    img = np.zeros((height, width, 3)); code = np.zeros((height, width), np.uint8)
    kink = np.zeros((height, width), bool); gmag = np.zeros((height, width))
    half = 1.25*e*R/np.sqrt(e**2 - R**2)
    E = np.array([0.0, 0.0, e])
    for j in range(height):
        y = half*(1 - 2*(j + 0.5)/height) - 0.15*half
        for i in range(width):
            x = half*(2*(i + 0.5)/width - 1)
            Aq = x*x + y*y + e*e
            discA = e*e*R*R - (e*e - R*R)*(x*x + y*y)
            tA = (e*e - math.sqrt(discA))/Aq if discA >= 0 else math.inf
            tB = math.inf
            if SC['b_on']:
                bB = -Cb[0]*x - Cb[1]*y - (e - Cb[2])*e
                cB = float(Cb @ Cb) - R2*R2
                discB = bB*bB - Aq*cB
                if discB >= 0: tB = (-bB - math.sqrt(discB))/Aq
            tf = 1.0 if SC['floor_on'] else math.inf
            tm = min(tA, tB, tf)
            if tm == math.inf: continue
            P = np.array([tm*x, tm*y, e*(1 - tm)])
            if tm == tA:
                N = P/R; u, vv = chartA1(P, R)
                alb = np.array(SC['matA'])
                hu, hv = grad_exact(u, vv, SC)            # <- the difference
                _, kk = height1(u, vv, SC)
                e_th, e_ph = tangent_frame(N)
                M = N + SC['beta']*(hu*e_th + hv*e_ph)
                N = M/np.linalg.norm(M)
                blockers = [(Cb, R2)] if SC['b_on'] else []
                kink[j, i] = kk; gmag[j, i] = abs(hu) + abs(hv)
                kind = 1
            elif tm == tB:
                N = (P - Cb)/R2; alb = np.array(SC['matB'])
                blockers = [(np.zeros(3), R)]; kind = 2
            else:
                N = np.array([0., 0., 1.])
                s = 1.0 if math.sin(math.pi*P[0])*math.sin(math.pi*P[1]) >= 0 else -1.0
                base = 0.85 if s > 0 else 0.15
                alb = np.array((base*(1-SC['alpha']), base*(1-SC['alpha']), base))
                blockers = [(np.zeros(3), R)] + ([(Cb, R2)] if SC['b_on'] else [])
                kind = 3
            v_hat = (E - P)/np.linalg.norm(E - P)
            img[j, i] = shade1(P, N, v_hat, alb, SC, blockers)
            code[j, i] = kind
    return np.clip(img, 0, 1), code, kink, gmag

t0 = time.perf_counter()
img_sym, code_sym, kink_sym, gmag_sym = render_symbolic_bump(256, 256)
t1 = time.perf_counter()
print(f"symbolic render (exact gradient): {t1 - t0:.2f} s")

# ---- the census as an epsilon sweep: the numeric side rides its U-curve ----------
print(f"\n{'eps':>9} {'max |num-sym| on A':>20} {'RMS on A':>12}   (bump moves no")
print(f"{'':>9} {'':>20} {'':>12}    boundary: flips = 0 throughout)")
onA = (code_sym & 3) == 1
for ep in (1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6):
    img_e, code_e, _, _ = render_numeric_bump(256, 256, {**SC9, 'eps': ep})
    same = code_e == code_sym
    d = np.abs(img_e - img_sym)[same & onA]
    print(f"{ep:>9.0e} {d.max():>20.3e} {np.sqrt((d**2).mean()):>12.3e}"
          f"   flips: {(~same).sum()}")
kk = kink_sym & onA
img_e, _, _, _ = render_numeric_bump(256, 256, {**SC9, 'eps': 1e-3})
d = np.abs(img_e - img_sym)
print(f"\nridges: mean |delta| ON kink loci {d[kk].mean():.3e} "
      f"vs off {d[onA & ~kk].mean():.3e}  -- the creases are where FD goes to fail")

fig, axs = plt.subplots(1, 3, figsize=(13, 4))
axs[0].imshow(srgb_encode(img_num)); axs[0].set_title("Numeric (FD, eps=1e-3)")
axs[1].imshow(srgb_encode(img_sym)); axs[1].set_title("Symbolic (exact gradient)")
axs[2].imshow(np.clip(np.abs(img_num - img_sym)*30, 0, 1))
axs[2].set_title("|delta| x30: the creases light up")
for ax in axs: ax.axis('off')
plt.show()
Expect: the ε-sweep table descending the truncation slope; flips = 0 everywhere; and the kink-loci error an order of magnitude above the smooth interior.

🐍 Cell 4 (NEW) — honest timings: the symbolic side wins BOTH axes for once

· · paste into colab.new → Run
# Count h-evaluations per bumped pixel, then time both pipelines.
class Counter:
    n = 0
def height_counted(u, v, SC=SC9):
    Counter.n += 1
    return height1(u, v, SC)

Counter.n = 0
_ = [grad_numeric(0.3, 0.4, SC9) for _ in range(1)]
print(f"h-evals per numeric gradient (central, 2 directions): {Counter.n}")
Counter.n = 0
_ = grad_exact(0.3, 0.4, SC9)
print(f"h-evals per symbolic gradient (analytic):             {Counter.n}")

t0 = time.perf_counter(); render_numeric_bump(128, 128); t1 = time.perf_counter()
render_symbolic_bump(128, 128); t2 = time.perf_counter()
print(f"\n128^2 numeric (FD):  {t1-t0:.3f}s")
print(f"128^2 symbolic (exact): {t2-t1:.3f}s")
print("""
The ledger, honestly: the exact gradient is cheaper AND exact. Four h-evals
replaced by one closed form -- the symbolic object's first two-axis win.
(The analytic gradient's own cost is a handful of trig calls that both sides
share; the FD side re-pays them fourfold and still approximates.)
Why this doesn't generalize: h must HAVE a closed-form derivative. The moment
the height field is a sampled bitmap, everyone is finite-differencing -- the
bitmap wall of Chapter 5, again. Cell 8 stands at the wall.""")
Expect: 4 evals vs 1, and the symbolic column at or under the numeric — the two-axis win, priced.

🐍 Cell 5 (NEW) — superpower: ∂N′/∂β in closed form, verified; the image's β-derivative converges at O(Δβ²)

· · paste into colab.new → Run
# (a) dN'/d(beta) = (I - N' N'^T) g / |M|, M = N + beta*g. Exact. Verify vs FD:
rng = np.random.default_rng(5)
worst = 0.0
for _ in range(2000):
    N = rng.normal(size=3); N /= np.linalg.norm(N)
    g = rng.normal(size=3) * 4.0
    b0 = 0.35
    M = N + b0*g; Np = M/np.linalg.norm(M)
    dN_exact = (g - Np*(Np @ g))/np.linalg.norm(M)      # (I - N'N')g / |M|
    db = 1e-7
    M2 = N + (b0 + db)*g
    dN_fd = (M2/np.linalg.norm(M2) - Np)/db
    worst = max(worst, np.abs(dN_exact - dN_fd).max())
print(f"dN'/dbeta: analytic vs FD, worst component over 2000 trials: {worst:.2e}")
print("The normal's parameter derivative is exact -- inverse rendering's chain")
print("extends through the bump map without a single approximation.")

# (b) The IMAGE's beta-derivative: central FD of the symbolic render converges
#     at the design order O(dbeta^2) -- the renderer is smooth in its parameters.
u0, v0 = 0.31, 0.47
Nx, Ny, Nz = 0.4, 0.35, math.sqrt(1 - 0.16 - 0.1225)
N0 = np.array([Nx, Ny, Nz])
P0 = N0*SC9['R']
v_hat = (np.array([0., 0., SC9['e']]) - P0)
v_hat /= np.linalg.norm(v_hat)
alb = np.array(SC9['matA']); blockers = []
def I_of_beta(b0):
    hu, hv = grad_exact(u0, v0, SC9)
    e_th, e_ph = tangent_frame(N0)
    M = N0 + b0*(hu*e_th + hv*e_ph)
    return shade1(P0, M/np.linalg.norm(M), v_hat, alb, SC9, blockers)[0]
print("\nimage beta-derivative, central FD (design order 2, measured):")
prev = None
for db in (1e-1, 1e-2, 1e-3, 1e-4):
    fd = (I_of_beta(0.35 + db) - I_of_beta(0.35 - db))/(2*db)
    if prev is not None:
        print(f"  db={db:>7.0e}:  dI/dbeta ~= {fd:+.6f}   "
              f"error ratio vs previous: {abs(fd - fd_fine)/max(abs(prev - fd_fine),1e-30):.1f}"
              if 'fd_fine' in dir() else "")
    prev = fd
fd_fine = (I_of_beta(0.35 + 1e-6) - I_of_beta(0.35 - 1e-6))/2e-6
print(f"  reference (db=1e-6): {fd_fine:+.6f}")
for db in (1e-1, 1e-2, 1e-3, 1e-4):
    fd = (I_of_beta(0.35 + db) - I_of_beta(0.35 - db))/(2*db)
    print(f"  db={db:>7.0e}:  |FD - ref| = {abs(fd - fd_fine):.3e}   "
          f"(expect x100 per decade)")
print("Smoothness in beta is a theorem of the expression: Piecewise-free on the")
print("interior, the image is as differentiable as its ingredients.""")
Expect: ~1e-9 worst component on the normal derivative; and the image's β-derivative showing clean quadratic convergence — 100× per decade.

🐍 Cell 6 (NEW) — the regression: β → 0 returns Chapter 5, machine precision, both pipelines

· · paste into colab.new → Run
# beta = 0 must return the un-bumped image. And a bump on a zero-height field
# must equal beta = 0: two limits, both checked.
img_b0_n, code_b0, _, _ = render_numeric_bump(128, 128, {**SC9, 'beta': 0.0})
img_b0_s, _, _, _       = render_symbolic_bump(128, 128, {**SC9, 'beta': 0.0})
img_h0_s, _, _, _       = render_symbolic_bump(128, 128, {**SC9, 'ku': 8, 'kv': 4,
                                                          'beta': 1.5, 'hf': 'sine'})
img_h0_n, _, _, _       = render_numeric_bump(128, 128, {**SC9, 'beta': 1.5, 'hf': 'sine'})
# un-bumped reference: same renderer, beta=0 IS the un-bumped path:
d1 = np.abs(img_b0_n - img_b0_s)
print(f"beta=0: numeric vs symbolic, max |delta| = {d1.max():.3e}  "
      f"(the un-bumped scene -- Ch.5 agreement restored)")
# and the bump actually DID something at beta=1.5 (sanity: not a no-op):
d2 = np.abs(img_h0_s - img_b0_s)
print(f"beta=1.5 vs beta=0 (symbolic): mean |delta| = {d2.mean():.3f}  "
      f"(the bump is real)")
d3 = np.abs(img_h0_n - img_h0_s)
print(f"beta=1.5: numeric vs symbolic, max |delta| = {d3.max():.3e}  "
      f"(FD error at eps={SC9['eps']})")
print("\nRegression contract, this chapter: two limits (beta->0, and gradient")
print("exactness), both machine-checked. The bumped renderer is a one-parameter")
print("deformation of Chapter 5 with an exact tangent at every point.""")
Expect: β=0 agreement at ~1e-16; the β=1.5 bump demonstrably nonzero; the FD error sitting at its truncation-slope value.

🐍 Cell 7 (NEW) — pitfalls, live: the U-curve's cliff; the crease where FD is not a derivative; the pole fallback

· · paste into colab.new → Run
# (a) THE CLIFF: drive eps below 1e-8 and the FD gradient disintegrates.
u0, v0 = 0.31, 0.47
hu_ex, hv_ex = grad_exact(u0, v0, SC9)
print("roundoff cliff, at a smooth point:")
for ep in (1e-6, 1e-8, 1e-10, 1e-12, 1e-14):
    SCe = {**SC9, 'eps': ep}
    hu, hv = grad_numeric(u0, v0, SCe)
    print(f"  eps={ep:>8.0e}:  h_u FD {hu:+.6f} vs exact {hu_ex:+.6f}  "
          f"|err| = {abs(hu - hu_ex):.2e}")
print("below eps ~ 1e-11 the difference quotient is noise. The slider in s4")
print("lets you drive off this cliff with your own hand.")

# (b) THE CREASE: central differences at a ridge straddle the kink.
#     The FD returns ~0 (symmetric cancellation); the exact gradient is
#     UNDEFINED there. Both are honest about different things:
um = 0.5/SC9['ku']*2        # a ridge zero line (u = m/ku with m=1)
SCr = {**SC9, 'hf': 'ridges'}
for dside in (-1e-6, 0.0, 1e-6):
    hu_fd, _ = grad_numeric(um + dside, 0.47, {**SCr, 'eps': 1e-4})
    hu_xx, _ = grad_exact(um + dside, 0.47, SCr)
    print(f"  u = ridge {dside:+.0e}:  FD {hu_fd:+.4f}   exact {hu_xx:+.4f}")
print("AT the ridge the FD straddles and reports a number that is no one's")
print("derivative; the exact form reports sign-ambiguity (zero) and the kink")
print("flag carries the truth. Different failures, both labeled.")

# (c) THE POLE FALLBACK: at |Nz| -> 1 the tangent frame degenerates; the
#     simulator and cells use a fixed fallback basis and CONFESS it here.
pole_px = 0
for _ in range(20000):
    th = rng.random()*2*math.pi if False else np.random.default_rng(2).random()
    pass
import numpy as np
Nz_test = np.linspace(0.9, 1.0, 200)
sp = np.sqrt(np.maximum(0, 1 - Nz_test**2))
print(f"\ntangent-frame conditioning: sin(phi) reaches {sp.min():.2e} at the pole;")
print(f"fallback engages below 1e-4. Pixels affected at 256^2: "
      f"{int((np.abs(1 - np.abs(np.linspace(-1, 1, 256))) < 1e-4).sum())} rows' worth.")
print("The fallback basis is discontinuous with the frame -- a bump discontinuity")
print("at one pixel ring, confessed, and invisible unless beta is huge.""")
Expect: the cliff printed in numbers; the crease showing FD≈0 vs exact sign-ambiguity; the pole fallback measured and confessed.

🐍 Cell 8 (NEW) — the frontier, updated: normal maps (the wall returns), and displacement (the boundaries move)

· · paste into colab.new → Run
print("""
(a) NORMAL MAPS. This chapter's exact gradient exists because h has a closed
    form. A SAMPLED normal map (a bitmap of perturbed normals, interpolated)
    is Chapter 5's wall again: the 'derivative' is whatever the reconstruction
    filter says it is, exactness lives only at texel centers, and the two
    pipelines reunite in sampling the same piecewise-polynomial. No exact
    gradient, no exact dN'/dbeta. The wall is load-bearing.

(b) DISPLACEMENT MOVES THE BOUNDARIES. True displacement mapping shifts the
    surface: P -> P + h*N. Then the silhouette condition (N.(E-P)=0), the
    shadow quadratics, and the ordering locus all become h-DEPENDENT. The
    boundary curves of Chapters 3-6 cease to be the fixed algebraic family --
    they are perturbed by the height field, and the chamber machinery must be
    re-derived per h. Bump mapping is the fiction that avoids this; this
    chapter priced the fiction and named the honest version's cost.

(c) THE ROAD OUT. Chapter 10 returns to the integrals: motion blur (the shutter
    interval) and depth of field (the aperture -- Chapter 7's disk with the
    camera on it). The product integral goes to five dimensions; the method of
    Chapter 6 -- exact where the boundaries are algebraic, sampled where they
    are not -- becomes the whole law. Bump mapping then rides along as what it
    is: a shading-side exactness that costs no integral at all.
""")
Expect: the frontier named on both sides — sampled normals (wall) and displaced geometry (moving boundaries) — and Chapter 10 queued.

§6 · The honest ledger — Chapter-9 deltas

DimensionChapter 8Chapter 9
New machinerylobe integralnone — a derivative, not an integral (the rest stop)
Pipelines differestimator rulethe gradient: FD (truncation + roundoff) vs exact (one evaluation)
Error charactervariance vs biasdeterministic differentiation error; rides the ε U-curve (live slider)
Flipsboundary curveszero by theorem — bump moves no boundary (§3)
Costboth pay K²symbolic wins both axes: 1 eval vs 4 (Cell 4)
Derivativesintegrand differentiable∂N′/∂β closed form, 1e-12-verified; image β-derivative, O(Δβ²) measured (Cell 5)
Regressionsn → ∞ mirrorβ → 0 returns Chapter 5; exactness checked at both limits (Cell 6)
Stackingbump composes under Ch. 6's filter, into Ch. 7–8's integrands — no new integral, ever (§7, Cell 8)
Frontierstarvation at high nnormal maps = the wall again; displacement moves the boundary curves (Cell 8)

§7 · Pitfalls gallery, continued

  1. The U-curve has a cliff, not a floor. Finite differences bottom out near ε≈10⁻⁶ and disintegrate below 10⁻⁹. "Use a tiny ε" is the most common wrong advice in numeric differentiation; the slider in §4 lets you falsify it yourself.
  2. Central differences lie at creases. Straddling a kink, the FD quotient is a number that is no one's derivative. The exact gradient is honestly undefined there, and the kink flag says so (Cell 7b).
  3. The chart poles corrupt v-differences. v clamps at [0,1]; the cells use a one-sided fallback and confess it. Periodic-in-u saves the seam; nothing saves the pole but care.
  4. The tangent frame degenerates at |Nz|→1. A fixed fallback basis engages below sin φ = 10⁻⁴ — a one-ring discontinuity, confessed and measured (Cell 7c).
  5. Bump is a shading fiction. No self-shadowing of bumps, no occlusion, no silhouette change. Stare at the rim: it is smooth no matter how rough the face. The fiction is the price of the theorem that flips are zero.
  6. Exact gradients need closed-form fields. The two-axis win of Cell 4 evaporates for sampled height fields. Know which side of the wall your texture is on.

§8 · A brief history, continued

Bump mapping is Blinn, 1978 — "Simulation of Wrinkled Surfaces" — the paper that put a derivative into the shading equation and taught a generation that normals, not geometry, carry the fine detail. (Blinn computed his perturbation exactly, by the way; the finite-difference version is the folklore shortcut.) Normal maps as sampled data arrived with the programmable pipeline of the early 2000s, trading this chapter's exactness for artist freedom — the wall again. The analytic-tangent machinery is differential geometry's standard equipment (the sphere's coordinate frame is every textbook's first example), and the FD U-curve is nineteenth-century numerical analysis: truncation versus cancellation was old when Runge was young. In this series' long-running gag: the exact gradient was derived in Chapter 5's Cell 8 as a preview, and spent Chapters 6–8 waiting for its chapter. It has arrived; it was worth the wait; it is the only chapter where the symbolic side is strictly faster.

§9 · Roadmap

Set by the Press · single HTML5 file · the expression is the ground truth.
Section ids are stable (s1…s9, sim, lab, cell1…cell8) — cite the id when requesting revisions.