@tinypoly/gpu-renderer 0.1.0 → 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 (99) hide show
  1. package/ARCHITECTURE.md +31 -3
  2. package/README.md +13 -4
  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 +6 -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/lightTree.d.ts +10 -3
  26. package/dist/lightTree.d.ts.map +1 -1
  27. package/dist/lightTree.js +56 -17
  28. package/dist/lightTree.js.map +1 -1
  29. package/dist/lights.d.ts +20 -0
  30. package/dist/lights.d.ts.map +1 -1
  31. package/dist/lights.js +48 -1
  32. package/dist/lights.js.map +1 -1
  33. package/dist/oidnPass.d.ts +48 -0
  34. package/dist/oidnPass.d.ts.map +1 -0
  35. package/dist/oidnPass.js +255 -0
  36. package/dist/oidnPass.js.map +1 -0
  37. package/dist/pathTracePass.d.ts +2 -2
  38. package/dist/pathTracePass.d.ts.map +1 -1
  39. package/dist/pathTracePass.js +9 -9
  40. package/dist/pathTracePass.js.map +1 -1
  41. package/dist/presentPass.d.ts +2 -0
  42. package/dist/presentPass.d.ts.map +1 -1
  43. package/dist/presentPass.js +1 -0
  44. package/dist/presentPass.js.map +1 -1
  45. package/dist/renderer.d.ts +20 -0
  46. package/dist/renderer.d.ts.map +1 -1
  47. package/dist/renderer.js +138 -19
  48. package/dist/renderer.js.map +1 -1
  49. package/dist/sceneUpload.d.ts +5 -0
  50. package/dist/sceneUpload.d.ts.map +1 -1
  51. package/dist/sceneUpload.js +4 -0
  52. package/dist/sceneUpload.js.map +1 -1
  53. package/dist/settings.d.ts +29 -1
  54. package/dist/settings.d.ts.map +1 -1
  55. package/dist/settings.js +15 -6
  56. package/dist/settings.js.map +1 -1
  57. package/dist/wgsl/lens.wgsl.d.ts +1 -1
  58. package/dist/wgsl/lens.wgsl.d.ts.map +1 -1
  59. package/dist/wgsl/lens.wgsl.js +110 -17
  60. package/dist/wgsl/lens.wgsl.js.map +1 -1
  61. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  62. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  63. package/dist/wgsl/lights.wgsl.js +197 -33
  64. package/dist/wgsl/lights.wgsl.js.map +1 -1
  65. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  66. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  67. package/dist/wgsl/pathTrace.wgsl.js +173 -131
  68. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  69. package/dist/wgsl/present.wgsl.d.ts +1 -1
  70. package/dist/wgsl/present.wgsl.d.ts.map +1 -1
  71. package/dist/wgsl/present.wgsl.js +11 -1
  72. package/dist/wgsl/present.wgsl.js.map +1 -1
  73. package/dist/wgsl/random.wgsl.d.ts +1 -1
  74. package/dist/wgsl/random.wgsl.d.ts.map +1 -1
  75. package/dist/wgsl/random.wgsl.js +28 -0
  76. package/dist/wgsl/random.wgsl.js.map +1 -1
  77. package/package.json +4 -2
  78. package/src/denoise.ts +15 -1
  79. package/src/device.ts +9 -0
  80. package/src/frameLayout.ts +6 -1
  81. package/src/imageResolve.ts +2 -0
  82. package/src/index.ts +2 -1
  83. package/src/lensPass.ts +17 -1
  84. package/src/lightTree.ts +67 -21
  85. package/src/lights.ts +65 -1
  86. package/src/oidnPass.ts +284 -0
  87. package/src/oidnWebgpu.d.ts +5 -0
  88. package/src/pathTracePass.ts +11 -9
  89. package/src/presentPass.ts +3 -0
  90. package/src/renderer.ts +161 -20
  91. package/src/sceneUpload.ts +9 -0
  92. package/src/settings.ts +40 -6
  93. package/src/wgsl/lens.wgsl.ts +110 -17
  94. package/src/wgsl/lights.wgsl.ts +197 -33
  95. package/src/wgsl/pathTrace.wgsl.ts +173 -131
  96. package/src/wgsl/present.wgsl.ts +11 -1
  97. package/src/wgsl/random.wgsl.ts +28 -0
  98. package/weights/LICENSE.txt +202 -0
  99. package/weights/rt_hdr_alb_nrm.tza +0 -0
@@ -35,6 +35,10 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
35
35
  { name: "localLightCount", type: "u32", doc: "How many leading lights sit in the scene, which the light tree picks." },
36
36
  { name: "localLightPick", type: "f32", doc: "The chance of picking one of them, the last one's cumulative probability." },
37
37
  { name: "lightTree", type: "u32", doc: "Where the light tree's nodes start in the texture buffer; zero picks by power alone." },
