@tinypoly/gpu-renderer 0.0.3 → 0.0.5
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 +22 -7
- package/README.md +1 -1
- 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 +7 -2
- package/dist/denoise.d.ts.map +1 -1
- package/dist/denoise.js +61 -21
- 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 +3 -0
- package/dist/frameLayout.js.map +1 -1
- package/dist/imageResolve.d.ts +26 -0
- package/dist/imageResolve.d.ts.map +1 -0
- package/dist/imageResolve.js +114 -0
- package/dist/imageResolve.js.map +1 -0
- package/dist/lights.d.ts +9 -1
- package/dist/lights.d.ts.map +1 -1
- package/dist/lights.js +9 -0
- package/dist/lights.js.map +1 -1
- package/dist/renderer.d.ts +1 -0
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +97 -62
- package/dist/renderer.js.map +1 -1
- package/dist/sceneUpload.d.ts +14 -1
- package/dist/sceneUpload.d.ts.map +1 -1
- package/dist/sceneUpload.js +21 -3
- package/dist/sceneUpload.js.map +1 -1
- 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/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 +20 -7
- package/dist/wgsl/common.wgsl.js.map +1 -1
- 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 +3 -2
- 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 +15 -1
- 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/surface.wgsl.d.ts +1 -1
- package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
- package/dist/wgsl/surface.wgsl.js +26 -3
- 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/bvh.ts +81 -12
- package/src/denoise.ts +66 -21
- package/src/dispatchScheduler.ts +16 -7
- package/src/frameLayout.ts +3 -0
- package/src/imageResolve.ts +136 -0
- package/src/lights.ts +19 -1
- package/src/renderer.ts +123 -77
- package/src/sceneUpload.ts +32 -4
- package/src/texturePack.ts +80 -13
- package/src/wgsl/bvh.wgsl.ts +167 -96
- package/src/wgsl/common.wgsl.ts +20 -7
- package/src/wgsl/lights.wgsl.ts +3 -2
- package/src/wgsl/pathTrace.wgsl.ts +15 -1
- package/src/wgsl/random.wgsl.ts +9 -17
- package/src/wgsl/surface.wgsl.ts +26 -3
- package/src/wgsl/texture.wgsl.ts +10 -11
package/src/dispatchScheduler.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface DispatchSchedulerOptions {
|
|
|
13
13
|
initialRows?: number;
|
|
14
14
|
/** Rows in the whole image. */
|
|
15
15
|
maxRows: number;
|
|
16
|
+
/** The most paths per pixel one submit may take, which is how far past the whole image a band may grow. */
|
|
17
|
+
maxSamples?: number;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** The most one measurement may widen the band, so an unmeasured machine meets the watchdog gradually. */
|
|
@@ -24,20 +26,26 @@ export class DispatchScheduler {
|
|
|
24
26
|
private readonly targetMs: number;
|
|
25
27
|
private readonly initialRows: number;
|
|
26
28
|
private readonly maxRows: number;
|
|
29
|
+
private readonly maxSamples: number;
|
|
30
|
+
/** The work of the next submit, in rows times paths per pixel. */
|
|
27
31
|
private rows: number;
|
|
28
|
-
/** Rows a millisecond, as far as anything has shown. Zero until the first submit says something. */
|
|
32
|
+
/** Rows a millisecond, one path each, as far as anything has shown. Zero until the first submit says something. */
|
|
29
33
|
private throughput = 0;
|
|
30
34
|
|
|
31
35
|
constructor(options: DispatchSchedulerOptions) {
|
|
32
36
|
this.targetMs = options.targetMs ?? 12;
|
|
33
37
|
this.maxRows = Math.max(1, Math.floor(options.maxRows));
|
|
38
|
+
this.maxSamples = Math.max(1, Math.floor(options.maxSamples ?? 1));
|
|
34
39
|
this.initialRows = Math.max(1, Math.min(this.maxRows, Math.floor(options.initialRows ?? 16)));
|
|
35
40
|
this.rows = this.initialRows;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Rows the next submit should cover, never more than what is left. A submit that takes several paths per pixel
|
|
45
|
+
* covers that many times fewer rows, so it still takes about the target time.
|
|
46
|
+
*/
|
|
47
|
+
next(remaining: number, samples = 1): number {
|
|
48
|
+
return Math.max(1, Math.min(Math.floor(this.rows / Math.max(1, samples)), Math.floor(remaining)));
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
/**
|
|
@@ -52,15 +60,16 @@ export class DispatchScheduler {
|
|
|
52
60
|
* The rate is measured from the rows actually submitted rather than from the current guess, so one band clamped by
|
|
53
61
|
* what was left of the image does not stop the next one from growing.
|
|
54
62
|
*/
|
|
55
|
-
record(durationMs: number, rows: number) {
|
|
63
|
+
record(durationMs: number, rows: number, samples = 1) {
|
|
56
64
|
if (!Number.isFinite(durationMs) || durationMs <= 0 || rows <= 0) return;
|
|
57
65
|
|
|
58
|
-
const
|
|
66
|
+
const work = rows * Math.max(1, samples);
|
|
67
|
+
const observed = work / durationMs;
|
|
59
68
|
|
|
60
69
|
// Good news is believed at once, since it can only mean the band was cheaper than the clock suggested. Bad news
|
|
61
70
|
// is taken gradually, so one submit that happened to land behind something else does not collapse the band.
|
|
62
71
|
this.throughput = observed > this.throughput ? observed : this.throughput * DECAY + observed * (1 - DECAY);
|
|
63
|
-
this.rows = Math.max(1, Math.min(this.maxRows,
|
|
72
|
+
this.rows = Math.max(1, Math.min(this.maxRows * this.maxSamples, work * GROW, this.targetMs * this.throughput));
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/** Back to the unmeasured band, for a new scene or a new size. */
|
package/src/frameLayout.ts
CHANGED
|
@@ -30,11 +30,14 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
|
|
|
30
30
|
{ name: "triangleCount", type: "u32" },
|
|
31
31
|
{ name: "lightCount", type: "u32" },
|
|
32
32
|
{ name: "areaLightCount", type: "u32", doc: "How many leading lights are emissive triangles." },
|
|
33
|
+
{ name: "lightCdf", type: "u32", doc: "Where every light's cumulative probability starts in the texture buffer." },
|
|
34
|
+
{ name: "lightTriangles", type: "u32", doc: "Where every emissive light's triangle starts in the texture buffer." },
|
|
33
35
|
{ name: "clampIndirect", type: "f32", doc: "Maximum luminance one indirect contribution may add; zero leaves it." },
|
|
34
36
|
{ name: "shadowSpread", type: "f32", doc: "Tangent of half the angular diameter shadow rays spread over." },
|
|
35
37
|
{ name: "shadowsEnabled", type: "u32" },
|
|
36
38
|
{ name: "uvOffset", type: "u32", doc: "Where the UV sets start inside the attribute buffer." },
|
|
37
39
|
{ name: "orderOffset", type: "u32", doc: "Where the hierarchy's triangle order starts inside the index buffer." },
|
|
40
|
+
{ name: "leafOffset", type: "u32", doc: "Where each triangle's position in that order starts inside the index buffer." },
|
|
38
41
|
{ name: "colorOffset", type: "u32", doc: "Where vertex colours start inside the attribute buffer." },
|
|
39
42
|
{ name: "hasColors", type: "u32" },
|
|
40
43
|
{ name: "varyingOffset", type: "u32", doc: "Where shader varyings start inside the attribute buffer." },
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createShaderModule } from "./shaderModule.js";
|
|
2
|
+
import type { RenderToneMapping } from "./upstream.js";
|
|
3
|
+
import { PRESENT_WGSL } from "./wgsl/present.wgsl.js";
|
|
4
|
+
|
|
5
|
+
/** The order the presentation shader's `toneMapping` switch expects. */
|
|
6
|
+
const TONE_MAPPING = ["aces", "agx", "neutral", "linear"] as const;
|
|
7
|
+
|
|
8
|
+
const UNIFORM_BYTES = 48;
|
|
9
|
+
|
|
10
|
+
const WORKGROUP = 8;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The finished image, resolved on the device: the presentation pass's display transform, run as a compute pass that
|
|
14
|
+
* writes straight-alpha sRGB bytes, four per pixel. Reading those back is a sixth of the float accumulation and needs
|
|
15
|
+
* no loop over the pixels on the page, which is what held it for seconds at the end of a large frame.
|
|
16
|
+
*
|
|
17
|
+
* It follows the image `render()` has always resolved: no grain, and the vignette measured from each pixel's corner.
|
|
18
|
+
*/
|
|
19
|
+
export const IMAGE_RESOLVE_WGSL = /* wgsl */ `${PRESENT_WGSL}
|
|
20
|
+
@group(0) @binding(3) var<storage, read_write> pixels: array<u32>;
|
|
21
|
+
|
|
22
|
+
fn encodeByte(value: f32) -> u32 {
|
|
23
|
+
return u32(round(clamp(value, 0.0, 1.0) * 255.0));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@compute @workgroup_size(${WORKGROUP}, ${WORKGROUP})
|
|
27
|
+
fn resolveMain(@builtin(global_invocation_id) id: vec3<u32>) {
|
|
28
|
+
let width = present.resolution.x;
|
|
29
|
+
let height = present.resolution.y;
|
|
30
|
+
|
|
31
|
+
if (id.x >= width || id.y >= height) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let index = id.y * width + id.x;
|
|
36
|
+
let stored = accumulation[index];
|
|
37
|
+
let samples = stored.w;
|
|
38
|
+
var alpha = 1.0;
|
|
39
|
+
|
|
40
|
+
if (present.transparent != 0u) {
|
|
41
|
+
alpha = select(0.0, clamp(accumulation[width * height + index].x / samples, 0.0, 1.0), samples > 0.0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var color = vec3<f32>(0.0);
|
|
45
|
+
|
|
46
|
+
if (samples > 0.0 && alpha > 0.0) {
|
|
47
|
+
let mean = select(stored.rgb / samples, denoised[index].rgb, present.denoised != 0u);
|
|
48
|
+
let centred = vec2<f32>(f32(id.x) / f32(width), f32(id.y) / f32(height)) - 0.5;
|
|
49
|
+
let falloff = max(0.0, 1.0 - present.vignette * dot(centred, centred) * 2.0);
|
|
50
|
+
|
|
51
|
+
color = mean / alpha * falloff;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let encoded = linearToSrgb(toneMap(color));
|
|
55
|
+
|
|
56
|
+
pixels[index] = encodeByte(encoded.r) | (encodeByte(encoded.g) << 8u) | (encodeByte(encoded.b) << 16u)
|
|
57
|
+
| (encodeByte(alpha) << 24u);
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
export interface ResolveSettings {
|
|
62
|
+
exposure: number;
|
|
63
|
+
toneMapping: RenderToneMapping;
|
|
64
|
+
vignette: number;
|
|
65
|
+
transparent: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class ImageResolvePass {
|
|
69
|
+
private readonly pipeline: GPUComputePipeline;
|
|
70
|
+
private readonly uniforms: GPUBuffer;
|
|
71
|
+
private readonly values = new ArrayBuffer(UNIFORM_BYTES);
|
|
72
|
+
|
|
73
|
+
constructor(private readonly device: GPUDevice) {
|
|
74
|
+
const module = createShaderModule(device, "image resolve", IMAGE_RESOLVE_WGSL);
|
|
75
|
+
|
|
76
|
+
this.pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "resolveMain" } });
|
|
77
|
+
this.uniforms = device.createBuffer({
|
|
78
|
+
size: UNIFORM_BYTES, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The image as RGBA bytes, straight alpha, row by row from the top. */
|
|
83
|
+
async read(accumulation: GPUBuffer, denoised: GPUBuffer | null, width: number, height: number,
|
|
84
|
+
settings: ResolveSettings): Promise<Uint8ClampedArray<ArrayBuffer>> {
|
|
85
|
+
const floats = new Float32Array(this.values), integers = new Uint32Array(this.values);
|
|
86
|
+
|
|
87
|
+
integers.fill(0);
|
|
88
|
+
integers[0] = width;
|
|
89
|
+
integers[1] = height;
|
|
90
|
+
floats[2] = settings.exposure;
|
|
91
|
+
integers[3] = Math.max(0, TONE_MAPPING.indexOf(settings.toneMapping));
|
|
92
|
+
floats[4] = settings.vignette;
|
|
93
|
+
integers[7] = settings.transparent ? 1 : 0;
|
|
94
|
+
integers[8] = denoised ? 1 : 0;
|
|
95
|
+
this.device.queue.writeBuffer(this.uniforms, 0, this.values);
|
|
96
|
+
|
|
97
|
+
const bytes = width * height * 4;
|
|
98
|
+
const output = this.device.createBuffer({ size: bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
|
|
99
|
+
const staging = this.device.createBuffer({ size: bytes, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const encoder = this.device.createCommandEncoder({ label: "image resolve" });
|
|
103
|
+
const pass = encoder.beginComputePass({ label: "image resolve" });
|
|
104
|
+
|
|
105
|
+
pass.setPipeline(this.pipeline);
|
|
106
|
+
pass.setBindGroup(0, this.device.createBindGroup({
|
|
107
|
+
layout: this.pipeline.getBindGroupLayout(0),
|
|
108
|
+
entries: [
|
|
109
|
+
{ binding: 0, resource: { buffer: this.uniforms } },
|
|
110
|
+
{ binding: 1, resource: { buffer: accumulation } },
|
|
111
|
+
{ binding: 2, resource: { buffer: denoised ?? accumulation } },
|
|
112
|
+
{ binding: 3, resource: { buffer: output } },
|
|
113
|
+
],
|
|
114
|
+
}));
|
|
115
|
+
pass.dispatchWorkgroups(Math.ceil(width / WORKGROUP), Math.ceil(height / WORKGROUP));
|
|
116
|
+
pass.end();
|
|
117
|
+
encoder.copyBufferToBuffer(output, 0, staging, 0, bytes);
|
|
118
|
+
this.device.queue.submit([encoder.finish()]);
|
|
119
|
+
|
|
120
|
+
await staging.mapAsync(GPUMapMode.READ);
|
|
121
|
+
|
|
122
|
+
const data = new Uint8ClampedArray(staging.getMappedRange().slice(0));
|
|
123
|
+
|
|
124
|
+
staging.unmap();
|
|
125
|
+
|
|
126
|
+
return data;
|
|
127
|
+
} finally {
|
|
128
|
+
output.destroy();
|
|
129
|
+
staging.destroy();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
dispose() {
|
|
134
|
+
this.uniforms.destroy();
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/lights.ts
CHANGED
|
@@ -50,8 +50,16 @@ export interface PackedLights {
|
|
|
50
50
|
ambient: { sky: number[]; ground: number[]; axis: number[] };
|
|
51
51
|
/** How often direct sampling picks the environment rather than a light. Zero when there is nothing to aim at. */
|
|
52
52
|
environmentPick: number;
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Words to append to the texture buffer: a spot light's map index and the matrix that projects it, then the
|
|
55
|
+
* cumulative probability of every entry and the triangle of every emissive one, packed tight so the two searches the
|
|
56
|
+
* shader makes over them read four bytes a step instead of a whole entry.
|
|
57
|
+
*/
|
|
54
58
|
extraWords: Uint32Array;
|
|
59
|
+
/** Where the cumulative probabilities start in the texture buffer, one float per entry. */
|
|
60
|
+
cdfAt: number;
|
|
61
|
+
/** Where the emissive entries' triangles start in the texture buffer, one word per emissive entry. */
|
|
62
|
+
trianglesAt: number;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
const luminance = (color: ArrayLike<number>) => 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2];
|
|
@@ -353,8 +361,18 @@ export function packLights(
|
|
|
353
361
|
});
|
|
354
362
|
|
|
355
363
|
const environmentPick = surrounding.length ? surrounding[0].weight / total : 0;
|
|
364
|
+
const base = options.extraBase ?? 0;
|
|
365
|
+
const cdfAt = base + extra.length;
|
|
366
|
+
|
|
367
|
+
for (let index = 0; index < candidates.length; index++) extra.push(words[index * LIGHT_WORDS + 1]);
|
|
368
|
+
|
|
369
|
+
const trianglesAt = base + extra.length;
|
|
370
|
+
|
|
371
|
+
for (let index = 0; index < areas.length; index++) extra.push(words[index * LIGHT_WORDS + 3]);
|
|
356
372
|
|
|
357
373
|
return {
|
|
374
|
+
cdfAt,
|
|
375
|
+
trianglesAt,
|
|
358
376
|
entries,
|
|
359
377
|
count: candidates.length,
|
|
360
378
|
areaCount: areas.length,
|
package/src/renderer.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { PerspectiveCamera, type LightProbe, type Material, type Object3D, type Scene } from "three";
|
|
2
2
|
import { Accumulator } from "./accumulator.js";
|
|
3
|
-
import { type PackedBvh } from "./bvh.js";
|
|
4
3
|
import { BvhBuilder, createDefaultBvhWorker, type BvhWorkerFactory } from "./bvhBuilder.js";
|
|
5
4
|
import { cameraMatrices, type CameraMatrices } from "./camera.js";
|
|
6
5
|
import { requestRenderDevice, type RenderDevice } from "./device.js";
|
|
7
6
|
import { loadEnvironment, type PackedEnvironment } from "./environment.js";
|
|
8
7
|
import { DispatchScheduler } from "./dispatchScheduler.js";
|
|
9
|
-
import { packGeometry
|
|
8
|
+
import { packGeometry } from "./geometry.js";
|
|
10
9
|
import { packLights } from "./lights.js";
|
|
11
10
|
import { NO_PROGRAM } from "./materialLayout.js";
|
|
12
|
-
import { NO_TEXTURE, planTextures,
|
|
11
|
+
import { NO_TEXTURE, planTextures, textureEntry, writeTextures } from "./texturePack.js";
|
|
13
12
|
import { PathTracePass, type FrameUniforms } from "./pathTracePass.js";
|
|
13
|
+
import { ImageResolvePass } from "./imageResolve.js";
|
|
14
14
|
import { PresentPass } from "./presentPass.js";
|
|
15
15
|
import {
|
|
16
16
|
CONVERGED_ERROR,
|
|
@@ -27,7 +27,6 @@ import { environmentPower, uploadScene, type SceneBuffers } from "./sceneUpload.
|
|
|
27
27
|
import {
|
|
28
28
|
DEFAULT_RENDER_ENVIRONMENT,
|
|
29
29
|
DEFAULT_RENDER_SETTINGS,
|
|
30
|
-
encodeColor,
|
|
31
30
|
EventEmitter,
|
|
32
31
|
mergeSettings,
|
|
33
32
|
serializeCamera,
|
|
@@ -131,6 +130,31 @@ const yieldToPage = () => new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
|
131
130
|
*/
|
|
132
131
|
const SHOW_EVERY_MS = 16;
|
|
133
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Submits on the device at once. Two keep it busy: the next band is queued while the last one runs, and each submit
|
|
135
|
+
* still stays short enough for the watchdog on its own.
|
|
136
|
+
*/
|
|
137
|
+
const BANDS_IN_FLIGHT = 2;
|
|
138
|
+
|
|
139
|
+
/** How often the canvas takes a freshly denoised image while a frame traces. */
|
|
140
|
+
const DENOISE_EVERY_MS = 200;
|
|
141
|
+
|
|
142
|
+
/** The most paths per pixel one dispatch takes, while no pixel can stop yet. */
|
|
143
|
+
const BATCH_PATHS = 4;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Paths per pixel the next pass takes. The first takes one, so the canvas shows something at once. Until adaptive
|
|
147
|
+
* sampling may stop a pixel, every pixel takes every path anyway, so a pass takes several and pays for reading and
|
|
148
|
+
* writing the accumulation once; from then on one at a time, so a pixel stops as soon as it has converged.
|
|
149
|
+
*/
|
|
150
|
+
function pathsPerDispatch(taken: number, until: number, adaptive: boolean): number {
|
|
151
|
+
if (taken === 0) return 1;
|
|
152
|
+
|
|
153
|
+
if (adaptive && taken >= until) return 1;
|
|
154
|
+
|
|
155
|
+
return Math.max(1, Math.min(BATCH_PATHS, until - taken));
|
|
156
|
+
}
|
|
157
|
+
|
|
134
158
|
const EMPTY_SCENE: SerializedScene = { meshes: [], lights: [], textures: [] };
|
|
135
159
|
|
|
136
160
|
/** Paths every pixel takes before adaptive sampling may stop it: below this its variance says too little. */
|
|
@@ -161,8 +185,18 @@ function rowRanges(rows: readonly number[]): [number, number][] {
|
|
|
161
185
|
const CAPTURE_SAMPLES = 32;
|
|
162
186
|
|
|
163
187
|
/** A scene on the device, with where its extra words went. */
|
|
188
|
+
/**
|
|
189
|
+
* What is kept of a packed scene once it is on the device: whether it follows the camera, and where each texture's
|
|
190
|
+
* texels start, for the captures that write into them. The packed arrays themselves, hundreds of megabytes for a large
|
|
191
|
+
* scene, are let go as soon as they are uploaded.
|
|
192
|
+
*/
|
|
193
|
+
interface PackedSummary {
|
|
194
|
+
cameraDependent: boolean;
|
|
195
|
+
textureOffsets: ReadonlyMap<string, number>;
|
|
196
|
+
}
|
|
197
|
+
|
|
164
198
|
interface PackedTarget {
|
|
165
|
-
packed:
|
|
199
|
+
packed: PackedSummary;
|
|
166
200
|
buffers: SceneBuffers;
|
|
167
201
|
skyData: number;
|
|
168
202
|
volumetricData: number;
|
|
@@ -201,7 +235,8 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
201
235
|
private pathTrace: PathTracePass | null = null;
|
|
202
236
|
private present: PresentPass | null = null;
|
|
203
237
|
private buffers: SceneBuffers | null = null;
|
|
204
|
-
private packed:
|
|
238
|
+
private packed: PackedSummary | null = null;
|
|
239
|
+
private imageResolve: ImageResolvePass | null = null;
|
|
205
240
|
private sceneDirty = true;
|
|
206
241
|
// Compute bakes, the procedural sky and translated shaders: prepared once per scene.
|
|
207
242
|
private assets: Promise<SceneAssets> | null = null;
|
|
@@ -242,7 +277,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
242
277
|
this.current = mergeSettings(DEFAULT_RENDER_SETTINGS, options.settings);
|
|
243
278
|
this.denoiserSettings = mergeDenoiser(DEFAULT_DENOISER, options.denoiser);
|
|
244
279
|
this.environment = DEFAULT_RENDER_ENVIRONMENT;
|
|
245
|
-
this.scheduler = new DispatchScheduler({ maxRows: this.renderHeight });
|
|
280
|
+
this.scheduler = new DispatchScheduler({ maxRows: this.renderHeight, maxSamples: BATCH_PATHS });
|
|
246
281
|
this.setEnvironment(options.environment ?? DEFAULT_RENDER_ENVIRONMENT);
|
|
247
282
|
|
|
248
283
|
if (options.scene) this.setScene(options.scene);
|
|
@@ -472,6 +507,8 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
472
507
|
this.buffers?.destroy();
|
|
473
508
|
this.accumulator?.destroy();
|
|
474
509
|
this.denoisePass?.destroy();
|
|
510
|
+
this.imageResolve?.dispose();
|
|
511
|
+
this.imageResolve = null;
|
|
475
512
|
this.gpu?.device.destroy();
|
|
476
513
|
this.buffers = null;
|
|
477
514
|
this.accumulator = null;
|
|
@@ -537,29 +574,57 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
537
574
|
this.emit("start", { width, height });
|
|
538
575
|
|
|
539
576
|
const target = Math.max(1, Math.round(this.current.samples));
|
|
540
|
-
// Adaptive sampling: the frame has the work of `target`
|
|
577
|
+
// Adaptive sampling: the frame has the work of `target` paths over the whole image to spend. Pixels that have
|
|
541
578
|
// converged stop taking samples, and a row with none left stops being dispatched, so what they would have used goes
|
|
542
579
|
// to the rows that still need it, up to a few times `target` paths in the noisiest pixels.
|
|
543
580
|
const adaptive = target > ADAPTIVE_MIN;
|
|
544
|
-
const
|
|
581
|
+
const maxPaths = adaptive ? target * ADAPTIVE_REACH : target;
|
|
582
|
+
// In rows times paths, the unit the scheduler measures work in.
|
|
545
583
|
const budget = target * height;
|
|
546
584
|
const accumulator = this.accumulator as Accumulator;
|
|
547
585
|
let active: number[] = Array.from({ length: height }, (_, row) => row);
|
|
548
|
-
|
|
586
|
+
// Paths every pixel still in the running has taken so far, which is also the next sample index.
|
|
587
|
+
let taken = 0;
|
|
588
|
+
let spent = 0, band = 0;
|
|
549
589
|
let shown = performance.now();
|
|
590
|
+
let denoisedAt = -Infinity;
|
|
550
591
|
const denoise = this.current.globalIllumination.denoise;
|
|
592
|
+
// Bands on the device, oldest first. The next one is encoded and queued while the last one runs, so the device
|
|
593
|
+
// never waits for the page to hear that a band finished.
|
|
594
|
+
const flight: { done: Promise<unknown>; submitted: number; rows: number; samples: number }[] = [];
|
|
595
|
+
let settledAt = 0;
|
|
596
|
+
|
|
597
|
+
const settle = async() => {
|
|
598
|
+
const oldest = flight.shift();
|
|
599
|
+
|
|
600
|
+
if (!oldest) return;
|
|
601
|
+
|
|
602
|
+
await oldest.done;
|
|
603
|
+
|
|
604
|
+
const now = performance.now();
|
|
605
|
+
|
|
606
|
+
// A band starts on the device when the one before it ends, or when it was submitted if the device was idle. The
|
|
607
|
+
// page may hear of that end late, which would make this band look shorter than it ran; since it was submitted it
|
|
608
|
+
// has waited for at most the bands ahead of it, so its share of that wait bounds it from below.
|
|
609
|
+
const ran = Math.max(now - Math.max(oldest.submitted, settledAt), (now - oldest.submitted) / BANDS_IN_FLIGHT);
|
|
610
|
+
|
|
611
|
+
this.scheduler.record(ran, oldest.rows, oldest.samples);
|
|
612
|
+
settledAt = now;
|
|
613
|
+
};
|
|
551
614
|
|
|
552
615
|
this.denoised = false;
|
|
553
616
|
this.finished = null;
|
|
554
617
|
|
|
555
618
|
const matrices = cameraMatrices(camera, width, height);
|
|
619
|
+
const uniforms = this.frameUniforms(camera, width, height, assets);
|
|
556
620
|
|
|
557
621
|
// Two over the projection's vertical scale and the height: one pixel's extent, per unit of depth in perspective.
|
|
558
622
|
this.denoiseView = { pixelScale: 2 / (matrices.projection[5] * height), orthographic: matrices.orthographic };
|
|
559
623
|
|
|
560
|
-
while (
|
|
624
|
+
while (taken < maxPaths && spent < budget && active.length) {
|
|
561
625
|
// Only a pass whose pixels may stop needs its rows flagged; before that every row is busy anyway.
|
|
562
|
-
const flagging = adaptive &&
|
|
626
|
+
const flagging = adaptive && taken >= ADAPTIVE_MIN;
|
|
627
|
+
const samples = pathsPerDispatch(taken, adaptive ? ADAPTIVE_MIN : target, adaptive);
|
|
563
628
|
|
|
564
629
|
if (flagging) {
|
|
565
630
|
const clear = gpu.device.createCommandEncoder();
|
|
@@ -576,17 +641,17 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
576
641
|
|
|
577
642
|
this.check(revision);
|
|
578
643
|
|
|
579
|
-
const rows = this.scheduler.next(end - row);
|
|
644
|
+
const rows = this.scheduler.next(end - row, samples);
|
|
580
645
|
const started = performance.now();
|
|
581
646
|
const encoder = gpu.device.createCommandEncoder();
|
|
582
647
|
|
|
583
648
|
this.emit("bucket", { worker: 0, bucket: { x: 0, y: row, width, height: rows, index: band++ }, width, height });
|
|
584
649
|
this.pathTrace?.encode(encoder, {
|
|
585
|
-
...
|
|
650
|
+
...uniforms,
|
|
586
651
|
bandOrigin: [0, row],
|
|
587
652
|
bandSize: [width, rows],
|
|
588
|
-
sampleIndex:
|
|
589
|
-
sampleCount:
|
|
653
|
+
sampleIndex: taken,
|
|
654
|
+
sampleCount: samples,
|
|
590
655
|
adaptiveMin: adaptive ? ADAPTIVE_MIN : 0,
|
|
591
656
|
adaptiveThreshold: ADAPTIVE_THRESHOLD,
|
|
592
657
|
});
|
|
@@ -596,11 +661,12 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
596
661
|
if (show) this.encodePresent(encoder, width, height);
|
|
597
662
|
|
|
598
663
|
gpu.device.queue.submit([encoder.finish()]);
|
|
599
|
-
|
|
664
|
+
flight.push({ done: gpu.device.queue.onSubmittedWorkDone(), submitted: performance.now(), rows, samples });
|
|
665
|
+
|
|
666
|
+
while (flight.length >= BANDS_IN_FLIGHT) await settle();
|
|
600
667
|
|
|
601
|
-
this.scheduler.record(performance.now() - started, rows);
|
|
602
668
|
row += rows;
|
|
603
|
-
spent += rows;
|
|
669
|
+
spent += rows * samples;
|
|
604
670
|
this.emit("progress", { completed: Math.min(spent, budget), total: budget, phase: "shade" });
|
|
605
671
|
|
|
606
672
|
if (show) {
|
|
@@ -610,24 +676,35 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
610
676
|
}
|
|
611
677
|
}
|
|
612
678
|
|
|
613
|
-
|
|
679
|
+
taken += samples;
|
|
614
680
|
this.frameIndex++;
|
|
615
681
|
|
|
616
|
-
// The
|
|
617
|
-
//
|
|
618
|
-
|
|
682
|
+
// The rows this pass still traced in are copied out before anything else is queued, so they can be read while
|
|
683
|
+
// the device goes on with the denoiser.
|
|
684
|
+
const rowsRead = flagging ? this.activeRows(gpu, accumulator) : null;
|
|
685
|
+
|
|
686
|
+
// The canvas shows the denoised image, refreshed a few times a second while the bands refine underneath it:
|
|
687
|
+
// filtering the whole image after every pass would cost a cheap scene as much as tracing it.
|
|
688
|
+
if (denoise && this.canvas && this.context && performance.now() - denoisedAt >= DENOISE_EVERY_MS) {
|
|
619
689
|
const encoder = gpu.device.createCommandEncoder();
|
|
620
690
|
|
|
621
691
|
this.encodeDenoise(gpu, encoder, width, height);
|
|
622
692
|
this.encodePresent(encoder, width, height);
|
|
623
693
|
gpu.device.queue.submit([encoder.finish()]);
|
|
694
|
+
denoisedAt = performance.now();
|
|
624
695
|
}
|
|
625
696
|
|
|
626
697
|
this.emit("sample", { samples: Math.min(target, Math.round(spent / height)), target });
|
|
627
698
|
|
|
628
|
-
if (
|
|
699
|
+
if (rowsRead) {
|
|
700
|
+
while (flight.length) await settle();
|
|
701
|
+
|
|
702
|
+
active = await rowsRead;
|
|
703
|
+
}
|
|
629
704
|
}
|
|
630
705
|
|
|
706
|
+
while (flight.length) await settle();
|
|
707
|
+
|
|
631
708
|
// Converged early, or spent: either way the image is as finished as this frame makes it.
|
|
632
709
|
this.emit("progress", { completed: budget, total: budget, phase: "shade" });
|
|
633
710
|
this.emit("sample", { samples: target, target });
|
|
@@ -698,17 +775,22 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
698
775
|
const environment = assets.sky?.environment ?? image;
|
|
699
776
|
const matrices = cameraMatrices(camera, width, height);
|
|
700
777
|
const view = `${JSON.stringify(camera)}|${width}|${height}`;
|
|
701
|
-
const moved = (!!this.packed?.
|
|
778
|
+
const moved = (!!this.packed?.cameraDependent || this.planar) && view !== this.packedView;
|
|
702
779
|
|
|
703
780
|
if (this.sceneDirty || !this.buffers || moved) {
|
|
704
781
|
// Planar surfaces need their captures, from this camera, before the scene that samples them is packed.
|
|
705
782
|
const planar = planarCaptures(assets.scene, camera, width, height);
|
|
706
783
|
|
|
784
|
+
// The scene on the device is replaced: letting it go first keeps the two from being held at once.
|
|
785
|
+
this.buffers?.destroy();
|
|
786
|
+
this.buffers = null;
|
|
787
|
+
this.target = null;
|
|
788
|
+
this.packed = null;
|
|
789
|
+
|
|
707
790
|
const packed = await this.packScene(
|
|
708
791
|
gpu, revision, planar?.scene ?? assets.scene, assets, matrices, width, height, environment);
|
|
709
792
|
|
|
710
793
|
this.packed = packed.packed;
|
|
711
|
-
this.buffers?.destroy();
|
|
712
794
|
this.buffers = packed.buffers;
|
|
713
795
|
this.target = packed;
|
|
714
796
|
this.sceneDirty = false;
|
|
@@ -724,7 +806,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
724
806
|
|
|
725
807
|
this.accumulator ??= new Accumulator(gpu.device, width, height);
|
|
726
808
|
|
|
727
|
-
if (resized) this.scheduler = new DispatchScheduler({ maxRows: height });
|
|
809
|
+
if (resized) this.scheduler = new DispatchScheduler({ maxRows: height, maxSamples: BATCH_PATHS });
|
|
728
810
|
|
|
729
811
|
this.pathTrace.setBindings(this.buffers, this.accumulator.buffer);
|
|
730
812
|
this.configureCanvas(gpu, width, height);
|
|
@@ -802,8 +884,12 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
802
884
|
// Nothing is on the device yet, so a frame replaced while the hierarchy was built has nothing to give back.
|
|
803
885
|
this.check(revision);
|
|
804
886
|
|
|
805
|
-
const
|
|
806
|
-
|
|
887
|
+
const buffers = uploadScene(gpu.device, geometry, bvh, textures, lights, environment, cdf);
|
|
888
|
+
|
|
889
|
+
const textureOffsets = new Map([...textures.indexOf]
|
|
890
|
+
.map(([id, index]) => [id, textures.data[textureEntry(index) + 2]]));
|
|
891
|
+
|
|
892
|
+
const packed: PackedSummary = { cameraDependent: geometry.cameraDependent, textureOffsets };
|
|
807
893
|
|
|
808
894
|
if (buffers.bytes.largest > gpu.maxStorageBufferBindingSize) {
|
|
809
895
|
buffers.destroy();
|
|
@@ -869,9 +955,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
869
955
|
}
|
|
870
956
|
|
|
871
957
|
const offset = (id: string | undefined) => {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
return index === undefined ? NO_TEXTURE : main.packed.textures.data[index * 16 + 2];
|
|
958
|
+
return (id === undefined ? undefined : main.packed.textureOffsets.get(id)) ?? NO_TEXTURE;
|
|
875
959
|
};
|
|
876
960
|
|
|
877
961
|
const encoder = gpu.device.createCommandEncoder();
|
|
@@ -988,12 +1072,15 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
988
1072
|
triangleCount: buffers?.triangleCount ?? 0,
|
|
989
1073
|
lightCount: buffers?.lightCount ?? 0,
|
|
990
1074
|
areaLightCount: buffers?.areaLightCount ?? 0,
|
|
1075
|
+
lightCdf: buffers?.lightCdf ?? 0,
|
|
1076
|
+
lightTriangles: buffers?.lightTriangles ?? 0,
|
|
991
1077
|
clampIndirect: Math.max(0, this.current.globalIllumination.clamp),
|
|
992
1078
|
// The setting is the angular diameter a light covers, in degrees, as it is for the CPU renderer's shadow blur.
|
|
993
1079
|
shadowSpread: shadows.enabled ? Math.tan(Math.max(0, shadows.softness) * Math.PI / 360) : 0,
|
|
994
1080
|
shadowsEnabled: shadows.enabled,
|
|
995
1081
|
uvOffset: buffers?.uvOffset ?? 0,
|
|
996
1082
|
orderOffset: buffers?.orderOffset ?? 0,
|
|
1083
|
+
leafOffset: buffers?.leafOffset ?? 0,
|
|
997
1084
|
colorOffset: buffers?.colorOffset ?? 0,
|
|
998
1085
|
hasColors: buffers?.hasColors ?? false,
|
|
999
1086
|
varyingOffset: buffers?.varyingOffset ?? 0,
|
|
@@ -1021,55 +1108,14 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
|
|
|
1021
1108
|
private async readImage(
|
|
1022
1109
|
gpu: RenderDevice, width: number, height: number, denoised: GPUBuffer | null): Promise<RenderImage> {
|
|
1023
1110
|
const accumulator = this.accumulator as Accumulator;
|
|
1024
|
-
// The radiance and the coverage; the guides and the row flags after them are not part of the image.
|
|
1025
|
-
const length = width * height * 32;
|
|
1026
|
-
|
|
1027
|
-
const read = async(buffer: GPUBuffer, bytes: number) => {
|
|
1028
|
-
const usage = GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST;
|
|
1029
|
-
const staging = gpu.device.createBuffer({ size: bytes, usage });
|
|
1030
|
-
const encoder = gpu.device.createCommandEncoder();
|
|
1031
|
-
|
|
1032
|
-
encoder.copyBufferToBuffer(buffer, 0, staging, 0, bytes);
|
|
1033
|
-
gpu.device.queue.submit([encoder.finish()]);
|
|
1034
|
-
await staging.mapAsync(GPUMapMode.READ);
|
|
1035
|
-
|
|
1036
|
-
const floats = new Float32Array(staging.getMappedRange().slice(0));
|
|
1037
|
-
|
|
1038
|
-
staging.unmap();
|
|
1039
|
-
staging.destroy();
|
|
1040
|
-
|
|
1041
|
-
return floats;
|
|
1042
|
-
};
|
|
1043
|
-
|
|
1044
|
-
const floats = await read(accumulator.buffer, length);
|
|
1045
|
-
// The denoiser's output is the mean radiance itself, one vec4 per pixel.
|
|
1046
|
-
const filtered = denoised ? await read(denoised, width * height * 16) : null;
|
|
1047
|
-
|
|
1048
|
-
const data = new Uint8ClampedArray(width * height * 4);
|
|
1049
1111
|
const { exposure, toneMapping, vignette } = this.current;
|
|
1050
1112
|
const transparent = this.current.background.mode === "transparent";
|
|
1051
|
-
const coverage = width * height * 4;
|
|
1052
|
-
|
|
1053
|
-
for (let pixel = 0; pixel < width * height; pixel++) {
|
|
1054
|
-
const samples = floats[pixel * 4 + 3];
|
|
1055
|
-
const x = (pixel % width) / width - 0.5, y = Math.floor(pixel / width) / height - 0.5;
|
|
1056
|
-
const falloff = Math.max(0, 1 - vignette * (x * x + y * y) * 2);
|
|
1057
|
-
|
|
1058
|
-
// The radiance is premultiplied by the coverage, which a transparent background makes the alpha.
|
|
1059
|
-
const alpha = !transparent ? 1
|
|
1060
|
-
: samples > 0 ? Math.min(1, Math.max(0, floats[coverage + pixel * 4] / samples)) : 0;
|
|
1061
1113
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1114
|
+
// Resolved on the device, so the page only copies the bytes of the image.
|
|
1115
|
+
this.imageResolve ??= new ImageResolvePass(gpu.device);
|
|
1064
1116
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
: [0, 0, 0];
|
|
1068
|
-
|
|
1069
|
-
const [r, g, b] = encodeColor(color, exposure, toneMapping);
|
|
1070
|
-
|
|
1071
|
-
data.set([r, g, b, Math.round(alpha * 255)], pixel * 4);
|
|
1072
|
-
}
|
|
1117
|
+
const data = await this.imageResolve.read(accumulator.buffer, denoised, width, height,
|
|
1118
|
+
{ exposure, toneMapping, vignette, transparent });
|
|
1073
1119
|
|
|
1074
1120
|
return { width, height, data };
|
|
1075
1121
|
}
|