@tinypoly/gpu-renderer 0.0.4 → 0.1.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 (140) hide show
  1. package/ARCHITECTURE.md +81 -10
  2. package/README.md +53 -5
  3. package/dist/atmosphere.d.ts +21 -0
  4. package/dist/atmosphere.d.ts.map +1 -0
  5. package/dist/atmosphere.js +72 -0
  6. package/dist/atmosphere.js.map +1 -0
  7. package/dist/bvh.d.ts +11 -6
  8. package/dist/bvh.d.ts.map +1 -1
  9. package/dist/bvh.js +64 -11
  10. package/dist/bvh.js.map +1 -1
  11. package/dist/denoise.d.ts +17 -2
  12. package/dist/denoise.d.ts.map +1 -1
  13. package/dist/denoise.js +95 -27
  14. package/dist/denoise.js.map +1 -1
  15. package/dist/dispatchScheduler.d.ts +11 -4
  16. package/dist/dispatchScheduler.d.ts.map +1 -1
  17. package/dist/dispatchScheduler.js +14 -7
  18. package/dist/dispatchScheduler.js.map +1 -1
  19. package/dist/frameLayout.d.ts.map +1 -1
  20. package/dist/frameLayout.js +18 -0
  21. package/dist/frameLayout.js.map +1 -1
  22. package/dist/geometry.d.ts.map +1 -1
  23. package/dist/geometry.js +6 -3
  24. package/dist/geometry.js.map +1 -1
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/lensPass.d.ts +60 -0
  30. package/dist/lensPass.d.ts.map +1 -0
  31. package/dist/lensPass.js +235 -0
  32. package/dist/lensPass.js.map +1 -0
  33. package/dist/lightTree.d.ts +43 -0
  34. package/dist/lightTree.d.ts.map +1 -0
  35. package/dist/lightTree.js +107 -0
  36. package/dist/lightTree.js.map +1 -0
  37. package/dist/lights.d.ts +19 -1
  38. package/dist/lights.d.ts.map +1 -1
  39. package/dist/lights.js +63 -1
  40. package/dist/lights.js.map +1 -1
  41. package/dist/materialLayout.d.ts +24 -0
  42. package/dist/materialLayout.d.ts.map +1 -1
  43. package/dist/materialLayout.js +36 -1
  44. package/dist/materialLayout.js.map +1 -1
  45. package/dist/pathTracePass.d.ts +6 -3
  46. package/dist/pathTracePass.d.ts.map +1 -1
  47. package/dist/pathTracePass.js +16 -10
  48. package/dist/pathTracePass.js.map +1 -1
  49. package/dist/renderer.d.ts +19 -7
  50. package/dist/renderer.d.ts.map +1 -1
  51. package/dist/renderer.js +198 -36
  52. package/dist/renderer.js.map +1 -1
  53. package/dist/sceneUpload.d.ts +19 -1
  54. package/dist/sceneUpload.d.ts.map +1 -1
  55. package/dist/sceneUpload.js +25 -3
  56. package/dist/sceneUpload.js.map +1 -1
  57. package/dist/settings.d.ts +136 -0
  58. package/dist/settings.d.ts.map +1 -0
  59. package/dist/settings.js +100 -0
  60. package/dist/settings.js.map +1 -0
  61. package/dist/subsurface.d.ts +76 -0
  62. package/dist/subsurface.d.ts.map +1 -0
  63. package/dist/subsurface.js +70 -0
  64. package/dist/subsurface.js.map +1 -0
  65. package/dist/texturePack.d.ts +16 -4
  66. package/dist/texturePack.d.ts.map +1 -1
  67. package/dist/texturePack.js +61 -8
  68. package/dist/texturePack.js.map +1 -1
  69. package/dist/volumeContainment.d.ts +17 -0
  70. package/dist/volumeContainment.d.ts.map +1 -0
  71. package/dist/volumeContainment.js +61 -0
  72. package/dist/volumeContainment.js.map +1 -0
  73. package/dist/wgsl/bsdf.wgsl.d.ts +1 -1
  74. package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -1
  75. package/dist/wgsl/bsdf.wgsl.js +35 -3
  76. package/dist/wgsl/bsdf.wgsl.js.map +1 -1
  77. package/dist/wgsl/bvh.wgsl.d.ts +8 -2
  78. package/dist/wgsl/bvh.wgsl.d.ts.map +1 -1
  79. package/dist/wgsl/bvh.wgsl.js +167 -96
  80. package/dist/wgsl/bvh.wgsl.js.map +1 -1
  81. package/dist/wgsl/common.wgsl.d.ts.map +1 -1
  82. package/dist/wgsl/common.wgsl.js +24 -7
  83. package/dist/wgsl/common.wgsl.js.map +1 -1
  84. package/dist/wgsl/lens.wgsl.d.ts +17 -0
  85. package/dist/wgsl/lens.wgsl.d.ts.map +1 -0
  86. package/dist/wgsl/lens.wgsl.js +315 -0
  87. package/dist/wgsl/lens.wgsl.js.map +1 -0
  88. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  89. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  90. package/dist/wgsl/lights.wgsl.js +206 -33
  91. package/dist/wgsl/lights.wgsl.js.map +1 -1
  92. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  93. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  94. package/dist/wgsl/pathTrace.wgsl.js +410 -25
  95. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  96. package/dist/wgsl/random.wgsl.d.ts +1 -1
  97. package/dist/wgsl/random.wgsl.d.ts.map +1 -1
  98. package/dist/wgsl/random.wgsl.js +9 -17
  99. package/dist/wgsl/random.wgsl.js.map +1 -1
  100. package/dist/wgsl/sampling.wgsl.d.ts +1 -1
  101. package/dist/wgsl/sampling.wgsl.d.ts.map +1 -1
  102. package/dist/wgsl/sampling.wgsl.js +25 -0
  103. package/dist/wgsl/sampling.wgsl.js.map +1 -1
  104. package/dist/wgsl/surface.wgsl.d.ts +1 -1
  105. package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
  106. package/dist/wgsl/surface.wgsl.js +47 -4
  107. package/dist/wgsl/surface.wgsl.js.map +1 -1
  108. package/dist/wgsl/texture.wgsl.d.ts +1 -1
  109. package/dist/wgsl/texture.wgsl.d.ts.map +1 -1
  110. package/dist/wgsl/texture.wgsl.js +10 -11
  111. package/dist/wgsl/texture.wgsl.js.map +1 -1
  112. package/package.json +1 -1
  113. package/src/atmosphere.ts +95 -0
  114. package/src/bvh.ts +81 -12
  115. package/src/denoise.ts +110 -27
  116. package/src/dispatchScheduler.ts +16 -7
  117. package/src/frameLayout.ts +18 -0
  118. package/src/geometry.ts +6 -2
  119. package/src/index.ts +13 -0
  120. package/src/lensPass.ts +299 -0
  121. package/src/lightTree.ts +172 -0
  122. package/src/lights.ts +90 -2
  123. package/src/materialLayout.ts +52 -1
  124. package/src/pathTracePass.ts +19 -10
  125. package/src/renderer.ts +258 -43
  126. package/src/sceneUpload.ts +41 -4
  127. package/src/settings.ts +234 -0
  128. package/src/subsurface.ts +153 -0
  129. package/src/texturePack.ts +80 -13
  130. package/src/volumeContainment.ts +81 -0
  131. package/src/wgsl/bsdf.wgsl.ts +35 -3
  132. package/src/wgsl/bvh.wgsl.ts +167 -96
  133. package/src/wgsl/common.wgsl.ts +24 -7
  134. package/src/wgsl/lens.wgsl.ts +314 -0
  135. package/src/wgsl/lights.wgsl.ts +206 -33
  136. package/src/wgsl/pathTrace.wgsl.ts +410 -25
  137. package/src/wgsl/random.wgsl.ts +9 -17
  138. package/src/wgsl/sampling.wgsl.ts +25 -0
  139. package/src/wgsl/surface.wgsl.ts +47 -4
  140. package/src/wgsl/texture.wgsl.ts +10 -11
