@tinypoly/gpu-renderer 0.0.5 → 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 (110) hide show
  1. package/ARCHITECTURE.md +59 -3
  2. package/README.md +52 -4
  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/denoise.d.ts +11 -1
  8. package/dist/denoise.d.ts.map +1 -1
  9. package/dist/denoise.js +34 -6
  10. package/dist/denoise.js.map +1 -1
  11. package/dist/frameLayout.d.ts.map +1 -1
  12. package/dist/frameLayout.js +15 -0
  13. package/dist/frameLayout.js.map +1 -1
  14. package/dist/geometry.d.ts.map +1 -1
  15. package/dist/geometry.js +6 -3
  16. package/dist/geometry.js.map +1 -1
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/lensPass.d.ts +60 -0
  22. package/dist/lensPass.d.ts.map +1 -0
  23. package/dist/lensPass.js +235 -0
  24. package/dist/lensPass.js.map +1 -0
  25. package/dist/lightTree.d.ts +43 -0
  26. package/dist/lightTree.d.ts.map +1 -0
  27. package/dist/lightTree.js +107 -0
  28. package/dist/lightTree.js.map +1 -0
  29. package/dist/lights.d.ts +10 -0
  30. package/dist/lights.d.ts.map +1 -1
  31. package/dist/lights.js +54 -1
  32. package/dist/lights.js.map +1 -1
  33. package/dist/materialLayout.d.ts +24 -0
  34. package/dist/materialLayout.d.ts.map +1 -1
  35. package/dist/materialLayout.js +36 -1
  36. package/dist/materialLayout.js.map +1 -1
  37. package/dist/pathTracePass.d.ts +6 -3
  38. package/dist/pathTracePass.d.ts.map +1 -1
  39. package/dist/pathTracePass.js +16 -10
  40. package/dist/pathTracePass.js.map +1 -1
  41. package/dist/renderer.d.ts +19 -7
  42. package/dist/renderer.d.ts.map +1 -1
  43. package/dist/renderer.js +118 -14
  44. package/dist/renderer.js.map +1 -1
  45. package/dist/sceneUpload.d.ts +5 -0
  46. package/dist/sceneUpload.d.ts.map +1 -1
  47. package/dist/sceneUpload.js +4 -0
  48. package/dist/sceneUpload.js.map +1 -1
  49. package/dist/settings.d.ts +136 -0
  50. package/dist/settings.d.ts.map +1 -0
  51. package/dist/settings.js +100 -0
  52. package/dist/settings.js.map +1 -0
  53. package/dist/subsurface.d.ts +76 -0
  54. package/dist/subsurface.d.ts.map +1 -0
  55. package/dist/subsurface.js +70 -0
  56. package/dist/subsurface.js.map +1 -0
  57. package/dist/volumeContainment.d.ts +17 -0
  58. package/dist/volumeContainment.d.ts.map +1 -0
  59. package/dist/volumeContainment.js +61 -0
  60. package/dist/volumeContainment.js.map +1 -0
  61. package/dist/wgsl/bsdf.wgsl.d.ts +1 -1
  62. package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -1
  63. package/dist/wgsl/bsdf.wgsl.js +35 -3
  64. package/dist/wgsl/bsdf.wgsl.js.map +1 -1
  65. package/dist/wgsl/common.wgsl.d.ts.map +1 -1
  66. package/dist/wgsl/common.wgsl.js +4 -0
  67. package/dist/wgsl/common.wgsl.js.map +1 -1
  68. package/dist/wgsl/lens.wgsl.d.ts +17 -0
  69. package/dist/wgsl/lens.wgsl.d.ts.map +1 -0
  70. package/dist/wgsl/lens.wgsl.js +315 -0
  71. package/dist/wgsl/lens.wgsl.js.map +1 -0
  72. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  73. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  74. package/dist/wgsl/lights.wgsl.js +203 -31
  75. package/dist/wgsl/lights.wgsl.js.map +1 -1
  76. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  77. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  78. package/dist/wgsl/pathTrace.wgsl.js +396 -25
  79. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  80. package/dist/wgsl/sampling.wgsl.d.ts +1 -1
  81. package/dist/wgsl/sampling.wgsl.d.ts.map +1 -1
  82. package/dist/wgsl/sampling.wgsl.js +25 -0
  83. package/dist/wgsl/sampling.wgsl.js.map +1 -1
  84. package/dist/wgsl/surface.wgsl.d.ts +1 -1
  85. package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
  86. package/dist/wgsl/surface.wgsl.js +21 -1
  87. package/dist/wgsl/surface.wgsl.js.map +1 -1
  88. package/package.json +1 -1
  89. package/src/atmosphere.ts +95 -0
  90. package/src/denoise.ts +44 -6
  91. package/src/frameLayout.ts +15 -0
  92. package/src/geometry.ts +6 -2
  93. package/src/index.ts +13 -0
  94. package/src/lensPass.ts +299 -0
  95. package/src/lightTree.ts +172 -0
  96. package/src/lights.ts +71 -1
  97. package/src/materialLayout.ts +52 -1
  98. package/src/pathTracePass.ts +19 -10
  99. package/src/renderer.ts +166 -22
  100. package/src/sceneUpload.ts +9 -0
  101. package/src/settings.ts +234 -0
  102. package/src/subsurface.ts +153 -0
  103. package/src/volumeContainment.ts +81 -0
  104. package/src/wgsl/bsdf.wgsl.ts +35 -3
  105. package/src/wgsl/common.wgsl.ts +4 -0
  106. package/src/wgsl/lens.wgsl.ts +314 -0
  107. package/src/wgsl/lights.wgsl.ts +203 -31
  108. package/src/wgsl/pathTrace.wgsl.ts +396 -25
  109. package/src/wgsl/sampling.wgsl.ts +25 -0
  110. package/src/wgsl/surface.wgsl.ts +21 -1
