Symbolic Rendering

Chapter 5 — Texture, or Albedo Becomes a Function

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 chart, its seam and pole as algebraic loci, the gamma piecewise, and the bitmap as floor-arithmetic. §4 is the live simulator with texture/chart views and instant gamma re-encoding. §5 is the laboratory: Cells 1 and 3 replace Chapter 3's (textured renderer, agreement census); Cells 2, 4–8 are new. §6–§9 as ever.
☰  contents
  1. What changes in Chapter 5
  2. The scene (Chapter-5 assumptions)
  3. The math, once, carefully
  4. Live simulator
  5. The Colab laboratory (Python / sympy)
  6. The honest ledger — Chapter-5 deltas
  7. Pitfalls gallery, continued
  8. A brief history, continued
  9. Roadmap

§1 · What changes in Chapter 5

Chapter 3's scene returns (two spheres, two lights, sign-logic shadows, the diffuse checker floor); Chapter 4's mirror is left where it lives — composition had its chapter. Three novelties:

  1. Albedo is a function. Sphere A's material is modulated by τ(u, v), composed with a chart from the surface to the texture square. Four textures: uv-checker, stripes, marble — and a sampled bitmap, generated procedurally and identically in JS and Python, so the agreement census cross-validates even the bitmap.
  2. The chart has singularities, and they are theorems. Longitude/latitude has a branch cut (the seam) and a pole. Under central projection both have closed-form images in the view window — §3 derives them; the simulator paints them.
  3. Gamma is faced. Previous chapters rendered linear and showed it raw. Now the sRGB encode is an explicit piecewise function applied at display — and the legacy pipeline (lighting in gamma space) is available as a switchable, measurable error.

§2 · The scene (Chapter-5 assumptions)

Chapter 3's scene, plus: a texture τ with frequencies $k_u, k_v$ on sphere A; a 16×16 procedural bitmap (ring pattern with a red marker dot, orientation visible); the floor checker retained as the trivial chart $u=x, v=y$; and a display stage with three policies — linear raw (Chapters 1–4), sRGB encode (correct), legacy (materials pre-encoded, lighting in gamma space, displayed raw — wrong, on purpose). Chart convention: $\theta = \operatorname{atan2}(N_y, N_x)$, $\varphi = \arccos(N_z)$, $u = (\theta+\pi)/2\pi$, $v = \varphi/\pi$ — one convention, shared by both pipelines, because branch cuts are contracts.

§3 · The math, once, carefully

The chart is composition. $I(x,y) = \text{shade}(\ldots,\ \tau(u(P(x,y)),\, v(P(x,y))),\ \ldots)$ — one more composition in Chapter 4's sense, and Cell 2 counts the swell. But this chart has two exact gifts:

Theorem (Longitude is the pixel's polar angle). On sphere A (center origin), $N_x = t x/R$ and $N_y = t y/R$ with $t \gt 0$, $R \gt 0$ — a positive common scaling, which atan2 ignores: $$\operatorname{atan2}(N_y, N_x) = \operatorname{atan2}(y, x) \quad\text{exactly.}$$ So the seam — $\theta$'s branch cut, $N_y = 0 \land N_x \lt 0$ — renders as the negative x-axis of the view window: a straight slit from the center to the left rim. Corollary (the pole sits at the window's center). $N_z = e(1-t)/R$ depends only on $r^2 = x^2+y^2$ (Δ and A both do), so $v$ is a function of radius: constant-$v$ curves are circles, constant-$u$ curves are rays, the front point $(x,y)=(0,0)$ is the chart's pole, and the uv-checker degenerates there to a pinwheel. The chart's pathology map is polar graph paper — provable (Cell 2) before a single pixel is textured.

The textures. Checker: $\tau = \operatorname{sign}(\sin \pi k_u u \cdot \sin \pi k_v v)$; stripes and marble likewise closed-form. The bitmap is the interesting one: nearest sampling is floor-arithmetic $\tau(u,v) = B[\lfloor N v' \rfloor, \lfloor N u' \rfloor]$ (a piecewise-constant function), and bilinear is a tent convolution — a piecewise-polynomial function. A sampled bitmap is not an approximation of a function; it is one, and "sampled vs closed-form" is a comparison of two symbolic objects (Cell 6).

Gamma is a composition-order theorem. The sRGB encode is a Piecewise: $$E(c) = \begin{cases} 12.92\,c, & c \le 0.0031308\\[2pt] 1.055\,c^{1/2.4} - 0.055, & \text{otherwise}\end{cases}$$ Correct pipeline: $\text{display} = E(\text{lighting}(\text{linear albedo}))$. Legacy pipeline: $\text{display} = \text{lighting}(E(\text{albedo}))$. These differ provably: $E(a+b) \ne E(a) + E(b)$ (sympy: substitute $a=b=\tfrac12$; the difference is a nonzero constant — Cell 5). Light addition must happen in linear space; the legacy washout is not a style, it is a measured error term.

Three gifts of Chapter 5: (1) chart singularities with closed-form images, drawn live; (2) gamma non-commutativity as a one-line theorem; (3) the bitmap's two sampling modes as two exact functions whose difference is computable per texel. Counterpoint: the chart is transcendental — for the closed-form textures the boundary curves wash out algebraic (rays and circles!), but for a bitmap, texture-boundary curves are images of arbitrary curves through a transcendental chart: Chapter 3's Sturm/CAD machinery no longer applies, and exact antialiasing of textured regions (Chapter 6) must split pixels along curves it can only sample. The frontier moves; it does not retreat.

§4 · Live simulator

Sphere A wears τ; the floor checker is the identity chart. The chart view paints (u, v) directly — watch the seam slit and the central pinwheel match §3's derivation. The sRGB toggle re-encodes the display without re-rendering (the buffers are linear; encoding is the last mile). The ledger reports interior max |ΔI|, flips — and max |Δ(u,v)|, the chart's own agreement.

model 
resolution 
initializing…
NUMERIC — chart & texture evaluated lazily, on hit
SYMBOLIC — total expression: τ evaluated everywhere, selected by chambers
|difference| ×10¹⁵ — black interior; flips trace the boundaries (and the seam)
numeric: symbolic: interior max |ΔI|: flips: max |Δ(u,v)|:
hover — the probe shows pixel → world, chamber, (u, v), τ, and both RGB triples.

Try this: (1) chart view with stripes: the seam is the horizontal slit left of center, the pinwheel is the chart's pole — both derived in §3, drawn here. (2) bitmap + magnify 0.05: texel Moiré; flip nearest ↔ bilinear — two exact functions, one visible difference. (3) Check legacy: the wash appears; flip display to linear raw and the ledger stays black — numeric and symbolic are wrong together. The census measures formulation agreement, never correctness; correctness is §3's theorem. (4) raw Heaviside: the NaN census, now with a texture riding along.

§5 · The Colab laboratory (Python / sympy)

Cumulative as ever: Cells 1 and 3 replace Chapter 3's; Cells 2, 4–8 are new. Paste in order, run in order; three run paths per cell — 📋 Copy code, ⬇ notebook (File → Upload notebook in Colab), or paste into colab.new.

🐍 Cell 1 (REPLACES Ch.3 Cell 1) — the numerical baseline: textures, bitmap, gamma policies

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

SC5 = 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',
           tex='checker', ku=8.0, kv=4.0, bmp_sampling='bilinear',
           legacy=False, alpha=0.25)

# --- the shared procedural bitmap: IDENTICAL formula in the JS simulator ------
NB = 16
ii, jj = np.meshgrid(np.arange(NB), np.arange(NB))
_ring = (np.floor(np.hypot(ii - 7.5, jj - 7.5)) % 2) == 0
BITMAP = np.where(_ring[..., None],
                  np.array([0.85, 0.62, 0.25]), np.array([0.16, 0.20, 0.34])).astype(float)
BITMAP[4, 4] = [0.80, 0.15, 0.15]      # orientation marker dot

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)

# --- numeric realizations: scalar, lazily evaluated (runtime branches) --------
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 texture1(u, v, SC=SC5):
    m = SC['matA']; t = SC['tex']
    if t == 'solid':
        alb = m
    elif t == 'checker':
        s = math.sin(math.pi*u*SC['ku']) * math.sin(math.pi*v*SC['kv'])
        b = 1.0 if s >= 0 else 0.2
        alb = (m[0]*b, m[1]*b, m[2]*b)
    elif t == 'stripes':
        b = 1.0 if math.sin(math.pi*u*SC['ku']) >= 0 else 0.2
        alb = (m[0]*b, m[1]*b, m[2]*b)
    elif t == 'marble':
        mm = 0.5 + 0.5*math.sin(2*math.pi*(SC['ku']*u + 0.35*math.sin(3*math.pi*SC['kv']*v)))
        b = 0.55 + 0.45*mm
        alb = (m[0]*b, m[1]*b, m[2]*b)
    elif t == 'bitmap':
        uu = (u*SC['ku']/4.0) % 1.0; vv = (v*SC['kv']/2.0) % 1.0
        if SC['bmp_sampling'] == 'nearest':
            alb = tuple(BITMAP[int(vv*NB) % NB, int(uu*NB) % NB])
        else:
            gx, gy = uu*NB - 0.5, vv*NB - 0.5
            x0, y0 = math.floor(gx), math.floor(gy)
            fx, fy = gx - x0, gy - y0
            px = lambda a, b: BITMAP[b % NB, a % NB]
            c00, c10, c01, c11 = px(x0,y0), px(x0+1,y0), px(x0,y0+1), px(x0+1,y0+1)
            alb = tuple(c00[k]*(1-fx)*(1-fy) + c10[k]*fx*(1-fy)
                        + c01[k]*(1-fx)*fy + c11[k]*fx*fy for k in range(3))
    if SC['legacy']:
        alb = tuple(srgb_encode(alb))          # wrong on purpose: encode BEFORE lighting
    return np.array(alb)

def floor_tex1(x, y, SC=SC5):
    s = 1.0 if math.sin(math.pi*x)*math.sin(math.pi*y) >= 0 else -1.0
    base = 0.85 if s > 0 else 0.15
    alb = (base*(1-SC['alpha']), base*(1-SC['alpha']), base)
    return np.array(srgb_encode(alb)) if SC['legacy'] else np.array(alb)

