@tinypoly/gpu-renderer 0.0.5 → 0.1.1
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.
- package/ARCHITECTURE.md +59 -3
- package/README.md +53 -4
- package/dist/atmosphere.d.ts +21 -0
- package/dist/atmosphere.d.ts.map +1 -0
- package/dist/atmosphere.js +72 -0
- package/dist/atmosphere.js.map +1 -0
- package/dist/denoise.d.ts +11 -1
- package/dist/denoise.d.ts.map +1 -1
- package/dist/denoise.js +34 -6
- package/dist/denoise.js.map +1 -1
- package/dist/frameLayout.d.ts.map +1 -1
- package/dist/frameLayout.js +17 -0
- package/dist/frameLayout.js.map +1 -1
- package/dist/geometry.d.ts.map +1 -1
- package/dist/geometry.js +6 -3
- package/dist/geometry.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lensPass.d.ts +60 -0
- package/dist/lensPass.d.ts.map +1 -0
- package/dist/lensPass.js +235 -0
- package/dist/lensPass.js.map +1 -0
- package/dist/lightTree.d.ts +50 -0
- package/dist/lightTree.d.ts.map +1 -0
- package/dist/lightTree.js +146 -0
- package/dist/lightTree.js.map +1 -0
- package/dist/lights.d.ts +12 -0
- package/dist/lights.d.ts.map +1 -1
- package/dist/lights.js +56 -1
- package/dist/lights.js.map +1 -1
- package/dist/materialLayout.d.ts +24 -0
- package/dist/materialLayout.d.ts.map +1 -1
- package/dist/materialLayout.js +36 -1
- package/dist/materialLayout.js.map +1 -1
- package/dist/pathTracePass.d.ts +6 -3
- package/dist/pathTracePass.d.ts.map +1 -1
- package/dist/pathTracePass.js +16 -10
- package/dist/pathTracePass.js.map +1 -1
- package/dist/renderer.d.ts +27 -7
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +153 -16
- package/dist/renderer.js.map +1 -1
- package/dist/sceneUpload.d.ts +6 -0
- package/dist/sceneUpload.d.ts.map +1 -1
- package/dist/sceneUpload.js +5 -0
- package/dist/sceneUpload.js.map +1 -1
- package/dist/settings.d.ts +142 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +100 -0
- package/dist/settings.js.map +1 -0
- package/dist/subsurface.d.ts +76 -0
- package/dist/subsurface.d.ts.map +1 -0
- package/dist/subsurface.js +70 -0
- package/dist/subsurface.js.map +1 -0
- package/dist/volumeContainment.d.ts +17 -0
- package/dist/volumeContainment.d.ts.map +1 -0
- package/dist/volumeContainment.js +61 -0
- package/dist/volumeContainment.js.map +1 -0
- package/dist/wgsl/bsdf.wgsl.d.ts +1 -1
- package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -1
- package/dist/wgsl/bsdf.wgsl.js +35 -3
- package/dist/wgsl/bsdf.wgsl.js.map +1 -1
- package/dist/wgsl/common.wgsl.d.ts.map +1 -1
- package/dist/wgsl/common.wgsl.js +4 -0
- package/dist/wgsl/common.wgsl.js.map +1 -1
- package/dist/wgsl/lens.wgsl.d.ts +17 -0
- package/dist/wgsl/lens.wgsl.d.ts.map +1 -0
- package/dist/wgsl/lens.wgsl.js +301 -0
- package/dist/wgsl/lens.wgsl.js.map +1 -0
- package/dist/wgsl/lights.wgsl.d.ts +1 -1
- package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
- package/dist/wgsl/lights.wgsl.js +237 -31
- package/dist/wgsl/lights.wgsl.js.map +1 -1
- package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
- package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
- package/dist/wgsl/pathTrace.wgsl.js +406 -25
- package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
- package/dist/wgsl/random.wgsl.d.ts +1 -1
- package/dist/wgsl/random.wgsl.d.ts.map +1 -1
- package/dist/wgsl/random.wgsl.js +28 -0
- package/dist/wgsl/random.wgsl.js.map +1 -1
- package/dist/wgsl/sampling.wgsl.d.ts +1 -1
- package/dist/wgsl/sampling.wgsl.d.ts.map +1 -1
- package/dist/wgsl/sampling.wgsl.js +25 -0
- package/dist/wgsl/sampling.wgsl.js.map +1 -1
- package/dist/wgsl/surface.wgsl.d.ts +1 -1
- package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
- package/dist/wgsl/surface.wgsl.js +21 -1
- package/dist/wgsl/surface.wgsl.js.map +1 -1
- package/package.json +1 -1
- package/src/atmosphere.ts +95 -0
- package/src/denoise.ts +44 -6
- package/src/frameLayout.ts +17 -0
- package/src/geometry.ts +6 -2
- package/src/index.ts +13 -0
- package/src/lensPass.ts +299 -0
- package/src/lightTree.ts +218 -0
- package/src/lights.ts +75 -1
- package/src/materialLayout.ts +52 -1
- package/src/pathTracePass.ts +19 -10
- package/src/renderer.ts +206 -24
- package/src/sceneUpload.ts +11 -0
- package/src/settings.ts +240 -0
- package/src/subsurface.ts +153 -0
- package/src/volumeContainment.ts +81 -0
- package/src/wgsl/bsdf.wgsl.ts +35 -3
- package/src/wgsl/common.wgsl.ts +4 -0
- package/src/wgsl/lens.wgsl.ts +300 -0
- package/src/wgsl/lights.wgsl.ts +237 -31
- package/src/wgsl/pathTrace.wgsl.ts +406 -25
- package/src/wgsl/random.wgsl.ts +28 -0
- package/src/wgsl/sampling.wgsl.ts +25 -0
- package/src/wgsl/surface.wgsl.ts +21 -1
package/src/lensPass.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lightTree.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
/**
|
|
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
|
+
*/
|
|
31
|
+
export const TREE_LEAF = 0x80000000;
|
|
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
|
+
|
|
44
|
+
export const TREE_ROOT_PARENT = 0xffffffff;
|
|
45
|
+
|
|
46
|
+
interface Cone {
|
|
47
|
+
axis: number[];
|
|
48
|
+
thetaO: number;
|
|
49
|
+
thetaE: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
53
|
+
|
|
54
|
+
function normalized(v: number[]): number[] {
|
|
55
|
+
const length = Math.hypot(v[0], v[1], v[2]);
|
|
56
|
+
|
|
57
|
+
return length > 0 ? v.map(c => c / length) : [0, 0, 1];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Rotates a towards b by an angle, in the plane they span. */
|
|
61
|
+
function rotateTowards(a: number[], b: number[], angle: number): number[] {
|
|
62
|
+
const along = dot(a, b);
|
|
63
|
+
const ortho = normalized(b.map((c, k) => c - a[k] * along));
|
|
64
|
+
|
|
65
|
+
return normalized(a.map((c, k) => c * Math.cos(angle) + ortho[k] * Math.sin(angle)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The smallest cone bounding two, as Conty and Kulla merge them. */
|
|
69
|
+
export function unionCone(first: Cone, second: Cone): Cone {
|
|
70
|
+
const [a, b] = first.thetaO >= second.thetaO ? [first, second] : [second, first];
|
|
71
|
+
const thetaE = Math.max(a.thetaE, b.thetaE);
|
|
72
|
+
const thetaD = Math.acos(Math.min(1, Math.max(-1, dot(a.axis, b.axis))));
|
|
73
|
+
|
|
74
|
+
if (Math.min(thetaD + b.thetaO, Math.PI) <= a.thetaO) return { axis: a.axis, thetaO: a.thetaO, thetaE };
|
|
75
|
+
|
|
76
|
+
const thetaO = (a.thetaO + thetaD + b.thetaO) / 2;
|
|
77
|
+
|
|
78
|
+
if (thetaO >= Math.PI) return { axis: a.axis, thetaO: Math.PI, thetaE };
|
|
79
|
+
|
|
80
|
+
// Two opposite axes have no plane between them: the cone then takes the whole sphere.
|
|
81
|
+
if (thetaD >= Math.PI - 1e-6) return { axis: a.axis, thetaO: Math.PI, thetaE };
|
|
82
|
+
|
|
83
|
+
return { axis: rotateTowards(a.axis, b.axis, thetaO - a.thetaO), thetaO, thetaE };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Node {
|
|
87
|
+
low: number[];
|
|
88
|
+
high: number[];
|
|
89
|
+
power: number;
|
|
90
|
+
cone: Cone;
|
|
91
|
+
left: number;
|
|
92
|
+
right: number;
|
|
93
|
+
parent: number;
|
|
94
|
+
/** The lights of a leaf, or none. */
|
|
95
|
+
lights: number[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
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.
|
|
102
|
+
*/
|
|
103
|
+
export function buildLightTree(lights: readonly TreeLight[]): {
|
|
104
|
+
words: Uint32Array; leafOf: Uint32Array; listAt: number;
|
|
105
|
+
} {
|
|
106
|
+
const nodes: Node[] = [];
|
|
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;
|
|
110
|
+
|
|
111
|
+
const build = (indices: number[], parent: number): number => {
|
|
112
|
+
const index = nodes.length;
|
|
113
|
+
const low = [Infinity, Infinity, Infinity], high = [-Infinity, -Infinity, -Infinity];
|
|
114
|
+
let power = 0;
|
|
115
|
+
let cone: Cone | null = null;
|
|
116
|
+
|
|
117
|
+
for (const i of indices) {
|
|
118
|
+
const light = lights[i];
|
|
119
|
+
|
|
120
|
+
for (let k = 0; k < 3; k++) {
|
|
121
|
+
low[k] = Math.min(low[k], light.low[k]);
|
|
122
|
+
high[k] = Math.max(high[k], light.high[k]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
power += light.power;
|
|
126
|
+
|
|
127
|
+
const own = { axis: normalized(light.axis), thetaO: light.thetaO, thetaE: light.thetaE };
|
|
128
|
+
|
|
129
|
+
cone = cone ? unionCone(cone, own) : own;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const node: Node = { low, high, power, cone: cone as Cone, left: -1, right: -1, parent, lights: [] };
|
|
133
|
+
|
|
134
|
+
nodes.push(node);
|
|
135
|
+
|
|
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;
|
|
142
|
+
|
|
143
|
+
return index;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const centre = (i: number, k: number) => (lights[i].low[k] + lights[i].high[k]) / 2;
|
|
147
|
+
|
|
148
|
+
const spread = [0, 1, 2].map(k => {
|
|
149
|
+
const values = indices.map(i => centre(i, k));
|
|
150
|
+
|
|
151
|
+
return Math.max(...values) - Math.min(...values);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const axis = spread.indexOf(Math.max(...spread));
|
|
155
|
+
const sorted = [...indices].sort((a, b) => centre(a, axis) - centre(b, axis));
|
|
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;
|
|
161
|
+
|
|
162
|
+
node.left = build(sorted.slice(0, half), index);
|
|
163
|
+
node.right = build(sorted.slice(half), index);
|
|
164
|
+
|
|
165
|
+
return index;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (lights.length) build(lights.map((_, i) => i), TREE_ROOT_PARENT);
|
|
169
|
+
|
|
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);
|
|
173
|
+
const floats = new Float32Array(words.buffer);
|
|
174
|
+
let entry = 0;
|
|
175
|
+
|
|
176
|
+
nodes.forEach((node, k) => {
|
|
177
|
+
const at = k * TREE_NODE_WORDS;
|
|
178
|
+
|
|
179
|
+
floats.set(node.low, at);
|
|
180
|
+
floats[at + 3] = node.power;
|
|
181
|
+
floats.set(node.high, at + 4);
|
|
182
|
+
// A cone that takes the whole sphere is stored as minus one, which the kernel reads as no orientation at all.
|
|
183
|
+
floats[at + 7] = node.cone.thetaO >= Math.PI ? -1 : Math.cos(node.cone.thetaO);
|
|
184
|
+
floats.set(node.cone.axis, at + 8);
|
|
185
|
+
floats[at + 11] = Math.cos(Math.min(node.cone.thetaE, Math.PI));
|
|
186
|
+
|
|
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
|
+
});
|
|
209
|
+
} else {
|
|
210
|
+
words[at + 12] = node.left;
|
|
211
|
+
words[at + 13] = node.right;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
words[at + 14] = node.parent >>> 0;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return { words, leafOf, listAt };
|
|
218
|
+
}
|
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,18 @@ 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;
|
|
74
|
+
/** Where the lights of the tree's leaves are listed in the texture buffer. */
|
|
75
|
+
listAt: number;
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
const luminance = (color: ArrayLike<number>) => 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2];
|
|
@@ -128,8 +141,15 @@ interface Candidate {
|
|
|
128
141
|
write(words: Uint32Array, floats: Float32Array, at: number): void;
|
|
129
142
|
/** Area measure density of a point on it, before the pick probability is folded in. */
|
|
130
143
|
density?: number;
|
|
144
|
+
/** What the light tree knows of a light that sits in the scene; none for a directional light or the environment. */
|
|
145
|
+
tree?: TreeLight;
|
|
131
146
|
}
|
|
132
147
|
|
|
148
|
+
const corners = (points: number[][]) => ({
|
|
149
|
+
low: [0, 1, 2].map(k => Math.min(...points.map(p => p[k]))),
|
|
150
|
+
high: [0, 1, 2].map(k => Math.max(...points.map(p => p[k]))),
|
|
151
|
+
});
|
|
152
|
+
|
|
133
153
|
/**
|
|
134
154
|
* Emissive triangles weighted by the power they radiate (area times emitted luminance), so a large or bright panel is
|
|
135
155
|
* chosen more often than a dim sliver.
|
|
@@ -153,11 +173,24 @@ function emissiveTriangles(geometry: PackedGeometry, bounds: Bounds): Candidate[
|
|
|
153
173
|
const centroid = [0, 1, 2].map(axis =>
|
|
154
174
|
(geometry.positions[at + axis] + geometry.positions[at + 3 + axis] + geometry.positions[at + 6 + axis]) / 3);
|
|
155
175
|
|
|
176
|
+
const p = geometry.positions;
|
|
177
|
+
const e1 = [p[at + 3] - p[at], p[at + 4] - p[at + 1], p[at + 5] - p[at + 2]];
|
|
178
|
+
const e2 = [p[at + 6] - p[at], p[at + 7] - p[at + 1], p[at + 8] - p[at + 2]];
|
|
179
|
+
|
|
156
180
|
candidates.push({
|
|
157
181
|
kind: LIGHT_KIND.area,
|
|
158
182
|
// The irradiance it delivers, so a bright panel and a bright lamp are weighed in the same unit.
|
|
159
183
|
weight: glow * area / reach(centroid, bounds) ** 2,
|
|
160
184
|
density: 1 / area,
|
|
185
|
+
// It radiates from both sides, so its cone is the whole sphere.
|
|
186
|
+
tree: {
|
|
187
|
+
...corners([0, 3, 6].map(v => [p[at + v], p[at + v + 1], p[at + v + 2]])),
|
|
188
|
+
power: glow * area,
|
|
189
|
+
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]]),
|
|
190
|
+
thetaO: Math.PI,
|
|
191
|
+
thetaE: Math.PI / 2,
|
|
192
|
+
density: 1 / area,
|
|
193
|
+
},
|
|
161
194
|
write(words, _floats, at) {
|
|
162
195
|
words[at] = LIGHT_KIND.area;
|
|
163
196
|
words[at + 3] = triangle;
|
|
@@ -220,6 +253,8 @@ function analyticLight(
|
|
|
220
253
|
return {
|
|
221
254
|
kind: LIGHT_KIND.point,
|
|
222
255
|
weight: emitted * falloff,
|
|
256
|
+
tree: { low: [...light.position], high: [...light.position], power: emitted, axis: [0, 0, 1], thetaO: Math.PI,
|
|
257
|
+
thetaE: Math.PI / 2, density: 1 },
|
|
223
258
|
write: (words, floats, at) => common(words, floats, at, LIGHT_KIND.point),
|
|
224
259
|
};
|
|
225
260
|
}
|
|
@@ -239,6 +274,9 @@ function analyticLight(
|
|
|
239
274
|
kind: LIGHT_KIND.spot,
|
|
240
275
|
// Only the cone is lit, so only that share of the scene can expect anything from it.
|
|
241
276
|
weight: emitted * falloff * (1 - Math.cos(Math.min(light.angle, Math.PI))) / 2,
|
|
277
|
+
// It shines from its position towards its target, within its outer cone.
|
|
278
|
+
tree: { low: [...light.position], high: [...light.position], power: emitted, axis: scale(axis, -1),
|
|
279
|
+
thetaO: Math.min(light.angle, Math.PI), thetaE: 0, density: 1 },
|
|
242
280
|
write(words, floats, at) {
|
|
243
281
|
common(words, floats, at, LIGHT_KIND.spot);
|
|
244
282
|
floats[at + 15] = outer;
|
|
@@ -259,10 +297,25 @@ function analyticLight(
|
|
|
259
297
|
const up = normalize([matrix[4], matrix[5], matrix[6]]);
|
|
260
298
|
const area = Math.max(light.width * light.height, 1e-12);
|
|
261
299
|
|
|
300
|
+
const half = [scale(right, light.width / 2), scale(up, light.height / 2)];
|
|
301
|
+
|
|
302
|
+
const facing = normalize([
|
|
303
|
+
right[1] * up[2] - right[2] * up[1], right[2] * up[0] - right[0] * up[2], right[0] * up[1] - right[1] * up[0]]);
|
|
304
|
+
|
|
262
305
|
return {
|
|
263
306
|
kind: LIGHT_KIND.rect,
|
|
264
307
|
weight: emitted * area / distance ** 2,
|
|
265
308
|
density: 1 / area,
|
|
309
|
+
// One sided: it lights the side its axes' cross product turns away from, over a hemisphere.
|
|
310
|
+
tree: {
|
|
311
|
+
...corners([[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([a, b]) =>
|
|
312
|
+
[0, 1, 2].map(k => light.position[k] + half[0][k] * a + half[1][k] * b))),
|
|
313
|
+
power: emitted * area,
|
|
314
|
+
axis: scale(facing, -1),
|
|
315
|
+
thetaO: 0,
|
|
316
|
+
thetaE: Math.PI / 2,
|
|
317
|
+
density: 1 / area,
|
|
318
|
+
},
|
|
266
319
|
write(words, floats, at) {
|
|
267
320
|
common(words, floats, at, LIGHT_KIND.rect);
|
|
268
321
|
floats.set(scale(right, light.width / 2), at + 16);
|
|
@@ -342,7 +395,11 @@ export function packLights(
|
|
|
342
395
|
}]
|
|
343
396
|
: [];
|
|
344
397
|
|
|
345
|
-
|
|
398
|
+
// What sits in the scene first, so the tree's lights are the leading entries; then what lies outside it.
|
|
399
|
+
const local = analytic.filter(candidate => candidate.tree);
|
|
400
|
+
const distant = analytic.filter(candidate => !candidate.tree);
|
|
401
|
+
const candidates = [...areas, ...local, ...distant, ...surrounding];
|
|
402
|
+
const localCount = areas.length + local.length;
|
|
346
403
|
const total = candidates.reduce((sum, candidate) => sum + candidate.weight, 0);
|
|
347
404
|
const entries = new ArrayBuffer(Math.max(1, candidates.length) * LIGHT_WORDS * 4);
|
|
348
405
|
const words = new Uint32Array(entries);
|
|
@@ -370,7 +427,24 @@ export function packLights(
|
|
|
370
427
|
|
|
371
428
|
for (let index = 0; index < areas.length; index++) extra.push(words[index * LIGHT_WORDS + 3]);
|
|
372
429
|
|
|
430
|
+
let treeAt = 0, leavesAt = 0, listAt = 0;
|
|
431
|
+
|
|
432
|
+
if (localCount > 0) {
|
|
433
|
+
const tree = buildLightTree(candidates.slice(0, localCount).map(candidate => candidate.tree as TreeLight));
|
|
434
|
+
|
|
435
|
+
treeAt = base + extra.length;
|
|
436
|
+
listAt = treeAt + tree.listAt;
|
|
437
|
+
for (const word of tree.words) extra.push(word);
|
|
438
|
+
leavesAt = base + extra.length;
|
|
439
|
+
for (const leaf of tree.leafOf) extra.push(leaf);
|
|
440
|
+
}
|
|
441
|
+
|
|
373
442
|
return {
|
|
443
|
+
localCount,
|
|
444
|
+
localPick: localCount > 0 ? floats[(localCount - 1) * LIGHT_WORDS + 1] : 0,
|
|
445
|
+
treeAt,
|
|
446
|
+
leavesAt,
|
|
447
|
+
listAt,
|
|
374
448
|
cdfAt,
|
|
375
449
|
trianglesAt,
|
|
376
450
|
entries,
|