rayzee 9.4.2 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +223 -21
  2. package/dist/ActiveColor-YvuQq44O.js +13 -0
  3. package/dist/ArchiveImporter-xuUJiklP.js +4301 -0
  4. package/dist/AssetConfig-DSEntE65.js +30 -0
  5. package/dist/BidirectionalIntegrator-C5PKjJoT.js +934 -0
  6. package/dist/ColorManagement-CheBCPaW.js +1488 -0
  7. package/dist/EngineDefaults-DH3bI5t-.js +844 -0
  8. package/dist/EngineEvents-Ci8dVWCP.js +73 -0
  9. package/dist/EngineIssues-CIZnXHsF.js +72 -0
  10. package/dist/FileFormats-sPGJeCRc.js +102 -0
  11. package/dist/Logger-BnHghprG.js +97 -0
  12. package/dist/{NeuralRetouch-vkdXXyK2.js → NeuralRetouch-CW11yLZ_.js} +8 -6
  13. package/dist/{NeuralSuperRes-FVqRJKQV.js → NeuralSuperRes-BekaecYg.js} +55 -120
  14. package/dist/PathTracer-Ccl9KOuF.js +29729 -0
  15. package/dist/PhysicalSky-Dv2PMFGW.js +1219 -0
  16. package/dist/RayzeeRenderer-BQxnzCTD.js +3542 -0
  17. package/dist/SolarLimb-ken-JbDK.js +721 -0
  18. package/dist/ToneMapGPU-BHi5JzIq.js +177 -0
  19. package/dist/ViewTransforms-D0rTLBsX.js +255 -0
  20. package/dist/WorkingMatrix-C6y233hT.js +27 -0
  21. package/dist/ZipReader-gDSWr0I5.js +98 -0
  22. package/dist/addons/archives.es.js +3 -0
  23. package/dist/addons/bidirectional.es.js +2 -0
  24. package/dist/addons/color.es.js +3 -0
  25. package/dist/addons/formats.es.js +2 -0
  26. package/dist/addons/physical-sky.es.js +2 -0
  27. package/dist/addons/storage.es.js +3 -0
  28. package/dist/archiveFormats-BZw7TiDp.js +75 -0
  29. package/dist/identity-DrQsGT2o.js +39 -0
  30. package/dist/openStorage-DwcBRUj7.js +820 -0
  31. package/dist/rayzee-core.es.js +12 -0
  32. package/dist/rayzee.es.js +2266 -44079
  33. package/dist/rayzee.umd.js +485 -473
  34. package/dist/sceneCachePolicy-BmwYOYDu.js +183 -0
  35. package/dist/shared-BGBcSxBQ.js +17 -0
  36. package/package.json +23 -2
  37. package/src/Color/ActiveColor.js +34 -0
  38. package/src/Color/BasicColor.js +147 -0
  39. package/src/Color/ColorManagement.js +7 -31
  40. package/src/EngineDefaults.js +22 -8
  41. package/src/EngineEvents.js +2 -0
  42. package/src/EngineIssues.js +1 -0
  43. package/src/PathTracerApp.js +617 -4016
  44. package/src/Pipeline/PipelineContext.js +0 -4
  45. package/src/Platform.js +36 -0
  46. package/src/Processor/ArchiveCache.js +1 -1
  47. package/src/Processor/ArchiveImporter.js +1259 -0
  48. package/src/Processor/AssetLoader.js +322 -1746
  49. package/src/Processor/AtmosphereModel.js +1 -2
  50. package/src/Processor/BVHBuilder.js +4 -95
  51. package/src/Processor/EnvironmentExactTable.js +187 -0
  52. package/src/Processor/EquirectHDRInfo.js +30 -223
  53. package/src/Processor/FileFormats.js +116 -0
  54. package/src/Processor/GLTFDecoders.js +85 -0
  55. package/src/Processor/GLTFDiffuseTransmission.js +63 -0
  56. package/src/Processor/GeometryExtractor.js +12 -1
  57. package/src/Processor/IESParser.js +47 -35
  58. package/src/Processor/KernelManager.js +35 -2
  59. package/src/Processor/MaterialPacking.js +8 -7
  60. package/src/Processor/PBRT/PBRTMaterials.js +129 -41
  61. package/src/Processor/PBRT/PBRTParser.js +30 -17
  62. package/src/Processor/PBRT/PBRTSceneBuilder.js +548 -37
  63. package/src/Processor/PBRT/PBRTTextureBake.js +154 -0
  64. package/src/Processor/PBRT/PFM.js +59 -0
  65. package/src/Processor/PackedRayBuffer.js +116 -9
  66. package/src/Processor/ParallelBVHBuilder.js +3 -6
  67. package/src/Processor/PhysicalSky.js +5 -4
  68. package/src/Processor/PlatformImageLoader.js +50 -6
  69. package/src/Processor/QueueManager.js +12 -3
  70. package/src/Processor/ReinsertionOptimizer.js +2 -2
  71. package/src/Processor/SceneProcessor.js +15 -55
  72. package/src/Processor/ShaderBuilder.js +6 -16
  73. package/src/Processor/SolarLimb.js +3 -0
  74. package/src/Processor/TextureCreator.js +1 -1
  75. package/src/Processor/Workers/BVHSubtreeWorker.js +1 -34
  76. package/src/Processor/Workers/BVHWorker.js +5 -18
  77. package/src/Processor/Workers/CDFWorker.js +6 -183
  78. package/src/Processor/archiveFormats.js +7 -0
  79. package/src/Processor/utils.js +2 -1
  80. package/src/RayzeeRenderer.js +3902 -0
  81. package/src/RenderSettings.js +49 -41
  82. package/src/Stages/ASVGF.js +5 -7
  83. package/src/Stages/AutoExposure.js +3 -12
  84. package/src/Stages/Compositor.js +8 -23
  85. package/src/Stages/NRD.js +9 -2
  86. package/src/Stages/NormalDepth.js +9 -6
  87. package/src/Stages/PathTracer.js +254 -38
  88. package/src/Stages/PathTracerStage.js +11 -16
  89. package/src/Storage/BLASCache.js +1 -1
  90. package/src/Storage/CDFCache.js +18 -17
  91. package/src/Storage/DownloadCache.js +1 -1
  92. package/src/Storage/SceneGraphCodec.js +46 -3
  93. package/src/Storage/SpillStore.js +1 -1
  94. package/src/Storage/StorageManager.js +2 -4
  95. package/src/Storage/areas.js +4 -0
  96. package/src/TSL/BVHTraversal.js +62 -24
  97. package/src/TSL/Bidirectional.js +204 -0
  98. package/src/TSL/BidirectionalLamps.js +132 -0
  99. package/src/TSL/Clearcoat.js +15 -6
  100. package/src/TSL/Common.js +19 -14
  101. package/src/TSL/ConnectKernel.js +147 -0
  102. package/src/TSL/Debugger.js +1 -1
  103. package/src/TSL/Displacement.js +4 -3
  104. package/src/TSL/EmissiveSampling.js +64 -17
  105. package/src/TSL/Environment.js +77 -106
  106. package/src/TSL/EnvironmentCDF.js +85 -39
  107. package/src/TSL/ExtendKernel.js +8 -3
  108. package/src/TSL/FinalWriteKernel.js +4 -4
  109. package/src/TSL/GenerateKernel.js +18 -4
  110. package/src/TSL/HitFacet.js +35 -5
  111. package/src/TSL/LightBVHSampling.js +40 -9
  112. package/src/TSL/LightGenerateKernel.js +293 -0
  113. package/src/TSL/LightGuide.js +198 -0
  114. package/src/TSL/LightSplatKernel.js +165 -0
  115. package/src/TSL/LightsCore.js +13 -38
  116. package/src/TSL/LightsDirect.js +28 -29
  117. package/src/TSL/LightsIndirect.js +3 -1
  118. package/src/TSL/LightsSampling.js +90 -38
  119. package/src/TSL/MaterialEvaluation.js +113 -31
  120. package/src/TSL/MaterialProperties.js +44 -17
  121. package/src/TSL/MaterialTransmission.js +52 -47
  122. package/src/TSL/MergeKernel.js +218 -0
  123. package/src/TSL/PathTracerCore.js +65 -29
  124. package/src/TSL/Random.js +4 -0
  125. package/src/TSL/SceneResources.js +51 -0
  126. package/src/TSL/ShadeKernel.js +881 -103
  127. package/src/TSL/Struct.js +10 -0
  128. package/src/TSL/Sun.js +1 -1
  129. package/src/TSL/TextureSampling.js +65 -60
  130. package/src/addons/archives.js +14 -0
  131. package/src/addons/bidirectional.js +13 -0
  132. package/src/addons/color.js +32 -0
  133. package/src/addons/formats.js +16 -0
  134. package/src/addons/physicalSky.js +14 -0
  135. package/src/addons/storage.js +19 -0
  136. package/src/core.js +35 -0
  137. package/src/index.js +3 -1
  138. package/src/integrators/BidirectionalIntegrator.js +832 -0
  139. package/src/managers/EnvironmentManager.js +85 -33
  140. package/src/managers/MaterialDataManager.js +42 -0
  141. package/src/managers/UniformManager.js +0 -1
  142. package/dist/ToneMapGPU-DKQM1pMd.js +0 -552
  143. package/dist/rolldown-runtime-CAFD8bLK.js +0 -11
  144. package/src/Processor/TreeletOptimizer.js +0 -574
