@tinypoly/gpu-renderer 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/ARCHITECTURE.md +31 -3
  2. package/README.md +11 -3
  3. package/dist/denoise.d.ts +10 -1
  4. package/dist/denoise.d.ts.map +1 -1
  5. package/dist/denoise.js +4 -0
  6. package/dist/denoise.js.map +1 -1
  7. package/dist/device.d.ts.map +1 -1
  8. package/dist/device.js +8 -0
  9. package/dist/device.js.map +1 -1
  10. package/dist/frameLayout.d.ts.map +1 -1
  11. package/dist/frameLayout.js +4 -1
  12. package/dist/frameLayout.js.map +1 -1
  13. package/dist/imageResolve.d.ts +2 -1
  14. package/dist/imageResolve.d.ts.map +1 -1
  15. package/dist/imageResolve.js +1 -0
  16. package/dist/imageResolve.js.map +1 -1
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/lensPass.d.ts +4 -0
  22. package/dist/lensPass.d.ts.map +1 -1
  23. package/dist/lensPass.js +14 -1
  24. package/dist/lensPass.js.map +1 -1
  25. package/dist/lights.d.ts +18 -0
  26. package/dist/lights.d.ts.map +1 -1
  27. package/dist/lights.js +45 -0
  28. package/dist/lights.js.map +1 -1
  29. package/dist/oidnPass.d.ts +48 -0
  30. package/dist/oidnPass.d.ts.map +1 -0
  31. package/dist/oidnPass.js +255 -0
  32. package/dist/oidnPass.js.map +1 -0
  33. package/dist/pathTracePass.d.ts +2 -2
  34. package/dist/pathTracePass.d.ts.map +1 -1
  35. package/dist/pathTracePass.js +9 -9
  36. package/dist/pathTracePass.js.map +1 -1
  37. package/dist/presentPass.d.ts +2 -0
  38. package/dist/presentPass.d.ts.map +1 -1
  39. package/dist/presentPass.js +1 -0
  40. package/dist/presentPass.js.map +1 -1
  41. package/dist/renderer.d.ts +12 -0
  42. package/dist/renderer.d.ts.map +1 -1
  43. package/dist/renderer.js +100 -14
  44. package/dist/renderer.js.map +1 -1
  45. package/dist/sceneUpload.d.ts +4 -0
  46. package/dist/sceneUpload.d.ts.map +1 -1
  47. package/dist/sceneUpload.js +3 -0
  48. package/dist/sceneUpload.js.map +1 -1
  49. package/dist/settings.d.ts +23 -1
  50. package/dist/settings.d.ts.map +1 -1
  51. package/dist/settings.js +13 -4
  52. package/dist/settings.js.map +1 -1
  53. package/dist/wgsl/lens.wgsl.d.ts +1 -1
  54. package/dist/wgsl/lens.wgsl.d.ts.map +1 -1
  55. package/dist/wgsl/lens.wgsl.js +108 -1
  56. package/dist/wgsl/lens.wgsl.js.map +1 -1
  57. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  58. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  59. package/dist/wgsl/lights.wgsl.js +147 -17
  60. package/dist/wgsl/lights.wgsl.js.map +1 -1
  61. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  62. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  63. package/dist/wgsl/pathTrace.wgsl.js +123 -91
  64. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  65. package/dist/wgsl/present.wgsl.d.ts +1 -1
  66. package/dist/wgsl/present.wgsl.d.ts.map +1 -1
  67. package/dist/wgsl/present.wgsl.js +11 -1
  68. package/dist/wgsl/present.wgsl.js.map +1 -1
  69. package/package.json +4 -2
  70. package/src/denoise.ts +15 -1
  71. package/src/device.ts +9 -0
  72. package/src/frameLayout.ts +4 -1
  73. package/src/imageResolve.ts +2 -0
  74. package/src/index.ts +2 -1
  75. package/src/lensPass.ts +17 -1
  76. package/src/lights.ts +60 -0
  77. package/src/oidnPass.ts +284 -0
  78. package/src/oidnWebgpu.d.ts +5 -0
  79. package/src/pathTracePass.ts +11 -9
  80. package/src/presentPass.ts +3 -0
  81. package/src/renderer.ts +118 -15
  82. package/src/sceneUpload.ts +7 -0
  83. package/src/settings.ts +32 -4
  84. package/src/wgsl/lens.wgsl.ts +108 -1
  85. package/src/wgsl/lights.wgsl.ts +147 -17
  86. package/src/wgsl/pathTrace.wgsl.ts +123 -91
  87. package/src/wgsl/present.wgsl.ts +11 -1
  88. package/weights/LICENSE.txt +202 -0
  89. package/weights/rt_hdr_alb_nrm.tza +0 -0