def shadowed1(P, Lp, C, Rr):
    """Shadow feeler, EXPLICIT roots (numeric formulation)."""
    seg = Lp - P
    a2 = float(seg @ seg); b2 = float((P - C) @ seg)
    c2 = float((P - C) @ (P - C)) - Rr*Rr
    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 render_numeric_tex(width, height, SC=SC5):
    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)
    uvm = np.full((height, width, 2), np.nan)
    alb_out = np.zeros((height, width, 3))
    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 = texture1(u, vv, SC)
                uvm[j, i] = (u, vv)
                blockers = [(Cb, R2)] if SC['b_on'] else []
                kind = 1
            elif tm == tB:
                N = (P - Cb)/R2; alb = np.array(SC['matB'])
                if SC['legacy']: alb = srgb_encode(alb)
                blockers = [(np.zeros(3), R)]
                kind = 2
            else:
                N = np.array([0., 0., 1.]); alb = floor_tex1(P[0], P[1], SC)
                blockers = [(np.zeros(3), R)] + ([(Cb, R2)] if SC['b_on'] else [])
                kind = 3
            v_hat = (E - P)/np.linalg.norm(E - P)
            amb = np.array(SC['ambient'])
            if SC['legacy']: amb = srgb_encode(amb)
            col = SC['ka']*amb
            cd = kind
            for li, (pos, color) in enumerate(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)
                cd |= 4 << li
            img[j, i] = col; code[j, i] = cd; alb_out[j, i] = alb
    return np.clip(img, 0, 1), code, uvm, alb_out

t0 = time.perf_counter()
img_num, code_num, uvm_num, alb_num = render_numeric_tex(256, 256)
t1 = time.perf_counter()
print(f"numeric textured render: {t1 - t0:.3f} s")
fig, axs = plt.subplots(1, 2, figsize=(9, 4))
axs[0].imshow(srgb_encode(img_num)); axs[0].set_title("Numeric, sRGB display")
axs[1].imshow(img_num); axs[1].set_title("Numeric, linear raw")
for ax in axs: ax.axis('off')
plt.show()
Expect: a checkered crimson sphere, pinwheel at the center, seam slit to the left; the sRGB panel visibly richer than the linear-raw panel (Chapters 1–4 were showing you the dark one).

🐍 Cell 2 (NEW) — the chart as an object: longitude is the pixel's polar angle; the pole is the window's center

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

x, y = sp.symbols('x y', real=True)
R, e = sp.symbols('R e', positive=True)
Aq = x**2 + y**2 + e**2
disc = e**2*R**2 - (e**2 - R**2)*(x**2 + y**2)
tA = (e**2 - sp.sqrt(disc))/Aq
Px, Py, Pz = tA*x, tA*y, e*(1 - tA)
Nx, Ny, Nz = Px/R, Py/R, Pz/R

# --- THEOREM: atan2(Ny, Nx) = atan2(y, x) exactly (positive common scaling) ----
lhs = sp.atan2(Ny, Nx)
rhs = sp.atan2(y, x)
num_check = []
rng = np.random.default_rng(3)
for _ in range(20000):
    xv, yv = rng.normal(size=2)*0.8
    dA = 16 - 15*(xv*xv + yv*yv)
    if dA < 0: continue
    tv = (16 - np.sqrt(dA))/(xv*xv + yv*yv + 16)
    num_check.append(abs(np.arctan2(tv*yv, tv*xv) - np.arctan2(yv, xv)) < 1e-12)
print(f"longitude == pixel polar angle: {sum(num_check)}/{len(num_check)} ✓ (exact)")

# --- COROLLARY: v depends only on r^2 ------------------------------------------
r2 = sp.symbols('r2', positive=True)
Nz_r = sp.simplify(Nz.xreplace({x**2: r2 - y**2}))
print("Nz as a function of r^2 only:", Nz_r)
print("=> constant-v curves are CIRCLES, constant-u curves are RAYS.")
print("=> seam (Ny=0, Nx<0) renders as the slit y=0, x<0.")
print("=> pole (Nz=1) renders at (x,y)=(0,0): the pinwheel at window center.")

# --- magnification: the chart Jacobian at pole vs mid-latitude ------------------
u_expr = (sp.atan2(Ny, Nx) + sp.pi)/(2*sp.pi)
v_expr = sp.acos(Nz)/sp.pi
print(f"\nops: u = {sp.count_ops(u_expr)}, v = {sp.count_ops(v_expr)}")
print("texture composition adds:", sp.count_ops(u_expr) + sp.count_ops(v_expr),
      "ops before tau itself")
print("At the pole the u-gradient is unbounded (angular coordinate); at the rim")
print("the v-gradient blows up (the sphere turns edge-on). Both are visible as")
print("texture squeezing -- and both are computed, not asserted, in Cell 7.")
Expect: 20,000/20,000 on the longitude identity; Nz printed as a function of r² alone; the op-count of the chart composition, measured.

🐍 Cell 3 (REPLACES Ch.3 Cell 3) — the symbolic textured renderer; agreement census now includes (u, v) and τ

· · paste into colab.new → Run
def grid(W, H, R=1.0, e=4.0):
    half = 1.25*e*R/np.sqrt(e*e - R*R)
    xs = np.linspace(-half + half/W,  half - half/W,  W)
    ys = np.linspace( half - half/H, -half + half/H,  H) - 0.15*half
    return np.meshgrid(xs, ys)

