| """W9: neural radiance cache — neural ray tracing on the project skeleton. |
| |
| Claim under test (the light-transport analog of the physics thesis): if |
| you keep visibility and direct lighting analytic and learn ONLY the |
| indirect transport with a tiny tied MLP, you match a many-bounce path |
| tracer at a fraction of the cost — one analytic ray + NEE + a network |
| lookup replaces the random walk after the first bounce. |
| |
| Render decomposition: L = emitted + direct(analytic) + indirect |
| - reference: indirect from high-spp path tracing (ground truth) |
| - neural: indirect from cache_theta(first-hit point, normal) |
| - baseline: an equal-COST path trace (few spp) for a fair comparison |
| |
| Success criteria: |
| A. cache accuracy on HELD-OUT surface points (never-seen view) — PSNR |
| of predicted vs true indirect radiance |
| B. full-image PSNR: neural-cached vs reference, and it beats the |
| equal-cost path-traced baseline |
| C. speedup at matched quality (spp the baseline needs to reach the |
| neural render's PSNR) |
| """ |
| import sys, os, time |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
|
|
| import numpy as np |
| import torch |
| from engine3d.raytrace import (camera_rays, trace_split, scene_tensors, |
| intersect) |
| from engine3d.neural_rt import RadianceCache |
|
|
| torch.manual_seed(0) |
| HERE = os.path.dirname(os.path.abspath(__file__)) |
| ROOT = os.path.dirname(HERE) |
| DEV = "cuda" if torch.cuda.is_available() else "cpu" |
| S = scene_tensors(DEV) |
|
|
|
|
| def rng_of(seed): |
| g = torch.Generator(device=DEV); g.manual_seed(seed); return g |
|
|
|
|
| def render(res, spp, seed, cam=(0.0, 0.0, 3.05), depth=5): |
| """Full reference render (emitted + direct + indirect), returns (res,res,3).""" |
| g = rng_of(seed) |
| acc = torch.zeros(res * res, 3, device=DEV) |
| for _ in range(spp): |
| o, d = camera_rays(res, True, DEV, g, cam=cam) |
| em, di, ind, _ = trace_split(o, d, S, g, depth=depth) |
| acc += em + di + ind |
| return (acc / spp).reshape(res, res, 3) |
|
|
|
|
| def render_components(res, spp, seed, cam=(0.0, 0.0, 3.05)): |
| """Per-pixel emitted+direct (low variance) and first-hit geometry, plus |
| a high-spp indirect target. Used to compose the neural image.""" |
| g = rng_of(seed) |
| ed = torch.zeros(res * res, 3, device=DEV) |
| ind = torch.zeros(res * res, 3, device=DEV) |
| o, d = camera_rays(res, False, DEV, g, cam=cam) |
| for _ in range(spp): |
| em, di, ii, first = trace_split(o, d, S, g, depth=5) |
| ed += em + di |
| ind += ii |
| ed /= spp; ind /= spp |
| o2, d2 = camera_rays(res, False, DEV, rng_of(seed), cam=cam) |
| _, n, alb, isl = intersect(o2, d2, S) |
| t, _, _, _ = intersect(o2, d2, S) |
| p = o2 + t[:, None] * d2 |
| hit = (t < 1e8) & ~isl |
| return ed, ind, p, n, hit |
|
|
|
|
| def psnr(a, b, mask=None): |
| if mask is not None: |
| a, b = a[mask], b[mask] |
| mse = ((a.clamp(0, 4) - b.clamp(0, 4)) ** 2).mean().item() |
| return 10 * np.log10(4.0 ** 2 / max(mse, 1e-12)) |
|
|
|
|
| |
| print(f"=== generating radiance-cache training data on {DEV} ===") |
| t0 = time.time() |
| PTS, NRM, TGT = [], [], [] |
| CAM_TRAIN = [(0.0, 0.0, 3.05), (0.7, 0.2, 2.9), (-0.7, 0.25, 2.9), |
| (0.0, 0.6, 2.8), (0.4, -0.3, 3.0)] |
| for ci, cam in enumerate(CAM_TRAIN): |
| g = rng_of(100 + ci) |
| o, d = camera_rays(80, True, DEV, g, cam=cam) |
| t, n, alb, isl = intersect(o, d, S) |
| p = o + t[:, None] * d |
| hit = (t < 1e8) & ~isl |
| p, n = p[hit], n[hit] |
| |
| ind = torch.zeros(len(p), 3, device=DEV) |
| SPP = 256 |
| for _ in range(SPP): |
| from engine3d.raytrace import cosine_hemisphere, nee, EPS |
| nd = cosine_hemisphere(n, g) |
| |
| |
| _, ndi, nind, _ = trace_split(p + EPS * n, nd, S, g, depth=4) |
| ind += (ndi + nind) |
| ind /= SPP |
| alb_h = alb[hit] |
| ind = ind * alb_h |
| PTS.append(p); NRM.append(n); TGT.append(ind) |
| P = torch.cat(PTS); Nrm = torch.cat(NRM); Y = torch.cat(TGT) |
| print(f" {len(P)} surface samples from {len(CAM_TRAIN)} views " |
| f"({time.time()-t0:.1f}s)") |
|
|
| |
| cache = RadianceCache().to(DEV) |
| print(f"=== training radiance cache ({cache.n_params()} params) ===") |
| opt = torch.optim.Adam(cache.parameters(), lr=3e-3) |
| sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=1500) |
| |
| |
| logY = torch.log1p(Y) |
| t0 = time.time() |
| for it in range(1500): |
| idx = torch.randint(0, len(P), (16384,), device=DEV) |
| pred = cache(P[idx], Nrm[idx]) |
| loss = ((torch.log1p(pred) - logY[idx]) ** 2).mean() |
| opt.zero_grad(); loss.backward(); opt.step(); sched.step() |
| if it % 300 == 0 or it == 1499: |
| print(f" it {it:4d} log-MSE {loss.item():.5f}") |
| print(f" ({time.time()-t0:.1f}s)") |
|
|
| |
| g = rng_of(999) |
| cam_test = (0.35, -0.15, 2.95) |
| o, d = camera_rays(80, True, DEV, g, cam=cam_test) |
| t, n, alb, isl = intersect(o, d, S) |
| p = o + t[:, None] * d |
| hit = (t < 1e8) & ~isl |
| p, n, alb = p[hit], n[hit], alb[hit] |
| ind = torch.zeros(len(p), 3, device=DEV) |
| from engine3d.raytrace import cosine_hemisphere, EPS |
| for _ in range(256): |
| nd = cosine_hemisphere(n, g) |
| _, ndi, nind, _ = trace_split(p + EPS * n, nd, S, g, depth=4) |
| ind += (ndi + nind) |
| ind = ind / 256 * alb |
| with torch.no_grad(): |
| pred = cache(p, n) |
| a_psnr = psnr(pred, ind) |
| print(f"\n[A] held-out indirect radiance PSNR: {a_psnr:.2f} dB " |
| f"(novel view, {len(p)} pts)") |
|
|
| |
| RES = 112 |
| REF_SPP = 256 |
| print(f"\n=== full renders at {RES}x{RES} ===") |
| t0 = time.time(); REF = render(RES, REF_SPP, 7); ref_ms = (time.time()-t0)*1000 |
| print(f" reference ({REF_SPP} spp): {ref_ms:.0f} ms") |
|
|
| |
| t0 = time.time() |
| ed, _, p, n, hit = render_components(RES, 4, 7) |
| with torch.no_grad(): |
| ind_pred = torch.zeros(RES * RES, 3, device=DEV) |
| ind_pred[hit] = cache(p[hit], n[hit]) |
| NEUR = (ed + ind_pred).reshape(RES, RES, 3) |
| neur_ms = (time.time()-t0)*1000 |
| print(f" neural (4 spp direct + cache): {neur_ms:.0f} ms") |
|
|
| |
| def spp_for_ms(target_ms): |
| t0 = time.time(); render(RES, 4, 7); one = (time.time()-t0)/4*1000 |
| return max(1, round(target_ms / one)), one |
| base_spp, per = spp_for_ms(neur_ms) |
| BASE = render(RES, base_spp, 7) |
|
|
| pn = psnr(NEUR, REF) |
| pb = psnr(BASE, REF) |
| print(f"\n[B] full-image PSNR vs reference:") |
| print(f" neural (cache) : {pn:.2f} dB ({neur_ms:.0f} ms)") |
| print(f" path trace (equal cost) : {pb:.2f} dB ({base_spp} spp)") |
|
|
| |
| match_spp = None |
| for spp in [8, 16, 32, 64, 128]: |
| if psnr(render(RES, spp, 7), REF) >= pn: |
| match_spp = spp; break |
| base_ms = (match_spp or 128) * per |
| print(f"\n[C] path trace needs ~{match_spp or '>256'} spp " |
| f"(~{base_ms:.0f} ms) to reach the neural PSNR " |
| f"-> ~{base_ms/neur_ms:.1f}x speedup at matched quality") |
|
|
| |
| try: |
| import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt |
| def tm(x): return np.clip((x.clamp(0, 4) ** (1/2.2)).cpu().numpy(), 0, 1) |
| err = (NEUR - REF).abs().mean(-1).cpu().numpy() |
| errb = (BASE - REF).abs().mean(-1).cpu().numpy() |
| fig, ax = plt.subplots(2, 3, figsize=(12, 8)) |
| for a in ax.ravel(): a.set_xticks([]); a.set_yticks([]) |
| ax[0,0].imshow(tm(REF)); ax[0,0].set_title(f"reference ({REF_SPP} spp)") |
| ax[0,1].imshow(tm(NEUR)); ax[0,1].set_title(f"neural cache — {pn:.1f} dB, {neur_ms:.0f} ms") |
| ax[0,2].imshow(tm(BASE)); ax[0,2].set_title(f"equal-cost path trace ({base_spp} spp) — {pb:.1f} dB") |
| ax[1,0].imshow(tm(ind_pred.reshape(RES,RES,3))); ax[1,0].set_title("learned indirect (cache only)") |
| m=max(err.max(),errb.max()) |
| ax[1,1].imshow(err,cmap="inferno",vmax=m); ax[1,1].set_title("neural error") |
| ax[1,2].imshow(errb,cmap="inferno",vmax=m); ax[1,2].set_title("path-trace error") |
| fig.suptitle(f"Neural radiance cache — learn indirect, keep visibility+direct analytic " |
| f"(held-out cache PSNR {a_psnr:.1f} dB)", fontsize=12) |
| fig.tight_layout() |
| out=os.path.join(ROOT,"neural_raytrace_validation.png") |
| fig.savefig(out,dpi=110); print(f"\nwrote {out}") |
| except Exception as e: |
| print("plot skipped:", e) |
|
|
| torch.save({"state_dict": cache.state_dict(), |
| "arch": {"n_freq": 6, "hidden": 96}, |
| "scene": "neon_cornell"}, |
| os.path.join(HERE, "radiance_cache.pt")) |
| print("saved experiments/radiance_cache.pt") |
|
|