phanerozoic commited on
Commit
39a1f38
·
verified ·
1 Parent(s): 6b3df26

Per-texel albedo textures with bilinear-footprint adjoints

Browse files
CARD.md CHANGED
@@ -6,18 +6,17 @@ license: apache-2.0
6
  # pathtracer-diff
7
 
8
  A differentiable Monte Carlo path tracer as one kernel, loadable through
9
- `kernels`. The forward pass renders a linear-radiance image with a megakernel
10
- unidirectional path tracer (diffuse + emissive materials, per-texel albedo
11
- textures with bilinear filtering, cosine-weighted BRDF sampling, optional
12
- next-event estimation, BVH traversal); the backward pass replays the
13
- identical paths with the identical counter-based random draws and emits
14
- analytic gradients to the albedo texels and the per-material emission.
15
- Inverse rendering then runs as a bare torch loop around the kernel, with no
16
- rendering framework in the loop: render, compare to a target, `backward()`,
17
- step. Per-texel albedo is what makes the inverse problem non-trivial: a
18
- texture is thousands of unknowns estimated through indirect illumination,
19
- where per-material constants make the pixel values merely multilinear in a
20
- handful of scalars.
21
 
22
  ## Usage
23
 
@@ -28,67 +27,73 @@ from kernels import get_kernel
28
  ptd = get_kernel("phanerozoic/pathtracer-diff", version=1, trust_remote_code=True)
29
 
30
  wall_tex = torch.full((256, 256, 3), 0.5, device="cuda", requires_grad=True)
31
- albedo = [torch.tensor([0.73] * 3), # constants are 1x1 textures
32
- torch.tensor([0.65, 0.05, 0.05]),
33
- wall_tex] # [H, W, 3] per-texel albedo
34
- scene = ptd.Scene(vertices, faces, material_ids, albedo, emission, uvs=uvs)
 
 
 
 
35
  cam = ptd.Camera(position=(2.78, 2.73, -8.0), look_at=(2.78, 2.73, 2.8),
36
  vfov_deg=39.0)
37
 
38
  img = ptd.render(scene, cam, 512, 512, spp=64, max_bounces=4) # [H, W, 3] linear
39
  loss = (img - target).square().mean()
40
- loss.backward() # gradients on wall_tex (and emission)
41
  ```
42
 
43
  `version` selects the release branch; `trust_remote_code` is required by
44
  `kernels` for publishers without the trusted-publisher mark.
45
 
46
- `albedo` is either an `[M, 3]` tensor of per-material constants or a list of
47
- M per-material textures `[Hm, Wm, 3]`; constants are treated as 1x1 textures,
48
- so both forms share one code path and both receive gradients. A fixed `seed`
49
- renders with the same paths every call, so the Monte Carlo objective is a
50
- deterministic function of the parameters and gradient descent sees a smooth
51
- landscape at any spp; that is the configuration an inverse-rendering loop
52
- wants.
53
 
54
  ## API
55
 
56
  | Symbol | Purpose |
57
  |---|---|
58
- | `Scene(vertices, faces, material_ids, albedo, emission, uvs)` | triangle-mesh scene; the BVH and the emissive-face light list are built at construction; `uvs` is `[V, 2]` per vertex or `[F, 3, 2]` per corner, textures wrap (repeat); texture shapes are fixed at construction |
59
  | `Camera(position, look_at, up, vfov_deg)` | pinhole camera |
60
- | `render(scene, camera, height, width, spp, max_bounces, nee, seed)` | differentiable render to `[H, W, 3]` f32 linear radiance; autograd to the scene's albedo (constants or texels) and emission |
 
61
  | `ops.pt_forward` / `ops.pt_backward` | raw kernel launches |
62
 
63
  ## How it works
64
 
65
  One thread owns one pixel and accumulates its spp samples serially, so the
66
- image is bitwise deterministic with no atomics in the forward pass. Each
67
- sample is a unidirectional path: the albedo at a hit is a bilinear fetch
68
- from the material's texture (repeat wrap), cosine-weighted hemisphere
69
- sampling makes the diffuse throughput multiplier exactly that albedo per
70
- bounce, and with next-event estimation on, emission is gathered at the
71
- camera hit while every later vertex estimates direct light by area sampling
72
- over the emissive-face list. NEE is skipped at the final vertex so the NEE
73
- and BRDF-only estimators cover camera-to-light paths of the same maximum
74
- length and agree in expectation at any bounce budget, not just in the limit.
75
-
76
- The backward pass is exact path replay. The random stream is counter-based
77
- Philox keyed by `(seed, pixel, sample)`, and sampling directions depend only
78
- on geometry and that stream, never on albedo or emission, so the backward
79
- kernel re-traces the identical paths with the identical draws and no stored
80
- path state. Each radiance term is a product of per-bounce albedo factors, a
81
- geometry scalar, and an emission value; the replay differentiates it in
82
- closed form with prefix/suffix exclusion products (no division, so zero
83
- albedos are safe) and scatters each bounce's gradient into the four texels
84
- of its bilinear footprint with the sampling weights, the exact adjoint of
85
- the fetch. Texture-gradient buffers up to 2048 texels are accumulated in
86
- per-block shared memory and flushed once (per-material constants and small
87
- textures take this path); larger textures use direct global atomics, where
88
- the contention is spread across the texel buffer. Parameter gradients are
89
- exactly the derivative of the rendered estimate: interior terms only, no
90
- visibility/silhouette gradients, which is the correct and complete
91
- derivative for materials and emission under fixed geometry.
 
 
 
92
 
93
  ## Correctness
94
 
@@ -96,57 +101,74 @@ Measured on RTX 6000 Ada (sm89) from a source build; the published `v1`
96
  variants additionally pass the full suite through `get_kernel` on a GeForce
97
  RTX 3070 Ti (Ampere sm86, Linux, torch 2.13, cu126):
98
 
99
- - **Analytic furnace, zero variance.** In a closed uniform box with BRDF
100
- sampling, every path returns exactly `E * sum a^k` independent of the
101
- random walk; at `a = 0.5`, `E = 0.25`, 8 bounces the analytic value is
102
- 0.498046875 and the worst pixel deviates 3.4e-3 (64x64, 4 spp).
103
- - **Estimator agreement.** The NEE and BRDF-only estimators agree to 0.10%
104
- on a diffuse room with an area light (128x128; 128 vs 512 spp).
105
- - **Gradients match finite differences.** With a fixed seed the loss is
106
- smooth in the parameters along the same paths; analytic replay gradients
107
- match central differences to 3.1e-4 max relative error over nine
108
- constant albedo/emission entries, and to 1.1e-3 over five texels of a
109
- 4x4 wall texture through the bilinear-footprint adjoint.
110
- - **Constants are 1x1 textures.** The two albedo forms render images equal
111
- to float rounding and receive matching gradients.
112
- - **Inverse rendering.** Recovering a wall color (3 unknowns) reaches max
113
- abs error 0.003 in 80 Adam steps; recovering an 8x8 wall texture (192
114
- unknowns) drives the image loss from 1.4e-4 to 4.8e-14 and the mean texel
115
- error below 5e-4 in 200 steps, both through the kernel alone.
116
- - **Deterministic.** Repeated forward renders are bitwise identical
117
- (`torch.equal`); gradient buffers accumulate through float atomics and are
118
- deterministic in expectation but not bitwise.
 
 
 
 
 
119
 
120
  ## Measured
121
 
122
- RTX 6000 Ada, Cornell box with an interior blocker (24 triangles, 4
123
- materials), 512x512, 64 spp, 4 bounces, NEE on:
 
124
 
125
- | pass | time | rate |
126
- |---|---|---|
127
- | forward | 9.6 ms | 1.76 Gpaths/s |
128
- | backward (replay) | 35.7 ms | 3.74x forward |
 
129
 
130
  ## Requirements and limits
131
 
132
  - NVIDIA GPU with compute capability 8.0+; float32 throughout.
133
- - Diffuse (Lambertian) + emissive materials; pinhole camera; at most 64
134
- materials and 16 bounces; two-sided surfaces; emission is per-material
135
- constant (textures carry albedo only).
136
- - Differentiable parameters are the albedo texels/constants and per-material
137
- emission. Geometry, camera, and UVs are not differentiable inputs, and
138
- there are no visibility/silhouette gradients.
139
- - Texture shapes are fixed at Scene construction; UVs wrap (repeat).
140
- - Published variants are Linux x86_64; on Windows `load_local.py` JIT-builds
141
- the same source and exposes the identical API.
 
 
 
 
 
 
142
 
143
  ## References
144
 
145
  Kajiya, "The Rendering Equation" (SIGGRAPH 1986); Vicini, Speierer, Jakob,
146
  "Path Replay Backpropagation" (SIGGRAPH 2021); Nimier-David, Speierer,
147
- Ruiz, Jakob, "Radiative Backpropagation" (SIGGRAPH 2020); Möller and
148
- Trumbore, "Fast, Minimum Storage Ray-Triangle Intersection" (1997); Duff et
149
- al., "Building an Orthonormal Basis, Revisited" (JCGT 2017).
 
 
 
 
150
 
151
  ## License
152
 
 
6
  # pathtracer-diff
7
 
8
  A differentiable Monte Carlo path tracer as one kernel, loadable through
9
+ `kernels`. The forward pass is a megakernel unidirectional path tracer:
10
+ Lambertian diffuse, GGX conductor (VNDF-sampled, height-correlated Smith),
11
+ and smooth dielectric materials; area lights and an importance-sampled
12
+ equirectangular environment map; multiple importance sampling (balance
13
+ heuristic) by default; per-texel albedo with bilinear filtering; a
14
+ binned-SAH BVH built in vectorized torch in fractions of a second at a
15
+ million triangles. The backward pass replays the identical paths with the
16
+ identical counter-based random draws and emits analytic gradients to the
17
+ albedo texels, the per-material emission, and the environment texels.
18
+ Inverse rendering runs as a bare torch loop around the kernel, with no
19
+ rendering framework in the loop: render, compare, `backward()`, step.
 
20
 
21
  ## Usage
22
 
 
27
  ptd = get_kernel("phanerozoic/pathtracer-diff", version=1, trust_remote_code=True)
28
 
29
  wall_tex = torch.full((256, 256, 3), 0.5, device="cuda", requires_grad=True)
30
+ env = torch.full((64, 128, 3), 0.8, device="cuda", requires_grad=True)
31
+ scene = ptd.Scene(vertices, faces, material_ids,
32
+ albedo=[torch.tensor([0.73] * 3), # constants are 1x1 textures
33
+ wall_tex, # [H, W, 3] per-texel albedo
34
+ torch.tensor([0.9, 0.6, 0.3])],
35
+ emission=emission, uvs=uvs,
36
+ material_types=[ptd.DIFFUSE, ptd.DIFFUSE, ptd.CONDUCTOR],
37
+ roughness=[0.3, 0.3, 0.2], env=env)
38
  cam = ptd.Camera(position=(2.78, 2.73, -8.0), look_at=(2.78, 2.73, 2.8),
39
  vfov_deg=39.0)
40
 
41
  img = ptd.render(scene, cam, 512, 512, spp=64, max_bounces=4) # [H, W, 3] linear
42
  loss = (img - target).square().mean()
43
+ loss.backward() # gradients on wall_tex, emission, and env
44
  ```
45
 
46
  `version` selects the release branch; `trust_remote_code` is required by
47
  `kernels` for publishers without the trusted-publisher mark.
48
 
49
+ A fixed `seed` renders the same paths every call, so the Monte Carlo
50
+ objective is a deterministic function of the parameters and gradient
51
+ descent sees a smooth landscape at any spp; that is the configuration an
52
+ inverse-rendering loop wants. `estimator` selects `"mis"` (default),
53
+ `"nee"`, or `"brdf"`.
 
 
54
 
55
  ## API
56
 
57
  | Symbol | Purpose |
58
  |---|---|
59
+ | `Scene(vertices, faces, material_ids, albedo, emission, uvs, material_types, roughness, ior, env)` | triangle-mesh scene; BVH, light list, and the detached environment CDF are built at construction; albedo is `[M, 3]` constants or a list of `[Hm, Wm, 3]` textures; `env` is an optional `[Eh, Ew, 3]` equirect map; texture/env shapes are fixed at construction |
60
  | `Camera(position, look_at, up, vfov_deg)` | pinhole camera |
61
+ | `render(scene, camera, height, width, spp, max_bounces, estimator, seed)` | differentiable render to `[H, W, 3]` f32 linear radiance; autograd to albedo texels, emission, and env texels |
62
+ | `DIFFUSE`, `CONDUCTOR`, `DIELECTRIC` | material type constants |
63
  | `ops.pt_forward` / `ops.pt_backward` | raw kernel launches |
64
 
65
  ## How it works
66
 
67
  One thread owns one pixel and accumulates its spp samples serially, so the
68
+ image is bitwise deterministic with no atomics in the forward pass.
69
+ Diffuse vertices sample the cosine hemisphere (the throughput multiplier
70
+ is then exactly the albedo); conductors sample the GGX visible-normal
71
+ distribution (Heitz 2018) with the height-correlated Smith term, so the
72
+ continuation weight is `F * G2/G1`; smooth dielectrics branch on the
73
+ Fresnel term between reflection and refraction. Direct light is estimated
74
+ at every non-delta vertex by area sampling over the emissive faces and,
75
+ when an environment map is present, by sampling a detached mixture of its
76
+ luminance CDF and the uniform sphere; BSDF-sampled hits on emitters and
77
+ environment misses are combined with the balance heuristic, so all three
78
+ estimators agree in expectation at any bounce budget.
79
+
80
+ The backward pass is exact path replay. Sampling distributions depend only
81
+ on geometry, frozen material parameters (roughness, ior), the
82
+ construction-time environment CDF, and the counter-based Philox stream
83
+ keyed by `(seed, pixel, sample)`, never on the differentiable parameters,
84
+ so the backward kernel re-traces identical paths with identical draws and
85
+ no stored path state. Every radiance term is a product of per-bounce
86
+ factors, each affine in its vertex's albedo texels (Schlick Fresnel is
87
+ affine in F0; dielectric factors are constant), times a linear emission or
88
+ environment texel; the replay differentiates the product in closed form
89
+ with prefix/suffix exclusion products (no division, so zero albedos are
90
+ safe) and scatters each factor's gradient into the four texels of its
91
+ bilinear footprint with the sampling weights, the exact adjoint of the
92
+ fetch. Texture-gradient buffers up to 2048 texels accumulate in per-block
93
+ shared memory and flush once; larger textures and environment gradients
94
+ use direct global atomics spread across the buffer. Gradients are the
95
+ exact derivative of the rendered estimate under frozen sampling: interior
96
+ terms only, no visibility/silhouette gradients.
97
 
98
  ## Correctness
99
 
 
101
  variants additionally pass the full suite through `get_kernel` on a GeForce
102
  RTX 3070 Ti (Ampere sm86, Linux, torch 2.13, cu126):
103
 
104
+ - **Analytic furnace, zero variance.** Closed uniform diffuse box under
105
+ BRDF sampling: every path returns exactly `E * sum a^k`; the analytic
106
+ value 0.498046875 is met with worst pixel deviation 3.4e-3. A diffuse
107
+ plane under a constant environment reproduces `albedo * c` the same way.
108
+ - **Fresnel slab.** A smooth glass slab (ior 1.5) at normal incidence
109
+ transmits the internal-bounce series `(1-R)/(1+R)`; measured 0.9222
110
+ against 0.923077 analytic (0.10%).
111
+ - **Estimator agreement.** MIS, NEE-only, and BRDF-only means agree within
112
+ Monte Carlo tolerance on a Cornell box, and MIS vs BRDF-only agree on a
113
+ GGX conductor under an environment map, which jointly exercises the VNDF
114
+ weight, the GGX pdf, and the environment pdf.
115
+ - **Gradients match finite differences along the same paths.** Constants
116
+ 4.6e-4 max relative error over nine entries; wall-texture texels 5.2e-3;
117
+ conductor F0 through GGX sampling and MIS 8.4e-5; environment texels
118
+ 2.1e-4 (detached CDF). Texels no path can see get exactly zero from both
119
+ the replay and the differences.
120
+ - **Constants are 1x1 textures.** Both albedo forms render equal images to
121
+ float rounding and receive matching gradients.
122
+ - **Inverse rendering.** A wall color (3 unknowns) recovers to max error
123
+ 0.004 in 80 Adam steps; an 8x8 wall texture (192 unknowns) drives the
124
+ image loss from 1.8e-4 to 6.5e-14 with mean texel error below 5e-4 in
125
+ 200 steps, both through the kernel alone.
126
+ - **Deterministic.** Repeated forward renders are bitwise identical;
127
+ gradient buffers accumulate through float atomics and are deterministic
128
+ in expectation but not bitwise.
129
 
130
  ## Measured
131
 
132
+ RTX 6000 Ada, 512x512, MIS, 4 bounces. The terrain scenes are displaced
133
+ grids with a 256x256 checker albedo texture, a GGX conductor block, and a
134
+ constant environment map; the SAH build is the vectorized torch builder.
135
 
136
+ | scene | build | forward | backward (replay) |
137
+ |---|---|---|---|
138
+ | Cornell box, 24 tris, 64 spp | - | 12.8 ms (1.31 Gpaths/s) | 59.0 ms (4.6x) |
139
+ | terrain, 522,254 tris, 16 spp | 0.2 s | 18.2 ms (230 Mpaths/s) | 35.3 ms (1.9x) |
140
+ | terrain, 1,045,470 tris, 16 spp | 0.3 s | 21.6 ms (194 Mpaths/s) | 37.9 ms (1.8x) |
141
 
142
  ## Requirements and limits
143
 
144
  - NVIDIA GPU with compute capability 8.0+; float32 throughout.
145
+ - Materials: Lambertian diffuse, GGX conductor (roughness clamped to
146
+ >= 0.01), smooth dielectric; per-material emission; pinhole camera; at
147
+ most 64 materials and 16 bounces; two-sided surfaces; no Russian
148
+ roulette (fixed bounce budget keeps the replay exact).
149
+ - Differentiable parameters: albedo texels/constants (all material types;
150
+ conductor F0 tint), per-material emission, environment texels. Geometry,
151
+ camera, UVs, roughness, and ior are frozen, and there are no
152
+ visibility/silhouette gradients.
153
+ - The environment sampling CDF is detached at Scene construction (a 0.5
154
+ uniform-sphere mixture keeps every direction reachable, so optimizing an
155
+ env map from an arbitrary initialization stays unbiased); rebuild the
156
+ Scene to re-importance-sample a changed map.
157
+ - Texture and env shapes are fixed at Scene construction; UVs wrap.
158
+ - Published variants are Linux x86_64; on Windows `load_local.py`
159
+ JIT-builds the same source and exposes the identical API.
160
 
161
  ## References
162
 
163
  Kajiya, "The Rendering Equation" (SIGGRAPH 1986); Vicini, Speierer, Jakob,
164
  "Path Replay Backpropagation" (SIGGRAPH 2021); Nimier-David, Speierer,
165
+ Ruiz, Jakob, "Radiative Backpropagation" (SIGGRAPH 2020); Heitz, "Sampling
166
+ the GGX Distribution of Visible Normals" (JCGT 2018); Heitz,
167
+ "Understanding the Masking-Shadowing Function" (JCGT 2014); Veach and
168
+ Guibas, "Optimally Combining Sampling Techniques" (SIGGRAPH 1995); Möller
169
+ and Trumbore, "Fast, Minimum Storage Ray-Triangle Intersection" (1997);
170
+ Duff et al., "Building an Orthonormal Basis, Revisited" (JCGT 2017); Wald,
171
+ "On fast Construction of SAH-based Bounding Volume Hierarchies" (2007).
172
 
173
  ## License
174
 
README.md CHANGED
@@ -6,18 +6,17 @@ license: apache-2.0
6
  # pathtracer-diff
7
 
8
  A differentiable Monte Carlo path tracer as one kernel, loadable through
9
- `kernels`. The forward pass renders a linear-radiance image with a megakernel
10
- unidirectional path tracer (diffuse + emissive materials, per-texel albedo
11
- textures with bilinear filtering, cosine-weighted BRDF sampling, optional
12
- next-event estimation, BVH traversal); the backward pass replays the
13
- identical paths with the identical counter-based random draws and emits
14
- analytic gradients to the albedo texels and the per-material emission.
15
- Inverse rendering then runs as a bare torch loop around the kernel, with no
16
- rendering framework in the loop: render, compare to a target, `backward()`,
17
- step. Per-texel albedo is what makes the inverse problem non-trivial: a
18
- texture is thousands of unknowns estimated through indirect illumination,
19
- where per-material constants make the pixel values merely multilinear in a
20
- handful of scalars.
21
 
22
  ## Usage
23
 
@@ -28,67 +27,73 @@ from kernels import get_kernel
28
  ptd = get_kernel("phanerozoic/pathtracer-diff", version=1, trust_remote_code=True)
29
 
30
  wall_tex = torch.full((256, 256, 3), 0.5, device="cuda", requires_grad=True)
31
- albedo = [torch.tensor([0.73] * 3), # constants are 1x1 textures
32
- torch.tensor([0.65, 0.05, 0.05]),
33
- wall_tex] # [H, W, 3] per-texel albedo
34
- scene = ptd.Scene(vertices, faces, material_ids, albedo, emission, uvs=uvs)
 
 
 
 
35
  cam = ptd.Camera(position=(2.78, 2.73, -8.0), look_at=(2.78, 2.73, 2.8),
36
  vfov_deg=39.0)
37
 
38
  img = ptd.render(scene, cam, 512, 512, spp=64, max_bounces=4) # [H, W, 3] linear
39
  loss = (img - target).square().mean()