# --- symbolic realization: TOTAL, vectorized; evaluated everywhere, selected ---
def textureV(u, v, SC=SC5):
    m = np.array(SC['matA']); t = SC['tex']
    if t == 'solid':
        alb = np.zeros(u.shape + (3,)) + m
    elif t == 'checker':
        s = np.sign(np.sin(np.pi*u*SC['ku']) * np.sin(np.pi*v*SC['kv']))
        s = np.where(s == 0, 1, s)
        alb = m*np.where(s > 0, 1.0, 0.2)[..., None]
    elif t == 'stripes':
        s = np.where(np.sign(np.sin(np.pi*u*SC['ku'])) == 0, 1,
                     np.sign(np.sin(np.pi*u*SC['ku'])))
        alb = m*np.where(s > 0, 1.0, 0.2)[..., None]
    elif t == 'marble':
        mm = 0.5 + 0.5*np.sin(2*np.pi*(SC['ku']*u + 0.35*np.sin(3*np.pi*SC['kv']*v)))
        alb = m*(0.55 + 0.45*mm)[..., None]
    elif t == 'bitmap':
        uu = (u*SC['ku']/4.0) % 1.0; vv = (v*SC['kv']/2.0) % 1.0
        if SC['bmp_sampling'] == 'nearest':
            alb = BITMAP[(np.floor(vv*NB)).astype(int) % NB,
                         (np.floor(uu*NB)).astype(int) % NB].astype(float)
        else:
            gx, gy = uu*NB - 0.5, vv*NB - 0.5
            x0, y0 = np.floor(gx).astype(int), np.floor(gy).astype(int)
            fx, fy = (gx - x0)[..., None], (gy - y0)[..., None]
            px = lambda a, b: BITMAP[b % NB, a % NB]
            alb = (px(x0,y0)*(1-fx)*(1-fy) + px(x0+1,y0)*fx*(1-fy)
                   + px(x0,y0+1)*(1-fx)*fy + px(x0+1,y0+1)*fx*fy)
    if SC['legacy']:
        alb = srgb_encode(alb)
    return alb

def render_symbolic_tex(X, Y, SC=SC5, regularized=True):
    R, e = SC['R'], SC['e']
    bx, by, bz = SC['Cb']; R2 = SC['R2']
    sq = (lambda v: np.sqrt(np.maximum(v, 0))) if regularized else np.sqrt
    Aq = X**2 + Y**2 + e**2
    discA = e*e*R*R - (e*e - R*R)*(X**2 + Y**2)
    tA = (e*e - sq(discA))/Aq
    bB = -bx*X - by*Y - (e - bz)*e
    cB = bx**2 + by**2 + (e - bz)**2 - R2**2
    discB = bB**2 - Aq*cB
    tB = (-bB - sq(discB))/Aq
    onA = (discA >= 0) & (~SC['b_on'] | (discB < 0) | (tA <= tB))
    onB = SC['b_on'] & (discB >= 0) & ~onA
    tminS = np.where(onA, tA, np.inf); tminS = np.where(onB, np.minimum(tminS, tB), tminS)
    onF = SC['floor_on'] & (1.0 < tminS)
    code = (onA*1 + onB*2 + onF*3).astype(np.uint8)
    # chart evaluated EVERYWHERE (totality); acos clamped total
    PxA, PyA, PzA = tA*X, tA*Y, e*(1 - tA)
    NzA = np.clip(PzA/R, -1, 1)
    uA = (np.arctan2(PyA/R, PxA/R) + np.pi)/(2*np.pi)
    vA = np.arccos(NzA)/np.pi
    albA = textureV(uA, vA, SC)
    s = np.sign(np.sin(np.pi*X)*np.sin(np.pi*Y)); s = np.where(s == 0, 1, s)
    base = np.where(s > 0, 0.85, 0.15)
    albF = np.stack([base*(1-SC['alpha']), base*(1-SC['alpha']), base], -1)
    if SC['legacy']: albF = srgb_encode(albF)
    albB = np.zeros(X.shape + (3,)) + np.array(SC['matB'])
    if SC['legacy']: albB = srgb_encode(albB)
    alb = albA*onA[..., None] + albB*onB[..., None] + albF*onF[..., None]
    amb = np.array(SC['ambient'])
    if SC['legacy']: amb = srgb_encode(amb)
    v = np.sqrt(Aq); vx, vy, vz = -X/v, -Y/v, e/v
    img = np.zeros(X.shape + (3,))
    for on, t, Rr, C, albS, blockers in (
        (onA, tA, R, (0., 0., 0.), albA, [((bx, by, bz), R2)] if SC['b_on'] else []),
        (onB, tB, R2, SC['Cb'], albB, [((0., 0., 0.), R)]),
        (onF, np.ones(X.shape), None, None, albF,
         [((0., 0., 0.), R)] + ([((bx, by, bz), R2)] if SC['b_on'] else []))):
        t3 = t if C is not None else np.ones(X.shape)
        Px, Py, Pz = t3*X, t3*Y, e*(1 - t3)
        if C is not None:
            Nx, Ny, Nz = (Px-C[0])/Rr, (Py-C[1])/Rr, (Pz-C[2])/Rr
        else:
            Nx, Ny, Nz = np.zeros(X.shape), np.zeros(X.shape), np.ones(X.shape)
        col = np.zeros(X.shape + (3,)) + SC['ka']*amb
        for li, (pos, color) in enumerate(SC['lights']):
            if color is None: continue
            wx, wy, wz = pos[0]-Px, pos[1]-Py, pos[2]-Pz
            a2 = wx*wx + wy*wy + wz*wz
            lit = np.ones(X.shape, dtype=bool)
            for BC, BR in blockers:
                qx, qy, qz = Px-BC[0], Py-BC[1], Pz-BC[2]
                b2 = qx*wx + qy*wy + qz*wz
                c2 = qx*qx + qy*qy + qz*qz - BR**2
                dsc = b2*b2 - a2*c2
                lit &= ~((dsc >= 0) & (b2 < 0) & (a2 + b2 > 0))      # Shadow Lemma
            wn = np.sqrt(a2)
            mu = (Nx*wx + Ny*wy + Nz*wz)/wn
            if SC['model'] == 'phong':
                wv = (wx*vx + wy*vy + wz*vz)/wn
                nu = np.clip((Nx*vx + Ny*vy + Nz*vz), -1, 1)
                spec = np.maximum(0, 2*mu*nu - wv)**SC['shin']
            else:
                hx, hy, hz = wx/wn+vx, wy/wn+vy, wz/wn+vz
                hn = np.sqrt(hx*hx + hy*hy + hz*hz)
                spec = np.maximum(0, (Nx*hx + Ny*hy + Nz*hz)/hn)**SC['shin']
            col += np.array(color)*(SC['kd']*albS*np.maximum(0, mu)[..., None]
                                    + SC['ks']*spec[..., None])*lit[..., None]
            code = code | ((lit & on).astype(np.uint8) << (2 + li))
        img += col*on[..., None]
    uvm = np.stack([uA, vA], -1)
    return np.clip(np.nan_to_num(img), 0, 1), code, uvm, alb

