@tinypoly/gpu-renderer 0.0.0-stage → 0.0.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.
Files changed (204) hide show
  1. package/ARCHITECTURE.md +339 -0
  2. package/LICENSE +21 -0
  3. package/README.md +284 -2
  4. package/dist/accumulator.d.ts +27 -0
  5. package/dist/accumulator.d.ts.map +1 -0
  6. package/dist/accumulator.js +57 -0
  7. package/dist/accumulator.js.map +1 -0
  8. package/dist/bvh.d.ts +26 -0
  9. package/dist/bvh.d.ts.map +1 -0
  10. package/dist/bvh.js +296 -0
  11. package/dist/bvh.js.map +1 -0
  12. package/dist/bvhBuilder.d.ts +72 -0
  13. package/dist/bvhBuilder.d.ts.map +1 -0
  14. package/dist/bvhBuilder.js +126 -0
  15. package/dist/bvhBuilder.js.map +1 -0
  16. package/dist/bvhWorker.d.ts +2 -0
  17. package/dist/bvhWorker.d.ts.map +1 -0
  18. package/dist/bvhWorker.js +8 -0
  19. package/dist/bvhWorker.js.map +1 -0
  20. package/dist/camera.d.ts +29 -0
  21. package/dist/camera.d.ts.map +1 -0
  22. package/dist/camera.js +48 -0
  23. package/dist/camera.js.map +1 -0
  24. package/dist/canvas.d.ts +9 -0
  25. package/dist/canvas.d.ts.map +1 -0
  26. package/dist/canvas.js +11 -0
  27. package/dist/canvas.js.map +1 -0
  28. package/dist/denoise.d.ts +69 -0
  29. package/dist/denoise.d.ts.map +1 -0
  30. package/dist/denoise.js +455 -0
  31. package/dist/denoise.js.map +1 -0
  32. package/dist/device.d.ts +37 -0
  33. package/dist/device.d.ts.map +1 -0
  34. package/dist/device.js +52 -0
  35. package/dist/device.js.map +1 -0
  36. package/dist/dispatchScheduler.d.ts +43 -0
  37. package/dist/dispatchScheduler.d.ts.map +1 -0
  38. package/dist/dispatchScheduler.js +49 -0
  39. package/dist/dispatchScheduler.js.map +1 -0
  40. package/dist/environment.d.ts +39 -0
  41. package/dist/environment.d.ts.map +1 -0
  42. package/dist/environment.js +168 -0
  43. package/dist/environment.js.map +1 -0
  44. package/dist/frameLayout.d.ts +4 -0
  45. package/dist/frameLayout.d.ts.map +1 -0
  46. package/dist/frameLayout.js +58 -0
  47. package/dist/frameLayout.js.map +1 -0
  48. package/dist/geometry.d.ts +75 -0
  49. package/dist/geometry.d.ts.map +1 -0
  50. package/dist/geometry.js +799 -0
  51. package/dist/geometry.js.map +1 -0
  52. package/dist/index.d.ts +27 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +19 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/lights.d.ts +64 -0
  57. package/dist/lights.d.ts.map +1 -0
  58. package/dist/lights.js +264 -0
  59. package/dist/lights.js.map +1 -0
  60. package/dist/materialLayout.d.ts +143 -0
  61. package/dist/materialLayout.d.ts.map +1 -0
  62. package/dist/materialLayout.js +297 -0
  63. package/dist/materialLayout.js.map +1 -0
  64. package/dist/math.d.ts +10 -0
  65. package/dist/math.d.ts.map +1 -0
  66. package/dist/math.js +40 -0
  67. package/dist/math.js.map +1 -0
  68. package/dist/pathTracePass.d.ts +27 -0
  69. package/dist/pathTracePass.d.ts.map +1 -0
  70. package/dist/pathTracePass.js +83 -0
  71. package/dist/pathTracePass.js.map +1 -0
  72. package/dist/planarCapture.d.ts +38 -0
  73. package/dist/planarCapture.d.ts.map +1 -0
  74. package/dist/planarCapture.js +163 -0
  75. package/dist/planarCapture.js.map +1 -0
  76. package/dist/presentPass.d.ts +31 -0
  77. package/dist/presentPass.d.ts.map +1 -0
  78. package/dist/presentPass.js +78 -0
  79. package/dist/presentPass.js.map +1 -0
  80. package/dist/renderer.d.ts +209 -0
  81. package/dist/renderer.d.ts.map +1 -0
  82. package/dist/renderer.js +847 -0
  83. package/dist/renderer.js.map +1 -0
  84. package/dist/sceneAssets.d.ts +24 -0
  85. package/dist/sceneAssets.d.ts.map +1 -0
  86. package/dist/sceneAssets.js +77 -0
  87. package/dist/sceneAssets.js.map +1 -0
  88. package/dist/sceneUpload.d.ts +61 -0
  89. package/dist/sceneUpload.d.ts.map +1 -0
  90. package/dist/sceneUpload.js +98 -0
  91. package/dist/sceneUpload.js.map +1 -0
  92. package/dist/shaderModule.d.ts +7 -0
  93. package/dist/shaderModule.d.ts.map +1 -0
  94. package/dist/shaderModule.js +19 -0
  95. package/dist/shaderModule.js.map +1 -0
  96. package/dist/shaderProgram.d.ts +124 -0
  97. package/dist/shaderProgram.d.ts.map +1 -0
  98. package/dist/shaderProgram.js +1030 -0
  99. package/dist/shaderProgram.js.map +1 -0
  100. package/dist/structLayout.d.ts +28 -0
  101. package/dist/structLayout.d.ts.map +1 -0
  102. package/dist/structLayout.js +58 -0
  103. package/dist/structLayout.js.map +1 -0
  104. package/dist/texturePack.d.ts +54 -0
  105. package/dist/texturePack.d.ts.map +1 -0
  106. package/dist/texturePack.js +86 -0
  107. package/dist/texturePack.js.map +1 -0
  108. package/dist/upstream.d.ts +7 -0
  109. package/dist/upstream.d.ts.map +1 -0
  110. package/dist/upstream.js +11 -0
  111. package/dist/upstream.js.map +1 -0
  112. package/dist/volumetric.d.ts +10 -0
  113. package/dist/volumetric.d.ts.map +1 -0
  114. package/dist/volumetric.js +34 -0
  115. package/dist/volumetric.js.map +1 -0
  116. package/dist/wgsl/bsdf.wgsl.d.ts +13 -0
  117. package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -0
  118. package/dist/wgsl/bsdf.wgsl.js +619 -0
  119. package/dist/wgsl/bsdf.wgsl.js.map +1 -0
  120. package/dist/wgsl/bvh.wgsl.d.ts +3 -0
  121. package/dist/wgsl/bvh.wgsl.d.ts.map +1 -0
  122. package/dist/wgsl/bvh.wgsl.js +218 -0
  123. package/dist/wgsl/bvh.wgsl.js.map +1 -0
  124. package/dist/wgsl/common.wgsl.d.ts +3 -0
  125. package/dist/wgsl/common.wgsl.d.ts.map +1 -0
  126. package/dist/wgsl/common.wgsl.js +193 -0
  127. package/dist/wgsl/common.wgsl.js.map +1 -0
  128. package/dist/wgsl/environment.wgsl.d.ts +10 -0
  129. package/dist/wgsl/environment.wgsl.d.ts.map +1 -0
  130. package/dist/wgsl/environment.wgsl.js +145 -0
  131. package/dist/wgsl/environment.wgsl.js.map +1 -0
  132. package/dist/wgsl/lights.wgsl.d.ts +10 -0
  133. package/dist/wgsl/lights.wgsl.d.ts.map +1 -0
  134. package/dist/wgsl/lights.wgsl.js +434 -0
  135. package/dist/wgsl/lights.wgsl.js.map +1 -0
  136. package/dist/wgsl/pathTrace.wgsl.d.ts +14 -0
  137. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -0
  138. package/dist/wgsl/pathTrace.wgsl.js +1020 -0
  139. package/dist/wgsl/pathTrace.wgsl.js.map +1 -0
  140. package/dist/wgsl/present.wgsl.d.ts +7 -0
  141. package/dist/wgsl/present.wgsl.d.ts.map +1 -0
  142. package/dist/wgsl/present.wgsl.js +154 -0
  143. package/dist/wgsl/present.wgsl.js.map +1 -0
  144. package/dist/wgsl/random.wgsl.d.ts +9 -0
  145. package/dist/wgsl/random.wgsl.d.ts.map +1 -0
  146. package/dist/wgsl/random.wgsl.js +91 -0
  147. package/dist/wgsl/random.wgsl.js.map +1 -0
  148. package/dist/wgsl/sampling.wgsl.d.ts +3 -0
  149. package/dist/wgsl/sampling.wgsl.d.ts.map +1 -0
  150. package/dist/wgsl/sampling.wgsl.js +31 -0
  151. package/dist/wgsl/sampling.wgsl.js.map +1 -0
  152. package/dist/wgsl/shaderSupport.wgsl.d.ts +6 -0
  153. package/dist/wgsl/shaderSupport.wgsl.d.ts.map +1 -0
  154. package/dist/wgsl/shaderSupport.wgsl.js +120 -0
  155. package/dist/wgsl/shaderSupport.wgsl.js.map +1 -0
  156. package/dist/wgsl/surface.wgsl.d.ts +10 -0
  157. package/dist/wgsl/surface.wgsl.d.ts.map +1 -0
  158. package/dist/wgsl/surface.wgsl.js +794 -0
  159. package/dist/wgsl/surface.wgsl.js.map +1 -0
  160. package/dist/wgsl/texture.wgsl.d.ts +11 -0
  161. package/dist/wgsl/texture.wgsl.d.ts.map +1 -0
  162. package/dist/wgsl/texture.wgsl.js +132 -0
  163. package/dist/wgsl/texture.wgsl.js.map +1 -0
  164. package/package.json +77 -3
  165. package/src/accumulator.ts +64 -0
  166. package/src/bvh.ts +336 -0
  167. package/src/bvhBuilder.ts +178 -0
  168. package/src/bvhWorker.ts +13 -0
  169. package/src/camera.ts +77 -0
  170. package/src/canvas.ts +13 -0
  171. package/src/denoise.ts +514 -0
  172. package/src/device.ts +81 -0
  173. package/src/dispatchScheduler.ts +71 -0
  174. package/src/environment.ts +233 -0
  175. package/src/frameLayout.ts +59 -0
  176. package/src/geometry.ts +1067 -0
  177. package/src/index.ts +64 -0
  178. package/src/lights.ts +365 -0
  179. package/src/materialLayout.ts +408 -0
  180. package/src/math.ts +48 -0
  181. package/src/pathTracePass.ts +97 -0
  182. package/src/planarCapture.ts +225 -0
  183. package/src/presentPass.ts +99 -0
  184. package/src/renderer.ts +1145 -0
  185. package/src/sceneAssets.ts +118 -0
  186. package/src/sceneUpload.ts +185 -0
  187. package/src/shaderModule.ts +19 -0
  188. package/src/shaderProgram.ts +1370 -0
  189. package/src/structLayout.ts +93 -0
  190. package/src/texturePack.ts +152 -0
  191. package/src/upstream.ts +56 -0
  192. package/src/volumetric.ts +42 -0
  193. package/src/wgsl/bsdf.wgsl.ts +618 -0
  194. package/src/wgsl/bvh.wgsl.ts +217 -0
  195. package/src/wgsl/common.wgsl.ts +193 -0
  196. package/src/wgsl/environment.wgsl.ts +144 -0
  197. package/src/wgsl/lights.wgsl.ts +433 -0
  198. package/src/wgsl/pathTrace.wgsl.ts +1019 -0
  199. package/src/wgsl/present.wgsl.ts +153 -0
  200. package/src/wgsl/random.wgsl.ts +90 -0
  201. package/src/wgsl/sampling.wgsl.ts +30 -0
  202. package/src/wgsl/shaderSupport.wgsl.ts +119 -0
  203. package/src/wgsl/surface.wgsl.ts +793 -0
  204. package/src/wgsl/texture.wgsl.ts +131 -0