@@ -53,6 +53,10 @@ export interface SceneBuffers {
53
53
  lightTree: number;
54
54
  lightLeaves: number;
55
55
  lightList: number;
56
+ /** The windows the sky is sampled through: see lights.ts. */
57
+ portals: number;
58
+ portalCount: number;
59
+ portalList: number;
56
60
  /** Radiance an ambient or hemisphere light adds, which no shadow ray can reach. */
57
61
  ambient: PackedLights["ambient"];
58
62
  /** Where the surrounding image and its distributions ended up, for the frame uniform. */
@@ -208,6 +212,9 @@ export function uploadScene(
208
212
  lightTree: lights.treeAt,
209
213
  lightLeaves: lights.leavesAt,
210
214
  lightList: lights.listAt,
215
+ portals: lights.portalTreeAt,
216
+ portalCount: lights.portalCount,
217
+ portalList: lights.portalListAt,
211
218
  ambient: lights.ambient,
212
219
  environment: {
213
220
  texture: environment ? textures.indexOf.get(environment.texture.id) ?? NO_TEXTURE : NO_TEXTURE,
package/src/settings.ts CHANGED
@@ -20,6 +20,12 @@ export interface GpuRenderSettings extends Omit<RenderSettings, "globalIlluminat
20
20
  * gets the samples a large panel far away would have taken.
21
21
  */
22
22
  lightTree: boolean;
23
+ /**
24
+ * Inside a room, aims at the sky through its windows (every thin transmissive material) as well as by its
25
+ * brightness, as Cycles' portals do, so daylight that enters through glass is found instead of stumbled upon.
26
+ * A point counts as inside when something is above it; elsewhere the sky is sampled as before.
27
+ */
28
+ portals: boolean;
23
29
  };
24
30
  /**
25
31
  * The light a real lens and sensor scatter around anything bright: a soft halo that fades with distance. Energy
@@ -60,11 +66,24 @@ export interface GpuRenderSettings extends Omit<RenderSettings, "globalIlluminat
60
66
  };
61
67
  /** A camera's exposure triangle and white balance. */
62
68
  camera: {
69
+ /**
70
+ * The contrast and saturation of AgX's look, `0` to `1`: `0` is its base curve, flat on purpose, and `1` a look
71
+ * like Blender's Punchy, pivoted on middle grey so the image keeps its brightness. The other tone mappings ignore it.
72
+ */
73
+ contrast: number;
63
74
  /**
64
75
  * Exposes from `iso`, `shutterSpeed` and `depthOfField.aperture` as a camera would, with `exposure` as a
65
76
  * compensation on top. The scene's lights then have to be in physical units (lux, candela, nits).
66
77
  */
67
78
  physicalExposure: boolean;
79
+ /**
80
+ * Meters the image as a camera in automatic does and exposes it to middle grey, from the mean of the logarithms of
81
+ * its luminance, weighed towards the centre: a bright window or sky cannot darken the rest. It takes the place of
82
+ * `exposure` and of the physical exposure, with `exposureCompensation` on top.
83
+ */
84
+ autoExposure: boolean;
85
+ /** Stops added to the metered exposure: positive brightens, as on a camera's dial. */
86
+ exposureCompensation: number;
68
87
  iso: number;
69
88
  /** Seconds. */
70
89
  shutterSpeed: number;
@@ -130,11 +149,14 @@ export const DEFAULT_GPU_RENDER_SETTINGS: Readonly<GpuRenderSettings> = Object.f
130
149
  // AgX rolls highlights off as film does, where ACES pushes saturated colours to the gamut's edge.
131
150
  toneMapping: "agx",
132
151
  globalIllumination: { ...DEFAULT_RENDER_SETTINGS.globalIllumination, russianRoulette: true, rouletteStart: 1,
133
- lightTree: true },
152
+ lightTree: true, portals: true },
134
153
  bloom: { enabled: true, intensity: 0.04, radius: 0.65 },
135
154
  glare: { enabled: true, intensity: 0.06, blades: 6, length: 0.25, rotation: 15 },
136
- lens: { chromaticAberration: 0.2, distortion: 0 },
137
- camera: { physicalExposure: false, iso: 100, shutterSpeed: 1 / 125, whiteBalance: 6500 },
155
+ lens: { chromaticAberration: 0.05, distortion: 0 },
156
+ camera: {
157
+ contrast: 1, physicalExposure: false, autoExposure: false, exposureCompensation: 0, iso: 100,
158
+ shutterSpeed: 1 / 125, whiteBalance: 6500,
159
+ },
138
160
  subsurface: { enabled: true, depth: 2 },
139
161
  atmosphere: { enabled: true, visibility: 20000, haze: 0.6, color: "" },
140
162
  volumes: { enabled: true },
@@ -160,10 +182,16 @@ export function physicalExposure(aperture: number, shutterSpeed: number, iso: nu
160
182
  return 1 / (1.2 * 2 ** ev100);
161
183
  }
162
184
 
163
- /** The exposure the image is multiplied by: the setting itself, or a camera's on top of it as compensation. */
185
+ /**
186
+ * The exposure the image is multiplied by: the setting itself, or a camera's on top of it as compensation, or with
187
+ * the automatic exposure only its compensation, the meter's own exposure being applied on the device.
188
+ */
164
189
  export function effectiveExposure(settings: GpuRenderSettings): number {
165
190
  const { camera, depthOfField, exposure } = settings;
166
191
 
192
+ // The meter's exposure is applied on the device, by the lens pass; what is left here is the compensation.
193
+ if (camera.autoExposure) return 2 ** (Number(camera.exposureCompensation) || 0);
194
+
167
195
  return camera.physicalExposure
168
196
  ? exposure * physicalExposure(depthOfField.aperture, camera.shutterSpeed, camera.iso)
169
197
  : exposure;
@@ -46,7 +46,8 @@ struct Lens {
46
46
  // The size of the level the rays are gathered at, and the angle of the first ray, in radians.
47
47
  streakSize: vec2<u32>,
48
48
  glareAngle: f32,
49
- pad0: f32,
49
+ // Whether the camera meters the image and exposes it to middle grey, which the meter pass works out.
50
+ autoExposure: f32,
50
51
  };
51
52
 
52
53
  const PI: f32 = 3.14159265358979;
@@ -61,6 +62,108 @@ const STREAK_TAPS: i32 = 24;
61
62
  @group(0) @binding(4) var<storage, read_write> up: array<vec4<f32>>;
62
63
  @group(0) @binding(5) var<storage, read_write> graded: array<vec4<f32>>;
63
64
  @group(0) @binding(6) var<storage, read_write> streaked: array<vec4<f32>>;
65
+ // What the camera's meter read: the exposure that brings the image's average to middle grey, in x.
66
+ @group(0) @binding(7) var<storage, read_write> metered: array<vec4<f32>>;
67
+
68
+ /** What a light meter calibrates to: an average scene reflects about eighteen percent. */
69
+ const MIDDLE_GREY: f32 = 0.18;
70
+ const METER_GRID: vec2<u32> = vec2<u32>(64u, 36u);
71
+ const METER_THREADS: u32 = 256u;
72
+
73
+ /**
74
+ * How far above middle grey the meter lets the bright end of the image land: three stops, where AgX still shows
75
+ * detail, three and a half under where it reaches white.
76
+ */
77
+ const HIGHLIGHT_CEILING: f32 = 8.0;
78
+ /** The share of the metered points that counts as the bright end: the light through a window, not a lamp's own pixels. */
79
+ const HIGHLIGHT_SHARE: f32 = 0.03;
80
+ /** Half stops of luminance, from 2^-16 to 2^16, which the bright end is found in. */
81
+ const METER_BINS: u32 = 64u;
82
+
83
+ var<workgroup> meterSums: array<vec2<f32>, 256>;
84
+ var<workgroup> meterHistogram: array<atomic<u32>, 64>;
85
+
86
+ /**
87
+ * The camera's meter: the image's average luminance over a grid of points, taken as the mean of the logarithms so that
88
+ * a bright window or a patch of sky cannot darken everything else, weighed towards the centre as a camera's
89
+ * centre-weighted metering is. What no path has reached yet, or is black, does not count. One workgroup reads the
90
+ * grid and sums it in shared memory.
91
+ *
92
+ * Then it guards the highlights, as a camera's matrix metering does: the average alone takes a dim attic with a shaft
93
+ * of sun in it to middle grey and burns the shaft out, where a photographer keeps the room dim. The brightest few
94
+ * percent of the points, found in a histogram of half stops, may land no more than {@link HIGHLIGHT_CEILING} times
95
+ * brighter; an even scene, whose bright end is close to its average, is left as the average says.
96
+ */
97
+ @compute @workgroup_size(256, 1, 1)
98
+ fn meter(@builtin(local_invocation_index) thread: u32) {
99
+ var sum = vec2<f32>(0.0);
100
+ let count = METER_GRID.x * METER_GRID.y;
101
+
102
+ if (thread < METER_BINS) {
103
+ atomicStore(&meterHistogram[thread], 0u);
104
+ }
105
+
106
+ workgroupBarrier();
107
+
108
+ for (var k = thread; k < count; k = k + METER_THREADS) {
109
+ let cell = vec2<f32>(f32(k % METER_GRID.x), f32(k / METER_GRID.x)) + 0.5;
110
+ let uv = cell / vec2<f32>(METER_GRID);
111
+ let luminance = dot(imageTexel(i32(uv.x * f32(lens.resolution.x)), i32(uv.y * f32(lens.resolution.y))),
112
+ vec3<f32>(0.2126, 0.7152, 0.0722));
113
+ let away = (uv - 0.5) * 2.0;
114
+ let weight = select(0.0, 1.0 - 0.6 * min(dot(away, away) * 0.5, 1.0), luminance > 0.0);
115
+
116
+ sum = sum + vec2<f32>(log(max(luminance, 1e-6)) * weight, weight);
117
+
118
+ if (luminance > 0.0) {
119
+ let bin = u32(clamp((log2(luminance) + 16.0) * 2.0, 0.0, f32(METER_BINS - 1u)));
120
+
121
+ atomicAdd(&meterHistogram[bin], 1u);
122
+ }
123
+ }
124
+
125
+ meterSums[thread] = sum;
126
+ workgroupBarrier();
127
+
128
+ for (var stride = METER_THREADS / 2u; stride > 0u; stride = stride / 2u) {
129
+ if (thread < stride) {
130
+ meterSums[thread] = meterSums[thread] + meterSums[thread + stride];
131
+ }
132
+
133
+ workgroupBarrier();
134
+ }
135
+
136
+ if (thread == 0u) {
137
+ let total = meterSums[0];
138
+ let average = select(MIDDLE_GREY, exp(total.x / max(total.y, 1e-6)), total.y > 0.0);
139
+ var exposure = MIDDLE_GREY / average;
140
+
141
+ // From the top down, the first half stop by which the bright share is reached; its upper edge is that end.
142
+ var points = 0u;
143
+
144
+ for (var bin = 0u; bin < METER_BINS; bin = bin + 1u) {
145
+ points = points + atomicLoad(&meterHistogram[bin]);
146
+ }
147
+
148
+ var above = 0u;
149
+ var bright = 0.0;
150
+
151
+ for (var bin = i32(METER_BINS) - 1; bin >= 0 && points > 0u; bin = bin - 1) {
152
+ above = above + atomicLoad(&meterHistogram[u32(bin)]);
153
+
154
+ if (f32(above) >= HIGHLIGHT_SHARE * f32(points)) {
155
+ bright = exp2(f32(bin + 1) * 0.5 - 16.0);
156
+ break;
157
+ }
158
+ }
159
+
160
+ if (bright > 0.0) {
161
+ exposure = min(exposure, HIGHLIGHT_CEILING * MIDDLE_GREY / bright);
162
+ }
163
+
164
+ metered[0] = vec4<f32>(clamp(exposure, 1e-4, 1e4), average, bright, 0.0);
165
+ }
166
+ }
64
167
 
65
168
  /** The image's own texel: its mean so far, or nothing where no path has landed yet. */
66
169
  fn imageTexel(x: i32, y: i32) -> vec3<f32> {
@@ -293,6 +396,10 @@ fn composite(@builtin(global_invocation_id) id: vec3<u32>) {
293
396
  color = mix(color, streakBilinear(position * vec2<f32>(lens.streakSize) / size), lens.glare);
294
397
  }
295
398
 
399
+ if (lens.autoExposure > 0.0) {
400
+ color = color * metered[0].x;
401
+ }
402
+
296
403
  let balanced = vec3<f32>(dot(lens.white0.xyz, color), dot(lens.white1.xyz, color), dot(lens.white2.xyz, color));
297
404
 
298
405
  graded[id.y * lens.resolution.x + id.x] = vec4<f32>(max(balanced, vec3<f32>(0.0)), 1.0);
@@ -64,8 +64,8 @@ const NO_LIGHT: u32 = 0xffffffffu;
64
64
  * angle the node's box takes from the point (Conty Estevez and Kulla's importance). A point inside the box rules
65
65
  * nothing out. A zero normal is a receiver that takes light from everywhere, such as a point in a volume.
66
66
  */
67
- fn lightNodeImportance(node: u32, position: vec3<f32>, normal: vec3<f32>) -> f32 {
68
- let at = frame.lightTree + node * TREE_NODE_WORDS;
67
+ fn lightNodeImportance(tree: u32, node: u32, position: vec3<f32>, normal: vec3<f32>) -> f32 {
68
+ let at = tree + node * TREE_NODE_WORDS;
69
69
  let low = extraVec4(at);
70
70
  let high = extraVec4(at + 4u);
71
71
 
@@ -125,13 +125,13 @@ fn lightNodeImportance(node: u32, position: vec3<f32>, normal: vec3<f32>) -> f32
125
125
  return low.w * orientation * facing / distance2;
126
126
  }
127
127
 
128
- /** The chance of stepping from a node of the tree to each of its children, from this point. */
129
- fn lightChildOdds(left: u32, right: u32, position: vec3<f32>, normal: vec3<f32>) -> vec2<f32> {
128
+ /** The chance of stepping from a node of a tree (the lights' or the windows') to each of its children, from here. */
129
+ fn lightChildOdds(tree: u32, left: u32, right: u32, position: vec3<f32>, normal: vec3<f32>) -> vec2<f32> {
130
130
  var importance = vec2<f32>(0.0);
131
131
 
132
132
  // One loop over the two children, so the importance is written into the kernel once per walk.
133
133
  for (var k = 0u; k < 2u; k = k + 1u) {
134
- importance[k] = lightNodeImportance(select(right, left, k == 0u), position, normal);
134
+ importance[k] = lightNodeImportance(tree, select(right, left, k == 0u), position, normal);
135
135
  }
136
136
 
137
137
  let total = importance.x + importance.y;
@@ -139,20 +139,23 @@ fn lightChildOdds(left: u32, right: u32, position: vec3<f32>, normal: vec3<f32>)
139
139
  return select(importance / total, vec2<f32>(0.0), total <= 0.0);
140
140
  }
141
141
 
142
- /** A light picked down the tree by importance from this point, with the chance of picking it; NO_LIGHT for none. */
143
- fn sampleLightTree(position: vec3<f32>, normal: vec3<f32>, u: f32, pdf: ptr<function, f32>,
142
+ /**
143
+ * An entry picked down a tree by importance from this point, with the chance of picking it; NO_LIGHT for none. The
144
+ * tree is the lights' or the windows', given by where its nodes and its leaves' list start.
145
+ */
146
+ fn sampleLightTree(tree: u32, list: u32, position: vec3<f32>, normal: vec3<f32>, u: f32, pdf: ptr<function, f32>,
144
147
  density: ptr<function, f32>) -> u32 {
145
148
  var node = 0u;
146
149
  var chance = 1.0;
147
150
  var x = u;
148
151
 
149
152
  for (var depth = 0u; depth < 64u; depth = depth + 1u) {
150
- let at = frame.lightTree + node * TREE_NODE_WORDS;
153
+ let at = tree + node * TREE_NODE_WORDS;
151
154
  let first = textures[at + 12u];
152
155
 
153
156
  // A leaf lists its lights, too close together to tell apart from here: one is picked by its share of the power.
154
157
  if ((first & TREE_LEAF) != 0u) {
155
- let start = frame.lightList + (first & ~TREE_LEAF) * TREE_ENTRY_WORDS;
158
+ let start = list + (first & ~TREE_LEAF) * TREE_ENTRY_WORDS;
156
159
  let count = textures[at + 13u];
157
160
  var slot = start + (count - 1u) * TREE_ENTRY_WORDS;
158
161
 
@@ -170,7 +173,7 @@ fn sampleLightTree(position: vec3<f32>, normal: vec3<f32>, u: f32, pdf: ptr<func
170
173
  }
171
174
 
172
175
  let second = textures[at + 13u];
173
- let odds = lightChildOdds(first, second, position, normal);
176
+ let odds = lightChildOdds(tree, first, second, position, normal);
174
177
 
175
178
  if (odds.x + odds.y <= 0.0) {
176
179
  return NO_LIGHT;
@@ -208,7 +211,7 @@ fn lightTreePdf(index: u32, position: vec3<f32>, normal: vec3<f32>) -> f32 {
208
211
 
209
212
  let at = frame.lightTree + parent * TREE_NODE_WORDS;
210
213
  let first = textures[at + 12u];
211
- let odds = lightChildOdds(first, textures[at + 13u], position, normal);
214
+ let odds = lightChildOdds(frame.lightTree, first, textures[at + 13u], position, normal);
212
215
 
213
216
  chance = chance * select(odds.y, odds.x, node == first);
214
217
  node = parent;
@@ -252,6 +255,119 @@ fn lightPdfOfTriangle(triangle: u32, position: vec3<f32>, normal: vec3<f32>) ->
252
255
  return 0.0;
253
256
  }
254
257
 
258
+ /** Share of the sky's samples aimed through the windows, from a point inside a room. */
259
+ const PORTAL_SHARE: f32 = 0.5;
260
+
261
+ /**
262
+ * How often the sky is aimed at through the windows from this point: from inside a room, where something stands above
263
+ * it, half the time; outside never, where the open sky is better found by its brightness. A function of the point alone,
264
+ * so the heuristic can ask it again when a bounce ray escapes.
265
+ */
266
+ fn portalChance(position: vec3<f32>) -> f32 {
267
+ if (!PORTALS || frame.portalCount == 0u) {
268
+ return 0.0;
269
+ }
270
+
271
+ return select(0.0, PORTAL_SHARE, traceAny(position, vec3<f32>(0.0, 1.0, 0.0), 1e30) != NO_HIT);
272
+ }
273
+
274
+ /** Window subtrees a ray's walk through the windows' tree keeps waiting. */
275
+ const PORTAL_STACK: u32 = 32u;
276
+
277
+ /**
278
+ * The solid angle density with which aiming through the windows produces this direction: over every window triangle
279
+ * it crosses, the chance the tree picks that window from here times the area density of a point on it, over the area
280
+ * it shows from here. The ray walks the tree as it would a hierarchy, by the nodes' boxes, carrying the chance of
281
+ * reaching each node, so the windows it misses cost a box test and the ones it crosses no walk back up.
282
+ */
283
+ fn portalPdf(position: vec3<f32>, direction: vec3<f32>) -> f32 {
284
+ var nodes: array<u32, PORTAL_STACK>;
285
+ var chances: array<f32, PORTAL_STACK>;
286
+ var top = 1u;
287
+ let inverse = 1.0 / direction;
288
+ var pdf = 0.0;
289
+
290
+ nodes[0] = 0u;
291
+ chances[0] = 1.0;
292
+
293
+ loop {
294
+ if (top == 0u) {
295
+ break;
296
+ }
297
+
298
+ top = top - 1u;
299
+
300
+ let node = nodes[top];
301
+ let chance = chances[top];
302
+ let at = frame.portals + node * TREE_NODE_WORDS;
303
+
304
+ if (enterBox(extraVec4(at).xyz, extraVec4(at + 4u).xyz, position, inverse, 1e30) >= 1e30) {
305
+ continue;
306
+ }
307
+
308
+ let first = textures[at + 12u];
309
+ let second = textures[at + 13u];
310
+
311
+ if ((first & TREE_LEAF) != 0u) {
312
+ let start = frame.portalList + (first & ~TREE_LEAF) * TREE_ENTRY_WORDS;
313
+
314
+ for (var j = 0u; j < second; j = j + 1u) {
315
+ let slot = start + j * TREE_ENTRY_WORDS;
316
+ let triangle = textures[slot];
317
+ let t = intersectTriangle(leafOf(triangle), position, direction).x;
318
+
319
+ if (t > 0.0) {
320
+ let cosine = abs(dot(triangleNormal(triangle), direction));
321
+
322
+ pdf = pdf + chance * extraFloat(slot + 1u) * extraFloat(slot + 2u) * t * t / max(cosine, 1e-6);
323
+ }
324
+ }
325
+
326
+ continue;
327
+ }
328
+
329
+ let odds = lightChildOdds(frame.portals, first, second, position, vec3<f32>(0.0));
330
+
331
+ for (var k = 0u; k < 2u; k = k + 1u) {
332
+ if (odds[k] > 0.0 && top < PORTAL_STACK) {
333
+ nodes[top] = select(second, first, k == 0u);
334
+ chances[top] = chance * odds[k];
335
+ top = top + 1u;
336
+ }
337
+ }
338
+ }
339
+
340
+ return pdf;
341
+ }
342
+
343
+ /** A direction through a point on a window the tree picks from here, or none from a point on the window itself. */
344
+ fn samplePortal(position: vec3<f32>, u: vec2<f32>) -> vec3<f32> {
345
+ var chance = 0.0;
346
+ var density = 0.0;
347
+ let triangle = sampleLightTree(frame.portals, frame.portalList, position, vec3<f32>(0.0), u.x, &chance, &density);
348
+
349
+ if (triangle == NO_LIGHT) {
350
+ return vec3<f32>(0.0);
351
+ }
352
+
353
+ // The tree spent the sample walking down; a fresh one places the point, which the pick does not depend on.
354
+ let point = trianglePointAt(triangle, triangleBarycentric(vec2<f32>(fract(u.x * 4096.0), u.y)));
355
+ let offset = point - position;
356
+
357
+ return select(vec3<f32>(0.0), normalize(offset), dot(offset, offset) > 1e-12);
358
+ }
359
+
360
+ /** The density of the sky's sampling from this point for this direction: by brightness, through windows, or both. */
361
+ fn skyPdf(position: vec3<f32>, direction: vec3<f32>, share: f32) -> f32 {
362
+ var pdf = (1.0 - share) * environmentPdf(direction);
363
+
364
+ if (share > 0.0) {
365
+ pdf = pdf + share * portalPdf(position, direction);
366
+ }
367
+
368
+ return pdf;
369
+ }
370
+
255
371
  /** The power heuristic: how much of a contribution the strategy that produced it keeps, against the other one. */
256
372
  fn misWeight(pdf: f32, other: f32) -> f32 {
257
373
  let a = pdf * pdf;
@@ -307,7 +423,7 @@ fn sampleLight(position: vec3<f32>, normal: vec3<f32>, pick: f32, u: vec2<f32>)
307
423
  if (frame.lightTree != 0u && pick < frame.localLightPick) {
308
424
  var chance = 0.0;
309
425
  var density = 1.0;
310
- let index = sampleLightTree(position, normal, pick / frame.localLightPick, &chance, &density);
426
+ let index = sampleLightTree(frame.lightTree, frame.lightList, position, normal, pick / frame.localLightPick, &chance, &density);
311
427
 
312
428
  if (index == NO_LIGHT || chance <= 0.0) {
313
429
  return noLight();
@@ -356,17 +472,31 @@ fn sampleLightEntry(light: Light, pdf: f32, position: vec3<f32>, u: vec2<f32>) -
356
472
  sample.misPdf = 0.0;
357
473
 
358
474
  if (light.kind == LIGHT_ENVIRONMENT) {
359
- let drawn = sampleEnvironmentDirection(u);
475
+ // From inside a room, part of the time through a window: daylight comes in there or not at all.
476
+ let share = portalChance(position);
477
+ var direction = vec3<f32>(0.0);
478
+
479
+ if (u.x < share) {
480
+ direction = samplePortal(position, vec2<f32>(u.x / share, u.y));
481
+ } else {
482
+ let drawn = sampleEnvironmentDirection(vec2<f32>(min((u.x - share) / (1.0 - share), 0.99999994), u.y));
483
+
484
+ if (drawn.pdf > 0.0) {
485
+ direction = drawn.direction;
486
+ }
487
+ }
488
+
489
+ let mixed = skyPdf(position, direction, share);
360
490
 
361
- if (drawn.pdf <= 0.0) {
491
+ if (all(direction == vec3<f32>(0.0)) || mixed <= 0.0) {
362
492
  return noLight();
363
493
  }
364
494
 
365
- let density = pdf * drawn.pdf;
495
+ let density = pdf * mixed;
366
496
 
367
- sample.direction = drawn.direction;
497
+ sample.direction = direction;
368
498
  sample.distance = 1e30;
369
- sample.value = environmentRadiance(drawn.direction) / density;
499
+ sample.value = environmentRadiance(direction) / density;
370
500
  // A bounce ray that escapes finds the same light, so the two strategies share it.
371
501
  sample.misPdf = density;
372
502