rayzee 9.4.1 → 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.
- package/README.md +226 -25
- package/dist/ActiveColor-YvuQq44O.js +13 -0
- package/dist/ArchiveImporter-xuUJiklP.js +4301 -0
- package/dist/AssetConfig-DSEntE65.js +30 -0
- package/dist/BidirectionalIntegrator-C5PKjJoT.js +934 -0
- package/dist/ColorManagement-CheBCPaW.js +1488 -0
- package/dist/EngineDefaults-DH3bI5t-.js +844 -0
- package/dist/EngineEvents-Ci8dVWCP.js +73 -0
- package/dist/EngineIssues-CIZnXHsF.js +72 -0
- package/dist/FileFormats-sPGJeCRc.js +102 -0
- package/dist/Logger-BnHghprG.js +97 -0
- package/dist/{NeuralRetouch-vkdXXyK2.js → NeuralRetouch-CW11yLZ_.js} +8 -6
- package/dist/{NeuralSuperRes-FVqRJKQV.js → NeuralSuperRes-BekaecYg.js} +55 -120
- package/dist/PathTracer-Ccl9KOuF.js +29729 -0
- package/dist/PhysicalSky-Dv2PMFGW.js +1219 -0
- package/dist/RayzeeRenderer-BQxnzCTD.js +3542 -0
- package/dist/SolarLimb-ken-JbDK.js +721 -0
- package/dist/ToneMapGPU-BHi5JzIq.js +177 -0
- package/dist/ViewTransforms-D0rTLBsX.js +255 -0
- package/dist/WorkingMatrix-C6y233hT.js +27 -0
- package/dist/ZipReader-gDSWr0I5.js +98 -0
- package/dist/addons/archives.es.js +3 -0
- package/dist/addons/bidirectional.es.js +2 -0
- package/dist/addons/color.es.js +3 -0
- package/dist/addons/formats.es.js +2 -0
- package/dist/addons/physical-sky.es.js +2 -0
- package/dist/addons/storage.es.js +3 -0
- package/dist/archiveFormats-BZw7TiDp.js +75 -0
- package/dist/identity-DrQsGT2o.js +39 -0
- package/dist/openStorage-DwcBRUj7.js +820 -0
- package/dist/rayzee-core.es.js +12 -0
- package/dist/rayzee.es.js +2266 -44125
- package/dist/rayzee.umd.js +485 -473
- package/dist/sceneCachePolicy-BmwYOYDu.js +183 -0
- package/dist/shared-BGBcSxBQ.js +17 -0
- package/package.json +23 -2
- package/src/Color/ActiveColor.js +34 -0
- package/src/Color/BasicColor.js +147 -0
- package/src/Color/ColorManagement.js +7 -31
- package/src/EngineDefaults.js +23 -14
- package/src/EngineEvents.js +2 -0
- package/src/EngineIssues.js +1 -0
- package/src/PathTracerApp.js +617 -4016
- package/src/Pipeline/PipelineContext.js +0 -4
- package/src/Platform.js +36 -0
- package/src/Processor/ArchiveCache.js +1 -1
- package/src/Processor/ArchiveImporter.js +1259 -0
- package/src/Processor/AssetLoader.js +322 -1746
- package/src/Processor/AtmosphereModel.js +1 -2
- package/src/Processor/BVHBuilder.js +4 -95
- package/src/Processor/EnvironmentExactTable.js +187 -0
- package/src/Processor/EquirectHDRInfo.js +30 -223
- package/src/Processor/FileFormats.js +116 -0
- package/src/Processor/GLTFDecoders.js +85 -0
- package/src/Processor/GLTFDiffuseTransmission.js +63 -0
- package/src/Processor/GeometryExtractor.js +12 -1
- package/src/Processor/IESParser.js +47 -35
- package/src/Processor/KernelManager.js +35 -2
- package/src/Processor/MaterialPacking.js +8 -7
- package/src/Processor/PBRT/PBRTMaterials.js +129 -41
- package/src/Processor/PBRT/PBRTParser.js +30 -17
- package/src/Processor/PBRT/PBRTSceneBuilder.js +548 -37
- package/src/Processor/PBRT/PBRTTextureBake.js +154 -0
- package/src/Processor/PBRT/PFM.js +59 -0
- package/src/Processor/PackedRayBuffer.js +116 -9
- package/src/Processor/ParallelBVHBuilder.js +3 -6
- package/src/Processor/PhysicalSky.js +5 -4
- package/src/Processor/PlatformImageLoader.js +50 -6
- package/src/Processor/QueueManager.js +12 -3
- package/src/Processor/ReinsertionOptimizer.js +2 -2
- package/src/Processor/SceneProcessor.js +15 -55
- package/src/Processor/ShaderBuilder.js +6 -16
- package/src/Processor/SimpleSky.js +1 -62
- package/src/Processor/SolarLimb.js +3 -0
- package/src/Processor/TextureCreator.js +1 -1
- package/src/Processor/Workers/BVHSubtreeWorker.js +1 -34
- package/src/Processor/Workers/BVHWorker.js +5 -18
- package/src/Processor/Workers/CDFWorker.js +6 -183
- package/src/Processor/archiveFormats.js +7 -0
- package/src/Processor/utils.js +2 -1
- package/src/RayzeeRenderer.js +3902 -0
- package/src/RenderSettings.js +49 -41
- package/src/Stages/ASVGF.js +5 -7
- package/src/Stages/AutoExposure.js +3 -12
- package/src/Stages/Compositor.js +8 -23
- package/src/Stages/NRD.js +9 -2
- package/src/Stages/NormalDepth.js +9 -6
- package/src/Stages/PathTracer.js +254 -38
- package/src/Stages/PathTracerStage.js +11 -16
- package/src/Storage/BLASCache.js +1 -1
- package/src/Storage/CDFCache.js +18 -17
- package/src/Storage/DownloadCache.js +1 -1
- package/src/Storage/SceneGraphCodec.js +46 -3
- package/src/Storage/SpillStore.js +1 -1
- package/src/Storage/StorageManager.js +2 -4
- package/src/Storage/areas.js +4 -0
- package/src/TSL/BVHTraversal.js +62 -24
- package/src/TSL/Bidirectional.js +204 -0
- package/src/TSL/BidirectionalLamps.js +132 -0
- package/src/TSL/Clearcoat.js +15 -6
- package/src/TSL/Common.js +19 -14
- package/src/TSL/ConnectKernel.js +147 -0
- package/src/TSL/Debugger.js +1 -1
- package/src/TSL/Displacement.js +4 -3
- package/src/TSL/EmissiveSampling.js +64 -17
- package/src/TSL/Environment.js +77 -106
- package/src/TSL/EnvironmentCDF.js +85 -39
- package/src/TSL/ExtendKernel.js +8 -3
- package/src/TSL/FinalWriteKernel.js +4 -4
- package/src/TSL/GenerateKernel.js +18 -4
- package/src/TSL/HitFacet.js +35 -5
- package/src/TSL/LightBVHSampling.js +40 -9
- package/src/TSL/LightGenerateKernel.js +293 -0
- package/src/TSL/LightGuide.js +198 -0
- package/src/TSL/LightSplatKernel.js +165 -0
- package/src/TSL/LightsCore.js +13 -38
- package/src/TSL/LightsDirect.js +28 -29
- package/src/TSL/LightsIndirect.js +3 -1
- package/src/TSL/LightsSampling.js +90 -38
- package/src/TSL/MaterialEvaluation.js +113 -31
- package/src/TSL/MaterialProperties.js +44 -17
- package/src/TSL/MaterialTransmission.js +52 -47
- package/src/TSL/MergeKernel.js +218 -0
- package/src/TSL/PathTracerCore.js +65 -29
- package/src/TSL/Random.js +4 -0
- package/src/TSL/SceneResources.js +51 -0
- package/src/TSL/ShadeKernel.js +881 -103
- package/src/TSL/Struct.js +10 -0
- package/src/TSL/Sun.js +1 -1
- package/src/TSL/TextureSampling.js +65 -60
- package/src/addons/archives.js +14 -0
- package/src/addons/bidirectional.js +13 -0
- package/src/addons/color.js +32 -0
- package/src/addons/formats.js +16 -0
- package/src/addons/physicalSky.js +14 -0
- package/src/addons/storage.js +19 -0
- package/src/core.js +35 -0
- package/src/index.js +3 -1
- package/src/integrators/BidirectionalIntegrator.js +832 -0
- package/src/managers/EnvironmentManager.js +94 -91
- package/src/managers/MaterialDataManager.js +42 -0
- package/src/managers/UniformManager.js +0 -1
- package/dist/ToneMapGPU-DKQM1pMd.js +0 -552
- package/dist/rolldown-runtime-CAFD8bLK.js +0 -11
- 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
|
+
}
|