Chapter 9 — Bump Mapping, or the Derivative Is the Texture
in the spirit of Utah graphics meets Macsyma (today: Python + sympy).
Kimi K3 — Assistance with Mathematical Development
☰ contents
§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:
- 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.
- 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.
- 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.
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
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()
🐍 Cell 2 (NEW) — the exact gradient, derived; the kink loci; and the FD U-curve, plotted
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()
🐍 Cell 3 (REPLACES Ch.5 Cell 3) — the symbolic renderer: exact gradient; the census is an ε-sweep of the numeric side
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()
🐍 Cell 4 (NEW) — honest timings: the symbolic side wins BOTH axes for once
# 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.""")
🐍 Cell 5 (NEW) — superpower: ∂N′/∂β in closed form, verified; the image's β-derivative converges at O(Δβ²)
# (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.""")
🐍 Cell 6 (NEW) — the regression: β → 0 returns Chapter 5, machine precision, both pipelines
# 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.""")
🐍 Cell 7 (NEW) — pitfalls, live: the U-curve's cliff; the crease where FD is not a derivative; the pole fallback
# (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.""")
🐍 Cell 8 (NEW) — the frontier, updated: normal maps (the wall returns), and displacement (the boundaries move)
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.
""")
§6 · The honest ledger — Chapter-9 deltas
| Dimension | Chapter 8 | Chapter 9 |
|---|---|---|
| New machinery | lobe integral | none — a derivative, not an integral (the rest stop) |
| Pipelines differ | estimator rule | the gradient: FD (truncation + roundoff) vs exact (one evaluation) |
| Error character | variance vs bias | deterministic differentiation error; rides the ε U-curve (live slider) |
| Flips | boundary curves | zero by theorem — bump moves no boundary (§3) |
| Cost | both pay K² | symbolic wins both axes: 1 eval vs 4 (Cell 4) |
| Derivatives | integrand differentiable | ∂N′/∂β closed form, 1e-12-verified; image β-derivative, O(Δβ²) measured (Cell 5) |
| Regressions | n → ∞ mirror | β → 0 returns Chapter 5; exactness checked at both limits (Cell 6) |
| Stacking | — | bump composes under Ch. 6's filter, into Ch. 7–8's integrands — no new integral, ever (§7, Cell 8) |
| Frontier | starvation at high n | normal maps = the wall again; displacement moves the boundary curves (Cell 8) |
§7 · Pitfalls gallery, continued
- 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.
- 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).
- 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.
- The tangent frame degenerates at |Nz|→1. A fixed fallback basis engages below sin φ = 10⁻⁴ — a one-ring discontinuity, confessed and measured (Cell 7c).
- 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.
- 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
- Ch. 10 — Motion blur and depth of field: the shutter interval and the aperture disk (Chapter 7's geometry, on the camera); the product integral goes 5-D.
- Appendix — Path space, participating media, and the settling of the symbolic object's final job: the integrand you can hold, differentiate (this chapter), and sample (Chapters 6–8), whose boundaries you can name (Chapters 3–5), and whose closed form you keep precisely as far as it goes — and no farther.
Section ids are stable (s1…s9, sim, lab, cell1…cell8) — cite the id when requesting revisions.