X, Y = grid(256, 256)
t0 = time.perf_counter()
img_sym, code_sym, uvm_sym, alb_sym = render_symbolic_tex(X, Y)
t1 = time.perf_counter()
print(f"symbolic textured render: {t1 - t0:.4f} s")

same = code_num == code_sym
d = np.abs(img_num - img_sym)
onA_both = (code_num & 3 == 1) & same
duv = np.abs(uvm_num[onA_both] - uvm_sym[onA_both])
print(f"interior max |num - sym|  = {d[same].max():.3e}   over {same.sum()} px")
print(f"flips: {(~same).sum()} px")
print(f"max |du,dv| on sphere A   = {duv.max():.3e}   (the chart agrees exactly)")
dalb = np.abs(alb_num[onA_both] - alb_sym[onA_both])
print(f"max |d_tau| on sphere A   = {dalb.max():.3e}   (bitmap included)")

fig, axs = plt.subplots(1, 3, figsize=(13, 4))
axs[0].imshow(srgb_encode(img_num)); axs[0].set_title("Numeric (sRGB)")
axs[1].imshow(srgb_encode(img_sym)); axs[1].set_title("Symbolic (sRGB)")
ov = srgb_encode(img_sym).copy(); ov[~same] = [1, 1, 1]
axs[2].imshow(ov); axs[2].set_title("flips in white")
for ax in axs: ax.axis('off')
plt.show()
Expect: interior ~1e-15, chart agreement ~1e-16, τ agreement ~1e-16 — the JS/Python bitmap formula is cross-validated by the census. Flips trace silhouette, shadow, and seam.

🐍 Cell 4 (NEW) — honest timings: what a texture costs; closed-form vs nearest vs bilinear

· · paste into colab.new → Run
print(f"{'texture':>14} {'numeric loop 128':>18} {'symbolic vec 256':>20}")
for tex in ('solid', 'checker', 'stripes', 'marble', 'bitmap'):
    for smp in (['nearest', 'bilinear'] if tex == 'bitmap' else ['bilinear']):
        SCt = {**SC5, 'tex': tex, 'bmp_sampling': smp}
        t0 = time.perf_counter(); render_numeric_tex(128, 128, SCt);  t1 = time.perf_counter()
        render_symbolic_tex(X, Y, SCt);                               t2 = time.perf_counter()
        tag = tex + ('/' + smp if tex == 'bitmap' else '')
        print(f"{tag:>14} {t1-t0:>17.3f}s {t2-t1:>19.3f}s")

print("""
Reading the table:
 * Closed-form textures cost a few transcendentals per pixel; the bitmap costs
   indexing (nearest) or four texel reads and three lerps (bilinear).
 * The symbolic form pays the chart and tau for EVERY pixel (totality), so its
   texture cost is constant across the frame; the numeric loop pays only on hits.
 * Bilinear vs nearest is not a quality knob here -- it is a choice between two
   EXACT functions: piecewise-constant vs piecewise-polynomial (Cell 6).
""")
Expect: textures add a visible but bounded cost; symbolic pays it uniformly; bilinear ≈ 2× nearest on the vec side.

🐍 Cell 5 (NEW) — gamma is a composition-order theorem: E(a+b) ≠ E(a)+E(b), and the legacy washout measured

· · paste into colab.new → Run
cc = sp.symbols('c', positive=True)
E_expr = sp.Piecewise((12.92*cc, cc <= sp.Rational(7803, 10**6)),
                      (1.055*cc**sp.Rational(5, 12) - 0.055, True))
