rayzee 9.1.2 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +174 -15
  2. package/dist/{NeuralRetouch-BRquqfmZ.js → NeuralRetouch-DgnfldWO.js} +1 -1
  3. package/dist/{NeuralSuperRes-CbNvQjCw.js → NeuralSuperRes-Baa2w8VU.js} +72 -40
  4. package/dist/{ToneMapGPU-DpUZDMbQ.js → ToneMapGPU-CNgexVto.js} +75 -71
  5. package/dist/rayzee.es.js +4915 -4314
  6. package/dist/rayzee.umd.js +96 -96
  7. package/package.json +3 -2
  8. package/src/AssetConfig.js +10 -5
  9. package/src/EngineEvents.js +41 -1
  10. package/src/EngineIssues.js +3 -0
  11. package/src/Headless.js +23 -7
  12. package/src/HeadlessCanvas.js +104 -0
  13. package/src/Passes/OIDNDenoiser.js +29 -4
  14. package/src/Passes/OIDNTemporalHistory.js +7 -0
  15. package/src/PathTracerApp.js +432 -119
  16. package/src/Platform.js +76 -0
  17. package/src/Processor/AssetLoader.js +66 -4
  18. package/src/Processor/BVHBuilder.js +2 -1
  19. package/src/Processor/EquirectHDRInfo.js +2 -1
  20. package/src/Processor/HostMemory.js +14 -0
  21. package/src/Processor/PBRT/PBRTSceneBuilder.js +26 -0
  22. package/src/Processor/ParallelBVHBuilder.js +7 -6
  23. package/src/Processor/PlatformImageLoader.js +118 -0
  24. package/src/Processor/ResampleRGBA8.js +102 -0
  25. package/src/Processor/SceneProcessor.js +7 -6
  26. package/src/Processor/TextureCreator.js +162 -13
  27. package/src/Processor/Workers/PackWorker.js +28 -0
  28. package/src/Stages/Compositor.js +25 -6
  29. package/src/Stages/PathTracer.js +142 -25
  30. package/src/Stages/PathTracerStage.js +6 -96
  31. package/src/TSL/DebugKernel.js +1 -1
  32. package/src/TSL/GenerateKernel.js +2 -5
  33. package/src/TSL/Random.js +3 -120
  34. package/src/TSL/ShadeKernel.js +3 -3
  35. package/src/index.js +2 -1
  36. package/src/managers/CameraManager.js +2 -2
  37. package/src/managers/DenoisingManager.js +76 -7
  38. package/src/managers/GoboManager.js +8 -1
  39. package/src/managers/IESManager.js +13 -2
  40. package/src/managers/InteractionManager.js +1 -1
  41. package/src/managers/LightManager.js +31 -0
  42. package/src/managers/OverlayManager.js +11 -1
  43. package/src/managers/VideoRenderManager.js +8 -0
  44. package/src/node/NodeWorker.js +144 -0
  45. package/src/node/index.js +9 -0
  46. package/src/node/nodePlatform.js +86 -0
@@ -1,11 +1,7 @@
1
1
  import { storage } from 'three/tsl';
2
2
  import { gpuOnlyStorageAttribute, uploadStorageChunkRange, uploadStorageChunks } from '../TSL/patches.js';
3
3
  import { StorageInstancedBufferAttribute } from 'three/webgpu';
4
- import {
5
- NearestFilter, Vector2, Matrix4,
6
- TextureLoader, RepeatWrapping
7
- } from 'three';
8
- import { stbnScalarTextureNode, stbnVec2TextureNode } from '../TSL/Random.js';
4
+ import { Vector2, Matrix4 } from 'three';
9
5
 
10
6
  // Pipeline system
11
7
  import { RenderStage, StageExecutionMode } from '../Pipeline/RenderStage.js';
@@ -26,8 +22,6 @@ import { LightSerializer } from '../Processor/LightSerializer';
26
22
  // Constants
27
23
  import { ENGINE_DEFAULTS as DEFAULT_STATE, TRIANGLE_DATA_LAYOUT } from '../EngineDefaults.js';
28
24
  import { TRI_GEO_ROWS, TRI_SHADE_ROWS } from '../TSL/Common.js';
29
- import { getAssetConfig } from '../AssetConfig.js';
30
- import { cachedObjectURL, DOWNLOAD_POLICY } from '../Storage/DownloadCache.js';
31
25
  import { createLogger, fmt } from '../utils/Logger.js';
32
26
 
33
27
  // Triangles converted per staging pass: 256K is 12 + 8 MB of staging, kept for the stage's life.
