Download experiments/w9_neural_radiance_cache.py from NeuralVerified/neural-raytracing: direct link, hf CLI and curl.
- Browser
- Download file 9.04 kB
-
https://huggingface.co/NeuralVerified/neural-raytracing/resolve/main/experiments/w9_neural_radiance_cache.py
- Command line
-
hf download hf://NeuralVerified/neural-raytracing/experiments/w9_neural_radiance_cache.py
-
curl -L -o w9_neural_radiance_cache.py https://huggingface.co/NeuralVerified/neural-raytracing/resolve/main/experiments/w9_neural_radiance_cache.py
9.04 kB
| """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) # emitted + direct | |
| ind = torch.zeros(res * res, 3, device=DEV) | |
| o, d = camera_rays(res, False, DEV, g, cam=cam) # no jitter: fixed hits | |
| 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)) | |
| # ---------- training data: surface points with GT indirect ---------- | |
| 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] | |
| # GT indirect at these points: average many one-bounce-onward paths | |
| 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) | |
| # radiance arriving from the bounce dir, path-traced (depth 4), | |
| # times the cosine-weighted albedo throughput (albedo/pi * pi = albedo) | |
| _, 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 # outgoing = albedo * incident indirect | |
| 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)") | |
| # ---------- train the cache ---------- | |
| 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) | |
| # tone-mapped (log) loss: indirect radiance spans orders of magnitude and | |
| # the eye is roughly logarithmic — regress log(1+L), like real NRC | |
| 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)") | |
| # ---------- criterion A: held-out points (novel view) ---------- | |
| 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)") | |
| # ---------- criterion B & C: full-image renders ---------- | |
| 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") | |
| # neural: analytic emitted+direct at low spp + cache indirect | |
| 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") | |
| # baseline: equal-cost full path trace | |
| 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)") | |
| # criterion C: spp the baseline needs to match the neural PSNR | |
| 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") | |
| # ---------- figure ---------- | |
| 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") | |