@@ -0,0 +1,299 @@
1
+ import { createShaderModule } from "./shaderModule.js";
2
+ import { NEUTRAL_WHITE_BALANCE, whiteBalanceMatrix, type GpuRenderSettings } from "./settings.js";
3
+ import { LENS_WGSL } from "./wgsl/lens.wgsl.js";
4
+
5
+ const UNIFORM_BYTES = 144;
6
+
7
+ const WORKGROUP = 8;
8
+
9
+ /** Halving stops once a level would be smaller than this on its short side, or after this many levels. */
10
+ const SMALLEST_LEVEL = 4;
11
+ const MAX_LEVELS = 8;
12
+
13
+ /** Where chromatic aberration `1` moves red and blue apart, as a share of the distance from the centre. */
14
+ const ABERRATION_SCALE = 0.005;
15
+
16
+ /** What the lens pass is told about the lens: the settings that reach it, resolved. */
17
+ export interface LensEffects {
18
+ /** Share of the light the halo takes; zero leaves the pyramid unbuilt. */
19
+ bloom: number;
20
+ radius: number;
21
+ aberration: number;
22
+ /** Share of the light the aperture's rays take; zero gathers none. */
23
+ glare: number;
24
+ /** How many rays, how far they reach as a share of the half diagonal, and the first one's angle in radians. */
25
+ rays: number;
26
+ glareLength: number;
27
+ glareAngle: number;
28
+ /** A linear sRGB matrix, row by row, or `null` for a neutral white balance. */
29
+ whiteBalance: number[] | null;
30
+ }
31
+
32
+ /** The lens effects these settings ask for, or `null` when they ask for none and the pass can be skipped. */
33
+ export function lensEffects(settings: GpuRenderSettings): LensEffects | null {
34
+ const { bloom, glare, lens, camera } = settings;
35
+ const intensity = bloom.enabled ? Math.min(1, Math.max(0, bloom.intensity)) : 0;
36
+ const streaks = glare.enabled ? Math.min(1, Math.max(0, glare.intensity)) : 0;
37
+ const aberration = Math.max(0, lens.chromaticAberration) * ABERRATION_SCALE;
38
+ const neutral = !Number.isFinite(camera.whiteBalance) || camera.whiteBalance === NEUTRAL_WHITE_BALANCE;
39
+ const blades = Math.min(16, Math.max(3, Math.round(Number(glare.blades) || 6)));
40
+
41
+ if (intensity <= 0 && streaks <= 0 && aberration <= 0 && neutral) return null;
42
+
43
+ return {
44
+ bloom: intensity,
45
+ radius: Math.min(0.95, Math.max(0, bloom.radius)),
46
+ aberration,
47
+ glare: streaks,
48
+ // Each blade's edge diffracts into a ray each way: an even count's opposite edges share their rays.
49
+ rays: blades % 2 === 0 ? blades : blades * 2,
50
+ glareLength: Math.min(1, Math.max(0, Number(glare.length) || 0)),
51
+ glareAngle: (Number(glare.rotation) || 0) * Math.PI / 180,
52
+ whiteBalance: neutral ? null : whiteBalanceMatrix(camera.whiteBalance),
53
+ };
54
+ }
55
+
56
+ interface Level {
57
+ width: number;
58
+ height: number;
59
+ /** In texels, in both pyramids. */
60
+ offset: number;
61
+ }
62
+
63
+ /** Halved levels of the image, the largest first, until one would be too small to be worth filtering. */
64
+ export function pyramidLevels(width: number, height: number): Level[] {
65
+ const levels: Level[] = [];
66
+ let w = width, h = height, offset = 0;
67
+
68
+ while (levels.length < MAX_LEVELS) {
69
+ w = Math.max(1, Math.ceil(w / 2));
70
+ h = Math.max(1, Math.ceil(h / 2));
71
+
72
+ if (levels.length > 0 && Math.min(w, h) < SMALLEST_LEVEL) break;
73
+
74
+ levels.push({ width: w, height: h, offset });
75
+ offset += w * h;
76
+ }
77
+
78
+ return levels;
79
+ }
80
+
81
+ /** The level the aperture's rays are gathered at: a quarter of the image, where there is one. */
82
+ function streakLevel(levels: Level[]): Level {
83
+ return levels[Math.min(1, levels.length - 1)];
84
+ }
85
+
86
+ /**
87
+ * The lens and the sensor, between the radiance and its display transform: bloom, chromatic aberration and white
88
+ * balance, into a buffer of linear colour the presentation and the image resolve read in place of the radiance.
89
+ */
90
+ export class LensPass {
91
+ private readonly layout: GPUBindGroupLayout;
92
+ private readonly downsample: GPUComputePipeline;
93
+ private readonly upsample: GPUComputePipeline;
94
+ private readonly composite: GPUComputePipeline;
95
+ private readonly streaks: GPUComputePipeline;
96
+ /** One uniform buffer per dispatch, so every one of them keeps its own values until the queue runs it. */
97
+ private readonly uniforms: GPUBuffer[] = [];
98
+ private readonly values = new ArrayBuffer(UNIFORM_BYTES);
99
+ private down: GPUBuffer | null = null;
100
+ private up: GPUBuffer | null = null;
101
+ private graded: GPUBuffer | null = null;
102
+ private streaked: GPUBuffer | null = null;
103
+ private size = "";
104
+
105
+ constructor(private readonly device: GPUDevice) {
106
+ const module = createShaderModule(device, "lens", LENS_WGSL);
107
+ const storage = (type: GPUBufferBindingType): GPUBindGroupLayoutEntry["buffer"] => ({ type });
108
+
109
+ this.layout = device.createBindGroupLayout({
110
+ label: "lens",
111
+ entries: [
112
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
113
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: storage("read-only-storage") },
114
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: storage("read-only-storage") },
115
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
116
+ { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
117
+ { binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
118
+ { binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: storage("storage") },
119
+ ],
120
+ });
121
+
122
+ const layout = device.createPipelineLayout({ bindGroupLayouts: [this.layout] });
123
+ const pipeline = (entryPoint: string) => device.createComputePipeline({ layout, compute: { module, entryPoint } });
124
+
125
+ this.downsample = pipeline("downsample");
126
+ this.upsample = pipeline("upsample");
127
+ this.composite = pipeline("composite");
128
+ this.streaks = pipeline("streaks");
129
+ }
130
+
131
+ /** The graded image of the last `encode`, one vec4 of linear colour per pixel. */
132
+ get output(): GPUBuffer | null {
133
+ return this.graded;
134
+ }
135
+
136
+ private ensure(width: number, height: number, levels: Level[]) {
137
+ const key = `${width}x${height}`;
138
+
139
+ if (key === this.size && this.graded) return;
140
+
141
+ this.destroyBuffers();
142
+ this.size = key;
143
+
144
+ const last = levels[levels.length - 1];
145
+ const texels = Math.max(1, last.offset + last.width * last.height);
146
+ // A pyramid of one level is copied into the halo as it is, which the two need copy usage for.
147
+ const usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST;
148
+
149
+ this.down = this.device.createBuffer({ label: "lens pyramid", size: texels * 16, usage });
150
+ this.up = this.device.createBuffer({ label: "lens halo", size: texels * 16, usage });
151
+ this.graded = this.device.createBuffer({ label: "lens output", size: width * height * 16,
152
+ usage: GPUBufferUsage.STORAGE });
153
+
154
+ const streak = streakLevel(levels);
155
+
156
+ this.streaked = this.device.createBuffer({ label: "lens rays", size: streak.width * streak.height * 16,
157
+ usage: GPUBufferUsage.STORAGE });
158
+ }
159
+
160
+ private uniform(index: number): GPUBuffer {
161
+ while (this.uniforms.length <= index) {
162
+ this.uniforms.push(this.device.createBuffer({
163
+ size: UNIFORM_BYTES, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
164
+ }));
165
+ }
166
+
167
+ return this.uniforms[index];
168
+ }
169
+
170
+ /**
171
+ * Encodes the whole pass over the accumulation (or the denoiser's output, when given) and returns the buffer it
172
+ * writes, which holds the result once the encoder has run.
173
+ */
174
+ encode(encoder: GPUCommandEncoder, accumulation: GPUBuffer, denoised: GPUBuffer | null, width: number,
175
+ height: number, effects: LensEffects): GPUBuffer {
176
+ const levels = pyramidLevels(width, height);
177
+
178
+ this.ensure(width, height, levels);
179
+
180
+ const floats = new Float32Array(this.values), integers = new Uint32Array(this.values);
181
+ const white = effects.whiteBalance ?? [1, 0, 0, 0, 1, 0, 0, 0, 1];
182
+ const halo = levels[0];
183
+ const streak = streakLevel(levels);
184
+ let used = 0;
185
+
186
+ const dispatch = (pipeline: GPUComputePipeline, target: { width: number; height: number },
187
+ fill: () => void) => {
188
+ integers.fill(0);
189
+ integers[0] = width;
190
+ integers[1] = height;
191
+ integers[2] = denoised ? 1 : 0;
192
+ integers[11] = halo.offset;
193
+ integers[12] = halo.width;
194
+ integers[13] = halo.height;
195
+ floats[14] = effects.bloom;
196
+ floats[15] = effects.radius;
197
+ floats[16] = effects.aberration;
198
+ floats[17] = effects.glare;
199
+ integers[18] = effects.rays;
200
+ floats[19] = effects.glareLength * Math.hypot(streak.width, streak.height) / 2;
201
+ integers[32] = streak.width;
202
+ integers[33] = streak.height;
203
+ floats[34] = effects.glareAngle;
204
+ for (let row = 0; row < 3; row++) for (let column = 0; column < 3; column++)
205
+ floats[20 + row * 4 + column] = white[row * 3 + column];
206
+ fill();
207
+
208
+ const buffer = this.uniform(used++);
209
+
210
+ this.device.queue.writeBuffer(buffer, 0, this.values);
211
+
212
+ const pass = encoder.beginComputePass({ label: "lens" });
213
+
214
+ pass.setPipeline(pipeline);
215
+ pass.setBindGroup(0, this.device.createBindGroup({
216
+ layout: this.layout,
217
+ entries: [
218
+ { binding: 0, resource: { buffer } },
219
+ { binding: 1, resource: { buffer: accumulation } },
220
+ { binding: 2, resource: { buffer: denoised ?? accumulation } },
221
+ { binding: 3, resource: { buffer: this.down as GPUBuffer } },
222
+ { binding: 4, resource: { buffer: this.up as GPUBuffer } },
223
+ { binding: 5, resource: { buffer: this.graded as GPUBuffer } },
224
+ { binding: 6, resource: { buffer: this.streaked as GPUBuffer } },
225
+ ],
226
+ }));
227
+ pass.dispatchWorkgroups(Math.ceil(target.width / WORKGROUP), Math.ceil(target.height / WORKGROUP));
228
+ pass.end();
229
+ };
230
+
231
+ const read = (level: { width: number; height: number; offset: number }) => {
232
+ integers[4] = level.width;
233
+ integers[5] = level.height;
234
+ integers[6] = level.offset;
235
+ };
236
+
237
+ const write = (level: Level) => {
238
+ integers[8] = level.width;
239
+ integers[9] = level.height;
240
+ integers[10] = level.offset;
241
+ };
242
+
243
+ // The rays are gathered from a level of the same pyramid, so either effect builds it.
244
+ if (effects.bloom > 0 || effects.glare > 0) {
245
+ levels.forEach((level, k) => dispatch(this.downsample, level, () => {
246
+ if (k === 0) {
247
+ read({ width, height, offset: 0 });
248
+ integers[7] = 1;
249
+ } else {
250
+ read(levels[k - 1]);
251
+ }
252
+
253
+ write(level);
254
+ }));
255
+ }
256
+
257
+ if (effects.glare > 0) {
258
+ dispatch(this.streaks, streak, () => {
259
+ read(streak);
260
+ write({ ...streak, offset: 0 });
261
+ });
262
+ }
263
+
264
+ if (effects.bloom > 0) {
265
+ // The smallest level folds into nothing wider; each one above takes the spread of everything below it.
266
+ for (let k = levels.length - 2; k >= 0; k--) {
267
+ dispatch(this.upsample, levels[k], () => {
268
+ read(levels[k + 1]);
269
+ integers[3] = k === levels.length - 2 ? 1 : 0;
270
+ write(levels[k]);
271
+ });
272
+ }
273
+
274
+ // A single level has nothing to fold: the halo is that level as it was filtered.
275
+ if (levels.length === 1) {
276
+ encoder.copyBufferToBuffer(this.down as GPUBuffer, 0, this.up as GPUBuffer, 0,
277
+ levels[0].width * levels[0].height * 16);
278
+ }
279
+ }
280
+
281
+ dispatch(this.composite, { width, height }, () => {});
282
+
283
+ return this.graded as GPUBuffer;
284
+ }
285
+
286
+ private destroyBuffers() {
287
+ this.down?.destroy();
288
+ this.up?.destroy();
289
+ this.graded?.destroy();
290
+ this.streaked?.destroy();
291
+ this.down = this.up = this.graded = this.streaked = null;
292
+ }
293
+
294
+ destroy() {
295
+ this.destroyBuffers();
296
+ for (const buffer of this.uniforms) buffer.destroy();
297
+ this.uniforms.length = 0;
298
+ }
299
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * A bounding hierarchy over the lights that sit in the scene (emissive triangles, point, spot and rectangular lights),
3
+ * which lets the integrator pick one by how much it can deliver to the point being shaded rather than by its power
4
+ * alone: Conty Estevez and Kulla, "Importance Sampling of Many Lights with Adaptive Tree Splitting" (2018), without the
5
+ * splitting, as Cycles uses it. Every node keeps its box, the power under it and a cone bounding the directions its
6
+ * lights face; the kernel walks down choosing a child by each one's importance from the shaded point, and walks the same
7
+ * path up to tell how likely a light a bounce ray hit was to be chosen that way.
8
+ */
9
+
10
+ /** A light as the tree sees it: where it is, how strong, which way it faces, and the area density of its points. */
11
+ export interface TreeLight {
12
+ low: number[];
13
+ high: number[];
14
+ /** Radiant intensity scale: what a unit distance away receives, so lights of every kind compare. */
15
+ power: number;
16
+ /** The axis its emission is centred on, the half angle of the normals around it, and how far past them it emits. */
17
+ axis: number[];
18
+ thetaO: number;
19
+ thetaE: number;
20
+ /** Area density of a point on it, one for a point light. */
21
+ density: number;
22
+ }
23
+
24
+ /** Sixteen words a node: low and power, high and cos theta_o, axis and cos theta_e, then the links. */
25
+ export const TREE_NODE_WORDS = 16;
26
+
27
+ /** The high bit of a node's first link marks a leaf, whose low bits are its light's index. */
28
+ export const TREE_LEAF = 0x80000000;
29
+
30
+ export const TREE_ROOT_PARENT = 0xffffffff;
31
+
32
+ interface Cone {
33
+ axis: number[];
34
+ thetaO: number;
35
+ thetaE: number;
36
+ }
37
+
38
+ const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
39
+
40
+ function normalized(v: number[]): number[] {
41
+ const length = Math.hypot(v[0], v[1], v[2]);
42
+
43
+ return length > 0 ? v.map(c => c / length) : [0, 0, 1];
44
+ }
45
+
46
+ /** Rotates a towards b by an angle, in the plane they span. */
47
+ function rotateTowards(a: number[], b: number[], angle: number): number[] {
48
+ const along = dot(a, b);
49
+ const ortho = normalized(b.map((c, k) => c - a[k] * along));
50
+
51
+ return normalized(a.map((c, k) => c * Math.cos(angle) + ortho[k] * Math.sin(angle)));
52
+ }
53
+
54
+ /** The smallest cone bounding two, as Conty and Kulla merge them. */
55
+ export function unionCone(first: Cone, second: Cone): Cone {
56
+ const [a, b] = first.thetaO >= second.thetaO ? [first, second] : [second, first];
57
+ const thetaE = Math.max(a.thetaE, b.thetaE);
58
+ const thetaD = Math.acos(Math.min(1, Math.max(-1, dot(a.axis, b.axis))));
59
+
60
+ if (Math.min(thetaD + b.thetaO, Math.PI) <= a.thetaO) return { axis: a.axis, thetaO: a.thetaO, thetaE };
61
+
62
+ const thetaO = (a.thetaO + thetaD + b.thetaO) / 2;
63
+
64
+ if (thetaO >= Math.PI) return { axis: a.axis, thetaO: Math.PI, thetaE };
65
+
66
+ // Two opposite axes have no plane between them: the cone then takes the whole sphere.
67
+ if (thetaD >= Math.PI - 1e-6) return { axis: a.axis, thetaO: Math.PI, thetaE };
68
+
69
+ return { axis: rotateTowards(a.axis, b.axis, thetaO - a.thetaO), thetaO, thetaE };
70
+ }
71
+
72
+ interface Node {
73
+ low: number[];
74
+ high: number[];
75
+ power: number;
76
+ cone: Cone;
77
+ left: number;
78
+ right: number;
79
+ parent: number;
80
+ /** The light of a leaf, or -1. */
81
+ light: number;
82
+ density: number;
83
+ }
84
+
85
+ /**
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.
88
+ */
89
+ export function buildLightTree(lights: readonly TreeLight[]): { words: Uint32Array; leafOf: Uint32Array } {
90
+ const nodes: Node[] = [];
91
+ const leafOf = new Uint32Array(lights.length);
92
+
93
+ const build = (indices: number[], parent: number): number => {
94
+ const index = nodes.length;
95
+ const low = [Infinity, Infinity, Infinity], high = [-Infinity, -Infinity, -Infinity];
96
+ let power = 0;
97
+ let cone: Cone | null = null;
98
+
99
+ for (const i of indices) {
100
+ const light = lights[i];
101
+
102
+ for (let k = 0; k < 3; k++) {
103
+ low[k] = Math.min(low[k], light.low[k]);
104
+ high[k] = Math.max(high[k], light.high[k]);
105
+ }
106
+
107
+ power += light.power;
108
+
109
+ const own = { axis: normalized(light.axis), thetaO: light.thetaO, thetaE: light.thetaE };
110
+
111
+ cone = cone ? unionCone(cone, own) : own;
112
+ }
113
+
114
+ const node: Node = { low, high, power, cone: cone as Cone, left: -1, right: -1, parent, light: -1, density: 1 };
115
+
116
+ nodes.push(node);
117
+
118
+ if (indices.length === 1) {
119
+ node.light = indices[0];
120
+ node.density = lights[indices[0]].density;
121
+ leafOf[indices[0]] = index;
122
+
123
+ return index;
124
+ }
125
+
126
+ const centre = (i: number, k: number) => (lights[i].low[k] + lights[i].high[k]) / 2;
127
+
128
+ const extent = [0, 1, 2].map(k => {
129
+ const values = indices.map(i => centre(i, k));
130
+
131
+ return Math.max(...values) - Math.min(...values);
132
+ });
133
+
134
+ const axis = extent.indexOf(Math.max(...extent));
135
+ const sorted = [...indices].sort((a, b) => centre(a, axis) - centre(b, axis));
136
+ const half = sorted.length >> 1;
137
+
138
+ node.left = build(sorted.slice(0, half), index);
139
+ node.right = build(sorted.slice(half), index);
140
+
141
+ return index;
142
+ };
143
+
144
+ if (lights.length) build(lights.map((_, i) => i), TREE_ROOT_PARENT);
145
+
146
+ const words = new Uint32Array(nodes.length * TREE_NODE_WORDS);
147
+ const floats = new Float32Array(words.buffer);
148
+
149
+ nodes.forEach((node, k) => {
150
+ const at = k * TREE_NODE_WORDS;
151
+
152
+ floats.set(node.low, at);
153
+ floats[at + 3] = node.power;
154
+ floats.set(node.high, at + 4);
155
+ // A cone that takes the whole sphere is stored as minus one, which the kernel reads as no orientation at all.
156
+ floats[at + 7] = node.cone.thetaO >= Math.PI ? -1 : Math.cos(node.cone.thetaO);
157
+ floats.set(node.cone.axis, at + 8);
158
+ floats[at + 11] = Math.cos(Math.min(node.cone.thetaE, Math.PI));
159
+
160
+ if (node.light >= 0) {
161
+ words[at + 12] = (TREE_LEAF | node.light) >>> 0;
162
+ floats[at + 13] = node.density;
163
+ } else {
164
+ words[at + 12] = node.left;
165
+ words[at + 13] = node.right;
166
+ }
167
+
168
+ words[at + 14] = node.parent >>> 0;
169
+ });
170
+
171
+ return { words, leafOf };
172
+ }
package/src/lights.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { PackedGeometry } from "./geometry.js";
2
+ import { buildLightTree, type TreeLight } from "./lightTree.js";
2
3
  import type { SerializedLight } from "./upstream.js";
3
4
 
4
5
  /**
@@ -50,8 +51,26 @@ export interface PackedLights {
50
51
  ambient: { sky: number[]; ground: number[]; axis: number[] };
51
52
  /** How often direct sampling picks the environment rather than a light. Zero when there is nothing to aim at. */
52
53
  environmentPick: number;
53
- /** Words to append to the texture buffer: a spot light's map index and the matrix that projects it. */
54
+ /**
55
+ * Words to append to the texture buffer: a spot light's map index and the matrix that projects it, then the
56
+ * cumulative probability of every entry and the triangle of every emissive one, packed tight so the two searches the
57
+ * shader makes over them read four bytes a step instead of a whole entry.
58
+ */
54
59
  extraWords: Uint32Array;
60
+ /** Where the cumulative probabilities start in the texture buffer, one float per entry. */
61
+ cdfAt: number;
62
+ /** Where the emissive entries' triangles start in the texture buffer, one word per emissive entry. */
63
+ trianglesAt: number;
64
+ /**
65
+ * How many leading entries sit in the scene (emissive triangles, point, spot and rectangular lights), which the light
66
+ * tree picks among; the directional lights and the environment follow them.
67
+ */
68
+ localCount: number;
69
+ /** The chance of picking one of those: exactly the cumulative probability of the last of them. */
70
+ localPick: number;
71
+ /** Where the light tree's nodes start in the texture buffer, and the leaf of every local entry; zero for no tree. */
72
+ treeAt: number;
73
+ leavesAt: number;
55
74
  }