@@ -144,9 +138,6 @@ export class PathTracerStage extends RenderStage {
144
138
  // Initialize rendering state
145
139
  this._initRenderingState();
146
140
 
147
- // Setup blue noise
148
- this.setupBlueNoise();
149
-
150
141
  // Cache frequently used objects
151
142
  this.tempVector2 = new Vector2();
152
143
  this.lastCameraMatrix = new Matrix4();
@@ -216,14 +207,8 @@ export class PathTracerStage extends RenderStage {
216
207
  // and refreshes the bound TextureNode in-place when it changes.
217
208
  this.iesProfiles = null;
218
209
 
219
- // STBN noise textures
220
- this.stbnScalarTexture = null;
221
- this.stbnVec2Texture = null;
222
-
223
210
  /**
224
- * Resolves once both STBN atlases have loaded. Await before any render whose
225
- * output must be reproducible — see PathTracerApp.setDeterministicMode().
226
- * Replaced by the real promise in setupBlueNoise(), which runs after this.
211
+ * @deprecated Always resolved: the engine no longer loads blue-noise textures.
227
212
  * @type {Promise<void>}
228
213
  */
229
214
  this.blueNoiseReady = Promise.resolve();
@@ -369,74 +354,6 @@ export class PathTracerStage extends RenderStage {
369
354
 
370
355
  }
371
356
 
372
- /**
373
- * Load STBN (Spatiotemporal Blue Noise) atlas textures.
374
- * Each atlas is 1024×1024: 8×8 grid of 128×128 tiles, 64 temporal slices.
375
- */
376
- setupBlueNoise() {
377
-
378
- const loader = new TextureLoader();
379
- loader.setCrossOrigin( 'anonymous' );
380
-
381
- const configure = ( tex ) => {
382
-
383
- tex.minFilter = NearestFilter;
384
- tex.magFilter = NearestFilter;
385
- tex.wrapS = RepeatWrapping;
386
- tex.wrapT = RepeatWrapping;
387
- tex.generateMipmaps = false;
388
- return tex;
389
-
390
- };
391
-
392
- const { stbnScalarAtlas, stbnVec2Atlas } = getAssetConfig();
393
-
394
- const load = async ( url ) => {
395
-
396
- const source = await cachedObjectURL( url, { policy: DOWNLOAD_POLICY.REVALIDATE } );
397
- try {
398
-
399
- return await loader.loadAsync( source.url );
400
-
401
- } finally {
402
-
403
- source.release();
404
-
405
- }
406
-
407
- };
408
-
409
- const scalarLoad = load( stbnScalarAtlas ).then( ( tex ) => {
410
-
411
- this.stbnScalarTexture = configure( tex );
412
- stbnScalarTextureNode.value = tex;
413
- log.debug( `STBN scalar atlas ${fmt.px( tex.image.width, tex.image.height )}` );
414
-
415
- } );
416
-
417
- const vec2Load = load( stbnVec2Atlas ).then( ( tex ) => {
418
-
419
- this.stbnVec2Texture = configure( tex );
420
- stbnVec2TextureNode.value = tex;
421
- log.debug( `STBN vec2 atlas ${fmt.px( tex.image.width, tex.image.height )}` );
422
-
423
- } );
424
-
425
- // Until the atlases land the STBN sampler reads a constant-0.5 placeholder and bakes
426
- // that degenerate "noise" permanently into the accumulation buffer. The cut-over frame
427
- // is disk/network dependent, so reproducible renders must await this. Never rejects.
428
- this.blueNoiseReady = Promise.allSettled( [ scalarLoad, vec2Load ] ).then( ( results ) => {
429
-
430
- for ( const result of results ) {
431
-
432
- if ( result.status === 'rejected' ) log.warn( 'STBN atlas failed to load', result.reason );
433
-
434
- }
435
-
436
- } );
437
-
438
- }
439
-
440
357
  /**
441
358
  * Setup event listeners for pipeline events
442
359
  */
@@ -646,8 +563,8 @@ export class PathTracerStage extends RenderStage {
646
563
  // ASVGF's history. A real mode change is still caught by the renderMode comparison.
647
564
  this.lastInteractionModeState = false;
648
565
 
649
- // Only deterministic mode rewinds the seed axis; see _pinSeedToFrame.
650
- if ( this._pinSeedToFrame ) this._seedTick = 0;
566
+ // Only deterministic mode and lockstep readbacks rewind the seed axis; see _pinSeedToFrame.
567
+ if ( this._pinSeedToFrame || this._lockstep ) this._seedTick = 0;
651
568
 
652
569
  }
653
570
 
@@ -1544,13 +1461,8 @@ export class PathTracerStage extends RenderStage {
1544
1461
 
1545
1462
  }
1546
1463
 
1547
- setBlueNoiseTexture( tex ) {
1548
-
1549
- // Legacy API — sets the scalar STBN atlas texture
1550
- this.stbnScalarTexture = tex;
1551
- if ( tex ) stbnScalarTextureNode.value = tex;
1552
-
1553
- }
1464
+ /** @deprecated No shader reads a blue-noise texture any more; kept so existing callers do not break. */
1465
+ setBlueNoiseTexture() {}
1554
1466
 
1555
1467
  /**
1556
1468
  * Rebuild the packed light buffer from cached lightBVH + emissive data.
@@ -1744,8 +1656,6 @@ export class PathTracerStage extends RenderStage {
1744
1656
  this.storageTextures?.dispose();
1745
1657
 
1746
1658
  // Dispose textures
1747
- this.stbnScalarTexture?.dispose();
1748
- this.stbnVec2Texture?.dispose();
1749
1659
  this.placeholderTexture?.dispose();
1750
1660
 
1751
1661
  // Clear data references
@@ -59,7 +59,7 @@ export function buildDebugKernel( params ) {
59
59
  If( visMode.equal( int( 9 ) ), () => {
60
60
 
61
61
  // One ray per pixel — plain per-frame jitter (totalRays = 1 → random, no stratified lattice).
62
- const jitter = getStratifiedSample( pixelCoord, int( 0 ), int( 1 ), seed, resolution, frame );
62
+ const jitter = getStratifiedSample( pixelCoord, int( 0 ), seed, resolution, frame );
63
63
  color.assign( vec4( jitter, 1.0, 1.0 ) );
64
64
 
65
65
  } ).Else( () => {
@@ -59,11 +59,8 @@ export function buildGenerateKernel( params ) {
59
59
  const baseSeed = getDecorrelatedSeed( { pixelCoord, rayIndex: int( 0 ), frame } ).toVar();
60
60
  const seed = pcgHash( { state: baseSeed } ).toVar();
61
61
 
62
- // Sample index 1 (not 0) so the AA sub-pixel jitter draws a DIFFERENT STBN cell
63
- // than the first-bounce BSDF sample (ShadeKernel uses sampleIndex 0). Every bounce
64
- // samples at index 0, so index 1 is collision-free — this decorrelates the sub-pixel
65
- // position from the first scatter direction (they were reading the identical cell).
66
- const stratifiedJitter = getStratifiedSample( pixelCoord, int( 1 ), int( 1 ), seed, resolution, frame ).toVar();
62
+ // Sample index 1, not the bounces' 0, so the sub-pixel position is independent of the first scatter.
63
+ const stratifiedJitter = getStratifiedSample( pixelCoord, int( 1 ), seed, resolution, frame ).toVar();
67
64
 
68
65
  // Y is subtracted because uv.y grows downward while the NDC this used to build grew upward —
69
66
  // keeps the sub-pixel sample sequence bit-identical to the pre-panorama ray gen.
package/src/TSL/Random.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // Three.js Transpiler r182
2
2
 
3
- import { uniform, texture, float, If, wgslFn, uint, TWO_PI, cos, sin, vec2, sqrt, fract, mod, ivec2, select, int, vec4, mix } from 'three/tsl';
4
- import { DataTexture, FloatType } from 'three';
3
+ import { uniform, float, If, wgslFn, uint, TWO_PI, cos, sin, vec2, sqrt, select, int, vec4 } from 'three/tsl';
5
4
 
6
5
  // -----------------------------------------------------------------------------
7
6
  // Uniform declarations and constants
@@ -12,31 +11,6 @@ const samplingTechnique = samplingTechniqueUniform;
12
11
 
13
12
  // 0: PCG, 1: Halton, 2: Owen-scrambled Sobol (default; anything higher falls back to it)
14
13
 
15
- // 1x1 placeholder — real texture assigned later via .value = ...
16
- const _placeholderData = new Float32Array( [ 0.5, 0.5, 0.5, 1.0 ] );
17
-
18
- const _placeholderScalar = new DataTexture( _placeholderData, 1, 1 );
19
- _placeholderScalar.type = FloatType;
20
- _placeholderScalar.needsUpdate = true;
21
-
22
- const _placeholderVec2 = new DataTexture( new Float32Array( [ 0.5, 0.5, 0.0, 1.0 ] ), 1, 1 );
23
- _placeholderVec2.type = FloatType;
24
- _placeholderVec2.needsUpdate = true;
25
-
26
- // STBN (Spatiotemporal Blue Noise) atlas textures — Heitz 2019
27
- // Each atlas: 1024×1024, 8×8 grid of 128×128 tiles, 64 temporal slices
28
- // Scalar atlas: single-channel (R) — optimal for 1D decisions (RR, lobe selection)
29
- // Vec2 atlas: two-channel (R,G) — decorrelated 2D pairs (direction sampling Xi)
30
- export const stbnScalarTextureNode = texture( _placeholderScalar );
31
- stbnScalarTextureNode.setUpdateMatrix( false );
32
-
33
- export const stbnVec2TextureNode = texture( _placeholderVec2 );
34
- stbnVec2TextureNode.setUpdateMatrix( false );
35
-
36
- // R2 quasi-random sequence constants (Roberts 2018) — optimal 2D additive offsets
37
- const R2_A1 = float( 0.7548776662466927 );
38
- const R2_A2 = float( 0.5698402909980532 );
39
-
40
14
  // -----------------------------------------------------------------------------
41
15
  // Basic random number generation
42
16
  // -----------------------------------------------------------------------------
@@ -160,53 +134,6 @@ export const RandomPointInCircle = ( rngState ) => {
160
134
  export const SAMPLER_DIMS_PER_BOUNCE = 32;
161
135
  export const SAMPLER_DIM_AUX_BASE = 4096;
162
136
 
163
- // -----------------------------------------------------------------------------
164
- // STBN atlas sampling — spatiotemporal blue noise
165
- // -----------------------------------------------------------------------------
166
- // Atlas layout: 8×8 grid of 128×128 tiles = 1024×1024 texture.
167
- // Temporal axis: frame % 64 selects tile (true STBN temporal decorrelation).
168
- // Spatial decorrelation: R2 quasi-random offset keyed on dimension + sample index.
169
-
170
- const computeSTBNAtlasCoord = ( pixelCoords, sampleIndex, dimensionIndex, frame ) => {
171
-
172
- // Temporal slice — true STBN temporal axis
173
- const slice = uint( frame ).bitAnd( uint( 63 ) ); // frame % 64
174
-
175
- // R2 quasi-random spatial offset for per-dimension/per-sample decorrelation
176
- const n = float( dimensionIndex ).add( float( sampleIndex ).mul( 7.0 ) );
177
- const offsetX = int( fract( n.mul( R2_A1 ).add( 0.5 ) ).mul( 128.0 ) );
178
- const offsetY = int( fract( n.mul( R2_A2 ).add( 0.5 ) ).mul( 128.0 ) );
179
-
180
- // Pixel within 128×128 tile (toroidal wrap via bitmask)
181
- const px = int( pixelCoords.x ).add( offsetX ).bitAnd( int( 127 ) );
182
- const py = int( pixelCoords.y ).add( offsetY ).bitAnd( int( 127 ) );
183
-
184
- // Atlas tile position from slice index
185
- const tileCol = int( slice ).bitAnd( int( 7 ) ); // slice % 8
186
- const tileRow = int( slice ).shiftRight( int( 3 ) ); // slice / 8
187
-
188
- return ivec2( tileCol.mul( int( 128 ) ).add( px ), tileRow.mul( int( 128 ) ).add( py ) );
189
-
190
- };
191
-
192
- // Sample decorrelated 2D STBN pair in [0,1]²
193
- export const sampleSTBN2D = ( pixelCoords, sampleIndex, dimensionPairIndex, frame ) => {
194
-
195
- const coord = computeSTBNAtlasCoord( pixelCoords, sampleIndex, dimensionPairIndex, frame );
196
- const raw = stbnVec2TextureNode.load( coord ).xy;
197
-
198
- // The atlas has only 64 temporal slices, so frame N and N+64 read the same slice: the
199
- // sample repeats and accumulation stops improving past 64 frames. Decorrelate across
200
- // 64-frame cycles with a Cranley-Patterson rotation (toroidal shift) by an R2 offset
201
- // keyed on the cycle index (frame >> 6). The offset is uniform per cycle, preserving
202
- // spatial and within-window temporal blue noise; cycle 0's offset is 0, so frames
203
- // 0-63 stay bit-identical. A toroidal shift of uniform samples stays uniform (unbiased).
204
- const cycle = float( uint( frame ).shiftRight( uint( 6 ) ) );
205
- const rotation = fract( vec2( R2_A1, R2_A2 ).mul( cycle ) );
206
- return fract( raw.add( rotation ) );
207
-
208
- };
209
-
210
137
  // -----------------------------------------------------------------------------
211
138
  // Low-discrepancy sequence generators
212
139
  // -----------------------------------------------------------------------------
@@ -531,52 +458,8 @@ export const getRandomSample1D = ( pixelCoord, sampleIndex, dimensionIndex, rngS
531
458
 
532
459
  };
533
460
 
534
- // Stratified sample. Both call sites pass totalRays = 1, so only the first branch is live;
535
- // the strata path below is kept for the multi-ray-per-pixel mode that was removed.
536
-
537
- export const getStratifiedSample = ( pixelCoord, rayIndex, totalRays, rngState, resolution, frame ) => {
538
-
539
- // result variable avoids early-return ReturnNode escaping into outer Fn scope
540
- const result = vec2( 0.0 ).toVar();
541
-
542
- If( totalRays.lessThanEqual( int( 1 ) ), () => {
543
-
544
- result.assign( getRandomSample( pixelCoord, rayIndex, int( 0 ), rngState, int( - 1 ), resolution, frame ) );
545
-
546
- } ).Else( () => {
547
-
548
- // Calculate strata dimensions
549
-
550
- const strataX = int( sqrt( float( totalRays ) ) );
551
- const strataY = totalRays.add( strataX ).sub( 1 ).div( strataX );
552
- const strataIdx = mod( rayIndex, strataX.mul( strataY ) );
553
- const sx = mod( strataIdx, strataX );
554
- const sy = strataIdx.div( strataX );
555
-
556
- // Base stratified position
557
-
558
- const strataPos = vec2( float( sx ), float( sy ) ).div( vec2( float( strataX ), float( strataY ) ) );
559
-
560
- const j1 = RandomValueFast( rngState ).toVar();
561
- const j2 = RandomValueFast( rngState ).toVar();
562
- const jitter = vec2( j1, j2 ).toVar();
563
-
564
- If( totalRays.greaterThan( int( 4 ) ), () => {
565
-
566
- const stbnInfluence = sampleSTBN2D( pixelCoord, rayIndex, int( 0 ), frame ).mul( 0.1 );
567
- jitter.assign( mix( jitter, stbnInfluence, 0.2 ) );
568
-
569
- } );
570
-
571
- jitter.divAssign( vec2( float( strataX ), float( strataY ) ) );
572
-
573
- result.assign( strataPos.add( jitter ) );
574
-
575
- } );
576
-
577
- return result;
578
-
579
- };
461
+ export const getStratifiedSample = ( pixelCoord, rayIndex, rngState, resolution, frame ) =>
462
+ getRandomSample( pixelCoord, rayIndex, int( 0 ), rngState, int( - 1 ), resolution, frame );
580
463
 
581
464
  // Get decorrelated seed with better mixing
582
465
 
@@ -201,9 +201,9 @@ export function buildShadeKernel( params ) {
201
201
  const pixelIndex = rayID;
202
202
  const rngState = readRngState( hitBufferRW, rayID ).toVar();
203
203
 
204
- // STBN keyed on (GLOBAL pixel, dimension, frame). pixelIndex is the LOCAL path slot; the global pixel
205
- // = chunkRowBase·W + localSlot, so the blue-noise pattern stays spatially aligned across row-band chunks
206
- // (and matches Generate's per-pixel RNG seed). chunkRowBase is 0 in the single-chunk case.
204
+ // Samples key on the GLOBAL pixel. pixelIndex is the LOCAL path slot; the global pixel
205
+ // = chunkRowBase·W + localSlot, so sequences stay aligned across row-band chunks
206
+ // (and match Generate's per-pixel RNG seed). chunkRowBase is 0 in the single-chunk case.
207
207
  // Hoisted here rather than at the BSDF draw because the ground catcher runs NEE far earlier.
208
208
  const _resX = int( resolution.x ).toVar();
209
209
  const _globalPixel = int( pixelIndex ).add( chunkRowBase.mul( _resX ) );
package/src/index.js CHANGED
@@ -12,7 +12,7 @@ import './TSL/patches.js';
12
12
  export { PathTracerApp, describeAdapter } from './PathTracerApp.js';
13
13
 
14
14
  // Event types
15
- export { EngineEvents } from './EngineEvents.js';
15
+ export { EngineEvents, LEGACY_EVENT_NAMES } from './EngineEvents.js';
16
16
 
17
17
  // Headless rendering — the supported entry point for a caller with no screen
18
18
  export { renderHeadless, openHeadless, captureHeadless } from './Headless.js';
@@ -25,6 +25,7 @@ export { SETTING_SOURCE } from './RenderSettings.js';
25
25
 
26
26
  // Asset URL / cache namespace overrides (call before constructing PathTracerApp)
27
27
  export { configureAssets, getAssetConfig } from './AssetConfig.js';
28
+ export { configurePlatform, getPlatform } from './Platform.js';
28
29
 
29
30
  // On-disk storage (origin private file system): caches, and areas a host defines for its own data
30
31
  export {
@@ -363,7 +363,7 @@ export class CameraManager extends EventDispatcher {
363
363
  onReset?.();
364
364
 
365
365
  this.dispatchEvent( {
366
- type: 'CameraSwitched', cameraIndex: index, effects: this._captureEffects(), fov: this.camera.fov,
366
+ type: EngineEvents.CAMERA_SWITCHED, cameraIndex: index, effects: this._captureEffects(), fov: this.camera.fov,
367
367
  cameraProjection: this._getSettings?.( 'cameraProjection' ),
368
368
  } );
369
369
 
@@ -717,7 +717,7 @@ export class CameraManager extends EventDispatcher {
717
717
  this.resetAutoFocus();
718
718
  this._reportOrthoHeight();
719
719
  this.dispatchEvent( {
720
- type: 'CameraSwitched', cameraIndex: this.currentCameraIndex, effects: this._captureEffects(), fov: this.camera.fov,
720
+ type: EngineEvents.CAMERA_SWITCHED, cameraIndex: this.currentCameraIndex, effects: this._captureEffects(), fov: this.camera.fov,
721
721
  cameraProjection: this._getSettings?.( 'cameraProjection' ),
722
722
  } );
723
723
 
@@ -3,6 +3,7 @@ import { OIDNDenoiser } from '../Passes/OIDNDenoiser.js';
3
3
  import { OIDNTemporalHistory } from '../Passes/OIDNTemporalHistory.js';
4
4
  import { AIUpscaler } from '../Passes/AIUpscaler.js';
5
5
  import { EngineEvents } from '../EngineEvents.js';
6
+ import { ISSUE_CODES } from '../EngineIssues.js';
6
7
  import { createLogger } from '../utils/Logger.js';
7
8
 
8
9
  // The neural passes live in `../neural/` but report through the manager that drives them, so they
@@ -100,12 +101,14 @@ export class DenoisingManager extends EventDispatcher {
100
101
  * @param {import('../Pipeline/RenderPipeline.js').RenderPipeline} params.pipeline
101
102
  * @param {Function} params.getExposure - () => current exposure value
102
103
  * @param {Function} params.getSaturation - () => current saturation value
104
+ * @param {import('../EngineIssues.js').IssueLog} [params.issues]
103
105
  */
104
- constructor( { renderer, mainCanvas, stages, pipeline, getExposure, getSaturation } ) {
106
+ constructor( { renderer, mainCanvas, stages, pipeline, getExposure, getSaturation, issues = null } ) {
105
107
 
106
108
  super();
107
109
 
108
110
  this.renderer = renderer;
111
+ this._issues = issues;
109
112
  this.mainCanvas = mainCanvas;
110
113
  this.upscalerCanvas = this._createUpscalerCanvas( mainCanvas );
111
114
  this.pipeline = pipeline;
@@ -183,6 +186,7 @@ export class DenoisingManager extends EventDispatcher {
183
186
  this._denoiserStartHandler = null;
184
187
  this._denoiserEndHandler = null;
185
188
  this._denoiserTileHandler = null;
189
+ this._denoiserErrorHandler = null;
186
190
  this._upscalerResChangedHandler = null;
187
191
  this._upscalerStartHandler = null;
188
192
  this._upscalerProgressHandler = null;
@@ -214,6 +218,23 @@ export class DenoisingManager extends EventDispatcher {
214
218
 
215
219
  }
216
220
 
221
+ /** GPU bytes of OIDN's inputs, output and motion history. */
222
+ gpuBytes() {
223
+
224
+ return ( this.denoiser?.gpuBytes?.() ?? 0 ) + ( this._history?.gpuBytes?.() ?? 0 );
225
+
226
+ }
227
+
228
+ _recordNoOverlayCanvas( pass ) {
229
+
230
+ this._issues?.record(
231
+ ISSUE_CODES.DENOISER_UNAVAILABLE,
232
+ `the ${pass} pass needs the render canvas to be in a document, and it is not — skipped`,
233
+ { pass }
234
+ );
235
+
236
+ }
237
+
217
238
  /**
218
239
  * Updates the render resolution and propagates to denoiser/upscaler.
219
240
  * @param {number} width
@@ -280,8 +301,6 @@ export class DenoisingManager extends EventDispatcher {
280
301
  */
281
302
  setupDenoiser() {
282
303
 
283
- if ( ! this.upscalerCanvas ) return;
284
-
285
304
  const pt = this._stages.pathTracer;
286
305
 
287
306
  // No canvas: the denoiser hands its result to the pipeline as a picture, and the
@@ -335,9 +354,17 @@ export class DenoisingManager extends EventDispatcher {
335
354
 
336
355
  };
337
356
 
357
+ // Only the weight load names a URL; the init wrapper re-reports that same failure.
358
+ this._denoiserErrorHandler = e => e.url && this._issues?.record(
359
+ ISSUE_CODES.ASSET_UNREACHABLE,
360
+ `denoiser weights "${e.url}" could not be loaded — the render is not denoised`,
361
+ { url: e.url, asset: 'oidn-weights', cause: String( e.error?.message ?? e.error ) }
362
+ );
363
+
338
364
  this.denoiser.addEventListener( 'start', this._denoiserStartHandler );
339
365
  this.denoiser.addEventListener( 'end', this._denoiserEndHandler );
340
366
  this.denoiser.addEventListener( 'tileProgress', this._denoiserTileHandler );
367
+ this.denoiser.addEventListener( 'error', this._denoiserErrorHandler );
341
368
 
342
369
  }
343
370
 
@@ -384,7 +411,7 @@ export class DenoisingManager extends EventDispatcher {
384
411
 
385
412
  // Forward lifecycle events (store refs for removal on re-setup / dispose)
386
413
  this._upscalerResChangedHandler = ( e ) =>
387
- this.dispatchEvent( { type: 'resolution_changed', width: e.width, height: e.height } );
414
+ this.dispatchEvent( { type: EngineEvents.RESOLUTION_CHANGED, width: e.width, height: e.height } );
388
415
  this._upscalerStartHandler = () =>
389
416
  this.dispatchEvent( { type: EngineEvents.UPSCALING_START } );
390
417
  this._upscalerProgressHandler = ( e ) =>
@@ -1101,7 +1128,7 @@ export class DenoisingManager extends EventDispatcher {
1101
1128
  // The nominal size, not the path tracer's: that one is smaller while the camera moves.
1102
1129
  if ( wasShowing && this._lastRenderWidth && this._lastRenderHeight ) {
1103
1130
 
1104
- this.dispatchEvent( { type: 'resolution_changed', width: this._lastRenderWidth, height: this._lastRenderHeight } );
1131
+ this.dispatchEvent( { type: EngineEvents.RESOLUTION_CHANGED, width: this._lastRenderWidth, height: this._lastRenderHeight } );
1105
1132
 
1106
1133
  }
1107
1134
 
@@ -1166,7 +1193,7 @@ export class DenoisingManager extends EventDispatcher {
1166
1193
 
1167
1194
  if ( ! this.upscalerCanvas ) {
1168
1195
 
1169
- neuralLog.warn( 'neural pass skipped: the engine has no overlay canvas to present on' );
1196
+ this._recordNoOverlayCanvas( 'neural' );
1170
1197
  return;
1171
1198
 
1172
1199
  }
@@ -1282,7 +1309,7 @@ export class DenoisingManager extends EventDispatcher {
1282
1309
  if ( this.mainCanvas ) this.mainCanvas.style.opacity = '0';
1283
1310
 
1284
1311
  // The host shows the output dimensions; without this they keep reading the render size.
1285
- this.dispatchEvent( { type: 'resolution_changed', width: image.width, height: image.height } );
1312
+ this.dispatchEvent( { type: EngineEvents.RESOLUTION_CHANGED, width: image.width, height: image.height } );
1286
1313
 
1287
1314
  } catch ( error ) {
1288
1315
 
@@ -1297,6 +1324,44 @@ export class DenoisingManager extends EventDispatcher {
1297
1324
 
1298
1325
  }
1299
1326
 
1327
+ /**
1328
+ * One denoise of the accumulation at the final tier, resolved once its picture is published.
1329
+ * Unlike onRenderComplete(), leaves the render loop and the upscaler alone.
1330
+ * @returns {Promise<boolean>} whether a denoised picture was published
1331
+ */
1332
+ async denoiseOnce() {
1333
+
1334
+ const dn = this.denoiser;
1335
+ if ( ! dn?.enabled ) return false;
1336
+
1337
+ this._cleanupCompletionListener();
1338
+ this._resetCadence();
1339
+ this._useFinalQuality();
1340
+
1341
+ // start() refuses during a run and only defers during a weight load.
1342
+ while ( dn.state.isLoading || dn.state.isDenoising ) {
1343
+
1344
+ await ( dn.state.isLoading ? dn.whenLoaded() : new Promise( ( resolve ) => {
1345
+
1346
+ const onEnd = () => {
1347
+
1348
+ dn.removeEventListener( 'end', onEnd );
1349
+ resolve();
1350
+
1351
+ };
1352
+
1353
+ dn.addEventListener( 'end', onEnd );
1354
+
1355
+ } ) );
1356
+
1357
+ }
1358
+
1359
+ const ok = await dn.start();
1360
+ if ( ok ) this._onDisplayRefresh?.();
1361
+ return ok && dn.hasOutput;
1362
+
1363
+ }
1364
+
1300
1365
  onRenderComplete( { isStillComplete } ) {
1301
1366
 
1302
1367
  // Remove any stale completion-chain listener from a previous render cycle
@@ -1459,6 +1524,7 @@ export class DenoisingManager extends EventDispatcher {
1459
1524
  if ( this._denoiserStartHandler ) this.denoiser.removeEventListener( 'start', this._denoiserStartHandler );
1460
1525
  if ( this._denoiserEndHandler ) this.denoiser.removeEventListener( 'end', this._denoiserEndHandler );
1461
1526
  if ( this._denoiserTileHandler ) this.denoiser.removeEventListener( 'tileProgress', this._denoiserTileHandler );
1527
+ if ( this._denoiserErrorHandler ) this.denoiser.removeEventListener( 'error', this._denoiserErrorHandler );
1462
1528
  this.denoiser.dispose();
1463
1529
  this.denoiser = null;
1464
1530
 
@@ -1482,6 +1548,7 @@ export class DenoisingManager extends EventDispatcher {
1482
1548
  this._denoiserStartHandler = null;
1483
1549
  this._denoiserEndHandler = null;
1484
1550
  this._denoiserTileHandler = null;
1551
+ this._denoiserErrorHandler = null;
1485
1552
  this._upscalerResChangedHandler = null;
1486
1553
  this._upscalerStartHandler = null;
1487
1554
  this._upscalerProgressHandler = null;
@@ -1490,6 +1557,7 @@ export class DenoisingManager extends EventDispatcher {
1490
1557
  this._onReset = null;
1491
1558
  this._onPostProcessRefresh = null;
1492
1559
  this._onDisplayRefresh = null;
1560
+ this._issues = null;
1493
1561
 
1494
1562
  if ( this.upscalerCanvas?.parentNode ) {
1495
1563
 
@@ -1669,6 +1737,7 @@ export class DenoisingManager extends EventDispatcher {
1669
1737
  setUpscalerEnabled( enabled ) {
1670
1738
 
1671
1739
  if ( this.upscaler ) this.upscaler.enabled = enabled;
1740
+ else if ( enabled ) this._recordNoOverlayCanvas( 'upscaler' );
1672
1741
  // The enlarged picture describes a setting that is no longer on.
1673
1742
  if ( ! enabled ) this._restoreRenderDisplay();
1674
1743
 
@@ -1,5 +1,6 @@
1
1
  import { DataArrayTexture, LinearFilter, RGBAFormat, UnsignedByteType } from 'three';
2
2
  import { cachedObjectURL } from '../Storage/DownloadCache.js';
3
+ import { ISSUE_CODES } from '../EngineIssues.js';
3
4
 
4
5
  /**
5
6
  * Manages projection masks ("gobos" / "cookies") for spot lights.
@@ -33,6 +34,7 @@ export class GoboManager {
33
34
 
34
35
  this.pathTracer = pathTracer;
35
36
  this._onReset = options.onReset || null;
37
+ this._issues = options.issues ?? null;
36
38
 
37
39
  /** @type {DataArrayTexture | null} */
38
40
  this.texture = null;
@@ -140,7 +142,11 @@ export class GoboManager {
140
142
  const entry = this.entries.find( e => e.name === name );
141
143
  if ( ! entry ) {
142
144
 
143
- console.warn( `GoboManager: unknown gobo "${name}"` );
145
+ this._issues?.record(
146
+ ISSUE_CODES.ASSET_UNREACHABLE,
147
+ `no gobo named "${name}" is loaded — the light projects without one`,
148
+ { name, asset: 'gobo' }
149
+ );
144
150
  return false;
145
151
 
146
152
  }
@@ -237,6 +243,7 @@ export class GoboManager {
237
243
  this.entries = [];
238
244
  this.pathTracer = null;
239
245
  this._onReset = null;
246
+ this._issues = null;
240
247
 
241
248
  }
242
249
 
@@ -1,6 +1,7 @@
1
1
  import { DataArrayTexture, LinearFilter, RGBAFormat, UnsignedByteType } from 'three';
2
2
  import { parseIES, resampleIESToGrid, deriveIESBeamAngle, deriveIESPenumbra } from '../Processor/IESParser.js';
3
3
  import { sharedDownloads } from '../Storage/DownloadCache.js';
4
+ import { ISSUE_CODES } from '../EngineIssues.js';
4
5
 
5
6
  /**
6
7
  * Manages IES photometric profiles for spot lights.
@@ -31,6 +32,7 @@ export class IESManager {
31
32
 
32
33
  this.pathTracer = pathTracer;
33
34
  this._onReset = options.onReset || null;
35
+ this._issues = options.issues ?? null;
34
36
 
35
37
  /** @type {DataArrayTexture | null} */
36
38
  this.texture = null;
@@ -72,7 +74,11 @@ export class IESManager {
72
74
 
73
75
  } catch ( err ) {
74
76
 
75
- console.warn( `IESManager: failed to load "${it.name}": ${err.message}` );
77
+ this._issues?.warn(
78
+ ISSUE_CODES.ASSET_UNREACHABLE,
79
+ `IES profile "${it.name}" could not be loaded: ${err.message}`,
80
+ { url: it.url, asset: 'ies', cause: err.message }
81
+ );
76
82
  return null;
77
83
 
78
84
  }
@@ -188,7 +194,11 @@ export class IESManager {
188
194
  const entry = this.entries.find( e => e.name === name );
189
195
  if ( ! entry ) {
190
196
 
191
- console.warn( `IESManager: unknown profile "${name}"` );
197
+ this._issues?.record(
198
+ ISSUE_CODES.ASSET_UNREACHABLE,
199
+ `no IES profile named "${name}" is loaded — the light keeps its plain distribution`,
200
+ { name, asset: 'ies' }
201
+ );
192
202
  return empty;
193
203
 
194
204
  }
@@ -249,6 +259,7 @@ export class IESManager {
249
259
  this.entries = [];
250
260
  this.pathTracer = null;
251
261
  this._onReset = null;
262
+ this._issues = null;
252
263
 
253
264
  }
254
265
 
@@ -809,7 +809,7 @@ export class InteractionManager extends EventDispatcher {
809
809
  this.addEventListener( 'focusChanged', ( event ) => {
810
810
 
811
811
  app.settings.set( 'focusDistance', event.worldDistance );
812
- app.dispatchEvent( { type: 'focusChanged', distance: event.distance } );
812
+ app.dispatchEvent( { type: EngineEvents.FOCUS_CHANGED, distance: event.distance } );
813
813
 
814
814
  } );
815
815