Quazim0t0 commited on
Commit
ac9d706
·
verified ·
1 Parent(s): d40779f

Import from Quazim0t0/neural-raytracing; repoint refs to NeuralVerified

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ neural_raytrace_validation.png filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ tags:
4
+ - neural-rendering
5
+ - ray-tracing
6
+ - radiance-cache
7
+ - graphics
8
+ library_name: pytorch
9
+ ---
10
+
11
+ # Neural Ray Tracing — Radiance Cache
12
+
13
+ The light-transport analog of the
14
+ [neural physics engine](https://huggingface.co/NeuralVerified/neural-physics-engine)
15
+ thesis: keep visibility and direct lighting **analytic**, learn **only the
16
+ indirect transport** with a tiny tied MLP. One analytic ray + next-event
17
+ estimation + a network lookup replaces the many-bounce random walk after
18
+ the first hit.
19
+
20
+ ## How it was done
21
+
22
+ Render decomposition: `L = emitted + direct(analytic) + indirect(learned)`.
23
+
24
+ 1. **Ground truth**: a small PyTorch path tracer
25
+ (`engine3d/raytrace.py`) renders high-spp references and splits each
26
+ pixel into emitted / direct / indirect components.
27
+ 2. **Training data**: first-hit surface points + normals paired with the
28
+ path-traced indirect radiance at those points.
29
+ 3. **The cache** (`engine3d/neural_rt.py`): one tiny MLP shared across the
30
+ whole scene (the tied-embedding structure used everywhere in this
31
+ project) maps (hit point, normal) → indirect RGB.
32
+ 4. **Composition**: at render time, trace one analytic primary ray, add
33
+ analytic direct lighting (NEE), and look up the cache for the rest.
34
+ 5. **Evaluation** (`experiments/w9_neural_radiance_cache.py`): PSNR on a
35
+ held-out camera view, compared against an *equal-cost* few-spp path
36
+ trace, plus the spp the baseline needs to match the neural render.
37
+
38
+ `experiments/w11_world_lighting.py` applies the same recipe to bake a
39
+ world ambient/GI field (`world_light.pt`) over the voxel world — this is
40
+ the "neural GI" used live in the
41
+ [Neural World demo Space](https://huggingface.co/spaces/NeuralVerified/neural-world),
42
+ parsed in-browser by `pt_loader.js`.
43
+
44
+ ## Checkpoints
45
+
46
+ | file | net | consumed by |
47
+ |---|---|---|
48
+ | `experiments/radiance_cache.pt` | indirect radiance cache MLP | `w9_neural_radiance_cache.py` renders |
49
+ | `experiments/world_light.pt` | world ambient/GI field | Neural World demo (browser) |
50
+
51
+ ## Validation
52
+
53
+ ![neural raytrace validation](neural_raytrace_validation.png)
54
+ ![world lighting validation](world_lighting_validation.png)
55
+
56
+ ## Reproduce
57
+
58
+ ```
59
+ python experiments/w9_neural_radiance_cache.py # trains + renders comparison
60
+ python experiments/w11_world_lighting.py # bakes world_light.pt
61
+ ```
62
+
63
+
64
+ ---
65
+
66
+ <!-- neuralverified-relocation-note -->
67
+ > **Now hosted by [NeuralVerified](https://huggingface.co/NeuralVerified).**
68
+ >
69
+ > This repo was moved into the NeuralVerified organization to help organize my profile.
70
+ > Originally published at [`Quazim0t0/neural-raytracing`](https://huggingface.co/Quazim0t0/neural-raytracing).
engine3d/neural_rt.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Neural radiance cache (W9 — neural ray tracing).
2
+
3
+ The same thesis as the physics engine, applied to light transport: keep
4
+ the exact parts analytic (ray/visibility, next-event direct lighting),
5
+ learn only the expensive part (multi-bounce indirect transport).
6
+
7
+ A path becomes: L = emitted + direct(analytic NEE) + cache_θ(x, n)
8
+ where cache_θ is one tiny MLP, tied across the whole scene, that maps a
9
+ surface point + normal to its outgoing indirect radiance. This replaces
10
+ the random walk after the first bounce with a single network lookup —
11
+ Müller et al.'s "Real-time Neural Radiance Caching" idea, distilled to
12
+ the project's local-projection skeleton.
13
+
14
+ Positional (frequency) encoding is essential: a plain MLP on raw xyz
15
+ cannot fit the high-frequency shading near contact shadows and the
16
+ color-bleeding corners. The encoding is the analytic structure we keep;
17
+ the MLP only learns amplitudes.
18
+ """
19
+ import numpy as np
20
+ import torch
21
+ import torch.nn as nn
22
+
23
+
24
+ class FreqEncoding(nn.Module):
25
+ """NeRF-style sin/cos encoding: x -> [x, sin(2^k x), cos(2^k x)]_k."""
26
+
27
+ def __init__(self, n_freq=6):
28
+ super().__init__()
29
+ self.register_buffer("bands", 2.0 ** torch.arange(n_freq) * torch.pi)
30
+ self.out_mult = 1 + 2 * n_freq
31
+
32
+ def forward(self, x):
33
+ proj = x[..., None] * self.bands # (...,D,F)
34
+ enc = torch.cat([torch.sin(proj), torch.cos(proj)], -1)
35
+ return torch.cat([x, enc.flatten(-2)], -1)
36
+
37
+
38
+ class RadianceCache(nn.Module):
39
+ """Tied MLP: (position, normal) -> outgoing indirect radiance (RGB).
40
+
41
+ Shared across every surface point in the scene (the tied-embedding
42
+ thesis, now over spatial location instead of mesh element). Output is
43
+ non-negative (softplus) — radiance cannot be negative, a guard baked
44
+ into the architecture rather than clamped after the fact.
45
+ """
46
+
47
+ def __init__(self, n_freq=6, hidden=96):
48
+ super().__init__()
49
+ self.enc = FreqEncoding(n_freq)
50
+ din = self.enc.out_mult * 3 + 3 # encoded position + raw normal
51
+ self.net = nn.Sequential(
52
+ nn.Linear(din, hidden), nn.SiLU(),
53
+ nn.Linear(hidden, hidden), nn.SiLU(),
54
+ nn.Linear(hidden, hidden), nn.SiLU(),
55
+ nn.Linear(hidden, 3),
56
+ )
57
+
58
+ def forward(self, pos, nrm):
59
+ h = torch.cat([self.enc(pos), nrm], -1)
60
+ return torch.nn.functional.softplus(self.net(h))
61
+
62
+ def n_params(self):
63
+ return sum(p.numel() for p in self.parameters())
engine3d/raytrace.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vectorized torch path tracer (the substrate for neural ray tracing).
2
+
3
+ Same design rule as the physics engine: keep the exact parts analytic —
4
+ ray intersections and next-event-estimated direct lighting — and reserve
5
+ learning for the expensive part (indirect transport, see W9 experiment).
6
+
7
+ Scene: a neon Cornell box. Diffuse surfaces, one area light on the
8
+ ceiling, a sphere and a box inside. All ray math is batched over
9
+ (N,3) tensors on the chosen device.
10
+ """
11
+ import torch
12
+
13
+ EPS = 1e-4
14
+
15
+ # ---- scene definition (unit room, front face at z=+1 open) ----
16
+ LIGHT = dict(y=0.999, half=0.42, Le=torch.tensor([11.0, 11.5, 13.0]))
17
+ SPHERE = dict(c=torch.tensor([-0.42, -0.65, -0.22]), r=0.35,
18
+ alb=torch.tensor([0.85, 0.85, 0.88]))
19
+ BOX = dict(mn=torch.tensor([0.12, -1.0, -0.10]),
20
+ mx=torch.tensor([0.72, -0.34, 0.52]),
21
+ alb=torch.tensor([0.30, 0.75, 0.95]))
22
+ WALLS = [ # (axis, value, normal-sign, albedo)
23
+ (0, -1.0, +1, [0.25, 0.55, 0.95]), # left — neon blue
24
+ (0, +1.0, -1, [0.95, 0.30, 0.40]), # right — crimson
25
+ (1, -1.0, +1, [0.70, 0.70, 0.72]), # floor
26
+ (1, +1.0, -1, [0.70, 0.70, 0.72]), # ceiling
27
+ (2, -1.0, +1, [0.55, 0.60, 0.75]), # back
28
+ ]
29
+
30
+
31
+ def _dev(dev):
32
+ out = {"Le": LIGHT["Le"].to(dev), "sc": SPHERE["c"].to(dev),
33
+ "sa": SPHERE["alb"].to(dev), "bmn": BOX["mn"].to(dev),
34
+ "bmx": BOX["mx"].to(dev), "ba": BOX["alb"].to(dev),
35
+ "wa": torch.tensor([w[3] for w in WALLS], device=dev)}
36
+ return out
37
+
38
+
39
+ def intersect(o, d, S):
40
+ """Closest hit for rays (N,3),(N,3) -> t, normal, albedo, is_light."""
41
+ N = len(o)
42
+ dev = o.device
43
+ INF = torch.full((N,), 1e9, device=dev)
44
+ best_t = INF.clone()
45
+ n = torch.zeros(N, 3, device=dev)
46
+ alb = torch.zeros(N, 3, device=dev)
47
+ is_light = torch.zeros(N, dtype=torch.bool, device=dev)
48
+
49
+ arange = torch.arange(N, device=dev)
50
+ for wi, (ax, val, sgn, _) in enumerate(WALLS):
51
+ denom = d[:, ax]
52
+ t = (val - o[:, ax]) / torch.where(denom.abs() < 1e-9,
53
+ torch.full_like(denom, 1e-9), denom)
54
+ p = o + t[:, None] * d
55
+ oth = [a for a in range(3) if a != ax]
56
+ ok = (t > EPS) & (t < best_t) \
57
+ & (p[:, oth[0]].abs() <= 1.0) & (p[:, oth[1]].abs() <= 1.0) \
58
+ & (p[:, 2] <= 1.0)
59
+ best_t = torch.where(ok, t, best_t)
60
+ nw = torch.zeros_like(n); nw[:, ax] = float(sgn)
61
+ n = torch.where(ok[:, None], nw, n)
62
+ alb = torch.where(ok[:, None], S["wa"][wi], alb)
63
+ # sphere
64
+ oc = o - S["sc"]
65
+ b = (oc * d).sum(1)
66
+ c = (oc * oc).sum(1) - SPHERE["r"] ** 2
67
+ disc = b * b - c
68
+ sq = torch.sqrt(disc.clamp_min(0))
69
+ t1 = -b - sq
70
+ t2 = -b + sq
71
+ ts = torch.where(t1 > EPS, t1, t2)
72
+ ok = (disc > 0) & (ts > EPS) & (ts < best_t)
73
+ best_t = torch.where(ok, ts, best_t)
74
+ ps = o + ts[:, None] * d
75
+ n = torch.where(ok[:, None], (ps - S["sc"]) / SPHERE["r"], n)
76
+ alb = torch.where(ok[:, None], S["sa"], alb)
77
+ # box (slabs)
78
+ inv = 1.0 / torch.where(d.abs() < 1e-9, torch.full_like(d, 1e-9), d)
79
+ t0s = (S["bmn"] - o) * inv
80
+ t1s = (S["bmx"] - o) * inv
81
+ tsm = torch.minimum(t0s, t1s).max(1).values
82
+ tbg = torch.maximum(t0s, t1s).min(1).values
83
+ ok = (tsm < tbg) & (tsm > EPS) & (tsm < best_t)
84
+ best_t = torch.where(ok, tsm, best_t)
85
+ pb = o + tsm[:, None] * d
86
+ ctr = (S["bmn"] + S["bmx"]) / 2
87
+ half = (S["bmx"] - S["bmn"]) / 2
88
+ rel = (pb - ctr) / half
89
+ axb = rel.abs().argmax(1)
90
+ nb = torch.zeros_like(pb)
91
+ nb[arange, axb] = torch.sign(rel[arange, axb])
92
+ n = torch.where(ok[:, None], nb, n)
93
+ alb = torch.where(ok[:, None], S["ba"], alb)
94
+ # light flag: recomputed cleanly from the final hit (ceiling patch)
95
+ p = o + best_t[:, None] * d
96
+ is_light = (best_t < 1e8) & (n[:, 1] == -1.0) & (p[:, 1] > 0.99) \
97
+ & (p[:, 0].abs() <= LIGHT["half"]) & (p[:, 2].abs() <= LIGHT["half"])
98
+ return best_t, n, alb, is_light
99
+
100
+
101
+ def cosine_hemisphere(n, rng):
102
+ """Cosine-weighted directions about normals n (N,3)."""
103
+ N = len(n)
104
+ u1 = torch.rand(N, device=n.device, generator=rng)
105
+ u2 = torch.rand(N, device=n.device, generator=rng)
106
+ r = torch.sqrt(u1)
107
+ phi = 2 * torch.pi * u2
108
+ x = r * torch.cos(phi)
109
+ y = r * torch.sin(phi)
110
+ z = torch.sqrt((1 - u1).clamp_min(0))
111
+ a = torch.where(n[:, 0:1].abs() > 0.9,
112
+ torch.tensor([0.0, 1.0, 0.0], device=n.device).expand_as(n),
113
+ torch.tensor([1.0, 0.0, 0.0], device=n.device).expand_as(n))
114
+ t = torch.linalg.cross(a, n)
115
+ t = t / t.norm(dim=1, keepdim=True).clamp_min(1e-9)
116
+ b = torch.linalg.cross(n, t)
117
+ return x[:, None] * t + y[:, None] * b + z[:, None] * n
118
+
119
+
120
+ def nee(p, n, alb, S, rng):
121
+ """Next-event estimation toward the ceiling light. Returns (N,3)."""
122
+ N = len(p)
123
+ dev = p.device
124
+ u = (torch.rand(N, 2, device=dev, generator=rng) * 2 - 1) * LIGHT["half"]
125
+ lp = torch.stack([u[:, 0], torch.full((N,), LIGHT["y"], device=dev),
126
+ u[:, 1]], 1)
127
+ dl = lp - p
128
+ dist = dl.norm(dim=1).clamp_min(1e-6)
129
+ dl = dl / dist[:, None]
130
+ cos_s = (n * dl).sum(1).clamp_min(0)
131
+ # light normal is (0,-1,0); the emission cosine is between it and the
132
+ # direction light->surface (=-dl), i.e. (0,-1,0)·(-dl) = +dl_y
133
+ cos_l = dl[:, 1].clamp_min(0)
134
+ t, _, _, _ = intersect(p + EPS * n, dl, S)
135
+ vis = t > dist - 3e-3
136
+ area = (2 * LIGHT["half"]) ** 2
137
+ g = cos_s * cos_l / (dist ** 2)
138
+ return (alb / torch.pi) * S["Le"] * (g * vis * area)[:, None]
139
+
140
+
141
+ def trace_split(o, d, S, rng, depth=5):
142
+ """Path trace with NEE; returns (emitted, direct, indirect) per ray.
143
+
144
+ emitted: light seen directly by the given ray
145
+ direct: NEE at the FIRST hit (analytic given visibility)
146
+ indirect: everything after the first bounce — the part the neural
147
+ cache learns
148
+ Also returns the first-hit geometry (t, n, albedo) for cache training.
149
+ """
150
+ N = len(o)
151
+ dev = o.device
152
+ emitted = torch.zeros(N, 3, device=dev)
153
+ direct = torch.zeros(N, 3, device=dev)
154
+ indirect = torch.zeros(N, 3, device=dev)
155
+ tput = torch.ones(N, 3, device=dev)
156
+ alive = torch.ones(N, dtype=torch.bool, device=dev)
157
+ first = {}
158
+ co, cd = o.clone(), d.clone()
159
+ for depth_i in range(depth):
160
+ t, n, alb, isl = intersect(co, cd, S)
161
+ hit = (t < 1e8) & alive
162
+ p = co + t[:, None] * cd
163
+ if depth_i == 0:
164
+ first = dict(t=t.clone(), n=n.clone(), alb=alb.clone(),
165
+ p=p.clone(), hit=hit.clone() & ~isl)
166
+ emitted[hit & isl] = S["Le"]
167
+ alive = alive & hit & ~isl
168
+ # no .any() early-out: at these depths the surviving-ray count is
169
+ # data-dependent and the sync costs more than the wasted work
170
+ contrib = torch.zeros(N, 3, device=dev)
171
+ contrib[alive] = nee(p[alive], n[alive], alb[alive], S, rng)
172
+ contrib = contrib * tput
173
+ if depth_i == 0:
174
+ direct = direct + contrib
175
+ else:
176
+ indirect = indirect + contrib
177
+ # bounce
178
+ nd = torch.zeros_like(cd)
179
+ nd[alive] = cosine_hemisphere(n[alive], rng)
180
+ tput = tput * alb
181
+ co = p + EPS * n
182
+ cd = nd
183
+ return emitted, direct, indirect, first
184
+
185
+
186
+ def camera_rays(res, jitter, dev, rng, cam=(0.0, 0.0, 3.05), fov=0.62):
187
+ ys, xs = torch.meshgrid(
188
+ torch.linspace(1, -1, res, device=dev),
189
+ torch.linspace(-1, 1, res, device=dev), indexing="ij")
190
+ if jitter:
191
+ xs = xs + (torch.rand(res, res, device=dev, generator=rng) - .5) * (2 / res)
192
+ ys = ys + (torch.rand(res, res, device=dev, generator=rng) - .5) * (2 / res)
193
+ d = torch.stack([xs * fov, ys * fov, -torch.ones_like(xs)], -1).reshape(-1, 3)
194
+ d = d / d.norm(dim=1, keepdim=True)
195
+ o = torch.tensor(cam, device=dev).expand_as(d).contiguous()
196
+ return o, d
197
+
198
+
199
+ def scene_tensors(dev):
200
+ return _dev(dev)
experiments/radiance_cache.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6c963cdd817d845c9290ced6c1d188887e28b0b41f12707a9f5ea9b7f7e0245d
3
+ size 95959
experiments/w11_world_lighting.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """W11: neural lighting cache for the voxel world (ray-traced AO -> .pt).
2
+
3
+ The ray-tracing tie-in for the Neural World demo. Sky visibility (ambient
4
+ occlusion) is TRACED: from each ground point we shoot a hemisphere of
5
+ rays and count how many escape past the tall blocks (house, trees) to the
6
+ sky. That expensive per-point integral is distilled into a tiny MLP that
7
+ the browser evaluates per tile in real time — the same "trace once, learn
8
+ the field, look it up" idea as the W9 radiance cache, specialized to this
9
+ scene's geometry.
10
+
11
+ Layout MUST match the demo's hand-placed world (index.html): house 3x3 at
12
+ rows/cols 2..4, ten trees at fixed spots — everything else is flat.
13
+ """
14
+ import sys, os, time
15
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
+
17
+ import numpy as np
18
+ import torch
19
+ import torch.nn as nn
20
+
21
+ torch.manual_seed(0)
22
+ HERE = os.path.dirname(os.path.abspath(__file__))
23
+ ROOT = os.path.dirname(HERE)
24
+ DEV = "cuda" if torch.cuda.is_available() else "cpu"
25
+ N = 32
26
+ NFREQ = 6
27
+
28
+ # tall occluders (cx, cz, half, top) — must match the demo world layout.
29
+ # house: 6x6 perimeter centred at (14.5, 14.5); trees: the demo's fixed set.
30
+ # x=col, z=row (three.js places tile (row r, col c) at x=c, z=r).
31
+ TREES = [[3, 4], [5, 20], [7, 27], [2, 10], [4, 28], [9, 3], [6, 24], [10, 29],
32
+ [20, 4], [22, 27], [25, 10], [27, 22], [29, 6], [24, 29], [19, 28],
33
+ [28, 15], [3, 16], [8, 8], [23, 3], [26, 26], [21, 20], [18, 3],
34
+ [29, 29], [2, 24]]
35
+ HR0, HC0, HS = 12, 12, 6
36
+ BOXES = [(HC0 + HS / 2, HR0 + HS / 2, HS / 2, 2.2)] # house block (x,z,half,top)
37
+ for (r, c) in TREES:
38
+ if not (HR0 - 2 <= r < HR0 + HS + 2 and HC0 - 2 <= c < HC0 + HS + 2):
39
+ BOXES.append((c + 0.5, r + 0.5, 0.6, 2.4)) # tree (x=col, z=row)
40
+ BOX = torch.tensor(BOXES, device=DEV) # (B,4): cx,cz,half,top
41
+
42
+
43
+ def sky_visibility(pts, n_rays=64):
44
+ """pts (P,2) ground xz -> AO in [0,1] via hemisphere ray marching."""
45
+ P = len(pts)
46
+ g = torch.Generator(device=DEV); g.manual_seed(1)
47
+ # cosine-ish hemisphere directions (upper)
48
+ u1 = torch.rand(n_rays, device=DEV, generator=g)
49
+ u2 = torch.rand(n_rays, device=DEV, generator=g)
50
+ r = torch.sqrt(u1); phi = 2 * np.pi * u2
51
+ dirs = torch.stack([r * torch.cos(phi),
52
+ torch.sqrt((1 - u1).clamp_min(0)), # up = +y
53
+ r * torch.sin(phi)], 1) # (n_rays,3)
54
+ o = torch.stack([pts[:, 0], torch.full((P,), 0.55, device=DEV),
55
+ pts[:, 1]], 1) # (P,3)
56
+ vis = torch.ones(P, n_rays, device=DEV)
57
+ cx, cz, half, top = BOX[:, 0], BOX[:, 1], BOX[:, 2], BOX[:, 3]
58
+ # march: does ray (o,d) hit any box slab before escaping upward?
59
+ for b in range(len(BOX)):
60
+ # slab intersection in xz, check the entry y is below the box top
61
+ dx = dirs[:, 0][None, :]; dz = dirs[:, 2][None, :]; dy = dirs[:, 1][None, :]
62
+ ox = o[:, 0][:, None]; oz = o[:, 2][:, None]; oy = o[:, 1][:, None]
63
+ inv_x = 1.0 / torch.where(dx.abs() < 1e-6, torch.full_like(dx, 1e-6), dx)
64
+ inv_z = 1.0 / torch.where(dz.abs() < 1e-6, torch.full_like(dz, 1e-6), dz)
65
+ tx1 = (cx[b] - half[b] - ox) * inv_x; tx2 = (cx[b] + half[b] - ox) * inv_x
66
+ tz1 = (cz[b] - half[b] - oz) * inv_z; tz2 = (cz[b] + half[b] - oz) * inv_z
67
+ tmin = torch.maximum(torch.minimum(tx1, tx2), torch.minimum(tz1, tz2))
68
+ tmax = torch.minimum(torch.maximum(tx1, tx2), torch.maximum(tz1, tz2))
69
+ hit_xz = (tmax > tmin) & (tmax > 0)
70
+ t_enter = tmin.clamp_min(0)
71
+ y_at = oy + dy * t_enter
72
+ blocked = hit_xz & (y_at < top[b]) & (t_enter > 1e-3)
73
+ vis = torch.where(blocked, torch.zeros_like(vis), vis)
74
+ return vis.mean(1) # (P,) AO
75
+
76
+
77
+ def freq_encode(xz):
78
+ bands = (2.0 ** torch.arange(NFREQ, device=xz.device)) * np.pi
79
+ proj = xz[..., None] * bands
80
+ return torch.cat([xz, torch.sin(proj).flatten(-2),
81
+ torch.cos(proj).flatten(-2)], -1)
82
+
83
+
84
+ class LightNet(nn.Module):
85
+ def __init__(self, hidden=64):
86
+ super().__init__()
87
+ din = 2 + 2 * 2 * NFREQ
88
+ self.net = nn.Sequential(nn.Linear(din, hidden), nn.SiLU(),
89
+ nn.Linear(hidden, hidden), nn.SiLU(),
90
+ nn.Linear(hidden, 3))
91
+
92
+ def forward(self, xz):
93
+ return torch.nn.functional.softplus(self.net(freq_encode(xz)))
94
+
95
+
96
+ # ---- ground-truth AO -> ambient RGB on a dense grid ----
97
+ print(f"=== tracing sky visibility on {DEV} ===")
98
+ t0 = time.time()
99
+ G = 112
100
+ gx, gz = torch.meshgrid(torch.linspace(0, N, G, device=DEV),
101
+ torch.linspace(0, N, G, device=DEV), indexing="ij")
102
+ pts = torch.stack([gx.ravel(), gz.ravel()], 1)
103
+ ao = sky_visibility(pts, n_rays=96)
104
+ SKY = torch.tensor([0.55, 0.70, 1.0], device=DEV) # cool sky
105
+ GROUND = torch.tensor([0.30, 0.32, 0.28], device=DEV) # warm bounce floor
106
+ amb = (0.30 + 0.70 * ao)[:, None] * SKY + (1 - ao)[:, None] * GROUND * 0.4
107
+ xz_norm = pts / N * 2 - 1
108
+ print(f" {G*G} points, mean AO {ao.mean():.3f} "
109
+ f"(min {ao.min():.3f}) ({time.time()-t0:.1f}s)")
110
+
111
+ # ---- fit the cache ----
112
+ net = LightNet().to(DEV)
113
+ print(f"=== training light cache ({sum(p.numel() for p in net.parameters())} params) ===")
114
+ opt = torch.optim.Adam(net.parameters(), lr=3e-3)
115
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=2500)
116
+ for it in range(2500):
117
+ idx = torch.randint(0, len(xz_norm), (8192,), device=DEV)
118
+ pred = net(xz_norm[idx])
119
+ loss = ((pred - amb[idx]) ** 2).mean()
120
+ opt.zero_grad(); loss.backward(); opt.step(); sched.step()
121
+ if it % 500 == 0 or it == 2499:
122
+ print(f" it {it:4d} MSE {loss.item():.6f}")
123
+ with torch.no_grad():
124
+ err = (net(xz_norm) - amb).abs().mean().item()
125
+ print(f" final mean abs error {err:.4f}")
126
+
127
+ # ---- figure: traced vs learned ambient ----
128
+ try:
129
+ import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt
130
+ with torch.no_grad():
131
+ pr = net(xz_norm).reshape(G, G, 3).clamp(0, 1).cpu().numpy()
132
+ gt = amb.reshape(G, G, 3).clamp(0, 1).cpu().numpy()
133
+ fig, ax = plt.subplots(1, 3, figsize=(14, 4.6))
134
+ ax[0].imshow(ao.reshape(G, G).cpu().numpy(), cmap="bone"); ax[0].set_title("traced sky visibility (AO)")
135
+ ax[1].imshow(gt); ax[1].set_title("target ambient (AO -> RGB)")
136
+ ax[2].imshow(pr); ax[2].set_title(f"neural cache (world_light.pt), err {err:.3f}")
137
+ for a in ax: a.set_xticks([]); a.set_yticks([])
138
+ fig.suptitle("Neural world lighting — traced ambient occlusion distilled to a .pt")
139
+ fig.tight_layout()
140
+ out = os.path.join(ROOT, "world_lighting_validation.png")
141
+ fig.savefig(out, dpi=110); print(f"wrote {out}")
142
+ except Exception as e:
143
+ print("plot skipped:", e)
144
+
145
+ torch.save({"state_dict": net.state_dict(), "arch": {"nfreq": NFREQ, "hidden": 64},
146
+ "scene": "neural_world_32"},
147
+ os.path.join(HERE, "world_light.pt"))
148
+ print("saved experiments/world_light.pt")
experiments/w9_neural_radiance_cache.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """W9: neural radiance cache — neural ray tracing on the project skeleton.
2
+
3
+ Claim under test (the light-transport analog of the physics thesis): if
4
+ you keep visibility and direct lighting analytic and learn ONLY the
5
+ indirect transport with a tiny tied MLP, you match a many-bounce path
6
+ tracer at a fraction of the cost — one analytic ray + NEE + a network
7
+ lookup replaces the random walk after the first bounce.
8
+
9
+ Render decomposition: L = emitted + direct(analytic) + indirect
10
+ - reference: indirect from high-spp path tracing (ground truth)
11
+ - neural: indirect from cache_theta(first-hit point, normal)
12
+ - baseline: an equal-COST path trace (few spp) for a fair comparison
13
+
14
+ Success criteria:
15
+ A. cache accuracy on HELD-OUT surface points (never-seen view) — PSNR
16
+ of predicted vs true indirect radiance
17
+ B. full-image PSNR: neural-cached vs reference, and it beats the
18
+ equal-cost path-traced baseline
19
+ C. speedup at matched quality (spp the baseline needs to reach the
20
+ neural render's PSNR)
21
+ """
22
+ import sys, os, time
23
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
24
+
25
+ import numpy as np
26
+ import torch
27
+ from engine3d.raytrace import (camera_rays, trace_split, scene_tensors,
28
+ intersect)
29
+ from engine3d.neural_rt import RadianceCache
30
+
31
+ torch.manual_seed(0)
32
+ HERE = os.path.dirname(os.path.abspath(__file__))
33
+ ROOT = os.path.dirname(HERE)
34
+ DEV = "cuda" if torch.cuda.is_available() else "cpu"
35
+ S = scene_tensors(DEV)
36
+
37
+
38
+ def rng_of(seed):
39
+ g = torch.Generator(device=DEV); g.manual_seed(seed); return g
40
+
41
+
42
+ def render(res, spp, seed, cam=(0.0, 0.0, 3.05), depth=5):
43
+ """Full reference render (emitted + direct + indirect), returns (res,res,3)."""
44
+ g = rng_of(seed)
45
+ acc = torch.zeros(res * res, 3, device=DEV)
46
+ for _ in range(spp):
47
+ o, d = camera_rays(res, True, DEV, g, cam=cam)
48
+ em, di, ind, _ = trace_split(o, d, S, g, depth=depth)
49
+ acc += em + di + ind
50
+ return (acc / spp).reshape(res, res, 3)
51
+
52
+
53
+ def render_components(res, spp, seed, cam=(0.0, 0.0, 3.05)):
54
+ """Per-pixel emitted+direct (low variance) and first-hit geometry, plus
55
+ a high-spp indirect target. Used to compose the neural image."""
56
+ g = rng_of(seed)
57
+ ed = torch.zeros(res * res, 3, device=DEV) # emitted + direct
58
+ ind = torch.zeros(res * res, 3, device=DEV)
59
+ o, d = camera_rays(res, False, DEV, g, cam=cam) # no jitter: fixed hits
60
+ for _ in range(spp):
61
+ em, di, ii, first = trace_split(o, d, S, g, depth=5)
62
+ ed += em + di
63
+ ind += ii
64
+ ed /= spp; ind /= spp
65
+ o2, d2 = camera_rays(res, False, DEV, rng_of(seed), cam=cam)
66
+ _, n, alb, isl = intersect(o2, d2, S)
67
+ t, _, _, _ = intersect(o2, d2, S)
68
+ p = o2 + t[:, None] * d2
69
+ hit = (t < 1e8) & ~isl
70
+ return ed, ind, p, n, hit
71
+
72
+
73
+ def psnr(a, b, mask=None):
74
+ if mask is not None:
75
+ a, b = a[mask], b[mask]
76
+ mse = ((a.clamp(0, 4) - b.clamp(0, 4)) ** 2).mean().item()
77
+ return 10 * np.log10(4.0 ** 2 / max(mse, 1e-12))
78
+
79
+
80
+ # ---------- training data: surface points with GT indirect ----------
81
+ print(f"=== generating radiance-cache training data on {DEV} ===")
82
+ t0 = time.time()
83
+ PTS, NRM, TGT = [], [], []
84
+ CAM_TRAIN = [(0.0, 0.0, 3.05), (0.7, 0.2, 2.9), (-0.7, 0.25, 2.9),
85
+ (0.0, 0.6, 2.8), (0.4, -0.3, 3.0)]
86
+ for ci, cam in enumerate(CAM_TRAIN):
87
+ g = rng_of(100 + ci)
88
+ o, d = camera_rays(80, True, DEV, g, cam=cam)
89
+ t, n, alb, isl = intersect(o, d, S)
90
+ p = o + t[:, None] * d
91
+ hit = (t < 1e8) & ~isl
92
+ p, n = p[hit], n[hit]
93
+ # GT indirect at these points: average many one-bounce-onward paths
94
+ ind = torch.zeros(len(p), 3, device=DEV)
95
+ SPP = 256
96
+ for _ in range(SPP):
97
+ from engine3d.raytrace import cosine_hemisphere, nee, EPS
98
+ nd = cosine_hemisphere(n, g)
99
+ # radiance arriving from the bounce dir, path-traced (depth 4),
100
+ # times the cosine-weighted albedo throughput (albedo/pi * pi = albedo)
101
+ _, ndi, nind, _ = trace_split(p + EPS * n, nd, S, g, depth=4)
102
+ ind += (ndi + nind)
103
+ ind /= SPP
104
+ alb_h = alb[hit]
105
+ ind = ind * alb_h # outgoing = albedo * incident indirect
106
+ PTS.append(p); NRM.append(n); TGT.append(ind)
107
+ P = torch.cat(PTS); Nrm = torch.cat(NRM); Y = torch.cat(TGT)
108
+ print(f" {len(P)} surface samples from {len(CAM_TRAIN)} views "
109
+ f"({time.time()-t0:.1f}s)")
110
+
111
+ # ---------- train the cache ----------
112
+ cache = RadianceCache().to(DEV)
113
+ print(f"=== training radiance cache ({cache.n_params()} params) ===")
114
+ opt = torch.optim.Adam(cache.parameters(), lr=3e-3)
115
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=1500)
116
+ # tone-mapped (log) loss: indirect radiance spans orders of magnitude and
117
+ # the eye is roughly logarithmic — regress log(1+L), like real NRC
118
+ logY = torch.log1p(Y)
119
+ t0 = time.time()
120
+ for it in range(1500):
121
+ idx = torch.randint(0, len(P), (16384,), device=DEV)
122
+ pred = cache(P[idx], Nrm[idx])
123
+ loss = ((torch.log1p(pred) - logY[idx]) ** 2).mean()
124
+ opt.zero_grad(); loss.backward(); opt.step(); sched.step()
125
+ if it % 300 == 0 or it == 1499:
126
+ print(f" it {it:4d} log-MSE {loss.item():.5f}")
127
+ print(f" ({time.time()-t0:.1f}s)")
128
+
129
+ # ---------- criterion A: held-out points (novel view) ----------
130
+ g = rng_of(999)
131
+ cam_test = (0.35, -0.15, 2.95)
132
+ o, d = camera_rays(80, True, DEV, g, cam=cam_test)
133
+ t, n, alb, isl = intersect(o, d, S)
134
+ p = o + t[:, None] * d
135
+ hit = (t < 1e8) & ~isl
136
+ p, n, alb = p[hit], n[hit], alb[hit]
137
+ ind = torch.zeros(len(p), 3, device=DEV)
138
+ from engine3d.raytrace import cosine_hemisphere, EPS
139
+ for _ in range(256):
140
+ nd = cosine_hemisphere(n, g)
141
+ _, ndi, nind, _ = trace_split(p + EPS * n, nd, S, g, depth=4)
142
+ ind += (ndi + nind)
143
+ ind = ind / 256 * alb
144
+ with torch.no_grad():
145
+ pred = cache(p, n)
146
+ a_psnr = psnr(pred, ind)
147
+ print(f"\n[A] held-out indirect radiance PSNR: {a_psnr:.2f} dB "
148
+ f"(novel view, {len(p)} pts)")
149
+
150
+ # ---------- criterion B & C: full-image renders ----------
151
+ RES = 112
152
+ REF_SPP = 256
153
+ print(f"\n=== full renders at {RES}x{RES} ===")
154
+ t0 = time.time(); REF = render(RES, REF_SPP, 7); ref_ms = (time.time()-t0)*1000
155
+ print(f" reference ({REF_SPP} spp): {ref_ms:.0f} ms")
156
+
157
+ # neural: analytic emitted+direct at low spp + cache indirect
158
+ t0 = time.time()
159
+ ed, _, p, n, hit = render_components(RES, 4, 7)
160
+ with torch.no_grad():
161
+ ind_pred = torch.zeros(RES * RES, 3, device=DEV)
162
+ ind_pred[hit] = cache(p[hit], n[hit])
163
+ NEUR = (ed + ind_pred).reshape(RES, RES, 3)
164
+ neur_ms = (time.time()-t0)*1000
165
+ print(f" neural (4 spp direct + cache): {neur_ms:.0f} ms")
166
+
167
+ # baseline: equal-cost full path trace
168
+ def spp_for_ms(target_ms):
169
+ t0 = time.time(); render(RES, 4, 7); one = (time.time()-t0)/4*1000
170
+ return max(1, round(target_ms / one)), one
171
+ base_spp, per = spp_for_ms(neur_ms)
172
+ BASE = render(RES, base_spp, 7)
173
+
174
+ pn = psnr(NEUR, REF)
175
+ pb = psnr(BASE, REF)
176
+ print(f"\n[B] full-image PSNR vs reference:")
177
+ print(f" neural (cache) : {pn:.2f} dB ({neur_ms:.0f} ms)")
178
+ print(f" path trace (equal cost) : {pb:.2f} dB ({base_spp} spp)")
179
+
180
+ # criterion C: spp the baseline needs to match the neural PSNR
181
+ match_spp = None
182
+ for spp in [8, 16, 32, 64, 128]:
183
+ if psnr(render(RES, spp, 7), REF) >= pn:
184
+ match_spp = spp; break
185
+ base_ms = (match_spp or 128) * per
186
+ print(f"\n[C] path trace needs ~{match_spp or '>256'} spp "
187
+ f"(~{base_ms:.0f} ms) to reach the neural PSNR "
188
+ f"-> ~{base_ms/neur_ms:.1f}x speedup at matched quality")
189
+
190
+ # ---------- figure ----------
191
+ try:
192
+ import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt
193
+ def tm(x): return np.clip((x.clamp(0, 4) ** (1/2.2)).cpu().numpy(), 0, 1)
194
+ err = (NEUR - REF).abs().mean(-1).cpu().numpy()
195
+ errb = (BASE - REF).abs().mean(-1).cpu().numpy()
196
+ fig, ax = plt.subplots(2, 3, figsize=(12, 8))
197
+ for a in ax.ravel(): a.set_xticks([]); a.set_yticks([])
198
+ ax[0,0].imshow(tm(REF)); ax[0,0].set_title(f"reference ({REF_SPP} spp)")
199
+ ax[0,1].imshow(tm(NEUR)); ax[0,1].set_title(f"neural cache — {pn:.1f} dB, {neur_ms:.0f} ms")
200
+ ax[0,2].imshow(tm(BASE)); ax[0,2].set_title(f"equal-cost path trace ({base_spp} spp) — {pb:.1f} dB")
201
+ ax[1,0].imshow(tm(ind_pred.reshape(RES,RES,3))); ax[1,0].set_title("learned indirect (cache only)")
202
+ m=max(err.max(),errb.max())
203
+ ax[1,1].imshow(err,cmap="inferno",vmax=m); ax[1,1].set_title("neural error")
204
+ ax[1,2].imshow(errb,cmap="inferno",vmax=m); ax[1,2].set_title("path-trace error")
205
+ fig.suptitle(f"Neural radiance cache — learn indirect, keep visibility+direct analytic "
206
+ f"(held-out cache PSNR {a_psnr:.1f} dB)", fontsize=12)
207
+ fig.tight_layout()
208
+ out=os.path.join(ROOT,"neural_raytrace_validation.png")
209
+ fig.savefig(out,dpi=110); print(f"\nwrote {out}")
210
+ except Exception as e:
211
+ print("plot skipped:", e)
212
+
213
+ torch.save({"state_dict": cache.state_dict(),
214
+ "arch": {"n_freq": 6, "hidden": 96},
215
+ "scene": "neon_cornell"},
216
+ os.path.join(HERE, "radiance_cache.pt"))
217
+ print("saved experiments/radiance_cache.pt")
experiments/world_light.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e127cfc99059eae827da9a78cf373e671a1d18253ac95a724c5d5a2000afa194
3
+ size 27184
neural_raytrace_validation.png ADDED

Git LFS Details

  • SHA256: fe85b21a05465ade49e339f5da1e97b2092d5f655239a9fb742b9d5b3fe64912
  • Pointer size: 131 Bytes
  • Size of remote file: 162 kB
world_lighting_validation.png ADDED