a = sp.Rational(1, 2)
diff = sp.N(E_expr.subs(cc, 2*a) - 2*E_expr.subs(cc, a))
print(f"E(0.5 + 0.5) - [E(0.5) + E(0.5)] = {diff}")
print("nonzero => lighting in gamma space is WRONG, by a computable amount.")
print("Correct order: shade in linear, encode at display. (Theorem, not taste.)")

# --- measure the legacy error over the whole frame -----------------------------
img_correct, _, _, _ = render_symbolic_tex(X, Y, {**SC5, 'legacy': False})
img_correct = srgb_encode(img_correct)
img_legacy, _, _, _  = render_symbolic_tex(X, Y, {**SC5, 'legacy': True})
img_naive            = np.clip(img_correct / 1.0, 0, 1)   # linear raw (no encode)
img_naive            = np.clip(np.nan_to_num(
    render_symbolic_tex(X, Y, {**SC5, 'legacy': False})[0]), 0, 1)

surf = (code_sym & 3) > 0
err = np.abs(img_legacy - img_correct)
print(f"\nlegacy vs correct: mean |delta| on surfaces = {err[surf].mean():.4f}, "
      f"max = {err[surf].max():.4f}")
lin = np.nan_to_num(render_symbolic_tex(X, Y, SC5)[0])
mid = surf & (lin.mean(axis=-1) > 0.1) & (lin.mean(axis=-1) < 0.6)
print(f"midtone mean |delta| = {err[mid].mean():.4f}   "
      f"(the washout lives in the midtones)")

fig, axs = plt.subplots(1, 3, figsize=(13, 4))
axs[0].imshow(img_correct); axs[0].set_title("correct: shade linear, encode last")
axs[1].imshow(img_legacy);  axs[1].set_title("legacy: lighting in gamma space")
axs[2].imshow(np.clip(err*4, 0, 1)); axs[2].set_title("|delta| x4: the measured error")
for ax in axs: ax.axis('off')
plt.show()

# --- the sRGB piecewise itself, with its kink -----------------------------------
cg = np.linspace(0, 1, 500)
plt.figure(figsize=(6, 3.4))
plt.plot(cg, srgb_encode(cg), color='#1f3a5f', lw=2, label='sRGB encode E(c)')
plt.plot(cg, cg, color='grey', ls=':', label='identity (Chapters 1-4 display)')
plt.axvline(0.0031308, color='#b8860b', lw=1)
plt.text(0.006, 0.09, 'kink: 12.92c | 1.055c^(1/2.4)-0.055', fontsize=9)
plt.title("the display encode is a Piecewise"); plt.legend(); plt.show()
Expect: a nonzero constant proving non-commutativity; the legacy frame visibly washed in the midtones, with the error map concentrated exactly there.

🐍 Cell 6 (NEW) — the bitmap is a piecewise function: nearest vs bilinear vs the ideal ring, and Nyquist measured

· · paste into colab.new → Run
# The bitmap SAMPLES an ideal ring function at texel centers. Compare its two
# exact reconstructions against the ideal, then sweep frequency to find aliasing.
uu, vv = np.meshgrid(np.linspace(0, 1, 512, endpoint=False),
                     np.linspace(0, 1, 512, endpoint=False))
ideal = np.where((np.floor(np.hypot((uu*NB - 8) % NB - 7.5 + 0*uu,
                                    (vv*NB - 8) % NB - 7.5)) % 2) == 0,
                 0.85, 0.15)
# (ideal is defined texel-wise: the function the bitmap sampled at centers)
gi, gj = np.meshgrid(np.arange(NB), np.arange(NB))
ideal_centers = np.where((np.floor(np.hypot(gi - 7.5, gj - 7.5)) % 2) == 0, 0.85, 0.15)

def recon(uu, vv, mode):
    if mode == 'nearest':
        return ideal_centers[(np.floor(vv*NB)).astype(int) % NB,
                             (np.floor(uu*NB)).astype(int) % NB]
    gx, gy = uu*NB - 0.5, vv*NB - 0.5
    x0, y0 = np.floor(gx).astype(int), np.floor(gy).astype(int)
    fx, fy = gx - x0, gy - y0
    px = lambda a, b: ideal_centers[b % NB, a % NB]
    return (px(x0,y0)*(1-fx)*(1-fy) + px(x0+1,y0)*fx*(1-fy)
            + px(x0,y0+1)*(1-fx)*fy + px(x0+1,y0+1)*fx*fy)

fig, axs = plt.subplots(1, 3, figsize=(13, 4))
axs[0].imshow(ideal_centers, cmap='gray', interpolation='nearest')
axs[0].set_title("the bitmap (what was sampled)")
axs[1].imshow(recon(uu, vv, 'nearest'), cmap='gray', interpolation='nearest')
axs[1].set_title("nearest: piecewise-constant function")
axs[2].imshow(recon(uu, vv, 'bilinear'), cmap='gray', interpolation='nearest')
axs[2].set_title("bilinear: piecewise-polynomial function")
for ax in axs: ax.axis('off')
plt.show()