@@ -0,0 +1,3902 @@
1
+ import { WebGPURenderer, RectAreaLightNode, SRGBColorSpace, LinearSRGBColorSpace } from 'three/webgpu';
2
+ import { texture as _tslTexture, cubeTexture as _tslCubeTexture } from 'three/tsl';
3
+ import {
4
+ Scene, EventDispatcher, Box3, REVISION,
5
+ } from 'three';
6
+ import { RectAreaLightTexturesLib } from 'three/addons/lights/RectAreaLightTexturesLib.js';
7
+ import { SceneHelpers } from './SceneHelpers.js';
8
+ import { PathTracer } from './Stages/PathTracer.js';
9
+ import { Compositor } from './Stages/Compositor.js';
10
+ import { RenderPipeline } from './Pipeline/RenderPipeline.js';
11
+ import { CompletionTracker } from './Pipeline/CompletionTracker.js';
12
+ import { ENGINE_DEFAULTS as DEFAULT_STATE, TRIANGLE_DATA_LAYOUT, BVH_LEAF_MARKERS, MAX_STORAGE_TEXTURE_SIZE, MAX_RESERVABLE_RENDER_SIZE, setReservedRenderSize, getRenderProfile, SPILL_TRIANGLE_BUDGET, SPILL_PLACEMENT_BUDGET } from './EngineDefaults.js';
13
+ import { updateStats, updateLoading, resetLoading, setStatusCallback, getDisplaySamples, disposeObjectFromMemory, disposeRenderer } from './Processor/utils.js';
14
+ import { BuildTimer } from './Processor/BuildTimer.js';
15
+ import { TextureReadback } from './Processor/TextureReadback.js';
16
+ import { createLogger, fmt } from './utils/Logger.js';
17
+ import { EngineEvents, LEGACY_EVENT_NAMES } from './EngineEvents.js';
18
+ import { IssueLog, ISSUE_CODES, EngineIssueError } from './EngineIssues.js';
19
+ import { getAssetConfig, isAssetConfigured } from './AssetConfig.js';
20
+ import { nameFromUrl } from './Storage/DownloadCache.js';
21
+ import { fileIdentity, identityKey } from './Storage/identity.js';
22
+ import { SETTING_SOURCE } from './RenderSettings.js';
23
+ import { toneMapToRGBA8 } from './Processor/ToneMapCPU.js';
24
+ import { PackedToneMapper } from './Processor/ToneMapGPU.js';
25
+ import { BasicColor } from './Color/BasicColor.js';
26
+ import { setActiveColorManagement } from './Color/ActiveColor.js';
27
+ import { getViewTransform } from './Color/ViewTransforms.js';
28
+ import { AssetLoader } from './Processor/AssetLoader.js';
29
+ import { SceneProcessor } from './Processor/SceneProcessor.js';
30
+ import { deviceMemoryGB } from './Processor/HostMemory.js';
31
+ import { createHeadlessCanvas } from './HeadlessCanvas.js';
32
+
33
+ // Managers
34
+ import { RenderSettings } from './RenderSettings.js';
35
+ import { ViewCamera } from './managers/ViewCamera.js';
36
+ import { LightManager } from './managers/LightManager.js';
37
+ import { TRIANGLE_PATCH_PROPERTIES } from './managers/MaterialDataManager.js';
38
+ import { VERSION } from './version.js';
39
+ import { toPortable } from './SceneState/portable.js';
40
+
41
+ export const RENDER_CHECKPOINT_VERSION = 1;
42
+
43
+ // The part of an archive a load chose, for sceneSource.
44
+ const partOf = ( { element, pbrtEntry } = {} ) => ( {
45
+ ...( element !== undefined ? { element } : {} ),
46
+ ...( pbrtEntry !== undefined ? { pbrtEntry } : {} ),
47
+ } );
48
+
49
+ // One app per canvas — auto-dispose a prior owner if the caller double-
50
+ // instantiates (StrictMode, HMR, etc.) so its rAF loop can't burn CPU.
51
+ const _appsByCanvas = new WeakMap();
52
+
53
+ const KNOWN_EVENTS = new Set( [ ...Object.values( EngineEvents ), ...Object.values( LEGACY_EVENT_NAMES ) ] );
54
+ const _warnedEvents = new Set();
55
+
56
+ // three's Animation.start() calls self.requestAnimationFrame during init() with no guard, and
57
+ // outside a browser there is no `self`. A headless app never uses that loop, so it gets an inert one.
58
+ async function initWithoutFrameLoop( renderer, headless ) {
59
+
60
+ if ( ! headless || typeof self !== 'undefined' ) return renderer.init();
61
+
62
+ globalThis.self = { requestAnimationFrame: () => 0, cancelAnimationFrame: () => {} };
63
+
64
+ try {
65
+
66
+ return await renderer.init();
67
+
68
+ } finally {
69
+
70
+ delete globalThis.self;
71
+
72
+ }
73
+
74
+ }
75
+
76
+
77
+ /**
78
+ * The renderer core: a scene and camera in, path-traced samples accumulated, the image out. It builds
79
+ * no denoiser, camera controls, gizmo, overlay or timeline — {@link PathTracerApp} is the viewer that
80
+ * adds them. See docs/CORE_AND_ADDONS.md.
81
+ *
82
+ * - `renderer.camera` — the camera rendered from; move it, then {@link reset}
83
+ * - `renderer.lightManager` — {@link LightManager} (CRUD, helpers, GPU transfer)
84
+ * - `renderer.environmentManager` — EnvironmentManager (HDRI, procedural sky, mode switching)
85
+ * - `renderer.settings` — {@link RenderSettings} (all render parameters)
86
+ * - `renderer.stages` — named pipeline stages: `pathTracer`, `compositor`
87
+ * - `renderer.sceneMeshes` — meshes backing the BVH, in buffer order (see {@link refitBVH})
88
+ * - `renderer.sceneModel` — root of the rendered model (a copy, for {@link loadObject3D})
89
+ *
90
+ * A layer built on the core overrides the hooks under "Hooks" at the end of the class; each runs at
91
+ * a fixed point of the frame, reset or load sequence and does nothing here.
92
+ */
93
+
94
+ const log = createLogger( 'engine' );
95
+
96
+ // An empty scene's tree: one triangle leaf holding nothing. Sixteen zeros read as an inner node whose children are itself.
97
+ function emptyBVH() {
98
+
99
+ const nodes = new Float32Array( 16 );
100
+ new Uint32Array( nodes.buffer )[ 3 ] = BVH_LEAF_MARKERS.TRIANGLE_LEAF;
101
+ return nodes;
102
+
103
+ }
104
+
105
+ /**
106
+ * Attaches the device-lost handler at module scope so the reaction closure captures only
107
+ * `holder` — an arrow written inside a method would share that method's context and pin
108
+ * the app through `this`. See _initRenderer().
109
+ */
110
+ function attachDeviceLostHandler( device, holder ) {
111
+
112
+ device.lost.then( ( info ) => {
113
+
114
+ if ( holder.app ) holder.app._handleDeviceLost( info );
115
+
116
+ } );
117
+
118
+ }
119
+
120
+ const SOFTWARE_ADAPTER = /swiftshader|llvmpipe|lavapipe|basic render|microsoft basic|warp/i;
121
+
122
+ /**
123
+ * Flags software rasterizers: correct output, ~100x slower, invisible in the image.
124
+ * `isFallbackAdapter` only covers adapters we asked to be fallbacks, so the strings matter.
125
+ *
126
+ * @param {GPUAdapter} adapter
127
+ * @returns {{vendor:string, architecture:string, device:string, description:string, isSoftware:boolean}}
128
+ */
129
+ export function describeAdapter( adapter ) {
130
+
131
+ const info = adapter.info ?? {};
132
+ const identity = `${info.vendor ?? ''} ${info.architecture ?? ''} ${info.device ?? ''} ${info.description ?? ''}`;
133
+
134
+ return {
135
+ vendor: info.vendor ?? '',
136
+ architecture: info.architecture ?? '',
137
+ device: info.device ?? '',
138
+ description: info.description ?? '',
139
+ isSoftware: info.isFallbackAdapter === true
140
+ || adapter.isFallbackAdapter === true
141
+ || SOFTWARE_ADAPTER.test( identity ),
142
+ };
143
+
144
+ }
145
+
146
+ export class RayzeeRenderer extends EventDispatcher {
147
+
148
+ /**
149
+ * @param {HTMLCanvasElement} canvas - Canvas element for rendering
150
+ * @param {Object} [options] - Engine options
151
+ * @param {boolean} [options.autoResize=true] - Automatically listen for window resize events
152
+ * @param {boolean} [options.headless=false] - a render nobody watches, and the default when no canvas
153
+ * is given: the engine supplies its own canvas (see HeadlessCanvas.js) and runs no render loop — drive it
154
+ * with renderFrames() or renderUntilComplete()
155
+ * @param {boolean} [options.strict=false] - Throw at the point of degradation instead of
156
+ * rendering a plausible wrong image. See EngineIssues.js; read `app.issues` when off.
157
+ * @param {string} [options.profile='viewer'] - Which tuning to apply where the viewer's
158
+ * product decisions differ from the physical answer. See RENDER_PROFILES.
159
+ * @param {number} [options.maxSceneBytes] - refuse a scene whose estimated host memory is
160
+ * above this. The default refuses where the renderer process would be killed instead of
161
+ * throwing; raise it deliberately, on a fresh browser. See HostMemory.js.
162
+ * @param {number} [options.hostMemoryGB] - the host's memory, where `navigator.deviceMemory` is
163
+ * absent (anything but Chrome) or wrong; it sizes the render reserve and the path pool
164
+ * @param {false|'auto'|Object} [options.storage] - on-disk storage; defaults to
165
+ * `configureAssets( { storage } )`, or off under `strict` unless that was set. 'auto' opens it through
166
+ * {@link setStorageOpener} (rayzee/addons/storage); a host-supplied StorageManager stays the host's to dispose.
167
+ * @param {boolean} [options.memorySpill=false] - experimental: once a large static scene is
168
+ * on the GPU, move its triangle records and BLAS nodes to disk (see
169
+ * {@link ensureSceneResident}). Needs storage; skipped for animated scenes.
170
+ *
171
+ * The engine dispatches `EngineEvents.FRAME` after each animate() iteration so hosts can
172
+ * tick external instrumentation (e.g. a stats panel) without coupling the engine to it.
173
+ */
174
+ constructor( canvas, options = {} ) {
175
+
176
+ super();
177
+
178
+ this._headless = options.headless === true || ! canvas;
179
+ canvas ??= createHeadlessCanvas();
180
+
181
+ try {
182
+
183
+ _appsByCanvas.get( canvas )?.dispose();
184
+
185
+ } catch ( err ) {
186
+
187
+ log.warn( 'prior canvas owner dispose failed', err );
188
+
189
+ }
190
+
191
+ _appsByCanvas.set( canvas, this );
192
+
193
+ this.canvas = canvas;
194
+ this._autoResize = ! this._headless && options.autoResize !== false;
195
+ // A scene budget the host may raise; read where SceneProcessor is built, well after this.
196
+ this._maxSceneBytes = options.maxSceneBytes;
197
+ this._hostMemoryGB = options.hostMemoryGB;
198
+ this._storageOption = options.storage;
199
+ this._storageOpener = null;
200
+ this._memorySpill = options.memorySpill === true;
201
+ /** @type {?Object} on-disk storage (a StorageManager), null when off or unavailable */
202
+ this.storage = null;
203
+ this._storageRelease = null;
204
+ // Apply the environment authored into a model file's metadata on load. See _beginSceneMetadataEnvironment().
205
+ this._applySceneMetadataEnabled = options.applySceneMetadata !== false;
206
+ this._unlitRestore = null;
207
+ this._applyingSceneMetadata = false;
208
+
209
+ // Before the settings: the profile supplies some of their defaults.
210
+ this._profile = getRenderProfile( options.profile );
211
+ this._profileName = options.profile ?? 'viewer';
212
+
213
+ // First, so no subsystem can degrade unrecorded.
214
+ this._issues = new IssueLog( {
215
+ strict: options.strict === true,
216
+ onIssue: ( issue ) => this.dispatchEvent( { type: EngineEvents.ISSUE, issue } ),
217
+ } );
218
+
219
+ /**
220
+ * Colour management: what the engine renders in, shows it as, and hands out. Here linear Rec.709 and
221
+ * three.js's own seven view transforms; setColorManagement() installs the OCIO pipeline.
222
+ */
223
+ this.color = new BasicColor( { issues: this._issues } );
224
+ setActiveColorManagement( this.color );
225
+
226
+ // ── Settings (single source of truth for all render parameters) ──
227
+ this.settings = new RenderSettings(
228
+ {
229
+ ...DEFAULT_STATE,
230
+ environmentRotation: this._profile.environmentRotation,
231
+ saturation: this._profile.saturation,
232
+ dofMode: this._profile.dofMode,
233
+ },
234
+ { issues: this._issues }
235
+ );
236
+
237
+ // ── Core objects (populated in init) ──
238
+ this.renderer = null;
239
+ this.scene = null;
240
+ this.meshScene = null;
241
+ this._sceneHelpers = null;
242
+
243
+ // ── Asset pipeline ──
244
+ this.assetLoader = null;
245
+ this._sdf = null;
246
+ // Max material-texture dimension (longest edge); applied on each scene build.
247
+ this._maxTextureSize = DEFAULT_STATE.maxTextureSize;
248
+
249
+ // ── Pipeline & stages ──
250
+ this.pipeline = null;
251
+
252
+ this._pendingReservedRenderSize = null;
253
+
254
+ /**
255
+ * Named access to all pipeline stages.
256
+ * Advanced consumers can reach into stages for fine-grained control.
257
+ * @type {Object}
258
+ */
259
+ this.stages = {};
260
+
261
+ /** @type {?import('three').PerspectiveCamera} the camera rendered from (a ViewCamera) */
262
+ this.camera = null;
263
+
264
+ // ── Managers (direct public access) ──
265
+ /** @type {LightManager} */
266
+ this.lightManager = null;
267
+ /** @type {import('./managers/EnvironmentManager.js').EnvironmentManager} */
268
+ this.environmentManager = null;
269
+
270
+ // ── State ──
271
+ this.isInitialized = false;
272
+ this.pauseRendering = false;
273
+ this._pathTracerEnabled = true;
274
+ this._rasterPrecompile = null;
275
+ this.animationManagerId = null;
276
+ this.needsReset = false;
277
+ this._loadingInProgress = false;
278
+ this._needsDisplayRefresh = false;
279
+ this._paused = false;
280
+ // Emissive-triangle NEE auto-follows the scene (on when it has emissive geometry)
281
+ // until the user toggles it explicitly; reset on each fresh model load.
282
+ this._emissiveSamplingUserSet = false;
283
+
284
+ // Render completion tracking
285
+ this.completion = new CompletionTracker();
286
+
287
+ // Resolution state
288
+ this._resizeDebounceTimer = null;
289
+ // The size the host asked for. The canvas backing store is this × _renderScale, which drops
290
+ // below 1 only while the camera moves.
291
+ this._displayWidth = 0;
292
+ this._displayHeight = 0;
293
+ this._renderScale = 1;
294
+ // What the pipeline last rendered at, before the render scale.
295
+ this._renderWidth = 0;
296
+ this._renderHeight = 0;
297
+
298
+ // Tracked listeners for clean dispose()
299
+ this._trackedListeners = [];
300
+ this._disposed = false;
301
+ this._deviceLost = false;
302
+ this._gpuDevice = null;
303
+ this._deviceLostHolder = null;
304
+
305
+ // Deterministic-render mode — see setDeterministicMode()
306
+ this._deterministic = false;
307
+ this._dispatchPinned = false;
308
+ this._deterministicRestore = null;
309
+
310
+ /** @type {?{vendor:string, architecture:string, device:string, description:string, isSoftware:boolean}} */
311
+ this.adapterInfo = null;
312
+
313
+ }
314
+
315
+ /**
316
+ * Registers an event listener and tracks it for automatic cleanup on dispose().
317
+ * @param {EventTarget|{addEventListener:Function, removeEventListener:Function}} target
318
+ * @param {string} type
319
+ * @param {Function} handler
320
+ */
321
+ _addTrackedListener( target, type, handler ) {
322
+
323
+ if ( ! target ) return;
324
+ target.addEventListener( type, handler );
325
+ this._trackedListeners.push( { target, type, handler } );
326
+
327
+ }
328
+
329
+ /** Removes all listeners registered via _addTrackedListener. */
330
+ _removeTrackedListeners() {
331
+
332
+ for ( const { target, type, handler } of this._trackedListeners ) {
333
+
334
+ try {
335
+
336
+ target.removeEventListener( type, handler );
337
+
338
+ } catch ( err ) {
339
+
340
+ log.warn( 'failed to remove listener', type, err );
341
+
342
+ }
343
+
344
+ }
345
+
346
+ this._trackedListeners.length = 0;
347
+
348
+ }
349
+
350
+ // ═══════════════════════════════════════════════════════════════
351
+ // Lifecycle
352
+ // ═══════════════════════════════════════════════════════════════
353
+
354
+ /**
355
+ * Initializes the WebGPU renderer, pipeline stages, and managers.
356
+ */
357
+ async init() {
358
+
359
+ const storageReady = this._initStorage();
360
+ await this._initRenderer();
361
+ this._applyPendingReservedRenderSize();
362
+ this.camera = this._createCamera();
363
+ this._initScenes();
364
+ this._initAssetPipeline();
365
+ this._initPipeline();
366
+ await this._initManagers();
367
+ this._wireEvents();
368
+ await storageReady;
369
+ this._attachStorage();
370
+
371
+ // Seed path tracer with minimal empty scene data
372
+ this.stages.pathTracer.setTriangleData( new Uint32Array( TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE ), 0 );
373
+ this.stages.pathTracer.setBVHData( emptyBVH() );
374
+ this.stages.pathTracer.materialData.setMaterialData( new Float32Array( 16 ) );
375
+ this.stages.pathTracer.setupMaterial();
376
+
377
+ this.isInitialized = true;
378
+ log.debug( 'WebGPU path tracer app initialized' );
379
+
380
+ return this;
381
+
382
+ }
383
+
384
+ /**
385
+ * Installs on-disk storage — `acquireSharedStorage` from rayzee/addons/storage — for the download, environment and
386
+ * scene caches and the memory spill. Before init(), or after it resolves; PathTracerApp installs it itself.
387
+ * @param {function(string): Promise<{storage: ?Object, reason?: string, release: function(): void}>} opener
388
+ */
389
+ async setStorageOpener( opener ) {
390
+
391
+ this._storageOpener = opener;
392
+ if ( ! this.isInitialized || this.storage || this._disposed ) return;
393
+ await this._initStorage();
394
+ this._attachStorage();
395
+
396
+ }
397
+
398
+ async _initStorage() {
399
+
400
+ // A batch render must not be answered from an earlier run's cache unless the host asked for one.
401
+ const option = this._storageOption
402
+ ?? ( this._issues.strict && ! isAssetConfigured( 'storage' ) ? false : getAssetConfig().storage );
403
+ if ( option === false ) return;
404
+
405
+ if ( option && typeof option === 'object' ) {
406
+
407
+ this.storage = option;
408
+
409
+ } else {
410
+
411
+ if ( ! this._storageOpener ) {
412
+
413
+ if ( this._storageOption !== undefined || isAssetConfigured( 'storage' ) ) {
414
+
415
+ this._issues.warn( ISSUE_CODES.CAPABILITY_MISSING, 'on-disk storage needs rayzee/addons/storage: setStorageOpener( acquireSharedStorage )' );
416
+
417
+ }
418
+
419
+ return;
420
+
421
+ }
422
+
423
+ const { storage, reason, release } = await this._storageOpener( getAssetConfig().cacheNamespace );
424
+ if ( this._disposed ) {
425
+
426
+ release();
427
+ return;
428
+
429
+ }
430
+
431
+ if ( ! storage ) {
432
+
433
+ this._issues.warn( ISSUE_CODES.STORAGE_UNAVAILABLE, `on-disk storage is off: ${reason}`, { reason } );
434
+ return;
435
+
436
+ }
437
+
438
+ this.storage = storage;
439
+ this._storageRelease = release;
440
+
441
+ }
442
+
443
+ this._addTrackedListener( this.storage, 'change', ( e ) => this.dispatchEvent( { type: EngineEvents.STORAGE_CHANGED, area: e.area } ) );
444
+ this._addTrackedListener( this.storage, 'issue', ( e ) => this._issues.warn( e.code, e.message, e.detail ) );
445
+
446
+ }
447
+
448
+ _attachStorage() {
449
+
450
+ if ( this.assetLoader ) this.assetLoader.storage = this.storage;
451
+
452
+ }
453
+
454
+ dispatchEvent( event ) {
455
+
456
+ super.dispatchEvent( event );
457
+ const legacy = LEGACY_EVENT_NAMES[ event.type ];
458
+ if ( legacy ) super.dispatchEvent( { ...event, type: legacy } );
459
+
460
+ }
461
+
462
+ addEventListener( type, listener ) {
463
+
464
+ if ( ! KNOWN_EVENTS.has( type ) && ! _warnedEvents.has( type ) ) {
465
+
466
+ _warnedEvents.add( type );
467
+ log.warn( `no engine event is named "${type}", so this listener will never fire — see EngineEvents` );
468
+
469
+ }
470
+
471
+ super.addEventListener( type, listener );
472
+
473
+ }
474
+
475
+ /**
476
+ * Starts the animation loop.
477
+ */
478
+ animate() {
479
+
480
+ if ( this._headless ) throw new Error( 'a headless app has no render loop — drive it with renderFrames() or renderUntilComplete()' );
481
+
482
+ // Device lost: stop the loop rather than rescheduling render() on a dead device.
483
+ if ( this._deviceLost ) return;
484
+
485
+ this.animationManagerId = requestAnimationFrame( () => this.animate() );
486
+
487
+ if ( this._loadingInProgress || this._sdf?.isProcessing ) {
488
+
489
+ this.dispatchEvent( { type: EngineEvents.FRAME } );
490
+ return;
491
+
492
+ }
493
+
494
+ this._beginFrame();
495
+
496
+ const cameraMoved = this.needsReset;
497
+ if ( this.needsReset ) {
498
+
499
+ // Before the reset, so the denoising manager's abort sees this frame as part of a move
500
+ // rather than deciding to hold on the first frame and dropping it on the second.
501
+ this.stages.pathTracer?.noteViewChanged();
502
+ this.reset( true );
503
+ this.needsReset = false;
504
+
505
+ }
506
+
507
+ this.camera.updateMatrixWorld();
508
+
509
+ // Raster fallback when path tracer is disabled
510
+ if ( ! this.pathTracerEnabled ) {
511
+
512
+ this.renderer.render( this.meshScene, this.camera );
513
+ this._renderHelperOverlay();
514
+ return;
515
+
516
+ }
517
+
518
+ if ( this.pauseRendering ) return;
519
+
520
+ this._beforeTrace();
521
+
522
+ // Render path tracing
523
+ if ( this.stages.pathTracer?.isReady ) {
524
+
525
+ if ( this.stages.pathTracer.isComplete && this.completion.renderCompleteDispatched ) {
526
+
527
+ if ( this._needsDisplayRefresh ) {
528
+
529
+ this._needsDisplayRefresh = false;
530
+ this.stages.compositor.render( this.pipeline.context );
531
+ this._renderHelperOverlay();
532
+
533
+ }
534
+
535
+ // Stop the loop to avoid constant CPU usage while idle
536
+ this.stopAnimation();
537
+ return;
538
+
539
+ }
540
+
541
+ if ( this._holdTrace( cameraMoved ) ) {
542
+
543
+ // Gizmos and outlines are drawn against the live camera, not the traced frame, so
544
+ // they would visibly lag the view if they only redrew on the frames that traced.
545
+ this._renderHelperOverlay();
546
+ this.dispatchEvent( { type: EngineEvents.FRAME } );
547
+ return;
548
+
549
+ }
550
+
551
+ // A lockstep readback is due and still in flight; the frame would trace nothing.
552
+ if ( this.stages.pathTracer.readbackWait() ) {
553
+
554
+ this.dispatchEvent( { type: EngineEvents.FRAME } );
555
+ return;
556
+
557
+ }
558
+
559
+ this._traceFrame( { liveDenoise: true } );
560
+
561
+ if ( this.stages.pathTracer.isComplete && this.completion.markComplete() ) {
562
+
563
+ this._renderCompleted();
564
+ this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...this._completionInfo() } );
565
+
566
+ }
567
+
568
+ }
569
+
570
+ this._renderHelperOverlay();
571
+ this.dispatchEvent( { type: EngineEvents.FRAME } );
572
+
573
+ }
574
+
575
+ // One frame of a render and its bookkeeping, shared by animate() and renderUntilComplete().
576
+ _traceFrame( { liveDenoise = false } = {} ) {
577
+
578
+ const stage = this.stages.pathTracer;
579
+ this.pipeline.render();
580
+ this._afterTrace( stage, liveDenoise );
581
+ if ( ! stage.isComplete ) this.completion.updateTime();
582
+
583
+ this._ensureVRAMWiring();
584
+ // VRAM is monotonic and only changes on allocation events (scene/env
585
+ // load, resize — each re-measures via _ensureVRAMWiring). Within an
586
+ // accumulation burst nothing reallocates, so re-walking every stage's
587
+ // textures each frame is wasted. Measure at burst start (catches any
588
+ // reset-triggered allocation) + a periodic backstop; read cached otherwise.
589
+ const tracker = stage.vramTracker;
590
+ const frame = stage.frameCount ?? 0;
591
+ if ( tracker && ( frame <= 1 || frame % 30 === 0 ) ) tracker.measure();
592
+
593
+ updateStats( {
594
+ timeElapsed: this.completion.timeElapsed,
595
+ samples: getDisplaySamples( stage ),
596
+ memoryUsed: tracker?.current ?? 0,
597
+ memoryPeak: tracker?.peak ?? 0,
598
+ } );
599
+
600
+ // Only the wall-clock stop — PathTracer.render() retires the ceiling and convergence
601
+ // itself, so whichever of the three arrives first wins.
602
+ if ( this.completion.isTimeLimitReached( stage, this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' ) ) ) {
603
+
604
+ stage.isComplete = true;
605
+
606
+ }
607
+
608
+ }
609
+
610
+ _completionInfo() {
611
+
612
+ return {
613
+ samples: this.stages.pathTracer.frameCount,
614
+ timeElapsed: this.completion.timeElapsed,
615
+ budgetOverrun: this.completion.budgetOverrun,
616
+ // null means isComplete was forced rather than earned (the reconcile below);
617
+ // a forced stop is closer to the ceiling than to convergence.
618
+ reason: this.completion.stopCondition( this.stages.pathTracer ) ?? 'samples',
619
+ };
620
+
621
+ }
622
+
623
+ /**
624
+ * Stops the animation loop.
625
+ */
626
+ stopAnimation() {
627
+
628
+ if ( this.animationManagerId ) {
629
+
630
+ cancelAnimationFrame( this.animationManagerId );
631
+ this.animationManagerId = null;
632
+
633
+ }
634
+
635
+ }
636
+
637
+ /**
638
+ * Handle GPU device loss: halt the render loop and notify hosts so they can surface a
639
+ * "renderer lost — reload" prompt. Full auto-recovery would require rebuilding every GPU
640
+ * resource, so this deliberately stops cleanly rather than attempting to re-init.
641
+ */
642
+ _handleDeviceLost( info ) {
643
+
644
+ if ( this._deviceLost ) return;
645
+ this._deviceLost = true;
646
+ log.error( `WebGPU device lost (${info?.reason || 'unknown'}): ${info?.message || ''}` );
647
+ this.stopAnimation();
648
+ this.dispatchEvent( { type: EngineEvents.DEVICE_LOST, reason: info?.reason, message: info?.message } );
649
+
650
+ }
651
+
652
+ /** Wakes the animation loop if it was stopped due to idle. */
653
+ wake() {
654
+
655
+ if ( this._deviceLost || this._headless ) return;
656
+ if ( ! this.animationManagerId && this.isInitialized && ! this._paused ) this.animate();
657
+
658
+ }
659
+
660
+ /** Pauses the animation loop. */
661
+ pause() {
662
+
663
+ this._paused = true;
664
+ this.stopAnimation();
665
+
666
+ }
667
+
668
+ /** Resumes the animation loop. */
669
+ resume() {
670
+
671
+ this._paused = false;
672
+ if ( ! this.animationManagerId && ! this._headless ) this.animate();
673
+
674
+ }
675
+
676
+ /**
677
+ * Resets the accumulation buffer.
678
+ * @param {boolean} soft - When true, denoisers keep their temporal history
679
+ * @param {Object} [options]
680
+ * @param {boolean} [options.motion] - only object placements or geometry moved; keeps the OIDN
681
+ * motion history, which rejects what moved per pixel
682
+ */
683
+ reset( soft = false, { motion = false } = {} ) {
684
+
685
+ // Objects moving is the view changing, as far as the live denoiser's refresh cadence goes.
686
+ if ( motion ) this.stages.pathTracer?.noteViewChanged();
687
+ this._beforeReset( soft || motion );
688
+
689
+ if ( this.pipeline ) {
690
+
691
+ this.pipeline.reset();
692
+ // History from before a hard restart is not comparable; a soft one keeps it.
693
+ if ( ! soft ) this.pipeline.eventBus.emit( 'pipeline:historyReset' );
694
+
695
+ }
696
+
697
+ this._afterReset();
698
+
699
+ this.completion.reset();
700
+ this.wake();
701
+ this.dispatchEvent( { type: EngineEvents.RENDER_RESET } );
702
+
703
+ }
704
+
705
+ /**
706
+ * Disposes of all resources.
707
+ */
708
+ dispose() {
709
+
710
+ if ( this._disposed ) return;
711
+ this._disposed = true;
712
+
713
+ this.dispatchEvent( { type: EngineEvents.DISPOSE } );
714
+ this.stopAnimation();
715
+ clearTimeout( this._resizeDebounceTimer );
716
+ this._resizeDebounceTimer = null;
717
+
718
+ this._removeTrackedListeners();
719
+ setStatusCallback( null );
720
+
721
+ this._storageRelease?.();
722
+ this._storageRelease = null;
723
+ this.storage = null;
724
+ this._sceneSource = this._sceneSourceFile = null;
725
+
726
+ this._issues.detach(); // onIssue captures `this`; see IssueLog.detach()
727
+
728
+ // Holds the renderer and the issue log, both of which outlive it otherwise.
729
+ this.color?.dispose();
730
+ this.color = null;
731
+ this._textureReadback?.dispose();
732
+ this._textureReadback = null;
733
+ this._readbackToneMapper?.dispose();
734
+ this._readbackToneMapper = null;
735
+
736
+ this._disposeExtensions();
737
+ this.lightManager?.dispose();
738
+
739
+ this.pipeline?.dispose();
740
+
741
+ // _sdf + assetLoader own the heaviest GPU allocations (material texture arrays,
742
+ // BVH/triangle buffers, loaded GLTF resources, BVH refit worker, loader caches).
743
+ // They are not referenced by the pipeline, so pipeline.dispose() does not reach them.
744
+ this._sdf?.dispose();
745
+ this._sdf = null;
746
+
747
+ this.assetLoader?.dispose();
748
+ this.assetLoader = null;
749
+
750
+ if ( this.meshScene ) {
751
+
752
+ this.meshScene.environment?.dispose();
753
+ this.meshScene.environment = null;
754
+
755
+ for ( const child of [ ...this.meshScene.children ] ) {
756
+
757
+ disposeObjectFromMemory( child );
758
+
759
+ }
760
+
761
+ this.meshScene.clear();
762
+ this.meshScene = null;
763
+
764
+ }
765
+
766
+ this._sceneHelpers?.clear();
767
+ this._sceneHelpers = null;
768
+
769
+ this.scene?.clear();
770
+ this.scene = null;
771
+
772
+ // Three.js 0.184 leak (confirmed via heap-snapshot retainer analysis): the
773
+ // Textures manager (one per renderer) registers a per-texture 'dispose'
774
+ // listener that closes over `this = Textures` — which transitively captures
775
+ // backend → renderer. These listeners are removed only when the texture
776
+ // itself is destroyed. For module-level singletons like EmptyTexture (new
777
+ // Texture in TextureNode.js) and its CubeTexture counterpart, the texture is
778
+ // never destroyed, so every renderer ever created leaks through the
779
+ // singleton's listener array.
780
+ //
781
+ // Safe when only a single PathTracerApp is active at a time. If you run
782
+ // multiple in parallel, reset listeners only on the renderer being disposed
783
+ // (not the shared singletons). The sibling _canvasTarget leak is handled by
784
+ // disposeRenderer().
785
+ try {
786
+
787
+ const emptyTex = _tslTexture().value;
788
+ const emptyCube = _tslCubeTexture().value;
789
+ if ( emptyTex?._listeners?.dispose ) emptyTex._listeners.dispose.length = 0;
790
+ if ( emptyCube?._listeners?.dispose ) emptyCube._listeners.dispose.length = 0;
791
+
792
+ } catch ( err ) {
793
+
794
+ log.warn( 'failed to clear TSL texture singleton listeners', err );
795
+
796
+ }
797
+
798
+ // Value of a WeakMap keyed by a canvas that outlives us: without this the whole
799
+ // disposed graph stays reachable until another app claims the same canvas.
800
+ if ( this.canvas && _appsByCanvas.get( this.canvas ) === this ) _appsByCanvas.delete( this.canvas );
801
+
802
+ if ( this._gpuDevice ) {
803
+
804
+ this._gpuDevice.onuncapturederror = null;
805
+ this._gpuDevice = null;
806
+
807
+ }
808
+
809
+ if ( this._deviceLostHolder ) {
810
+
811
+ this._deviceLostHolder.app = null;
812
+ this._deviceLostHolder = null;
813
+
814
+ }
815
+
816
+ disposeRenderer( this.renderer );
817
+ this.renderer = null;
818
+
819
+ this.stages = {};
820
+ this.isInitialized = false;
821
+
822
+ }
823
+
824
+ // ═══════════════════════════════════════════════════════════════
825
+ // Asset Loading
826
+ // ═══════════════════════════════════════════════════════════════
827
+
828
+ /**
829
+ * Tears down the current scene: stops animation, deselects, disposes
830
+ * the loaded model + its GPU resources, clears lights, and seeds the
831
+ * path tracer with an empty scene. Leaves the renderer, pipeline, and
832
+ * managers intact so a subsequent loadModel() can reuse them.
833
+ *
834
+ * Safe to call at any point after init() (including while idle).
835
+ * Throws if called concurrently with a load.
836
+ */
837
+ unloadScene() {
838
+
839
+ if ( ! this.isInitialized ) return;
840
+ if ( this._loadingInProgress ) {
841
+
842
+ throw new Error( 'PathTracerApp.unloadScene: cannot unload while a load is in progress' );
843
+
844
+ }
845
+
846
+ if ( this._disposed ) return;
847
+
848
+ this._releaseSceneState();
849
+
850
+ this.assetLoader?.releaseTargetModel();
851
+ this._sceneSource = this._sceneSourceFile = null;
852
+
853
+ // Clear lights in the WebGPU light scene
854
+ this.lightManager?.clearLights?.();
855
+
856
+ // Seed path tracer with empty data (matches the init-time seed)
857
+ if ( this.stages.pathTracer ) {
858
+
859
+ this.stages.pathTracer.setTriangleData( new Uint32Array( TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE ), 0 );
860
+ this.stages.pathTracer.setBVHData( emptyBVH() );
861
+ this.stages.pathTracer.materialData.setMaterialData( new Float32Array( 16 ) );
862
+ this.stages.pathTracer.setEmissiveTriangleData?.( new Float32Array( 0 ), 0, 0 );
863
+ this.stages.pathTracer.setupMaterial();
864
+
865
+ }
866
+
867
+ this._dropDisplay();
868
+ this.reset();
869
+ this.dispatchEvent( { type: EngineEvents.SCENE_UNLOADED } );
870
+
871
+ }
872
+
873
+ /**
874
+ * Loads a model, builds BVH, and uploads scene data.
875
+ * @param {string} url - Model URL
876
+ * @param {{cacheKey?: string}} [options] - download-cache key for a URL that expires
877
+ */
878
+ async loadModel( url, options = {} ) {
879
+
880
+ await this._loadWithSceneRebuild(
881
+ () => this.assetLoader.loadModel( url, options ),
882
+ { type: EngineEvents.MODEL_LOADED, url },
883
+ { kind: 'url', url, cacheKey: options.cacheKey ?? null }
884
+ );
885
+
886
+ }
887
+
888
+ /**
889
+ * Loads a Three.js Object3D directly into the path tracer scene.
890
+ * Builds BVH from the object's meshes and uploads scene data.
891
+ *
892
+ * Renders a copy: `object3d` is never reparented, rewritten or disposed, so passing a
893
+ * subtree of a scene the host still renders is safe. Geometry/material/texture are shared
894
+ * by reference, and any ancestor transform is baked in. Later edits to `object3d` do not
895
+ * reach the render — mutate {@link sceneModel}, then {@link refitBVH}/{@link refitBLASes}.
896
+ *
897
+ * Lights keep three.js units — `RectAreaLight.intensity` in nits, point/spot in candela — and are
898
+ * converted to radiant power on the copy. `areaLightIntensityScale` does not apply here.
899
+ *
900
+ * @param {import('three').Object3D} object3d - The Object3D to render; left untouched.
901
+ * @param {string} [name='object3d'] - Display name for the object
902
+ */
903
+ async loadObject3D( object3d, name = 'object3d' ) {
904
+
905
+ await this._loadWithSceneRebuild(
906
+ () => this.assetLoader.loadObject3D( object3d, name ),
907
+ { type: EngineEvents.OBJECT3D_LOADED, name },
908
+ { kind: 'object3d', name }
909
+ );
910
+
911
+ }
912
+
913
+ /**
914
+ * A load is already running. Typed so hosts can tell "you clicked too fast" apart from
915
+ * a genuine load failure and say so, instead of dropping the request on the floor.
916
+ * @private
917
+ */
918
+ _busyError( where ) {
919
+
920
+ const error = new Error( `${where}: another load is already in progress` );
921
+ error.code = 'LOAD_IN_PROGRESS';
922
+ return error;
923
+
924
+ }
925
+
926
+ /**
927
+ * Loads an environment map and rebuilds CDF.
928
+ * @param {string} url - Environment URL
929
+ */
930
+ async loadEnvironment( url ) {
931
+
932
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp.loadEnvironment' );
933
+
934
+ this._loadingInProgress = true;
935
+
936
+ try {
937
+
938
+ await this.assetLoader.loadEnvironment( url );
939
+
940
+ const environmentTexture = this.meshScene.environment;
941
+ if ( environmentTexture && this.stages.pathTracer ) {
942
+
943
+ await this.stages.pathTracer.environment.setEnvironmentMap( environmentTexture );
944
+
945
+ }
946
+
947
+ this.pipeline?.eventBus.emit( 'pipeline:lightingChanged' );
948
+ this.reset();
949
+ this.dispatchEvent( { type: EngineEvents.ENVIRONMENT_LOADED, url } );
950
+
951
+ } finally {
952
+
953
+ this._loadingInProgress = false;
954
+
955
+ }
956
+
957
+ }
958
+
959
+ /**
960
+ * Lists the independently loadable parts of an archive without unpacking it, so a host
961
+ * can offer a choice for a scene too large to load whole.
962
+ * @param {File} file
963
+ * @returns {Promise<{kind:string, root:string|null, elements:Array, entryCount:number, totalBytes:number}>}
964
+ */
965
+ async inspectArchive( file ) {
966
+
967
+ return await this.assetLoader.inspectArchive( file );
968
+
969
+ }
970
+
971
+ /**
972
+ * Loads a user-supplied File (drag-drop, file picker) — model, archive, or environment
973
+ * map, dispatched by extension.
974
+ *
975
+ * Prefer this over driving `assetLoader.loadAssetFromFile()` directly. That bypasses the
976
+ * in-progress guard, and AssetLoader disposes the outgoing model before it knows whether
977
+ * the engine will rebuild: a file dropped mid-load would tear down the live scene, add the
978
+ * new one to the graph, and then be discarded by the `load` handler — leaving the path
979
+ * tracer rendering buffers whose geometry has been freed. Here a concurrent call throws
980
+ * LOAD_IN_PROGRESS before anything is touched.
981
+ *
982
+ * @param {File|string} file - a File, or a URL to download (through the download cache)
983
+ * @param {object} [options] - forwarded to the archive loader: `element` to load one
984
+ * subtree of a multi-part scene, `pbrtEntry` to choose among several .pbrt scenes. For a
985
+ * URL also `filename` and `cacheKey` (see AssetLoader.loadAssetFromUrl).
986
+ * @returns {Promise<void>}
987
+ */
988
+ async loadFile( file, options = {} ) {
989
+
990
+ options = this._sceneBudgets( options );
991
+
992
+ if ( typeof file === 'string' ) {
993
+
994
+ const filename = options.filename ?? nameFromUrl( file );
995
+ const format = this.assetLoader?.getFileFormat( filename );
996
+ if ( ! format ) throw this.assetLoader.formatError( filename );
997
+ if ( format.type === 'environment' || format.type === 'image' ) return this.loadEnvironment( file );
998
+
999
+ await this._loadWithSceneRebuild(
1000
+ () => this.assetLoader.loadAssetFromUrl( file, { ...options, filename } ),
1001
+ { type: EngineEvents.MODEL_LOADED, filename },
1002
+ { kind: 'url', url: file, filename, cacheKey: options.cacheKey ?? null, ...partOf( options ) }
1003
+ );
1004
+ return;
1005
+
1006
+ }
1007
+
1008
+ const format = this.assetLoader?.getFileFormat( file?.name || '' );
1009
+ if ( ! format ) throw this.assetLoader.formatError( file?.name || '' );
1010
+
1011
+ if ( format.type !== 'environment' && format.type !== 'image' ) {
1012
+
1013
+ const identity = fileIdentity( file ).catch( () => ( { name: file.name, size: file.size, lastModified: file.lastModified ?? 0, sample: null } ) );
1014
+ await this._loadWithSceneRebuild(
1015
+ () => this.assetLoader.loadAssetFromFile( file, options ),
1016
+ { type: EngineEvents.MODEL_LOADED, filename: file.name },
1017
+ async () => {
1018
+
1019
+ const id = await identity;
1020
+ return { kind: 'local-file', file: id, key: id.sample ? identityKey( id ) : null, ...partOf( options ) };
1021
+
1022
+ }
1023
+ );
1024
+ this._sceneSourceFile = file;
1025
+ return;
1026
+
1027
+ }
1028
+
1029
+ // Environment drop: install it, no scene rebuild.
1030
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp.loadFile' );
1031
+
1032
+ this._loadingInProgress = true;
1033
+
1034
+ try {
1035
+
1036
+ await this.assetLoader.loadAssetFromFile( file );
1037
+
1038
+ const texture = this.meshScene.environment;
1039
+ if ( texture && this.stages.pathTracer ) {
1040
+
1041
+ await this.stages.pathTracer.environment.applyHDRI( texture );
1042
+
1043
+ }
1044
+
1045
+ this.pipeline?.eventBus.emit( 'pipeline:lightingChanged' );
1046
+ this.reset();
1047
+ this.dispatchEvent( { type: EngineEvents.ENVIRONMENT_LOADED, filename: file.name } );
1048
+
1049
+ } finally {
1050
+
1051
+ this._loadingInProgress = false;
1052
+
1053
+ }
1054
+
1055
+ }
1056
+
1057
+ /**
1058
+ * Loads example models by index.
1059
+ * @param {number} index
1060
+ * @param {Array} modelFiles
1061
+ */
1062
+ async loadExampleModels( index, modelFiles ) {
1063
+
1064
+ await this._loadWithSceneRebuild(
1065
+ () => this.assetLoader.loadExampleModels( index, modelFiles ),
1066
+ { type: EngineEvents.MODEL_LOADED, index },
1067
+ { kind: 'url', url: modelFiles[ index ]?.url ?? null, cacheKey: null }
1068
+ );
1069
+
1070
+ }
1071
+
1072
+ /**
1073
+ * Cancel the in-flight model/environment download, if any. The active
1074
+ * loadAsync() rejects with a typed LOAD_CANCELLED error, which callers treat
1075
+ * as a user cancellation (not a load failure). Only the network-download phase
1076
+ * is cancelable — once processing (BVH/textures) has started this is a no-op.
1077
+ * The scene is untouched: replace-loads release the old model only after the
1078
+ * download succeeds, and appends parent nothing until then.
1079
+ */
1080
+ cancelLoad() {
1081
+
1082
+ if ( ! this._loadingInProgress ) return;
1083
+ this.assetLoader?.cancelActiveLoad();
1084
+
1085
+ }
1086
+
1087
+ /**
1088
+ * Set the max material-texture dimension (longest edge) used when processing a
1089
+ * scene's textures into GPU arrays. Clamped to the hardware ceiling. Larger =
1090
+ * sharper textures, ~quadratic VRAM. By default reprocesses the current scene so
1091
+ * the change is visible without a manual reload.
1092
+ * @param {number} size
1093
+ * @param {Object} [opts]
1094
+ * @param {boolean} [opts.reprocess=true] - Rebuild the current scene now.
1095
+ * @returns {Promise<void>}
1096
+ */
1097
+ async setMaxTextureSize( size, { reprocess = true } = {} ) {
1098
+
1099
+ const prev = this._maxTextureSize;
1100
+ const clamped = this._sdf?.setMaxTextureSize( size );
1101
+ this._maxTextureSize = clamped ?? size;
1102
+ if ( typeof this.stages?.pathTracer?.sdfs?.setMaxTextureSize === 'function' ) {
1103
+
1104
+ this.stages.pathTracer.sdfs.setMaxTextureSize( this._maxTextureSize );
1105
+
1106
+ }
1107
+
1108
+ // Reprocess the loaded scene so the new cap takes effect immediately.
1109
+ if ( reprocess && this._maxTextureSize !== prev && this._sdf?.triangles && ! this._loadingInProgress ) {
1110
+
1111
+ this._loadingInProgress = true;
1112
+ try {
1113
+
1114
+ await this.loadSceneData();
1115
+ this.reset();
1116
+ this.dispatchEvent( { type: EngineEvents.TEXTURES_REPROCESSED, maxTextureSize: this._maxTextureSize } );
1117
+
1118
+ } finally {
1119
+
1120
+ this._loadingInProgress = false;
1121
+
1122
+ }
1123
+
1124
+ }
1125
+
1126
+ }
1127
+
1128
+ /**
1129
+ * Where the model on screen came from, as plain data — what a saved session reopens:
1130
+ * `{ kind: 'url', url, cacheKey, filename?, element?, pbrtEntry? }`,
1131
+ * `{ kind: 'local-file', file: {name, size, lastModified, sample}, key, element?, pbrtEntry? }` or
1132
+ * `{ kind: 'object3d', name }`. Null before a load and after {@link unloadScene}.
1133
+ * @returns {?Object}
1134
+ */
1135
+ get sceneSource() {
1136
+
1137
+ return this._sceneSource ?? null;
1138
+
1139
+ }
1140
+
1141
+ /** The File a local load came from, so a host can save a project that carries it; null otherwise. */
1142
+ get sceneSourceFile() {
1143
+
1144
+ return this._sceneSourceFile ?? null;
1145
+
1146
+ }
1147
+
1148
+ /** Shared pipeline: load asset → sync controls → build BVH → reset → dispatch events */
1149
+ async _loadWithSceneRebuild( loadFn, eventPayload, source = null ) {
1150
+
1151
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp' );
1152
+
1153
+ this._loadingInProgress = true;
1154
+
1155
+ try {
1156
+
1157
+ await loadFn();
1158
+ this._sceneSource = typeof source === 'function' ? await source() : source;
1159
+ this._sceneSourceFile = null;
1160
+ // A fresh model re-establishes the emissive-sampling auto-default (incremental
1161
+ // rebuilds — add/remove object, texture reprocess — preserve the user's choice).
1162
+ this._emissiveSamplingUserSet = false;
1163
+ // Replace-load clears any dynamically-appended models — but only AFTER
1164
+ // loadFn() succeeds, so a failed load leaves the current scene intact.
1165
+ // (The old primary was already released by releaseTargetModel() in loadFn.)
1166
+ this._clearAppendedModels();
1167
+ this._modelReplaced();
1168
+ await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
1169
+ this._maybeSpill();
1170
+ this.pipeline?.eventBus.emit( 'pipeline:lightingChanged' );
1171
+ // Not held: the first denoise of a new scene lands ~1.1 s after the load (upload and
1172
+ // shader compilation come first), and a second of the previous model reads as a bug.
1173
+ this._dropDisplay();
1174
+ this.reset();
1175
+ this.dispatchEvent( eventPayload );
1176
+ this._modelAnnounced();
1177
+
1178
+ } catch ( error ) {
1179
+
1180
+ // loadFn released the previous model before this one was known to be loadable, so
1181
+ // there is nothing to fall back to. Leaving it half-built would keep the last
1182
+ // frame's buffers on screen under a scene that no longer exists.
1183
+ if ( ! error || error.code !== 'LOAD_IN_PROGRESS' ) this._discardFailedLoad();
1184
+
1185
+ // Only the scene build reported its own failure; one in the parse left a host's loading
1186
+ // status spinning on its last step. A part prompt is not a failure, so it just ends it.
1187
+ if ( error?.code === 'ARCHIVE_NEEDS_ELEMENT' ) resetLoading();
1188
+ else if ( error?.code !== 'LOAD_IN_PROGRESS' ) updateLoading( { status: `Error: ${error?.message ?? error}`, failed: true, progress: 100 } );
1189
+ throw error;
1190
+
1191
+ } finally {
1192
+
1193
+ this._loadingInProgress = false;
1194
+
1195
+ }
1196
+
1197
+ }
1198
+
1199
+ /**
1200
+ * Put the engine back to an empty scene after a load failed part-way. The failure itself is
1201
+ * rethrown for the host to report; this only makes sure what is on screen matches it.
1202
+ * @private
1203
+ */
1204
+ _discardFailedLoad() {
1205
+
1206
+ this._sceneSource = this._sceneSourceFile = null;
1207
+
1208
+ try {
1209
+
1210
+ this._clearAppendedModels();
1211
+ this.assetLoader?.releaseTargetModel();
1212
+ this._dropDisplay();
1213
+ this.reset();
1214
+
1215
+ } catch ( cleanupError ) {
1216
+
1217
+ console.warn( 'PathTracerApp: could not clear the scene after a failed load', cleanupError );
1218
+
1219
+ }
1220
+
1221
+ }
1222
+
1223
+ /**
1224
+ * Scene-level authoring metadata carried by the current model file (glTF `extras`),
1225
+ * or null when the file has none. See {@link module:Processor/SceneMetadata}. A pbrt scene
1226
+ * without an infinite light carries `environment: { enabled: false }`.
1227
+ * @type {{ environment?: { sourceFile?: string, rotation?: number, intensity?: number, enabled?: boolean } }|null}
1228
+ */
1229
+ get sceneMetadata() {
1230
+
1231
+ return this.assetLoader?.sceneMetadata ?? null;
1232
+
1233
+ }
1234
+
1235
+ /**
1236
+ * Starts fetching the environment authored into the just-loaded model's metadata and
1237
+ * returns a promise for the texture — deliberately NOT awaited here. The fetch is the
1238
+ * longest single step of such a load (measured 1448 ms for a 1k HDRI against 325 ms to
1239
+ * build and upload a 112k-triangle scene), so the promise is handed to loadSceneData(),
1240
+ * which installs it and builds its CDF while the BVH builds.
1241
+ *
1242
+ * Called from both replace-load seams: the URL/catalog path (_loadWithSceneRebuild) and
1243
+ * the drag-drop / File-open path, which drives the AssetLoader directly and rebuilds via
1244
+ * the 'load' event (_onAssetLoaded). Appending a model never applies it — an appended
1245
+ * model must not hijack the scene's environment.
1246
+ *
1247
+ * Resolves to null rather than rejecting: a failed fetch leaves the model on the current
1248
+ * environment. Returns null outright when there is nothing to apply. Under `strict` the
1249
+ * recorded issue throws instead, and the rejection surfaces where loadSceneData awaits it.
1250
+ * Opt out per app with `new PathTracerApp( canvas, { applySceneMetadata: false } )`.
1251
+ *
1252
+ * @returns {Promise<import('three').Texture|null>|null}
1253
+ */
1254
+ _beginSceneMetadataEnvironment() {
1255
+
1256
+ this._restoreUnlitSettings();
1257
+ const env = this.sceneMetadata?.environment;
1258
+ if ( this._applySceneMetadataEnabled && env?.enabled === false ) {
1259
+
1260
+ this._applyUnlitSettings();
1261
+ return null;
1262
+
1263
+ }
1264
+
1265
+ if ( ! this._applySceneMetadataEnabled || ! env?.sourceFile || ! this.stages.pathTracer ) return null;
1266
+
1267
+ // Claim 'hdri' mode up front: loadEnvironment() fires beforeEnvironmentLoad, and a
1268
+ // host that answers it with setMode( 'hdri' ) would otherwise restore the HDRI
1269
+ // stashed by the current sky mode on top of the download now in flight.
1270
+ this.stages.pathTracer.environment.beginHDRI();
1271
+ this._applyingSceneMetadata = true;
1272
+
1273
+ return this.assetLoader.loadEnvironment( env.sourceFile )
1274
+ .catch( error => {
1275
+
1276
+ this._issues.record(
1277
+ ISSUE_CODES.ENVIRONMENT_LOAD_FAILED,
1278
+ `authored environment "${env.sourceFile}" failed to load — lighting falls back to the previous environment`,
1279
+ { sourceFile: env.sourceFile, cause: String( error?.message ?? error ) }
1280
+ );
1281
+ return null;
1282
+
1283
+ } )
1284
+ .finally( () => {
1285
+
1286
+ this._applyingSceneMetadata = false;
1287
+
1288
+ } );
1289
+
1290
+ }
1291
+
1292
+ /**
1293
+ * Mirrors the authored environment onto the settings once its texture is installed.
1294
+ * The authored env is both the light and the backdrop, so the backdrop axis
1295
+ * (showBackground / transparentBackground) is forced to the image.
1296
+ * @private
1297
+ */
1298
+ _applySceneMetadataSettings( texture ) {
1299
+
1300
+ const metadata = this.sceneMetadata;
1301
+ const env = metadata?.environment;
1302
+ if ( ! env || env.enabled === false ) return;
1303
+
1304
+ const updates = { enableEnvironment: true, showBackground: true, transparentBackground: false };
1305
+ if ( env.intensity !== undefined ) {
1306
+
1307
+ updates.environmentIntensity = env.intensity;
1308
+ updates.backgroundIntensity = env.intensity;
1309
+
1310
+ }
1311
+
1312
+ if ( env.rotation !== undefined ) updates.environmentRotation = env.rotation;
1313
+
1314
+ this.settings.setMany( updates, { reset: false, source: SETTING_SOURCE.SCENE_METADATA } );
1315
+ if ( this.scene ) this.scene.background = texture;
1316
+
1317
+ this.dispatchEvent( { type: EngineEvents.SCENE_METADATA_APPLIED, metadata, environment: { ...env } } );
1318
+
1319
+ }
1320
+
1321
+ /**
1322
+ * A scene that has no environment (a pbrt scene without an infinite light) renders with none: no light
1323
+ * from it and a plain backdrop. The next model gets back what it replaced, unless someone changed it since.
1324
+ * @private
1325
+ */
1326
+ _applyUnlitSettings() {
1327
+
1328
+ const effective = this.settings.getEffective();
1329
+ const updates = { enableEnvironment: false, showBackground: false };
1330
+ this._unlitRestore = Object.fromEntries( Object.keys( updates ).map( key => [ key, { value: effective[ key ].value, source: effective[ key ].source } ] ) );
1331
+ this.settings.setMany( updates, { reset: false, source: SETTING_SOURCE.SCENE_METADATA } );
1332
+ this.dispatchEvent( { type: EngineEvents.SCENE_METADATA_APPLIED, metadata: this.sceneMetadata, environment: { enabled: false } } );
1333
+
1334
+ }
1335
+
1336
+ /** @private */
1337
+ _restoreUnlitSettings() {
1338
+
1339
+ const saved = this._unlitRestore;
1340
+ if ( ! saved ) return;
1341
+ this._unlitRestore = null;
1342
+ const effective = this.settings.getEffective();
1343
+ for ( const [ key, { value, source } ] of Object.entries( saved ) ) {
1344
+
1345
+ if ( effective[ key ]?.source === SETTING_SOURCE.SCENE_METADATA ) this.settings.set( key, value, { reset: false, source } );
1346
+
1347
+ }
1348
+
1349
+ this.dispatchEvent( { type: EngineEvents.SCENE_METADATA_APPLIED, metadata: this.sceneMetadata, environment: { enabled: this.settings.get( 'enableEnvironment' ) } } );
1350
+
1351
+ }
1352
+
1353
+ /**
1354
+ * Builds BVH from meshScene and uploads all scene data to the path tracer.
1355
+ * @param {Object} [options]
1356
+ * @param {Promise<import('three').Texture|null>} [options.pendingEnvironment] - An
1357
+ * environment still being fetched (see _beginSceneMetadataEnvironment). Installed and
1358
+ * CDF'd concurrently with the BVH build rather than before it.
1359
+ * @returns {boolean}
1360
+ */
1361
+ async loadSceneData( { pendingEnvironment = null, keepUserLights = false } = {} ) {
1362
+
1363
+ // The append path (addModel) keeps its selection: the selected object persists.
1364
+ this._releaseSceneState( { keepSelection: !! this._preserveSelectionOnRebuild } );
1365
+
1366
+ // Tag the primary (replace-loaded) model so it appears in the scene-object list.
1367
+ this._tagPrimarySceneObject();
1368
+
1369
+ const timer = new BuildTimer( '', { namespace: 'scene', level: 'info' } );
1370
+ // meshScene.environment is the HDRI slot: a sky or colour in use outlives the model.
1371
+ const hdri = () => this.stages.pathTracer.environment.envParams.mode === 'hdri' ? this.meshScene.environment : null;
1372
+ const environmentTexture = hdri();
1373
+
1374
+ // Environment CDF build in parallel with BVH
1375
+ let cdfPromise = null;
1376
+ if ( pendingEnvironment ) {
1377
+
1378
+ // The authored environment is still downloading. Install + CDF it off to the side so
1379
+ // the fetch overlaps the BVH build below instead of gating it.
1380
+ timer.start( 'Environment fetch + CDF (concurrent)' );
1381
+ cdfPromise = pendingEnvironment
1382
+ .then( async texture => {
1383
+
1384
+ if ( ! texture ) return;
1385
+ await this.stages.pathTracer.environment.applyHDRI( texture );
1386
+ this._applySceneMetadataSettings( texture );
1387
+
1388
+ } )
1389
+ .catch( error => this._issues.record(
1390
+ ISSUE_CODES.ENVIRONMENT_LOAD_FAILED,
1391
+ 'authored environment failed to install — the scene is lit by whatever was already loaded',
1392
+ { cause: String( error?.message ?? error ) }
1393
+ ) )
1394
+ .finally( () => timer.end( 'Environment fetch + CDF (concurrent)' ) );
1395
+
1396
+ } else if ( environmentTexture?.image?.data ) {
1397
+
1398
+ timer.start( 'Environment CDF build (worker)' );
1399
+ this.stages.pathTracer.scene.environment = environmentTexture;
1400
+ cdfPromise = this.stages.pathTracer.environment.buildEnvironmentCDF()
1401
+ .then( () => {
1402
+
1403
+ timer.end( 'Environment CDF build (worker)' );
1404
+ // A loader can install the environment itself and still author how it should
1405
+ // be read — a pbrt scene bakes the light's transform in and needs rotation 0.
1406
+ if ( this._applySceneMetadataEnabled ) this._applySceneMetadataSettings( environmentTexture );
1407
+
1408
+ } );
1409
+
1410
+ }
1411
+
1412
+ // Build BVH
1413
+ timer.start( 'BVH build (SceneProcessor)' );
1414
+ this._sdf.setMaxTextureSize( this._maxTextureSize );
1415
+ await this._sdf.buildBVH( this.meshScene, { sceneKey: this.assetLoader?.sceneSourceKey ?? null, progressive: this._progressiveSpill() } );
1416
+ this.assetLoader?.flushPendingGraph( this._sdf.performanceMetrics.totalProcessingTime );
1417
+ timer.end( 'BVH build (SceneProcessor)' );
1418
+
1419
+ // Transfer geometry, materials, and textures to GPU
1420
+ updateLoading( { status: "Transferring data to GPU...", progress: 86 } );
1421
+ await new Promise( r => setTimeout( r, 0 ) );
1422
+ timer.start( 'GPU data transfer' );
1423
+
1424
+ // Re-read rather than reusing the snapshot above: a pendingEnvironment may have landed
1425
+ // during the BVH build, and the snapshot is then a disposed texture.
1426
+ if ( ! this._sdf.uploadToPathTracer( this.stages.pathTracer, this.lightManager, this.meshScene, hdri(), { keepUserLights } ) ) return false;
1427
+
1428
+ // Patch per-mesh visibility into the TLAS leaves we just uploaded
1429
+ this.stages.pathTracer._meshRefs = this.stages.pathTracer._collectMeshRefs( this.meshScene );
1430
+ this.stages.pathTracer.setMeshVisibilityData( this.stages.pathTracer._meshRefs );
1431
+
1432
+ // Drop authored-hidden meshes' triangles from the emissive-NEE structure
1433
+ // (runs before setupMaterial so the kernels compile against the final buffer)
1434
+ this._refreshEmissiveForVisibility();
1435
+
1436
+ timer.end( 'GPU data transfer' );
1437
+
1438
+ // Compile shaders
1439
+ updateLoading( { status: "Compiling shaders...", progress: 90 } );
1440
+ await new Promise( r => setTimeout( r, 0 ) );
1441
+ timer.start( 'Material setup (TSL compile)' );
1442
+ this.stages.pathTracer.setupMaterial();
1443
+ timer.end( 'Material setup (TSL compile)' );
1444
+
1445
+ this._rasterPrecompile = null;
1446
+
1447
+ if ( ! this._pathTracerEnabled ) {
1448
+
1449
+ timer.start( 'Pipeline precompile' );
1450
+ await this.precompileRaster();
1451
+ timer.end( 'Pipeline precompile' );
1452
+
1453
+ }
1454
+
1455
+ // Wait for CDF
1456
+ if ( cdfPromise ) {
1457
+
1458
+ updateLoading( { status: "Finalizing environment map...", progress: 95 } );
1459
+ await cdfPromise;
1460
+ this.stages.pathTracer.environment.applyCDFResults();
1461
+
1462
+ }
1463
+
1464
+ // Seed the ground-projection plane AND the shadow-catcher plane to the scene floor so models
1465
+ // that aren't authored at y=0 sit on the ground (not sunk) — auto-updates on every model change.
1466
+ const sceneMinY = this.getSceneMinY();
1467
+ this.settings.set( 'groundProjectionLevel', sceneMinY, { reset: false } );
1468
+ this.settings.set( 'groundCatcherHeight', sceneMinY, { reset: false } );
1469
+
1470
+ // Auto-follow the scene: enable emissive-triangle NEE when the scene has emissive
1471
+ // geometry, disable it when it doesn't — unless the user set the toggle explicitly.
1472
+ // Runs before applyAll()/SceneRebuild so the uniform and UI both pick up the new value.
1473
+ if ( ! this._emissiveSamplingUserSet ) {
1474
+
1475
+ // Keyed on the canonical (unfiltered) emissive set — emissiveTriangleCount
1476
+ // reflects only the visible subset, and hidden emitters can be shown later.
1477
+ const hasEmissive = ( this._sdf?.emissiveTriangleBuilder?.emissiveTriangles?.length
1478
+ ?? this._sdf?.emissiveTriangleCount ?? 0 ) > 0;
1479
+ this.settings.set( 'enableEmissiveTriangleSampling', hasEmissive, { reset: false } );
1480
+
1481
+ }
1482
+
1483
+ // Apply all settings to stages in one shot
1484
+ timer.start( 'Apply settings' );
1485
+ this.settings.applyAll();
1486
+ this.stages.compositor.setTransparentBackground( this.settings.get( 'transparentBackground' ) );
1487
+ timer.end( 'Apply settings' );
1488
+
1489
+ timer.print( this._sceneSummaryParts() );
1490
+ resetLoading();
1491
+
1492
+ this._sceneRebuilt();
1493
+
1494
+ this.dispatchEvent( { type: EngineEvents.SCENE_REBUILD } );
1495
+ return true;
1496
+
1497
+ }
1498
+
1499
+ /** Counts for the single `[scene]` summary line emitted after a scene build. */
1500
+ _sceneSummaryParts() {
1501
+
1502
+ const pt = this.stages.pathTracer;
1503
+ const meshes = this._sdf?.instanceTable?.setCount ?? 0;
1504
+ const maps = this._sdf?.geometryExtractor?.maps?.length ?? 0;
1505
+
1506
+ return [
1507
+ fmt.count( pt.triangleCount, 'tri' ),
1508
+ meshes ? fmt.count( meshes, 'mesh', 'meshes' ) : null,
1509
+ fmt.count( pt.materialData.materialCount, 'material' ),
1510
+ maps ? fmt.count( maps, 'map' ) : null,
1511
+ fmt.count( pt.bvhNodeCount, 'BVH node' ),
1512
+ ];
1513
+
1514
+ }
1515
+
1516
+ // ═══════════════════════════════════════════════════════════════
1517
+ // Dynamic scene objects (add / remove / list / visibility)
1518
+ //
1519
+ // Top-level objects are the auto-created "Ground" plane plus each loaded
1520
+ // model root parented into meshScene. The scene graph + per-root userData
1521
+ // tags are the single source of truth (no separate registry): ids are
1522
+ // Object3D uuids (stable across rebuilds, since the same root persists).
1523
+ // ═══════════════════════════════════════════════════════════════
1524
+
1525
+ /** Tag the primary (replace-loaded) model as a removable scene object (read by the Outliner + removeSceneObject). Idempotent. */
1526
+ _tagPrimarySceneObject() {
1527
+
1528
+ const m = this.sceneModel;
1529
+ if ( ! m ) return;
1530
+ m.userData.__rayzeeSceneObject = true;
1531
+
1532
+ }
1533
+
1534
+ /** Remove + dispose all dynamically-appended models (keeps Ground and the primary). */
1535
+ _clearAppendedModels() {
1536
+
1537
+ const scene = this.meshScene;
1538
+ if ( ! scene ) return;
1539
+ const floor = this.assetLoader?.floorPlane;
1540
+ const primary = this.sceneModel;
1541
+ for ( const child of [ ...scene.children ] ) {
1542
+
1543
+ if ( child === floor || child === primary ) continue;
1544
+ if ( ! child.userData?.__rayzeeSceneObject ) continue;
1545
+ this.assetLoader.removeModelRoot( child );
1546
+
1547
+ }
1548
+
1549
+ }
1550
+
1551
+ /** Reframe-free rebuild sequence. Assumes the _loadingInProgress guard is already held. */
1552
+ async _finishRebuildNoReframe( eventPayload ) {
1553
+
1554
+ await this.loadSceneData( { keepUserLights: true } ); // emits 'SceneRebuild'
1555
+ this._maybeSpill();
1556
+ this._sceneBoundsChanged();
1557
+ this.pipeline?.eventBus.emit( 'pipeline:lightingChanged' );
1558
+ this.reset();
1559
+ if ( eventPayload ) this.dispatchEvent( eventPayload );
1560
+
1561
+ }
1562
+
1563
+ /**
1564
+ * Append a model by URL to the current scene (does NOT replace it), then rebuild
1565
+ * without reframing the camera.
1566
+ * @param {string} url
1567
+ * @param {Object} [opts]
1568
+ * @param {string} [opts.name] - Display name for the scene-object list.
1569
+ * @returns {Promise<string>} the new object's id (Object3D uuid).
1570
+ */
1571
+ async addModel( url, { name, cacheKey } = {} ) {
1572
+
1573
+ if ( this._loadingInProgress ) {
1574
+
1575
+ throw new Error( 'PathTracerApp.addModel: another load is already in progress' );
1576
+
1577
+ }
1578
+
1579
+ this._loadingInProgress = true;
1580
+ this._preserveSelectionOnRebuild = true;
1581
+ try {
1582
+
1583
+ const { root } = await this.assetLoader.appendModel( url, { cacheKey } );
1584
+ root.userData.__rayzeeSceneObject = true;
1585
+ root.userData.__rayzeeSourceUrl = url;
1586
+ if ( cacheKey ) root.userData.__rayzeeCacheKey = cacheKey;
1587
+ if ( name ) root.userData.__rayzeeName = name;
1588
+ await this._finishRebuildNoReframe( { type: EngineEvents.MODEL_ADDED, url, id: root.uuid } );
1589
+ return root.uuid;
1590
+
1591
+ } finally {
1592
+
1593
+ this._preserveSelectionOnRebuild = false;
1594
+ this._loadingInProgress = false;
1595
+
1596
+ }
1597
+
1598
+ }
1599
+
1600
+ /**
1601
+ * Append a caller-owned Object3D to the current scene, then rebuild (no reframe).
1602
+ * Appends a copy; the object passed in is never mutated. Same rules as {@link loadObject3D}.
1603
+ * @param {import('three').Object3D} object3d
1604
+ * @param {Object} [opts]
1605
+ * @param {string} [opts.name]
1606
+ * @returns {Promise<string>} the new object's id (Object3D uuid).
1607
+ */
1608
+ async addModelFromObject3D( object3d, { name } = {} ) {
1609
+
1610
+ if ( this._loadingInProgress ) {
1611
+
1612
+ throw new Error( 'PathTracerApp.addModelFromObject3D: another load is already in progress' );
1613
+
1614
+ }
1615
+
1616
+ this._loadingInProgress = true;
1617
+ this._preserveSelectionOnRebuild = true;
1618
+ try {
1619
+
1620
+ const { root } = this.assetLoader.appendObject3D( object3d, name || 'object3d' );
1621
+ root.userData.__rayzeeSceneObject = true;
1622
+ if ( name ) root.userData.__rayzeeName = name;
1623
+ await this._finishRebuildNoReframe( { type: EngineEvents.MODEL_ADDED, id: root.uuid } );
1624
+ return root.uuid;
1625
+
1626
+ } finally {
1627
+
1628
+ this._preserveSelectionOnRebuild = false;
1629
+ this._loadingInProgress = false;
1630
+
1631
+ }
1632
+
1633
+ }
1634
+
1635
+ /**
1636
+ * Remove a scene object by id (Object3D uuid). The Ground plane is permanent.
1637
+ * @param {string} id
1638
+ * @returns {Promise<boolean>} true if removed.
1639
+ */
1640
+ async removeSceneObject( id ) {
1641
+
1642
+ const scene = this.meshScene;
1643
+ if ( ! scene ) return false;
1644
+
1645
+ const floor = this.assetLoader?.floorPlane;
1646
+ if ( floor && floor.uuid === id ) return false; // Ground is not deletable
1647
+
1648
+ const root = this.getSceneObject( id );
1649
+ if ( ! root ) return false;
1650
+
1651
+ if ( this._loadingInProgress ) {
1652
+
1653
+ throw new Error( 'PathTracerApp.removeSceneObject: another load is already in progress' );
1654
+
1655
+ }
1656
+
1657
+ this._loadingInProgress = true;
1658
+ try {
1659
+
1660
+ this._releaseSceneState();
1661
+
1662
+ if ( root === this.sceneModel ) {
1663
+
1664
+ this.assetLoader.releaseTargetModel();
1665
+
1666
+ } else {
1667
+
1668
+ this.assetLoader.removeModelRoot( root );
1669
+
1670
+ }
1671
+
1672
+ // Ground is permanent (removal refused above), so the scene always keeps
1673
+ // renderable geometry — a full rebuild is always valid here.
1674
+ await this._finishRebuildNoReframe( { type: EngineEvents.SCENE_OBJECT_REMOVED, id } );
1675
+
1676
+ return true;
1677
+
1678
+ } finally {
1679
+
1680
+ this._loadingInProgress = false;
1681
+
1682
+ }
1683
+
1684
+ }
1685
+
1686
+ /**
1687
+ * Toggle a scene object's visibility without rebuilding (O(1) TLAS-leaf patch).
1688
+ * @param {string} id - Object3D uuid.
1689
+ * @param {boolean | ((prev:boolean)=>boolean)} visible
1690
+ * @returns {boolean|null} new visibility, or null if not found.
1691
+ */
1692
+ setSceneObjectVisibility( id, visible ) {
1693
+
1694
+ return this.setMeshVisibilityByUuid( id, visible );
1695
+
1696
+ }
1697
+
1698
+ // ═══════════════════════════════════════════════════════════════
1699
+ // Dynamic cameras (add / remove)
1700
+ // ═══════════════════════════════════════════════════════════════
1701
+
1702
+ /** Triangle and placement budgets for a scene archive: raised when the build can spill. @private */
1703
+ _sceneBudgets( options ) {
1704
+
1705
+ if ( ! this._memorySpill || ! this.storage ) return options;
1706
+ return { maxTriangles: SPILL_TRIANGLE_BUDGET, maxPlacements: SPILL_PLACEMENT_BUDGET, ...options };
1707
+
1708
+ }
1709
+
1710
+ /**
1711
+ * Experimental memory spill, during the build: static scenes only, since a deforming clip
1712
+ * refits every frame. @private
1713
+ */
1714
+ _progressiveSpill() {
1715
+
1716
+ if ( ! this._memorySpill || ! this.storage || ( this.assetLoader?.animations?.length ?? 0 ) > 0 ) return null;
1717
+ const stage = this.stages.pathTracer;
1718
+ return { storage: this.storage, uploader: ( records, kind ) => kind === 'triangles' ? stage.createTriangleChunkUploader( records ) : stage.createChunkUploader( records ) };
1719
+
1720
+ }
1721
+
1722
+ /** Experimental memory spill for a static scene, in the background. @private */
1723
+ _maybeSpill() {
1724
+
1725
+ if ( ! this._memorySpill || ! this.storage || ( this.assetLoader?.animations?.length ?? 0 ) > 0 ) return;
1726
+ this._sdf?.spillToDisk( this.storage )
1727
+ .then( result => result && this.dispatchEvent( { type: EngineEvents.SCENE_SPILLED, ...result } ) )
1728
+ .catch( error => this._issues.warn( ISSUE_CODES.STORAGE_WRITE_FAILED, `memory spill failed: ${error.message}` ) );
1729
+
1730
+ }
1731
+
1732
+ /**
1733
+ * Reads back what {@link options.memorySpill} moved to disk. The engine does this itself before
1734
+ * its own readers; a host calling {@link refitBLASes} on a spilled scene awaits it first.
1735
+ */
1736
+ async ensureSceneResident() {
1737
+
1738
+ if ( this._sdf?.spilled ) await this._sdf.ensureResident();
1739
+
1740
+ }
1741
+
1742
+ // ═══════════════════════════════════════════════════════════════
1743
+ // BVH Refit (Animation)
1744
+ // ═══════════════════════════════════════════════════════════════
1745
+
1746
+ /**
1747
+ * The meshes backing the current acceleration structure, in the order their triangles
1748
+ * occupy the shared buffers — which is what "original mesh order" means in
1749
+ * {@link refitBVH} and {@link refitBLASes}, and what `meshIndex` indexes.
1750
+ *
1751
+ * Walking your own model instead is not equivalent: the list is a depth-first pre-order
1752
+ * traversal of the whole mesh scene, so it also contains engine-owned meshes (the hidden
1753
+ * ground-projection disk) and any mesh a multi-material split produced. A positions
1754
+ * buffer built from a different set is silently misaligned.
1755
+ *
1756
+ * @returns {import('three').Mesh[]} Live reference — do not mutate.
1757
+ */
1758
+ get sceneMeshes() {
1759
+
1760
+ return this._sdf?.meshes ?? [];
1761
+
1762
+ }
1763
+
1764
+ /**
1765
+ * Root of the model actually being rendered. For {@link loadObject3D} this is the engine's
1766
+ * copy, not the object you passed — mutate this one, then {@link refitBVH}/{@link refitBLASes}.
1767
+ *
1768
+ * @returns {import('three').Object3D|null} Live reference, or null when nothing is loaded.
1769
+ */
1770
+ get sceneModel() {
1771
+
1772
+ return this.assetLoader?.targetModel ?? null;
1773
+
1774
+ }
1775
+
1776
+ /**
1777
+ * Resolve an id from {@link addModel}/{@link addModelFromObject3D} to the root being
1778
+ * rendered for it — for an appended Object3D, the engine's copy.
1779
+ *
1780
+ * @param {string} id
1781
+ * @returns {import('three').Object3D|null} Live reference, or null if no such object.
1782
+ */
1783
+ getSceneObject( id ) {
1784
+
1785
+ const root = this.meshScene?.children.find( c => c.uuid === id );
1786
+ return root?.userData?.__rayzeeSceneObject ? root : null;
1787
+
1788
+ }
1789
+
1790
+ /**
1791
+ * Update vertex positions for animation without full BVH rebuild.
1792
+ * O(N) bottom-up AABB refit instead of O(N log N) SAH rebuild.
1793
+ *
1794
+ * Topology must stay the same (same triangle count and connectivity).
1795
+ * Call this per-frame for skeletal/morph-target animation.
1796
+ *
1797
+ * Positions come in one of two shapes. A **callback** `(meshIndex, triCount) => Float32Array`
1798
+ * is asked for one mesh at a time and may return the same scratch buffer each call — prefer it,
1799
+ * since it never holds more than one mesh. A **scene-wide Float32Array** of 9 floats per
1800
+ * triangle for every triangle (meshes in {@link sceneMeshes} order, triangles in index order)
1801
+ * still works, but is 1,030 MB at 30M triangles and will not allocate at that size.
1802
+ *
1803
+ * @param {Float32Array|function(number, number): Float32Array} newPositions - (ax,ay,az, bx,by,bz, cx,cy,cz) per triangle, world space
1804
+ * @param {Float32Array|function(number, number): Float32Array} [newNormals] - Optional smooth normals, same two shapes. If omitted, face normals are computed from positions.
1805
+ * @returns {Promise<{ refitTimeMs: number }>}
1806
+ */
1807
+ async refitBVH( newPositions, newNormals ) {
1808
+
1809
+ await this.ensureSceneResident();
1810
+ const { triRanges, bvhRanges, ...result } = await this._sdf.refitBVH( newPositions, newNormals );
1811
+
1812
+ this.stages.pathTracer.updateTriangleData( this._sdf.triangles, triRanges );
1813
+ this.stages.pathTracer.updateBVHData( this._sdf.bvh, bvhRanges );
1814
+ this.reset( false, { motion: true } );
1815
+
1816
+ return result;
1817
+
1818
+ }
1819
+
1820
+ /**
1821
+ * Refit specific mesh BLASes and rebuild TLAS after object transform.
1822
+ * Faster than refitBVH for single-object transforms in multi-mesh scenes.
1823
+ *
1824
+ * @param {number[]} affectedMeshIndices - Mesh indices to refit
1825
+ * @param {Float32Array|function(number, number): Float32Array} newPositions - the same two shapes
1826
+ * {@link refitBVH} takes; only the affected meshes are asked for
1827
+ * @param {Float32Array|function(number, number): Float32Array} [newNormals] - Optional smooth normals
1828
+ * @returns {{ refitTimeMs: number }}
1829
+ */
1830
+ refitBLASes( affectedMeshIndices, newPositions, newNormals ) {
1831
+
1832
+ if ( this._sdf?.spilled ) throw new Error( 'refitBLASes: the scene is spilled to disk — await app.ensureSceneResident() first' );
1833
+ const result = this._sdf.refitBLASes( affectedMeshIndices, newPositions, newNormals );
1834
+
1835
+ const { triRanges, bvhRanges } = this._sdf.computeBLASDirtyRanges( affectedMeshIndices );
1836
+ this.stages.pathTracer.updateBufferRanges( triRanges, bvhRanges );
1837
+ this.reset( false, { motion: true } );
1838
+
1839
+ // Kick off background rebuild for optimal SAH quality
1840
+ this._sdf.scheduleBackgroundRebuild( affectedMeshIndices, ( meshIndex ) => {
1841
+
1842
+ // Swap complete — upload just that mesh's triangles and nodes, plus the TLAS. A whole
1843
+ // re-upload here is gigabytes on a large scene, for one mesh's worth of change.
1844
+ const dirty = this._sdf.computeBLASDirtyRanges( [ meshIndex ] );
1845
+ this.stages.pathTracer.updateBufferRanges( dirty.triRanges, dirty.bvhRanges );
1846
+ this.reset( false, { motion: true } );
1847
+
1848
+ } );
1849
+
1850
+ return result;
1851
+
1852
+ }
1853
+
1854
+ /**
1855
+ * Apply new transforms for objects that moved, without touching their geometry.
1856
+ *
1857
+ * This is the right call for a gizmo drag or any other rigid move: triangles are stored in
1858
+ * each object's own space, so only the placement matrix changes. {@link refitBLASes} is for
1859
+ * geometry that actually deformed — on a rigid move it rewrites vertices needlessly, and
1860
+ * drags along any other object sharing the same geometry.
1861
+ *
1862
+ * @param {number[]} meshIndices - indices into {@link sceneMeshes}
1863
+ * @returns {{ refitTimeMs: number, placements: number }}
1864
+ */
1865
+ updateMeshTransforms( meshIndices ) {
1866
+
1867
+ this._notePlacementsMoving( meshIndices );
1868
+ const result = this._sdf.updateMeshTransforms( meshIndices );
1869
+
1870
+ this.stages.pathTracer.updateBufferRanges( [], [ this._sdf.computeTLASDirtyRange() ] );
1871
+ this.reset( false, { motion: true } );
1872
+
1873
+ return result;
1874
+
1875
+ }
1876
+
1877
+ // ═══════════════════════════════════════════════════════════════
1878
+ // Resize
1879
+ // ═══════════════════════════════════════════════════════════════
1880
+
1881
+ /**
1882
+ * Raise (or lower) the reserved render size — the square dimension every compute StorageTexture + aux
1883
+ * buffer is pre-allocated at. Needed to enable resolutions above the 2048 default (e.g. 4K). The request
1884
+ * is device-capped: 4K reservation (~1.5 GB of MRT textures) is only granted on GPUs with ample VRAM +
1885
+ * a large storage-buffer binding limit; weaker devices clamp to 2048.
1886
+ *
1887
+ * Callable at any point in the lifecycle:
1888
+ * - before init(): recorded, then applied during init() before the stages are constructed, so they
1889
+ * pre-allocate at the raised size directly. The device gate cannot run until the device exists, so
1890
+ * the value returned here is the request, not the verdict — read getReservedRenderResolution() after
1891
+ * init(), or listen for `reserved_render_size_changed`.
1892
+ * - after init(): applied immediately, re-initialising the reserved GPU storage in place.
1893
+ * @param {number} requestedPx desired reserved size (longest edge)
1894
+ * @param {Object} [opts]
1895
+ * @param {boolean} [opts.allowLower=false] permit lowering, paying a rebuild, to reclaim VRAM
1896
+ * @returns {number} the applied reserved size (the pending request when called before init)
1897
+ */
1898
+ setReservedRenderResolution( requestedPx, { allowLower = false } = {} ) {
1899
+
1900
+ const prev = MAX_STORAGE_TEXTURE_SIZE;
1901
+
1902
+ // Monotonic up: UI-driven callers request whatever the current view needs, so honouring decreases made
1903
+ // the reserve oscillate on every preview↔render switch and paid a full kernel rebuild each time.
1904
+ const target = allowLower ? requestedPx : Math.max( requestedPx, prev );
1905
+
1906
+ // No device yet: the gate below has nothing to interrogate and would clamp a 4K-capable GPU to 2048.
1907
+ // init() replays the request once the device exists, still before the stages allocate.
1908
+ if ( ! this.renderer ) {
1909
+
1910
+ this._pendingReservedRenderSize = { requestedPx, allowLower };
1911
+ return Math.max( 256, Math.min( MAX_RESERVABLE_RENDER_SIZE, Math.floor( target ) || 256 ) );
1912
+
1913
+ }
1914
+
1915
+ const limits = this.renderer.backend?.device?.limits;
1916
+ const maxBinding = limits?.maxStorageBufferBindingSize || ( 128 * 1024 * 1024 );
1917
+ const memory = deviceMemoryGB( this._hostMemoryGB );
1918
+ // 4K reserve pins the accum MRT (~1.5 GB) + aux; only grant it on clearly-capable GPUs.
1919
+ const deviceSafeMax = ( memory.gb >= 8 && maxBinding >= 1024 * 1024 * 1024 )
1920
+ ? MAX_RESERVABLE_RENDER_SIZE : 2048;
1921
+ const applied = setReservedRenderSize( Math.min( target, deviceSafeMax ) );
1922
+
1923
+ if ( applied < Math.floor( requestedPx ) ) {
1924
+
1925
+ this._issues.warn(
1926
+ ISSUE_CODES.RENDER_RESERVE_CAPPED,
1927
+ `reserved render size ${fmt.n( requestedPx )}px was capped to ${fmt.n( applied )}px by this device's limits — ` +
1928
+ `renders above ${fmt.n( applied )}px will be declined` +
1929
+ ( memory.assumed && memory.gb < 8 ? ' (host memory unknown, 4 GB assumed: pass hostMemoryGB)' : '' ),
1930
+ { requested: requestedPx, applied, deviceMemoryGB: memory.gb, assumed: memory.assumed, maxStorageBufferBindingSize: maxBinding }
1931
+ );
1932
+
1933
+ }
1934
+
1935
+ // Gate the realloc on the textures that EXIST, not on how the binding moved: a raise applied while
1936
+ // nothing was allocated leaves `applied === prev` for every later call, so keying off that let the
1937
+ // first ineffective call poison every effective one after it (issue #9). The path tracer's write MRT
1938
+ // witnesses the reserve the stages were last built at.
1939
+ const allocated = this.stages?.pathTracer?.storageTextures?.writeColor?.image?.width ?? applied;
1940
+
1941
+ // Re-init the reserved GPU storage in place: each stage recreates its pre-allocated StorageTextures at
1942
+ // the new size + rebuilds its compute pipelines. Stage OBJECTS, manager refs and event wiring are
1943
+ // preserved (so no re-subscription needed); scene geometry buffers are resolution-independent and
1944
+ // reused. Rendering is paused across the swap so no in-flight dispatch references a disposed texture.
1945
+ if ( allocated !== applied ) {
1946
+
1947
+ const wasPaused = this.pauseRendering;
1948
+ this.pauseRendering = true;
1949
+ try {
1950
+
1951
+ // Lowering below the live backing store makes copyToReadTargets read past the end of the
1952
+ // (now smaller) write textures — a GPUValidationError. Shrink the backing store first.
1953
+ // renderer.setSize, not setCanvasSize/onResize: those also rewrite camera.aspect.
1954
+ const backing = this.renderer.domElement;
1955
+ if ( backing.width > applied || backing.height > applied ) {
1956
+
1957
+ this.renderer.setSize(
1958
+ Math.min( backing.width, applied ), Math.min( backing.height, applied ), false );
1959
+
1960
+ }
1961
+
1962
+ for ( const stage of Object.values( this.stages ) ) stage?.reallocateReservedStorage?.();
1963
+
1964
+ } finally {
1965
+
1966
+ this.pauseRendering = wasPaused;
1967
+
1968
+ }
1969
+
1970
+ this.reset();
1971
+ this.dispatchEvent( { type: EngineEvents.RESERVED_RENDER_SIZE_CHANGED, size: applied } );
1972
+
1973
+ }
1974
+
1975
+ return applied;
1976
+
1977
+ }
1978
+
1979
+ /**
1980
+ * Replay a setReservedRenderResolution() call made before init(): after the device exists, so the gate is
1981
+ * evaluated against the real GPU, and before _initPipeline() constructs the stages, which read the reserve
1982
+ * in their constructors. The event carries the device's verdict — the only authoritative answer a pre-init
1983
+ * caller can get.
1984
+ */
1985
+ _applyPendingReservedRenderSize() {
1986
+
1987
+ const pending = this._pendingReservedRenderSize;
1988
+ if ( ! pending ) return;
1989
+
1990
+ this._pendingReservedRenderSize = null;
1991
+
1992
+ const applied = this.setReservedRenderResolution( pending.requestedPx, { allowLower: pending.allowLower } );
1993
+ this.dispatchEvent( { type: EngineEvents.RESERVED_RENDER_SIZE_CHANGED, size: applied } );
1994
+
1995
+ }
1996
+
1997
+ /**
1998
+ * The current reserved (pre-allocated) square render size in px.
1999
+ * @returns {number}
2000
+ */
2001
+ getReservedRenderResolution() {
2002
+
2003
+ return MAX_STORAGE_TEXTURE_SIZE;
2004
+
2005
+ }
2006
+
2007
+ /**
2008
+ * Guard against render resolutions the compute pipeline can't support.
2009
+ * Per-resolution StorageTextures are pre-allocated at MAX_STORAGE_TEXTURE_SIZE
2010
+ * and never resized, so a larger request would overflow them. Warn and skip.
2011
+ * @returns {boolean} true if the size is renderable
2012
+ */
2013
+ _isRenderSizeSupported( width, height ) {
2014
+
2015
+ if ( width > MAX_STORAGE_TEXTURE_SIZE || height > MAX_STORAGE_TEXTURE_SIZE ) {
2016
+
2017
+ this._issues.record(
2018
+ ISSUE_CODES.RENDER_SIZE_DECLINED,
2019
+ `render resolution ${width}×${height} exceeds the ${MAX_STORAGE_TEXTURE_SIZE}px storage reserve — resize ignored, ` +
2020
+ `so output stays at the previous size. Raise it with setReservedRenderResolution( ${Math.max( width, height )} ) before init().`,
2021
+ { width, height, reserve: MAX_STORAGE_TEXTURE_SIZE }
2022
+ );
2023
+ return false;
2024
+
2025
+ }
2026
+
2027
+ return true;
2028
+
2029
+ }
2030
+
2031
+ onResize() {
2032
+
2033
+ const width = this.canvas.clientWidth;
2034
+ const height = this.canvas.clientHeight;
2035
+ if ( width === 0 || height === 0 ) return;
2036
+ if ( ! this._isRenderSizeSupported( width, height ) ) return;
2037
+
2038
+ this.renderer.setPixelRatio( 1.0 );
2039
+ this._setDisplaySize( width, height );
2040
+
2041
+ if ( width === this._renderWidth && height === this._renderHeight ) return;
2042
+
2043
+ clearTimeout( this._resizeDebounceTimer );
2044
+ this._resizeDebounceTimer = setTimeout( () => {
2045
+
2046
+ this._applyRenderResize( width, height );
2047
+
2048
+ }, 300 );
2049
+
2050
+ }
2051
+
2052
+ _applyRenderResize( renderWidth, renderHeight ) {
2053
+
2054
+ if ( ! this._isRenderSizeSupported( renderWidth, renderHeight ) ) return;
2055
+
2056
+ this.pipeline?.setSize( this._scaled( renderWidth ), this._scaled( renderHeight ) );
2057
+ this._renderWidth = renderWidth;
2058
+ this._renderHeight = renderHeight;
2059
+ this._renderSizeChanged( renderWidth, renderHeight );
2060
+ this.needsReset = true;
2061
+
2062
+ this.dispatchEvent( { type: EngineEvents.RESOLUTION_CHANGED, width: renderWidth, height: renderHeight } );
2063
+
2064
+ }
2065
+
2066
+ /**
2067
+ * Set the render resolution in pixels, applied immediately (unlike the debounced onResize()).
2068
+ * @param {number} width
2069
+ * @param {number} height
2070
+ * @returns {{width: number, height: number}|null} the size now in effect, or null if the request was
2071
+ * declined — zero, or above the reserved render size (raise it with setReservedRenderResolution).
2072
+ * A declined request leaves the previous size in place, so ignoring this return renders at the
2073
+ * wrong resolution with nothing but a warning to show for it.
2074
+ */
2075
+ setCanvasSize( width, height ) {
2076
+
2077
+ if ( width === 0 || height === 0 ) return null;
2078
+ if ( ! this._isRenderSizeSupported( width, height ) ) return null;
2079
+
2080
+ this.renderer.setPixelRatio( 1.0 );
2081
+ this._setDisplaySize( width, height );
2082
+
2083
+ clearTimeout( this._resizeDebounceTimer );
2084
+ this._applyRenderResize( width, height );
2085
+
2086
+ return { width, height };
2087
+
2088
+ }
2089
+
2090
+ _scaled( size ) {
2091
+
2092
+ return Math.max( 1, Math.round( size * this._renderScale ) );
2093
+
2094
+ }
2095
+
2096
+ // The wavefront takes its resolution from the canvas backing store, so that is what has to shrink.
2097
+ // updateStyle=false keeps the CSS size, and the browser stretches the smaller frame over it.
2098
+ _setDisplaySize( width, height ) {
2099
+
2100
+ this._displayWidth = width;
2101
+ this._displayHeight = height;
2102
+
2103
+ this.renderer.setSize( this._scaled( width ), this._scaled( height ), false );
2104
+ this.camera.aspect = width / height;
2105
+ this.camera.updateProjectionMatrix();
2106
+
2107
+ }
2108
+
2109
+ // ═══════════════════════════════════════════════════════════════
2110
+ // Mode Configuration
2111
+ // ═══════════════════════════════════════════════════════════════
2112
+
2113
+ refreshFrame() {
2114
+
2115
+ this._needsDisplayRefresh = true;
2116
+ this.wake();
2117
+
2118
+ }
2119
+
2120
+ // ═══════════════════════════════════════════════════════════════
2121
+ // Deterministic / headless control
2122
+ // ═══════════════════════════════════════════════════════════════
2123
+
2124
+ /**
2125
+ * Pins every wall-clock- and readback-dependent input so that N samples from a
2126
+ * fresh `reset()` reproduce bit-for-bit.
2127
+ *
2128
+ * The image is always a pure function of (pixel, frame, uniforms) — no shader reads
2129
+ * a clock and there is no `Math.random()` in the render path. What varies run to run
2130
+ * is *which* uniforms and dispatch grids are live on frame k:
2131
+ *
2132
+ * - `useAdaptiveSampling` retires the whole frame off an async CONVERGED_COUNT
2133
+ * readback, so two runs accumulate different sample totals for one `maxSamples`.
2134
+ * - `usePixelFreeze` sizes the bounce-0 grid from a stale active-pixel readback.
2135
+ * - `_bounceEarlyExitThreshold` / `_useDynamicDispatch` both consume the async
2136
+ * survivor curve. Kernels bind on ENTERING_COUNT, so an under-sized grid silently
2137
+ * drops rays, and the frame a readback lands on is GPU-scheduled.
2138
+ * - `interactionModeEnabled` is a 100 ms timer that lowers the render resolution, disables
2139
+ * accumulation and freezes frameCount; it engages on the very first frame.
2140
+ * - auto-focus raycasts per frame and auto-exposure adapts off `performance.now()`.
2141
+ *
2142
+ * Leaves the rAF loop stopped — drive rendering with {@link PathTracerApp#renderFrames}.
2143
+ * Idempotent; pass `false` to restore the previous configuration.
2144
+ *
2145
+ * `pinDispatch: false` keeps the two readback-driven dispatch heuristics
2146
+ * (`_useDynamicDispatch` and the per-bounce early exit) ACTIVE while still pinning the
2147
+ * render loop, sample count and timers. Output is then no longer bit-reproducible, so
2148
+ * this is only for performance measurement — it exists because those heuristics are
2149
+ * real shipping behaviour, and benchmarking with them disabled measures a configuration
2150
+ * production never runs.
2151
+ *
2152
+ * @param {boolean} [enabled=true]
2153
+ * @param {Object} [options]
2154
+ * @param {boolean} [options.pinDispatch=true] - false to keep production dispatch heuristics
2155
+ * @returns {boolean} whether deterministic mode is now active
2156
+ */
2157
+ setDeterministicMode( enabled = true, { pinDispatch = true } = {} ) {
2158
+
2159
+ const stage = this.stages.pathTracer;
2160
+ if ( ! stage ) return false;
2161
+
2162
+ if ( enabled ) {
2163
+
2164
+ if ( ! this._deterministicRestore ) {
2165
+
2166
+ this._deterministicRestore = {
2167
+ settings: {
2168
+ useAdaptiveSampling: this.settings.get( 'useAdaptiveSampling' ),
2169
+ usePixelFreeze: this.settings.get( 'usePixelFreeze' ),
2170
+ interactionModeEnabled: this.settings.get( 'interactionModeEnabled' ),
2171
+ renderLimitMode: this.settings.get( 'renderLimitMode' ),
2172
+ renderTimeLimit: this.settings.get( 'renderTimeLimit' ),
2173
+ },
2174
+ bounceEarlyExit: stage._bounceEarlyExitThreshold,
2175
+ dynamicDispatch: stage._useDynamicDispatch,
2176
+ extras: this._deterministicSnapshot(),
2177
+ };
2178
+
2179
+ }
2180
+
2181
+ // Cleared together on purpose: the freeze streak is stamped inside the
2182
+ // convergence block, so freeze-on/adaptive-off would run the freeze path
2183
+ // against a streak buffer nothing writes or clears.
2184
+ this.settings.setMany( {
2185
+ useAdaptiveSampling: false,
2186
+ usePixelFreeze: false,
2187
+ interactionModeEnabled: false,
2188
+ renderLimitMode: 'frames',
2189
+ renderTimeLimit: 0,
2190
+ }, { silent: true } );
2191
+
2192
+ if ( pinDispatch ) {
2193
+
2194
+ // -1 is unreachable by a uint survivor count, and both _buildWavefrontKernels()
2195
+ // and _resizeWavefrontInPlace() preserve the sentinel across rebuilds.
2196
+ stage._bounceEarlyExitThreshold = - 1;
2197
+ stage._useDynamicDispatch = false;
2198
+
2199
+ } else {
2200
+
2201
+ // Restore the values captured on first enable, so a perf pass measures the
2202
+ // same dispatch behaviour a real render uses.
2203
+ stage._bounceEarlyExitThreshold = this._deterministicRestore.bounceEarlyExit;
2204
+ stage._useDynamicDispatch = this._deterministicRestore.dynamicDispatch;
2205
+
2206
+ }
2207
+
2208
+ this._setDeterministicExtras( true, this._deterministicRestore.extras );
2209
+
2210
+ // The seed axis free-runs across accumulation resets so a camera drag gets fresh
2211
+ // sequences; offline rendering needs the opposite. Pinning it makes seedFrame track
2212
+ // frameCount, so N samples reproduce bit-for-bit. reset() below zeroes the tick.
2213
+ stage._pinSeedToFrame = true;
2214
+
2215
+ this._deterministic = true;
2216
+ this._dispatchPinned = pinDispatch;
2217
+
2218
+ } else if ( this._deterministicRestore ) {
2219
+
2220
+ const prev = this._deterministicRestore;
2221
+
2222
+ this.settings.setMany( prev.settings, { silent: true } );
2223
+ stage._bounceEarlyExitThreshold = prev.bounceEarlyExit;
2224
+ stage._useDynamicDispatch = prev.dynamicDispatch;
2225
+
2226
+ this._setDeterministicExtras( false, prev.extras );
2227
+
2228
+ stage._pinSeedToFrame = false;
2229
+
2230
+ this._deterministicRestore = null;
2231
+ this._deterministic = false;
2232
+ this._dispatchPinned = false;
2233
+
2234
+ }
2235
+
2236
+ this.reset();
2237
+ this.stopAnimation(); // reset() calls wake(); a manual render loop must not race rAF
2238
+
2239
+ return this._deterministic;
2240
+
2241
+ }
2242
+
2243
+ /** What the engine survived rather than failed on, oldest first. @returns {Object[]} copies */
2244
+ get issues() {
2245
+
2246
+ return this._issues.list;
2247
+
2248
+ }
2249
+
2250
+ /** Non-empty means: do not publish this frame. */
2251
+ get issueErrors() {
2252
+
2253
+ return this._issues.errors;
2254
+
2255
+ }
2256
+
2257
+ /** Scopes the log to one render on a reused app. */
2258
+ clearIssues() {
2259
+
2260
+ this._issues.clear();
2261
+
2262
+ }
2263
+
2264
+ /**
2265
+ * Whether output is currently bit-reproducible. False when the dispatch heuristics
2266
+ * were left active via `pinDispatch: false`, since those consume async readbacks.
2267
+ */
2268
+ get isDeterministic() {
2269
+
2270
+ return this._deterministic && this._dispatchPinned;
2271
+
2272
+ }
2273
+
2274
+ /**
2275
+ * Accumulates exactly `count` samples synchronously, bypassing the rAF loop.
2276
+ *
2277
+ * A frame retired by adaptive sampling stops advancing `frameCount`, so a fixed-count loop
2278
+ * can never reach `count`. `allowEarlyRetire` makes that an outcome instead of a throw.
2279
+ *
2280
+ * @param {number} count - samples to accumulate
2281
+ * @param {Object} [options]
2282
+ * @param {boolean} [options.reset=true] - restart accumulation from sample 0 first
2283
+ * @param {number} [options.yieldEvery=8] - yield to the event loop every N passes (0 disables)
2284
+ * @param {function(number): void} [options.onProgress] - called with the running sample count
2285
+ * @param {boolean} [options.allowEarlyRetire=false]
2286
+ * @returns {Promise<number>} samples accumulated; below `count` only when retired early
2287
+ */
2288
+ async renderFrames( count, { reset = true, yieldEvery = 8, onProgress, allowEarlyRetire = false } = {} ) {
2289
+
2290
+ const stage = this.stages.pathTracer;
2291
+ if ( ! stage ) throw new Error( 'renderFrames: app is not initialized' );
2292
+ if ( ! ( count > 0 ) ) throw new Error( `renderFrames: count must be positive, got ${count}` );
2293
+
2294
+ const target = ( reset ? 0 : stage.frameCount ) + count;
2295
+
2296
+ // completionThreshold is a cached JS number derived from maxSamples, so this must
2297
+ // go through the settings handler — writing the uniform alone would not move it.
2298
+ if ( this.settings.get( 'maxSamples' ) < target ) {
2299
+
2300
+ this.settings.set( 'maxSamples', target, { silent: true, reset: false } );
2301
+
2302
+ }
2303
+
2304
+ if ( reset ) this.reset();
2305
+ this.stopAnimation();
2306
+
2307
+ const maxPasses = count + 64;
2308
+ let passes = 0;
2309
+
2310
+ let retiredEarly = false;
2311
+
2312
+ while ( stage.frameCount < target && passes < maxPasses ) {
2313
+
2314
+ if ( this._deviceLost ) throw new Error( 'renderFrames: WebGPU device lost' );
2315
+ if ( ! stage.isReady ) throw new Error( 'renderFrames: path tracer stage is not ready' );
2316
+
2317
+ const wait = stage.readbackWait();
2318
+ if ( wait ) {
2319
+
2320
+ await this._awaitReadback( wait );
2321
+ continue;
2322
+
2323
+ }
2324
+
2325
+ // A host moves the camera between calls, and a load frames it with lookAt(), which leaves matrixWorld stale.
2326
+ this.camera.updateMatrixWorld();
2327
+ this.pipeline.render();
2328
+ passes ++;
2329
+
2330
+ // render() no-ops once complete; spinning would burn the budget for nothing.
2331
+ if ( stage.isComplete && stage.frameCount < target ) {
2332
+
2333
+ retiredEarly = true;
2334
+ break;
2335
+
2336
+ }
2337
+
2338
+ onProgress?.( stage.frameCount );
2339
+
2340
+ if ( yieldEvery > 0 && passes % yieldEvery === 0 ) {
2341
+
2342
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
2343
+
2344
+ }
2345
+
2346
+ }
2347
+
2348
+ if ( stage.frameCount < target && ! ( retiredEarly && allowEarlyRetire ) ) {
2349
+
2350
+ const cause = retiredEarly
2351
+ ? 'adaptive sampling retired the frame at the convergence threshold — pass ' +
2352
+ '`allowEarlyRetire: true`, or disable it with setDeterministicMode()'
2353
+ : 'something retired the render (maxSamples, a stray reset, or a canvas resize)';
2354
+
2355
+ throw new Error(
2356
+ `renderFrames: stopped at ${stage.frameCount}/${target} samples after ${passes} passes — ${cause}`
2357
+ );
2358
+
2359
+ }
2360
+
2361
+ return stage.frameCount;
2362
+
2363
+ }
2364
+
2365
+ /**
2366
+ * Renders until the render's own stop condition — the sample ceiling, adaptive convergence or the
2367
+ * time limit, whichever comes first — without requestAnimationFrame, then runs the closing denoise
2368
+ * once. The production counterpart of {@link renderFrames}: adaptive sampling stays on, and its
2369
+ * readbacks run in lockstep (see PathTracer.setLockstepReadbacks), so the same input renders the same
2370
+ * image however fast frames are submitted — except under a time limit, which is wall-clock by nature.
2371
+ * The upscaler does not run. `EngineEvents.RENDER_COMPLETE` fires as it does from the loop.
2372
+ *
2373
+ * @param {Object} [options]
2374
+ * @param {boolean} [options.reset=true] - restart accumulation from sample 0 first
2375
+ * @param {boolean} [options.denoise=true] - run the final OIDN pass when `finalDenoise` is on (PathTracerApp;
2376
+ * the core has no denoiser)
2377
+ * @param {AbortSignal} [options.signal] - rejects with its reason once aborted
2378
+ * @param {number} [options.drainEvery=4] - every N frames, wait for the GPU to finish the frames
2379
+ * before the last N, so submissions stay at most 2N frames ahead without the GPU idling
2380
+ * @param {function(number): void} [options.onProgress] - called with the running sample count
2381
+ * @returns {Promise<{samples: number, retiredBy: 'samples'|'converged'|'timeLimit', timeElapsed: number,
2382
+ * budgetOverrun: boolean, denoised: boolean}>}
2383
+ */
2384
+ async renderUntilComplete( { reset = true, denoise = true, signal, drainEvery = 4, onProgress } = {} ) {
2385
+
2386
+ const stage = this.stages.pathTracer;
2387
+ if ( ! stage ) throw new Error( 'renderUntilComplete: app is not initialized' );
2388
+ if ( this._loadingInProgress || this._sdf?.isProcessing ) throw new Error( 'renderUntilComplete: a scene is still loading' );
2389
+
2390
+ const lockstepWas = stage.lockstepReadbacks;
2391
+ stage.setLockstepReadbacks( true );
2392
+ // A 100 ms wall-clock mode for camera drags; frames in it do not count, so a batch render has no use for it.
2393
+ const interaction = this.settings.getEffective().interactionModeEnabled;
2394
+ this.settings.set( 'interactionModeEnabled', false, { silent: true, reset: false } );
2395
+
2396
+ try {
2397
+
2398
+ if ( reset ) this.reset();
2399
+ this.stopAnimation();
2400
+
2401
+ const queue = this.renderer.backend.device?.queue;
2402
+ let passes = 0;
2403
+ let previousBatch = null;
2404
+
2405
+ while ( ! stage.isComplete ) {
2406
+
2407
+ signal?.throwIfAborted();
2408
+ if ( this._deviceLost ) throw new Error( 'renderUntilComplete: WebGPU device lost' );
2409
+ if ( ! stage.isReady ) throw new Error( 'renderUntilComplete: path tracer stage is not ready' );
2410
+
2411
+ const wait = stage.readbackWait();
2412
+ if ( wait ) {
2413
+
2414
+ await this._awaitReadback( wait );
2415
+ continue;
2416
+
2417
+ }
2418
+
2419
+ this.camera.updateMatrixWorld();
2420
+ const counted = stage.frameCount;
2421
+ this._traceFrame();
2422
+ onProgress?.( stage.frameCount );
2423
+
2424
+ // A frame that did not count, with no readback to wait on, must still give timers a turn.
2425
+ if ( stage.frameCount === counted && ! stage.isComplete ) await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
2426
+
2427
+ if ( queue && drainEvery > 0 && ++ passes % drainEvery === 0 ) {
2428
+
2429
+ const batch = queue.onSubmittedWorkDone();
2430
+ if ( previousBatch ) await previousBatch;
2431
+ previousBatch = batch;
2432
+
2433
+ }
2434
+
2435
+ }
2436
+
2437
+ const info = this._completionInfo();
2438
+ if ( this.completion.markComplete() ) this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...info } );
2439
+
2440
+ const denoised = denoise ? await this._finalDenoise() : false;
2441
+ const { reason, ...rest } = info;
2442
+ return { ...rest, retiredBy: reason, denoised };
2443
+
2444
+ } finally {
2445
+
2446
+ stage.setLockstepReadbacks( lockstepWas );
2447
+ if ( interaction ) this.settings.set( 'interactionModeEnabled', interaction.value, { silent: true, reset: false, source: interaction.source } );
2448
+
2449
+ }
2450
+
2451
+ }
2452
+
2453
+ /**
2454
+ * What produced the current image, as plain JSON, to keep beside a render: engine and three.js
2455
+ * versions, the profile by name and by value (a name's values can change between releases), the
2456
+ * adapter, every live setting with its source, the colour pipeline, and the modes that decide
2457
+ * whether the render reproduces.
2458
+ * @returns {Object}
2459
+ */
2460
+ getProvenance() {
2461
+
2462
+ const stage = this.stages.pathTracer;
2463
+ const color = this.color?.status();
2464
+
2465
+ return toPortable( {
2466
+ engine: VERSION,
2467
+ three: REVISION,
2468
+ profile: { name: this._profileName, values: { ...this._profile } },
2469
+ adapter: this.adapterInfo ?? null,
2470
+ mode: {
2471
+ headless: this._headless,
2472
+ strict: this._issues.strict,
2473
+ deterministic: this.isDeterministic,
2474
+ lockstep: !! stage?.accumulationLockstep,
2475
+ },
2476
+ render: stage ? {
2477
+ width: stage.width,
2478
+ height: stage.height,
2479
+ samples: stage.frameCount,
2480
+ complete: stage.isComplete,
2481
+ retiredBy: stage.isComplete ? this.completion.stopCondition( stage ) ?? 'samples' : null,
2482
+ } : null,
2483
+ color: color ? {
2484
+ config: color.config?.id ?? null,
2485
+ workingSpace: color.workingSpace,
2486
+ view: color.activeView ?? color.activeTransform?.id ?? null,
2487
+ context: color.context,
2488
+ } : null,
2489
+ scene: this.sceneSource,
2490
+ settings: this.settings.getEffective(),
2491
+ issues: this.issues.map( ( { code, severity } ) => ( { code, severity } ) ),
2492
+ } );
2493
+
2494
+ }
2495
+
2496
+ /**
2497
+ * Lockstep readbacks for renders driven by the loop or by {@link renderFrames}:
2498
+ * {@link renderUntilComplete} turns them on for its own duration. See PathTracer.setLockstepReadbacks.
2499
+ * @param {boolean} [enabled=true]
2500
+ */
2501
+ setLockstepReadbacks( enabled = true ) {
2502
+
2503
+ this.stages.pathTracer?.setLockstepReadbacks( enabled );
2504
+
2505
+ }
2506
+
2507
+ // A readback that never lands would hold the render forever; a lost device is the one way it can't.
2508
+ async _awaitReadback( wait ) {
2509
+
2510
+ const lost = this.renderer.backend.device?.lost;
2511
+ const outcome = await ( lost ? Promise.race( [ wait.then( () => null ), lost ] ) : wait );
2512
+ if ( outcome ) throw new Error( `WebGPU device lost while awaiting a readback: ${outcome.message ?? outcome.reason}` );
2513
+
2514
+ }
2515
+
2516
+ /**
2517
+ * A render in progress as data: the accumulated colour (and the aux buffers while they hold
2518
+ * samples), the per-pixel convergence buffers, and the counters that choose the next sample.
2519
+ * {@link restoreRenderCheckpoint} continues it — bit-for-bit in deterministic mode, since the
2520
+ * random sequence is a pure function of pixel, frame and seed tick. At 4K it is ~500 MB.
2521
+ * @returns {Promise<?Object>} null before the path tracer exists
2522
+ */
2523
+ async captureRenderCheckpoint() {
2524
+
2525
+ const stage = this.stages.pathTracer;
2526
+ if ( ! stage ) return null;
2527
+ const accumulation = await stage.captureAccumulation();
2528
+ return { v: RENDER_CHECKPOINT_VERSION, engine: VERSION, savedAt: Date.now(), samples: accumulation.state.frameCount, ...accumulation };
2529
+
2530
+ }
2531
+
2532
+ /**
2533
+ * Continues a {@link captureRenderCheckpoint}. The scene, settings and render size must be the
2534
+ * ones it was taken with (restore the scene state first), and the scene must have rendered a
2535
+ * frame at that size since it last changed. Anything that resets accumulation afterwards
2536
+ * discards it. Rendering is not restarted: call {@link wake} or {@link renderFrames}.
2537
+ * @param {Object} checkpoint
2538
+ */
2539
+ restoreRenderCheckpoint( checkpoint ) {
2540
+
2541
+ if ( checkpoint?.v !== RENDER_CHECKPOINT_VERSION ) throw new Error( `render checkpoint version ${checkpoint?.v} is not ${RENDER_CHECKPOINT_VERSION}` );
2542
+ const stage = this.stages.pathTracer;
2543
+ if ( ! stage ) throw new Error( 'restoreRenderCheckpoint: app is not initialized' );
2544
+
2545
+ stage.restoreAccumulation( checkpoint );
2546
+ this.needsReset = false;
2547
+ this.dispatchEvent( { type: EngineEvents.RENDER_RESET, restored: checkpoint.samples } );
2548
+
2549
+ }
2550
+
2551
+ /**
2552
+ * The path tracer's accumulation as pixels, read from its storage target rather than the
2553
+ * canvas — so it works headless, works while the page is hidden, and cannot pick up a
2554
+ * helper overlay.
2555
+ *
2556
+ * This is `pathtracer:color`, NOT the Compositor's resolved output: denoising and
2557
+ * edge filtering are downstream and are absent here. Use getCanvas() when you want what
2558
+ * the viewport shows.
2559
+ *
2560
+ * @param {Object} [options]
2561
+ * @param {string} [options.colorSpace='srgb'] - `'srgb'` for display bytes through the active
2562
+ * view transform, `'linear'` for the raw working-space accumulation, or the name of a colour
2563
+ * space in the loaded OCIO config for a delivery buffer (float, e.g. `'ACES2065-1'`)
2564
+ * @param {boolean} [options.preserveAlpha=false] - srgb only
2565
+ * @param {'accumulation'|'display'} [options.source='accumulation'] - `'display'` reads what the
2566
+ * viewport is showing — denoised when a denoiser has run — instead of the raw
2567
+ * accumulation
2568
+ * @returns {Promise<{data: Float32Array|Uint8ClampedArray, width: number, height: number, colorSpace: string,
2569
+ * source: string, toneMappedOn?: 'gpu'|'cpu'}>} `source` is what was read: `'accumulation'`, or the
2570
+ * denoiser that published it (`'oidn'`, `'asvgf'`, `'nrd'`, `'edgeFiltering'`, `'bilateralFiltering'`).
2571
+ * `toneMappedOn` is set for `'srgb'` only.
2572
+ */
2573
+ async renderToBuffer( { colorSpace = 'srgb', preserveAlpha = false, source = 'accumulation' } = {} ) {
2574
+
2575
+ const named = colorSpace !== 'linear' && colorSpace !== 'srgb';
2576
+ if ( named && ! this.color?.hasConfig ) {
2577
+
2578
+ throw new Error(
2579
+ `renderToBuffer: colorSpace must be 'linear' or 'srgb' without a colour config loaded, got "${colorSpace}"`
2580
+ );
2581
+
2582
+ }
2583
+
2584
+ const stage = this.stages?.pathTracer;
2585
+ const target = stage?.storageTextures?.readTarget;
2586
+ if ( ! target ) throw new Error( 'renderToBuffer: no render target — call init() and render at least one sample' );
2587
+
2588
+ // The pool over-allocates to the reserve, so the texture is larger than the frame.
2589
+ const { width, height } = stage;
2590
+
2591
+ if ( colorSpace === 'srgb' ) {
2592
+
2593
+ const shown = source === 'display' ? this._displaySource( target ) : null;
2594
+ const tone = {
2595
+ exposure: this.renderer.toneMappingExposure,
2596
+ toneMapping: this.renderer.toneMapping,
2597
+ saturation: this.settings.get( 'saturation' ) ?? 1,
2598
+ preserveAlpha,
2599
+ };
2600
+
2601
+ const gpu = await this._toneMapOnGPU( shown?.texture ?? null, target, width, height, tone );
2602
+ const data = gpu ?? toneMapToRGBA8( shown
2603
+ ? await this._readTexture( shown.texture, width, height )
2604
+ : await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), tone );
2605
+ return { data, width, height, colorSpace, source: shown?.source ?? 'accumulation', toneMappedOn: gpu ? 'gpu' : 'cpu' };
2606
+
2607
+ }
2608
+
2609
+ const { data: linear, source: read } = source === 'display'
2610
+ ? await this._readDisplaySource( target, width, height )
2611
+ : { data: await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), source: 'accumulation' };
2612
+
2613
+ // `colorSpace` stays 'linear' — callers branch on it. `workingSpace` is the new, additive
2614
+ // answer to "linear in what primaries", which only means something once a config is loaded.
2615
+ if ( colorSpace === 'linear' ) {
2616
+
2617
+ return { data: linear, width, height, colorSpace, workingSpace: this.color?.workingSpace ?? null, source: read };
2618
+
2619
+ }
2620
+
2621
+ // A named space is a delivery buffer, not a picture: scene-referred float in whatever the
2622
+ // config calls that space. Saving an EXR for a compositor and grading through an sRGB view
2623
+ // on screen are different questions, and this is the one that answers the first.
2624
+ const { rgba, colorSpace: got } = this.color.exportPixels( linear, colorSpace );
2625
+ return { data: rgba, width, height, colorSpace: got, source: read };
2626
+
2627
+ }
2628
+
2629
+ /**
2630
+ * Display bytes tone-mapped on the card, or null to do it on the CPU. On the CPU it took 1.4 s at
2631
+ * 4096×2160 on an M-series Mac and 10 s on a cloud L4's host; the GPU pass matches it within one
2632
+ * level (`tests/gpu/toneMapParity.test.js`).
2633
+ */
2634
+ async _toneMapOnGPU( shown, accumulation, width, height, tone ) {
2635
+
2636
+ const backend = this.renderer?.backend;
2637
+ if ( ! backend?.device ) return this._toneMapFallback( 'the renderer has no WebGPU device' );
2638
+
2639
+ try {
2640
+
2641
+ const texture = shown ? this._readbackPass().draw( shown, width, height ).texture : accumulation.textures[ 0 ];
2642
+ const gpuTexture = backend.get( texture ).texture;
2643
+ if ( ! gpuTexture ) return this._toneMapFallback( 'the picture has no GPU texture yet' );
2644
+
2645
+ this._readbackToneMapper ??= new PackedToneMapper( backend.device, 'rayzee:readback-tonemap', { input: 'texture' } );
2646
+ this._readbackToneMapper.ensureSize( width, height );
2647
+ return await this._readbackToneMapper.toRGBA8( gpuTexture, tone );
2648
+
2649
+ } catch ( error ) {
2650
+
2651
+ if ( error instanceof EngineIssueError ) throw error;
2652
+ return this._toneMapFallback( 'the GPU pass threw', error );
2653
+
2654
+ } finally {
2655
+
2656
+ this._readbackToneMapper?.release();
2657
+ if ( shown ) this._textureReadback?.release();
2658
+
2659
+ }
2660
+
2661
+ }
2662
+
2663
+ /** A warning, so strict keeps going: the CPU bytes match within a level, only slower. @private */
2664
+ _toneMapFallback( reason, error = null ) {
2665
+
2666
+ const cause = error ? String( error?.message ?? error ) : null;
2667
+ const message = `renderToBuffer: tone mapped on the CPU — ${reason}${cause ? `: ${cause}` : ''}`;
2668
+ log.warn( message );
2669
+ this._issues.warn( ISSUE_CODES.OUTPUT_TONEMAP_FALLBACK, message, { reason, cause } );
2670
+ return null;
2671
+
2672
+ }
2673
+
2674
+ /**
2675
+ * Enables WebGPU timestamp queries so {@link PathTracerApp#getGPUTimings} reports real
2676
+ * GPU time. Off by default because the queries themselves cost time. No-ops when the
2677
+ * device lacks the `timestamp-query` feature.
2678
+ *
2679
+ * @param {boolean} [enabled=true]
2680
+ * @returns {boolean} whether timestamp tracking is now active
2681
+ */
2682
+ enableGPUTiming( enabled = true ) {
2683
+
2684
+ const backend = this.renderer?.backend;
2685
+ if ( ! backend ) return false;
2686
+ if ( enabled && backend.hasFeature?.( 'timestamp-query' ) !== true ) return false;
2687
+
2688
+ backend.trackTimestamp = enabled;
2689
+ return backend.trackTimestamp === enabled;
2690
+
2691
+ }
2692
+
2693
+ /**
2694
+ * Real GPU milliseconds for the last resolved frame, from WebGPU timestamp queries.
2695
+ * Returns null unless {@link PathTracerApp#enableGPUTiming} was called.
2696
+ *
2697
+ * This is the only true GPU metric available: `pipeline.getStats()` times
2698
+ * `performance.now()` around each stage's render(), which is command *encoding*
2699
+ * time and stays flat while GPU cost doubles.
2700
+ *
2701
+ * @returns {Promise<{ compute: number, render: number, total: number }|null>}
2702
+ */
2703
+ async getGPUTimings() {
2704
+
2705
+ const renderer = this.renderer;
2706
+ if ( ! renderer?.backend?.trackTimestamp ) return null;
2707
+
2708
+ await renderer.resolveTimestampsAsync( 'compute' );
2709
+ await renderer.resolveTimestampsAsync( 'render' );
2710
+
2711
+ const compute = renderer.info.compute.timestamp || 0;
2712
+ const render = renderer.info.render.timestamp || 0;
2713
+
2714
+ return { compute, render, total: compute + render };
2715
+
2716
+ }
2717
+
2718
+ /**
2719
+ * Per-kernel GPU milliseconds for the last resolved frame.
2720
+ *
2721
+ * {@link PathTracerApp#getGPUTimings} only reports the frame aggregate, which buries a change to
2722
+ * one kernel under everything else. The backend's timestamp query pool already retains one
2723
+ * duration per compute pass, keyed `c:<frameCalls>:<nodeId>:f<frame>` (three.js
2724
+ * `WebGPUTimestampQueryPool.resolveQueriesAsync` → `timestamps`); this attributes those back to
2725
+ * kernel names through `KernelManager`'s registry.
2726
+ *
2727
+ * Durations are SUMMED per kernel across the frame, so `extend` reports its whole per-frame cost
2728
+ * over every bounce iteration rather than one bounce. `unattributed` collects passes belonging to
2729
+ * no registered kernel (other stages, denoisers) so `sum(kernels) + unattributed` reconciles with
2730
+ * `total` — a gap there means a pass was missed, not that a kernel is free.
2731
+ *
2732
+ * The pool's map accumulates across frames and is never pruned, hence the newest-frame filter.
2733
+ *
2734
+ * Requires {@link PathTracerApp#enableGPUTiming}. Returns null when timestamps are unavailable.
2735
+ *
2736
+ * @returns {Promise<{kernels: Object<string, number>, total: number, unattributed: number, frame: number}|null>}
2737
+ */
2738
+ async getKernelGPUTimings() {
2739
+
2740
+ const renderer = this.renderer;
2741
+ if ( ! renderer?.backend?.trackTimestamp ) return null;
2742
+
2743
+ await renderer.resolveTimestampsAsync( 'compute' );
2744
+
2745
+ const timestamps = renderer.backend.timestampQueryPool?.compute?.timestamps;
2746
+ if ( ! timestamps || timestamps.size === 0 ) return null;
2747
+
2748
+ const nameByNodeId = new Map();
2749
+ const kernelMap = this.stages?.pathTracer?._kernelManager?.kernels;
2750
+ if ( kernelMap ) {
2751
+
2752
+ for ( const [ name, node ] of kernelMap ) nameByNodeId.set( node.id, name );
2753
+
2754
+ }
2755
+
2756
+ const parsed = [];
2757
+ let frame = - 1;
2758
+
2759
+ for ( const [ uid, ms ] of timestamps ) {
2760
+
2761
+ // 'c:<frameCalls>:<nodeId>:f<frame>'
2762
+ const parts = uid.split( ':' );
2763
+ if ( parts.length !== 4 ) continue;
2764
+
2765
+ const f = Number( parts[ 3 ].slice( 1 ) );
2766
+ if ( ! Number.isFinite( f ) ) continue;
2767
+
2768
+ parsed.push( { nodeId: Number( parts[ 2 ] ), f, ms } );
2769
+ if ( f > frame ) frame = f;
2770
+
2771
+ }
2772
+
2773
+ const kernels = {};
2774
+ let total = 0;
2775
+ let unattributed = 0;
2776
+
2777
+ for ( const entry of parsed ) {
2778
+
2779
+ if ( entry.f !== frame ) continue;
2780
+
2781
+ total += entry.ms;
2782
+ const name = nameByNodeId.get( entry.nodeId );
2783
+ if ( name === undefined ) unattributed += entry.ms;
2784
+ else kernels[ name ] = ( kernels[ name ] ?? 0 ) + entry.ms;
2785
+
2786
+ }
2787
+
2788
+ return { kernels, total, unattributed, frame };
2789
+
2790
+ }
2791
+
2792
+ // ═══════════════════════════════════════════════════════════════
2793
+ // Output (absorbed from OutputAPI)
2794
+ // ═══════════════════════════════════════════════════════════════
2795
+
2796
+ /**
2797
+ * Returns the canvas element holding the image on screen — denoised, graded and tone-mapped,
2798
+ * whatever is currently showing. The upscaler is the one thing that paints elsewhere.
2799
+ * @returns {HTMLCanvasElement|null}
2800
+ */
2801
+ getCanvas() {
2802
+
2803
+ if ( ! this.renderer?.domElement ) return null;
2804
+
2805
+ const shown = this._displayCanvas();
2806
+ if ( shown ) return shown;
2807
+
2808
+ // A presented WebGPU canvas only reads back what was drawn immediately before, so draw.
2809
+ // This also puts the denoised picture on it: the Compositor prefers it over the raw render.
2810
+ if ( this.stages.compositor && this.pipeline?.context ) {
2811
+
2812
+ this.stages.compositor.render( this.pipeline.context );
2813
+
2814
+ }
2815
+
2816
+ return this.renderer.domElement;
2817
+
2818
+ }
2819
+
2820
+ /**
2821
+ * Captures the current render as a Blob. Returns null if no canvas is
2822
+ * available. The host is responsible for downloading or otherwise
2823
+ * consuming the result.
2824
+ *
2825
+ * @param {Object} [options]
2826
+ * @param {string} [options.type='image/png'] - MIME type for the encoded image
2827
+ * @param {number} [options.quality] - 0–1 quality hint for lossy formats
2828
+ * @returns {Promise<Blob|null>}
2829
+ */
2830
+ screenshot( { type = 'image/png', quality } = {} ) {
2831
+
2832
+ const canvas = this.getCanvas();
2833
+ if ( ! canvas ) return Promise.resolve( null );
2834
+
2835
+ return new Promise( ( resolve ) => canvas.toBlob( resolve, type, quality ) );
2836
+
2837
+ }
2838
+
2839
+ /**
2840
+ * Returns scene statistics (triangle count, mesh count, etc.).
2841
+ * @returns {Object|null}
2842
+ */
2843
+ getStatistics() {
2844
+
2845
+ try {
2846
+
2847
+ return this._sdf?.getStatistics?.() ?? null;
2848
+
2849
+ } catch {
2850
+
2851
+ return null;
2852
+
2853
+ }
2854
+
2855
+ }
2856
+
2857
+ /**
2858
+ * When false, `animate()` rasters `meshScene` instead of path tracing. Those raster pipelines
2859
+ * are compiled on the switch rather than at load — `compileAsync` costs seconds on a
2860
+ * many-material model, and path tracing, the default, never uses them.
2861
+ * @returns {boolean}
2862
+ */
2863
+ get pathTracerEnabled() {
2864
+
2865
+ return this._pathTracerEnabled;
2866
+
2867
+ }
2868
+
2869
+ set pathTracerEnabled( value ) {
2870
+
2871
+ this._pathTracerEnabled = value;
2872
+ if ( ! value ) this.precompileRaster();
2873
+
2874
+ }
2875
+
2876
+ /**
2877
+ * Warms the raster fallback's pipelines. Once per loaded model; concurrent callers await the
2878
+ * same compile rather than racing past a flag set before it finishes.
2879
+ * @returns {Promise<void>}
2880
+ */
2881
+ precompileRaster() {
2882
+
2883
+ if ( ! this.renderer || ! this.meshScene ) return Promise.resolve();
2884
+
2885
+ this._rasterPrecompile ??= this.renderer
2886
+ .compileAsync( this.meshScene, this.camera )
2887
+ .catch( err => log.warn( 'raster fallback precompile failed', err ) );
2888
+
2889
+ return this._rasterPrecompile;
2890
+
2891
+ }
2892
+
2893
+ /**
2894
+ * Whether a model/environment load is currently in progress.
2895
+ * @returns {boolean}
2896
+ */
2897
+ get isLoading() {
2898
+
2899
+ return this._loadingInProgress;
2900
+
2901
+ }
2902
+
2903
+ /**
2904
+ * Whether the path tracer has finished converging.
2905
+ * @returns {boolean}
2906
+ */
2907
+ isComplete() {
2908
+
2909
+ return this.stages.pathTracer?.isComplete ?? false;
2910
+
2911
+ }
2912
+
2913
+ /**
2914
+ * Returns the current accumulated frame/sample count.
2915
+ * @returns {number}
2916
+ */
2917
+ getFrameCount() {
2918
+
2919
+ return this.stages.pathTracer?.frameCount || 0;
2920
+
2921
+ }
2922
+
2923
+ /**
2924
+ * Adaptive-sampling telemetry: `{ converged, activePixels, totalPixels, frame }`.
2925
+ * Counts come from an async readback taken on settled views only, so they lag a few frames
2926
+ * and read zero while the camera moves.
2927
+ * @returns {?Object}
2928
+ */
2929
+ getConvergenceStats() {
2930
+
2931
+ return this.stages.pathTracer?.getConvergenceStats?.() ?? null;
2932
+
2933
+ }
2934
+
2935
+ /** The path tracer's VRAM tracker, or null before stages are built. */
2936
+ get vram() {
2937
+
2938
+ return this.stages.pathTracer?.vramTracker ?? null;
2939
+
2940
+ }
2941
+
2942
+ /**
2943
+ * On-demand current/peak GPU memory snapshot.
2944
+ * @returns {{ current: number, peak: number, byCategory: Object }} bytes
2945
+ */
2946
+ getMemoryInfo() {
2947
+
2948
+ // Wired from the loop too, which renderFrames never enters: a headless host would miss these categories.
2949
+ this._ensureVRAMWiring();
2950
+ return this.stages.pathTracer?.vramTracker?.measure() ?? { current: 0, peak: 0, byCategory: {} };
2951
+
2952
+ }
2953
+
2954
+ /**
2955
+ * CPU-side memory for the last scene build: what the preflight predicted, what each phase
2956
+ * allocated, and what was live at each phase boundary.
2957
+ *
2958
+ * Note that `performance.memory.usedJSHeapSize` does NOT count SharedArrayBuffer, and the
2959
+ * triangle and BVH stores are SAB-backed — so the browser's own heap reading under-reports
2960
+ * a large scene by several gigabytes and this is the figure to trust.
2961
+ *
2962
+ * @returns {?{preflight: ?Object, allocatedBytes: number, peakLiveBytes: number,
2963
+ * byPhase: Object, samples: Object[]}} null before a scene is built
2964
+ */
2965
+ getHostMemoryInfo() {
2966
+
2967
+ const sp = this._sdf;
2968
+ if ( ! sp?.memory ) return null;
2969
+ return { preflight: sp.memoryPreflight, ...sp.memory.report };
2970
+
2971
+ }
2972
+
2973
+ // Idempotent: registers the cross-stage texture provider and re-measures on
2974
+ // allocation events (scene/env load, resize) so peak is caught even while idle.
2975
+ _ensureVRAMWiring() {
2976
+
2977
+ if ( this._vramWired ) return;
2978
+ const tracker = this.stages.pathTracer?.vramTracker;
2979
+ if ( ! tracker ) return; // stages not ready yet
2980
+
2981
+ tracker.register( 'stages', () => this._collectStageTextures() );
2982
+ this._registerVRAM( tracker );
2983
+ // One image per presented surface (a browser may keep one or two more), and the buffer
2984
+ // three's output pass tone-maps through.
2985
+ tracker.register( 'canvas', () => [
2986
+ { bytes: this.canvas.width * this.canvas.height * 4 },
2987
+ ...( this.renderer?._frameBufferTargets?.values() ?? [] ),
2988
+ ...this._canvasResources(),
2989
+ ] );
2990
+
2991
+ const remeasure = () => tracker.measure();
2992
+ this._addTrackedListener( this, EngineEvents.SCENE_REBUILD, remeasure );
2993
+ this._addTrackedListener( this, EngineEvents.ENVIRONMENT_LOADED, remeasure );
2994
+ this._addTrackedListener( this, EngineEvents.RESOLUTION_CHANGED, remeasure );
2995
+
2996
+ this._vramWired = true;
2997
+
2998
+ }
2999
+
3000
+ // Direct StorageTexture/RenderTarget properties of every non-pathTracer stage
3001
+ // (denoiser/G-buffer/filter targets). The pathTracer's own buffers/textures are
3002
+ // registered explicitly; measure() dedupes by identity so overlaps don't double-count.
3003
+ _collectStageTextures() {
3004
+
3005
+ const out = [];
3006
+ const stages = this.stages || {};
3007
+ const pt = stages.pathTracer;
3008
+
3009
+ for ( const key in stages ) {
3010
+
3011
+ const stage = stages[ key ];
3012
+ if ( ! stage || stage === pt || typeof stage !== 'object' ) continue;
3013
+
3014
+ for ( const prop in stage ) {
3015
+
3016
+ const v = stage[ prop ];
3017
+ if ( v && ( v.isTexture || v.isRenderTarget ) ) out.push( v );
3018
+
3019
+ }
3020
+
3021
+ }
3022
+
3023
+ return out;
3024
+
3025
+ }
3026
+
3027
+ // ═══════════════════════════════════════════════════════════════
3028
+ // Materials (absorbed from MaterialsAPI)
3029
+ // ═══════════════════════════════════════════════════════════════
3030
+
3031
+ /**
3032
+ * Updates a single material property and triggers emissive rebuild if needed.
3033
+ * @param {number} materialIndex
3034
+ * @param {string} property
3035
+ * @param {*} value
3036
+ */
3037
+ /**
3038
+ * World-space minimum Y of the loaded scene (the floor). Used to seed the
3039
+ * analytic ground-plane shadow catcher height. Returns 0 if no scene is loaded.
3040
+ * @returns {number}
3041
+ */
3042
+ getSceneMinY() {
3043
+
3044
+ if ( ! this.meshScene ) return 0;
3045
+ const box = new Box3().setFromObject( this.meshScene );
3046
+ return Number.isFinite( box.min.y ) ? box.min.y : 0;
3047
+
3048
+ }
3049
+
3050
+ /**
3051
+ * Read back the scalar material property the shader is actually using.
3052
+ * @param {number} materialIndex
3053
+ * @param {string} property
3054
+ * @returns {number|undefined}
3055
+ */
3056
+ getMaterialProperty( materialIndex, property ) {
3057
+
3058
+ return this.stages.pathTracer?.materialData.getMaterialProperty( materialIndex, property );
3059
+
3060
+ }
3061
+
3062
+ /**
3063
+ * Where a material property's value came from: `'material'` (the three.js material carried
3064
+ * it), `'mapped'` (converted from a Basic/Lambert/Phong/Toon material), `'default'` (the model
3065
+ * never said; filled from MATERIAL_DEFAULTS) or `'host'` (set through setMaterialProperty).
3066
+ * @param {number} materialIndex
3067
+ * @param {string} property
3068
+ * @returns {string|undefined}
3069
+ */
3070
+ getMaterialPropertySource( materialIndex, property ) {
3071
+
3072
+ return this.stages.pathTracer?.materialData.getMaterialPropertySource( materialIndex, property );
3073
+
3074
+ }
3075
+
3076
+ setMaterialProperty( materialIndex, property, value ) {
3077
+
3078
+ // These rewrite triangle records, which a spilled scene has to read back first.
3079
+ if ( this._sdf?.spilled && TRIANGLE_PATCH_PROPERTIES.has( property ) ) {
3080
+
3081
+ this.ensureSceneResident().then( () => this.setMaterialProperty( materialIndex, property, value ) );
3082
+ return;
3083
+
3084
+ }
3085
+
3086
+ this.stages.pathTracer?.materialData.updateMaterialProperty( materialIndex, property, value );
3087
+
3088
+ // Keep the emissive-NEE structure in sync unconditionally (not gated on the
3089
+ // sampling toggle) so edits made while NEE is off aren't lost on re-enable.
3090
+ const emissiveAffectingProps = [ 'emissive', 'emissiveIntensity' ];
3091
+ if ( emissiveAffectingProps.includes( property ) && this._sdf ) {
3092
+
3093
+ this._uploadEmissivePayload( this._sdf.updateMaterialEmissive( materialIndex, property, value ) );
3094
+
3095
+ }
3096
+
3097
+ this.reset();
3098
+
3099
+ }
3100
+
3101
+ /**
3102
+ * Upload a rebuilt emissive-NEE payload (sorted emissive data + Light BVH +
3103
+ * bit-trail map) to the path tracer stage. No-op for a null payload.
3104
+ * @param {object|null} payload
3105
+ * @private
3106
+ */
3107
+ _uploadEmissivePayload( payload ) {
3108
+
3109
+ if ( ! payload || ! this.stages.pathTracer ) return;
3110
+
3111
+ this.stages.pathTracer.setEmissiveTriangleData(
3112
+ payload.rawData, payload.emissiveCount, payload.totalPower, payload.bitTrailMap,
3113
+ );
3114
+ if ( payload.lightBVHNodeData ) {
3115
+
3116
+ this.stages.pathTracer.setLightBVHData( payload.lightBVHNodeData, payload.lightBVHNodeCount );
3117
+
3118
+ }
3119
+
3120
+ }
3121
+
3122
+ /**
3123
+ * Re-derive the emissive-NEE sampled set from current per-mesh world-visibility
3124
+ * (InstanceTable is the source of truth — patched by the stage's visibility API).
3125
+ * Hidden meshes' triangles are dropped from the Light BVH so they stop casting
3126
+ * NEE light; the shadow ray can't do it (a hidden mesh no longer self-occludes).
3127
+ * @private
3128
+ */
3129
+ _refreshEmissiveForVisibility() {
3130
+
3131
+ const table = this._sdf?.instanceTable;
3132
+ if ( ! table ) return;
3133
+
3134
+ // The emitter list names meshes, the table placements.
3135
+ const hidden = new Set();
3136
+ for ( let i = 0; i < table.count; i ++ ) {
3137
+
3138
+ if ( table.isSet[ i ] && ! table.visible[ i ] ) hidden.add( table.sourceMesh[ i ] );
3139
+
3140
+ }
3141
+
3142
+ this._uploadEmissivePayload( this._sdf.rebuildEmissiveForVisibility( hidden ) );
3143
+
3144
+ }
3145
+
3146
+ /**
3147
+ * Explicitly enable/disable emissive-triangle next-event estimation. Marks the
3148
+ * setting as user-controlled so scene rebuilds stop auto-following the scene's
3149
+ * emissive content; the next fresh model load re-arms the auto-default.
3150
+ * @param {boolean} enabled
3151
+ */
3152
+ setEmissiveTriangleSampling( enabled ) {
3153
+
3154
+ this._emissiveSamplingUserSet = true;
3155
+ this.settings.set( 'enableEmissiveTriangleSampling', enabled );
3156
+
3157
+ }
3158
+
3159
+ /**
3160
+ * @returns {boolean} True if the current scene contains emissive geometry
3161
+ * (regardless of per-mesh visibility).
3162
+ */
3163
+ hasEmissiveGeometry() {
3164
+
3165
+ return ( this._sdf?.emissiveTriangleBuilder?.emissiveTriangles?.length
3166
+ ?? this._sdf?.emissiveTriangleCount ?? 0 ) > 0;
3167
+
3168
+ }
3169
+
3170
+ /**
3171
+ * Update per-mesh visibility without rebuilding the scene.
3172
+ * Walks the parent chain to resolve world-space visibility.
3173
+ * @param {number} meshIndex
3174
+ * @param {boolean} visible
3175
+ */
3176
+ setMeshVisibility( meshIndex, visible ) {
3177
+
3178
+ this.stages.pathTracer?.updateMeshVisibility( meshIndex, visible );
3179
+ this._refreshEmissiveForVisibility();
3180
+ this.reset();
3181
+
3182
+ }
3183
+
3184
+ /**
3185
+ * Recompute world-visibility for all meshes.
3186
+ * Call after changing visibility on groups or parent objects.
3187
+ */
3188
+ updateAllMeshVisibility() {
3189
+
3190
+ this.stages.pathTracer?.updateAllMeshVisibility();
3191
+ this._refreshEmissiveForVisibility();
3192
+ this.reset();
3193
+
3194
+ }
3195
+
3196
+ /**
3197
+ * The active mesh-bearing scene. Prefer this over reading `scene`/`meshScene`
3198
+ * directly — the engine may swap the underlying scene between rebuilds.
3199
+ * @returns {import('three').Scene}
3200
+ */
3201
+ getScene() {
3202
+
3203
+ return this.meshScene || this.scene;
3204
+
3205
+ }
3206
+
3207
+ // Sets when `visible` is a boolean; toggles when it's an updater (prev) => next.
3208
+ /**
3209
+ * @param {string} uuid
3210
+ * @param {boolean | ((prev: boolean) => boolean)} visible
3211
+ * @returns {boolean | null} new visibility, or null if the mesh wasn't found
3212
+ */
3213
+ setMeshVisibilityByUuid( uuid, visible ) {
3214
+
3215
+ const object = this.getScene()?.getObjectByProperty( 'uuid', uuid );
3216
+ if ( ! object ) return null;
3217
+ const next = typeof visible === 'function' ? !! visible( object.visible ) : !! visible;
3218
+ object.visible = next;
3219
+ this.updateAllMeshVisibility();
3220
+ return next;
3221
+
3222
+ }
3223
+
3224
+ /**
3225
+ * Updates a material's texture transform (offset, repeat, rotation).
3226
+ * @param {number} materialIndex
3227
+ * @param {string} textureName
3228
+ * @param {Object} transform
3229
+ */
3230
+ setTextureTransform( materialIndex, textureName, transform ) {
3231
+
3232
+ this.stages.pathTracer?.materialData.updateTextureTransform( materialIndex, textureName, transform );
3233
+ this.reset();
3234
+
3235
+ }
3236
+
3237
+ /**
3238
+ * What a `'display'` read shows: the texture a denoiser published, or null for the raw
3239
+ * accumulation — recorded as a fallback when a denoiser is in use.
3240
+ * @private
3241
+ */
3242
+ _displaySource( accumulation ) {
3243
+
3244
+ const context = this.pipeline?.context;
3245
+ const found = context && this.stages.compositor?.resolveLightSource( context );
3246
+ if ( found && found.key !== 'pathtracer:color' && found.texture !== accumulation.texture ) {
3247
+
3248
+ return { texture: found.texture, source: found.key.split( ':' )[ 0 ] };
3249
+
3250
+ }
3251
+
3252
+ if ( this._denoiserInUse() ) {
3253
+
3254
+ this._issues.record(
3255
+ ISSUE_CODES.OUTPUT_SOURCE_FALLBACK,
3256
+ 'renderToBuffer: \'display\' was asked for, but no denoiser has published a picture — returned the raw accumulation',
3257
+ { requested: 'display', read: 'accumulation' }
3258
+ );
3259
+
3260
+ }
3261
+
3262
+ return null;
3263
+
3264
+ }
3265
+
3266
+ /**
3267
+ * What the Compositor is drawing from, as linear float. Falls back to the accumulation when no
3268
+ * denoiser has published anything, which is also what the viewport shows then.
3269
+ * @private
3270
+ */
3271
+ async _readDisplaySource( accumulation, width, height ) {
3272
+
3273
+ const shown = this._displaySource( accumulation );
3274
+ if ( ! shown ) return { data: await this.renderer.readRenderTargetPixelsAsync( accumulation, 0, 0, width, height, 0 ), source: 'accumulation' };
3275
+ return { data: await this._readTexture( shown.texture, width, height ), source: shown.source };
3276
+
3277
+ }
3278
+
3279
+ _readbackPass() {
3280
+
3281
+ return ( this._textureReadback ??= new TextureReadback( this.renderer ) );
3282
+
3283
+ }
3284
+
3285
+ _readTexture( texture, width, height ) {
3286
+
3287
+ return this._readbackPass().read( texture, width, height );
3288
+
3289
+ }
3290
+
3291
+ /**
3292
+ * Set what colour space one texture is in, then rebuild so it takes effect.
3293
+ *
3294
+ * The per-image colour space every OCIO application offers. Three kinds of answer:
3295
+ * - `null` — automatic: tag, override, the config's file rules, then three.js
3296
+ * - `'srgb'`/`'linear'` — three.js's own two, which work with no config at all
3297
+ * - a config space — anything the loaded config names, including log and camera spaces
3298
+ *
3299
+ * A config space is carried as `userData.ocioColorSpace`, and the texture's three.js colour space
3300
+ * is set to match the colour pool while it is, so harmonization leaves the stored bytes as the
3301
+ * author wrote them — the named transform has to read them raw. The original is kept and put
3302
+ * back when the choice is cleared.
3303
+ *
3304
+ * @param {import('three').Texture} texture
3305
+ * @param {?string} choice
3306
+ */
3307
+ async setTextureColorSpace( texture, choice ) {
3308
+
3309
+ if ( ! texture?.isTexture ) throw new Error( 'setTextureColorSpace wants a three.js texture' );
3310
+
3311
+ const ud = texture.userData ?? ( texture.userData = {} );
3312
+ if ( ud.__rayzeeOriginalColorSpace === undefined ) ud.__rayzeeOriginalColorSpace = texture.colorSpace;
3313
+ const original = ud.__rayzeeOriginalColorSpace;
3314
+
3315
+ delete ud.ocioColorSpace;
3316
+ delete ud.__rayzeeColorSpaceChoice;
3317
+
3318
+ if ( choice === null || choice === undefined ) {
3319
+
3320
+ texture.colorSpace = original;
3321
+ delete ud.__rayzeeOriginalColorSpace;
3322
+
3323
+ } else if ( choice === 'srgb' || choice === 'linear' ) {
3324
+
3325
+ texture.colorSpace = choice === 'srgb' ? SRGBColorSpace : LinearSRGBColorSpace;
3326
+ ud.__rayzeeColorSpaceChoice = choice;
3327
+
3328
+ } else {
3329
+
3330
+ if ( ! this.color?.hasConfig ) throw new Error( `"${choice}" needs a colour config loaded` );
3331
+ ud.ocioColorSpace = choice;
3332
+ texture.colorSpace = SRGBColorSpace;
3333
+
3334
+ }
3335
+
3336
+ texture.needsUpdate = true;
3337
+ if ( this.stages.pathTracer?.sdfs ) await this.rebuildMaterials();
3338
+
3339
+ }
3340
+
3341
+ /**
3342
+ * Installs a colour-management class in place of the core's basic one — `ColorManagement` from
3343
+ * rayzee/addons/color, the OCIO pipeline. Before init() or after; PathTracerApp installs it itself.
3344
+ * @param {Function} ColorClass - constructed with `{ issues }`
3345
+ */
3346
+ setColorManagement( ColorClass ) {
3347
+
3348
+ this.color?.dispose();
3349
+ this.color = new ColorClass( { issues: this._issues } );
3350
+ setActiveColorManagement( this.color );
3351
+ if ( this.renderer ) this.color.attachRenderer( this.renderer );
3352
+
3353
+ }
3354
+
3355
+ /**
3356
+ * Load a colour config, keeping the scene consistent with it.
3357
+ *
3358
+ * Prefer this to `app.color.loadConfig()` whenever a scene is loaded. A working space adopted
3359
+ * under the previous config is undone first, *while that config is still loaded* — the
3360
+ * environment is converted in place, and only the config that converted it can convert it
3361
+ * back. With `adoptWorkingSpace` the scene is rebuilt in the new space afterwards.
3362
+ *
3363
+ * @param {Object} options - as `ColorManagement.loadConfig`
3364
+ * @returns {Promise<Object>} the config description
3365
+ */
3366
+ async loadColorConfig( options = {} ) {
3367
+
3368
+ await this._leaveColorWorkingSpace();
3369
+ const shown = getViewTransform( this.renderer?.toneMapping );
3370
+ const described = await this.color.loadConfig( options );
3371
+ if ( this.color.workingSpaceAdopted ) await this.applyColorWorkingSpace();
3372
+ // A baked view the config kept is still what is on screen.
3373
+ const kept = shown?.source === 'ocio' && getViewTransform( this.renderer?.toneMapping ) === shown;
3374
+ if ( ! kept || this.color.workingSpaceAdopted ) this.reset();
3375
+ return described;
3376
+
3377
+ }
3378
+
3379
+ /** Unload the colour config, putting the scene back into linear Rec.709 first. */
3380
+ async unloadColorConfig() {
3381
+
3382
+ await this._leaveColorWorkingSpace();
3383
+ this.color.unloadConfig();
3384
+ this.reset();
3385
+
3386
+ }
3387
+
3388
+ /** @private */
3389
+ async _leaveColorWorkingSpace() {
3390
+
3391
+ if ( ! this.color?.workingSpaceAdopted ) return;
3392
+ this.color.setWorkingSpace( null );
3393
+ await this.applyColorWorkingSpace();
3394
+
3395
+ }
3396
+
3397
+ /**
3398
+ * Rebuild everything the working space touches, after it has been changed.
3399
+ *
3400
+ * Changing what the engine renders in is not a display setting — it changes what every texture,
3401
+ * tint and light colour already loaded *means*. Textures and materials are re-packed from their
3402
+ * pristine three.js sources, so they convert cleanly; the environment is converted where it
3403
+ * lies, from whichever space it currently holds, and its importance-sampling table is rebuilt
3404
+ * because its pixels moved.
3405
+ *
3406
+ * Call this after `app.color.setWorkingSpace()`. Doing nothing instead leaves a scene half in
3407
+ * one space and half in another, which reads as a colour cast with no obvious cause.
3408
+ */
3409
+ async applyColorWorkingSpace() {
3410
+
3411
+ const env = this.environmentManager?.getEnvironmentTexture?.();
3412
+ let envChanged = false;
3413
+
3414
+ if ( env && this.color?.hasConfig ) {
3415
+
3416
+ try {
3417
+
3418
+ envChanged = this.color.convertTexturePixels( env );
3419
+
3420
+ } catch ( error ) {
3421
+
3422
+ log.warn( `environment colour conversion failed: ${error.message}` );
3423
+
3424
+ }
3425
+
3426
+ }
3427
+
3428
+ // Nothing to rebuild before a scene exists; anything loaded later converts on its way in.
3429
+ if ( this.stages.pathTracer?.sdfs ) {
3430
+
3431
+ await this.rebuildMaterials();
3432
+ this._uploadEmissivePayload( this._sdf?.rebuildEmissiveColors?.() ?? null );
3433
+
3434
+ }
3435
+
3436
+ if ( envChanged ) await this.environmentManager?.buildEnvironmentCDF?.();
3437
+ // Its pixels never leave the GPU, so it is baked again in the new space rather than converted.
3438
+ if ( env?._isPhysicalSky ) await this.environmentManager.generateProceduralSkyTexture();
3439
+ this.environmentManager?.refreshSun?.();
3440
+
3441
+ this.reset();
3442
+
3443
+ }
3444
+
3445
+ /**
3446
+ * Full material rebuild (required after texture changes).
3447
+ * @param {import('three').Scene} [scene]
3448
+ */
3449
+ async rebuildMaterials( scene ) {
3450
+
3451
+ await this.ensureSceneResident();
3452
+ await this.stages.pathTracer?.rebuildMaterials( scene || this.meshScene );
3453
+ this.reset();
3454
+
3455
+ }
3456
+
3457
+ // ═══════════════════════════════════════════════════════════════
3458
+ // Private — Initialization
3459
+ // ═══════════════════════════════════════════════════════════════
3460
+
3461
+ async _initRenderer() {
3462
+
3463
+ setStatusCallback( ( event ) => this.dispatchEvent( event ) );
3464
+
3465
+ if ( typeof navigator === 'undefined' || ! navigator.gpu ) {
3466
+
3467
+ throw new Error( 'WebGPU is not available — outside a browser, install one as navigator.gpu (for Node, the `webgpu` package)' );
3468
+
3469
+ }
3470
+
3471
+ const adapter = await navigator.gpu.requestAdapter( { powerPreference: 'high-performance' } );
3472
+ if ( ! adapter ) {
3473
+
3474
+ throw new Error( 'Failed to get WebGPU adapter' );
3475
+
3476
+ }
3477
+
3478
+ this.adapterInfo = describeAdapter( adapter );
3479
+ if ( this.adapterInfo.isSoftware ) {
3480
+
3481
+ // Warning: the image is correct, only the cost is wrong.
3482
+ this._issues.warn(
3483
+ ISSUE_CODES.ADAPTER_SOFTWARE,
3484
+ `software GPU adapter "${this.adapterInfo.description || this.adapterInfo.vendor}" — ` +
3485
+ 'renders are correct but orders of magnitude slower than hardware',
3486
+ { ...this.adapterInfo }
3487
+ );
3488
+ log.warn( `software GPU adapter — check app.adapterInfo.isSoftware. ${this.adapterInfo.description}` );
3489
+
3490
+ }
3491
+
3492
+ const adapterLimits = adapter.limits;
3493
+
3494
+ // A limit that is NOT requested is granted at WebGPU's portable default, never at the
3495
+ // adapter's maximum — on Apple Metal-3 that is 16 sampled textures of 48, 4 storage
3496
+ // textures of 8, 256 workgroup invocations of 1024 and 256 array layers of 2048.
3497
+ // Requesting exactly what the adapter reports can never fail, so a weaker GPU simply
3498
+ // gets less and every consumer has to stay adaptive.
3499
+ const requiredLimits = { maxColorAttachmentBytesPerSample: 128 };
3500
+
3501
+ for ( const key of [
3502
+ 'maxBufferSize',
3503
+ 'maxStorageBufferBindingSize',
3504
+ 'maxSampledTexturesPerShaderStage',
3505
+ 'maxStorageTexturesPerShaderStage',
3506
+ 'maxTextureArrayLayers',
3507
+ 'maxTextureDimension2D',
3508
+ 'maxComputeInvocationsPerWorkgroup',
3509
+ 'maxComputeWorkgroupStorageSize',
3510
+ 'maxComputeWorkgroupSizeX',
3511
+ 'maxComputeWorkgroupSizeY',
3512
+ 'maxComputeWorkgroupSizeZ',
3513
+ ] ) {
3514
+
3515
+ const value = adapterLimits[ key ];
3516
+ if ( value !== undefined ) requiredLimits[ key ] = value;
3517
+
3518
+ }
3519
+
3520
+ // Shade binds exactly 10 and the kernels are written to that budget; asking for more
3521
+ // buys nothing, and 10 is already this adapter's ceiling.
3522
+ requiredLimits.maxStorageBuffersPerShaderStage = Math.min( adapterLimits.maxStorageBuffersPerShaderStage, 10 );
3523
+
3524
+ this.renderer = new WebGPURenderer( {
3525
+ canvas: this.canvas,
3526
+ alpha: true,
3527
+ powerPreference: 'high-performance',
3528
+ requiredLimits,
3529
+ } );
3530
+
3531
+ await initWithoutFrameLoop( this.renderer, this._headless );
3532
+
3533
+ // WebGPURenderer swaps in WebGL2 on failure with only a warn(). The wavefront path is
3534
+ // compute-only, so every frame would fail against an empty canvas.
3535
+ if ( ! this.renderer.backend?.isWebGPUBackend ) {
3536
+
3537
+ throw new Error(
3538
+ 'WebGPU backend unavailable — three.js fell back to WebGL2, which cannot run the ' +
3539
+ 'path tracer\'s compute kernels. Check GPU process flags and driver support.'
3540
+ );
3541
+
3542
+ }
3543
+
3544
+ // Detect GPU device loss (dGPU/iGPU switch, driver reset, TDR watchdog on heavy
3545
+ // compute). Without this the rAF loop keeps calling render() on a dead device,
3546
+ // spewing errors forever. reason 'destroyed' during dispose() is intentional teardown.
3547
+ //
3548
+ // Neither handler may outlive dispose() still holding this app. The device itself does
3549
+ // outlive it: three's RenderObjects.dispose() drops its chain maps without disposing
3550
+ // the render objects, and its Textures manager leaves a listener on every module-level
3551
+ // texture singleton, so backend and device stay reachable for the page's lifetime. A
3552
+ // handler capturing `this` therefore pinned the whole app graph — ~120 MiB of triangle,
3553
+ // BVH and CPU-side texture data per create/dispose cycle. `lost` is a pending promise
3554
+ // whose reaction cannot be unregistered, so it reads the app out of a holder that
3555
+ // dispose() empties; `onuncapturederror` is cleared there directly.
3556
+ const gpuDevice = this.renderer.backend?.device;
3557
+ if ( gpuDevice?.lost ) {
3558
+
3559
+ this._gpuDevice = gpuDevice;
3560
+ this._deviceLostHolder = { app: this };
3561
+ attachDeviceLostHandler( gpuDevice, this._deviceLostHolder );
3562
+ gpuDevice.onuncapturederror = ( event ) => log.error( 'WebGPU uncaptured error:', event.error );
3563
+
3564
+ }
3565
+
3566
+ RectAreaLightNode.setLTC( RectAreaLightTexturesLib.init() );
3567
+
3568
+ this.renderer.outputColorSpace = SRGBColorSpace;
3569
+ this.renderer.toneMapping = this._profile.toneMapping;
3570
+ this.renderer.toneMappingExposure = 1.0;
3571
+ this.renderer.setPixelRatio( 1.0 );
3572
+
3573
+ // Hands every registered view transform to the renderer and keeps `outputColorSpace` in
3574
+ // step with whichever is selected — an OCIO view already encoded for its display must not
3575
+ // be encoded a second time by the output pass.
3576
+ this.color.attachRenderer( this.renderer );
3577
+
3578
+ }
3579
+
3580
+ _initScenes() {
3581
+
3582
+ this.scene = new Scene();
3583
+ this.meshScene = new Scene();
3584
+ this._sceneHelpers = new SceneHelpers();
3585
+
3586
+ }
3587
+
3588
+ _initAssetPipeline() {
3589
+
3590
+ this._sdf = new SceneProcessor( {
3591
+ issues: this._issues,
3592
+ // Spread into defaults, so only pass it when the host actually set one.
3593
+ ...( this._maxSceneBytes === undefined ? {} : { maxSceneBytes: this._maxSceneBytes } ),
3594
+ } );
3595
+ this.assetLoader = new AssetLoader( this.meshScene, this.camera, null, { issues: this._issues, profile: this._profile } );
3596
+ this.assetLoader.setRenderer( this.renderer );
3597
+ this.assetLoader.createFloorPlane();
3598
+
3599
+ }
3600
+
3601
+ _initPipeline() {
3602
+
3603
+ this._createStages();
3604
+
3605
+ const { clientWidth: w, clientHeight: h } = this.canvas;
3606
+ this.pipeline = new RenderPipeline( this.renderer, w || 1, h || 1, { issues: this._issues } );
3607
+
3608
+ this.pipeline.addStage( this.stages.pathTracer );
3609
+ for ( const stage of this._extraStages ) this.pipeline.addStage( stage );
3610
+ this.pipeline.addStage( this.stages.compositor );
3611
+
3612
+ const initRenderW = this.canvas.clientWidth || 1;
3613
+ const initRenderH = this.canvas.clientHeight || 1;
3614
+ this._displayWidth = initRenderW;
3615
+ this._displayHeight = initRenderH;
3616
+ this._renderWidth = initRenderW;
3617
+ this._renderHeight = initRenderH;
3618
+ this.pipeline.setSize( initRenderW, initRenderH );
3619
+
3620
+ }
3621
+
3622
+ async _initManagers() {
3623
+
3624
+ this.lightManager = new LightManager( this.scene, this._sceneHelpers, this.stages.pathTracer, {
3625
+ onReset: () => this.reset(),
3626
+ onLightRemoved: light => this._lightRemoved( light ),
3627
+ } );
3628
+
3629
+ // Expose environment manager (lives on pathTracer stage)
3630
+ this.environmentManager = this.stages.pathTracer.environment;
3631
+ this.environmentManager.issues = this._issues;
3632
+ this.stages.pathTracer.issues = this._issues;
3633
+ this.environmentManager.callbacks.onLightingChanged = () => this.pipeline.eventBus.emit( 'pipeline:lightingChanged' );
3634
+ // A whole-app reset, not the stage's: a sky bake lands after its input, often once the loop is idle.
3635
+ this.environmentManager.callbacks.onReset = () => this.reset();
3636
+
3637
+ }
3638
+
3639
+ _wireEvents() {
3640
+
3641
+ this.settings.bind( this._settingsBindings() );
3642
+
3643
+ this.renderer.toneMappingExposure = this.settings.get( 'exposure' ) ?? 1.0;
3644
+
3645
+ // Resize handling
3646
+ this.onResize();
3647
+ this.resizeHandler = () => this.onResize();
3648
+ if ( this._autoResize ) {
3649
+
3650
+ this._addTrackedListener( window, 'resize', this.resizeHandler );
3651
+
3652
+ }
3653
+
3654
+ // Asset load events
3655
+ this._onAssetLoaded = async ( event ) => {
3656
+
3657
+ if ( this._loadingInProgress ) return;
3658
+
3659
+ // The authored-environment fetch runs through this same AssetLoader, so its 'load'
3660
+ // event lands right back here. We install that texture ourselves — without the
3661
+ // guard the texture branch below would rebuild the CDF a second time and
3662
+ // resetLoading() would tear down the overlay mid-upload. Model events still pass:
3663
+ // a user can drop another file while the environment is in flight.
3664
+ if ( this._applyingSceneMetadata && event.texture ) return;
3665
+
3666
+ if ( event.model ) {
3667
+
3668
+ // Drag-drop / file load is a replace: clear any appended models first.
3669
+ this._clearAppendedModels();
3670
+ await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
3671
+
3672
+ } else if ( event.texture ) {
3673
+
3674
+ const envTexture = this.meshScene.environment;
3675
+ if ( envTexture && this.stages.pathTracer ) {
3676
+
3677
+ await this.stages.pathTracer.environment.setEnvironmentMap( envTexture );
3678
+
3679
+ }
3680
+
3681
+ resetLoading();
3682
+
3683
+ }
3684
+
3685
+ this.pauseRendering = false;
3686
+ this.reset();
3687
+
3688
+ };
3689
+
3690
+ this._addTrackedListener( this.assetLoader, 'load', this._onAssetLoaded );
3691
+
3692
+ this.pipeline.eventBus.on( 'pathtracer:compiling', ( { compiling } ) => this.dispatchEvent( { type: EngineEvents.SHADERS_COMPILING, compiling } ) );
3693
+
3694
+ }
3695
+
3696
+ // ═══════════════════════════════════════════════════════════════
3697
+ // Private — Stage creation & setup
3698
+ // ═══════════════════════════════════════════════════════════════
3699
+
3700
+ _createStages() {
3701
+
3702
+ this.stages.pathTracer = new PathTracer( this.renderer, this.scene, this.camera, { hostMemoryGB: this._hostMemoryGB } );
3703
+ this._extraStages = this._createExtraStages();
3704
+ this.stages.compositor = new Compositor( this.renderer, {
3705
+ saturation: this.settings.get( 'saturation' ) ?? DEFAULT_STATE.saturation,
3706
+ pathTracer: this.stages.pathTracer,
3707
+ displaySources: this._displaySources(),
3708
+ } );
3709
+
3710
+ }
3711
+
3712
+ _reconcileCompletion() {
3713
+
3714
+ const stage = this.stages.pathTracer;
3715
+ if ( ! stage ) return;
3716
+
3717
+ const shouldBeComplete = this.completion.isLimitReached(
3718
+ stage, this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' )
3719
+ );
3720
+
3721
+ if ( shouldBeComplete && ! stage.isComplete ) {
3722
+
3723
+ stage.isComplete = true;
3724
+
3725
+ } else if ( ! shouldBeComplete && stage.isComplete ) {
3726
+
3727
+ stage.isComplete = false;
3728
+ this.completion.resumeFromPause();
3729
+ this._completionReopened();
3730
+
3731
+ this.dispatchEvent( { type: EngineEvents.RENDER_RESET } );
3732
+ this.wake();
3733
+
3734
+ }
3735
+
3736
+ }
3737
+
3738
+ /**
3739
+ * Forwards events from a source EventDispatcher to this app instance.
3740
+ */
3741
+ _forwardEvents( source, eventTypes ) {
3742
+
3743
+ if ( ! source ) return;
3744
+ for ( const type of eventTypes ) {
3745
+
3746
+ this._addTrackedListener( source, type, ( e ) => this.dispatchEvent( e ) );
3747
+
3748
+ }
3749
+
3750
+ }
3751
+
3752
+ // ═══════════════════════════════════════════════════════════════
3753
+ // Hooks — PathTracerApp overrides these. Each runs at a fixed point and does nothing here.
3754
+ // ═══════════════════════════════════════════════════════════════
3755
+
3756
+ /** The camera rendered from, made once in init(). */
3757
+ _createCamera() {
3758
+
3759
+ const camera = new ViewCamera( 60, this.canvas.clientWidth / this.canvas.clientHeight || 1, 0.01, 1000 );
3760
+ camera.position.set( 0, 0, 5 );
3761
+ return camera;
3762
+
3763
+ }
3764
+
3765
+ /** Stages run between the path tracer and the compositor, in pipeline order. */
3766
+ _createExtraStages() {
3767
+
3768
+ return [];
3769
+
3770
+ }
3771
+
3772
+ /** Context keys of pictures the compositor shows instead of the accumulation, first published wins. */
3773
+ _displaySources() {
3774
+
3775
+ return [];
3776
+
3777
+ }
3778
+
3779
+ /** What RenderSettings routes to. */
3780
+ _settingsBindings() {
3781
+
3782
+ return {
3783
+ stages: this.stages,
3784
+ resetCallback: () => this.reset(),
3785
+ reconcileCompletion: () => this._reconcileCompletion(),
3786
+ // three.js applies it inside the tone-mapping branch, so NoToneMapping ignores it
3787
+ applyExposure: ( value ) => {
3788
+
3789
+ this.renderer.toneMappingExposure = value;
3790
+
3791
+ },
3792
+ };
3793
+
3794
+ }
3795
+
3796
+ /** Start of a loop frame, before a pending reset applies. */
3797
+ _beginFrame() {}
3798
+
3799
+ /** Just before a loop frame traces. */
3800
+ _beforeTrace() {}
3801
+
3802
+ /** True skips tracing this loop frame. */
3803
+ _holdTrace( /* cameraMoved */ ) {
3804
+
3805
+ return false;
3806
+
3807
+ }
3808
+
3809
+ /** After every traced frame; `live` from the render loop. */
3810
+ _afterTrace( /* stage, live */ ) {}
3811
+
3812
+ /** Once, on the loop frame the render completes. */
3813
+ _renderCompleted() {}
3814
+
3815
+ /** Draws what sits over the image. Here only brings the light scene's matrices up to date. */
3816
+ _renderHelperOverlay() {
3817
+
3818
+ this.scene.updateMatrixWorld();
3819
+
3820
+ }
3821
+
3822
+ /** Before accumulation restarts; `keepHistory` for a soft or motion reset. */
3823
+ _beforeReset( /* keepHistory */ ) {}
3824
+
3825
+ /** After the pipeline restarted accumulation. */
3826
+ _afterReset() {}
3827
+
3828
+ /** The picture shown no longer matches the scene. */
3829
+ _dropDisplay() {}
3830
+
3831
+ /** The scene is about to be rebuilt, unloaded or lose an object. */
3832
+ _releaseSceneState( /* { keepSelection } */ ) {}
3833
+
3834
+ /** A replace-load landed its model, before the rebuild. */
3835
+ _modelReplaced() {}
3836
+
3837
+ /** After a replace-load's event was dispatched. */
3838
+ _modelAnnounced() {}
3839
+
3840
+ /** End of a scene rebuild, before SCENE_REBUILD. */
3841
+ _sceneRebuilt() {}
3842
+
3843
+ /** An append or removal changed the scene's bounds without reframing it. */
3844
+ _sceneBoundsChanged() {}
3845
+
3846
+ /** Placements of these meshes are about to move. */
3847
+ _notePlacementsMoving( /* meshIndices */ ) {}
3848
+
3849
+ /** A light was removed. */
3850
+ _lightRemoved( /* light */ ) {}
3851
+
3852
+ /** The render size changed (before the render scale). */
3853
+ _renderSizeChanged( /* width, height */ ) {}
3854
+
3855
+ /** The sample limit rose above a finished render, which resumes. */
3856
+ _completionReopened() {}
3857
+
3858
+ /** renderUntilComplete()'s closing pass; resolves whether one ran. */
3859
+ async _finalDenoise() {
3860
+
3861
+ return false;
3862
+
3863
+ }
3864
+
3865
+ /** Whether a 'display' read should find a processed picture. */
3866
+ _denoiserInUse() {
3867
+
3868
+ return false;
3869
+
3870
+ }
3871
+
3872
+ /** A canvas other than the renderer's that shows the image, or null. */
3873
+ _displayCanvas() {
3874
+
3875
+ return null;
3876
+
3877
+ }
3878
+
3879
+ /** More VRAM categories, between 'stages' and 'canvas'. */
3880
+ _registerVRAM( /* tracker */ ) {}
3881
+
3882
+ /** GPU resources counted with the canvas. */
3883
+ _canvasResources() {
3884
+
3885
+ return [];
3886
+
3887
+ }
3888
+
3889
+ /** Deterministic mode: what to put back when it ends. */
3890
+ _deterministicSnapshot() {
3891
+
3892
+ return null;
3893
+
3894
+ }
3895
+
3896
+ /** Deterministic mode: pin (enabled), or put back from the snapshot. */
3897
+ _setDeterministicExtras( /* enabled, snapshot */ ) {}
3898
+
3899
+ /** Frees what a layer above built, before the core's own teardown. */
3900
+ _disposeExtensions() {}
3901
+
3902
+ }