38
+ { name: "portals", type: "u32", doc: "Where the tree of the windows the sky is sampled through starts in the texture buffer." },
39
+ { name: "portalCount", type: "u32", doc: "How many window triangles there are; zero samples the sky by its brightness alone." },
40
+ { name: "portalList", type: "u32", doc: "Where the windows of that tree's leaves are listed in the texture buffer." },
41
+ { name: "lightList", type: "u32", doc: "Where the lights of the tree's leaves are listed in the texture buffer." },
38
42
  { name: "lightLeaves", type: "u32", doc: "Where the leaf of every local light starts in the texture buffer." },
39
43
  { name: "clampIndirect", type: "f32", doc: "Maximum luminance one indirect contribution may add; zero leaves it." },
40
44
  { name: "shadowSpread", type: "f32", doc: "Tangent of half the angular diameter shadow rays spread over." },
@@ -54,7 +58,7 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
54
58
  { name: "skyProgram", type: "u32", doc: "A procedural sky's foreground program, or NO_PROGRAM." },
55
59
  { name: "skyData", type: "u32" },
56
60
  { name: "skyBackground", type: "u32", doc: "Whether a procedural sky paints the background (1) or leaves it black (0)." },
57
- { name: "skyIntensity", type: "f32", doc: "The strength a procedural sky lights with, which its background ignores." },
61
+ { name: "skyIntensity", type: "f32", doc: "The strength a procedural sky lights with, and shows with as the background." },
58
62
  { name: "pixelCount", type: "u32", doc: "Where the alpha half of the accumulation buffer starts." },
59
63
  { name: "volumetricCount", type: "u32", doc: "Lights that scatter in the air, and where their records start." },
60
64
  { name: "volumetricData", type: "u32" },
@@ -63,6 +67,7 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
63
67
  { name: "lensDistortion", type: "f32", doc: "Radial distortion of the camera ray: positive is barrel, negative pincushion." },
64
68
  { name: "rouletteThreshold", type: "f32", doc: "Throughput under which a path may be ended by chance; zero never." },
65
69
  { name: "rouletteStart", type: "u32", doc: "Bounces every path takes before the roulette may end it." },
70
+ { name: "subsurfaceDepth", type: "u32", doc: "Bounces at which light still walks inside a material; deeper it reflects." },
66
71
  { name: "subsurfaceEnabled", type: "u32", doc: "Whether materials that scatter light inside do; 0 renders them opaque." },
67
72
  { name: "bevelEnabled", type: "u32", doc: "Whether materials with a bevel radius round their edges." },
68
73
  { name: "volumesEnabled", type: "u32", doc: "Whether volumes scatter light; 0 leaves their meshes out altogether." },
@@ -62,6 +62,7 @@ export interface ResolveSettings {
62
62
  exposure: number;
63
63
  toneMapping: RenderToneMapping;
64
64
  vignette: number;
65
+ contrast: number;
65
66
  transparent: boolean;
66
67
  }
67
68
 