# --- Nyquist, measured: checker frequency vs reconstruction error ---------------
freqs = np.arange(1, 17)
errs_near, errs_bil = [], []
for k in freqs:
    f = np.sign(np.sin(np.pi*k*uu)*np.sin(np.pi*k*vv)) > 0
    # sample f into a k-scaled bitmap grid of NB texels, reconstruct, compare
    fb = (np.sign(np.sin(np.pi*k*(np.arange(NB)+0.5)/NB)
                  [None, :]*np.ones((NB, 1))) > 0)
    fb2 = fb * (np.sign(np.sin(np.pi*k*(np.arange(NB)+0.5)/NB))[:, None] > 0)
    rn = fb2[(np.floor(vv*NB)).astype(int), (np.floor(uu*NB)).astype(int)]
    errs_near.append(np.abs(rn - f).mean())
    # bilinear on the boolean pattern as floats
    fbf = fb2.astype(float)
    gx, gy = uu*NB - 0.5, vv*NB - 0.5
    x0, y0 = np.floor(gx).astype(int), np.floor(gy).astype(int)
    fx2, fy2 = gx - x0, gy - y0
    px = lambda a, b: fbf[b % NB, a % NB]
    rb = (px(x0,y0)*(1-fx2)*(1-fy2) + px(x0+1,y0)*fx2*(1-fy2)
          + px(x0,y0+1)*(1-fx2)*fy2 + px(x0+1,y0+1)*fx2*fy2)
    errs_bil.append(np.abs(np.clip(rb, 0, 1) - f).mean())

plt.figure(figsize=(6.5, 3.6))
plt.plot(freqs, errs_near, 'o-', color='#7a1f1f', label='nearest')
plt.plot(freqs, errs_bil, 's-', color='#1f3a5f', label='bilinear')
plt.axvline(NB/2, color='#b8860b', lw=1.5)
plt.text(NB/2 + 0.2, 0.05, 'NB/2 = 8: the bitmap Nyquist', fontsize=9)
plt.xlabel('checker frequency k'); plt.ylabel('mean reconstruction error')
plt.title('sampling a symbolic function has a Nyquist limit'); plt.legend(); plt.show()
print("Past k = NB/2 the error stops growing gracefully and starts oscillating:")
print("aliasing is a theorem about frequency content, symbolic or not.")
Expect: the two reconstructions side by side with the source bitmap; and the error curves bending at k = 8 — the bitmap's Nyquist, measured.

🐍 Cell 7 (NEW) — pitfalls, live: the seam tear, the pole pinch, and the raw-Heaviside census with texture

· · paste into colab.new → Run
# (a) THE SEAM TEAR: use a chart with the WRONG wrap (u in [-0.5, 0.5) via bare
#     arctan instead of atan2) and the texture discontinuity becomes visible as
#     a one-pixel tear along y = 0, x < 0 -- exactly the slit of section 3.
u_bad = np.arctan(np.where(np.abs(X) > 1e-12, Y/np.maximum(np.abs(X), 1e-30), 0))
u_bad = (u_bad/np.pi) % 1.0          # wrong chart: loses quadrant information
alb_bad = textureV(u_bad, uvm_sym[..., 1], {**SC5, 'tex': 'checker'})
alb_ok  = textureV(uvm_sym[..., 0], uvm_sym[..., 1], {**SC5, 'tex': 'checker'})
onA = (code_sym & 3) == 1
tear = onA & (np.abs(alb_bad - alb_ok).sum(axis=-1) > 0.5)
print(f"wrong-chart tear pixels on sphere A: {tear.sum()} "
      f"(they lie on the seam slit y = 0, x < 0)")
fig, axs = plt.subplots(1, 3, figsize=(13, 4))
axs[0].imshow(np.where(onA[..., None], alb_bad, 0.0))
axs[0].set_title("wrong chart (bare arctan): the tear")
axs[1].imshow(np.where(onA[..., None], alb_ok, 0.0))
axs[1].set_title("correct chart (atan2)")