56
75
 
57
76
  const luminance = (color: ArrayLike<number>) => 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2];
@@ -120,8 +139,15 @@ interface Candidate {
120
139
  write(words: Uint32Array, floats: Float32Array, at: number): void;
121
140
  /** Area measure density of a point on it, before the pick probability is folded in. */
122
141
  density?: number;
142
+ /** What the light tree knows of a light that sits in the scene; none for a directional light or the environment. */
143
+ tree?: TreeLight;
123
144
  }
124
145
 
146
+ const corners = (points: number[][]) => ({
147
+ low: [0, 1, 2].map(k => Math.min(...points.map(p => p[k]))),
148
+ high: [0, 1, 2].map(k => Math.max(...points.map(p => p[k]))),
149
+ });
150
+
125
151
  /**
126
152
  * Emissive triangles weighted by the power they radiate (area times emitted luminance), so a large or bright panel is
127
153
  * chosen more often than a dim sliver.
@@ -145,11 +171,24 @@ function emissiveTriangles(geometry: PackedGeometry, bounds: Bounds): Candidate[
145
171
  const centroid = [0, 1, 2].map(axis =>
146
172
  (geometry.positions[at + axis] + geometry.positions[at + 3 + axis] + geometry.positions[at + 6 + axis]) / 3);
147
173
 
174
+ const p = geometry.positions;
175
+ const e1 = [p[at + 3] - p[at], p[at + 4] - p[at + 1], p[at + 5] - p[at + 2]];
176
+ const e2 = [p[at + 6] - p[at], p[at + 7] - p[at + 1], p[at + 8] - p[at + 2]];
177
+
148
178
  candidates.push({
149
179
  kind: LIGHT_KIND.area,
150
180
  // The irradiance it delivers, so a bright panel and a bright lamp are weighed in the same unit.
151
181
  weight: glow * area / reach(centroid, bounds) ** 2,
152
182
  density: 1 / area,
183
+ // It radiates from both sides, so its cone is the whole sphere.
184
+ tree: {
185
+ ...corners([0, 3, 6].map(v => [p[at + v], p[at + v + 1], p[at + v + 2]])),
186
+ power: glow * area,
187
+ axis: normalize([e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]]),
188
+ thetaO: Math.PI,
189
+ thetaE: Math.PI / 2,
190
+ density: 1 / area,
191
+ },
153
192
  write(words, _floats, at) {
154
193
  words[at] = LIGHT_KIND.area;
155
194
  words[at + 3] = triangle;
@@ -212,6 +251,8 @@ function analyticLight(
212
251
  return {
213
252
  kind: LIGHT_KIND.point,
214
253
  weight: emitted * falloff,
254
+ tree: { low: [...light.position], high: [...light.position], power: emitted, axis: [0, 0, 1], thetaO: Math.PI,
255
+ thetaE: Math.PI / 2, density: 1 },
215
256
  write: (words, floats, at) => common(words, floats, at, LIGHT_KIND.point),
216
257
  };
217
258
  }
@@ -231,6 +272,9 @@ function analyticLight(
231
272
  kind: LIGHT_KIND.spot,
232
273
  // Only the cone is lit, so only that share of the scene can expect anything from it.
233
274
  weight: emitted * falloff * (1 - Math.cos(Math.min(light.angle, Math.PI))) / 2,
275
+ // It shines from its position towards its target, within its outer cone.
276
+ tree: { low: [...light.position], high: [...light.position], power: emitted, axis: scale(axis, -1),
277
+ thetaO: Math.min(light.angle, Math.PI), thetaE: 0, density: 1 },
234
278
  write(words, floats, at) {
235
279
  common(words, floats, at, LIGHT_KIND.spot);
236
280
  floats[at + 15] = outer;
@@ -251,10 +295,25 @@ function analyticLight(
251
295
  const up = normalize([matrix[4], matrix[5], matrix[6]]);
252
296
  const area = Math.max(light.width * light.height, 1e-12);
253
297
 
298
+ const half = [scale(right, light.width / 2), scale(up, light.height / 2)];
299
+
300
+ const facing = normalize([
301
+ right[1] * up[2] - right[2] * up[1], right[2] * up[0] - right[0] * up[2], right[0] * up[1] - right[1] * up[0]]);
302
+
254
303
  return {
255
304
  kind: LIGHT_KIND.rect,
256
305
  weight: emitted * area / distance ** 2,
257
306
  density: 1 / area,
307
+ // One sided: it lights the side its axes' cross product turns away from, over a hemisphere.
308
+ tree: {
309
+ ...corners([[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([a, b]) =>
310
+ [0, 1, 2].map(k => light.position[k] + half[0][k] * a + half[1][k] * b))),
311
+ power: emitted * area,
312
+ axis: scale(facing, -1),
313
+ thetaO: 0,
314
+ thetaE: Math.PI / 2,
315
+ density: 1 / area,
316
+ },
258
317
  write(words, floats, at) {
259
318
  common(words, floats, at, LIGHT_KIND.rect);
260
319
  floats.set(scale(right, light.width / 2), at + 16);
@@ -334,7 +393,11 @@ export function packLights(
334
393
  }]
335
394
  : [];
336
395
 
337
- const candidates = [...areas, ...analytic, ...surrounding];
396
+ // What sits in the scene first, so the tree's lights are the leading entries; then what lies outside it.
397
+ const local = analytic.filter(candidate => candidate.tree);
398
+ const distant = analytic.filter(candidate => !candidate.tree);
399
+ const candidates = [...areas, ...local, ...distant, ...surrounding];
400
+ const localCount = areas.length + local.length;
338
401
  const total = candidates.reduce((sum, candidate) => sum + candidate.weight, 0);
339
402
  const entries = new ArrayBuffer(Math.max(1, candidates.length) * LIGHT_WORDS * 4);
340
403
  const words = new Uint32Array(entries);
@@ -353,8 +416,33 @@ export function packLights(
353
416
  });
354
417
 
355
418
  const environmentPick = surrounding.length ? surrounding[0].weight / total : 0;
419
+ const base = options.extraBase ?? 0;
420
+ const cdfAt = base + extra.length;
421
+
422
+ for (let index = 0; index < candidates.length; index++) extra.push(words[index * LIGHT_WORDS + 1]);
423
+
424
+ const trianglesAt = base + extra.length;
425
+
426
+ for (let index = 0; index < areas.length; index++) extra.push(words[index * LIGHT_WORDS + 3]);
427
+
428
+ let treeAt = 0, leavesAt = 0;
429
+
430
+ if (localCount > 0) {
431
+ const tree = buildLightTree(candidates.slice(0, localCount).map(candidate => candidate.tree as TreeLight));
432
+
433
+ treeAt = base + extra.length;
434
+ for (const word of tree.words) extra.push(word);
435
+ leavesAt = base + extra.length;
436
+ for (const leaf of tree.leafOf) extra.push(leaf);
437
+ }
356
438
 
357
439
  return {
440
+ localCount,
441
+ localPick: localCount > 0 ? floats[(localCount - 1) * LIGHT_WORDS + 1] : 0,
442
+ treeAt,
443
+ leavesAt,
444
+ cdfAt,
445
+ trianglesAt,
358
446
  entries,
359
447
  count: candidates.length,
360
448
  areaCount: areas.length,