@@ -0,0 +1,1145 @@
1
+ import { PerspectiveCamera, type LightProbe, type Material, type Object3D, type Scene } from "three";
2
+ import { Accumulator } from "./accumulator.js";
3
+ import { type PackedBvh } from "./bvh.js";
4
+ import { BvhBuilder, createDefaultBvhWorker, type BvhWorkerFactory } from "./bvhBuilder.js";
5
+ import { cameraMatrices, type CameraMatrices } from "./camera.js";
6
+ import { requestRenderDevice, type RenderDevice } from "./device.js";
7
+ import { loadEnvironment, type PackedEnvironment } from "./environment.js";
8
+ import { DispatchScheduler } from "./dispatchScheduler.js";
9
+ import { packGeometry, type PackedGeometry } from "./geometry.js";
10
+ import { packLights } from "./lights.js";
11
+ import { NO_PROGRAM } from "./materialLayout.js";
12
+ import { NO_TEXTURE, planTextures, writeTextures, type PackedTextures } from "./texturePack.js";
13
+ import { PathTracePass, type FrameUniforms } from "./pathTracePass.js";
14
+ import { PresentPass } from "./presentPass.js";
15
+ import {
16
+ CONVERGED_ERROR,
17
+ DEFAULT_DENOISER,
18
+ DenoisePass,
19
+ mergeDenoiser,
20
+ type DenoiserSettings,
21
+ type DenoiseView,
22
+ } from "./denoise.js";
23
+ import { CaptureResolvePass, planarCaptures, type PlanarCaptures } from "./planarCapture.js";
24
+ import { prepareSceneAssets, type SceneAssets } from "./sceneAssets.js";
25
+ import { packVolumetricLights, VOLUMETRIC_WORDS } from "./volumetric.js";
26
+ import { environmentPower, uploadScene, type SceneBuffers } from "./sceneUpload.js";
27
+ import {
28
+ DEFAULT_RENDER_ENVIRONMENT,
29
+ DEFAULT_RENDER_SETTINGS,
30
+ encodeColor,
31
+ EventEmitter,
32
+ mergeSettings,
33
+ serializeCamera,
34
+ serializeScene,
35
+ srgbToLinear,
36
+ type Bucket,
37
+ type CameraState,
38
+ type RenderCamera,
39
+ type RenderEnvironment,
40
+ type RenderBackgroundMode,
41
+ type RenderImage,
42
+ type RenderPhase,
43
+ type RenderPixels,
44
+ type RenderScene,
45
+ type RenderSettings,
46
+ type RenderSettingsInput,
47
+ type SerializedScene,
48
+ } from "./upstream.js";
49
+
50
+ export type { RenderCamera, RenderImage, RenderPhase, RenderPixels, RenderScene } from "./upstream.js";
51
+
52
+ /**
53
+ * The CPU renderer's events, with the same payloads, so a host listens to either renderer the same way. What differs
54
+ * is when they fire: nothing is finished until the last sample lands, so the pixels arrive once.
55
+ */
56
+ export interface GpuRendererEvents {
57
+ /** The scene is on the device and tracing starts; `image` has the render size and is empty. */
58
+ start: { width: number; height: number };
59
+ /** The finished image, once, as a single region at (0, 0). `image` already holds it. */
60
+ pixels: RenderPixels;
61
+ /** Rows traced out of `total`, over every sample. `"prepare"` while the scene uploads, `"shade"` while it traces. */
62
+ progress: { completed: number; total: number; phase: RenderPhase };
63
+ /** The band of rows being traced, as a bucket of worker 0, or `null` when the device is idle. */
64
+ bucket: { worker: number; bucket: Bucket | null; width: number; height: number };
65
+ /** One more sample per pixel finished over the whole image. Only this renderer has it. */
66
+ sample: { samples: number; target: number };
67
+ /** `image` is finished. */
68
+ complete: void;
69
+ /** The frame failed; `render()` rejects with the same error. */
70
+ error: Error;
71
+ }
72
+
73
+ /** The CPU renderer's options without its worker pool, which has no counterpart on a device. */
74
+ export interface GpuRendererOptions {
75
+ scene?: RenderScene | SerializedScene;
76
+ /** Defaults to a perspective camera at (5, 5, 5) looking at the origin. */
77
+ camera?: RenderCamera;
78
+ /** Merged over `DEFAULT_RENDER_SETTINGS`, group by group. */
79
+ settings?: RenderSettingsInput;
80
+ /** Defaults to `DEFAULT_RENDER_ENVIRONMENT`. */
81
+ environment?: RenderEnvironment;
82
+ /** Image size in CSS pixels; the render size also applies `pixelRatio` and `settings.renderScale`. */
83
+ width: number;
84
+ height: number;
85
+ /** Defaults to 1. */
86
+ pixelRatio?: number;
87
+ /** How the denoiser filters, when `settings.globalIllumination.denoise` turns it on. Merged over `DEFAULT_DENOISER`. */
88
+ denoiser?: Partial<DenoiserSettings>;
89
+ /**
90
+ * Builds the BVH of a large scene in a Web Worker, so the page keeps responding while it is split. The tree is the
91
+ * same either way. Defaults to true where Web Workers exist, or when `createBvhWorker` is given; false builds on the
92
+ * main thread. If the worker cannot start, the renderer warns once and builds on the main thread.
93
+ */
94
+ bvhWorker?: boolean;
95
+ /**
96
+ * Starts the BVH worker. Replace it when the bundler cannot follow `new URL("./bvhWorker.js", import.meta.url)`, or
97
+ * outside the browser, with anything shaped like a Web Worker that runs `dist/bvhWorker.js`.
98
+ */
99
+ createBvhWorker?: BvhWorkerFactory;
100
+ }
101
+
102
+ interface PendingFrame {
103
+ resolve: (image: RenderImage) => void;
104
+ reject: (reason: Error) => void;
105
+ }
106
+
107
+ function abortError(message: string): Error {
108
+ return Object.assign(new Error(message), { name: "AbortError" });
109
+ }
110
+
111
+ /** `render()` rejects with this when a newer frame, `cancel()` or `dispose()` replaced the frame it started. */
112
+ export const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === "AbortError";
113
+
114
+ /** `#rrggbb` to linear rgb, the space the renderer works in. */
115
+ function linearColor(color: string | undefined, fallback: readonly number[]): number[] {
116
+ const match = /^#?([0-9a-f]{6})$/i.exec(color ?? "");
117
+
118
+ if (!match) return [...fallback];
119
+
120
+ const value = parseInt(match[1], 16);
121
+
122
+ return [(value >> 16) & 255, (value >> 8) & 255, value & 255].map(channel => srgbToLinear(channel / 255));
123
+ }
124
+
125
+ /** Hands the thread back to the page for a moment, so input and painting go on between submits. */
126
+ const yieldToPage = () => new Promise<void>(resolve => setTimeout(resolve, 0));
127
+
128
+ /**
129
+ * How often the canvas is drawn and the page gets the thread back while a frame traces: about once a display frame.
130
+ * Waiting for an animation frame after every band would leave the device idle for most of each one.
131
+ */
132
+ const SHOW_EVERY_MS = 16;
133
+
134
+ const EMPTY_SCENE: SerializedScene = { meshes: [], lights: [], textures: [] };
135
+
136
+ /** Paths every pixel takes before adaptive sampling may stop it: below this its variance says too little. */
137
+ const ADAPTIVE_MIN = 32;
138
+ /** How many times the target a pixel that has not converged may take, when converged pixels leave the work to it. */
139
+ const ADAPTIVE_REACH = 4;
140
+ /**
141
+ * The error under which a pixel stops: its luminance's standard error over the square root of its luminance, about
142
+ * half a step of an 8 bit sRGB image in the mid tones.
143
+ */
144
+ const ADAPTIVE_THRESHOLD = CONVERGED_ERROR;
145
+
146
+ /** Contiguous runs of the given sorted rows, as [first, end) pairs. */
147
+ function rowRanges(rows: readonly number[]): [number, number][] {
148
+ const ranges: [number, number][] = [];
149
+
150
+ for (const row of rows) {
151
+ const last = ranges[ranges.length - 1];
152
+
153
+ if (last && last[1] === row) last[1] = row + 1;
154
+ else ranges.push([row, row + 1]);
155
+ }
156
+
157
+ return ranges;
158
+ }
159
+
160
+ /** How many paths per pixel a planar capture gets. It is a texture a shader distorts, not the image itself. */
161
+ const CAPTURE_SAMPLES = 32;
162
+
163
+ /** A scene on the device, with where its extra words went. */
164
+ interface PackedTarget {
165
+ packed: { geometry: PackedGeometry; bvh: PackedBvh; textures: PackedTextures };
166
+ buffers: SceneBuffers;
167
+ skyData: number;
168
+ volumetricData: number;
169
+ volumetricCount: number;
170
+ }
171
+
172
+ /**
173
+ * Path traces a Three.js scene on the GPU, behind the same API as `CpuRenderer`. The setters record the inputs;
174
+ * `render()` opens a frame with whatever is current and accumulates samples until it reaches `settings.samples`.
175
+ */
176
+ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
177
+ private current: RenderSettings;
178
+ private environment: RenderEnvironment;
179
+ // Serializing walks the whole scene, so it is started by the setter and awaited by the frame that needs it.
180
+ private scene: Promise<SerializedScene> = Promise.resolve(EMPTY_SCENE);
181
+ // A LightProbe's harmonics, which the snapshot does not carry: read from the live scene when there is one.
182
+ private probe: number[] = new Array(27).fill(0);
183
+ // A Three camera is kept by reference and read at every `render()`, as the CPU renderer does; a snapshot stays fixed.
184
+ private camera: RenderCamera | null = null;
185
+ private width: number;
186
+ private height: number;
187
+ private pixelRatio: number;
188
+ private output: RenderImage | null = null;
189
+ private frame: PendingFrame | null = null;
190
+ private isPaused = false;
191
+ private resumed: (() => void) | null = null;
192
+ private unpaused: Promise<void> = Promise.resolve();
193
+
194
+ private ready: Promise<RenderDevice> | null = null;
195
+ private gpu: RenderDevice | null = null;
196
+ private accumulator: Accumulator | null = null;
197
+ private pathTrace: PathTracePass | null = null;
198
+ private present: PresentPass | null = null;
199
+ private buffers: SceneBuffers | null = null;
200
+ private packed: { geometry: PackedGeometry; bvh: PackedBvh; textures: PackedTextures } | null = null;
201
+ private sceneDirty = true;
202
+ // Compute bakes, the procedural sky and translated shaders: prepared once per scene.
203
+ private assets: Promise<SceneAssets> | null = null;
204
+ // What the packed triangles were built for, when points, lines, sprites or vertex shaders tie them to the camera.
205
+ private packedView = "";
206
+ // Where the packed scene's own extra words went: the sky's uniforms and the volumetric lights.
207
+ private target: PackedTarget | null = null;
208
+ // Whether the scene has planar captures, which are taken from the camera and so follow it.
209
+ private planar = false;
210
+ private resolve: CaptureResolvePass | null = null;
211
+ private denoisePass: DenoisePass | null = null;
212
+ private denoiserSettings: DenoiserSettings = { ...DEFAULT_DENOISER };
213
+ // The size of the last frame that finished, which setDenoiser filters again; null while a frame traces.
214
+ private finished: { width: number; height: number } | null = null;
215
+ // Whether the denoiser's output holds this frame yet: until it does, the canvas shows the accumulation itself.
216
+ private denoised = false;
217
+ // How large a pixel is for the camera of the frame, which the specular filter sizes its footprint by.
218
+ private denoiseView: DenoiseView = { pixelScale: 1, orthographic: false };
219
+ // Reading an environment image is a fetch, so it is started once and awaited by the frame that needs it.
220
+ private environmentSource = "";
221
+ private environmentImage: Promise<PackedEnvironment | null> = Promise.resolve(null);
222
+ private canvas: HTMLCanvasElement | null = null;
223
+ private context: GPUCanvasContext | null = null;
224
+ private scheduler: DispatchScheduler;
225
+ private revision = 0;
226
+ private frameIndex = 0;
227
+ private disposed = false;
228
+ private bvh: BvhBuilder;
229
+
230
+ constructor(options: GpuRendererOptions) {
231
+ super();
232
+ this.bvh = new BvhBuilder(
233
+ options.bvhWorker ?? (options.createBvhWorker !== undefined || typeof Worker !== "undefined"),
234
+ options.createBvhWorker ?? createDefaultBvhWorker);
235
+ this.width = Math.max(1, Math.floor(options.width));
236
+ this.height = Math.max(1, Math.floor(options.height));
237
+ this.pixelRatio = checkedPixelRatio(options.pixelRatio ?? 1);
238
+ this.current = mergeSettings(DEFAULT_RENDER_SETTINGS, options.settings);
239
+ this.denoiserSettings = mergeDenoiser(DEFAULT_DENOISER, options.denoiser);
240
+ this.environment = DEFAULT_RENDER_ENVIRONMENT;
241
+ this.scheduler = new DispatchScheduler({ maxRows: this.renderHeight });
242
+ this.setEnvironment(options.environment ?? DEFAULT_RENDER_ENVIRONMENT);
243
+
244
+ if (options.scene) this.setScene(options.scene);
245
+ if (options.camera) this.setCamera(options.camera);
246
+ }
247
+
248
+ /** The settings the next frame renders with. */
249
+ get settings(): Readonly<RenderSettings> {
250
+ return this.current;
251
+ }
252
+
253
+ /** How the denoiser filters. */
254
+ get denoiser(): Readonly<DenoiserSettings> {
255
+ return this.denoiserSettings;
256
+ }
257
+
258
+ /**
259
+ * Merges the given fields into the denoiser's settings. Filtering costs nothing like tracing, so a finished image is
260
+ * filtered again at once, without tracing anything: `image` and the canvas take the result and `pixels` fires. A frame
261
+ * in flight uses them from its next pass on.
262
+ */
263
+ setDenoiser(input: Partial<DenoiserSettings>) {
264
+ this.denoiserSettings = mergeDenoiser(this.denoiserSettings, input);
265
+ this.refilter();
266
+ }
267
+
268
+ /** Filters the finished image again with the current denoiser settings, or shows it unfiltered when it is off. */
269
+ private refilter() {
270
+ const finished = this.finished, gpu = this.gpu;
271
+
272
+ if (!finished || !gpu || !this.accumulator || this.rendering) return;
273
+
274
+ const revision = this.revision;
275
+ const denoise = this.current.globalIllumination.denoise;
276
+
277
+ void (async() => {
278
+ const encoder = gpu.device.createCommandEncoder();
279
+
280
+ if (denoise) this.encodeDenoise(gpu, encoder, finished.width, finished.height);
281
+ else this.denoised = false;
282
+
283
+ this.encodePresent(encoder, finished.width, finished.height);
284
+ gpu.device.queue.submit([encoder.finish()]);
285
+
286
+ const image = await this.readImage(gpu, finished.width, finished.height,
287
+ denoise ? this.denoisePass?.output ?? null : null);
288
+
289
+ // A frame started meanwhile owns the image now.
290
+ if (revision !== this.revision || this.rendering) return;
291
+
292
+ this.output = image;
293
+ this.emit("pixels", { x: 0, y: 0, ...image });
294
+ })().catch((error: unknown) => this.emit("error", error instanceof Error ? error : new Error(String(error))));
295
+ }
296
+
297
+ /** A frame is in flight: `render()` was called and has not settled. */
298
+ get rendering(): boolean {
299
+ return this.frame !== null;
300
+ }
301
+
302
+ get paused(): boolean {
303
+ return this.isPaused;
304
+ }
305
+
306
+ /**
307
+ * The image of the last finished frame, or an empty one of the render size while a frame traces; `null` before the
308
+ * first frame starts. Each frame allocates a new buffer, so a reference taken at `complete` stays as it is.
309
+ */
310
+ get image(): RenderImage | null {
311
+ return this.output;
312
+ }
313
+
314
+ get renderWidth(): number {
315
+ return Math.max(1, Math.floor(this.width * this.pixelRatio * this.current.renderScale));
316
+ }
317
+
318
+ get renderHeight(): number {
319
+ return Math.max(1, Math.floor(this.height * this.pixelRatio * this.current.renderScale));
320
+ }
321
+
322
+ /** Image size in CSS pixels for the next frame; `pixelRatio` stays as it is when omitted. */
323
+ setSize(width: number, height: number, pixelRatio = this.pixelRatio) {
324
+ this.width = Math.max(1, Math.floor(width));
325
+ this.height = Math.max(1, Math.floor(height));
326
+ this.pixelRatio = checkedPixelRatio(pixelRatio);
327
+ }
328
+
329
+ setPixelRatio(pixelRatio: number) {
330
+ this.pixelRatio = checkedPixelRatio(pixelRatio);
331
+ }
332
+
333
+ /**
334
+ * Takes a Three scene, serialized in the background, or the result of `serializeScene`. The live scene is not read
335
+ * again until this is called once more.
336
+ */
337
+ setScene(scene: RenderScene | SerializedScene) {
338
+ this.probe = "isScene" in scene && scene.isScene ? lightProbes(scene as Scene) : new Array(27).fill(0);
339
+ this.scene = "isScene" in scene && scene.isScene
340
+ ? serializeLive(scene as Scene)
341
+ : Promise.resolve("meshes" in scene ? scene : (scene as { scene: SerializedScene }).scene);
342
+ this.sceneDirty = true;
343
+ this.assets = null;
344
+ }
345
+
346
+ /**
347
+ * A Three camera is read at every `render()`, so moving it or changing its aspect only needs a new frame.
348
+ * A `CameraState` snapshot is used as it is.
349
+ */
350
+ setCamera(camera: RenderCamera) {
351
+ this.camera = camera;
352
+ }
353
+
354
+ /**
355
+ * Merges the given fields into the current settings, group by group. Turning the denoiser on or off also filters the
356
+ * finished image again at once, as `setDenoiser` does, since that needs no tracing.
357
+ */
358
+ setSettings(settings: RenderSettingsInput) {
359
+ const denoise = this.current.globalIllumination.denoise;
360
+
361
+ this.current = mergeSettings(this.current, settings);
362
+
363
+ if (this.current.globalIllumination.denoise !== denoise) this.refilter();
364
+ }
365
+
366
+ setEnvironment(environment: RenderEnvironment) {
367
+ this.environment = environment;
368
+
369
+ // Only the image itself has to be read again; its strength and rotation are uniforms.
370
+ const source = environment.kind === "hdri" ? environment.url : "";
371
+
372
+ if (source === this.environmentSource) return;
373
+
374
+ this.environmentSource = source;
375
+ this.environmentImage = loadEnvironment(environment).catch(() => null);
376
+ // The image shares a buffer with the scene's own textures, so the upload has to happen again.
377
+ this.sceneDirty = true;
378
+ }
379
+
380
+ /** Used by `attachCanvas`; pass `null` to detach. */
381
+ setOutputCanvas(canvas: HTMLCanvasElement | null) {
382
+ this.canvas = canvas;
383
+ this.context = null;
384
+ }
385
+
386
+ /**
387
+ * Starts a frame with the current scene, camera, settings, environment and size, discarding any frame in
388
+ * progress, and accumulates `settings.samples` samples per pixel. Resolves with the finished image. Rejects with an
389
+ * `AbortError` when a later `render()`, `cancel()` or `dispose()` replaces the frame, and with the error when the
390
+ * frame fails.
391
+ */
392
+ render(): Promise<RenderImage> {
393
+ if (this.disposed) return Promise.reject(new Error("GPU renderer: this renderer was disposed."));
394
+
395
+ // The camera as it is at this call, even if it moves while the scene is still serializing.
396
+ const camera = !this.camera ? defaultCamera()
397
+ : "isCamera" in this.camera ? serializeCamera(this.camera) : this.camera;
398
+
399
+ this.abort("A newer render() replaced this frame.");
400
+
401
+ const revision = ++this.revision;
402
+
403
+ const settled = new Promise<RenderImage>((resolve, reject) => {
404
+ this.frame = { resolve, reject };
405
+ });
406
+
407
+ this.accumulate(revision, camera).then(
408
+ image => {
409
+ if (revision !== this.revision) return;
410
+
411
+ this.emit("complete");
412
+ this.settle()?.resolve(image);
413
+ },
414
+ (error: unknown) => {
415
+ // A replaced frame was already rejected by whatever replaced it.
416
+ if (revision !== this.revision || isAbortError(error)) return;
417
+
418
+ this.fail(error instanceof Error ? error : new Error(String(error)));
419
+ });
420
+
421
+ return settled;
422
+ }
423
+
424
+ /** Stops between two dispatches; `resume()` continues the same frame. */
425
+ pause() {
426
+ if (this.isPaused) return;
427
+
428
+ this.isPaused = true;
429
+ this.unpaused = new Promise(resolve => { this.resumed = resolve; });
430
+ }
431
+
432
+ resume() {
433
+ if (!this.isPaused) return;
434
+
435
+ this.isPaused = false;
436
+ this.resumed?.();
437
+ this.resumed = null;
438
+ }
439
+
440
+ /**
441
+ * Stops the frame in flight and keeps the device for the next one. `render()` rejects with an `AbortError` and
442
+ * `image` keeps what it held. A paused frame is cancelled too, and the next one starts unpaused. Does nothing
443
+ * without a frame in flight.
444
+ */
445
+ cancel() {
446
+ if (this.disposed || !this.frame) return;
447
+
448
+ this.revision++;
449
+ this.resume();
450
+ this.emitIdle();
451
+ this.abort("The frame was cancelled.");
452
+ }
453
+
454
+ /** Releases the device. A frame in flight rejects with an `AbortError`. */
455
+ dispose() {
456
+ if (this.disposed) return;
457
+
458
+ this.disposed = true;
459
+ this.revision++;
460
+ this.resume();
461
+ this.abort("The renderer was disposed.");
462
+ this.bvh.dispose();
463
+ this.buffers?.destroy();
464
+ this.accumulator?.destroy();
465
+ this.denoisePass?.destroy();
466
+ this.gpu?.device.destroy();
467
+ this.buffers = null;
468
+ this.accumulator = null;
469
+ this.gpu = null;
470
+ this.ready = null;
471
+ }
472
+
473
+ /** Takes the frame in flight, if any, so it can be settled after the events go out. */
474
+ private settle(): PendingFrame | null {
475
+ const frame = this.frame;
476
+
477
+ this.frame = null;
478
+
479
+ return frame;
480
+ }
481
+
482
+ /** Settles the frame in flight, if any, with an `AbortError`. */
483
+ private abort(reason: string) {
484
+ this.settle()?.reject(abortError(reason));
485
+ }
486
+
487
+ private fail(error: Error) {
488
+ const frame = this.settle();
489
+
490
+ this.emitIdle();
491
+ this.emit("error", error);
492
+ frame?.reject(error);
493
+ }
494
+
495
+ private emitIdle() {
496
+ this.emit("bucket", { worker: 0, bucket: null, width: this.output?.width ?? 0, height: this.output?.height ?? 0 });
497
+ }
498
+
499
+ private async device(): Promise<RenderDevice> {
500
+ this.ready ??= requestRenderDevice({
501
+ onLost: reason => {
502
+ if (!this.disposed) this.fail(new Error(`GPU renderer: the device was lost (${reason}).`));
503
+ },
504
+ });
505
+ this.gpu = await this.ready;
506
+
507
+ return this.gpu;
508
+ }
509
+
510
+ private async accumulate(revision: number, camera: CameraState): Promise<RenderImage> {
511
+ this.emit("progress", { completed: 0, total: 1, phase: "prepare" });
512
+
513
+ const gpu = await this.device();
514
+ const scene = await this.scene;
515
+ const environment = await this.environmentImage;
516
+ const width = this.renderWidth, height = this.renderHeight;
517
+
518
+ this.check(revision);
519
+ this.assets ??= prepareSceneAssets(scene);
520
+
521
+ const assets = await this.assets;
522
+
523
+ this.check(revision);
524
+ await this.prepare(gpu, revision, assets, camera, width, height, environment);
525
+ this.output = { width, height, data: new Uint8ClampedArray(width * height * 4) };
526
+ this.emit("start", { width, height });
527
+
528
+ const target = Math.max(1, Math.round(this.current.samples));
529
+ // Adaptive sampling: the frame has the work of `target` passes over the whole image to spend. Pixels that have
530
+ // converged stop taking samples, and a row with none left stops being dispatched, so what they would have used goes
531
+ // to the rows that still need it, up to a few times `target` paths in the noisiest pixels.
532
+ const adaptive = target > ADAPTIVE_MIN;
533
+ const maxPasses = adaptive ? target * ADAPTIVE_REACH : target;
534
+ const budget = target * height;
535
+ const accumulator = this.accumulator as Accumulator;
536
+ let active: number[] = Array.from({ length: height }, (_, row) => row);
537
+ let spent = 0, band = 0, pass = 0;
538
+ let shown = performance.now();
539
+ const denoise = this.current.globalIllumination.denoise;
540
+
541
+ this.denoised = false;
542
+ this.finished = null;
543
+
544
+ const matrices = cameraMatrices(camera, width, height);
545
+
546
+ // Two over the projection's vertical scale and the height: one pixel's extent, per unit of depth in perspective.
547
+ this.denoiseView = { pixelScale: 2 / (matrices.projection[5] * height), orthographic: matrices.orthographic };
548
+
549
+ while (pass < maxPasses && spent < budget && active.length) {
550
+ // Only a pass whose pixels may stop needs its rows flagged; before that every row is busy anyway.
551
+ const flagging = adaptive && pass >= ADAPTIVE_MIN;
552
+
553
+ if (flagging) {
554
+ const clear = gpu.device.createCommandEncoder();
555
+
556
+ clear.clearBuffer(accumulator.buffer, accumulator.rowFlagsOffset, accumulator.rowFlagsLength);
557
+ gpu.device.queue.submit([clear.finish()]);
558
+ }
559
+
560
+ for (const [first, end] of rowRanges(active)) {
561
+ let row = first;
562
+
563
+ while (row < end) {
564
+ if (this.isPaused) await this.unpaused;
565
+
566
+ this.check(revision);
567
+
568
+ const rows = this.scheduler.next(end - row);
569
+ const started = performance.now();
570
+ const encoder = gpu.device.createCommandEncoder();
571
+
572
+ this.emit("bucket", { worker: 0, bucket: { x: 0, y: row, width, height: rows, index: band++ }, width, height });
573
+ this.pathTrace?.encode(encoder, {
574
+ ...this.frameUniforms(camera, width, height, assets),
575
+ bandOrigin: [0, row],
576
+ bandSize: [width, rows],
577
+ sampleIndex: pass,
578
+ sampleCount: 1,
579
+ adaptiveMin: adaptive ? ADAPTIVE_MIN : 0,
580
+ adaptiveThreshold: ADAPTIVE_THRESHOLD,
581
+ });
582
+ // The canvas is drawn about once a display frame; in between the device only traces.
583
+ const show = this.canvas !== null && started - shown >= SHOW_EVERY_MS;
584
+
585
+ if (show) this.encodePresent(encoder, width, height);
586
+
587
+ gpu.device.queue.submit([encoder.finish()]);
588
+ await gpu.device.queue.onSubmittedWorkDone();
589
+
590
+ this.scheduler.record(performance.now() - started, rows);
591
+ row += rows;
592
+ spent += rows;
593
+ this.emit("progress", { completed: Math.min(spent, budget), total: budget, phase: "shade" });
594
+
595
+ if (show) {
596
+ shown = performance.now();
597
+ await yieldToPage();
598
+ }
599
+ }
600
+ }
601
+
602
+ pass++;
603
+ this.frameIndex++;
604
+
605
+ // The canvas shows the denoised image from the end of each pass on, and the bands of the next pass refine
606
+ // underneath it until it is denoised again.
607
+ if (denoise && this.canvas && this.context) {
608
+ const encoder = gpu.device.createCommandEncoder();
609
+
610
+ this.encodeDenoise(gpu, encoder, width, height);
611
+ this.encodePresent(encoder, width, height);
612
+ gpu.device.queue.submit([encoder.finish()]);
613
+ }
614
+
615
+ this.emit("sample", { samples: Math.min(target, Math.round(spent / height)), target });
616
+
617
+ if (flagging) active = await this.activeRows(gpu, accumulator);
618
+ }
619
+
620
+ // Converged early, or spent: either way the image is as finished as this frame makes it.
621
+ this.emit("progress", { completed: budget, total: budget, phase: "shade" });
622
+ this.emit("sample", { samples: target, target });
623
+
624
+ this.check(revision);
625
+
626
+ if (denoise) {
627
+ const encoder = gpu.device.createCommandEncoder();
628
+
629
+ this.encodeDenoise(gpu, encoder, width, height);
630
+ this.encodePresent(encoder, width, height);
631
+ gpu.device.queue.submit([encoder.finish()]);
632
+ }
633
+
634
+ const image = await this.readImage(gpu, width, height, denoise ? this.denoisePass?.output ?? null : null);
635
+
636
+ this.check(revision);
637
+ this.output = image;
638
+ this.finished = { width, height };
639
+ this.emitIdle();
640
+ this.emit("pixels", { x: 0, y: 0, ...image });
641
+
642
+ return image;
643
+ }
644
+
645
+ /** The rows a pass still traced something in, read back from the accumulator's row flags. */
646
+ private async activeRows(gpu: RenderDevice, accumulator: Accumulator): Promise<number[]> {
647
+ const staging = gpu.device.createBuffer({
648
+ size: accumulator.rowFlagsLength,
649
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
650
+ });
651
+
652
+ const encoder = gpu.device.createCommandEncoder();
653
+
654
+ encoder.copyBufferToBuffer(accumulator.buffer, accumulator.rowFlagsOffset, staging, 0, accumulator.rowFlagsLength);
655
+ gpu.device.queue.submit([encoder.finish()]);
656
+ await staging.mapAsync(GPUMapMode.READ);
657
+
658
+ const flags = new Float32Array(staging.getMappedRange().slice(0));
659
+
660
+ staging.unmap();
661
+ staging.destroy();
662
+
663
+ const rows: number[] = [];
664
+
665
+ for (let row = 0; row < flags.length / 4; row++) if (flags[row * 4] > 0) rows.push(row);
666
+
667
+ return rows;
668
+ }
669
+
670
+ private check(revision: number) {
671
+ if (revision !== this.revision || this.disposed) throw abortError("A newer render() replaced this frame.");
672
+ }
673
+
674
+ /** Uploads what changed and makes sure the passes point at the current buffers. */
675
+ private async prepare(
676
+ gpu: RenderDevice,
677
+ revision: number,
678
+ assets: SceneAssets,
679
+ camera: CameraState,
680
+ width: number,
681
+ height: number,
682
+ image: PackedEnvironment | null) {
683
+ this.pathTrace ??= new PathTracePass(gpu.device);
684
+ this.pathTrace.setPrograms(assets.programs);
685
+
686
+ // A procedural sky stands in for the renderer's own environment, as it does in the CPU renderer.
687
+ const environment = assets.sky?.environment ?? image;
688
+ const matrices = cameraMatrices(camera, width, height);
689
+ const view = `${JSON.stringify(camera)}|${width}|${height}`;
690
+ const moved = (!!this.packed?.geometry.cameraDependent || this.planar) && view !== this.packedView;
691
+
692
+ if (this.sceneDirty || !this.buffers || moved) {
693
+ // Planar surfaces need their captures, from this camera, before the scene that samples them is packed.
694
+ const planar = planarCaptures(assets.scene, camera, width, height);
695
+
696
+ const packed = await this.packScene(
697
+ gpu, revision, planar?.scene ?? assets.scene, assets, matrices, width, height, environment);
698
+
699
+ this.packed = packed.packed;
700
+ this.buffers?.destroy();
701
+ this.buffers = packed.buffers;
702
+ this.target = packed;
703
+ this.sceneDirty = false;
704
+ this.planar = !!planar;
705
+
706
+ if (planar) await this.renderCaptures(gpu, revision, planar, assets, environment);
707
+
708
+ // Only once the captures are in: a frame replaced while they were taken has to take them again.
709
+ this.packedView = view;
710
+ }
711
+
712
+ const resized = !this.accumulator || this.accumulator.resize(width, height);
713
+
714
+ this.accumulator ??= new Accumulator(gpu.device, width, height);
715
+
716
+ if (resized) this.scheduler = new DispatchScheduler({ maxRows: height });
717
+
718
+ this.pathTrace.setBindings(this.buffers, this.accumulator.buffer);
719
+ this.configureCanvas(gpu, width, height);
720
+
721
+ // Every frame starts from nothing: the camera, the scene or the settings may have moved since the last one. The
722
+ // canvas is drawn from the cleared accumulation at once, so the last image does not linger until the first band of
723
+ // the new one lands: every pixel goes see-through, and the new samples arrive over whatever the page shows below.
724
+ const encoder = gpu.device.createCommandEncoder();
725
+
726
+ this.denoised = false;
727
+ this.accumulator.clear(encoder);
728
+ this.encodePresent(encoder, width, height);
729
+ gpu.device.queue.submit([encoder.finish()]);
730
+ }
731
+
732
+ /** Packs and uploads one scene: its textures, geometry, hierarchy, lights and every extra word they point at. */
733
+ private async packScene(
734
+ gpu: RenderDevice,
735
+ revision: number,
736
+ scene: SerializedScene,
737
+ assets: SceneAssets,
738
+ matrices: CameraMatrices,
739
+ width: number,
740
+ height: number,
741
+ environment: PackedEnvironment | null): Promise<PackedTarget> {
742
+ // The textures are planned first: a material refers to one by the index it lands on in that table, and to its
743
+ // extra words by where they land after the texels. The environment rides along in the same buffer.
744
+ const plan = planTextures(scene, environment ? [environment.texture] : []);
745
+ const extra: number[] = [];
746
+
747
+ const append = (words: ArrayLike<number>) => {
748
+ const at = plan.extraOffset + extra.length;
749
+
750
+ for (let i = 0; i < words.length; i++) extra.push(words[i]);
751
+
752
+ return at;
753
+ };
754
+
755
+ const cdf = environment
756
+ ? append(new Uint32Array(environment.distribution.buffer, environment.distribution.byteOffset,
757
+ environment.distribution.length))
758
+ : plan.extraOffset;
759
+
760
+ const textureData = new Map(scene.textures.map(texture => [texture.id, texture]));
761
+
762
+ const geometry = packGeometry(scene, {
763
+ textures: plan.indexOf,
764
+ textureData,
765
+ camera: matrices,
766
+ width,
767
+ height,
768
+ programs: assets.programs,
769
+ extraBase: plan.extraOffset + extra.length,
770
+ fog: scene.fog,
771
+ });
772
+
773
+ append(geometry.extraWords);
774
+
775
+ const lights = packLights(geometry, scene.lights, { power: environmentPower(environment) },
776
+ { textures: plan.indexOf, extraBase: plan.extraOffset + extra.length });
777
+
778
+ append(lights.extraWords);
779
+
780
+ const foreground = assets.programs.foreground;
781
+
782
+ const skyData = foreground && assets.sky
783
+ ? append(foreground.packUniforms({ ...assets.sky.uniforms, uCpuSky: { texture: "procedural-sky" } }, plan.indexOf))
784
+ : 0;
785
+
786
+ const volumetric = packVolumetricLights(scene.lights);
787
+ const volumetricData = append(new Uint32Array(volumetric.buffer));
788
+ const textures = writeTextures(plan, new Uint32Array(extra));
789
+ const bvh = await this.bvh.build(geometry.positions, geometry.triangleCount);
790
+
791
+ // Nothing is on the device yet, so a frame replaced while the hierarchy was built has nothing to give back.
792
+ this.check(revision);
793
+
794
+ const packed = { geometry, bvh, textures };
795
+ const buffers = uploadScene(gpu.device, geometry, packed.bvh, textures, lights, environment, cdf);
796
+
797
+ if (buffers.bytes.largest > gpu.maxStorageBufferBindingSize) {
798
+ buffers.destroy();
799
+ throw new Error("GPU renderer: this scene needs a larger storage buffer than the device allows.");
800
+ }
801
+
802
+ return {
803
+ packed,
804
+ buffers,
805
+ skyData,
806
+ volumetricData,
807
+ volumetricCount: Math.min(volumetric.length / VOLUMETRIC_WORDS, 8),
808
+ };
809
+ }
810
+
811
+ /**
812
+ * Renders a scene's planar captures with this renderer, from the mirrored and the real camera, and writes each into
813
+ * the texels of its placeholder in the main scene's texture buffer. Everything is queued ahead of the frame itself,
814
+ * which the queue then runs in order.
815
+ */
816
+ private async renderCaptures(gpu: RenderDevice, revision: number, planar: PlanarCaptures, assets: SceneAssets,
817
+ environment: PackedEnvironment | null) {
818
+ const main = this.target as PackedTarget;
819
+ const pathTrace = this.pathTrace as PathTracePass;
820
+ const { width, height } = planar;
821
+ const accumulator = new Accumulator(gpu.device, width, height);
822
+ const empty: SerializedScene = { ...planar.captureScene, meshes: [] };
823
+ const scenes = new Map<string, PackedTarget>();
824
+
825
+ this.resolve ??= new CaptureResolvePass(gpu.device);
826
+
827
+ try {
828
+ for (const capture of planar.captures) {
829
+ let target = scenes.get(capture.scene);
830
+
831
+ if (!target) {
832
+ target = await this.packScene(gpu, revision, capture.scene === "scene" ? planar.captureScene : empty, assets,
833
+ cameraMatrices(capture.camera, width, height), width, height, environment);
834
+ scenes.set(capture.scene, target);
835
+ }
836
+
837
+ const clear = gpu.device.createCommandEncoder();
838
+
839
+ accumulator.clear(clear);
840
+ gpu.device.queue.submit([clear.finish()]);
841
+ pathTrace.setBindings(target.buffers, accumulator.buffer);
842
+
843
+ const uniforms = {
844
+ ...this.frameUniforms(capture.camera, width, height, assets, target, capture.background),
845
+ lens: [0, 1, 0, 0],
846
+ volumetricCount: 0,
847
+ clipPlane: capture.clip ?? [0, 0, 0, 0],
848
+ bandOrigin: [0, 0],
849
+ bandSize: [width, height],
850
+ sampleCount: 1,
851
+ };
852
+
853
+ for (let sample = 0; sample < CAPTURE_SAMPLES; sample++) {
854
+ const encoder = gpu.device.createCommandEncoder();
855
+
856
+ pathTrace.encode(encoder, { ...uniforms, sampleIndex: sample });
857
+ gpu.device.queue.submit([encoder.finish()]);
858
+ }
859
+
860
+ const offset = (id: string | undefined) => {
861
+ const index = id === undefined ? undefined : main.packed.textures.indexOf.get(id);
862
+
863
+ return index === undefined ? NO_TEXTURE : main.packed.textures.data[index * 16 + 2];
864
+ };
865
+
866
+ const encoder = gpu.device.createCommandEncoder();
867
+
868
+ (this.resolve as CaptureResolvePass).encode(encoder, accumulator.buffer, main.buffers.textures, width, height,
869
+ offset(capture.texture), offset(capture.depth));
870
+ gpu.device.queue.submit([encoder.finish()]);
871
+ }
872
+ } finally {
873
+ // The queue keeps what it still needs alive until the work that uses it is done.
874
+ for (const target of scenes.values()) target.buffers.destroy();
875
+
876
+ accumulator.destroy();
877
+ }
878
+ }
879
+
880
+ private configureCanvas(gpu: RenderDevice, width: number, height: number) {
881
+ if (!this.canvas) return;
882
+
883
+ const format = navigator.gpu.getPreferredCanvasFormat();
884
+
885
+ this.canvas.width = width;
886
+ this.canvas.height = height;
887
+
888
+ // Premultiplied always: a transparent background needs it, and a pixel no sample has reached yet stays see-through
889
+ // rather than black, so a page can show something under the canvas until the image covers it.
890
+ if (!this.context) {
891
+ this.context = this.canvas.getContext("webgpu");
892
+ this.context?.configure({ device: gpu.device, format, alphaMode: "premultiplied" });
893
+ }
894
+
895
+ if (!this.present || this.present.format !== format) this.present = new PresentPass(gpu.device, format);
896
+
897
+ this.present.setAccumulation((this.accumulator as Accumulator).buffer);
898
+ }
899
+
900
+ private encodePresent(encoder: GPUCommandEncoder, width: number, height: number) {
901
+ if (!this.context || !this.present) return;
902
+
903
+ this.present.encode(encoder, this.context.getCurrentTexture().createView(), {
904
+ width,
905
+ height,
906
+ exposure: this.current.exposure,
907
+ toneMapping: this.current.toneMapping,
908
+ vignette: this.current.vignette,
909
+ grain: this.current.grain,
910
+ frameIndex: this.frameIndex,
911
+ transparent: this.current.background.mode === "transparent",
912
+ denoised: this.denoised ? this.denoisePass?.output : null,
913
+ });
914
+ }
915
+
916
+ /** Denoises the accumulation as it stands, for the canvas and for the finished image. */
917
+ private encodeDenoise(gpu: RenderDevice, encoder: GPUCommandEncoder, width: number, height: number) {
918
+ this.denoisePass ??= new DenoisePass(gpu.device);
919
+ this.denoisePass.encode(encoder, (this.accumulator as Accumulator).buffer, width, height, this.denoiserSettings,
920
+ this.denoiseView);
921
+ this.denoised = true;
922
+ }
923
+
924
+ private frameUniforms(
925
+ camera: CameraState,
926
+ width: number,
927
+ height: number,
928
+ assets: SceneAssets,
929
+ target: PackedTarget | null = this.target,
930
+ backgroundOverride?: RenderBackgroundMode)
931
+ : Omit<FrameUniforms, "bandOrigin" | "bandSize" | "sampleIndex" | "sampleCount"> {
932
+ const buffers = target?.buffers ?? null;
933
+ const matrices: CameraMatrices = cameraMatrices(camera, width, height);
934
+ const gradient = this.environment.kind === "gradient" ? this.environment : null;
935
+ const ambient = buffers?.ambient ?? { sky: [0, 0, 0], ground: [0, 0, 0], axis: [0, 1, 0] };
936
+ const surrounding = buffers?.environment;
937
+ const { shadows, fog, depthOfField } = this.current;
938
+ const background = { ...this.current.background, mode: backgroundOverride ?? this.current.background.mode };
939
+ const sky = assets.sky;
940
+ const sceneFog = assets.scene.fog;
941
+ // The CPU renderer's lens: a 35 mm frame, its focal length from the field of view, and the f-number's aperture.
942
+ const focalLength = 0.035 / (2 * Math.tan(matrices.fov * Math.PI / 360));
943
+
944
+ const lensRadius = depthOfField.enabled && !matrices.orthographic
945
+ ? focalLength / (2 * Math.max(depthOfField.aperture, 1e-3))
946
+ : 0;
947
+
948
+ const backgroundMode = background.mode === "color" ? 1 : background.mode === "transparent" ? 2 : 0;
949
+
950
+ return {
951
+ cameraToWorld: matrices.cameraWorld,
952
+ worldToCamera: matrices.view,
953
+ projection: matrices.projection,
954
+ inverseProjection: matrices.inverseProjection,
955
+ environmentTop: [...linearColor(gradient?.topColor, [1, 1, 1]), gradient?.exponent ?? 2],
956
+ environmentBottom: [
957
+ ...linearColor(gradient?.bottomColor, [0, 0, 0]),
958
+ sky ? sky.intensity : this.environment.intensity ?? 1,
959
+ ],
960
+ ambientSky: [...ambient.sky, 0],
961
+ ambientGround: [...ambient.ground, 0],
962
+ ambientAxis: [...ambient.axis, 0],
963
+ ...Object.fromEntries(Array.from({ length: 9 }, (_, k) =>
964
+ [`probe${k}`, [...this.probe.slice(k * 3, k * 3 + 3), 0]])),
965
+ // The settings' fog covers every surface; otherwise the scene's own fog covers the materials that accept it.
966
+ fogColor: fog.enabled
967
+ ? [...linearColor(fog.color, [0.5, 0.5, 0.5]), 1]
968
+ : sceneFog ? [...sceneFog.color, sceneFog.density !== undefined ? 2 : 1] : [0, 0, 0, 0],
969
+ fogParams: fog.enabled
970
+ ? [fog.near, fog.far, 0, 1]
971
+ : [sceneFog?.near ?? 0, sceneFog?.far ?? 0, sceneFog?.density ?? 0, 0],
972
+ background: [...linearColor(background.color, [0, 0, 0]), backgroundMode],
973
+ lens: [lensRadius, Math.max(depthOfField.focusDistance, 1e-3), depthOfField.blades, 0],
974
+ resolution: [width, height],
975
+ cameraKind: matrices.orthographic ? 1 : 0,
976
+ bounces: Math.max(1, Math.round(this.current.globalIllumination.bounces)),
977
+ triangleCount: buffers?.triangleCount ?? 0,
978
+ lightCount: buffers?.lightCount ?? 0,
979
+ areaLightCount: buffers?.areaLightCount ?? 0,
980
+ clampIndirect: Math.max(0, this.current.globalIllumination.clamp),
981
+ // The setting is the angular diameter a light covers, in degrees, as it is for the CPU renderer's shadow blur.
982
+ shadowSpread: shadows.enabled ? Math.tan(Math.max(0, shadows.softness) * Math.PI / 360) : 0,
983
+ shadowsEnabled: shadows.enabled,
984
+ uvOffset: buffers?.uvOffset ?? 0,
985
+ orderOffset: buffers?.orderOffset ?? 0,
986
+ colorOffset: buffers?.colorOffset ?? 0,
987
+ hasColors: buffers?.hasColors ?? false,
988
+ varyingOffset: buffers?.varyingOffset ?? 0,
989
+ environmentTexture: surrounding?.texture ?? NO_TEXTURE,
990
+ environmentCdf: surrounding?.cdf ?? 0,
991
+ environmentCdfSize: [surrounding?.width ?? 0, surrounding?.height ?? 0],
992
+ environmentTotal: surrounding?.total ?? 0,
993
+ // A procedural sky ignores the rotation, so its sun stays where its light is.
994
+ environmentRotation: sky ? 0 : (this.environment.rotation ?? 0) * Math.PI / 180,
995
+ environmentPick: surrounding?.pick ?? 0,
996
+ skyProgram: sky && assets.programs.foreground ? assets.programs.foreground.id : NO_PROGRAM,
997
+ skyData: target?.skyData ?? 0,
998
+ skyBackground: sky ? (sky.background ? 1 : 0) : 1,
999
+ skyIntensity: sky ? Math.max(sky.intensity, 1e-6) : 0,
1000
+ pixelCount: width * height,
1001
+ volumetricCount: target?.volumetricCount ?? 0,
1002
+ volumetricData: target?.volumetricData ?? 0,
1003
+ };
1004
+ }
1005
+
1006
+ /**
1007
+ * Copies the accumulation buffer back and resolves it on the CPU, with the same exposure, tone mapping and sRGB
1008
+ * encoding the presentation pass applies. Grain is a presentation effect and is not part of the returned image.
1009
+ */
1010
+ private async readImage(
1011
+ gpu: RenderDevice, width: number, height: number, denoised: GPUBuffer | null): Promise<RenderImage> {
1012
+ const accumulator = this.accumulator as Accumulator;
1013
+ // The radiance and the coverage; the guides and the row flags after them are not part of the image.
1014
+ const length = width * height * 32;
1015
+
1016
+ const read = async(buffer: GPUBuffer, bytes: number) => {
1017
+ const usage = GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST;
1018
+ const staging = gpu.device.createBuffer({ size: bytes, usage });
1019
+ const encoder = gpu.device.createCommandEncoder();
1020
+
1021
+ encoder.copyBufferToBuffer(buffer, 0, staging, 0, bytes);
1022
+ gpu.device.queue.submit([encoder.finish()]);
1023
+ await staging.mapAsync(GPUMapMode.READ);
1024
+
1025
+ const floats = new Float32Array(staging.getMappedRange().slice(0));
1026
+
1027
+ staging.unmap();
1028
+ staging.destroy();
1029
+
1030
+ return floats;
1031
+ };
1032
+
1033
+ const floats = await read(accumulator.buffer, length);
1034
+ // The denoiser's output is the mean radiance itself, one vec4 per pixel.
1035
+ const filtered = denoised ? await read(denoised, width * height * 16) : null;
1036
+
1037
+ const data = new Uint8ClampedArray(width * height * 4);
1038
+ const { exposure, toneMapping, vignette } = this.current;
1039
+ const transparent = this.current.background.mode === "transparent";
1040
+ const coverage = width * height * 4;
1041
+
1042
+ for (let pixel = 0; pixel < width * height; pixel++) {
1043
+ const samples = floats[pixel * 4 + 3];
1044
+ const x = (pixel % width) / width - 0.5, y = Math.floor(pixel / width) / height - 0.5;
1045
+ const falloff = Math.max(0, 1 - vignette * (x * x + y * y) * 2);
1046
+
1047
+ // The radiance is premultiplied by the coverage, which a transparent background makes the alpha.
1048
+ const alpha = !transparent ? 1
1049
+ : samples > 0 ? Math.min(1, Math.max(0, floats[coverage + pixel * 4] / samples)) : 0;
1050
+
1051
+ const mean = (channel: number) =>
1052
+ (filtered ? filtered[pixel * 4 + channel] : floats[pixel * 4 + channel] / samples);
1053
+
1054
+ const color = samples > 0 && alpha > 0
1055
+ ? [0, 1, 2].map(channel => mean(channel) / alpha * falloff)
1056
+ : [0, 0, 0];
1057
+
1058
+ const [r, g, b] = encodeColor(color, exposure, toneMapping);
1059
+
1060
+ data.set([r, g, b, Math.round(alpha * 255)], pixel * 4);
1061
+ }
1062
+
1063
+ return { width, height, data };
1064
+ }
1065
+ }
1066
+
1067
+ /** The material types the CPU renderer's snapshot accepts by name. */
1068
+ const SERIALIZABLE = new Set([
1069
+ "PointsMaterial", "LineBasicMaterial", "LineDashedMaterial", "SpriteMaterial", "MeshBasicMaterial",
1070
+ "MeshLambertMaterial", "MeshPhongMaterial", "MeshToonMaterial", "MeshStandardMaterial", "MeshPhysicalMaterial",
1071
+ "MeshNormalMaterial", "MeshDepthMaterial", "MeshDistanceMaterial", "MeshMatcapMaterial", "ShadowMaterial",
1072
+ "ShaderMaterial", "RawShaderMaterial"]);
1073
+
1074
+ /**
1075
+ * Serializes a live scene. The snapshot accepts materials by their type name, so a subclass of ShaderMaterial with a
1076
+ * name of its own (LineMaterial, for fat lines) would be refused; it passes as the class it extends, which is all the
1077
+ * snapshot reads of it, and gets its name back once the materials have been read.
1078
+ */
1079
+ async function serializeLive(scene: Scene): Promise<SerializedScene> {
1080
+ const renamed = new Map<Material, string>();
1081
+
1082
+ scene.traverse(object => {
1083
+ const value = (object as Object3D & { material?: Material | Material[] }).material;
1084
+
1085
+ for (const material of Array.isArray(value) ? value : value ? [value] : []) {
1086
+ const shader = material as Material & { isShaderMaterial?: boolean; isRawShaderMaterial?: boolean };
1087
+
1088
+ if (shader.isShaderMaterial && !SERIALIZABLE.has(material.type) && !renamed.has(material)) {
1089
+ renamed.set(material, material.type);
1090
+ material.type = shader.isRawShaderMaterial ? "RawShaderMaterial" : "ShaderMaterial";
1091
+ }
1092
+ }
1093
+ });
1094
+
1095
+ try {
1096
+ return (await serializeScene(scene)).scene;
1097
+ } finally {
1098
+ for (const [material, type] of renamed) material.type = type;
1099
+ }
1100
+ }
1101
+
1102
+ /**
1103
+ * The summed coefficients of every LightProbe the renderer would draw, times its intensity, as Three's WebGLLights sums
1104
+ * them. The snapshot keeps a probe's colour and intensity but not its harmonics, so they are read from the live scene.
1105
+ */
1106
+ function lightProbes(scene: Scene): number[] {
1107
+ const sum = new Array(27).fill(0);
1108
+
1109
+ const visit = (object: Object3D) => {
1110
+ if (!object.visible || (object.userData?.cpuRenderer as { exclude?: boolean } | undefined)?.exclude) return;
1111
+
1112
+ const probe = object as LightProbe;
1113
+
1114
+ if (probe.isLightProbe)
1115
+ probe.sh.coefficients.forEach((coefficient, k) => {
1116
+ sum[k * 3] += coefficient.x * probe.intensity;
1117
+ sum[k * 3 + 1] += coefficient.y * probe.intensity;
1118
+ sum[k * 3 + 2] += coefficient.z * probe.intensity;
1119
+ });
1120
+
1121
+ for (const child of object.children) visit(child);
1122
+ };
1123
+
1124
+ visit(scene);
1125
+
1126
+ return sum;
1127
+ }
1128
+
1129
+ function checkedPixelRatio(pixelRatio: number): number {
1130
+ if (!Number.isFinite(pixelRatio) || pixelRatio <= 0)
1131
+ throw new Error("GPU renderer: pixelRatio must be a positive number.");
1132
+
1133
+ return pixelRatio;
1134
+ }
1135
+
1136
+ /** A perspective camera at (5, 5, 5) looking at the origin, for a frame rendered before any camera is set. */
1137
+ function defaultCamera(): CameraState {
1138
+ const camera = new PerspectiveCamera(50, 1, 0.1, 1000);
1139
+
1140
+ camera.position.set(5, 5, 5);
1141
+ camera.lookAt(0, 0, 0);
1142
+ camera.updateMatrixWorld();
1143
+
1144
+ return serializeCamera(camera);
1145
+ }