# (b) THE POLE PINCH, MEASURED: |du| between horizontally adjacent pixels.
du = np.abs(np.diff(uvm_sym[..., 0], axis=1))
du = np.minimum(du, 1 - du)          # circular distance across the wrap
cx, cy = du.shape[0]//2, du.shape[1]//2
ring_near = du[cx-8:cx+8, cy-8:cy+8].mean()
ring_far  = du[cx-8:cx+8, du.shape[1]//4*3-8:du.shape[1]//4*3+8].mean()
print(f"\nmean |du| per pixel step near the pole (center): {ring_near:.5f}")
print(f"mean |du| per pixel step mid-sphere:               {ring_far:.5f}")
print(f"magnification ratio ~ {ring_near/max(ring_far,1e-30):.1f}x -- "
      f"the pinch, measured")
axs[2].imshow(onA & (du > np.percentile(du[onA], 99)), cmap='autumn')
axs[2].set_title("top 1% |du|: the pole pinch, located")
for ax in axs: ax.axis('off')
plt.show()

# (c) RAW HEAVISIDE + TEXTURE: the census, again
img_raw, _, _, _ = render_symbolic_tex(X, Y, SC5, regularized=False)
print(f"\nraw assembly NaN pixels: {np.isnan(img_raw).any(axis=-1).sum()}")
print("tau itself is total (atan2, acos-clamped, floors): the NaNs still come")
print("from the chamber sqrts. Regularize; the texture rides along unharmed.")
Expect: a visible tear along the derived slit; the pinch ratio measured (large at center); the NaN census unchanged in kind, texture or no texture.

🐍 Cell 8 (NEW) — the frontier, updated: which texture boundaries stay algebraic; bump mapping as the next composition

· · paste into colab.new → Run
# (a) WHICH TEXTURE BOUNDARIES ARE ALGEBRAIC?
# Checker edges: sin(pi*ku*u) = 0  <=>  u = m/ku  <=>  atan2(y, x) = const
#   => RAYS from the window center (algebraic!).
#               sin(pi*kv*v) = 0  <=>  v = m/kv  <=>  r^2 = const
#   => CIRCLES (algebraic!).
# So for THIS chart + closed-form tau, Chapter 3's CAD machinery still applies.
print("checker boundaries = rays u=const and circles v=const: semi-algebraic.")
print("The transcendental chart washed out -- atan2 cancelled, Nz was r^2.")
print("But for a BITMAP tau, boundaries are images of arbitrary curves through")
print("the chart: no Sturm, no CAD. Exact integration over textured pixels")
print("(Chapter 6) splits along curves it can only sample. Honest frontier.")
r2s = sp.symbols('r2', positive=True)
Nz_r = sp.simplify(Nz.xreplace({x**2: r2s - y**2}))
print("\nNz(r^2) =", Nz_r)

# (b) BUMP MAPPING IS THE NEXT COMPOSITION: N' = normalize(N + beta * grad tau).
#     It perturbs the normal by the TEXTURE'S DERIVATIVE -- which symbolic
#     rendering computes exactly (the image was already differentiable in Ch.1).
su, sv = sp.symbols('u v', real=True)
tau_b = sp.sin(sp.pi*8*su)*sp.sin(sp.pi*4*sv)      # checker, smoothed
grad = (sp.diff(tau_b, su), sp.diff(tau_b, sv))
print(f"\ntau(u,v) = {tau_b}")
print(f"dtau/du = {grad[0]}   ({sp.count_ops(grad[0])} ops)")
print(f"dtau/dv = {grad[1]}   ({sp.count_ops(grad[1])} ops)")
print("N' = (N + beta*(tau_u * Bu + tau_v * Bv)) / |...| : pure composition,")
print("the derivative exact. Chapter 9's headline, priced in advance.")
Expect: Nz printed in r² alone (rays-and-circles confirmed); the exact texture gradient, ready for bump mapping's composition.

§6 · The honest ledger — Chapter-5 deltas

DimensionChapter 4Chapter 5
Albedoconstants + floor checkerτ(u, v) composed with a chart; four textures incl. a sampled bitmap
Chartidentity (floor)longitude/latitude; seam and pole are theorems with closed-form images (§3, Cell 2)
Singularitiest′ = 0 self-hitbranch cut → the slit y=0, x<0; pole → window center, pinwheel; pinch measured (Cell 7b)
Bitmapnearest = floor-arithmetic, bilinear = tent polynomials; Nyquist measured at k = NB/2 (Cell 6)
Displaylinear rawsRGB Piecewise; legacy mode is a measured error term, not a look (Cell 5)
Agreementinterior max + flips+ max |Δ(u,v)| and max |Δτ| — the chart and the bitmap cross-validated (Cell 3)
Cautionε-hack vs strictthe ledger measures formulation agreement, never correctness — both pipelines legacy-wrong together (§4)
Frontiercomposition swellclosed-form textures stay algebraic (rays & circles); bitmap boundaries escape CAD (Cell 8)

§7 · Pitfalls gallery, continued

  1. The chart is a contract. Branch-cut conventions (bare arctan vs atan2; wrap vs clamp; seam at ±π vs 0..2π) must match across pipelines or the tear lines the seam slit — and flips will find it even if your eyes don't (Cell 7a).
  2. The pole pinch is unbounded magnification. Near the window's center, one pixel step is a large step in u; any texture with fine u-detail aliases there. This is why production charts avoid poles — and why Chapter 6's filter matters.
  3. Gamma-space lighting is measurably wrong. E(a+b) ≠ E(a)+E(b); the washout concentrates in the midtones. Shade in linear; encode last; the order is a theorem.
  4. Sampling mode is a model choice, not an error. Nearest and bilinear are two different exact functions sharing 256 sample values. Comparing one against the other as "error" is a category mistake; comparing either against the ideal is measured (Cell 6).
  5. The ledger cannot detect shared wrongness. Legacy mode: numeric and symbolic agree to 1e-15 while both are wrong. Agreement validates formulation; only theory validates correctness. Say so in the caption of every black diff canvas.
  6. Raw Heaviside, again. τ is total; the chamber sqrts still poison. The texture changes nothing about the discipline: regularize or select.

§8 · A brief history, continued

Texture mapping enters with Catmull's 1974 Utah thesis; Blinn & Newell's 1976 environment maps and Blinn's 1978 bump maps made the surface itself a function; Williams' mipmaps (1983) and Crow's summed-area tables (1984) were the first honest answers to the minification question this chapter's Nyquist cell measures; Perlin (1985) made texture procedural — τ as pure function, no samples at all; Cook's shade trees (1984) and RenderMan (1988) made shading a little language of composed functions, which is this entire series' thesis wearing a bowtie. The gamma story is shameful and instructive: sRGB (1996) standardized the encode, and for over a decade the industry lit in gamma space anyway — the legacy mode in §4 is that decade, measurable now in one line. In the other world: two-argument arctan is Fortran's ATAN2 (1961) — the branch cut made an argument — and the bitmap is the CAS's oldest data structure (an array) being read as what it always was: a piecewise function, piecewise-constant or piecewise-polynomial by choice of reconstruction.

§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.