40
- loss.backward() # gradients on wall_tex (and emission)
41
  ```
42
 
43
  `version` selects the release branch; `trust_remote_code` is required by
44
  `kernels` for publishers without the trusted-publisher mark.
45
 
46
- `albedo` is either an `[M, 3]` tensor of per-material constants or a list of
47
- M per-material textures `[Hm, Wm, 3]`; constants are treated as 1x1 textures,
48
- so both forms share one code path and both receive gradients. A fixed `seed`
49
- renders with the same paths every call, so the Monte Carlo objective is a
50
- deterministic function of the parameters and gradient descent sees a smooth
51
- landscape at any spp; that is the configuration an inverse-rendering loop
52
- wants.
53
 
54
  ## API
55
 
56
  | Symbol | Purpose |
57
  |---|---|
58
- | `Scene(vertices, faces, material_ids, albedo, emission, uvs)` | triangle-mesh scene; the BVH and the emissive-face light list are built at construction; `uvs` is `[V, 2]` per vertex or `[F, 3, 2]` per corner, textures wrap (repeat); texture shapes are fixed at construction |
59
  | `Camera(position, look_at, up, vfov_deg)` | pinhole camera |
60
- | `render(scene, camera, height, width, spp, max_bounces, nee, seed)` | differentiable render to `[H, W, 3]` f32 linear radiance; autograd to the scene's albedo (constants or texels) and emission |
 
61
  | `ops.pt_forward` / `ops.pt_backward` | raw kernel launches |
62
 
63
  ## How it works
64
 
65
  One thread owns one pixel and accumulates its spp samples serially, so the
66
- image is bitwise deterministic with no atomics in the forward pass. Each
67
- sample is a unidirectional path: the albedo at a hit is a bilinear fetch
68
- from the material's texture (repeat wrap), cosine-weighted hemisphere
69
- sampling makes the diffuse throughput multiplier exactly that albedo per
70
- bounce, and with next-event estimation on, emission is gathered at the
71
- camera hit while every later vertex estimates direct light by area sampling
72
- over the emissive-face list. NEE is skipped at the final vertex so the NEE
73
- and BRDF-only estimators cover camera-to-light paths of the same maximum
74
- length and agree in expectation at any bounce budget, not just in the limit.
75
-
76
- The backward pass is exact path replay. The random stream is counter-based
77
- Philox keyed by `(seed, pixel, sample)`, and sampling directions depend only
78
- on geometry and that stream, never on albedo or emission, so the backward
79
- kernel re-traces the identical paths with the identical draws and no stored
80
- path state. Each radiance term is a product of per-bounce albedo factors, a
81
- geometry scalar, and an emission value; the replay differentiates it in
82
- closed form with prefix/suffix exclusion products (no division, so zero
83
- albedos are safe) and scatters each bounce's gradient into the four texels
84
- of its bilinear footprint with the sampling weights, the exact adjoint of
85
- the fetch. Texture-gradient buffers up to 2048 texels are accumulated in
86
- per-block shared memory and flushed once (per-material constants and small
87
- textures take this path); larger textures use direct global atomics, where
88
- the contention is spread across the texel buffer. Parameter gradients are
89
- exactly the derivative of the rendered estimate: interior terms only, no
90
- visibility/silhouette gradients, which is the correct and complete
91
- derivative for materials and emission under fixed geometry.
 
 
 
92
 
93
  ## Correctness
94
 
@@ -96,58 +101,75 @@ Measured on RTX 6000 Ada (sm89) from a source build; the published `v1`
96
  variants additionally pass the full suite through `get_kernel` on a GeForce
97
  RTX 3070 Ti (Ampere sm86, Linux, torch 2.13, cu126):
98
 
99
- - **Analytic furnace, zero variance.** In a closed uniform box with BRDF
100
- sampling, every path returns exactly `E * sum a^k` independent of the
101
- random walk; at `a = 0.5`, `E = 0.25`, 8 bounces the analytic value is
102
- 0.498046875 and the worst pixel deviates 3.4e-3 (64x64, 4 spp).
103
- - **Estimator agreement.** The NEE and BRDF-only estimators agree to 0.10%
104
- on a diffuse room with an area light (128x128; 128 vs 512 spp).
105
- - **Gradients match finite differences.** With a fixed seed the loss is
106
- smooth in the parameters along the same paths; analytic replay gradients
107
- match central differences to 3.1e-4 max relative error over nine
108
- constant albedo/emission entries, and to 1.1e-3 over five texels of a
109
- 4x4 wall texture through the bilinear-footprint adjoint.
110
- - **Constants are 1x1 textures.** The two albedo forms render images equal
111
- to float rounding and receive matching gradients.
112
- - **Inverse rendering.** Recovering a wall color (3 unknowns) reaches max
113
- abs error 0.003 in 80 Adam steps; recovering an 8x8 wall texture (192
114
- unknowns) drives the image loss from 1.4e-4 to 4.8e-14 and the mean texel
115
- error below 5e-4 in 200 steps, both through the kernel alone.
116
- - **Deterministic.** Repeated forward renders are bitwise identical
117
- (`torch.equal`); gradient buffers accumulate through float atomics and are
118
- deterministic in expectation but not bitwise.
 
 
 
 
 
119
 
120
  ## Measured
121
 
122
- RTX 6000 Ada, Cornell box with an interior blocker (24 triangles, 4
123
- materials), 512x512, 64 spp, 4 bounces, NEE on:
 
124
 
125
- | pass | time | rate |
126
- |---|---|---|
127
- | forward | 9.6 ms | 1.76 Gpaths/s |
128
- | backward (replay) | 35.7 ms | 3.74x forward |
 
129
 
130
  ## Requirements and limits
131
 
132
  - NVIDIA GPU with compute capability 8.0+; float32 throughout.
133
- - Diffuse (Lambertian) + emissive materials; pinhole camera; at most 64
134
- materials and 16 bounces; two-sided surfaces; emission is per-material
135
- constant (textures carry albedo only).
136
- - Differentiable parameters are the albedo texels/constants and per-material
137
- emission. Geometry, camera, and UVs are not differentiable inputs, and
138
- there are no visibility/silhouette gradients.
139
- - Texture shapes are fixed at Scene construction; UVs wrap (repeat).
140
- - Published variants are Linux x86_64; on Windows `load_local.py` JIT-builds
141
- the same source and exposes the identical API.
 
 
 
 
 
 
142
 
143
  ## References
144
 
145
  Kajiya, "The Rendering Equation" (SIGGRAPH 1986); Vicini, Speierer, Jakob,
146
  "Path Replay Backpropagation" (SIGGRAPH 2021); Nimier-David, Speierer,
147
- Ruiz, Jakob, "Radiative Backpropagation" (SIGGRAPH 2020); Möller and
148
- Trumbore, "Fast, Minimum Storage Ray-Triangle Intersection" (1997); Duff et
149
- al., "Building an Orthonormal Basis, Revisited" (JCGT 2017).
 
 
 
 
150
 
151
  ## License
152
 
153
- Apache-2.0.
 
6
  # pathtracer-diff
7
 
8
  A differentiable Monte Carlo path tracer as one kernel, loadable through
9
+ `kernels`. The forward pass is a megakernel unidirectional path tracer:
10
+ Lambertian diffuse, GGX conductor (VNDF-sampled, height-correlated Smith),
11
+ and smooth dielectric materials; area lights and an importance-sampled
12
+ equirectangular environment map; multiple importance sampling (balance
13
+ heuristic) by default; per-texel albedo with bilinear filtering; a
14
+ binned-SAH BVH built in vectorized torch in fractions of a second at a
15
+ million triangles. The backward pass replays the identical paths with the
16
+ identical counter-based random draws and emits analytic gradients to the
17
+ albedo texels, the per-material emission, and the environment texels.
18
+ Inverse rendering runs as a bare torch loop around the kernel, with no
19
+ rendering framework in the loop: render, compare, `backward()`, step.
 
20
 
21
  ## Usage
22
 
 
27
  ptd = get_kernel("phanerozoic/pathtracer-diff", version=1, trust_remote_code=True)
28
 
29
  wall_tex = torch.full((256, 256, 3), 0.5, device="cuda", requires_grad=True)
30
+ env = torch.full((64, 128, 3), 0.8, device="cuda", requires_grad=True)
31
+ scene = ptd.Scene(vertices, faces, material_ids,
32
+ albedo=[torch.tensor([0.73] * 3), # constants are 1x1 textures
33
+ wall_tex, # [H, W, 3] per-texel albedo
34
+ torch.tensor([0.9, 0.6, 0.3])],
35
+ emission=emission, uvs=uvs,
36
+ material_types=[ptd.DIFFUSE, ptd.DIFFUSE, ptd.CONDUCTOR],
37
+ roughness=[0.3, 0.3, 0.2], env=env)
38
  cam = ptd.Camera(position=(2.78, 2.73, -8.0), look_at=(2.78, 2.73, 2.8),
39
  vfov_deg=39.0)
40
 
41
  img = ptd.render(scene, cam, 512, 512, spp=64, max_bounces=4) # [H, W, 3] linear
42
  loss = (img - target).square().mean()
43
+ loss.backward() # gradients on wall_tex, emission, and env
44
  ```
45
 
46
  `version` selects the release branch; `trust_remote_code` is required by
47
  `kernels` for publishers without the trusted-publisher mark.
48
 
49
+ A fixed `seed` renders the same paths every call, so the Monte Carlo
50
+ objective is a deterministic function of the parameters and gradient
51
+ descent sees a smooth landscape at any spp; that is the configuration an
52
+ inverse-rendering loop wants. `estimator` selects `"mis"` (default),
53
+ `"nee"`, or `"brdf"`.
 
 
54
 
55
  ## API
56
 
57
  | Symbol | Purpose |
58
  |---|---|
59
+ | `Scene(vertices, faces, material_ids, albedo, emission, uvs, material_types, roughness, ior, env)` | triangle-mesh scene; BVH, light list, and the detached environment CDF are built at construction; albedo is `[M, 3]` constants or a list of `[Hm, Wm, 3]` textures; `env` is an optional `[Eh, Ew, 3]` equirect map; texture/env shapes are fixed at construction |
60
  | `Camera(position, look_at, up, vfov_deg)` | pinhole camera |
61
+ | `render(scene, camera, height, width, spp, max_bounces, estimator, seed)` | differentiable render to `[H, W, 3]` f32 linear radiance; autograd to albedo texels, emission, and env texels |
62
+ | `DIFFUSE`, `CONDUCTOR`, `DIELECTRIC` | material type constants |
63
  | `ops.pt_forward` / `ops.pt_backward` | raw kernel launches |
64
 
65
  ## How it works
66
 
67
  One thread owns one pixel and accumulates its spp samples serially, so the
68
+ image is bitwise deterministic with no atomics in the forward pass.
69
+ Diffuse vertices sample the cosine hemisphere (the throughput multiplier
70
+ is then exactly the albedo); conductors sample the GGX visible-normal
71
+ distribution (Heitz 2018) with the height-correlated Smith term, so the
72
+ continuation weight is `F * G2/G1`; smooth dielectrics branch on the
73
+ Fresnel term between reflection and refraction. Direct light is estimated
74
+ at every non-delta vertex by area sampling over the emissive faces and,
75
+ when an environment map is present, by sampling a detached mixture of its
76
+ luminance CDF and the uniform sphere; BSDF-sampled hits on emitters and
77
+ environment misses are combined with the balance heuristic, so all three
78
+ estimators agree in expectation at any bounce budget.
79
+
80
+ The backward pass is exact path replay. Sampling distributions depend only
81
+ on geometry, frozen material parameters (roughness, ior), the
82
+ construction-time environment CDF, and the counter-based Philox stream
83
+ keyed by `(seed, pixel, sample)`, never on the differentiable parameters,
84
+ so the backward kernel re-traces identical paths with identical draws and
85
+ no stored path state. Every radiance term is a product of per-bounce
86
+ factors, each affine in its vertex's albedo texels (Schlick Fresnel is
87
+ affine in F0; dielectric factors are constant), times a linear emission or
88
+ environment texel; the replay differentiates the product in closed form
89
+ with prefix/suffix exclusion products (no division, so zero albedos are
90
+ safe) and scatters each factor's gradient into the four texels of its
91
+ bilinear footprint with the sampling weights, the exact adjoint of the
92
+ fetch. Texture-gradient buffers up to 2048 texels accumulate in per-block
93
+ shared memory and flush once; larger textures and environment gradients
94
+ use direct global atomics spread across the buffer. Gradients are the
95
+ exact derivative of the rendered estimate under frozen sampling: interior
96
+ terms only, no visibility/silhouette gradients.
97
 
98
  ## Correctness
99
 
 
101
  variants additionally pass the full suite through `get_kernel` on a GeForce
102
  RTX 3070 Ti (Ampere sm86, Linux, torch 2.13, cu126):
103
 
104
+ - **Analytic furnace, zero variance.** Closed uniform diffuse box under
105
+ BRDF sampling: every path returns exactly `E * sum a^k`; the analytic
106
+ value 0.498046875 is met with worst pixel deviation 3.4e-3. A diffuse
107
+ plane under a constant environment reproduces `albedo * c` the same way.
108
+ - **Fresnel slab.** A smooth glass slab (ior 1.5) at normal incidence
109
+ transmits the internal-bounce series `(1-R)/(1+R)`; measured 0.9222
110
+ against 0.923077 analytic (0.10%).
111
+ - **Estimator agreement.** MIS, NEE-only, and BRDF-only means agree within
112
+ Monte Carlo tolerance on a Cornell box, and MIS vs BRDF-only agree on a
113
+ GGX conductor under an environment map, which jointly exercises the VNDF
114
+ weight, the GGX pdf, and the environment pdf.
115
+ - **Gradients match finite differences along the same paths.** Constants
116
+ 4.6e-4 max relative error over nine entries; wall-texture texels 5.2e-3;
117
+ conductor F0 through GGX sampling and MIS 8.4e-5; environment texels
118
+ 2.1e-4 (detached CDF). Texels no path can see get exactly zero from both
119
+ the replay and the differences.
120
+ - **Constants are 1x1 textures.** Both albedo forms render equal images to
121
+ float rounding and receive matching gradients.
122
+ - **Inverse rendering.** A wall color (3 unknowns) recovers to max error
123
+ 0.004 in 80 Adam steps; an 8x8 wall texture (192 unknowns) drives the
124
+ image loss from 1.8e-4 to 6.5e-14 with mean texel error below 5e-4 in
125
+ 200 steps, both through the kernel alone.
126
+ - **Deterministic.** Repeated forward renders are bitwise identical;
127
+ gradient buffers accumulate through float atomics and are deterministic
128
+ in expectation but not bitwise.
129
 
130
  ## Measured
131
 
132
+ RTX 6000 Ada, 512x512, MIS, 4 bounces. The terrain scenes are displaced
133
+ grids with a 256x256 checker albedo texture, a GGX conductor block, and a
134
+ constant environment map; the SAH build is the vectorized torch builder.
135
 
136
+ | scene | build | forward | backward (replay) |
137
+ |---|---|---|---|
138
+ | Cornell box, 24 tris, 64 spp | - | 12.8 ms (1.31 Gpaths/s) | 59.0 ms (4.6x) |
139
+ | terrain, 522,254 tris, 16 spp | 0.2 s | 18.2 ms (230 Mpaths/s) | 35.3 ms (1.9x) |
140
+ | terrain, 1,045,470 tris, 16 spp | 0.3 s | 21.6 ms (194 Mpaths/s) | 37.9 ms (1.8x) |
141
 
142
  ## Requirements and limits
143
 
144
  - NVIDIA GPU with compute capability 8.0+; float32 throughout.
145
+ - Materials: Lambertian diffuse, GGX conductor (roughness clamped to
146
+ >= 0.01), smooth dielectric; per-material emission; pinhole camera; at
147
+ most 64 materials and 16 bounces; two-sided surfaces; no Russian
148
+ roulette (fixed bounce budget keeps the replay exact).
149
+ - Differentiable parameters: albedo texels/constants (all material types;
150
+ conductor F0 tint), per-material emission, environment texels. Geometry,
151
+ camera, UVs, roughness, and ior are frozen, and there are no
152
+ visibility/silhouette gradients.
153
+ - The environment sampling CDF is detached at Scene construction (a 0.5
154
+ uniform-sphere mixture keeps every direction reachable, so optimizing an
155
+ env map from an arbitrary initialization stays unbiased); rebuild the
156
+ Scene to re-importance-sample a changed map.
157
+ - Texture and env shapes are fixed at Scene construction; UVs wrap.
158
+ - Published variants are Linux x86_64; on Windows `load_local.py`
159
+ JIT-builds the same source and exposes the identical API.
160
 
161
  ## References
162
 
163
  Kajiya, "The Rendering Equation" (SIGGRAPH 1986); Vicini, Speierer, Jakob,
164
  "Path Replay Backpropagation" (SIGGRAPH 2021); Nimier-David, Speierer,
165
+ Ruiz, Jakob, "Radiative Backpropagation" (SIGGRAPH 2020); Heitz, "Sampling
166
+ the GGX Distribution of Visible Normals" (JCGT 2018); Heitz,
167
+ "Understanding the Masking-Shadowing Function" (JCGT 2014); Veach and
168
+ Guibas, "Optimally Combining Sampling Techniques" (SIGGRAPH 1995); Möller
169
+ and Trumbore, "Fast, Minimum Storage Ray-Triangle Intersection" (1997);
170
+ Duff et al., "Building an Orthonormal Basis, Revisited" (JCGT 2017); Wald,
171
+ "On fast Construction of SAH-based Bounding Volume Hierarchies" (2007).
172
 
173
  ## License
174
 
175
+ Apache-2.0.
dev/report_local.py CHANGED
@@ -222,36 +222,206 @@ print(f"texture inverse: 8x8x3 (192 unknowns) recovered to mean abs err "
222
  f"{err.mean().item():.3f} (max {err.max().item():.3f}), "
223
  f"loss {first:.2e} -> {loss.item():.2e} in 200 steps")
224
 
225
- # ---- throughput
226
- scene = cornell(with_box=True)
227
- H, W, spp, B = 512, 512, 64, 4
228
- img = ptd.render(scene, cam, H, W, spp=spp, max_bounces=B) # warmup + autograd off
229
- torch.cuda.synchronize()
230
- ts = []
231
- for _ in range(3):
232
- t0 = time.perf_counter()
233
- img = ptd.render(scene, cam, H, W, spp=spp, max_bounces=B)
234
- torch.cuda.synchronize()
235
- ts.append(time.perf_counter() - t0)
236
- fwd = sorted(ts)[1]
237
- paths = H * W * spp
238
- print(f"forward: {H}x{W} spp {spp} bounces {B} ({scene.n_faces} tris): "
239
- f"{fwd*1e3:.1f} ms, {paths/fwd/1e6:.0f} Mpaths/s")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  alb = scene.albedo.clone().requires_grad_(True)
242
  scene.albedo = alb
243
- g = torch.ones(H, W, 3, device="cuda")
244
- img = ptd.render(scene, cam, H, W, spp=spp, max_bounces=B)
245
- img.backward(g) # warmup
246
- torch.cuda.synchronize()
247
- ts = []
248
- for _ in range(3):
249
- alb.grad = None
250
- img = ptd.render(scene, cam, H, W, spp=spp, max_bounces=B)
251
- torch.cuda.synchronize()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  t0 = time.perf_counter()
253
- img.backward(g)
254
- torch.cuda.synchronize()
255
- ts.append(time.perf_counter() - t0)
256
- bwd = sorted(ts)[1]
257
- print(f"backward (replay): {bwd*1e3:.1f} ms, {bwd/fwd:.2f}x forward")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  f"{err.mean().item():.3f} (max {err.max().item():.3f}), "
223
  f"loss {first:.2e} -> {loss.item():.2e} in 200 steps")
224
 
225
+ # ---- Fresnel slab analytic
226
+ verts, faces, mat = [], [], []
227
+
228
+
229
+ def quad_w(a, b, c, d, m):
230
+ base = len(verts)
231
+ verts.extend([a, b, c, d])
232
+ faces.extend(quad(base, base + 1, base + 2, base + 3))
233
+ mat.extend([m, m])
234
+
235
+
236
+ quad_w((-10, -10, 5), (10, -10, 5), (10, 10, 5), (-10, 10, 5), 0)
237
+ add_box(verts, faces, mat, (-10, -10, 2.0), (10, 10, 2.2), 1)
238
+ slab = ptd.Scene(verts, faces, mat, albedo=[[0, 0, 0], [0, 0, 0]],
239
+ emission=[[1.0] * 3, [0, 0, 0]],
240
+ material_types=[ptd.DIFFUSE, ptd.DIELECTRIC], ior=[1.5, 1.5])
241
+ scam = ptd.Camera(position=(0, 0, 0), look_at=(0, 0, 1), vfov_deg=10.0)
242
+ img = ptd.render(slab, scam, 32, 32, spp=1024, max_bounces=10,
243
+ estimator="brdf", seed=5)
244
+ R = (0.5 / 2.5) ** 2
245
+ exp_T = (1 - R) / (1 + R)
246
+ got = img[12:20, 12:20].mean().item()
247
+ print(f"fresnel slab (ior 1.5): analytic T {exp_T:.6f}, measured {got:.6f}, "
248
+ f"rel err {abs(got-exp_T)/exp_T:.4%}")
249
+
250
+ # ---- conductor F0 FD (through GGX + MIS)
251
+ H = W = 48
252
+ torch.manual_seed(0)
253
+ Wr = torch.rand(H, W, 3, device="cuda")
254
+ base_alb = torch.tensor([[0.73] * 3, [0.9, 0.5, 0.2], [0.12, 0.45, 0.15],
255
+ [0.78] * 3], device="cuda")
256
+
257
+
258
+ def cond_loss(alb):
259
+ verts, faces, mat = [], [], []
260
+
261
+ def wall(a, b, c, d, m):
262
+ b0 = len(verts)
263
+ verts.extend([a, b, c, d])
264
+ faces.extend(quad(b0, b0 + 1, b0 + 2, b0 + 3))
265
+ mat.extend([m, m])
266
+
267
+ X, Y, Z = 5.56, 5.488, 5.592
268
+ wall((0, 0, 0), (X, 0, 0), (X, 0, Z), (0, 0, Z), 0)
269
+ wall((0, Y, 0), (0, Y, Z), (X, Y, Z), (X, Y, 0), 0)
270
+ wall((0, 0, Z), (X, 0, Z), (X, Y, Z), (0, Y, Z), 0)
271
+ wall((0, 0, 0), (0, 0, Z), (0, Y, Z), (0, Y, 0), 1)
272
+ wall((X, 0, 0), (X, Y, 0), (X, Y, Z), (X, 0, Z), 2)
273
+ wall((2.13, Y - 0.001, 2.27), (3.43, Y - 0.001, 2.27),
274
+ (3.43, Y - 0.001, 3.32), (2.13, Y - 0.001, 3.32), 3)
275
+ sc = ptd.Scene(verts, faces, mat, albedo=alb,
276
+ emission=[[0, 0, 0], [0, 0, 0], [0, 0, 0],
277
+ [18.4, 15.6, 8.0]],
278
+ material_types=[0, 1, 0, 0], roughness=[0.3, 0.2, 0.3, 0.3])
279
+ return (ptd.render(sc, cam, H, W, spp=8, max_bounces=3, seed=13) * Wr).sum()
280
+
281
 
282
+ alb = base_alb.clone().requires_grad_(True)
283
+ cond_loss(alb).backward()
284
+ worst = 0.0
285
+ for (i, c) in [(1, 0), (1, 2), (0, 1)]:
286
+ h = 2e-3
287
+ ap = base_alb.clone(); ap[i, c] += h
288
+ am = base_alb.clone(); am[i, c] -= h
289
+ fd = (cond_loss(ap) - cond_loss(am)).item() / (2 * h)
290
+ worst = max(worst, abs(alb.grad[i, c].item() - fd) / abs(fd))
291
+ print(f"conductor F0 gradients vs central differences (GGX+MIS): "
292
+ f"max rel err {worst:.2e} over 3 entries")
293
+
294
+ # ---- env texel FD
295
+ torch.manual_seed(3)
296
+ env0 = torch.rand(4, 8, 3, device="cuda") * 1.5 + 0.2
297
+ verts, faces, mat = [], [], []
298
+ b0 = len(verts)
299
+ verts.extend([(-5, 0, -5), (5, 0, -5), (5, 0, 5), (-5, 0, 5)])
300
+ faces.extend(quad(b0, b0 + 1, b0 + 2, b0 + 3))
301
+ mat.extend([0, 0])
302
+ ecam = ptd.Camera(position=(0, 2.0, 0.01), look_at=(0, 0, 0.5), vfov_deg=40.0)
303
+ torch.manual_seed(0)
304
+ Wre = torch.rand(32, 32, 3, device="cuda")
305
+ esc = ptd.Scene(verts, faces, mat, albedo=[[0.6] * 3], emission=[[0, 0, 0]],
306
+ env=env0)
307
+
308
+
309
+ def env_loss(e):
310
+ esc.env = e.to("cuda")
311
+ return (ptd.render(esc, ecam, 32, 32, spp=8, max_bounces=3, seed=11)
312
+ * Wre).sum()
313
+
314
+
315
+ e = env0.clone().requires_grad_(True)
316
+ env_loss(e).backward()
317
+ worst = 0.0
318
+ for (y, x, c) in [(0, 0, 0), (1, 4, 1), (1, 7, 2)]:
319
+ h = 5e-3
320
+ ep = env0.clone(); ep[y, x, c] += h
321
+ em2 = env0.clone(); em2[y, x, c] -= h
322
+ fd = (env_loss(ep) - env_loss(em2)).item() / (2 * h)
323
+ worst = max(worst, abs(e.grad[y, x, c].item() - fd) / abs(fd))
324
+ print(f"environment texel gradients vs central differences: "
325
+ f"max rel err {worst:.2e} over 3 texels (detached CDF)")
326
+
327
+
328
+ # ---- throughput helper
329
+ def bench(scene, bcam, H, W, spp, B, label, grad_leaf=None):
330
+ img = ptd.render(scene, bcam, H, W, spp=spp, max_bounces=B)
331
+ torch.cuda.synchronize()
332
+ ts = []
333
+ for _ in range(3):
334
+ t0 = time.perf_counter()
335
+ img = ptd.render(scene, bcam, H, W, spp=spp, max_bounces=B)
336
+ torch.cuda.synchronize()
337
+ ts.append(time.perf_counter() - t0)
338
+ fwd = sorted(ts)[1]
339
+ paths = H * W * spp
340
+ line = (f"{label}: forward {fwd*1e3:.1f} ms ({paths/fwd/1e6:.0f} Mpaths/s)")
341
+ if grad_leaf is not None:
342
+ g = torch.ones(H, W, 3, device="cuda")
343
+ img = ptd.render(scene, bcam, H, W, spp=spp, max_bounces=B)
344
+ img.backward(g)
345
+ torch.cuda.synchronize()
346
+ ts = []
347
+ for _ in range(3):
348
+ grad_leaf.grad = None
349
+ img = ptd.render(scene, bcam, H, W, spp=spp, max_bounces=B)
350
+ torch.cuda.synchronize()
351
+ t0 = time.perf_counter()
352
+ img.backward(g)
353
+ torch.cuda.synchronize()
354
+ ts.append(time.perf_counter() - t0)
355
+ bwd = sorted(ts)[1]
356
+ line += f"; backward {bwd*1e3:.1f} ms ({bwd/fwd:.2f}x)"
357
+ print(line)
358
+
359
+
360
+ # cornell (continuity with v1 numbers)
361
+ scene = cornell(with_box=True)
362
  alb = scene.albedo.clone().requires_grad_(True)
363
  scene.albedo = alb