@@ -92,6 +93,7 @@ export class ImageResolvePass {
92
93
  floats[4] = settings.vignette;
93
94
  integers[7] = settings.transparent ? 1 : 0;
94
95
  integers[8] = denoised ? 1 : 0;
96
+ floats[9] = settings.contrast;
95
97
  this.device.queue.writeBuffer(this.uniforms, 0, this.values);
96
98
 
97
99
  const bytes = width * height * 4;
package/src/index.ts CHANGED
@@ -19,7 +19,8 @@ export type {
19
19
  export { attachCanvas } from "./canvas.js";
20
20
 
21
21
  export { DEFAULT_DENOISER } from "./denoise.js";
22
- export type { DenoiserSettings } from "./denoise.js";
22
+ export { DEFAULT_OIDN_WEIGHTS } from "./oidnPass.js";
23
+ export type { DenoiserAlgorithm, DenoiserSettings } from "./denoise.js";
23
24
 
24
25
  // The settings only this renderer reads, beside the CPU renderer's, and the defaults it renders with.
25
26
  export {
package/src/lensPass.ts CHANGED
@@ -27,6 +27,8 @@ export interface LensEffects {
27
27
  glareAngle: number;
28
28
  /** A linear sRGB matrix, row by row, or `null` for a neutral white balance. */
29
29
  whiteBalance: number[] | null;
30
+ /** Whether the image is metered and exposed to middle grey before the display transform. */
31
+ autoExposure: boolean;
30
32
  }
31
33
 
32
34
  /** The lens effects these settings ask for, or `null` when they ask for none and the pass can be skipped. */
@@ -38,7 +40,9 @@ export function lensEffects(settings: GpuRenderSettings): LensEffects | null {
38
40
  const neutral = !Number.isFinite(camera.whiteBalance) || camera.whiteBalance === NEUTRAL_WHITE_BALANCE;
39
41
  const blades = Math.min(16, Math.max(3, Math.round(Number(glare.blades) || 6)));
40
42
 
41
- if (intensity <= 0 && streaks <= 0 && aberration <= 0 && neutral) return null;
43
+ const autoExposure = camera.autoExposure === true;
44
+
45
+ if (intensity <= 0 && streaks <= 0 && aberration <= 0 && neutral && !autoExposure) return null;
42
46
 
43
47
  return {
44
48
  bloom: intensity,
@@ -50,6 +54,7 @@ export function lensEffects(settings: GpuRenderSettings): LensEffects | null {
50
54
  glareLength: Math.min(1, Math.max(0, Number(glare.length) || 0)),
51
55
  glareAngle: (Number(glare.rotation) || 0) * Math.PI / 180,
52
56
  whiteBalance: neutral ? null : whiteBalanceMatrix(camera.whiteBalance),
57
+ autoExposure,
53
58
  };
54
59
  }
55
60
 
@@ -93,6 +98,8 @@ export class LensPass {
93
98
  private readonly upsample: GPUComputePipeline;
94
99
  private readonly composite: GPUComputePipeline;
95
100
  private readonly streaks: GPUComputePipeline;
101
+ private readonly meter: GPUComputePipeline;
102
+ private readonly metered: GPUBuffer;
96
103
  /** One uniform buffer per dispatch, so every one of them keeps its own values until the queue runs it. */
97
104
  private readonly uniforms: GPUBuffer[] = [];
98
105
  private readonly values = new ArrayBuffer(UNIFORM_BYTES);
@@ -116,6 +123,7 @@ export class LensPass {
116
123
  { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
117
124
  { binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
118
125
  { binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
126
+ { binding: 7, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
119
127
  ],
120
128
  });
121
129
 
@@ -126,6 +134,8 @@ export class LensPass {
126
134
  this.upsample = pipeline("upsample");
127
135
  this.composite = pipeline("composite");
128
136
  this.streaks = pipeline("streaks");
137
+ this.meter = pipeline("meter");
138
+ this.metered = device.createBuffer({ label: "lens meter", size: 16, usage: GPUBufferUsage.STORAGE });
129
139
  }
130
140
 
131
141
  /** The graded image of the last `encode`, one vec4 of linear colour per pixel. */
@@ -201,6 +211,7 @@ export class LensPass {
201
211
  integers[32] = streak.width;
202
212
  integers[33] = streak.height;
203
213
  floats[34] = effects.glareAngle;
214
+ floats[35] = effects.autoExposure ? 1 : 0;
204
215
  for (let row = 0; row < 3; row++) for (let column = 0; column < 3; column++)
205
216
  floats[20 + row * 4 + column] = white[row * 3 + column];
206
217
  fill();
@@ -222,6 +233,7 @@ export class LensPass {
222
233
  { binding: 4, resource: { buffer: this.up as GPUBuffer } },
223
234
  { binding: 5, resource: { buffer: this.graded as GPUBuffer } },
224
235
  { binding: 6, resource: { buffer: this.streaked as GPUBuffer } },
236
+ { binding: 7, resource: { buffer: this.metered } },
225
237
  ],
226
238
  }));
227
239
  pass.dispatchWorkgroups(Math.ceil(target.width / WORKGROUP), Math.ceil(target.height / WORKGROUP));
@@ -278,6 +290,9 @@ export class LensPass {
278
290
  }
279
291
  }
280
292
 
293
+ // The meter is one workgroup, whatever the size: dispatched as a single tile of the grid it reads.
294
+ if (effects.autoExposure) dispatch(this.meter, { width: 1, height: 1 }, () => {});
295
+
281
296
  dispatch(this.composite, { width, height }, () => {});
282
297
 
283
298
  return this.graded as GPUBuffer;
@@ -293,6 +308,7 @@ export class LensPass {
293
308
 
294
309
  destroy() {
295
310
  this.destroyBuffers();
311
+ this.metered.destroy();
296
312
  for (const buffer of this.uniforms) buffer.destroy();
297
313
  this.uniforms.length = 0;
298
314
  }
package/src/lightTree.ts CHANGED
@@ -24,9 +24,23 @@ export interface TreeLight {
24
24
  /** Sixteen words a node: low and power, high and cos theta_o, axis and cos theta_e, then the links. */
25
25
  export const TREE_NODE_WORDS = 16;
26
26
 
27
- /** The high bit of a node's first link marks a leaf, whose low bits are its light's index. */
27
+ /**
28
+ * The high bit of a node's first link marks a leaf, whose low bits say where its lights start in the list after the
29
+ * nodes; its second link says how many there are.
30
+ */
28
31
  export const TREE_LEAF = 0x80000000;
29
32
 
33
+ /** Four words a light in a leaf's list: the light, its chance within the leaf, its area density, the running chance. */
34
+ export const TREE_ENTRY_WORDS = 4;
35
+
36
+ /**
37
+ * A leaf holds up to this many lights, when they are so close together, against how far the lights spread, that from
38
+ * any point the tree could not tell them apart: the triangles of one small lamp. Lights further apart each keep a leaf,
39
+ * which keeps every choice between them by distance and facing.
40
+ */
41
+ const LEAF_LIGHTS = 8;
42
+ const LEAF_EXTENT = 0.02;
43
+
30
44
  export const TREE_ROOT_PARENT = 0xffffffff;
31
45
 
32
46
  interface Cone {
@@ -77,18 +91,22 @@ interface Node {
77
91
  left: number;
78
92
  right: number;
79
93
  parent: number;
80
- /** The light of a leaf, or -1. */
81
- light: number;
82
- density: number;
94
+ /** The lights of a leaf, or none. */
95
+ lights: number[];
83
96
  }
84
97
 
85
98
  /**
86
- * The tree over these lights, as words for the texture buffer, and the leaf each light ended in. Split at the median
87
- * of the longest axis of the centres, which needs no cost model and keeps the depth near log2 of the count.
99
+ * The tree over these lights, as words for the texture buffer, the leaf and list slot each light ended in, and where
100
+ * the list of the leaves' lights starts. Split at the middle of the longest axis of the centres, which needs no cost
101
+ * model and keeps a tight group, such as one lamp's triangles, on one side to share a leaf.
88
102
  */
89
- export function buildLightTree(lights: readonly TreeLight[]): { words: Uint32Array; leafOf: Uint32Array } {
103
+ export function buildLightTree(lights: readonly TreeLight[]): {
104
+ words: Uint32Array; leafOf: Uint32Array; listAt: number;
105
+ } {
90
106
  const nodes: Node[] = [];
91
- const leafOf = new Uint32Array(lights.length);
107
+ // Two words a light: its leaf, and where it sits in the list of lights after the nodes.
108
+ const leafOf = new Uint32Array(lights.length * 2);
109
+ let span = 0;
92
110
 
93
111
  const build = (indices: number[], parent: number): number => {
94
112
  const index = nodes.length;
@@ -111,29 +129,35 @@ export function buildLightTree(lights: readonly TreeLight[]): { words: Uint32Arr
111
129
  cone = cone ? unionCone(cone, own) : own;
112
130
  }
113
131
 
114
- const node: Node = { low, high, power, cone: cone as Cone, left: -1, right: -1, parent, light: -1, density: 1 };
132
+ const node: Node = { low, high, power, cone: cone as Cone, left: -1, right: -1, parent, lights: [] };
115
133
 
116
134
  nodes.push(node);
117
135
 
118
- if (indices.length === 1) {
119
- node.light = indices[0];
120
- node.density = lights[indices[0]].density;
121
- leafOf[indices[0]] = index;
136
+ const extent = Math.hypot(high[0] - low[0], high[1] - low[1], high[2] - low[2]);
137
+
138
+ if (index === 0) span = extent;
139
+
140
+ if (indices.length === 1 || (indices.length <= LEAF_LIGHTS && extent <= span * LEAF_EXTENT)) {
141
+ node.lights = indices;
122
142
 
123
143
  return index;
124
144
  }
125
145
 
126
146
  const centre = (i: number, k: number) => (lights[i].low[k] + lights[i].high[k]) / 2;
127
147
 
128
- const extent = [0, 1, 2].map(k => {
148
+ const spread = [0, 1, 2].map(k => {
129
149
  const values = indices.map(i => centre(i, k));
130
150
 
131
151
  return Math.max(...values) - Math.min(...values);
132
152
  });
133
153
 
134
- const axis = extent.indexOf(Math.max(...extent));
154
+ const axis = spread.indexOf(Math.max(...spread));
135
155
  const sorted = [...indices].sort((a, b) => centre(a, axis) - centre(b, axis));
136
- const half = sorted.length >> 1;
156
+ // Split at the middle of the extent, which keeps a tight group of lights together on one side; the median only
157
+ // when that leaves a side empty, as lights all at one place do.
158
+ const middle = (centre(sorted[0], axis) + centre(sorted[sorted.length - 1], axis)) / 2;
159
+ const below = sorted.findIndex(i => centre(i, axis) > middle);
160
+ const half = below > 0 ? below : sorted.length >> 1;
137
161
 
138
162
  node.left = build(sorted.slice(0, half), index);
139
163
  node.right = build(sorted.slice(half), index);
@@ -143,8 +167,11 @@ export function buildLightTree(lights: readonly TreeLight[]): { words: Uint32Arr
143
167
 
144
168
  if (lights.length) build(lights.map((_, i) => i), TREE_ROOT_PARENT);
145
169
 
146
- const words = new Uint32Array(nodes.length * TREE_NODE_WORDS);
170
+ const listAt = nodes.length * TREE_NODE_WORDS;
171
+ const entries = nodes.reduce((sum, node) => sum + node.lights.length, 0);
172
+ const words = new Uint32Array(listAt + entries * TREE_ENTRY_WORDS);
147
173
  const floats = new Float32Array(words.buffer);
174
+ let entry = 0;
148
175
 
149
176
  nodes.forEach((node, k) => {
150
177
  const at = k * TREE_NODE_WORDS;
@@ -157,9 +184,28 @@ export function buildLightTree(lights: readonly TreeLight[]): { words: Uint32Arr
157
184
  floats.set(node.cone.axis, at + 8);
158
185
  floats[at + 11] = Math.cos(Math.min(node.cone.thetaE, Math.PI));
159
186
 
160
- if (node.light >= 0) {
161
- words[at + 12] = (TREE_LEAF | node.light) >>> 0;
162
- floats[at + 13] = node.density;
187
+ if (node.lights.length) {
188
+ words[at + 12] = (TREE_LEAF | entry) >>> 0;
189
+ words[at + 13] = node.lights.length;
190
+
191
+ // Within a leaf, by power: the lights are too close together for anything else to tell them apart.
192
+ const total = node.lights.reduce((sum, i) => sum + lights[i].power, 0);
193
+ let running = 0;
194
+
195
+ node.lights.forEach((light, j) => {
196
+ const chance = total > 0 ? lights[light].power / total : 1 / node.lights.length;
197
+ const slot = listAt + entry * TREE_ENTRY_WORDS;
198
+
199
+ running += chance;
200
+ words[slot] = light;
201
+ floats[slot + 1] = chance;
202
+ floats[slot + 2] = lights[light].density;
203
+ // The last closes the range exactly, so a unit sample never falls past it.
204
+ floats[slot + 3] = j === node.lights.length - 1 ? 1 : running;
205
+ leafOf[light * 2] = k;
206
+ leafOf[light * 2 + 1] = entry;
207
+ entry++;
208
+ });
163
209
  } else {
164
210
  words[at + 12] = node.left;
165
211
  words[at + 13] = node.right;
@@ -168,5 +214,5 @@ export function buildLightTree(lights: readonly TreeLight[]): { words: Uint32Arr
168
214
  words[at + 14] = node.parent >>> 0;
169
215
  });
170
216
 
171
- return { words, leafOf };
217
+ return { words, leafOf, listAt };
172
218
  }
package/src/lights.ts CHANGED
@@ -71,6 +71,51 @@ export interface PackedLights {
71
71
  /** Where the light tree's nodes start in the texture buffer, and the leaf of every local entry; zero for no tree. */
72
72
  treeAt: number;
73
73
  leavesAt: number;
74
+ /** Where the lights of the tree's leaves are listed in the texture buffer. */
75
+ listAt: number;
76
+ /**
77
+ * The windows the sky is also sampled through, from inside: the triangles of every thin transmissive material, in a
78
+ * tree of their own, as the lights have, so a point aims at the windows near it rather than at every one in the
79
+ * building alike. Where its nodes start, where its leaves' list starts, and how many windows there are.
80
+ */
81
+ portalTreeAt: number;
82
+ portalListAt: number;
83
+ portalCount: number;
84
+ }
85
+
86
+ /**
87
+ * The triangles of thin glass, which is where daylight enters a room, as the tree sees them: their bounds, their area
88
+ * as power, so a window weighs by the solid angle it takes from a point, and no orientation, since a pane lets the sky
89
+ * through from either side.
90
+ */
91
+ export function portalTriangles(geometry: PackedGeometry): { triangles: number[]; lights: TreeLight[] } {
92
+ const windows = geometry.materialProperties.map(p => p.transmission > 0 && p.thickness <= 0);
93
+ const triangles: number[] = [], lights: TreeLight[] = [];
94
+
95
+ for (let triangle = 0; triangle < geometry.triangleCount; triangle++) {
96
+ if (!windows[geometry.materials[triangle]]) continue;
97
+
98
+ const at = triangle * 9;
99
+ const area = doubleArea(geometry.positions, at) / 2;
100
+
101
+ if (!(area > 0)) continue;
102
+
103
+ const corner = (k: number) => [0, 1, 2].map(c => geometry.positions[at + k * 3 + c]);
104
+ const points = [corner(0), corner(1), corner(2)];
105
+
106
+ triangles.push(triangle);
107
+ lights.push({
108
+ low: [0, 1, 2].map(c => Math.min(points[0][c], points[1][c], points[2][c])),
109
+ high: [0, 1, 2].map(c => Math.max(points[0][c], points[1][c], points[2][c])),
110
+ power: area,
111
+ axis: [0, 1, 0],
112
+ thetaO: Math.PI,
113
+ thetaE: Math.PI / 2,
114
+ density: 1 / area,
115
+ });
116
+ }
117
+
118
+ return { triangles, lights };
74
119
  }
75
120
 
76
121
  const luminance = (color: ArrayLike<number>) => 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2];
@@ -425,22 +470,41 @@ export function packLights(
425
470
 
426
471
  for (let index = 0; index < areas.length; index++) extra.push(words[index * LIGHT_WORDS + 3]);
427
472
 
428
- let treeAt = 0, leavesAt = 0;
473
+ let treeAt = 0, leavesAt = 0, listAt = 0;
429
474
 
430
475
  if (localCount > 0) {
431
476
  const tree = buildLightTree(candidates.slice(0, localCount).map(candidate => candidate.tree as TreeLight));
432
477
 
433
478
  treeAt = base + extra.length;
479
+ listAt = treeAt + tree.listAt;
434
480
  for (const word of tree.words) extra.push(word);
435
481
  leavesAt = base + extra.length;
436
482
  for (const leaf of tree.leafOf) extra.push(leaf);
437
483
  }
438
484
 
485
+ const portals = environment && environment.power > 0 ? portalTriangles(geometry) : { triangles: [], lights: [] };
486
+ let portalTreeAt = 0, portalListAt = 0;
487
+
488
+ if (portals.triangles.length) {
489
+ const tree = buildLightTree(portals.lights);
490
+
491
+ portalTreeAt = base + extra.length;
492
+ portalListAt = portalTreeAt + tree.listAt;
493
+
494
+ // The leaves list window numbers; the kernel wants the triangles they stand for.
495
+ for (let k = tree.listAt; k < tree.words.length; k += 4) tree.words[k] = portals.triangles[tree.words[k]];
496
+ for (const word of tree.words) extra.push(word);
497
+ }
498
+
439
499
  return {
500
+ portalTreeAt,
501
+ portalListAt,
502
+ portalCount: portals.triangles.length,
440
503
  localCount,
441
504
  localPick: localCount > 0 ? floats[(localCount - 1) * LIGHT_WORDS + 1] : 0,
442
505
  treeAt,
443
506
  leavesAt,
507
+ listAt,
444
508
  cdfAt,
445
509
  trianglesAt,
446
510
  entries,
@@ -0,0 +1,284 @@
1
+ import type { UNet } from "oidn-webgpu";
2
+ import { initUNetFromURL } from "oidn-webgpu/dist/oidn.js";
3
+ import { createShaderModule } from "./shaderModule.js";
4
+
5
+ /**
6
+ * The weights the OIDN denoiser loads by default: the HDR ray tracing network that takes the albedo and the normal as
7
+ * well as the colour (RenderKit's oidn-weights, Apache-2.0), shipped with the package. A bundler that moves the package
8
+ * elsewhere, or an app that serves its own copy, passes `denoiser.oidnWeights` instead.
9
+ */
10
+ export const DEFAULT_OIDN_WEIGHTS = new URL("../weights/rt_hdr_alb_nrm.tza", import.meta.url).href;
11
+
12
+ const WORKGROUP = 8;
13
+ const METER_GRID = [64, 36] as const;
14
+
15
+ /**
16
+ * Reads the accumulation into the three images OIDN takes, and back.
17
+ *
18
+ * The network was trained on images whose average sits near middle grey, which its own CPU path reaches by an
19
+ * automatic exposure; on the device that scale is left to the caller. `meter` takes the mean of the logarithms of the
20
+ * image's luminance over a grid, as the lens pass's meter does, `prepare` writes the colour times the scale that brings
21
+ * it to middle grey, the albedo (diffuse and specular, so a mirror is not taken for black) and the normal, and `finish`
22
+ * divides the network's output by the same scale and mixes it with the traced image by the strength.
23
+ */
24
+ export const OIDN_WGSL = /* wgsl */ `
25
+ struct Params {
26
+ size: vec2<u32>,
27
+ count: u32,
28
+ strength: f32,
29
+ };
30
+
31
+ @group(0) @binding(0) var<uniform> params: Params;
32
+ @group(0) @binding(1) var<storage, read> accumulation: array<vec4<f32>>;
33
+ @group(0) @binding(2) var<storage, read_write> color: array<vec4<f32>>;
34
+ @group(0) @binding(3) var<storage, read_write> albedo: array<vec4<f32>>;
35
+ @group(0) @binding(4) var<storage, read_write> normal: array<vec4<f32>>;
36
+ @group(0) @binding(5) var<storage, read_write> scale: array<f32>;
37
+
38
+ const MIDDLE_GREY: f32 = 0.18;
39
+ const GRID: vec2<u32> = vec2<u32>(${METER_GRID[0]}u, ${METER_GRID[1]}u);
40
+
41
+ var<workgroup> sums: array<vec2<f32>, 256>;
42
+
43
+ fn luminance(value: vec3<f32>) -> f32 {
44
+ return dot(value, vec3<f32>(0.2126, 0.7152, 0.0722));
45
+ }
46
+
47
+ fn mean(index: u32) -> vec3<f32> {
48
+ let stored = accumulation[index];
49
+
50
+ return select(vec3<f32>(0.0), stored.rgb / stored.w, stored.w > 0.0);
51
+ }
52
+
53
+ @compute @workgroup_size(256, 1, 1)
54
+ fn meter(@builtin(local_invocation_index) thread: u32) {
55
+ var sum = vec2<f32>(0.0);
56
+
57
+ for (var k = thread; k < GRID.x * GRID.y; k = k + 256u) {
58
+ let uv = (vec2<f32>(f32(k % GRID.x), f32(k / GRID.x)) + 0.5) / vec2<f32>(GRID);
59
+ let pixel = min(vec2<u32>(uv * vec2<f32>(params.size)), params.size - 1u);
60
+ let value = luminance(mean(pixel.y * params.size.x + pixel.x));
61
+
62
+ if (value > 0.0) {
63
+ sum = sum + vec2<f32>(log(value), 1.0);
64
+ }
65
+ }
66
+
67
+ sums[thread] = sum;
68
+ workgroupBarrier();
69
+
70
+ for (var stride = 128u; stride > 0u; stride = stride / 2u) {
71
+ if (thread < stride) {
72
+ sums[thread] = sums[thread] + sums[thread + stride];
73
+ }
74
+
75
+ workgroupBarrier();
76
+ }
77
+
78
+ if (thread == 0u) {
79
+ let total = sums[0];
80
+
81
+ scale[0] = select(1.0, clamp(MIDDLE_GREY / exp(total.x / total.y), 1e-6, 1e6), total.y > 0.0);
82
+ }
83
+ }
84
+
85
+ @compute @workgroup_size(${WORKGROUP}, ${WORKGROUP})
86
+ fn prepare(@builtin(global_invocation_id) id: vec3<u32>) {
87
+ if (id.x >= params.size.x || id.y >= params.size.y) {
88
+ return;
89
+ }
90
+
91
+ let index = id.y * params.size.x + id.x;
92
+ let samples = max(accumulation[index].w, 1.0);
93
+ let traced = mean(index) * scale[0];
94
+ let normalSum = accumulation[3u * params.count + index].xyz;
95
+ let size = length(normalSum);
96
+ let surface = size > 1e-6;
97
+ let reflectance = (accumulation[2u * params.count + index].rgb + accumulation[5u * params.count + index].rgb)
98
+ / samples;
99
+
100
+ color[index] = vec4<f32>(max(traced, vec3<f32>(0.0)), 1.0);
101
+ // Where the camera sees no surface, the sky, the albedo is the colour itself, as OIDN asks of the background.
102
+ albedo[index] = vec4<f32>(select(clamp(traced, vec3<f32>(0.0), vec3<f32>(1.0)),
103
+ clamp(reflectance, vec3<f32>(0.0), vec3<f32>(1.0)), surface), 1.0);
104
+ normal[index] = vec4<f32>(select(vec3<f32>(0.0), normalSum / size, surface), 0.0);
105
+ }
106
+
107
+ // The network's output arrives in the colour binding; the albedo binding takes the result.
108
+ @compute @workgroup_size(${WORKGROUP}, ${WORKGROUP})
109
+ fn finish(@builtin(global_invocation_id) id: vec3<u32>) {
110
+ if (id.x >= params.size.x || id.y >= params.size.y) {
111
+ return;
112
+ }
113
+
114
+ let index = id.y * params.size.x + id.x;
115
+ let traced = mean(index);
116
+ let denoised = max(color[index].rgb, vec3<f32>(0.0)) / scale[0];
117
+
118
+ albedo[index] = vec4<f32>(mix(traced, denoised, clamp(params.strength, 0.0, 1.0)), 1.0);
119
+ }
120
+ `;
121
+
122
+ const networks = new WeakMap<GPUDevice, Map<string, Promise<UNet>>>();
123
+
124
+ /** One network per device and weights file, kept for as long as the device lives: loading it compiles its kernels. */
125
+ function network(device: GPUDevice, weights: string): Promise<UNet> {
126
+ let byUrl = networks.get(device);
127
+
128
+ if (!byUrl) networks.set(device, byUrl = new Map());
129
+
130
+ let unet = byUrl.get(weights);
131
+
132
+ if (!unet) {
133
+ unet = initUNetFromURL(weights, { device }, { aux: true, hdr: true });
134
+ // A file that failed to load may load on a later attempt.
135
+ unet.catch(() => byUrl.delete(weights));
136
+ byUrl.set(weights, unet);
137
+ }
138
+
139
+ return unet;
140
+ }
141
+
142
+ /**
143
+ * Open Image Denoise (Intel's neural denoiser, through oidn-webgpu): the image, its albedo and its normal through a
144
+ * U-Net trained on path traced images, entirely on the device. It keeps texture and fine detail that a hand-made
145
+ * filter blurs, at the cost of a fraction of a second for a large image.
146
+ */
147
+ export class OidnPass {
148
+ private readonly layout: GPUBindGroupLayout;
149
+ private readonly meter: GPUComputePipeline;
150
+ private readonly prepare: GPUComputePipeline;
151
+ private readonly finish: GPUComputePipeline;
152
+ private readonly params: GPUBuffer;
153
+ private readonly scale: GPUBuffer;
154
+ private buffers: GPUBuffer[] = [];
155
+ private width = 0;
156
+ private height = 0;
157
+ /** The denoised radiance, one vec4 per pixel, once `run` has resolved. */
158
+ output: GPUBuffer | null = null;
159
+
160
+ constructor(private readonly device: GPUDevice) {
161
+ const module = createShaderModule(device, "oidn", OIDN_WGSL);
162
+ const visibility = GPUShaderStage.COMPUTE;
163
+ const storage = (type: GPUBufferBindingType) => ({ binding: 0, visibility, buffer: { type } });
164
+
165
+ this.layout = device.createBindGroupLayout({
166
+ entries: [
167
+ { binding: 0, visibility, buffer: { type: "uniform" } },
168
+ { ...storage("read-only-storage"), binding: 1 },
169
+ { ...storage("storage"), binding: 2 },
170
+ { ...storage("storage"), binding: 3 },
171
+ { ...storage("storage"), binding: 4 },
172
+ { ...storage("storage"), binding: 5 },
173
+ ],
174
+ });
175
+
176
+ const layout = device.createPipelineLayout({ bindGroupLayouts: [this.layout] });
177
+ const pipeline = (entryPoint: string) => device.createComputePipeline({ layout, compute: { module, entryPoint } });
178
+
179
+ this.meter = pipeline("meter");
180
+ this.prepare = pipeline("prepare");
181
+ this.finish = pipeline("finish");
182
+ this.params = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
183
+ this.scale = device.createBuffer({ size: 16, usage: GPUBufferUsage.STORAGE });
184
+ }
185
+
186
+ private resize(width: number, height: number) {
187
+ if (width === this.width && height === this.height && this.buffers.length) return;
188
+
189
+ for (const buffer of this.buffers) buffer.destroy();
190
+
191
+ const size = Math.max(16, width * height * 16);
192
+ const usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST;
193
+
194
+ // Colour, albedo and normal for the network, then the result.
195
+ this.buffers = Array.from({ length: 4 }, () => this.device.createBuffer({ size, usage }));
196
+ this.output = this.buffers[3];
197
+ this.width = width;
198
+ this.height = height;
199
+ }
200
+
201
+ private group(accumulation: GPUBuffer, color: GPUBuffer, albedo: GPUBuffer, normal: GPUBuffer): GPUBindGroup {
202
+ return this.device.createBindGroup({
203
+ layout: this.layout,
204
+ entries: [
205
+ { binding: 0, resource: { buffer: this.params } },
206
+ { binding: 1, resource: { buffer: accumulation } },
207
+ { binding: 2, resource: { buffer: color } },
208
+ { binding: 3, resource: { buffer: albedo } },
209
+ { binding: 4, resource: { buffer: normal } },
210
+ { binding: 5, resource: { buffer: this.scale } },
211
+ ],
212
+ });
213
+ }
214
+
215
+ private dispatch(encoder: GPUCommandEncoder, pipeline: GPUComputePipeline, group: GPUBindGroup, x: number,
216
+ y: number) {
217
+ const pass = encoder.beginComputePass({ label: "oidn" });
218
+
219
+ pass.setPipeline(pipeline);
220
+ pass.setBindGroup(0, group);
221
+ pass.dispatchWorkgroups(x, y);
222
+ pass.end();
223
+ }
224
+
225
+ /**
226
+ * Denoises what `accumulation` holds into `output`. The images are read from it as it stands on the queue when this
227
+ * is called; the promise resolves once the result has been written, and rejects when the network cannot load.
228
+ */
229
+ async run(accumulation: GPUBuffer, width: number, height: number, strength: number, weights: string,
230
+ signal?: { cancelled: boolean }): Promise<boolean> {
231
+ const unet = await network(this.device, weights);
232
+
233
+ if (signal?.cancelled) return false;
234
+
235
+ this.resize(width, height);
236
+
237
+ const [color, albedo, normal, output] = this.buffers;
238
+ const values = new ArrayBuffer(16);
239
+
240
+ new Uint32Array(values).set([width, height, width * height]);
241
+ new Float32Array(values)[3] = strength;
242
+ this.device.queue.writeBuffer(this.params, 0, values);
243
+
244
+ const groups = [Math.ceil(width / WORKGROUP), Math.ceil(height / WORKGROUP)] as const;
245
+ const inputs = this.group(accumulation, color, albedo, normal);
246
+ const encoder = this.device.createCommandEncoder({ label: "oidn prepare" });
247
+
248
+ this.dispatch(encoder, this.meter, inputs, 1, 1);
249
+ this.dispatch(encoder, this.prepare, inputs, ...groups);
250
+ this.device.queue.submit([encoder.finish()]);
251
+
252
+ const denoised = await new Promise<GPUBuffer | null>((resolve, reject) => {
253
+ const abort = unet.tileExecute({
254
+ color: { data: color, width, height },
255
+ albedo: { data: albedo, width, height },
256
+ normal: { data: normal, width, height },
257
+ done: result => resolve(result.data),
258
+ error: reject,
259
+ progress: () => {
260
+ if (signal?.cancelled) {
261
+ abort();
262
+ resolve(null);
263
+ }
264
+ },
265
+ });
266
+ });
267
+
268
+ if (!denoised || signal?.cancelled) return false;
269
+
270
+ const finishing = this.device.createCommandEncoder({ label: "oidn finish" });
271
+
272
+ this.dispatch(finishing, this.finish, this.group(accumulation, denoised, output, normal), ...groups);
273
+ this.device.queue.submit([finishing.finish()]);
274
+
275
+ return true;
276
+ }
277
+
278
+ destroy() {
279
+ for (const buffer of [...this.buffers, this.params, this.scale]) buffer.destroy();
280
+
281
+ this.buffers = [];
282
+ this.output = null;
283
+ }
284
+ }
@@ -0,0 +1,5 @@
1
+ // The package's lib/ imports its modules without extensions, which only a bundler resolves; its dist/ is one ES module
2
+ // that a browser and Node both load, with the same exports as lib/main.
3
+ declare module "oidn-webgpu/dist/oidn.js" {
4
+ export * from "oidn-webgpu";
5
+ }