neural-raytracing / experiments /w9_neural_radiance_cache.py
Quazim0t0's picture
Import from Quazim0t0/neural-raytracing; repoint refs to NeuralVerified
ac9d706 verified
Raw
History Blame Contribute Delete
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")