@@ -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
  /**
@@ -60,6 +61,16 @@ export interface PackedLights {
60
61
  cdfAt: number;
61
62
  /** Where the emissive entries' triangles start in the texture buffer, one word per emissive entry. */
62
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;
63
74
  }
64
75
 
65
76
  const luminance = (color: ArrayLike<number>) => 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2];
@@ -128,8 +139,15 @@ interface Candidate {
128
139
  write(words: Uint32Array, floats: Float32Array, at: number): void;
129
140
  /** Area measure density of a point on it, before the pick probability is folded in. */
130
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;
131
144
  }
132
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
+
133
151
  /**
134
152
  * Emissive triangles weighted by the power they radiate (area times emitted luminance), so a large or bright panel is
135
153
  * chosen more often than a dim sliver.
@@ -153,11 +171,24 @@ function emissiveTriangles(geometry: PackedGeometry, bounds: Bounds): Candidate[
153
171
  const centroid = [0, 1, 2].map(axis =>
154
172
  (geometry.positions[at + axis] + geometry.positions[at + 3 + axis] + geometry.positions[at + 6 + axis]) / 3);
155
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
+
156
178
  candidates.push({
157
179
  kind: LIGHT_KIND.area,
158
180
  // The irradiance it delivers, so a bright panel and a bright lamp are weighed in the same unit.
159
181
  weight: glow * area / reach(centroid, bounds) ** 2,
160
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
+ },
161
192
  write(words, _floats, at) {
162
193
  words[at] = LIGHT_KIND.area;
163
194
  words[at + 3] = triangle;
@@ -220,6 +251,8 @@ function analyticLight(
220
251
  return {
221
252
  kind: LIGHT_KIND.point,
222
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 },
223
256
  write: (words, floats, at) => common(words, floats, at, LIGHT_KIND.point),
224
257
  };
225
258
  }
@@ -239,6 +272,9 @@ function analyticLight(
239
272
  kind: LIGHT_KIND.spot,
240
273
  // Only the cone is lit, so only that share of the scene can expect anything from it.
241
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 },
242
278
  write(words, floats, at) {
243
279
  common(words, floats, at, LIGHT_KIND.spot);
244
280
  floats[at + 15] = outer;
@@ -259,10 +295,25 @@ function analyticLight(
259
295
  const up = normalize([matrix[4], matrix[5], matrix[6]]);
260
296
  const area = Math.max(light.width * light.height, 1e-12);
261
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
+
262
303
  return {
263
304
  kind: LIGHT_KIND.rect,
264
305
  weight: emitted * area / distance ** 2,
265
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
+ },
266
317
  write(words, floats, at) {
267
318
  common(words, floats, at, LIGHT_KIND.rect);
268
319
  floats.set(scale(right, light.width / 2), at + 16);
@@ -342,7 +393,11 @@ export function packLights(
342
393
  }]
343
394
  : [];
344
395
 
345
- 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;
346
401
  const total = candidates.reduce((sum, candidate) => sum + candidate.weight, 0);
347
402
  const entries = new ArrayBuffer(Math.max(1, candidates.length) * LIGHT_WORDS * 4);
348
403
  const words = new Uint32Array(entries);
@@ -370,7 +425,22 @@ export function packLights(
370
425
 
371
426
  for (let index = 0; index < areas.length; index++) extra.push(words[index * LIGHT_WORDS + 3]);
372
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
+ }
438
+
373
439
  return {
440
+ localCount,
441
+ localPick: localCount > 0 ? floats[(localCount - 1) * LIGHT_WORDS + 1] : 0,
442
+ treeAt,
443
+ leavesAt,
374
444
  cdfAt,
375
445
  trianglesAt,
376
446
  entries,