364
+ bench(scene, cam, 512, 512, 64, 4, "cornell 24 tris, 512x512 spp 64 b4", alb)
365
+
366
+
367
+ # ---- large scenes: displaced terrain + conductor box + checker + env
368
+ def terrain_scene(n):
369
+ ax = torch.linspace(0, 10, n + 1)
370
+ X, Z = torch.meshgrid(ax, ax, indexing="ij")
371
+ Y = (0.35 * torch.sin(X * 1.7) * torch.cos(Z * 1.3) +
372
+ 0.2 * torch.sin(X * 4.1 + 1.0) * torch.sin(Z * 3.7) +
373
+ 0.08 * torch.sin(X * 9.3) * torch.cos(Z * 8.1))
374
+ verts = torch.stack([X, Y, Z], dim=-1).reshape(-1, 3)
375
+ ii = torch.arange(n)
376
+ jj = torch.arange(n)
377
+ I, J = torch.meshgrid(ii, jj, indexing="ij")
378
+ v00 = (I * (n + 1) + J).reshape(-1)
379
+ v10 = ((I + 1) * (n + 1) + J).reshape(-1)
380
+ v01 = (I * (n + 1) + J + 1).reshape(-1)
381
+ v11 = ((I + 1) * (n + 1) + J + 1).reshape(-1)
382
+ f1 = torch.stack([v00, v10, v11], dim=1)
383
+ f2 = torch.stack([v00, v11, v01], dim=1)
384
+ faces = torch.cat([f1, f2], dim=0)
385
+ mat = torch.zeros(faces.shape[0], dtype=torch.int64)
386
+ uv = torch.stack([X / 10.0, Z / 10.0], dim=-1).reshape(-1, 2)
387
+
388
+ verts_l, faces_l, mat_l = [verts], [faces], [mat]
389
+ base = verts.shape[0]
390
+ bx = torch.tensor([(4.2, 0.4, 4.2), (5.8, 0.4, 4.2), (5.8, 0.4, 5.8),
391
+ (4.2, 0.4, 5.8), (4.2, 2.2, 4.2), (5.8, 2.2, 4.2),
392
+ (5.8, 2.2, 5.8), (4.2, 2.2, 5.8)])
393
+ verts_l.append(bx)
394
+ bq = [[0, 1, 2], [0, 2, 3], [4, 7, 6], [4, 6, 5], [0, 4, 5], [0, 5, 1],
395
+ [3, 2, 6], [3, 6, 7], [0, 3, 7], [0, 7, 4], [1, 5, 6], [1, 6, 2]]
396
+ faces_l.append(torch.tensor(bq, dtype=torch.int64) + base)
397
+ mat_l.append(torch.ones(12, dtype=torch.int64))
398
+ verts = torch.cat(verts_l)
399
+ faces = torch.cat(faces_l)
400
+ mat = torch.cat(mat_l)
401
+ uv = torch.cat([uv, torch.zeros(8, 2)])
402
+
403
+ yy, xx = torch.meshgrid(torch.arange(256), torch.arange(256),
404
+ indexing="ij")
405
+ checker = (((yy // 16 + xx // 16) % 2).float() * 0.45 + 0.3)
406
+ tex = torch.stack([checker, checker * 0.9, checker * 0.7], dim=-1)
407
+ env = torch.full((16, 32, 3), 0.9)
408
  t0 = time.perf_counter()
409
+ sc = ptd.Scene(verts, faces, mat,
410
+ albedo=[tex.cuda(), torch.tensor([0.9, 0.65, 0.35])],
411
+ emission=[[0, 0, 0], [0, 0, 0]],
412
+ material_types=[ptd.DIFFUSE, ptd.CONDUCTOR],
413
+ roughness=[0.3, 0.15], uvs=uv, env=env)
414
+ build = time.perf_counter() - t0
415
+ return sc, build
416
+
417
+
418
+ tcam = ptd.Camera(position=(11.0, 4.5, 11.0), look_at=(5.0, 0.5, 5.0),
419
+ vfov_deg=45.0)
420
+ for n in (511, 723):
421
+ sc, build = terrain_scene(n)
422
+ ntri = sc.n_faces
423
+ tex_leaf = sc.albedo_textures[0].requires_grad_(True)
424
+ sc.albedo_textures[0] = tex_leaf
425
+ bench(sc, tcam, 512, 512, 16, 4,
426
+ f"terrain {ntri} tris (SAH build {build:.1f} s), 512x512 spp 16 b4",
427
+ tex_leaf)
local_bind.cpp CHANGED
@@ -9,35 +9,83 @@
9
 
10
  namespace {
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  void pt_forward(torch::Tensor tris, torch::Tensor mat_ids, torch::Tensor uvs,
13
  torch::Tensor nodes_f, torch::Tensor nodes_i,
14
  torch::Tensor light_faces, torch::Tensor light_cdf,
15
  double total_light_area, torch::Tensor tex,
16
  torch::Tensor tex_hdr, torch::Tensor emission,
17
- torch::Tensor cam, int64_t spp, int64_t max_bounces, bool nee,
18
- int64_t seed, torch::Tensor image) {
19
- TORCH_CHECK(tris.is_cuda() && tris.is_contiguous(), "tris");
20
- TORCH_CHECK(uvs.numel() == tris.size(0) * 6, "uvs [F, 3, 2]");
21
- TORCH_CHECK(image.is_cuda() && image.is_contiguous() && image.dim() == 3,
22
- "image [H, W, 3]");
23
- TORCH_CHECK(tex_hdr.size(0) <= 64, "at most 64 materials");
24
- TORCH_CHECK(max_bounces >= 1 && max_bounces <= 16, "bounces in [1,16]");
 
 
 
25
  const at::cuda::CUDAGuard guard(tris.device());
26
  cudaStream_t stream = at::cuda::getCurrentCUDAStream();
27
- int L = (int)light_faces.numel();
28
  torch::Tensor cam_h = cam.to(torch::kFloat32).to(torch::kCPU).contiguous();
29
- ptd_forward_launch(
30
- tris.const_data_ptr<float>(), mat_ids.const_data_ptr<int>(),
31
- uvs.const_data_ptr<float>(), (int)tris.size(0),
32
- nodes_f.const_data_ptr<float>(), nodes_i.const_data_ptr<int>(),
33
- (int)nodes_f.size(0), L ? light_faces.const_data_ptr<int>() : nullptr,
34
- L ? light_cdf.const_data_ptr<float>() : nullptr, L,
35
- (float)total_light_area, tex.const_data_ptr<float>(),
36
- tex_hdr.const_data_ptr<int>(), (int)tex.size(0),
37
- emission.const_data_ptr<float>(),
38
- (int)tex_hdr.size(0), cam_h.const_data_ptr<float>(),
39
- (int)image.size(0), (int)image.size(1), (int)spp, (int)max_bounces,
40
- nee ? 1 : 0, (long long)seed, image.data_ptr<float>(), stream);
41
  C10_CUDA_KERNEL_LAUNCH_CHECK();
42
  }
43
 
@@ -46,30 +94,30 @@ void pt_backward(torch::Tensor tris, torch::Tensor mat_ids, torch::Tensor uvs,
46
  torch::Tensor light_faces, torch::Tensor light_cdf,
47
  double total_light_area, torch::Tensor tex,
48
  torch::Tensor tex_hdr, torch::Tensor emission,
49
- torch::Tensor cam, int64_t spp, int64_t max_bounces, bool nee,
50
- int64_t seed, torch::Tensor grad_image,
51
- torch::Tensor grad_tex, torch::Tensor grad_emission) {
52
- TORCH_CHECK(grad_image.is_cuda() && grad_image.is_contiguous(), "grad_image");
53
- TORCH_CHECK(grad_tex.sizes() == tex.sizes(), "grad_tex must match tex");
54
- TORCH_CHECK(tex_hdr.size(0) <= 64, "at most 64 materials");
 
 
 
 
 
 
 
55
  const at::cuda::CUDAGuard guard(tris.device());
56
  cudaStream_t stream = at::cuda::getCurrentCUDAStream();
57
- int L = (int)light_faces.numel();
58
  torch::Tensor cam_h = cam.to(torch::kFloat32).to(torch::kCPU).contiguous();
59
- ptd_backward_launch(
60
- tris.const_data_ptr<float>(), mat_ids.const_data_ptr<int>(),
61
- uvs.const_data_ptr<float>(), (int)tris.size(0),
62
- nodes_f.const_data_ptr<float>(), nodes_i.const_data_ptr<int>(),
63
- (int)nodes_f.size(0), L ? light_faces.const_data_ptr<int>() : nullptr,
64
- L ? light_cdf.const_data_ptr<float>() : nullptr, L,
65
- (float)total_light_area, tex.const_data_ptr<float>(),
66
- tex_hdr.const_data_ptr<int>(), (int)tex.size(0),
67
- emission.const_data_ptr<float>(),
68
- (int)tex_hdr.size(0), cam_h.const_data_ptr<float>(),
69
- (int)grad_image.size(0), (int)grad_image.size(1), (int)spp,
70
- (int)max_bounces, nee ? 1 : 0, (long long)seed,
71
- grad_image.const_data_ptr<float>(), grad_tex.data_ptr<float>(),
72
- grad_emission.data_ptr<float>(), stream);
73
  C10_CUDA_KERNEL_LAUNCH_CHECK();
74
  }
75
 
 
9
 
10
  namespace {
11
 
12
+ const float* fptr(const torch::Tensor& t) {
13
+ return t.numel() ? t.const_data_ptr<float>() : nullptr;
14
+ }
15
+ const int* iptr(const torch::Tensor& t) {
16
+ return t.numel() ? t.const_data_ptr<int>() : nullptr;
17
+ }
18
+
19
+ PtdSceneArgs pack_args(const torch::Tensor& tris, const torch::Tensor& mat_ids,
20
+ const torch::Tensor& uvs, const torch::Tensor& nodes_f,
21
+ const torch::Tensor& nodes_i,
22
+ const torch::Tensor& light_faces,
23
+ const torch::Tensor& light_cdf, double total_light_area,
24
+ const torch::Tensor& tex, const torch::Tensor& tex_hdr,
25
+ const torch::Tensor& emission,
26
+ const torch::Tensor& mat_type,
27
+ const torch::Tensor& mat_rough,
28
+ const torch::Tensor& mat_ior, const torch::Tensor& env,
29
+ const torch::Tensor& env_cdf_m,
30
+ const torch::Tensor& env_cdf_c,
31
+ const torch::Tensor& env_pdf, int64_t env_w,
32
+ int64_t env_h, int64_t max_bounces) {
33
+ TORCH_CHECK(tris.is_cuda() && tris.is_contiguous(), "tris");
34
+ TORCH_CHECK(uvs.numel() == tris.size(0) * 6, "uvs [F, 3, 2]");
35
+ TORCH_CHECK(tex_hdr.size(0) <= 64, "at most 64 materials");
36
+ TORCH_CHECK(max_bounces >= 1 && max_bounces <= 16, "bounces in [1,16]");
37
+ PtdSceneArgs a;
38
+ a.tris = tris.const_data_ptr<float>();
39
+ a.mat_ids = mat_ids.const_data_ptr<int>();
40
+ a.uvs = uvs.const_data_ptr<float>();
41
+ a.n_faces = (int)tris.size(0);
42
+ a.nodes_f = nodes_f.const_data_ptr<float>();
43
+ a.nodes_i = nodes_i.const_data_ptr<int>();
44
+ a.n_nodes = (int)nodes_f.size(0);
45
+ a.light_faces = iptr(light_faces);
46
+ a.light_cdf = fptr(light_cdf);
47
+ a.n_lights = (int)light_faces.numel();
48
+ a.total_light_area = (float)total_light_area;
49
+ a.tex = tex.const_data_ptr<float>();
50
+ a.tex_hdr = tex_hdr.const_data_ptr<int>();
51
+ a.n_texels = (int)tex.size(0);
52
+ a.emission = emission.const_data_ptr<float>();
53
+ a.mat_type = mat_type.const_data_ptr<int>();
54
+ a.mat_rough = mat_rough.const_data_ptr<float>();
55
+ a.mat_ior = mat_ior.const_data_ptr<float>();
56
+ a.n_mats = (int)tex_hdr.size(0);
57
+ a.env = fptr(env);
58
+ a.env_w = (int)env_w;
59
+ a.env_h = (int)env_h;
60
+ a.env_cdf_m = fptr(env_cdf_m);
61
+ a.env_cdf_c = fptr(env_cdf_c);
62
+ a.env_pdf = fptr(env_pdf);
63
+ return a;
64
+ }
65
+
66
  void pt_forward(torch::Tensor tris, torch::Tensor mat_ids, torch::Tensor uvs,
67
  torch::Tensor nodes_f, torch::Tensor nodes_i,
68
  torch::Tensor light_faces, torch::Tensor light_cdf,
69
  double total_light_area, torch::Tensor tex,
70
  torch::Tensor tex_hdr, torch::Tensor emission,
71
+ torch::Tensor mat_type, torch::Tensor mat_rough,
72
+ torch::Tensor mat_ior, torch::Tensor env,
73
+ torch::Tensor env_cdf_m, torch::Tensor env_cdf_c,
74
+ torch::Tensor env_pdf, int64_t env_w, int64_t env_h,
75
+ torch::Tensor cam, int64_t spp, int64_t max_bounces,
76
+ int64_t mode, int64_t seed, torch::Tensor image) {
77
+ PtdSceneArgs a = pack_args(tris, mat_ids, uvs, nodes_f, nodes_i, light_faces,
78
+ light_cdf, total_light_area, tex, tex_hdr,
79
+ emission, mat_type, mat_rough, mat_ior, env,
80
+ env_cdf_m, env_cdf_c, env_pdf, env_w, env_h,
81
+ max_bounces);
82
  const at::cuda::CUDAGuard guard(tris.device());
83
  cudaStream_t stream = at::cuda::getCurrentCUDAStream();
 
84
  torch::Tensor cam_h = cam.to(torch::kFloat32).to(torch::kCPU).contiguous();
85
+ ptd_forward_launch(&a, cam_h.const_data_ptr<float>(), (int)image.size(0),
86
+ (int)image.size(1), (int)spp, (int)max_bounces,
87
+ (int)mode, (long long)seed, image.data_ptr<float>(),
88
+ stream);
 
 
 
 
 
 
 
 
89
  C10_CUDA_KERNEL_LAUNCH_CHECK();
90
  }
91
 
 
94
  torch::Tensor light_faces, torch::Tensor light_cdf,
95
  double total_light_area, torch::Tensor tex,
96
  torch::Tensor tex_hdr, torch::Tensor emission,
97
+ torch::Tensor mat_type, torch::Tensor mat_rough,
98
+ torch::Tensor mat_ior, torch::Tensor env,
99
+ torch::Tensor env_cdf_m, torch::Tensor env_cdf_c,
100
+ torch::Tensor env_pdf, int64_t env_w, int64_t env_h,
101
+ torch::Tensor cam, int64_t spp, int64_t max_bounces,
102
+ int64_t mode, int64_t seed, torch::Tensor grad_image,
103
+ torch::Tensor grad_tex, torch::Tensor grad_emission,
104
+ torch::Tensor grad_env) {
105
+ PtdSceneArgs a = pack_args(tris, mat_ids, uvs, nodes_f, nodes_i, light_faces,
106
+ light_cdf, total_light_area, tex, tex_hdr,
107
+ emission, mat_type, mat_rough, mat_ior, env,
108
+ env_cdf_m, env_cdf_c, env_pdf, env_w, env_h,
109
+ max_bounces);
110
  const at::cuda::CUDAGuard guard(tris.device());
111
  cudaStream_t stream = at::cuda::getCurrentCUDAStream();
 
112
  torch::Tensor cam_h = cam.to(torch::kFloat32).to(torch::kCPU).contiguous();
113
+ ptd_backward_launch(&a, cam_h.const_data_ptr<float>(),
114
+ (int)grad_image.size(0), (int)grad_image.size(1),
115
+ (int)spp, (int)max_bounces, (int)mode, (long long)seed,
116
+ grad_image.const_data_ptr<float>(),
117
+ grad_tex.data_ptr<float>(),
118
+ grad_emission.data_ptr<float>(),
119
+ grad_env.numel() ? grad_env.data_ptr<float>() : nullptr,
120
+ stream);
 
 
 
 
 
 
121
  C10_CUDA_KERNEL_LAUNCH_CHECK();
122
  }
123
 
pathtracer_diff_cuda/pathtracer.cu CHANGED
@@ -1,28 +1,29 @@
1
  // pathtracer-diff: a differentiable Monte Carlo path tracer as one kernel.
2
  //
3
- // Forward: a megakernel unidirectional path tracer (diffuse + emissive
4
- // materials, per-texel albedo textures with bilinear filtering, cosine-
5
- // weighted BRDF sampling, optional next-event estimation, BVH traversal).
6
- // One thread owns one pixel and accumulates its spp samples serially, so
7
- // the image is bitwise deterministic.
 
 
 
 
 
8
  //
9
- // Backward: exact path replay. Sampling directions depend only on geometry
10
- // and the counter-based Philox stream, never on the differentiable
11
- // parameters (albedo texels and per-material emission), so the backward
12
- // kernel re-traces the identical paths with the identical draws and
13
- // accumulates analytic gradients with no stored path state (path-replay
14
- // backpropagation, interior terms only: no visibility/silhouette
15
- // gradients). Each bounce's albedo factor is a bilinear combination of four
16
- // texels; its gradient is scattered to those texels with the bilinear
17
- // weights, the exact adjoint of the fetch.
18
- //
19
- // Radiance estimator with NEE on: emission is gathered at the camera hit
20
- // only; every later vertex estimates direct light by area sampling, and NEE
21
- // is skipped at the final vertex so the NEE and BRDF-only estimators cover
22
- // camera-to-light paths of the same maximum length. With NEE off: emission
23
- // is gathered at every hit (with cosine sampling the diffuse throughput
24
- // factor is exactly the albedo, which makes a closed uniform furnace a
25
- // zero-variance analytic test case).
26
 
27
  #include <cuda_runtime.h>
28
  #include <curand_kernel.h>
@@ -36,14 +37,16 @@ namespace {
36
  constexpr int kThreads = 128;
37
  constexpr int kMaxBounces = 16;
38
  constexpr int kMaxMats = 64;
39
- // Texture-gradient buffers up to this many texels are accumulated in
40
- // dynamic shared memory per block and flushed once; larger buffers take
41
- // direct global atomics (contention is then spread across many texels).
42
- constexpr int kSharedTexels = 2048;
43
  constexpr int kStack = 64;
 
44
  constexpr float kInvPi = 0.31830988618379067154f;
45
  constexpr float kRayEps = 1e-4f;
46
  constexpr float kShadowEps = 1e-3f;
 
 
 
 
47
 
48
  // ---------------------------------------------------------------- float3 ops
49
  __device__ __forceinline__ float3 f3(float x, float y, float z) {
@@ -67,6 +70,7 @@ __device__ __forceinline__ float3 cross(float3 a, float3 b) {
67
  __device__ __forceinline__ float3 normalize(float3 a) {
68
  return a * rsqrtf(fmaxf(dot(a, a), 1e-30f));
69
  }
 
70
 
71
  // Duff et al. 2017, "Building an Orthonormal Basis, Revisited".
72
  __device__ __forceinline__ void onb(float3 n, float3& t, float3& b) {
@@ -79,24 +83,33 @@ __device__ __forceinline__ void onb(float3 n, float3& t, float3& b) {
79
 
80
  // ---------------------------------------------------------------- scene view
81
  struct DevScene {
82
- const float* tris; // [F, 9] v0 v1 v2
83
  const int* mats; // [F]
84
- const float* uvs; // [F, 3, 2] per-corner uv
85
- const float* nf; // [N, 6] bbox lo.xyz hi.xyz
86
- const int* ni; // [N, 3] (left,right,0) | (start,count,1)
87
  int n_nodes;
88
- const int* lf; // [L] emissive face indices
89
- const float* lcdf; // [L] normalized area cdf
90
  int nl;
91
- float larea; // total emissive area
92
- const float* tex; // [T, 3] flat albedo texels, all materials
93
- const int* thdr; // [M, 3] (texel offset, W, H) per material
94
- int nt; // T, total texel count
95
  const float* emi; // [M, 3]
 
 
 
96
  int nm;
 
 
 
 
 
 
97
  };
98
 
99
- struct DevCam { // pos, fwd, right*tan(v/2)*aspect, up*tan(v/2)
100
  float p[3], f[3], r[3], u[3];
101
  };
102
 
@@ -155,6 +168,7 @@ __device__ int bvh_closest(const DevScene& sc, float3 ro, float3 rd,
155
  float tmin, float& tbest, float& bu, float& bv,
156
  float3& ngbest) {
157
  float roa[3] = {ro.x, ro.y, ro.z};
 
158
  float inv[3];
159
  inv_dir(rd, inv);
160
  int stack[kStack];
@@ -165,7 +179,7 @@ __device__ int bvh_closest(const DevScene& sc, float3 ro, float3 rd,
165
  int nid = stack[--sp];
166
  if (!slab(&sc.nf[nid * 6], roa, inv, tbest)) continue;
167
  const int* n = &sc.ni[nid * 3];
168
- if (n[2]) {
169
  for (int f = n[0]; f < n[0] + n[1]; ++f) {
170
  float t, u, v;
171
  float3 ng;
@@ -178,8 +192,11 @@ __device__ int bvh_closest(const DevScene& sc, float3 ro, float3 rd,
178
  }
179
  }
180
  } else if (sp + 2 <= kStack) {
181
- stack[sp++] = n[0];
182
- stack[sp++] = n[1];
 
 
 
183
  }
184
  }
185
  return best;
@@ -197,7 +214,7 @@ __device__ bool bvh_occluded(const DevScene& sc, float3 ro, float3 rd,
197
  int nid = stack[--sp];
198
  if (!slab(&sc.nf[nid * 6], roa, inv, tmax)) continue;
199
  const int* n = &sc.ni[nid * 3];
200
- if (n[2]) {
201
  for (int f = n[0]; f < n[0] + n[1]; ++f) {
202
  float t, u, v;
203
  float3 ng;
@@ -212,8 +229,8 @@ __device__ bool bvh_occluded(const DevScene& sc, float3 ro, float3 rd,
212
  return false;
213
  }
214
 
215
- __device__ __forceinline__ int pick_light(const float* cdf, int nl, float r) {
216
- int lo = 0, hi = nl - 1;
217
  while (lo < hi) {
218
  int mid = (lo + hi) >> 1;
219
  if (cdf[mid] < r) lo = mid + 1; else hi = mid;
@@ -221,18 +238,10 @@ __device__ __forceinline__ int pick_light(const float* cdf, int nl, float r) {
221
  return lo;
222
  }
223
 
224
- // Bilinear albedo fetch with repeat wrap; also emits the four texel indices
225
- // and weights (the footprint) for the backward scatter.
226
- __device__ __forceinline__ void sample_albedo(const DevScene& sc, int m,
227
- int face, float bu, float bv,
228
- float rgb[3], int idx4[4],
229
- float w4[4]) {
230
- const float* U = &sc.uvs[face * 6];
231
- float w0 = 1.0f - bu - bv;
232
- float u = w0 * U[0] + bu * U[2] + bv * U[4];
233
- float v = w0 * U[1] + bu * U[3] + bv * U[5];
234
- const int* h = &sc.thdr[m * 3];
235
- int off = h[0], W = h[1], H = h[2];
236
  float uu = u * (float)W - 0.5f;
237
  float vv = v * (float)H - 0.5f;
238
  float fx = uu - floorf(uu);
@@ -249,52 +258,240 @@ __device__ __forceinline__ void sample_albedo(const DevScene& sc, int m,
249
  rgb[0] = rgb[1] = rgb[2] = 0.0f;
250
  #pragma unroll
251
  for (int i = 0; i < 4; ++i) {
252
- const float* t = &sc.tex[idx4[i] * 3];
253
  rgb[0] += w4[i] * t[0];
254
  rgb[1] += w4[i] * t[1];
255
  rgb[2] += w4[i] * t[2];
256
  }
257
  }
258
 
259
- // Texel-gradient scatter: shared staging when the whole buffer fits,
260
- // direct global atomics otherwise.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  __device__ __forceinline__ void scatter_tex(float* s_gt, float* g_tex,
262
  int idx, int c, float v) {
263
- if (s_gt) {
264
- atomicAdd(&s_gt[idx * 3 + c], v);
265
- } else {
266
- atomicAdd(&g_tex[idx * 3 + c], v);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  }
268
  }
269
 
270
- // ------------------------------------------------------------- path tracing
271
- // One camera path: forward accumulates radiance into acc; backward (GRAD)
272
- // accumulates parameter gradients (texel scatter for albedo, shared buffer
273
- // for emission). The RNG draw order is identical in both instantiations by
274
- // construction.
275
  template <bool GRAD>
276
  __device__ void trace_path(const DevScene& sc, float3 ro, float3 rd,
277
- curandStatePhilox4_32_10_t& st, int B, int nee,
278
  float3& acc, const float gs[3], float* g_tex,
279
- float* s_gt, float* s_ge) {
280
- float T[3] = {1.0f, 1.0f, 1.0f}; // prefix albedo product
281
- float albs[kMaxBounces][3]; // per-bounce sampled albedo
282
- int fp_i[kMaxBounces][4]; // per-bounce texel footprint
283
- float fp_w[kMaxBounces][4];
284
- int mats[kMaxBounces];
285
- float suf[kMaxBounces + 1][3]; // suffix products workspace
286
 
287
  for (int k = 0; k < B; ++k) {
288
- float tbest = 3.0e38f;
289
  float bu = 0.0f, bv = 0.0f;
290
  float3 ng;
291
  int face = bvh_closest(sc, ro, rd, kRayEps, tbest, bu, bv, ng);
292
- if (face < 0) break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
  int m = sc.mats[face];
 
295
  float3 x = ro + rd * tbest;
296
  float3 n = normalize(ng);
297
- if (dot(n, rd) > 0.0f) n = f3(-n.x, -n.y, -n.z);
 
298
 
299
  float am[3];
300
  int idx4[4];
@@ -302,118 +499,231 @@ __device__ void trace_path(const DevScene& sc, float3 ro, float3 rd,
302
  sample_albedo(sc, m, face, bu, bv, am, idx4, w4);
303
  const float* em = &sc.emi[m * 3];
304
 
305
- // Emission gathered at the camera hit (NEE on) or at every hit (NEE off).
306
- if (k == 0 || !nee) {
307
- if (!GRAD) {
308
- acc.x += T[0] * em[0];
309
- acc.y += T[1] * em[1];
310
- acc.z += T[2] * em[2];
311
- } else {
312
- for (int c = 0; c < 3; ++c)
313
- atomicAdd(&s_ge[m * 3 + c], gs[c] * T[c]);
314
- if (k > 0) {
315
- // dC/d texel: exclusion product over bounces j < k, scattered to
316
- // bounce j's bilinear footprint.
317
- suf[k][0] = suf[k][1] = suf[k][2] = 1.0f;
318
- for (int i = k - 1; i >= 0; --i)
319
- for (int c = 0; c < 3; ++c) suf[i][c] = suf[i + 1][c] * albs[i][c];
320
- float pref[3] = {1.0f, 1.0f, 1.0f};
321
- for (int j = 0; j < k; ++j) {
322
- for (int c = 0; c < 3; ++c) {
323
- float base = gs[c] * em[c] * pref[c] * suf[j + 1][c];
324
- #pragma unroll
325
- for (int i = 0; i < 4; ++i)
326
- scatter_tex(s_gt, g_tex, fp_i[j][i], c, base * fp_w[j][i]);
327
- pref[c] *= albs[j][c];
328
- }
329
- }
330
- }
331
  }
332
  }
333
 
334
- if (GRAD) {
335
- albs[k][0] = am[0];
336
- albs[k][1] = am[1];
337
- albs[k][2] = am[2];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  #pragma unroll
339
- for (int i = 0; i < 4; ++i) {
340
- fp_i[k][i] = idx4[i];
341
- fp_w[k][i] = w4[i];
342
  }
343
- mats[k] = m;
 
 
344
  }
345
 
346
- // Next-event estimation against the area-sampled light list, skipped at
347
- // the final vertex to match the BRDF-only estimator's truncation.
348
- if (nee && sc.nl > 0 && k + 1 < B) {
 
 
 
 
 
 
 
 
 
 
 
349
  float r1 = curand_uniform(&st);
350
  float r2 = curand_uniform(&st);
351
  float r3 = curand_uniform(&st);
352
- int li = pick_light(sc.lcdf, sc.nl, r1);
353
- int lface = sc.lf[li];
354
- const float* lv = &sc.tris[lface * 9];
355
- float3 lv0 = f3(lv[0], lv[1], lv[2]);
356
- float3 le1 = f3(lv[3], lv[4], lv[5]) - lv0;
357
- float3 le2 = f3(lv[6], lv[7], lv[8]) - lv0;
358
- float su = sqrtf(r2);
359
- float b0 = 1.0f - su;
360
- float b1 = r3 * su;
361
- float3 y = lv0 + le1 * b0 + le2 * b1; // uniform on the triangle
362
- float3 ln = normalize(cross(le1, le2));
363
- float3 dvec = y - x;
364
- float d2 = fmaxf(dot(dvec, dvec), 1e-12f);
365
- float d = sqrtf(d2);
366
- float3 wi = dvec * (1.0f / d);
367
- if (dot(ln, dvec) > 0.0f) ln = f3(-ln.x, -ln.y, -ln.z);
368
- float cx = dot(n, wi);
369
- float cy = -dot(ln, wi);
370
- int lm = sc.mats[lface];
371
- if (cx > 1e-6f && cy > 1e-6f && d > kShadowEps * 2.0f) {
372
- float3 xo = x + n * kRayEps;
373
- if (!bvh_occluded(sc, xo, wi, d - kShadowEps)) {
374
- float S = cx * cy / d2 * sc.larea * kInvPi;
375
- const float* el = &sc.emi[lm * 3];
376
- if (!GRAD) {
377
- acc.x += T[0] * am[0] * el[0] * S;
378
- acc.y += T[1] * am[1] * el[1] * S;
379
- acc.z += T[2] * am[2] * el[2] * S;
380
- } else {
381
- // Term = prod_{j<=k} a_j * E_l * S; albs[k] is already stored.
382
- suf[k + 1][0] = suf[k + 1][1] = suf[k + 1][2] = 1.0f;
383
- for (int i = k; i >= 0; --i)
384
- for (int c = 0; c < 3; ++c)
385
- suf[i][c] = suf[i + 1][c] * albs[i][c];
386
- for (int c = 0; c < 3; ++c)
387
- atomicAdd(&s_ge[lm * 3 + c], gs[c] * suf[0][c] * S);
388
- float pref[3] = {1.0f, 1.0f, 1.0f};
389
- for (int j = 0; j <= k; ++j) {
390
- for (int c = 0; c < 3; ++c) {
391
- float base = gs[c] * el[c] * S * pref[c] * suf[j + 1][c];
392
- #pragma unroll
393
- for (int i = 0; i < 4; ++i)
394
- scatter_tex(s_gt, g_tex, fp_i[j][i], c, base * fp_w[j][i]);
395
- pref[c] *= albs[j][c];
396
- }
397
  }
398
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  }
400
  }
401
  }
402
 
403
- // Cosine-weighted continuation: BRDF*cos/pdf = albedo exactly.
404
- float rd1 = curand_uniform(&st);
405
- float rd2 = curand_uniform(&st);
406
- float rr = sqrtf(rd1);
407
- float phi = 6.2831853071795864769f * rd2;
408
- float3 tang, bit;
409
- onb(n, tang, bit);
410
- float3 nd = tang * (rr * cosf(phi)) + bit * (rr * sinf(phi)) +
411
- n * sqrtf(fmaxf(0.0f, 1.0f - rd1));
412
- rd = normalize(nd);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  ro = x + n * kRayEps;
414
- T[0] *= am[0];
415
- T[1] *= am[1];
416
- T[2] *= am[2];
 
 
 
 
 
 
417
  }
418
  }
419
 
@@ -428,7 +738,7 @@ __device__ __forceinline__ float3 camera_ray(const DevCam& cam, int px, int py,
428
  }
429
 
430
  __global__ void k_forward(DevScene sc, DevCam cam, int H, int W, int spp,
431
- int B, int nee, unsigned long long seed,
432
  float* img) {
433
  int pid = blockIdx.x * blockDim.x + threadIdx.x;
434
  if (pid >= H * W) return;
@@ -441,8 +751,8 @@ __global__ void k_forward(DevScene sc, DevCam cam, int H, int W, int spp,
441
  float jx = curand_uniform(&st);
442
  float jy = curand_uniform(&st);
443
  float3 rd = camera_ray(cam, px, py, W, H, jx, jy);
444
- trace_path<false>(sc, ro, rd, st, B, nee, acc, nullptr, nullptr, nullptr,
445
- nullptr);
446
  }
447
  float inv_spp = 1.0f / (float)spp;
448
  img[pid * 3 + 0] = acc.x * inv_spp;
@@ -451,12 +761,13 @@ __global__ void k_forward(DevScene sc, DevCam cam, int H, int W, int spp,
451
  }
452
 
453
  __global__ void k_backward(DevScene sc, DevCam cam, int H, int W, int spp,
454
- int B, int nee, unsigned long long seed,
455
- const float* gimg, float* g_tex, float* ge) {
 
456
  extern __shared__ float smem[];
457
- float* s_ge = smem; // [nm * 3]
458
  bool staged = sc.nt <= kSharedTexels;
459
- float* s_gt = staged ? smem + sc.nm * 3 : nullptr; // [nt * 3] when staged
460
  int nm3 = sc.nm * 3;
461
  int nt3 = staged ? sc.nt * 3 : 0;
462
  for (int i = threadIdx.x; i < nm3 + nt3; i += blockDim.x) smem[i] = 0.0f;
@@ -476,7 +787,8 @@ __global__ void k_backward(DevScene sc, DevCam cam, int H, int W, int spp,
476
  float jx = curand_uniform(&st);
477
  float jy = curand_uniform(&st);
478
  float3 rd = camera_ray(cam, px, py, W, H, jx, jy);
479
- trace_path<true>(sc, ro, rd, st, B, nee, acc, gs, g_tex, s_gt, s_ge);
 
480
  }
481
  }
482
 
@@ -488,28 +800,32 @@ __global__ void k_backward(DevScene sc, DevCam cam, int H, int W, int spp,
488
  }
489
  }
490
 
491
- DevScene make_scene(const float* tris, const int* mat_ids, const float* uvs,
492
- const float* nodes_f, const int* nodes_i, int n_nodes,
493
- const int* light_faces, const float* light_cdf,
494
- int n_lights, float total_light_area, const float* tex,
495
- const int* tex_hdr, int n_texels, const float* emission,
496
- int n_mats) {
497
  DevScene sc;
498
- sc.tris = tris;
499
- sc.mats = mat_ids;
500
- sc.uvs = uvs;
501
- sc.nf = nodes_f;
502
- sc.ni = nodes_i;
503
- sc.n_nodes = n_nodes;
504
- sc.lf = light_faces;
505
- sc.lcdf = light_cdf;
506
- sc.nl = n_lights;
507
- sc.larea = total_light_area;
508
- sc.tex = tex;
509
- sc.thdr = tex_hdr;
510
- sc.nt = n_texels;
511
- sc.emi = emission;
512
- sc.nm = n_mats;
 
 
 
 
 
 
 
 
 
513
  return sc;
514
  }
515
 
@@ -526,41 +842,30 @@ DevCam make_cam(const float* cam) {
526
 
527
  } // namespace
528
 
529
- extern "C" void ptd_forward_launch(
530
- const float* tris, const int* mat_ids, const float* uvs, int /*n_faces*/,
531
- const float* nodes_f, const int* nodes_i, int n_nodes,
532
- const int* light_faces, const float* light_cdf, int n_lights,
533
- float total_light_area, const float* tex, const int* tex_hdr,
534
- int n_texels, const float* emission, int n_mats, const float* cam, int H,
535
- int W, int spp, int max_bounces, int nee, long long seed, float* image,
536
- cudaStream_t stream) {
537
- DevScene sc = make_scene(tris, mat_ids, uvs, nodes_f, nodes_i, n_nodes,
538
- light_faces, light_cdf, n_lights, total_light_area,
539
- tex, tex_hdr, n_texels, emission, n_mats);
540
  DevCam dc = make_cam(cam);
541
  int blocks = (H * W + kThreads - 1) / kThreads;
542
  k_forward<<<blocks, kThreads, 0, stream>>>(
543
- sc, dc, H, W, spp, max_bounces, nee, (unsigned long long)seed, image);
544
- }
545
-
546
- extern "C" void ptd_backward_launch(
547
- const float* tris, const int* mat_ids, const float* uvs, int /*n_faces*/,
548
- const float* nodes_f, const int* nodes_i, int n_nodes,
549
- const int* light_faces, const float* light_cdf, int n_lights,
550
- float total_light_area, const float* tex, const int* tex_hdr,
551
- int n_texels, const float* emission, int n_mats, const float* cam, int H,
552
- int W, int spp, int max_bounces, int nee, long long seed,
553
- const float* grad_image, float* grad_tex, float* grad_emission,
554
- cudaStream_t stream) {
555
- DevScene sc = make_scene(tris, mat_ids, uvs, nodes_f, nodes_i, n_nodes,
556
- light_faces, light_cdf, n_lights, total_light_area,
557
- tex, tex_hdr, n_texels, emission, n_mats);
558
  DevCam dc = make_cam(cam);
559
  int blocks = (H * W + kThreads - 1) / kThreads;
560
- bool staged = n_texels <= kSharedTexels;
561
- size_t smem = (size_t)(n_mats * 3 + (staged ? n_texels * 3 : 0)) *
562
- sizeof(float);
563
  k_backward<<<blocks, kThreads, smem, stream>>>(
564
- sc, dc, H, W, spp, max_bounces, nee, (unsigned long long)seed,
565
- grad_image, grad_tex, grad_emission);
566
  }
 
1
  // pathtracer-diff: a differentiable Monte Carlo path tracer as one kernel.
2
  //
3
+ // Forward: a megakernel unidirectional path tracer. Materials: Lambertian
4
+ // diffuse, GGX conductor (height-correlated Smith, VNDF sampling), and
5
+ // smooth dielectric (Fresnel-branched reflection/refraction). Lights: any
6
+ // emissive face (area-sampled) and an optional importance-sampled
7
+ // equirectangular environment map. Estimators: multiple importance
8
+ // sampling (balance heuristic) by default, with legacy NEE-only and
9
+ // BRDF-only modes. Per-texel albedo with bilinear filtering; binned-SAH
10
+ // BVH with near-child-first traversal. One thread owns one pixel and
11
+ // accumulates its spp samples serially, so the image is bitwise
12
+ // deterministic.
13
  //
14
+ // Backward: exact path replay. Sampling distributions depend only on
15
+ // geometry, frozen material parameters (roughness, ior), the detached
16
+ // environment CDF, and the counter-based Philox stream -- never on the
17
+ // differentiable parameters (albedo texels, per-material emission,
18
+ // environment texels). The backward kernel therefore re-traces identical
19
+ // paths with identical draws and no stored path state. Every radiance term
20
+ // is a product of per-bounce factors, each AFFINE in its own vertex's
21
+ // albedo texels (diffuse: a; conductor: Schlick F(F0) is affine in F0;
22
+ // dielectric: constant), times an emission or environment texel (linear).
23
+ // The replay differentiates the product in closed form with prefix/suffix
24
+ // exclusion products and scatters each factor's gradient into the four
25
+ // texels of its bilinear footprint -- the exact adjoint of the fetch.
26
+ // Interior terms only: no visibility/silhouette gradients.
 
 
 
 
27
 
28
  #include <cuda_runtime.h>
29
  #include <curand_kernel.h>
 
37
  constexpr int kThreads = 128;
38
  constexpr int kMaxBounces = 16;
39
  constexpr int kMaxMats = 64;
40
+ constexpr int kSharedTexels = 2048; // shared-staged texture-gradient limit
 
 
 
41
  constexpr int kStack = 64;
42
+ constexpr float kPi = 3.14159265358979323846f;
43
  constexpr float kInvPi = 0.31830988618379067154f;
44
  constexpr float kRayEps = 1e-4f;
45
  constexpr float kShadowEps = 1e-3f;
46
+ constexpr float kEnvDist = 1e30f;
47
+
48
+ enum Mode { kModeBrdf = 0, kModeNee = 1, kModeMis = 2 };
49
+ enum MatType { kDiffuse = 0, kConductor = 1, kDielectric = 2 };
50
 
51
  // ---------------------------------------------------------------- float3 ops
52
  __device__ __forceinline__ float3 f3(float x, float y, float z) {
 
70
  __device__ __forceinline__ float3 normalize(float3 a) {
71
  return a * rsqrtf(fmaxf(dot(a, a), 1e-30f));
72
  }
73
+ __device__ __forceinline__ float3 neg(float3 a) { return f3(-a.x, -a.y, -a.z); }
74
 
75
  // Duff et al. 2017, "Building an Orthonormal Basis, Revisited".
76
  __device__ __forceinline__ void onb(float3 n, float3& t, float3& b) {
 
83
 
84
  // ---------------------------------------------------------------- scene view
85
  struct DevScene {
86
+ const float* tris; // [F, 9]
87
  const int* mats; // [F]
88
+ const float* uvs; // [F, 3, 2]
89
+ const float* nf; // [N, 6]
90
+ const int* ni; // [N, 3] internal (l, r, axis<<1) | leaf (start, count, odd)
91
  int n_nodes;
92
+ const int* lf; // [L] emissive faces
93
+ const float* lcdf; // [L]
94
  int nl;
95
+ float larea;
96
+ const float* tex; // [T, 3] albedo texels
97
+ const int* thdr; // [M, 3] (offset, W, H)
98
+ int nt;
99
  const float* emi; // [M, 3]
100
+ const int* mtype; // [M] material type
101
+ const float* mrough; // [M] GGX alpha (conductor)
102
+ const float* mior; // [M] ior (dielectric)
103
  int nm;
104
+ // environment map (equirect) + detached sampling tables
105
+ const float* env; // [Eh*Ew, 3] or null
106
+ int ew, eh;
107
+ const float* ecdf_m; // [Eh] marginal cdf over rows
108
+ const float* ecdf_c; // [Eh*Ew] conditional cdf per row
109
+ const float* epdf; // [Eh*Ew] table pdf over the image (integrates to 1)
110
  };
111
 
112
+ struct DevCam {
113
  float p[3], f[3], r[3], u[3];
114
  };
115
 
 
168
  float tmin, float& tbest, float& bu, float& bv,
169
  float3& ngbest) {
170
  float roa[3] = {ro.x, ro.y, ro.z};
171
+ float dira[3] = {rd.x, rd.y, rd.z};
172
  float inv[3];
173
  inv_dir(rd, inv);
174
  int stack[kStack];
 
179
  int nid = stack[--sp];
180
  if (!slab(&sc.nf[nid * 6], roa, inv, tbest)) continue;
181
  const int* n = &sc.ni[nid * 3];
182
+ if (n[2] & 1) {
183
  for (int f = n[0]; f < n[0] + n[1]; ++f) {
184
  float t, u, v;
185
  float3 ng;
 
192
  }
193
  }
194
  } else if (sp + 2 <= kStack) {
195
+ int axis = n[2] >> 1;
196
+ int near = (dira[axis] >= 0.0f) ? n[0] : n[1];
197
+ int far = (dira[axis] >= 0.0f) ? n[1] : n[0];
198
+ stack[sp++] = far;
199
+ stack[sp++] = near;
200
  }
201
  }
202
  return best;
 
214
  int nid = stack[--sp];
215
  if (!slab(&sc.nf[nid * 6], roa, inv, tmax)) continue;
216
  const int* n = &sc.ni[nid * 3];
217
+ if (n[2] & 1) {
218
  for (int f = n[0]; f < n[0] + n[1]; ++f) {
219
  float t, u, v;
220
  float3 ng;
 
229
  return false;
230
  }
231
 
232
+ __device__ __forceinline__ int cdf_pick(const float* cdf, int n, float r) {
233
+ int lo = 0, hi = n - 1;
234
  while (lo < hi) {
235
  int mid = (lo + hi) >> 1;
236
  if (cdf[mid] < r) lo = mid + 1; else hi = mid;
 
238
  return lo;
239
  }
240
 
241
+ // Bilinear fetch with repeat wrap over a flat texel block.
242
+ __device__ __forceinline__ void bilinear(const float* block, int off, int W,
243
+ int H, float u, float v, float rgb[3],
244
+ int idx4[4], float w4[4]) {
 
 
 
 
 
 
 
 
245
  float uu = u * (float)W - 0.5f;
246
  float vv = v * (float)H - 0.5f;
247
  float fx = uu - floorf(uu);
 
258
  rgb[0] = rgb[1] = rgb[2] = 0.0f;
259
  #pragma unroll
260
  for (int i = 0; i < 4; ++i) {
261
+ const float* t = &block[idx4[i] * 3];
262
  rgb[0] += w4[i] * t[0];
263
  rgb[1] += w4[i] * t[1];
264
  rgb[2] += w4[i] * t[2];
265
  }
266
  }
267
 
268
+ __device__ __forceinline__ void sample_albedo(const DevScene& sc, int m,
269
+ int face, float bu, float bv,
270
+ float rgb[3], int idx4[4],
271
+ float w4[4]) {
272
+ const float* U = &sc.uvs[face * 6];
273
+ float w0 = 1.0f - bu - bv;
274
+ float u = w0 * U[0] + bu * U[2] + bv * U[4];
275
+ float v = w0 * U[1] + bu * U[3] + bv * U[5];
276
+ const int* h = &sc.thdr[m * 3];
277
+ bilinear(sc.tex, h[0], h[1], h[2], u, v, rgb, idx4, w4);
278
+ }
279
+
280
+ // ----------------------------------------------------------- environment map
281
+ __device__ __forceinline__ void dir_to_equirect(float3 d, float& u, float& v) {
282
+ float phi = atan2f(d.z, d.x); // [-pi, pi]
283
+ float theta = acosf(fminf(fmaxf(d.y, -1.0f), 1.0f)); // [0, pi], y-up
284
+ u = (phi + kPi) / (2.0f * kPi);
285
+ v = theta / kPi;
286
+ }
287
+
288
+ __device__ __forceinline__ float3 equirect_to_dir(float u, float v) {
289
+ float phi = u * 2.0f * kPi - kPi;
290
+ float theta = v * kPi;
291
+ float st = sinf(theta);
292
+ return f3(st * cosf(phi), cosf(theta), st * sinf(phi));
293
+ }
294
+
295
+ // Solid-angle pdf of the detached env sampling mixture (0.5 table + 0.5
296
+ // uniform sphere).
297
+ __device__ __forceinline__ float env_pdf(const DevScene& sc, float3 d) {
298
+ float u, v;
299
+ dir_to_equirect(d, u, v);
300
+ int x = min(sc.ew - 1, (int)(u * sc.ew));
301
+ int y = min(sc.eh - 1, (int)(v * sc.eh));
302
+ float st = fmaxf(sinf((y + 0.5f) * kPi / sc.eh), 1e-4f);
303
+ float p_img = sc.epdf[y * sc.ew + x]; // per-texel image pdf (sums to 1)
304
+ float p_tab = p_img * (float)(sc.ew * sc.eh) / (2.0f * kPi * kPi * st);
305
+ return 0.5f * p_tab + 0.5f * (1.0f / (4.0f * kPi));
306
+ }
307
+
308
+ __device__ __forceinline__ void env_fetch(const DevScene& sc, float3 d,
309
+ float rgb[3], int idx4[4],
310
+ float w4[4]) {
311
+ float u, v;
312
+ dir_to_equirect(d, u, v);
313
+ // clamp v away from the poles to keep the bilinear rows in range
314
+ v = fminf(fmaxf(v, 0.5f / sc.eh), 1.0f - 0.5f / sc.eh);
315
+ bilinear(sc.env, 0, sc.ew, sc.eh, u, v, rgb, idx4, w4);
316
+ }
317
+
318
+ // ------------------------------------------------------------------- GGX
319
+ __device__ __forceinline__ float ggx_lambda(float a2, float cs) {
320
+ float c2 = cs * cs;
321
+ float t2 = fmaxf(0.0f, 1.0f - c2) / fmaxf(c2, 1e-12f);
322
+ return 0.5f * (-1.0f + sqrtf(1.0f + a2 * t2));
323
+ }
324
+
325
+ __device__ __forceinline__ float ggx_d(float a2, float ch) {
326
+ float d = ch * ch * (a2 - 1.0f) + 1.0f;
327
+ return a2 / fmaxf(kPi * d * d, 1e-20f);
328
+ }
329
+
330
+ // Heitz 2018 VNDF sampling; wi in local frame (z up), wi.z > 0.
331
+ __device__ __forceinline__ float3 ggx_sample_vndf(float3 wi, float alpha,
332
+ float u1, float u2) {
333
+ float3 vh = normalize(f3(alpha * wi.x, alpha * wi.y, wi.z));
334
+ float lensq = vh.x * vh.x + vh.y * vh.y;
335
+ float3 T1 = lensq > 1e-12f ? f3(-vh.y, vh.x, 0.0f) * rsqrtf(lensq)
336
+ : f3(1.0f, 0.0f, 0.0f);
337
+ float3 T2 = cross(vh, T1);
338
+ float r = sqrtf(u1);
339
+ float phi = 2.0f * kPi * u2;
340
+ float t1 = r * cosf(phi);
341
+ float t2 = r * sinf(phi);
342
+ float s = 0.5f * (1.0f + vh.z);
343
+ t2 = (1.0f - s) * sqrtf(fmaxf(0.0f, 1.0f - t1 * t1)) + s * t2;
344
+ float3 nh = T1 * t1 + T2 * t2 +
345
+ vh * sqrtf(fmaxf(0.0f, 1.0f - t1 * t1 - t2 * t2));
346
+ return normalize(f3(alpha * nh.x, alpha * nh.y, fmaxf(1e-6f, nh.z)));
347
+ }
348
+
349
+ // pdf of wo under VNDF sampling (solid angle).
350
+ __device__ __forceinline__ float ggx_pdf(float a2, float3 wi, float3 wo) {
351
+ float3 h = normalize(wi + wo);
352
+ float ch = fmaxf(h.z, 1e-6f);
353
+ float wih = fmaxf(dot(wi, h), 1e-6f);
354
+ float g1 = 1.0f / (1.0f + ggx_lambda(a2, wi.z));
355
+ return g1 * ggx_d(a2, ch) * wih / fmaxf(wi.z, 1e-6f) / (4.0f * wih);
356
+ }
357
+
358
+ // Fresnel (dielectric, unpolarized), cos_i >= 0; n1 -> n2.
359
+ __device__ __forceinline__ float fresnel_dielectric(float cos_i, float eta) {
360
+ float s2 = (1.0f - cos_i * cos_i) / (eta * eta);
361
+ if (s2 >= 1.0f) return 1.0f; // TIR
362
+ float cos_t = sqrtf(1.0f - s2);
363
+ float rs = (cos_i - eta * cos_t) / (cos_i + eta * cos_t);
364
+ float rp = (eta * cos_i - cos_t) / (eta * cos_i + cos_t);
365
+ return 0.5f * (rs * rs + rp * rp);
366
+ }
367
+
368
+ // Schlick term split so F(F0) = F0 * (1 - s) + s: affine in F0.
369
+ __device__ __forceinline__ float schlick_s(float cos_h) {
370
+ float m = fminf(fmaxf(1.0f - cos_h, 0.0f), 1.0f);
371
+ float m2 = m * m;
372
+ return m2 * m2 * m;
373
+ }
374
+
375
+ __device__ __forceinline__ float mis_w(float pa, float pb) {
376
+ return pa / fmaxf(pa + pb, 1e-20f);
377
+ }
378
+
379
+ // ------------------------------------------------------------- path tracing
380
  __device__ __forceinline__ void scatter_tex(float* s_gt, float* g_tex,
381
  int idx, int c, float v) {
382
+ if (s_gt) atomicAdd(&s_gt[idx * 3 + c], v);
383
+ else atomicAdd(&g_tex[idx * 3 + c], v);
384
+ }
385
+
386
+ // Per-bounce factor record for the replay: value and d(value)/d(albedo
387
+ // texel channel), both per channel, plus the texel footprint.
388
+ struct Factor {
389
+ float val[3];
390
+ float dc[3];
391
+ int fi[4];
392
+ float fw[4];
393
+ };
394
+
395
+ // Accumulate one radiance term and (GRAD) its gradients:
396
+ // term = (prod over the nk factors fs, channelwise) * S * E
397
+ // where fs holds the continuation factors and, for NEE-type terms, the
398
+ // vertex's own BSDF factor as the last entry. T is the product of the
399
+ // continuation factors only; tf is the last factor's value (null for
400
+ // emission/env gathers, whose product is T alone). E is emission (index
401
+ // em_mat >= 0) or environment (footprint env_i/env_w).
402
+ template <bool GRAD>
403
+ __device__ void add_term(const Factor* fs, int nk, const float T[3],
404
+ const float* tf, float S, const float E[3],
405
+ int em_mat, const int env_i[4], const float env_w[4],
406
+ float3& acc, const float gs[3], float* g_tex,
407
+ float* s_gt, float* s_ge, float* g_env) {
408
+ float P[3] = {T[0], T[1], T[2]};
409
+ if (tf) {
410
+ P[0] *= tf[0];
411
+ P[1] *= tf[1];
412
+ P[2] *= tf[2];
413
+ }
414
+ if (!GRAD) {
415
+ acc.x += P[0] * S * E[0];
416
+ acc.y += P[1] * S * E[1];
417
+ acc.z += P[2] * S * E[2];
418
+ return;
419
+ }
420
+ // gradient to the emission / environment texels: g * prodA * S
421
+ if (em_mat >= 0) {
422
+ for (int c = 0; c < 3; ++c)
423
+ atomicAdd(&s_ge[em_mat * 3 + c], gs[c] * P[c] * S);
424
+ } else if (env_i) {
425
+ for (int c = 0; c < 3; ++c) {
426
+ float base = gs[c] * P[c] * S;
427
+ #pragma unroll
428
+ for (int i = 0; i < 4; ++i)
429
+ atomicAdd(&g_env[env_i[i] * 3 + c], base * env_w[i]);
430
+ }
431
+ }
432
+ // gradient to each factor's albedo texels via exclusion products
433
+ if (nk <= 0) return;
434
+ float suf[kMaxBounces + 1][3];
435
+ suf[nk][0] = suf[nk][1] = suf[nk][2] = 1.0f;
436
+ for (int i = nk - 1; i >= 0; --i)
437
+ for (int c = 0; c < 3; ++c) suf[i][c] = suf[i + 1][c] * fs[i].val[c];
438
+ float pref[3] = {1.0f, 1.0f, 1.0f};
439
+ for (int j = 0; j < nk; ++j) {
440
+ for (int c = 0; c < 3; ++c) {
441
+ if (fs[j].dc[c] != 0.0f) {
442
+ float base = gs[c] * E[c] * S * pref[c] * suf[j + 1][c] * fs[j].dc[c];
443
+ #pragma unroll
444
+ for (int i = 0; i < 4; ++i)
445
+ scatter_tex(s_gt, g_tex, fs[j].fi[i], c, base * fs[j].fw[i]);
446
+ }
447
+ pref[c] *= fs[j].val[c];
448
+ }
449
  }
450
  }
451
 
 
 
 
 
 
452
  template <bool GRAD>
453
  __device__ void trace_path(const DevScene& sc, float3 ro, float3 rd,
454
+ curandStatePhilox4_32_10_t& st, int B, int mode,
455
  float3& acc, const float gs[3], float* g_tex,
456
+ float* s_gt, float* s_ge, float* g_env) {
457
+ float T[3] = {1.0f, 1.0f, 1.0f};
458
+ Factor fs[kMaxBounces];
459
+ int nk = 0; // factors recorded so far (= bounces done)
460
+ float prev_pdf = 0.0f; // solid-angle pdf of the previous BSDF sample
461
+ bool prev_delta = true; // camera counts as delta (w = 1 on first hit)
462
+ bool has_env = sc.env != nullptr;
463
 
464
  for (int k = 0; k < B; ++k) {
465
+ float tbest = kEnvDist;
466
  float bu = 0.0f, bv = 0.0f;
467
  float3 ng;
468
  int face = bvh_closest(sc, ro, rd, kRayEps, tbest, bu, bv, ng);
469
+
470
+ if (face < 0) {
471
+ // Environment gather with MIS against the env-NEE at the previous
472
+ // vertex (weight 1 after the camera or a delta vertex, or outside
473
+ // MIS mode).
474
+ if (has_env) {
475
+ float E[3];
476
+ int ei[4];
477
+ float ew[4];
478
+ env_fetch(sc, rd, E, ei, ew);
479
+ float w = 1.0f;
480
+ if (mode == kModeMis && !prev_delta)
481
+ w = mis_w(prev_pdf, env_pdf(sc, rd));
482
+ if (mode != kModeNee || prev_delta)
483
+ add_term<GRAD>(fs, nk, T, nullptr, w, E, -1, ei, ew, acc, gs,
484
+ g_tex, s_gt, s_ge, g_env);
485
+ }
486
+ break;
487
+ }
488
 
489
  int m = sc.mats[face];
490
+ int mt = sc.mtype[m];
491
  float3 x = ro + rd * tbest;
492
  float3 n = normalize(ng);
493
+ bool backface = dot(n, rd) > 0.0f;
494
+ if (backface) n = neg(n);
495
 
496
  float am[3];
497
  int idx4[4];
 
499
  sample_albedo(sc, m, face, bu, bv, am, idx4, w4);
500
  const float* em = &sc.emi[m * 3];
501
 
502
+ // Emission gather. BRDF mode: always. NEE mode: camera hit only. MIS:
503
+ // always, weighted against the light-NEE at the previous vertex.
504
+ bool emissive = em[0] > 0.0f || em[1] > 0.0f || em[2] > 0.0f;
505
+ if (emissive) {
506
+ float w = 1.0f;
507
+ bool add = true;
508
+ if (mode == kModeNee) {
509
+ add = (k == 0);
510
+ } else if (mode == kModeMis && !prev_delta && sc.nl > 0) {
511
+ float cy = fabsf(dot(normalize(ng), rd));
512
+ float p_l = (tbest * tbest) / fmaxf(cy * sc.larea, 1e-12f);
513
+ w = mis_w(prev_pdf, p_l);
514
+ }
515
+ if (add) {
516
+ float E[3] = {em[0], em[1], em[2]};
517
+ add_term<GRAD>(fs, nk, T, nullptr, w, E, m, nullptr, nullptr, acc, gs,
518
+ g_tex, s_gt, s_ge, g_env);
 
 
 
 
 
 
 
 
 
519
  }
520
  }
521
 
522
+ // ---------------- dielectric: delta lobes, no NEE, unit factor
523
+ if (mt == kDielectric) {
524
+ float r1 = curand_uniform(&st);
525
+ float r2 = curand_uniform(&st);
526
+ (void)r2;
527
+ float eta = backface ? sc.mior[m] : 1.0f / sc.mior[m]; // n1/n2
528
+ float ci = -dot(rd, n);
529
+ float F = fresnel_dielectric(ci, 1.0f / eta);
530
+ float3 nd;
531
+ if (r1 < F) {
532
+ nd = rd + n * (2.0f * ci);
533
+ ro = x + n * kRayEps;
534
+ } else {
535
+ float s2 = eta * eta * (1.0f - ci * ci);
536
+ float ct = sqrtf(fmaxf(0.0f, 1.0f - s2));
537
+ nd = rd * eta + n * (eta * ci - ct);
538
+ ro = x - n * kRayEps;
539
+ }
540
+ rd = normalize(nd);
541
+ if (nk < kMaxBounces) {
542
+ Factor& f = fs[nk++];
543
+ for (int c = 0; c < 3; ++c) { f.val[c] = 1.0f; f.dc[c] = 0.0f; }
544
  #pragma unroll
545
+ for (int i = 0; i < 4; ++i) { f.fi[i] = idx4[i]; f.fw[i] = w4[i]; }
 
 
546
  }
547
+ prev_delta = true;
548
+ prev_pdf = 0.0f;
549
+ continue;
550
  }
551
 
552
+ // Local frame; wi = toward camera.
553
+ float3 tang, bit;
554
+ onb(n, tang, bit);
555
+ float3 wi_w = neg(rd);
556
+ float3 wi = f3(dot(wi_w, tang), dot(wi_w, bit), dot(wi_w, n));
557
+ wi.z = fmaxf(wi.z, 1e-6f);
558
+ float alpha = fmaxf(sc.mrough[m], 0.01f);
559
+ float a2 = alpha * alpha;
560
+
561
+ // ---------------- light NEE (area lights)
562
+ bool nee_here = (mode == kModeNee && sc.nl > 0 && k + 1 < B) ||
563
+ (mode == kModeMis && sc.nl > 0);
564
+ if ((mode != kModeBrdf) && sc.nl > 0 &&
565
+ (mode == kModeMis || k + 1 < B)) {
566
  float r1 = curand_uniform(&st);
567
  float r2 = curand_uniform(&st);
568
  float r3 = curand_uniform(&st);
569
+ if (nee_here) {
570
+ int li = cdf_pick(sc.lcdf, sc.nl, r1);
571
+ int lface = sc.lf[li];
572
+ const float* lv = &sc.tris[lface * 9];
573
+ float3 lv0 = f3(lv[0], lv[1], lv[2]);
574
+ float3 le1 = f3(lv[3], lv[4], lv[5]) - lv0;
575
+ float3 le2 = f3(lv[6], lv[7], lv[8]) - lv0;
576
+ float su = sqrtf(r2);
577
+ float3 y = lv0 + le1 * (1.0f - su) + le2 * (r3 * su);
578
+ float3 ln = normalize(cross(le1, le2));
579
+ float3 dvec = y - x;
580
+ float d2 = fmaxf(dot(dvec, dvec), 1e-12f);
581
+ float d = sqrtf(d2);
582
+ float3 wo_w = dvec * (1.0f / d);
583
+ if (dot(ln, dvec) > 0.0f) ln = neg(ln);
584
+ float cx = dot(n, wo_w);
585
+ float cy = -dot(ln, wo_w);
586
+ int lm = sc.mats[lface];
587
+ if (cx > 1e-6f && cy > 1e-6f && d > kShadowEps * 2.0f &&
588
+ !bvh_occluded(sc, x + n * kRayEps, wo_w, d - kShadowEps)) {
589
+ float3 wo = f3(dot(wo_w, tang), dot(wo_w, bit), cx);
590
+ // f * cos / pdf_A, with pdf_A -> S_geo = cx*cy/d2 * larea
591
+ float S_geo = cx * cy / d2 * sc.larea;
592
+ float tf_val[3], tf_dc[3];
593
+ float S;
594
+ if (mt == kDiffuse) {
595
+ S = S_geo * kInvPi;
596
+ for (int c = 0; c < 3; ++c) { tf_val[c] = am[c]; tf_dc[c] = 1.0f; }
597
+ } else { // conductor
598
+ float3 h = normalize(wi + wo);
599
+ float sgl = schlick_s(fmaxf(dot(wi, h), 0.0f));
600
+ float G2 = 1.0f / (1.0f + ggx_lambda(a2, wi.z) +
601
+ ggx_lambda(a2, wo.z));
602
+ float gg = ggx_d(a2, fmaxf(h.z, 1e-6f)) * G2 /
603
+ (4.0f * wi.z * fmaxf(wo.z, 1e-6f));
604
+ S = S_geo * gg;
605
+ for (int c = 0; c < 3; ++c) {
606
+ tf_val[c] = am[c] * (1.0f - sgl) + sgl;
607
+ tf_dc[c] = 1.0f - sgl;
 
 
 
 
 
 
608
  }
609
  }
610
+ float w = 1.0f;
611
+ if (mode == kModeMis) {
612
+ float p_lw = d2 / fmaxf(cy * sc.larea, 1e-12f);
613
+ float p_bw = (mt == kDiffuse) ? fmaxf(wo.z, 0.0f) * kInvPi
614
+ : ggx_pdf(a2, wi, wo);
615
+ w = mis_w(p_lw, p_bw);
616
+ }
617
+ if (nk < kMaxBounces) {
618
+ Factor& f = fs[nk];
619
+ for (int c = 0; c < 3; ++c) { f.val[c] = tf_val[c]; f.dc[c] = tf_dc[c]; }
620
+ #pragma unroll
621
+ for (int i = 0; i < 4; ++i) { f.fi[i] = idx4[i]; f.fw[i] = w4[i]; }
622
+ float E[3] = {sc.emi[lm * 3], sc.emi[lm * 3 + 1], sc.emi[lm * 3 + 2]};
623
+ add_term<GRAD>(fs, nk + 1, T, f.val, S * w, E, lm, nullptr, nullptr,
624
+ acc, gs, g_tex, s_gt, s_ge, g_env);
625
+ }
626
  }
627
  }
628
  }
629
 
630
+ // ---------------- environment NEE (MIS mode only)
631
+ if (mode == kModeMis && has_env) {
632
+ float e1 = curand_uniform(&st);
633
+ float e2 = curand_uniform(&st);
634
+ float e3 = curand_uniform(&st);
635
+ float3 wo_w;
636
+ if (e1 < 0.5f) { // table
637
+ int y = cdf_pick(sc.ecdf_m, sc.eh, e2);
638
+ int xcol = cdf_pick(&sc.ecdf_c[y * sc.ew], sc.ew, e3);
639
+ float u = (xcol + 0.5f) / sc.ew;
640
+ float v = (y + 0.5f) / sc.eh;
641
+ wo_w = equirect_to_dir(u, v);
642
+ } else { // uniform sphere
643
+ float z = 1.0f - 2.0f * e2;
644
+ float rxy = sqrtf(fmaxf(0.0f, 1.0f - z * z));
645
+ float ph = 2.0f * kPi * e3;
646
+ wo_w = f3(rxy * cosf(ph), z, rxy * sinf(ph));
647
+ }
648
+ float cx = dot(n, wo_w);
649
+ if (cx > 1e-6f &&
650
+ !bvh_occluded(sc, x + n * kRayEps, wo_w, kEnvDist)) {
651
+ float p = env_pdf(sc, wo_w);
652
+ float3 wo = f3(dot(wo_w, tang), dot(wo_w, bit), cx);
653
+ float tf_val[3], tf_dc[3];
654
+ float S;
655
+ if (mt == kDiffuse) {
656
+ S = cx * kInvPi / p;
657
+ for (int c = 0; c < 3; ++c) { tf_val[c] = am[c]; tf_dc[c] = 1.0f; }
658
+ } else {
659
+ float3 h = normalize(wi + wo);
660
+ float sgl = schlick_s(fmaxf(dot(wi, h), 0.0f));
661
+ float G2 = 1.0f / (1.0f + ggx_lambda(a2, wi.z) +
662
+ ggx_lambda(a2, wo.z));
663
+ float gg = ggx_d(a2, fmaxf(h.z, 1e-6f)) * G2 /
664
+ (4.0f * wi.z * fmaxf(wo.z, 1e-6f));
665
+ S = cx * gg / p;
666
+ for (int c = 0; c < 3; ++c) {
667
+ tf_val[c] = am[c] * (1.0f - sgl) + sgl;
668
+ tf_dc[c] = 1.0f - sgl;
669
+ }
670
+ }
671
+ float p_bw = (mt == kDiffuse) ? fmaxf(wo.z, 0.0f) * kInvPi
672
+ : ggx_pdf(a2, wi, wo);
673
+ float w = mis_w(p, p_bw);
674
+ float E[3];
675
+ int ei[4];
676
+ float ewt[4];
677
+ env_fetch(sc, wo_w, E, ei, ewt);
678
+ if (nk < kMaxBounces) {
679
+ Factor& f = fs[nk];
680
+ for (int c = 0; c < 3; ++c) { f.val[c] = tf_val[c]; f.dc[c] = tf_dc[c]; }
681
+ #pragma unroll
682
+ for (int i = 0; i < 4; ++i) { f.fi[i] = idx4[i]; f.fw[i] = w4[i]; }
683
+ add_term<GRAD>(fs, nk + 1, T, f.val, S * w, E, -1, ei, ewt, acc, gs,
684
+ g_tex, s_gt, s_ge, g_env);
685
+ }
686
+ }
687
+ }
688
+
689
+ // ---------------- BSDF continuation
690
+ float c1 = curand_uniform(&st);
691
+ float c2 = curand_uniform(&st);
692
+ float3 wo;
693
+ float fv[3], fdc[3];
694
+ if (mt == kDiffuse) {
695
+ float rr = sqrtf(c1);
696
+ float phi = 2.0f * kPi * c2;
697
+ wo = f3(rr * cosf(phi), rr * sinf(phi), sqrtf(fmaxf(0.0f, 1.0f - c1)));
698
+ prev_pdf = fmaxf(wo.z, 1e-8f) * kInvPi;
699
+ for (int c = 0; c < 3; ++c) { fv[c] = am[c]; fdc[c] = 1.0f; }
700
+ } else { // conductor: VNDF sample, weight = F * G2/G1
701
+ float3 h = ggx_sample_vndf(wi, alpha, c1, c2);
702
+ float wih = dot(wi, h);
703
+ wo = h * (2.0f * wih) - wi;
704
+ if (wo.z <= 1e-6f) break; // sampled below the horizon: zero weight
705
+ float sgl = schlick_s(fmaxf(wih, 0.0f));
706
+ float li = ggx_lambda(a2, wi.z);
707
+ float lo = ggx_lambda(a2, wo.z);
708
+ float gw = (1.0f + li) / (1.0f + li + lo); // G2/G1
709
+ prev_pdf = ggx_pdf(a2, wi, wo);
710
+ for (int c = 0; c < 3; ++c) {
711
+ fv[c] = (am[c] * (1.0f - sgl) + sgl) * gw;
712
+ fdc[c] = (1.0f - sgl) * gw;
713
+ }
714
+ }
715
+ prev_delta = false;
716
+ rd = normalize(tang * wo.x + bit * wo.y + n * wo.z);
717
  ro = x + n * kRayEps;
718
+ if (nk < kMaxBounces) {
719
+ Factor& f = fs[nk++];
720
+ for (int c = 0; c < 3; ++c) { f.val[c] = fv[c]; f.dc[c] = fdc[c]; }
721
+ #pragma unroll
722
+ for (int i = 0; i < 4; ++i) { f.fi[i] = idx4[i]; f.fw[i] = w4[i]; }
723
+ }
724
+ T[0] *= fv[0];
725
+ T[1] *= fv[1];
726
+ T[2] *= fv[2];
727
  }
728
  }
729
 
 
738
  }
739
 
740
  __global__ void k_forward(DevScene sc, DevCam cam, int H, int W, int spp,
741
+ int B, int mode, unsigned long long seed,
742
  float* img) {
743
  int pid = blockIdx.x * blockDim.x + threadIdx.x;
744
  if (pid >= H * W) return;
 
751
  float jx = curand_uniform(&st);
752
  float jy = curand_uniform(&st);
753
  float3 rd = camera_ray(cam, px, py, W, H, jx, jy);
754
+ trace_path<false>(sc, ro, rd, st, B, mode, acc, nullptr, nullptr, nullptr,
755
+ nullptr, nullptr);
756
  }
757
  float inv_spp = 1.0f / (float)spp;
758
  img[pid * 3 + 0] = acc.x * inv_spp;
 
761
  }
762
 
763
  __global__ void k_backward(DevScene sc, DevCam cam, int H, int W, int spp,
764
+ int B, int mode, unsigned long long seed,
765
+ const float* gimg, float* g_tex, float* ge,
766
+ float* g_env) {
767
  extern __shared__ float smem[];
768
+ float* s_ge = smem;
769
  bool staged = sc.nt <= kSharedTexels;
770
+ float* s_gt = staged ? smem + sc.nm * 3 : nullptr;
771
  int nm3 = sc.nm * 3;
772
  int nt3 = staged ? sc.nt * 3 : 0;
773
  for (int i = threadIdx.x; i < nm3 + nt3; i += blockDim.x) smem[i] = 0.0f;
 
787
  float jx = curand_uniform(&st);
788
  float jy = curand_uniform(&st);
789
  float3 rd = camera_ray(cam, px, py, W, H, jx, jy);
790
+ trace_path<true>(sc, ro, rd, st, B, mode, acc, gs, g_tex, s_gt, s_ge,
791
+ g_env);
792
  }
793
  }
794
 
 
800
  }
801
  }
802
 
803
+ DevScene make_scene(const PtdSceneArgs& a) {
 
 
 
 
 
804
  DevScene sc;
805
+ sc.tris = a.tris;
806
+ sc.mats = a.mat_ids;
807
+ sc.uvs = a.uvs;
808
+ sc.nf = a.nodes_f;
809
+ sc.ni = a.nodes_i;
810
+ sc.n_nodes = a.n_nodes;
811
+ sc.lf = a.light_faces;
812
+ sc.lcdf = a.light_cdf;
813
+ sc.nl = a.n_lights;
814
+ sc.larea = a.total_light_area;
815
+ sc.tex = a.tex;
816
+ sc.thdr = a.tex_hdr;
817
+ sc.nt = a.n_texels;
818
+ sc.emi = a.emission;
819
+ sc.mtype = a.mat_type;
820
+ sc.mrough = a.mat_rough;
821
+ sc.mior = a.mat_ior;
822
+ sc.nm = a.n_mats;
823
+ sc.env = a.env;
824
+ sc.ew = a.env_w;
825
+ sc.eh = a.env_h;
826
+ sc.ecdf_m = a.env_cdf_m;
827
+ sc.ecdf_c = a.env_cdf_c;
828
+ sc.epdf = a.env_pdf;
829
  return sc;
830
  }
831
 
 
842
 
843
  } // namespace
844
 
845
+ extern "C" void ptd_forward_launch(const PtdSceneArgs* args, const float* cam,
846
+ int H, int W, int spp, int max_bounces,
847
+ int mode, long long seed, float* image,
848
+ cudaStream_t stream) {
849
+ DevScene sc = make_scene(*args);
 
 
 
 
 
 
850
  DevCam dc = make_cam(cam);
851
  int blocks = (H * W + kThreads - 1) / kThreads;
852
  k_forward<<<blocks, kThreads, 0, stream>>>(
853
+ sc, dc, H, W, spp, max_bounces, mode, (unsigned long long)seed, image);
854
+ }
855
+
856
+ extern "C" void ptd_backward_launch(const PtdSceneArgs* args, const float* cam,
857
+ int H, int W, int spp, int max_bounces,
858
+ int mode, long long seed,
859
+ const float* grad_image, float* grad_tex,
860
+ float* grad_emission, float* grad_env,
861
+ cudaStream_t stream) {
862
+ DevScene sc = make_scene(*args);
 
 
 
 
 
863
  DevCam dc = make_cam(cam);
864
  int blocks = (H * W + kThreads - 1) / kThreads;
865
+ bool staged = args->n_texels <= kSharedTexels;
866
+ size_t smem = (size_t)(args->n_mats * 3 +
867
+ (staged ? args->n_texels * 3 : 0)) * sizeof(float);
868
  k_backward<<<blocks, kThreads, smem, stream>>>(
869
+ sc, dc, H, W, spp, max_bounces, mode, (unsigned long long)seed,
870
+ grad_image, grad_tex, grad_emission, grad_env);
871
  }
pathtracer_diff_cuda/pathtracer_launch.h CHANGED
@@ -6,27 +6,43 @@
6
 
7
  extern "C" {
8
 
9
- void ptd_forward_launch(
10
- const float* tris, const int* mat_ids, const float* uvs, int n_faces,
11
- const float* nodes_f, const int* nodes_i, int n_nodes,
12
- const int* light_faces, const float* light_cdf, int n_lights,
13
- float total_light_area,
14
- const float* tex, const int* tex_hdr, int n_texels,
15
- const float* emission, int n_mats,
16
- const float* cam,
17
- int H, int W, int spp, int max_bounces, int nee,
18
- long long seed, float* image, cudaStream_t stream);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
- void ptd_backward_launch(
21
- const float* tris, const int* mat_ids, const float* uvs, int n_faces,
22
- const float* nodes_f, const int* nodes_i, int n_nodes,
23
- const int* light_faces, const float* light_cdf, int n_lights,
24
- float total_light_area,
25
- const float* tex, const int* tex_hdr, int n_texels,
26
- const float* emission, int n_mats,
27
- const float* cam,
28
- int H, int W, int spp, int max_bounces, int nee,
29
- long long seed, const float* grad_image,
30
- float* grad_tex, float* grad_emission, cudaStream_t stream);
31
 
32
  } // extern "C"
 
6
 
7
  extern "C" {
8
 
9
+ // All device pointers; ints/floats are host scalars. env* may be null/0
10
+ // when the scene has no environment map.
11
+ struct PtdSceneArgs {
12
+ const float* tris; // [F, 9]
13
+ const int* mat_ids; // [F]
14
+ const float* uvs; // [F, 3, 2]
15
+ int n_faces;
16
+ const float* nodes_f; // [N, 6]
17
+ const int* nodes_i; // [N, 3]
18
+ int n_nodes;
19
+ const int* light_faces; // [L]
20
+ const float* light_cdf; // [L]
21
+ int n_lights;
22
+ float total_light_area;
23
+ const float* tex; // [T, 3]
24
+ const int* tex_hdr; // [M, 3]
25
+ int n_texels;
26
+ const float* emission; // [M, 3]
27
+ const int* mat_type; // [M] 0 diffuse | 1 conductor | 2 dielectric
28
+ const float* mat_rough; // [M]
29
+ const float* mat_ior; // [M]
30
+ int n_mats;
31
+ const float* env; // [Eh*Ew, 3] or null
32
+ int env_w, env_h;
33
+ const float* env_cdf_m; // [Eh]
34
+ const float* env_cdf_c; // [Eh*Ew]
35
+ const float* env_pdf; // [Eh*Ew], sums to 1
36
+ };
37
 
38
+ void ptd_forward_launch(const PtdSceneArgs* args, const float* cam, int H,
39
+ int W, int spp, int max_bounces, int mode,
40
+ long long seed, float* image, cudaStream_t stream);
41
+
42
+ void ptd_backward_launch(const PtdSceneArgs* args, const float* cam, int H,
43
+ int W, int spp, int max_bounces, int mode,
44
+ long long seed, const float* grad_image,
45
+ float* grad_tex, float* grad_emission,
46
+ float* grad_env, cudaStream_t stream);
 
 
47
 
48
  } // extern "C"
tests/test_pathtracer_diff.py CHANGED
@@ -353,6 +353,208 @@ def test_texture_inverse_recovery():
353
  assert err.mean().item() < 0.08, err.mean().item()
354
 
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  @requires_cuda
357
  @pytest.mark.kernels_ci
358
  def test_validation():
@@ -366,6 +568,16 @@ def test_validation():
366
  cam = ptd.Camera(position=(0.5, 0.5, 0.5), look_at=(0.5, 0.5, 1.0))
367
  with pytest.raises(ValueError):
368
  ptd.render(scene, cam, 8, 8, spp=1, max_bounces=17)
 
 
 
 
 
 
 
 
 
 
369
  img = ptd.render(scene, cam, 8, 8, spp=2, max_bounces=2)
370
  assert img.shape == (8, 8, 3) and img.dtype == torch.float32
371
  assert torch.isfinite(img).all()
 
353
  assert err.mean().item() < 0.08, err.mean().item()
354
 
355
 
356
+ @requires_cuda
357
+ @pytest.mark.kernels_ci
358
+ def test_estimators_agree_three_ways():
359
+ """BRDF-only, NEE-only, and MIS target the same integral at the same
360
+ bounce budget; their image means must agree within Monte Carlo
361
+ tolerance."""
362
+ scene = cornell(with_box=True)
363
+ cam = cornell_camera()
364
+ m_mis = ptd.render(scene, cam, 64, 64, spp=64, max_bounces=4,
365
+ estimator="mis", seed=1).mean().item()
366
+ m_nee = ptd.render(scene, cam, 64, 64, spp=64, max_bounces=4,
367
+ estimator="nee", seed=2).mean().item()
368
+ m_brdf = ptd.render(scene, cam, 64, 64, spp=1024, max_bounces=4,
369
+ estimator="brdf", seed=3).mean().item()
370
+ assert abs(m_mis - m_nee) / m_nee < 0.03, (m_mis, m_nee)
371
+ assert abs(m_mis - m_brdf) / m_brdf < 0.05, (m_mis, m_brdf)
372
+
373
+
374
+ @requires_cuda
375
+ @pytest.mark.kernels_ci
376
+ def test_conductor_under_env_estimators_agree():
377
+ """A GGX conductor under a constant environment: MIS and BRDF-only
378
+ means must agree, which jointly validates the VNDF weight, the GGX pdf,
379
+ and the environment pdf."""
380
+ verts, faces, mat = [], [], []
381
+
382
+ def quad_w(a, b, c, d, m):
383
+ base = len(verts)
384
+ verts.extend([a, b, c, d])
385
+ faces.extend(quad(base, base + 1, base + 2, base + 3))
386
+ mat.extend([m, m])
387
+
388
+ quad_w((-4, 0, -4), (4, 0, -4), (4, 0, 4), (-4, 0, 4), 0) # floor
389
+ add_box(verts, faces, mat, (-1, 0, -1), (1, 2, 1), 1) # metal box
390
+ env = torch.full((8, 16, 3), 1.0)
391
+ scene = ptd.Scene(verts, faces, mat,
392
+ albedo=[[0.5, 0.5, 0.5], [0.9, 0.6, 0.3]],
393
+ emission=[[0, 0, 0], [0, 0, 0]],
394
+ material_types=[ptd.DIFFUSE, ptd.CONDUCTOR],
395
+ roughness=[0.3, 0.25], env=env)
396
+ cam = ptd.Camera(position=(5, 3, 5), look_at=(0, 1, 0), vfov_deg=45.0)
397
+ m_mis = ptd.render(scene, cam, 64, 64, spp=64, max_bounces=4,
398
+ estimator="mis", seed=1).mean().item()
399
+ m_brdf = ptd.render(scene, cam, 64, 64, spp=512, max_bounces=4,
400
+ estimator="brdf", seed=2).mean().item()
401
+ assert abs(m_mis - m_brdf) / m_brdf < 0.04, (m_mis, m_brdf)
402
+
403
+
404
+ @requires_cuda
405
+ @pytest.mark.kernels_ci
406
+ def test_fresnel_slab_analytic():
407
+ """A smooth glass slab in front of a uniform emitter, viewed at normal
408
+ incidence: the transmitted fraction sums the internal-bounce series to
409
+ (1-R)/(1+R) with R = ((n-1)/(n+1))^2."""
410
+ verts, faces, mat = [], [], []
411
+
412
+ def quad_w(a, b, c, d, m):
413
+ base = len(verts)
414
+ verts.extend([a, b, c, d])
415
+ faces.extend(quad(base, base + 1, base + 2, base + 3))
416
+ mat.extend([m, m])
417
+
418
+ quad_w((-10, -10, 5), (10, -10, 5), (10, 10, 5), (-10, 10, 5), 0) # emitter
419
+ add_box(verts, faces, mat, (-10, -10, 2.0), (10, 10, 2.2), 1) # slab
420
+ n_ior = 1.5
421
+ scene = ptd.Scene(verts, faces, mat,
422
+ albedo=[[0, 0, 0], [0, 0, 0]],
423
+ emission=[[1.0, 1.0, 1.0], [0, 0, 0]],
424
+ material_types=[ptd.DIFFUSE, ptd.DIELECTRIC],
425
+ ior=[1.5, n_ior])
426
+ cam = ptd.Camera(position=(0, 0, 0), look_at=(0, 0, 1), vfov_deg=10.0)
427
+ img = ptd.render(scene, cam, 32, 32, spp=256, max_bounces=10,
428
+ estimator="brdf", seed=5)
429
+ R = ((n_ior - 1) / (n_ior + 1)) ** 2
430
+ expected = (1 - R) / (1 + R)
431
+ got = img[12:20, 12:20].mean().item()
432
+ assert abs(got - expected) / expected < 0.01, (got, expected)
433
+
434
+
435
+ @requires_cuda
436
+ @pytest.mark.kernels_ci
437
+ def test_env_furnace_diffuse():
438
+ """A diffuse plane under a constant environment c: with BRDF sampling
439
+ every sample returns exactly albedo * c (cosine pdf cancellation plus a
440
+ guaranteed env hit), and MIS agrees in expectation."""
441
+ verts, faces, mat = [], [], []
442
+ base = len(verts)
443
+ verts.extend([(-5, 0, -5), (5, 0, -5), (5, 0, 5), (-5, 0, 5)])
444
+ faces.extend(quad(base, base + 1, base + 2, base + 3))
445
+ mat.extend([0, 0])
446
+ c = 0.8
447
+ a = 0.6
448
+ env = torch.full((8, 16, 3), c)
449
+ scene = ptd.Scene(verts, faces, mat, albedo=[[a] * 3],
450
+ emission=[[0, 0, 0]], env=env)
451
+ cam = ptd.Camera(position=(0, 2.0, 0.01), look_at=(0, 0, 0.2),
452
+ vfov_deg=30.0)
453
+ img = ptd.render(scene, cam, 32, 32, spp=4, max_bounces=3,
454
+ estimator="brdf", seed=0)
455
+ assert (img - a * c).abs().max().item() < 2e-3, \
456
+ (img.min().item(), img.max().item(), a * c)
457
+ m_mis = ptd.render(scene, cam, 32, 32, spp=256, max_bounces=3,
458
+ estimator="mis", seed=1).mean().item()
459
+ assert abs(m_mis - a * c) / (a * c) < 0.02, (m_mis, a * c)
460
+
461
+
462
+ @requires_cuda
463
+ @pytest.mark.kernels_ci
464
+ def test_env_texel_gradients_match_fd():
465
+ """Environment texels are linear in the estimate and the sampling CDF is
466
+ detached, so same-seed central differences are exact."""
467
+ torch.manual_seed(3)
468
+ env0 = torch.rand(4, 8, 3, device="cuda") * 1.5 + 0.2
469
+ verts, faces, mat = [], [], []
470
+ base = len(verts)
471
+ verts.extend([(-5, 0, -5), (5, 0, -5), (5, 0, 5), (-5, 0, 5)])
472
+ faces.extend(quad(base, base + 1, base + 2, base + 3))
473
+ mat.extend([0, 0])
474
+ cam = ptd.Camera(position=(0, 2.0, 0.01), look_at=(0, 0, 0.5),
475
+ vfov_deg=40.0)
476
+ torch.manual_seed(0)
477
+ Wr = torch.rand(32, 32, 3, device="cuda")
478
+ scene = ptd.Scene(verts, faces, mat, albedo=[[0.6] * 3],
479
+ emission=[[0, 0, 0]], env=env0)
480
+
481
+ def loss_of(e):
482
+ # swap texel values under the construction-time (detached) CDF, the
483
+ # frozen-sampling semantics the kernel defines
484
+ scene.env = e.to("cuda")
485
+ img = ptd.render(scene, cam, 32, 32, spp=8, max_bounces=3, seed=11)
486
+ return (img * Wr).sum()
487
+
488
+ e = env0.clone().requires_grad_(True)
489
+ loss_of(e).backward()
490
+ worst = 0.0
491
+ for (y, x, c) in [(0, 0, 0), (1, 4, 1), (1, 7, 2)]: # upper hemisphere
492
+ h = 5e-3
493
+ ep = env0.clone(); ep[y, x, c] += h
494
+ em = env0.clone(); em[y, x, c] -= h
495
+ fd = (loss_of(ep) - loss_of(em)).item() / (2 * h)
496
+ an = e.grad[y, x, c].item()
497
+ assert fd != 0.0, (y, x, c)
498
+ worst = max(worst, abs(an - fd) / abs(fd))
499
+ assert worst < 2e-2, worst
500
+ # a texel no path can see gets exactly zero from both sides
501
+ assert e.grad[3, 0, 0].item() == 0.0
502
+
503
+
504
+ @requires_cuda
505
+ @pytest.mark.kernels_ci
506
+ def test_conductor_albedo_gradients_match_fd():
507
+ """Conductor F0 enters every term through Schlick Fresnel, which is
508
+ affine in F0, so replay gradients must match same-seed central
509
+ differences through GGX sampling and MIS."""
510
+ cam = cornell_camera()
511
+ H = W = 48
512
+ torch.manual_seed(0)
513
+ Wr = torch.rand(H, W, 3, device="cuda")
514
+ base_alb = torch.tensor([[0.73, 0.73, 0.73], [0.9, 0.5, 0.2],
515
+ [0.12, 0.45, 0.15], [0.78, 0.78, 0.78]],
516
+ device="cuda")
517
+
518
+ def loss_of(alb):
519
+ verts, faces, mat = [], [], []
520
+
521
+ def wall(a, b, c, d, m):
522
+ b0 = len(verts)
523
+ verts.extend([a, b, c, d])
524
+ faces.extend(quad(b0, b0 + 1, b0 + 2, b0 + 3))
525
+ mat.extend([m, m])
526
+
527
+ X, Y, Z = 5.56, 5.488, 5.592
528
+ wall((0, 0, 0), (X, 0, 0), (X, 0, Z), (0, 0, Z), 0)
529
+ wall((0, Y, 0), (0, Y, Z), (X, Y, Z), (X, Y, 0), 0)
530
+ wall((0, 0, Z), (X, 0, Z), (X, Y, Z), (0, Y, Z), 0)
531
+ wall((0, 0, 0), (0, 0, Z), (0, Y, Z), (0, Y, 0), 1) # conductor
532
+ wall((X, 0, 0), (X, Y, 0), (X, Y, Z), (X, 0, Z), 2)
533
+ wall((2.13, Y - 0.001, 2.27), (3.43, Y - 0.001, 2.27),
534
+ (3.43, Y - 0.001, 3.32), (2.13, Y - 0.001, 3.32), 3)
535
+ scene = ptd.Scene(verts, faces, mat, albedo=alb,
536
+ emission=[[0, 0, 0], [0, 0, 0], [0, 0, 0],
537
+ [18.4, 15.6, 8.0]],
538
+ material_types=[ptd.DIFFUSE, ptd.CONDUCTOR,
539
+ ptd.DIFFUSE, ptd.DIFFUSE],
540
+ roughness=[0.3, 0.2, 0.3, 0.3])
541
+ img = ptd.render(scene, cam, H, W, spp=8, max_bounces=3, seed=13)
542
+ return (img * Wr).sum()
543
+
544
+ alb = base_alb.clone().requires_grad_(True)
545
+ loss_of(alb).backward()
546
+ worst = 0.0
547
+ for (i, c) in [(1, 0), (1, 2), (0, 1)]:
548
+ h = 2e-3
549
+ ap = base_alb.clone(); ap[i, c] += h
550
+ am = base_alb.clone(); am[i, c] -= h
551
+ fd = (loss_of(ap) - loss_of(am)).item() / (2 * h)
552
+ an = alb.grad[i, c].item()
553
+ assert fd != 0.0, (i, c)
554
+ worst = max(worst, abs(an - fd) / abs(fd))
555
+ assert worst < 2e-2, worst
556
+
557
+
558
  @requires_cuda
559
  @pytest.mark.kernels_ci
560
  def test_validation():
 
568
  cam = ptd.Camera(position=(0.5, 0.5, 0.5), look_at=(0.5, 0.5, 1.0))
569
  with pytest.raises(ValueError):
570
  ptd.render(scene, cam, 8, 8, spp=1, max_bounces=17)
571
+ with pytest.raises(ValueError):
572
+ ptd.render(scene, cam, 8, 8, spp=1, estimator="mis", nee=True)
573
+ with pytest.raises(ValueError):
574
+ ptd.Scene(verts, faces, mat, albedo=[[0.5] * 3],
575
+ emission=[[1.0] * 3], material_types=[7])
576
+ env = torch.full((4, 8, 3), 1.0)
577
+ scene_env = ptd.Scene(verts, faces, mat, albedo=[[0.5] * 3],
578
+ emission=[[1.0] * 3], env=env)
579
+ with pytest.raises(ValueError):
580
+ ptd.render(scene_env, cam, 8, 8, spp=1, estimator="nee")
581
  img = ptd.render(scene, cam, 8, 8, spp=2, max_bounces=2)
582
  assert img.shape == (8, 8, 3) and img.dtype == torch.float32
583
  assert torch.isfinite(img).all()
torch-ext/pathtracer_diff/__init__.py CHANGED
@@ -1,28 +1,32 @@
1
  """pathtracer-diff: a differentiable Monte Carlo path tracer as one kernel.
2
 
3
- Forward renders a linear-radiance image with a megakernel path tracer
4
- (diffuse + emissive materials, per-texel albedo textures with bilinear
5
- filtering, cosine-weighted BRDF sampling, optional next-event estimation,
6
- BVH); one thread owns one pixel, so the image is bitwise deterministic.
7
- Backward replays the identical paths with the identical counter-based
8
- Philox draws and accumulates analytic gradients to the albedo texels and
9
- the per-material emission, scattering each bounce's gradient into the four
10
- texels of its bilinear footprint with the sampling weights (the exact
11
- adjoint of the fetch). Sampling directions never depend on the
12
- differentiable parameters, so the replay is exact. No visibility or
13
- silhouette gradients: geometry is not a differentiable input.
 
 
 
 
 
 
14
 
15
  from kernels import get_kernel
16
  ptd = get_kernel("phanerozoic/pathtracer-diff", version=1, trust_remote_code=True)
17
 
18
- scene = ptd.Scene(vertices, faces, material_ids, albedo, emission, uvs=uvs)
 
 
19
  cam = ptd.Camera(position=(2.78, 2.73, -8.0), look_at=(2.78, 2.73, 2.8))
20
- img = ptd.render(scene, cam, 256, 256, spp=64) # autograd to albedo/emission
21
- img.sum().backward()
22
-
23
- `albedo` is either an [M, 3] tensor of per-material constants or a list of
24
- M per-material textures [Hm, Wm, 3]; constants are treated as 1x1 textures,
25
- so the two forms share one code path and both receive gradients.
26
  """
27
  import math
28
 
@@ -32,15 +36,172 @@ from ._ops import ops
32
 
33
  MAX_MATERIALS = 64
34
  MAX_BOUNCES = 16
35
-
36
- __all__ = ["Scene", "Camera", "render", "ops", "MAX_MATERIALS", "MAX_BOUNCES"]
37
-
38
-
39
- def _build_bvh(tris, leaf_size=4):
40
- """Median-split BVH over [F, 9] triangles (pure torch, CPU). Returns
41
- (nodes_f [N, 6], nodes_i [N, 3], order [F]) with leaves as contiguous
42
- ranges of the reordered face list. nodes_i rows are (left, right, 0) or
43
- (start, count, 1)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  tris = tris.to(torch.float32)
45
  F = tris.shape[0]
46
  v = tris.reshape(F, 3, 3)
@@ -50,35 +211,103 @@ def _build_bvh(tris, leaf_size=4):
50
 
51
  nodes_f, nodes_i, order = [], [], []
52
 
53
- def rec(idx):
 
 
 
 
 
 
54
  nid = len(nodes_f)
55
  nodes_f.append(None)
56
  nodes_i.append(None)
 
 
 
 
 
 
 
57
  blo = lo[idx].amin(dim=0)
58
  bhi = hi[idx].amax(dim=0)
59
  pad = 1e-5 * (1.0 + blo.abs() + bhi.abs())
60
  nodes_f[nid] = torch.cat([blo - pad, bhi + pad])
61
- if idx.numel() <= leaf_size:
62
  start = len(order)
63
  order.extend(idx.tolist())
64
- nodes_i[nid] = (start, idx.numel(), 1)
65
- return nid
66
- ax = int((bhi - blo).argmax())
67
- srt = idx[torch.argsort(cen[idx, ax], stable=True)]
68
- mid = idx.numel() // 2
69
- left = rec(srt[:mid])
70
- right = rec(srt[mid:])
71
- nodes_i[nid] = (left, right, 0)
72
- return nid
73
-
74
- import sys
75
-
76
- old = sys.getrecursionlimit()
77
- sys.setrecursionlimit(max(old, 64 + 2 * int(math.log2(max(F, 2))) * 64))
78
- try:
79
- rec(torch.arange(F))
80
- finally:
81
- sys.setrecursionlimit(old)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  return (torch.stack(nodes_f),
83
  torch.tensor(nodes_i, dtype=torch.int32),
84
  torch.tensor(order, dtype=torch.int64))
@@ -118,18 +347,22 @@ def _as_texture(t, device):
118
 
119
 
120
  class Scene:
121
- """Triangle-mesh scene with per-texel albedo and per-material emission.
122
-
123
- vertices [V, 3] float, faces [F, 3] int, material_ids [F] int.
124
- albedo: [M, 3] tensor of per-material constants (treated as 1x1
125
- textures) or a list of M textures [Hm, Wm, 3]; either form may require
126
- grad. emission [M, 3] float32 (may require grad). uvs: None (all zeros),
127
- [V, 2] per-vertex, or [F, 3, 2] per corner; textures wrap (repeat).
128
- Texture shapes are fixed at construction; faces are reordered by the
129
- BVH build, and emissive faces become the area-sampled light list."""
 
 
 
130
 
131
  def __init__(self, vertices, faces, material_ids, albedo, emission,
132
- uvs=None, device="cuda"):
 
133
  vertices = torch.as_tensor(vertices, dtype=torch.float32)
134
  faces = torch.as_tensor(faces, dtype=torch.int64)
135
  material_ids = torch.as_tensor(material_ids, dtype=torch.int64)
@@ -160,6 +393,20 @@ class Scene:
160
  if int(material_ids.max()) >= M or int(material_ids.min()) < 0:
161
  raise ValueError("material_ids out of range")
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  shapes = ([(1, 1)] * M if self.albedo is not None else
164
  [(int(t.shape[0]), int(t.shape[1]))
165
  for t in self.albedo_textures])
@@ -201,6 +448,35 @@ class Scene:
201
  total = 0.0
202
  cdf = torch.zeros(0, dtype=torch.float32)
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  self.device = device
205
  self.tris = tris.to(device).contiguous()
206
  self.mat_ids = mat_ids.to(device).contiguous()
@@ -217,11 +493,8 @@ class Scene:
217
  return self.tris.shape[0]
218
 
219
  def _flat_albedo(self):
220
- """Pack the albedo into the flat [T, 3] texel buffer inside the
221
- autograd graph, so texel gradients flow back to the caller's
222
- tensors. Texture shapes must match construction."""
223
  if self.albedo is not None:
224
- if self.albedo.shape[0] * 1 != self.tex_hdr.shape[0]:
225
  raise ValueError("albedo/material count changed")
226
  return self.albedo.reshape(-1, 3)
227
  parts = []
@@ -232,52 +505,78 @@ class Scene:
232
  parts.append(t.reshape(-1, 3))
233
  return torch.cat(parts, dim=0)
234
 
 
 
 
 
 
 
 
 
235
 
236
  class _RenderFn(torch.autograd.Function):
237
  @staticmethod
238
- def forward(ctx, tex_flat, emission, scene, cam_t, H, W, spp, max_bounces,
239
- nee, seed):
240
  image = torch.empty(H, W, 3, device=tex_flat.device,
241
  dtype=torch.float32)
242
  ops.pt_forward(scene.tris, scene.mat_ids, scene.uvs, scene.nodes_f,
243
  scene.nodes_i, scene.light_faces, scene.light_cdf,
244
  float(scene.total_light_area),
245
  tex_flat.detach().contiguous(), scene.tex_hdr,
246
- emission.detach().contiguous(), cam_t, spp,
247
- max_bounces, nee, seed, image)
 
 
 
 
248
  ctx.scene = scene
249
  ctx.cam_t = cam_t
250
- ctx.params = (H, W, spp, max_bounces, nee, seed)
251
- ctx.save_for_backward(tex_flat, emission)
252
  return image
253
 
254
  @staticmethod
255
  def backward(ctx, grad_image):
256
- tex_flat, emission = ctx.saved_tensors
257
  scene, cam_t = ctx.scene, ctx.cam_t
258
- H, W, spp, max_bounces, nee, seed = ctx.params
259
  ga = torch.zeros_like(tex_flat)
260
  ge = torch.zeros_like(emission)
 
261
  ops.pt_backward(scene.tris, scene.mat_ids, scene.uvs, scene.nodes_f,
262
  scene.nodes_i, scene.light_faces, scene.light_cdf,
263
  float(scene.total_light_area),
264
  tex_flat.detach().contiguous(), scene.tex_hdr,
265
- emission.detach().contiguous(), cam_t, spp,
266
- max_bounces, nee, seed, grad_image.contiguous(), ga,
267
- ge)
268
- return ga, ge, None, None, None, None, None, None, None, None
 
 
 
269
 
270
 
271
- def render(scene, camera, height, width, spp=64, max_bounces=4, nee=True,
272
- seed=0):
273
  """Render a linear-radiance image [H, W, 3] float32, differentiable with
274
- respect to the scene's albedo (constants or texels) and emission. A fixed
275
- seed makes the estimate a deterministic function of the parameters
276
- (correlated samples), which is what inverse-rendering loops want."""
 
 
277
  if not (1 <= max_bounces <= MAX_BOUNCES):
278
  raise ValueError(f"max_bounces must be in [1, {MAX_BOUNCES}]")
 
 
 
 
 
 
 
 
279
  cam_t = camera.tensor(height, width, device=scene.device)
280
- tex_flat = scene._flat_albedo()
281
- return _RenderFn.apply(tex_flat, scene.emission, scene, cam_t, height,
282
- width, int(spp), int(max_bounces), bool(nee),
283
  int(seed))
 
1
  """pathtracer-diff: a differentiable Monte Carlo path tracer as one kernel.
2
 
3
+ Forward renders a linear-radiance image with a megakernel path tracer:
4
+ Lambertian diffuse, GGX conductor (VNDF-sampled, height-correlated Smith),
5
+ and smooth dielectric materials; area lights and an importance-sampled
6
+ equirectangular environment map; multiple importance sampling by default;
7
+ per-texel albedo with bilinear filtering; binned-SAH BVH. One thread owns
8
+ one pixel, so the image is bitwise deterministic.
9
+
10
+ Backward is exact path replay: sampling distributions depend only on
11
+ geometry, frozen material parameters (roughness, ior), the environment CDF
12
+ (detached at Scene construction), and the counter-based Philox stream,
13
+ never on the differentiable parameters. Every radiance term is a product
14
+ of per-bounce factors that are affine in their vertex's albedo texels
15
+ (Schlick Fresnel is affine in F0), times a linear emission or environment
16
+ texel, so the replay differentiates in closed form and scatters through
17
+ the exact bilinear-footprint adjoint. Gradients flow to albedo texels,
18
+ per-material emission, and environment texels. No visibility/silhouette
19
+ gradients: geometry, camera, roughness, and ior are frozen.
20
 
21
  from kernels import get_kernel
22
  ptd = get_kernel("phanerozoic/pathtracer-diff", version=1, trust_remote_code=True)
23
 
24
+ scene = ptd.Scene(vertices, faces, material_ids, albedo, emission,
25
+ uvs=uvs, material_types=types, roughness=rough,
26
+ ior=ior, env=env_map)
27
  cam = ptd.Camera(position=(2.78, 2.73, -8.0), look_at=(2.78, 2.73, 2.8))
28
+ img = ptd.render(scene, cam, 512, 512, spp=64)
29
+ img.sum().backward() # grads on albedo texels / emission / env texels
 
 
 
 
30
  """
31
  import math
32
 
 
36
 
37
  MAX_MATERIALS = 64
38
  MAX_BOUNCES = 16
39
+ DIFFUSE, CONDUCTOR, DIELECTRIC = 0, 1, 2
40
+ _MODES = {"brdf": 0, "nee": 1, "mis": 2}
41
+
42
+ __all__ = ["Scene", "Camera", "render", "ops", "MAX_MATERIALS", "MAX_BOUNCES",
43
+ "DIFFUSE", "CONDUCTOR", "DIELECTRIC"]
44
+
45
+
46
+ def _build_bvh(tris, leaf_size=4, sah_bins=16):
47
+ """Binned-SAH BVH over [F, 9] triangles, built level-wise with fully
48
+ vectorized torch ops (segmented reductions + stable-sort partitioning),
49
+ so million-triangle scenes build in seconds. Returns (nodes_f [N, 6],
50
+ nodes_i [N, 3], order [F]). nodes_i rows are (left, right, axis << 1)
51
+ for internal nodes and (start, count, 1) for leaves; internal children
52
+ are the (lower, upper) coordinate halves along the split axis, so
53
+ traversal can visit the near child first."""
54
+ dev = "cuda" if torch.cuda.is_available() else "cpu"
55
+ tris = tris.to(torch.float32).to(dev)
56
+ F = tris.shape[0]
57
+ v = tris.reshape(F, 3, 3)
58
+ tlo = v.amin(dim=1)
59
+ thi = v.amax(dim=1)
60
+ cen = (tlo + thi) * 0.5
61
+
62
+ perm = torch.arange(F, device=dev)
63
+ # active segments: contiguous [start, end) ranges of perm, one per node
64
+ starts = torch.zeros(1, dtype=torch.long, device=dev)
65
+ ends = torch.full((1,), F, dtype=torch.long, device=dev)
66
+ ids = torch.zeros(1, dtype=torch.long, device=dev)
67
+
68
+ INF = float("inf")
69
+ nf_parts, ni_parts = [], [] # per-node rows appended in id order
70
+ n_nodes = 1
71
+
72
+ def half_area(lo, hi):
73
+ d = (hi - lo).clamp(min=0)
74
+ return d[..., 0] * d[..., 1] + d[..., 1] * d[..., 2] + \
75
+ d[..., 2] * d[..., 0]
76
+
77
+ while ids.numel() > 0:
78
+ A = ids.numel()
79
+ seg_len = ends - starts
80
+ total = int(seg_len.sum())
81
+ # active positions and per-position segment index
82
+ csum = torch.cumsum(seg_len, 0) - seg_len
83
+ pos = starts.repeat_interleave(seg_len) + \
84
+ (torch.arange(total, device=dev) -
85
+ csum.repeat_interleave(seg_len))
86
+ s = torch.arange(A, device=dev).repeat_interleave(seg_len)
87
+ tri = perm[pos]
88
+
89
+ # per-segment node bounds and centroid bounds
90
+ nlo = torch.full((A, 3), INF, device=dev)
91
+ nhi = torch.full((A, 3), -INF, device=dev)
92
+ clo = torch.full((A, 3), INF, device=dev)
93
+ chi = torch.full((A, 3), -INF, device=dev)
94
+ s3 = s.unsqueeze(1).expand(-1, 3)
95
+ nlo.scatter_reduce_(0, s3, tlo[tri], reduce="amin", include_self=True)
96
+ nhi.scatter_reduce_(0, s3, thi[tri], reduce="amax", include_self=True)
97
+ clo.scatter_reduce_(0, s3, cen[tri], reduce="amin", include_self=True)
98
+ chi.scatter_reduce_(0, s3, cen[tri], reduce="amax", include_self=True)
99
+ pad = 1e-5 * (1.0 + nlo.abs() + nhi.abs())
100
+ bounds = torch.cat([nlo - pad, nhi + pad], dim=1) # [A, 6]
101
+
102
+ leaf_mask = seg_len <= leaf_size
103
+ split_mask = ~leaf_mask
104
+
105
+ # choose axis (largest centroid extent) and bin the centroids
106
+ ext = (chi - clo).clamp(min=0)
107
+ axis = ext.argmax(dim=1) # [A]
108
+ ax_pos = axis[s]
109
+ coord = cen[tri].gather(1, ax_pos.unsqueeze(1)).squeeze(1)
110
+ seg_lo = clo.gather(1, axis.unsqueeze(1)).squeeze(1)[s]
111
+ seg_ext = ext.gather(1, axis.unsqueeze(1)).squeeze(1)[s]
112
+ t = (coord - seg_lo) / seg_ext.clamp(min=1e-30)
113
+ bin_i = (t * sah_bins).long().clamp(0, sah_bins - 1)
114
+ degen = seg_ext <= 1e-12 # all centroids equal on the axis
115
+
116
+ # per-(segment, bin) counts and bounds
117
+ key = s * sah_bins + bin_i
118
+ counts = torch.bincount(key, minlength=A * sah_bins) \
119
+ .reshape(A, sah_bins)
120
+ blo = torch.full((A * sah_bins, 3), INF, device=dev)
121
+ bhi = torch.full((A * sah_bins, 3), -INF, device=dev)
122
+ k3 = key.unsqueeze(1).expand(-1, 3)
123
+ blo.scatter_reduce_(0, k3, tlo[tri], reduce="amin", include_self=True)
124
+ bhi.scatter_reduce_(0, k3, thi[tri], reduce="amax", include_self=True)
125
+ blo = blo.reshape(A, sah_bins, 3)
126
+ bhi = bhi.reshape(A, sah_bins, 3)
127
+
128
+ # prefix (left) and suffix (right) running bounds and counts
129
+ plo = torch.cummin(blo, dim=1).values
130
+ phi = torch.cummax(bhi, dim=1).values
131
+ pn = torch.cumsum(counts, dim=1)
132
+ slo = torch.flip(torch.cummin(torch.flip(blo, [1]), dim=1).values, [1])
133
+ shi = torch.flip(torch.cummax(torch.flip(bhi, [1]), dim=1).values, [1])
134
+ sn = torch.flip(torch.cumsum(torch.flip(counts, [1]), dim=1), [1])
135
+
136
+ # SAH cost of splitting after bin b (b = 0..bins-2)
137
+ nl = pn[:, :-1].float()
138
+ nr = sn[:, 1:].float()
139
+ cost = half_area(plo[:, :-1], phi[:, :-1]) * nl + \
140
+ half_area(slo[:, 1:], shi[:, 1:]) * nr
141
+ cost = torch.where((nl > 0) & (nr > 0), cost,
142
+ torch.full_like(cost, INF))
143
+ best_cost, best_bin = cost.min(dim=1)
144
+ sah_ok = torch.isfinite(best_cost) & ~degen[csum] # per segment
145
+
146
+ # side: left if bin <= best_bin (SAH) else lower positional half
147
+ # (median fallback for degenerate segments)
148
+ side_left = bin_i <= best_bin[s]
149
+ within = pos - starts[s]
150
+ med_left = within < (seg_len[s] // 2)
151
+ use_sah = sah_ok[s]
152
+ side_left = torch.where(use_sah, side_left, med_left)
153
+ side_left = side_left & split_mask[s]
154
+
155
+ # per-segment left count; guard SAH splits that put everything on
156
+ # one side (possible only via numerics) with the median fallback
157
+ nl_seg = torch.zeros(A, dtype=torch.long, device=dev)
158
+ nl_seg.scatter_add_(0, s, side_left.long())
159
+ bad = split_mask & ((nl_seg == 0) | (nl_seg == seg_len))
160
+ if bool(bad.any()):
161
+ fix = bad[s]
162
+ side_left = torch.where(fix, med_left & split_mask[s], side_left)
163
+ nl_seg = torch.zeros(A, dtype=torch.long, device=dev)
164
+ nl_seg.scatter_add_(0, s, side_left.long())
165
+
166
+ # partition each segment in place: stable sort by (segment, right?)
167
+ k2 = s * 2 + (~side_left).long()
168
+ order2 = torch.argsort(k2, stable=True)
169
+ perm[pos] = tri[order2]
170
+
171
+ # allocate children for splitting segments, emit node rows
172
+ n_split = int(split_mask.sum())
173
+ child_rank = torch.cumsum(split_mask.long(), 0) - 1
174
+ left_ids = n_nodes + 2 * child_rank
175
+ right_ids = left_ids + 1
176
+
177
+ ni = torch.empty(A, 3, dtype=torch.int64, device=dev)
178
+ ni[:, 0] = torch.where(split_mask, left_ids, starts)
179
+ ni[:, 1] = torch.where(split_mask, right_ids, seg_len)
180
+ ni[:, 2] = torch.where(split_mask, axis * 2,
181
+ torch.ones_like(axis))
182
+ nf_parts.append((ids, bounds))
183
+ ni_parts.append((ids, ni))
184
+
185
+ # next level
186
+ mid = starts + nl_seg
187
+ new_starts = torch.cat([starts[split_mask], mid[split_mask]])
188
+ new_ends = torch.cat([mid[split_mask], ends[split_mask]])
189
+ new_ids = torch.cat([left_ids[split_mask], right_ids[split_mask]])
190
+ n_nodes += 2 * n_split
191
+ starts, ends, ids = new_starts, new_ends, new_ids
192
+
193
+ nodes_f = torch.empty(n_nodes, 6, device=dev)
194
+ nodes_i = torch.empty(n_nodes, 3, dtype=torch.int64, device=dev)
195
+ for idv, rows in nf_parts:
196
+ nodes_f[idv] = rows
197
+ for idv, rows in ni_parts:
198
+ nodes_i[idv] = rows
199
+ return (nodes_f.cpu(), nodes_i.to(torch.int32).cpu(), perm.cpu())
200
+
201
+
202
+ def _build_bvh_reference(tris, leaf_size=4, force_split=8, sah_bins=16):
203
+ """Recursive reference builder (kept for cross-checking the vectorized
204
+ build in development)."""
205
  tris = tris.to(torch.float32)
206
  F = tris.shape[0]
207
  v = tris.reshape(F, 3, 3)
 
211
 
212
  nodes_f, nodes_i, order = [], [], []
213
 
214
+ def half_area(blo, bhi):
215
+ d = (bhi - blo).clamp(min=0)
216
+ return d[0] * d[1] + d[1] * d[2] + d[2] * d[0]
217
+
218
+ stack = []
219
+
220
+ def alloc(idx):
221
  nid = len(nodes_f)
222
  nodes_f.append(None)
223
  nodes_i.append(None)
224
+ stack.append((nid, idx))
225
+ return nid
226
+
227
+ root = alloc(torch.arange(F))
228
+ while stack:
229
+ nid, idx = stack.pop()
230
+ n = idx.numel()
231
  blo = lo[idx].amin(dim=0)
232
  bhi = hi[idx].amax(dim=0)
233
  pad = 1e-5 * (1.0 + blo.abs() + bhi.abs())
234
  nodes_f[nid] = torch.cat([blo - pad, bhi + pad])
235
+ if n <= leaf_size:
236
  start = len(order)
237
  order.extend(idx.tolist())
238
+ nodes_i[nid] = (start, n, 1)
239
+ continue
240
+
241
+ cb_lo = cen[idx].amin(dim=0)
242
+ cb_hi = cen[idx].amax(dim=0)
243
+ ext = cb_hi - cb_lo
244
+ axis = int(ext.argmax())
245
+ split_pos = None
246
+ if float(ext[axis]) > 1e-12:
247
+ c = cen[idx, axis]
248
+ edges = torch.linspace(float(cb_lo[axis]), float(cb_hi[axis]),
249
+ sah_bins + 1)
250
+ b = torch.bucketize(c, edges[1:-1])
251
+ counts = torch.bincount(b, minlength=sah_bins)
252
+ # per-bin bounds over the node's triangles
253
+ binlo = torch.full((sah_bins, 3), float("inf"))
254
+ binhi = torch.full((sah_bins, 3), float("-inf"))
255
+ binlo.scatter_reduce_(0, b.unsqueeze(1).expand(-1, 3), lo[idx],
256
+ reduce="amin", include_self=True)
257
+ binhi.scatter_reduce_(0, b.unsqueeze(1).expand(-1, 3), hi[idx],
258
+ reduce="amax", include_self=True)
259
+ best_cost, best_bin = None, None
260
+ nl = 0
261
+ llo = torch.full((3,), float("inf"))
262
+ lhi = torch.full((3,), float("-inf"))
263
+ pre = []
264
+ for i in range(sah_bins - 1):
265
+ if counts[i] > 0:
266
+ llo = torch.minimum(llo, binlo[i])
267
+ lhi = torch.maximum(lhi, binhi[i])
268
+ nl += int(counts[i])
269
+ pre.append((nl, llo.clone(), lhi.clone()))
270
+ nr = 0
271
+ rlo = torch.full((3,), float("inf"))
272
+ rhi = torch.full((3,), float("-inf"))
273
+ for i in range(sah_bins - 1, 0, -1):
274
+ if counts[i] > 0:
275
+ rlo = torch.minimum(rlo, binlo[i])
276
+ rhi = torch.maximum(rhi, binhi[i])
277
+ nr += int(counts[i])
278
+ nl_i, llo_i, lhi_i = pre[i - 1]
279
+ if nl_i == 0 or nr == 0:
280
+ continue
281
+ cost = (half_area(llo_i, lhi_i) * nl_i +
282
+ half_area(rlo, rhi) * nr)
283
+ if best_cost is None or float(cost) < best_cost:
284
+ best_cost = float(cost)
285
+ best_bin = i - 1
286
+ if best_bin is not None:
287
+ parent_area = half_area(blo, bhi)
288
+ leaf_cost = float(n) * float(parent_area)
289
+ split_cost = 0.125 * float(parent_area) + best_cost
290
+ if split_cost < leaf_cost or n > force_split:
291
+ mask = b <= best_bin
292
+ left_idx = idx[mask]
293
+ right_idx = idx[~mask]
294
+ if left_idx.numel() > 0 and right_idx.numel() > 0:
295
+ split_pos = (left_idx, right_idx)
296
+ if split_pos is None:
297
+ if n > force_split and float(ext[axis]) > 1e-12:
298
+ srt = idx[torch.argsort(cen[idx, axis], stable=True)]
299
+ mid = n // 2
300
+ split_pos = (srt[:mid], srt[mid:])
301
+ else:
302
+ start = len(order)
303
+ order.extend(idx.tolist())
304
+ nodes_i[nid] = (start, n, 1)
305
+ continue
306
+ left = alloc(split_pos[0])
307
+ right = alloc(split_pos[1])
308
+ nodes_i[nid] = (left, right, axis << 1)
309
+
310
+ assert root == 0
311
  return (torch.stack(nodes_f),
312
  torch.tensor(nodes_i, dtype=torch.int32),
313
  torch.tensor(order, dtype=torch.int64))
 
347
 
348
 
349
  class Scene:
350
+ """Triangle-mesh scene.
351
+
352
+ vertices [V, 3], faces [F, 3], material_ids [F]. albedo: [M, 3]
353
+ constants or a list of M textures [Hm, Wm, 3] (constants are 1x1
354
+ textures; both forms receive gradients). emission [M, 3] (may require
355
+ grad). uvs: None, [V, 2], or [F, 3, 2]; textures wrap. material_types
356
+ [M] of DIFFUSE|CONDUCTOR|DIELECTRIC (default all DIFFUSE); roughness
357
+ [M] GGX alpha for conductors (default 0.3, clamped >= 0.01); ior [M]
358
+ for dielectrics (default 1.5). env: optional [Eh, Ew, 3] equirect
359
+ radiance map (may require grad); its importance-sampling CDF is built
360
+ detached at construction. Texture and env shapes are fixed at
361
+ construction."""
362
 
363
  def __init__(self, vertices, faces, material_ids, albedo, emission,
364
+ uvs=None, material_types=None, roughness=None, ior=None,
365
+ env=None, device="cuda"):
366
  vertices = torch.as_tensor(vertices, dtype=torch.float32)
367
  faces = torch.as_tensor(faces, dtype=torch.int64)
368
  material_ids = torch.as_tensor(material_ids, dtype=torch.int64)
 
393
  if int(material_ids.max()) >= M or int(material_ids.min()) < 0:
394
  raise ValueError("material_ids out of range")
395
 
396
+ def mvec(x, default, dtype):
397
+ if x is None:
398
+ return torch.full((M,), default, dtype=dtype, device=device)
399
+ t = torch.as_tensor(x, dtype=dtype).reshape(-1).to(device)
400
+ if t.numel() != M:
401
+ raise ValueError("per-material array must have M entries")
402
+ return t
403
+
404
+ self.mat_type = mvec(material_types, DIFFUSE, torch.int32).contiguous()
405
+ if int(self.mat_type.max()) > 2 or int(self.mat_type.min()) < 0:
406
+ raise ValueError("material_types must be 0|1|2")
407
+ self.mat_rough = mvec(roughness, 0.3, torch.float32).contiguous()
408
+ self.mat_ior = mvec(ior, 1.5, torch.float32).contiguous()
409
+
410
  shapes = ([(1, 1)] * M if self.albedo is not None else
411
  [(int(t.shape[0]), int(t.shape[1]))
412
  for t in self.albedo_textures])
 
448
  total = 0.0
449
  cdf = torch.zeros(0, dtype=torch.float32)
450
 
451
+ # environment map + detached sampling tables
452
+ if env is not None:
453
+ env = torch.as_tensor(env, dtype=torch.float32)
454
+ if env.dim() != 3 or env.shape[2] != 3 or env.shape[0] < 2 or \
455
+ env.shape[1] < 2:
456
+ raise ValueError("env must be [Eh, Ew, 3]")
457
+ self.env = env.to(device)
458
+ eh, ew = int(env.shape[0]), int(env.shape[1])
459
+ lum = self.env.detach().mean(dim=2).cpu().double() + 1e-8
460
+ sint = torch.sin((torch.arange(eh, dtype=torch.float64) + 0.5)
461
+ * math.pi / eh).clamp(min=1e-4)
462
+ roww = (lum.sum(dim=1) * sint)
463
+ row_p = roww / roww.sum()
464
+ cdf_m = row_p.cumsum(0).float()
465
+ col_p = lum / lum.sum(dim=1, keepdim=True)
466
+ cdf_c = col_p.cumsum(1).float()
467
+ pdf_img = (row_p.unsqueeze(1) * col_p).float() # sums to 1
468
+ self.env_w, self.env_h = ew, eh
469
+ self.env_cdf_m = cdf_m.to(device).contiguous()
470
+ self.env_cdf_c = cdf_c.reshape(-1).to(device).contiguous()
471
+ self.env_pdf = pdf_img.reshape(-1).to(device).contiguous()
472
+ else:
473
+ self.env = None
474
+ self.env_w = self.env_h = 0
475
+ z = torch.zeros(0, dtype=torch.float32, device=device)
476
+ self.env_cdf_m = z
477
+ self.env_cdf_c = z
478
+ self.env_pdf = z
479
+
480
  self.device = device
481
  self.tris = tris.to(device).contiguous()
482
  self.mat_ids = mat_ids.to(device).contiguous()
 
493
  return self.tris.shape[0]
494
 
495
  def _flat_albedo(self):
 
 
 
496
  if self.albedo is not None:
497
+ if self.albedo.shape[0] != self.tex_hdr.shape[0]:
498
  raise ValueError("albedo/material count changed")
499
  return self.albedo.reshape(-1, 3)
500
  parts = []
 
505
  parts.append(t.reshape(-1, 3))
506
  return torch.cat(parts, dim=0)
507
 
508
+ def _flat_env(self):
509
+ if self.env is None:
510
+ return torch.zeros(0, 3, dtype=torch.float32, device=self.device)
511
+ if (int(self.env.shape[0]), int(self.env.shape[1])) != \
512
+ (self.env_h, self.env_w):
513
+ raise ValueError("env shape is fixed at construction")
514
+ return self.env.reshape(-1, 3)
515
+
516
 
517
  class _RenderFn(torch.autograd.Function):
518
  @staticmethod
519
+ def forward(ctx, tex_flat, emission, env_flat, scene, cam_t, H, W, spp,
520
+ max_bounces, mode, seed):
521
  image = torch.empty(H, W, 3, device=tex_flat.device,
522
  dtype=torch.float32)
523
  ops.pt_forward(scene.tris, scene.mat_ids, scene.uvs, scene.nodes_f,
524
  scene.nodes_i, scene.light_faces, scene.light_cdf,
525
  float(scene.total_light_area),
526
  tex_flat.detach().contiguous(), scene.tex_hdr,
527
+ emission.detach().contiguous(), scene.mat_type,
528
+ scene.mat_rough, scene.mat_ior,
529
+ env_flat.detach().contiguous(), scene.env_cdf_m,
530
+ scene.env_cdf_c, scene.env_pdf, scene.env_w,
531
+ scene.env_h, cam_t, spp, max_bounces, mode, seed,
532
+ image)
533
  ctx.scene = scene
534
  ctx.cam_t = cam_t
535
+ ctx.params = (H, W, spp, max_bounces, mode, seed)
536
+ ctx.save_for_backward(tex_flat, emission, env_flat)
537
  return image
538
 
539
  @staticmethod
540
  def backward(ctx, grad_image):
541
+ tex_flat, emission, env_flat = ctx.saved_tensors
542
  scene, cam_t = ctx.scene, ctx.cam_t
543
+ H, W, spp, max_bounces, mode, seed = ctx.params
544
  ga = torch.zeros_like(tex_flat)
545
  ge = torch.zeros_like(emission)
546
+ genv = torch.zeros_like(env_flat)
547
  ops.pt_backward(scene.tris, scene.mat_ids, scene.uvs, scene.nodes_f,
548
  scene.nodes_i, scene.light_faces, scene.light_cdf,
549
  float(scene.total_light_area),
550
  tex_flat.detach().contiguous(), scene.tex_hdr,
551
+ emission.detach().contiguous(), scene.mat_type,
552
+ scene.mat_rough, scene.mat_ior,
553
+ env_flat.detach().contiguous(), scene.env_cdf_m,
554
+ scene.env_cdf_c, scene.env_pdf, scene.env_w,
555
+ scene.env_h, cam_t, spp, max_bounces, mode, seed,
556
+ grad_image.contiguous(), ga, ge, genv)
557
+ return (ga, ge, genv, None, None, None, None, None, None, None, None)
558
 
559
 
560
+ def render(scene, camera, height, width, spp=64, max_bounces=4,
561
+ estimator=None, nee=None, seed=0):
562
  """Render a linear-radiance image [H, W, 3] float32, differentiable with
563
+ respect to the scene's albedo texels, emission, and environment texels.
564
+ estimator: "mis" (default), "nee", or "brdf"; the legacy `nee` bool maps
565
+ True->"nee", False->"brdf". A fixed seed renders the same paths every
566
+ call, so the Monte Carlo objective is a deterministic function of the
567
+ parameters."""
568
  if not (1 <= max_bounces <= MAX_BOUNCES):
569
  raise ValueError(f"max_bounces must be in [1, {MAX_BOUNCES}]")
570
+ if estimator is not None and nee is not None:
571
+ raise ValueError("pass estimator or nee, not both")
572
+ if estimator is None:
573
+ estimator = "mis" if nee is None else ("nee" if nee else "brdf")
574
+ if estimator not in _MODES:
575
+ raise ValueError('estimator must be "mis", "nee", or "brdf"')
576
+ if estimator == "nee" and scene.env is not None:
577
+ raise ValueError('environment maps require the "mis" or "brdf" estimator')
578
  cam_t = camera.tensor(height, width, device=scene.device)
579
+ return _RenderFn.apply(scene._flat_albedo(), scene.emission,
580
+ scene._flat_env(), scene, cam_t, height, width,
581
+ int(spp), int(max_bounces), _MODES[estimator],
582
  int(seed))
torch-ext/torch_binding.cpp CHANGED
@@ -10,13 +10,28 @@
10
 
11
  namespace {
12
 
13
- void check_scene(const torch::Tensor& tris, const torch::Tensor& mat_ids,
14
- const torch::Tensor& uvs, const torch::Tensor& nodes_f,
15
- const torch::Tensor& nodes_i,
16
- const torch::Tensor& light_faces,
17
- const torch::Tensor& light_cdf, const torch::Tensor& tex,
18
- const torch::Tensor& tex_hdr, const torch::Tensor& emission,
19
- const torch::Tensor& cam, int64_t spp, int64_t max_bounces) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  TORCH_CHECK(tris.is_cuda() && tris.is_contiguous() &&
21
  tris.dtype() == torch::kFloat32 && tris.dim() == 2 &&
22
  tris.size(1) == 9,
@@ -37,13 +52,9 @@ void check_scene(const torch::Tensor& tris, const torch::Tensor& mat_ids,
37
  nodes_i.dtype() == torch::kInt32 && nodes_i.dim() == 2 &&
38
  nodes_i.size(1) == 3 && nodes_i.size(0) == nodes_f.size(0),
39
  "nodes_i must be contiguous CUDA i32 [N, 3]");
40
- TORCH_CHECK(light_faces.is_cuda() && light_faces.is_contiguous() &&
41
- light_faces.dtype() == torch::kInt32,
42
- "light_faces must be contiguous CUDA i32 [L]");
43
- TORCH_CHECK(light_cdf.is_cuda() && light_cdf.is_contiguous() &&
44
- light_cdf.dtype() == torch::kFloat32 &&
45
  light_cdf.numel() == light_faces.numel(),
46
- "light_cdf must be contiguous CUDA f32 [L]");
47
  TORCH_CHECK(tex.is_cuda() && tex.is_contiguous() &&
48
  tex.dtype() == torch::kFloat32 && tex.dim() == 2 &&
49
  tex.size(1) == 3,
@@ -52,16 +63,60 @@ void check_scene(const torch::Tensor& tris, const torch::Tensor& mat_ids,
52
  tex_hdr.dtype() == torch::kInt32 && tex_hdr.dim() == 2 &&
53
  tex_hdr.size(1) == 3,
54
  "tex_hdr must be contiguous CUDA i32 [M, 3]");
 
 
55
  TORCH_CHECK(emission.is_cuda() && emission.is_contiguous() &&
56
  emission.dtype() == torch::kFloat32 &&
57
- emission.dim() == 2 && emission.size(1) == 3 &&
58
- emission.size(0) == tex_hdr.size(0),
59
  "emission must be contiguous CUDA f32 [M, 3]");
60
- TORCH_CHECK(tex_hdr.size(0) <= 64, "at most 64 materials");
61
- TORCH_CHECK(cam.numel() == 12, "cam must have 12 elements");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  TORCH_CHECK(spp >= 1, "spp must be >= 1");
63
  TORCH_CHECK(max_bounces >= 1 && max_bounces <= 16,
64
  "max_bounces must be in [1, 16]");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  }
66
 
67
  } // namespace
@@ -71,30 +126,28 @@ void pt_forward(torch::Tensor tris, torch::Tensor mat_ids, torch::Tensor uvs,
71
  torch::Tensor light_faces, torch::Tensor light_cdf,
72
  double total_light_area, torch::Tensor tex,
73
  torch::Tensor tex_hdr, torch::Tensor emission,
74
- torch::Tensor cam, int64_t spp, int64_t max_bounces, bool nee,
75
- int64_t seed, torch::Tensor image) {
76
- check_scene(tris, mat_ids, uvs, nodes_f, nodes_i, light_faces, light_cdf,
77
- tex, tex_hdr, emission, cam, spp, max_bounces);
 
 
 
 
 
 
 
78
  TORCH_CHECK(image.is_cuda() && image.is_contiguous() &&
79
  image.dtype() == torch::kFloat32 && image.dim() == 3 &&
80
  image.size(2) == 3,
81
  "image must be contiguous CUDA f32 [H, W, 3]");
82
  const at::cuda::CUDAGuard guard(tris.device());
83
  cudaStream_t stream = at::cuda::getCurrentCUDAStream();
84
- int L = (int)light_faces.numel();
85
  torch::Tensor cam_h = cam.to(torch::kFloat32).to(torch::kCPU).contiguous();
86
- ptd_forward_launch(
87
- tris.const_data_ptr<float>(), mat_ids.const_data_ptr<int>(),
88
- uvs.const_data_ptr<float>(), (int)tris.size(0),
89
- nodes_f.const_data_ptr<float>(), nodes_i.const_data_ptr<int>(),
90
- (int)nodes_f.size(0), L ? light_faces.const_data_ptr<int>() : nullptr,
91
- L ? light_cdf.const_data_ptr<float>() : nullptr, L,
92
- (float)total_light_area, tex.const_data_ptr<float>(),
93
- tex_hdr.const_data_ptr<int>(), (int)tex.size(0),
94
- emission.const_data_ptr<float>(),
95
- (int)tex_hdr.size(0), cam_h.const_data_ptr<float>(),
96
- (int)image.size(0), (int)image.size(1), (int)spp, (int)max_bounces,
97
- nee ? 1 : 0, (long long)seed, image.data_ptr<float>(), stream);
98
  C10_CUDA_KERNEL_LAUNCH_CHECK();
99
  }
100
 
@@ -103,11 +156,19 @@ void pt_backward(torch::Tensor tris, torch::Tensor mat_ids, torch::Tensor uvs,
103
  torch::Tensor light_faces, torch::Tensor light_cdf,
104
  double total_light_area, torch::Tensor tex,
105
  torch::Tensor tex_hdr, torch::Tensor emission,
106
- torch::Tensor cam, int64_t spp, int64_t max_bounces, bool nee,
107
- int64_t seed, torch::Tensor grad_image,
108
- torch::Tensor grad_tex, torch::Tensor grad_emission) {
109
- check_scene(tris, mat_ids, uvs, nodes_f, nodes_i, light_faces, light_cdf,
110
- tex, tex_hdr, emission, cam, spp, max_bounces);
 
 
 
 
 
 
 
 
111
  TORCH_CHECK(grad_image.is_cuda() && grad_image.is_contiguous() &&
112
  grad_image.dtype() == torch::kFloat32 &&
113
  grad_image.dim() == 3 && grad_image.size(2) == 3,
@@ -118,24 +179,19 @@ void pt_backward(torch::Tensor tris, torch::Tensor mat_ids, torch::Tensor uvs,
118
  TORCH_CHECK(grad_emission.is_cuda() && grad_emission.is_contiguous() &&
119
  grad_emission.sizes() == emission.sizes(),
120
  "grad_emission must match emission");
 
 
121
  const at::cuda::CUDAGuard guard(tris.device());
122
  cudaStream_t stream = at::cuda::getCurrentCUDAStream();
123
- int L = (int)light_faces.numel();
124
  torch::Tensor cam_h = cam.to(torch::kFloat32).to(torch::kCPU).contiguous();
125
- ptd_backward_launch(
126
- tris.const_data_ptr<float>(), mat_ids.const_data_ptr<int>(),
127
- uvs.const_data_ptr<float>(), (int)tris.size(0),
128
- nodes_f.const_data_ptr<float>(), nodes_i.const_data_ptr<int>(),
129
- (int)nodes_f.size(0), L ? light_faces.const_data_ptr<int>() : nullptr,
130
- L ? light_cdf.const_data_ptr<float>() : nullptr, L,
131
- (float)total_light_area, tex.const_data_ptr<float>(),
132
- tex_hdr.const_data_ptr<int>(), (int)tex.size(0),
133
- emission.const_data_ptr<float>(),
134
- (int)tex_hdr.size(0), cam_h.const_data_ptr<float>(),
135
- (int)grad_image.size(0), (int)grad_image.size(1), (int)spp,
136
- (int)max_bounces, nee ? 1 : 0, (long long)seed,
137
- grad_image.const_data_ptr<float>(), grad_tex.data_ptr<float>(),
138
- grad_emission.data_ptr<float>(), stream);
139
  C10_CUDA_KERNEL_LAUNCH_CHECK();
140
  }
141
 
@@ -144,7 +200,9 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) {
144
  "pt_forward(Tensor tris, Tensor mat_ids, Tensor uvs, Tensor nodes_f,"
145
  " Tensor nodes_i, Tensor light_faces, Tensor light_cdf,"
146
  " float total_light_area, Tensor tex, Tensor tex_hdr, Tensor emission,"
147
- " Tensor cam, int spp, int max_bounces, bool nee, int seed,"
 
 
148
  " Tensor! image) -> ()");
149
  ops.impl("pt_forward", torch::kCUDA, &pt_forward);
150
 
@@ -152,8 +210,11 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) {
152
  "pt_backward(Tensor tris, Tensor mat_ids, Tensor uvs, Tensor nodes_f,"
153
  " Tensor nodes_i, Tensor light_faces, Tensor light_cdf,"
154
  " float total_light_area, Tensor tex, Tensor tex_hdr, Tensor emission,"
155
- " Tensor cam, int spp, int max_bounces, bool nee, int seed,"
156
- " Tensor grad_image, Tensor! grad_tex, Tensor! grad_emission) -> ()");
 
 
 
157
  ops.impl("pt_backward", torch::kCUDA, &pt_backward);
158
  }
159
 
 
10
 
11
  namespace {
12
 
13
+ const float* fptr(const torch::Tensor& t) {
14
+ return t.numel() ? t.const_data_ptr<float>() : nullptr;
15
+ }
16
+ const int* iptr(const torch::Tensor& t) {
17
+ return t.numel() ? t.const_data_ptr<int>() : nullptr;
18
+ }
19
+
20
+ PtdSceneArgs pack_args(const torch::Tensor& tris, const torch::Tensor& mat_ids,
21
+ const torch::Tensor& uvs, const torch::Tensor& nodes_f,
22
+ const torch::Tensor& nodes_i,
23
+ const torch::Tensor& light_faces,
24
+ const torch::Tensor& light_cdf, double total_light_area,
25
+ const torch::Tensor& tex, const torch::Tensor& tex_hdr,
26
+ const torch::Tensor& emission,
27
+ const torch::Tensor& mat_type,
28
+ const torch::Tensor& mat_rough,
29
+ const torch::Tensor& mat_ior, const torch::Tensor& env,
30
+ const torch::Tensor& env_cdf_m,
31
+ const torch::Tensor& env_cdf_c,
32
+ const torch::Tensor& env_pdf, int64_t env_w,
33
+ int64_t env_h, int64_t spp, int64_t max_bounces,
34
+ int64_t mode) {
35
  TORCH_CHECK(tris.is_cuda() && tris.is_contiguous() &&
36
  tris.dtype() == torch::kFloat32 && tris.dim() == 2 &&
37
  tris.size(1) == 9,
 
52
  nodes_i.dtype() == torch::kInt32 && nodes_i.dim() == 2 &&
53
  nodes_i.size(1) == 3 && nodes_i.size(0) == nodes_f.size(0),
54
  "nodes_i must be contiguous CUDA i32 [N, 3]");
55
+ TORCH_CHECK(light_faces.dtype() == torch::kInt32 &&
 
 
 
 
56
  light_cdf.numel() == light_faces.numel(),
57
+ "light list mismatch");
58
  TORCH_CHECK(tex.is_cuda() && tex.is_contiguous() &&
59
  tex.dtype() == torch::kFloat32 && tex.dim() == 2 &&
60
  tex.size(1) == 3,
 
63
  tex_hdr.dtype() == torch::kInt32 && tex_hdr.dim() == 2 &&
64
  tex_hdr.size(1) == 3,
65
  "tex_hdr must be contiguous CUDA i32 [M, 3]");
66
+ int64_t M = tex_hdr.size(0);
67
+ TORCH_CHECK(M <= 64, "at most 64 materials");
68
  TORCH_CHECK(emission.is_cuda() && emission.is_contiguous() &&
69
  emission.dtype() == torch::kFloat32 &&
70
+ emission.sizes() == torch::IntArrayRef({M, 3}),
 
71
  "emission must be contiguous CUDA f32 [M, 3]");
72
+ TORCH_CHECK(mat_type.dtype() == torch::kInt32 && mat_type.numel() == M,
73
+ "mat_type must be i32 [M]");
74
+ TORCH_CHECK(mat_rough.dtype() == torch::kFloat32 && mat_rough.numel() == M,
75
+ "mat_rough must be f32 [M]");
76
+ TORCH_CHECK(mat_ior.dtype() == torch::kFloat32 && mat_ior.numel() == M,
77
+ "mat_ior must be f32 [M]");
78
+ if (env.numel()) {
79
+ TORCH_CHECK(env.is_cuda() && env.is_contiguous() &&
80
+ env.dtype() == torch::kFloat32 &&
81
+ env.numel() == env_w * env_h * 3,
82
+ "env must be contiguous CUDA f32 [Eh*Ew, 3]");
83
+ TORCH_CHECK(env_cdf_m.numel() == env_h &&
84
+ env_cdf_c.numel() == env_w * env_h &&
85
+ env_pdf.numel() == env_w * env_h,
86
+ "env tables mismatch");
87
+ }
88
  TORCH_CHECK(spp >= 1, "spp must be >= 1");
89
  TORCH_CHECK(max_bounces >= 1 && max_bounces <= 16,
90
  "max_bounces must be in [1, 16]");
91
+ TORCH_CHECK(mode >= 0 && mode <= 2, "mode must be 0|1|2");
92
+
93
+ PtdSceneArgs a;
94
+ a.tris = tris.const_data_ptr<float>();
95
+ a.mat_ids = mat_ids.const_data_ptr<int>();
96
+ a.uvs = uvs.const_data_ptr<float>();
97
+ a.n_faces = (int)tris.size(0);
98
+ a.nodes_f = nodes_f.const_data_ptr<float>();
99
+ a.nodes_i = nodes_i.const_data_ptr<int>();
100
+ a.n_nodes = (int)nodes_f.size(0);
101
+ a.light_faces = iptr(light_faces);
102
+ a.light_cdf = fptr(light_cdf);
103
+ a.n_lights = (int)light_faces.numel();
104
+ a.total_light_area = (float)total_light_area;
105
+ a.tex = tex.const_data_ptr<float>();
106
+ a.tex_hdr = tex_hdr.const_data_ptr<int>();
107
+ a.n_texels = (int)tex.size(0);
108
+ a.emission = emission.const_data_ptr<float>();
109
+ a.mat_type = mat_type.const_data_ptr<int>();
110
+ a.mat_rough = mat_rough.const_data_ptr<float>();
111
+ a.mat_ior = mat_ior.const_data_ptr<float>();
112
+ a.n_mats = (int)M;
113
+ a.env = fptr(env);
114
+ a.env_w = (int)env_w;
115
+ a.env_h = (int)env_h;
116
+ a.env_cdf_m = fptr(env_cdf_m);
117
+ a.env_cdf_c = fptr(env_cdf_c);
118
+ a.env_pdf = fptr(env_pdf);
119
+ return a;
120
  }
121
 
122
  } // namespace
 
126
  torch::Tensor light_faces, torch::Tensor light_cdf,
127
  double total_light_area, torch::Tensor tex,
128
  torch::Tensor tex_hdr, torch::Tensor emission,
129
+ torch::Tensor mat_type, torch::Tensor mat_rough,
130
+ torch::Tensor mat_ior, torch::Tensor env,
131
+ torch::Tensor env_cdf_m, torch::Tensor env_cdf_c,
132
+ torch::Tensor env_pdf, int64_t env_w, int64_t env_h,
133
+ torch::Tensor cam, int64_t spp, int64_t max_bounces,
134
+ int64_t mode, int64_t seed, torch::Tensor image) {
135
+ PtdSceneArgs a = pack_args(tris, mat_ids, uvs, nodes_f, nodes_i, light_faces,
136
+ light_cdf, total_light_area, tex, tex_hdr,
137
+ emission, mat_type, mat_rough, mat_ior, env,
138
+ env_cdf_m, env_cdf_c, env_pdf, env_w, env_h, spp,
139
+ max_bounces, mode);
140
  TORCH_CHECK(image.is_cuda() && image.is_contiguous() &&
141
  image.dtype() == torch::kFloat32 && image.dim() == 3 &&
142
  image.size(2) == 3,
143
  "image must be contiguous CUDA f32 [H, W, 3]");
144
  const at::cuda::CUDAGuard guard(tris.device());
145
  cudaStream_t stream = at::cuda::getCurrentCUDAStream();
 
146
  torch::Tensor cam_h = cam.to(torch::kFloat32).to(torch::kCPU).contiguous();
147
+ ptd_forward_launch(&a, cam_h.const_data_ptr<float>(), (int)image.size(0),
148
+ (int)image.size(1), (int)spp, (int)max_bounces,
149
+ (int)mode, (long long)seed, image.data_ptr<float>(),
150
+ stream);
 
 
 
 
 
 
 
 
151
  C10_CUDA_KERNEL_LAUNCH_CHECK();
152
  }
153
 
 
156
  torch::Tensor light_faces, torch::Tensor light_cdf,
157
  double total_light_area, torch::Tensor tex,
158
  torch::Tensor tex_hdr, torch::Tensor emission,
159
+ torch::Tensor mat_type, torch::Tensor mat_rough,
160
+ torch::Tensor mat_ior, torch::Tensor env,
161
+ torch::Tensor env_cdf_m, torch::Tensor env_cdf_c,
162
+ torch::Tensor env_pdf, int64_t env_w, int64_t env_h,
163
+ torch::Tensor cam, int64_t spp, int64_t max_bounces,
164
+ int64_t mode, int64_t seed, torch::Tensor grad_image,
165
+ torch::Tensor grad_tex, torch::Tensor grad_emission,
166
+ torch::Tensor grad_env) {
167
+ PtdSceneArgs a = pack_args(tris, mat_ids, uvs, nodes_f, nodes_i, light_faces,
168
+ light_cdf, total_light_area, tex, tex_hdr,
169
+ emission, mat_type, mat_rough, mat_ior, env,
170
+ env_cdf_m, env_cdf_c, env_pdf, env_w, env_h, spp,
171
+ max_bounces, mode);
172
  TORCH_CHECK(grad_image.is_cuda() && grad_image.is_contiguous() &&
173
  grad_image.dtype() == torch::kFloat32 &&
174
  grad_image.dim() == 3 && grad_image.size(2) == 3,
 
179
  TORCH_CHECK(grad_emission.is_cuda() && grad_emission.is_contiguous() &&
180
  grad_emission.sizes() == emission.sizes(),
181
  "grad_emission must match emission");
182
+ TORCH_CHECK(grad_env.is_contiguous() && grad_env.sizes() == env.sizes(),
183
+ "grad_env must match env");
184
  const at::cuda::CUDAGuard guard(tris.device());
185
  cudaStream_t stream = at::cuda::getCurrentCUDAStream();
 
186
  torch::Tensor cam_h = cam.to(torch::kFloat32).to(torch::kCPU).contiguous();
187
+ ptd_backward_launch(&a, cam_h.const_data_ptr<float>(),
188
+ (int)grad_image.size(0), (int)grad_image.size(1),
189
+ (int)spp, (int)max_bounces, (int)mode, (long long)seed,
190
+ grad_image.const_data_ptr<float>(),
191
+ grad_tex.data_ptr<float>(),
192
+ grad_emission.data_ptr<float>(),
193
+ grad_env.numel() ? grad_env.data_ptr<float>() : nullptr,
194
+ stream);
 
 
 
 
 
 
195
  C10_CUDA_KERNEL_LAUNCH_CHECK();
196
  }
197
 
 
200
  "pt_forward(Tensor tris, Tensor mat_ids, Tensor uvs, Tensor nodes_f,"
201
  " Tensor nodes_i, Tensor light_faces, Tensor light_cdf,"
202
  " float total_light_area, Tensor tex, Tensor tex_hdr, Tensor emission,"
203
+ " Tensor mat_type, Tensor mat_rough, Tensor mat_ior, Tensor env,"
204
+ " Tensor env_cdf_m, Tensor env_cdf_c, Tensor env_pdf, int env_w,"
205
+ " int env_h, Tensor cam, int spp, int max_bounces, int mode, int seed,"
206
  " Tensor! image) -> ()");
207
  ops.impl("pt_forward", torch::kCUDA, &pt_forward);
208
 
 
210
  "pt_backward(Tensor tris, Tensor mat_ids, Tensor uvs, Tensor nodes_f,"
211
  " Tensor nodes_i, Tensor light_faces, Tensor light_cdf,"
212
  " float total_light_area, Tensor tex, Tensor tex_hdr, Tensor emission,"
213
+ " Tensor mat_type, Tensor mat_rough, Tensor mat_ior, Tensor env,"
214
+ " Tensor env_cdf_m, Tensor env_cdf_c, Tensor env_pdf, int env_w,"
215
+ " int env_h, Tensor cam, int spp, int max_bounces, int mode, int seed,"
216
+ " Tensor grad_image, Tensor! grad_tex, Tensor! grad_emission,"
217
+ " Tensor! grad_env) -> ()");
218
  ops.impl("pt_backward", torch::kCUDA, &pt_backward);
219
  }
220
 
torch-ext/torch_binding.h CHANGED
@@ -7,14 +7,23 @@ void pt_forward(torch::Tensor tris, torch::Tensor mat_ids, torch::Tensor uvs,
7
  torch::Tensor light_faces, torch::Tensor light_cdf,
8
  double total_light_area, torch::Tensor tex,
9
  torch::Tensor tex_hdr, torch::Tensor emission,
10
- torch::Tensor cam, int64_t spp, int64_t max_bounces, bool nee,
11
- int64_t seed, torch::Tensor image);
 
 
 
 
12
 
13
  void pt_backward(torch::Tensor tris, torch::Tensor mat_ids, torch::Tensor uvs,
14
  torch::Tensor nodes_f, torch::Tensor nodes_i,
15
  torch::Tensor light_faces, torch::Tensor light_cdf,
16
  double total_light_area, torch::Tensor tex,
17
  torch::Tensor tex_hdr, torch::Tensor emission,
18
- torch::Tensor cam, int64_t spp, int64_t max_bounces, bool nee,
19
- int64_t seed, torch::Tensor grad_image,
20
- torch::Tensor grad_tex, torch::Tensor grad_emission);
 
 
 
 
 
 
7
  torch::Tensor light_faces, torch::Tensor light_cdf,
8
  double total_light_area, torch::Tensor tex,
9
  torch::Tensor tex_hdr, torch::Tensor emission,
10
+ torch::Tensor mat_type, torch::Tensor mat_rough,
11
+ torch::Tensor mat_ior, torch::Tensor env,
12
+ torch::Tensor env_cdf_m, torch::Tensor env_cdf_c,
13
+ torch::Tensor env_pdf, int64_t env_w, int64_t env_h,
14
+ torch::Tensor cam, int64_t spp, int64_t max_bounces,
15
+ int64_t mode, int64_t seed, torch::Tensor image);
16
 
17
  void pt_backward(torch::Tensor tris, torch::Tensor mat_ids, torch::Tensor uvs,
18
  torch::Tensor nodes_f, torch::Tensor nodes_i,
19
  torch::Tensor light_faces, torch::Tensor light_cdf,
20
  double total_light_area, torch::Tensor tex,
21
  torch::Tensor tex_hdr, torch::Tensor emission,
22
+ torch::Tensor mat_type, torch::Tensor mat_rough,
23
+ torch::Tensor mat_ior, torch::Tensor env,
24
+ torch::Tensor env_cdf_m, torch::Tensor env_cdf_c,
25
+ torch::Tensor env_pdf, int64_t env_w, int64_t env_h,
26
+ torch::Tensor cam, int64_t spp, int64_t max_bounces,
27
+ int64_t mode, int64_t seed, torch::Tensor grad_image,
28
+ torch::Tensor grad_tex, torch::Tensor grad_emission,
29
+ torch::Tensor grad_env);