@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.
- package/ARCHITECTURE.md +81 -10
- package/README.md +53 -5
- 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/bvh.d.ts +11 -6
- package/dist/bvh.d.ts.map +1 -1
- package/dist/bvh.js +64 -11
- package/dist/bvh.js.map +1 -1
- package/dist/denoise.d.ts +17 -2
- package/dist/denoise.d.ts.map +1 -1
- package/dist/denoise.js +95 -27
- package/dist/denoise.js.map +1 -1
- package/dist/dispatchScheduler.d.ts +11 -4
- package/dist/dispatchScheduler.d.ts.map +1 -1
- package/dist/dispatchScheduler.js +14 -7
- package/dist/dispatchScheduler.js.map +1 -1
- package/dist/frameLayout.d.ts.map +1 -1
- package/dist/frameLayout.js +18 -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 +43 -0
- package/dist/lightTree.d.ts.map +1 -0
- package/dist/lightTree.js +107 -0
- package/dist/lightTree.js.map +1 -0
- package/dist/lights.d.ts +19 -1
- package/dist/lights.d.ts.map +1 -1
- package/dist/lights.js +63 -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 +19 -7
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +198 -36
- package/dist/renderer.js.map +1 -1
- package/dist/sceneUpload.d.ts +19 -1
- package/dist/sceneUpload.d.ts.map +1 -1
- package/dist/sceneUpload.js +25 -3
- package/dist/sceneUpload.js.map +1 -1
- package/dist/settings.d.ts +136 -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/texturePack.d.ts +16 -4
- package/dist/texturePack.d.ts.map +1 -1
- package/dist/texturePack.js +61 -8
- package/dist/texturePack.js.map +1 -1
- 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/bvh.wgsl.d.ts +8 -2
- package/dist/wgsl/bvh.wgsl.d.ts.map +1 -1
- package/dist/wgsl/bvh.wgsl.js +167 -96
- package/dist/wgsl/bvh.wgsl.js.map +1 -1
- package/dist/wgsl/common.wgsl.d.ts.map +1 -1
- package/dist/wgsl/common.wgsl.js +24 -7
- 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 +315 -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 +206 -33
- 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 +410 -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 +9 -17
- 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 +47 -4
- package/dist/wgsl/surface.wgsl.js.map +1 -1
- package/dist/wgsl/texture.wgsl.d.ts +1 -1
- package/dist/wgsl/texture.wgsl.d.ts.map +1 -1
- package/dist/wgsl/texture.wgsl.js +10 -11
- package/dist/wgsl/texture.wgsl.js.map +1 -1
- package/package.json +1 -1
- package/src/atmosphere.ts +95 -0
- package/src/bvh.ts +81 -12
- package/src/denoise.ts +110 -27
- package/src/dispatchScheduler.ts +16 -7
- package/src/frameLayout.ts +18 -0
- package/src/geometry.ts +6 -2
- package/src/index.ts +13 -0
- package/src/lensPass.ts +299 -0
- package/src/lightTree.ts +172 -0
- package/src/lights.ts +90 -2
- package/src/materialLayout.ts +52 -1
- package/src/pathTracePass.ts +19 -10
- package/src/renderer.ts +258 -43
- package/src/sceneUpload.ts +41 -4
- package/src/settings.ts +234 -0
- package/src/subsurface.ts +153 -0
- package/src/texturePack.ts +80 -13
- package/src/volumeContainment.ts +81 -0
- package/src/wgsl/bsdf.wgsl.ts +35 -3
- package/src/wgsl/bvh.wgsl.ts +167 -96
- package/src/wgsl/common.wgsl.ts +24 -7
- package/src/wgsl/lens.wgsl.ts +314 -0
- package/src/wgsl/lights.wgsl.ts +206 -33
- package/src/wgsl/pathTrace.wgsl.ts +410 -25
- package/src/wgsl/random.wgsl.ts +9 -17
- package/src/wgsl/sampling.wgsl.ts +25 -0
- package/src/wgsl/surface.wgsl.ts +47 -4
- package/src/wgsl/texture.wgsl.ts +10 -11
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,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
|
-
/**
|
|
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
|
-
|
|
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,
|