rayzee 7.27.1 → 7.28.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.
@@ -1,7 +1,7 @@
1
1
  import { WebGPURenderer, RectAreaLightNode, SRGBColorSpace } from 'three/webgpu';
2
2
  import { texture as _tslTexture, cubeTexture as _tslCubeTexture } from 'three/tsl';
3
3
  import {
4
- ACESFilmicToneMapping, Scene, EventDispatcher, Box3
4
+ Scene, EventDispatcher, Box3
5
5
  } from 'three';
6
6
  import { RectAreaLightTexturesLib } from 'three/addons/lights/RectAreaLightTexturesLib.js';
7
7
  import { SceneHelpers } from './SceneHelpers.js';
@@ -16,12 +16,15 @@ import { AutoExposure } from './Stages/AutoExposure.js';
16
16
  import { Compositor } from './Stages/Compositor.js';
17
17
  import { RenderPipeline } from './Pipeline/RenderPipeline.js';
18
18
  import { CompletionTracker } from './Pipeline/CompletionTracker.js';
19
- import { ENGINE_DEFAULTS as DEFAULT_STATE, PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, MAX_STORAGE_TEXTURE_SIZE, MAX_RESERVABLE_RENDER_SIZE, setReservedRenderSize } from './EngineDefaults.js';
19
+ import { ENGINE_DEFAULTS as DEFAULT_STATE, PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, MAX_STORAGE_TEXTURE_SIZE, MAX_RESERVABLE_RENDER_SIZE, setReservedRenderSize, getRenderProfile } from './EngineDefaults.js';
20
20
  import { updateStats, updateLoading, resetLoading, setStatusCallback, getDisplaySamples, disposeObjectFromMemory, disposeRenderer } from './Processor/utils.js';
21
21
  import { BuildTimer } from './Processor/BuildTimer.js';
22
22
  import { createLogger, fmt } from './utils/Logger.js';
23
23
  import { InteractionManager } from './managers/InteractionManager.js';
24
24
  import { EngineEvents } from './EngineEvents.js';
25
+ import { IssueLog, ISSUE_CODES } from './EngineIssues.js';
26
+ import { SETTING_SOURCE } from './RenderSettings.js';
27
+ import { toneMapToRGBA8 } from './Processor/ToneMapCPU.js';
25
28
  import { AssetLoader } from './Processor/AssetLoader.js';
26
29
  import { SceneProcessor } from './Processor/SceneProcessor.js';
27
30
 
@@ -80,6 +83,32 @@ function attachDeviceLostHandler( device, holder ) {
80
83
 
81
84
  }
82
85
 
86
+ const SOFTWARE_ADAPTER = /swiftshader|llvmpipe|lavapipe|basic render|microsoft basic|warp/i;
87
+
88
+ /**
89
+ * Flags software rasterizers: correct output, ~100x slower, invisible in the image.
90
+ * `isFallbackAdapter` only covers adapters we asked to be fallbacks, so the strings matter.
91
+ *
92
+ * @param {GPUAdapter} adapter
93
+ * @returns {{vendor:string, architecture:string, device:string, description:string, isSoftware:boolean}}
94
+ */
95
+ export function describeAdapter( adapter ) {
96
+
97
+ const info = adapter.info ?? {};
98
+ const identity = `${info.vendor ?? ''} ${info.architecture ?? ''} ${info.device ?? ''} ${info.description ?? ''}`;
99
+
100
+ return {
101
+ vendor: info.vendor ?? '',
102
+ architecture: info.architecture ?? '',
103
+ device: info.device ?? '',
104
+ description: info.description ?? '',
105
+ isSoftware: info.isFallbackAdapter === true
106
+ || adapter.isFallbackAdapter === true
107
+ || SOFTWARE_ADAPTER.test( identity ),
108
+ };
109
+
110
+ }
111
+
83
112
  export class PathTracerApp extends EventDispatcher {
84
113
 
85
114
  /**
@@ -88,6 +117,10 @@ export class PathTracerApp extends EventDispatcher {
88
117
  * @param {boolean} [options.autoResize=true] - Automatically listen for window resize events
89
118
  * @param {HTMLElement} [options.container] - Single DOM parent the engine mounts all auxiliary
90
119
  * elements into (HUD overlay, denoiser canvas). Defaults to `canvas.parentNode`.
120
+ * @param {boolean} [options.strict=false] - Throw at the point of degradation instead of
121
+ * rendering a plausible wrong image. See EngineIssues.js; read `app.issues` when off.
122
+ * @param {string} [options.profile='viewer'] - Which tuning to apply where the viewer's
123
+ * product decisions differ from the physical answer. See RENDER_PROFILES.
91
124
  *
92
125
  * The engine dispatches `EngineEvents.FRAME` after each animate() iteration so hosts can
93
126
  * tick external instrumentation (e.g. a stats panel) without coupling the engine to it.
@@ -115,8 +148,24 @@ export class PathTracerApp extends EventDispatcher {
115
148
  this._applySceneMetadataEnabled = options.applySceneMetadata !== false;
116
149
  this._applyingSceneMetadata = false;
117
150
 
151
+ // Before the settings: the profile supplies some of their defaults.
152
+ this._profile = getRenderProfile( options.profile );
153
+
154
+ // First, so no subsystem can degrade unrecorded.
155
+ this._issues = new IssueLog( {
156
+ strict: options.strict === true,
157
+ onIssue: ( issue ) => this.dispatchEvent( { type: EngineEvents.ISSUE, issue } ),
158
+ } );
159
+
118
160
  // ── Settings (single source of truth for all render parameters) ──
119
- this.settings = new RenderSettings( DEFAULT_STATE );
161
+ this.settings = new RenderSettings(
162
+ {
163
+ ...DEFAULT_STATE,
164
+ environmentRotation: this._profile.environmentRotation,
165
+ saturation: this._profile.saturation,
166
+ },
167
+ { issues: this._issues }
168
+ );
120
169
 
121
170
  // ── Core objects (populated in init) ──
122
171
  this.renderer = null;
@@ -197,6 +246,9 @@ export class PathTracerApp extends EventDispatcher {
197
246
  this._dispatchPinned = false;
198
247
  this._deterministicRestore = null;
199
248
 
249
+ /** @type {?{vendor:string, architecture:string, device:string, description:string, isSoftware:boolean}} */
250
+ this.adapterInfo = null;
251
+
200
252
  }
201
253
 
202
254
  /**
@@ -501,6 +553,8 @@ export class PathTracerApp extends EventDispatcher {
501
553
  this._removeTrackedListeners();
502
554
  setStatusCallback( null );
503
555
 
556
+ this._issues.detach(); // onIssue captures `this`; see IssueLog.detach()
557
+
504
558
  this.interactionManager?.deselect?.();
505
559
  this.transformManager?.detach?.();
506
560
 
@@ -915,8 +969,9 @@ export class PathTracerApp extends EventDispatcher {
915
969
  * the 'load' event (_onAssetLoaded). Appending a model never applies it — an appended
916
970
  * model must not hijack the scene's environment.
917
971
  *
918
- * Resolves to null and never rejects: a failed fetch leaves the model on the current
919
- * environment. Returns null outright when there is nothing to apply.
972
+ * Resolves to null rather than rejecting: a failed fetch leaves the model on the current
973
+ * environment. Returns null outright when there is nothing to apply. Under `strict` the
974
+ * recorded issue throws instead, and the rejection surfaces where loadSceneData awaits it.
920
975
  * Opt out per app with `new PathTracerApp( canvas, { applySceneMetadata: false } )`.
921
976
  *
922
977
  * @returns {Promise<import('three').Texture|null>|null}
@@ -935,7 +990,11 @@ export class PathTracerApp extends EventDispatcher {
935
990
  return this.assetLoader.loadEnvironment( env.sourceFile )
936
991
  .catch( error => {
937
992
 
938
- log.warn( `scene metadata: environment "${env.sourceFile}" failed to load`, error );
993
+ this._issues.record(
994
+ ISSUE_CODES.ENVIRONMENT_LOAD_FAILED,
995
+ `authored environment "${env.sourceFile}" failed to load — lighting falls back to the previous environment`,
996
+ { sourceFile: env.sourceFile, cause: String( error?.message ?? error ) }
997
+ );
939
998
  return null;
940
999
 
941
1000
  } )
@@ -969,10 +1028,10 @@ export class PathTracerApp extends EventDispatcher {
969
1028
 
970
1029
  if ( env.rotation !== undefined ) updates.environmentRotation = env.rotation;
971
1030
 
972
- this.settings.setMany( updates, { reset: false } );
1031
+ this.settings.setMany( updates, { reset: false, source: SETTING_SOURCE.SCENE_METADATA } );
973
1032
  if ( this.scene ) this.scene.background = texture;
974
1033
 
975
- this.dispatchEvent( { type: 'SceneMetadataApplied', metadata, environment: { ...env } } );
1034
+ this.dispatchEvent( { type: EngineEvents.SCENE_METADATA_APPLIED, metadata, environment: { ...env } } );
976
1035
 
977
1036
  }
978
1037
 
@@ -1016,7 +1075,11 @@ export class PathTracerApp extends EventDispatcher {
1016
1075
  this._applySceneMetadataSettings( texture );
1017
1076
 
1018
1077
  } )
1019
- .catch( error => log.warn( 'scene metadata: environment install failed', error ) )
1078
+ .catch( error => this._issues.record(
1079
+ ISSUE_CODES.ENVIRONMENT_LOAD_FAILED,
1080
+ 'authored environment failed to install — the scene is lit by whatever was already loaded',
1081
+ { cause: String( error?.message ?? error ) }
1082
+ ) )
1020
1083
  .finally( () => timer.end( 'Environment fetch + CDF (concurrent)' ) );
1021
1084
 
1022
1085
  } else if ( environmentTexture?.image?.data ) {
@@ -1571,7 +1634,12 @@ export class PathTracerApp extends EventDispatcher {
1571
1634
 
1572
1635
  if ( applied < pending.requestedPx ) {
1573
1636
 
1574
- log.warn( `reserved render size ${fmt.n( pending.requestedPx )}px was capped to ${fmt.n( applied )}px by this device's limits — renders above ${fmt.n( applied )}px will be declined.` );
1637
+ this._issues.warn(
1638
+ ISSUE_CODES.RENDER_RESERVE_CAPPED,
1639
+ `reserved render size ${fmt.n( pending.requestedPx )}px was capped to ${fmt.n( applied )}px by this device's limits — ` +
1640
+ `renders above ${fmt.n( applied )}px will be declined`,
1641
+ { requested: pending.requestedPx, applied }
1642
+ );
1575
1643
 
1576
1644
  }
1577
1645
 
@@ -1599,7 +1667,12 @@ export class PathTracerApp extends EventDispatcher {
1599
1667
 
1600
1668
  if ( width > MAX_STORAGE_TEXTURE_SIZE || height > MAX_STORAGE_TEXTURE_SIZE ) {
1601
1669
 
1602
- log.warn( `render resolution ${width}×${height} exceeds the ${MAX_STORAGE_TEXTURE_SIZE}px reserve (compute storage textures are pre-allocated at ${MAX_STORAGE_TEXTURE_SIZE}px). Ignoring resize — raise the reserve with setReservedRenderResolution( ${Math.max( width, height )} ) first, or use a resolution ≤ ${MAX_STORAGE_TEXTURE_SIZE}.` );
1670
+ this._issues.record(
1671
+ ISSUE_CODES.RENDER_SIZE_DECLINED,
1672
+ `render resolution ${width}×${height} exceeds the ${MAX_STORAGE_TEXTURE_SIZE}px storage reserve — resize ignored, ` +
1673
+ `so output stays at the previous size. Raise it with setReservedRenderResolution( ${Math.max( width, height )} ) before init().`,
1674
+ { width, height, reserve: MAX_STORAGE_TEXTURE_SIZE }
1675
+ );
1603
1676
  return false;
1604
1677
 
1605
1678
  }
@@ -1704,7 +1777,7 @@ export class PathTracerApp extends EventDispatcher {
1704
1777
  usePixelFreeze: config.usePixelFreeze ?? false,
1705
1778
  pixelFreezeThreshold: config.pixelFreezeThreshold ?? DEFAULT_STATE.pixelFreezeThreshold,
1706
1779
  pixelFreezeStability: config.pixelFreezeStability ?? DEFAULT_STATE.pixelFreezeStability,
1707
- }, { silent: true } );
1780
+ }, { silent: true, source: SETTING_SOURCE.MODE_PRESET } );
1708
1781
 
1709
1782
  // renderMode has no SETTING_ROUTES entry
1710
1783
  this.stages.pathTracer?.setUniform( 'renderMode', parseInt( config.renderMode ) );
@@ -1923,6 +1996,27 @@ export class PathTracerApp extends EventDispatcher {
1923
1996
 
1924
1997
  }
1925
1998
 
1999
+ /** What the engine survived rather than failed on, oldest first. @returns {Object[]} copies */
2000
+ get issues() {
2001
+
2002
+ return this._issues.list;
2003
+
2004
+ }
2005
+
2006
+ /** Non-empty means: do not publish this frame. */
2007
+ get issueErrors() {
2008
+
2009
+ return this._issues.errors;
2010
+
2011
+ }
2012
+
2013
+ /** Scopes the log to one render on a reused app. */
2014
+ clearIssues() {
2015
+
2016
+ this._issues.clear();
2017
+
2018
+ }
2019
+
1926
2020
  /**
1927
2021
  * Whether output is currently bit-reproducible. False when the dispatch heuristics
1928
2022
  * were left active via `pinDispatch: false`, since those consume async readbacks.
@@ -1939,14 +2033,18 @@ export class PathTracerApp extends EventDispatcher {
1939
2033
  * Awaits the STBN atlases first — until they land the sampler reads a constant-0.5
1940
2034
  * placeholder that gets baked permanently into the accumulation buffer.
1941
2035
  *
2036
+ * A frame retired by adaptive sampling stops advancing `frameCount`, so a fixed-count loop
2037
+ * can never reach `count`. `allowEarlyRetire` makes that an outcome instead of a throw.
2038
+ *
1942
2039
  * @param {number} count - samples to accumulate
1943
2040
  * @param {Object} [options]
1944
2041
  * @param {boolean} [options.reset=true] - restart accumulation from sample 0 first
1945
2042
  * @param {number} [options.yieldEvery=8] - yield to the event loop every N passes (0 disables)
1946
2043
  * @param {function(number): void} [options.onProgress] - called with the running sample count
1947
- * @returns {Promise<number>} the final accumulated sample count
2044
+ * @param {boolean} [options.allowEarlyRetire=false]
2045
+ * @returns {Promise<number>} samples accumulated; below `count` only when retired early
1948
2046
  */
1949
- async renderFrames( count, { reset = true, yieldEvery = 8, onProgress } = {} ) {
2047
+ async renderFrames( count, { reset = true, yieldEvery = 8, onProgress, allowEarlyRetire = false } = {} ) {
1950
2048
 
1951
2049
  const stage = this.stages.pathTracer;
1952
2050
  if ( ! stage ) throw new Error( 'renderFrames: app is not initialized' );
@@ -1970,6 +2068,8 @@ export class PathTracerApp extends EventDispatcher {
1970
2068
  const maxPasses = count + 64;
1971
2069
  let passes = 0;
1972
2070
 
2071
+ let retiredEarly = false;
2072
+
1973
2073
  while ( stage.frameCount < target && passes < maxPasses ) {
1974
2074
 
1975
2075
  if ( this._deviceLost ) throw new Error( 'renderFrames: WebGPU device lost' );
@@ -1978,6 +2078,14 @@ export class PathTracerApp extends EventDispatcher {
1978
2078
  this.pipeline.render();
1979
2079
  passes ++;
1980
2080
 
2081
+ // render() no-ops once complete; spinning would burn the budget for nothing.
2082
+ if ( stage.isComplete && stage.frameCount < target ) {
2083
+
2084
+ retiredEarly = true;
2085
+ break;
2086
+
2087
+ }
2088
+
1981
2089
  onProgress?.( stage.frameCount );
1982
2090
 
1983
2091
  if ( yieldEvery > 0 && passes % yieldEvery === 0 ) {
@@ -1988,11 +2096,15 @@ export class PathTracerApp extends EventDispatcher {
1988
2096
 
1989
2097
  }
1990
2098
 
1991
- if ( stage.frameCount < target ) {
2099
+ if ( stage.frameCount < target && ! ( retiredEarly && allowEarlyRetire ) ) {
2100
+
2101
+ const cause = retiredEarly
2102
+ ? 'adaptive sampling retired the frame at the convergence threshold — pass ' +
2103
+ '`allowEarlyRetire: true`, or disable it with setDeterministicMode()'
2104
+ : 'something retired the render (maxSamples, a stray reset, or a canvas resize)';
1992
2105
 
1993
2106
  throw new Error(
1994
- `renderFrames: stalled at ${stage.frameCount}/${target} samples after ${passes} passes — ` +
1995
- 'something retired the render (maxSamples, a stray reset, or a canvas resize)'
2107
+ `renderFrames: stopped at ${stage.frameCount}/${target} samples after ${passes} passes — ${cause}`
1996
2108
  );
1997
2109
 
1998
2110
  }
@@ -2001,6 +2113,53 @@ export class PathTracerApp extends EventDispatcher {
2001
2113
 
2002
2114
  }
2003
2115
 
2116
+ /**
2117
+ * The path tracer's accumulation as pixels, read from its storage target rather than the
2118
+ * canvas — so it works headless, works while the page is hidden, and cannot pick up a
2119
+ * helper overlay.
2120
+ *
2121
+ * This is `pathtracer:color`, NOT the Compositor's resolved output: denoising, bloom and
2122
+ * edge filtering are downstream and are absent here. Use getCanvas() when you want what
2123
+ * the viewport shows.
2124
+ *
2125
+ * @param {Object} [options]
2126
+ * @param {'linear'|'srgb'} [options.colorSpace='srgb']
2127
+ * @param {boolean} [options.preserveAlpha=false] - srgb only
2128
+ * @returns {Promise<{data: Float32Array|Uint8ClampedArray, width: number, height: number, colorSpace: string}>}
2129
+ */
2130
+ async renderToBuffer( { colorSpace = 'srgb', preserveAlpha = false } = {} ) {
2131
+
2132
+ if ( colorSpace !== 'linear' && colorSpace !== 'srgb' ) {
2133
+
2134
+ throw new Error( `renderToBuffer: colorSpace must be 'linear' or 'srgb', got "${colorSpace}"` );
2135
+
2136
+ }
2137
+
2138
+ const stage = this.stages?.pathTracer;
2139
+ const target = stage?.storageTextures?.readTarget;
2140
+ if ( ! target ) throw new Error( 'renderToBuffer: no render target — call init() and render at least one sample' );
2141
+
2142
+ // The pool over-allocates to the reserve, so the texture is larger than the frame.
2143
+ const { width, height } = stage;
2144
+
2145
+ const linear = await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 );
2146
+
2147
+ if ( colorSpace === 'linear' ) return { data: linear, width, height, colorSpace };
2148
+
2149
+ return {
2150
+ data: toneMapToRGBA8( linear, {
2151
+ exposure: this.renderer.toneMappingExposure,
2152
+ toneMapping: this.renderer.toneMapping,
2153
+ saturation: this.settings.get( 'saturation' ) ?? 1,
2154
+ preserveAlpha,
2155
+ } ),
2156
+ width,
2157
+ height,
2158
+ colorSpace,
2159
+ };
2160
+
2161
+ }
2162
+
2004
2163
  /**
2005
2164
  * Enables WebGPU timestamp queries so {@link PathTracerApp#getGPUTimings} reports real
2006
2165
  * GPU time. Off by default because the queries themselves cost time. No-ops when the
@@ -2580,6 +2739,20 @@ export class PathTracerApp extends EventDispatcher {
2580
2739
 
2581
2740
  }
2582
2741
 
2742
+ this.adapterInfo = describeAdapter( adapter );
2743
+ if ( this.adapterInfo.isSoftware ) {
2744
+
2745
+ // Warning: the image is correct, only the cost is wrong.
2746
+ this._issues.warn(
2747
+ ISSUE_CODES.ADAPTER_SOFTWARE,
2748
+ `software GPU adapter "${this.adapterInfo.description || this.adapterInfo.vendor}" — ` +
2749
+ 'renders are correct but orders of magnitude slower than hardware',
2750
+ { ...this.adapterInfo }
2751
+ );
2752
+ log.warn( `software GPU adapter — check app.adapterInfo.isSoftware. ${this.adapterInfo.description}` );
2753
+
2754
+ }
2755
+
2583
2756
  const adapterLimits = adapter.limits;
2584
2757
 
2585
2758
  this.renderer = new WebGPURenderer( {
@@ -2596,6 +2769,17 @@ export class PathTracerApp extends EventDispatcher {
2596
2769
 
2597
2770
  await this.renderer.init();
2598
2771
 
2772
+ // WebGPURenderer swaps in WebGL2 on failure with only a warn(). The wavefront path is
2773
+ // compute-only, so every frame would fail against an empty canvas.
2774
+ if ( ! this.renderer.backend?.isWebGPUBackend ) {
2775
+
2776
+ throw new Error(
2777
+ 'WebGPU backend unavailable — three.js fell back to WebGL2, which cannot run the ' +
2778
+ 'path tracer\'s compute kernels. Check GPU process flags and driver support.'
2779
+ );
2780
+
2781
+ }
2782
+
2599
2783
  // Detect GPU device loss (dGPU/iGPU switch, driver reset, TDR watchdog on heavy
2600
2784
  // compute). Without this the rAF loop keeps calling render() on a dead device,
2601
2785
  // spewing errors forever. reason 'destroyed' during dispose() is intentional teardown.
@@ -2621,7 +2805,7 @@ export class PathTracerApp extends EventDispatcher {
2621
2805
  RectAreaLightNode.setLTC( RectAreaLightTexturesLib.init() );
2622
2806
 
2623
2807
  this.renderer.outputColorSpace = SRGBColorSpace;
2624
- this.renderer.toneMapping = ACESFilmicToneMapping;
2808
+ this.renderer.toneMapping = this._profile.toneMapping;
2625
2809
  this.renderer.toneMappingExposure = 1.0;
2626
2810
  this.renderer.setPixelRatio( 1.0 );
2627
2811
 
@@ -2643,8 +2827,11 @@ export class PathTracerApp extends EventDispatcher {
2643
2827
 
2644
2828
  _initAssetPipeline() {
2645
2829
 
2646
- this._sdf = new SceneProcessor();
2647
- this.assetLoader = new AssetLoader( this.meshScene, this.cameraManager.camera, this.cameraManager.controls );
2830
+ this._sdf = new SceneProcessor( { issues: this._issues } );
2831
+ this.assetLoader = new AssetLoader(
2832
+ this.meshScene, this.cameraManager.camera, this.cameraManager.controls,
2833
+ { issues: this._issues, profile: this._profile }
2834
+ );
2648
2835
  this.assetLoader.setRenderer( this.renderer );
2649
2836
  this.assetLoader.createFloorPlane();
2650
2837
 
@@ -2662,7 +2849,7 @@ export class PathTracerApp extends EventDispatcher {
2662
2849
  this._createStages();
2663
2850
 
2664
2851
  const { clientWidth: w, clientHeight: h } = this.canvas;
2665
- this.pipeline = new RenderPipeline( this.renderer, w || 1, h || 1 );
2852
+ this.pipeline = new RenderPipeline( this.renderer, w || 1, h || 1, { issues: this._issues } );
2666
2853
 
2667
2854
  this.pipeline.addStage( this.stages.pathTracer );
2668
2855
  this.pipeline.addStage( this.stages.normalDepth );
@@ -1,6 +1,7 @@
1
1
  import { PipelineContext } from './PipelineContext.js';
2
2
  import { EventDispatcher } from './EventDispatcher.js';
3
3
  import { auditStageRender } from './BindingAudit.js';
4
+ import { ISSUE_CODES } from '../EngineIssues.js';
4
5
 
5
6
  /**
6
7
  * RenderPipeline - Orchestrates execution of pipeline stages
@@ -36,15 +37,42 @@ import { auditStageRender } from './BindingAudit.js';
36
37
  */
37
38
  export class RenderPipeline {
38
39
 
40
+ /**
41
+ * A failed stage leaves a half-composited frame that still reaches the canvas — the exact
42
+ * plausible-wrong-image case a batch host must not publish.
43
+ * @private
44
+ */
45
+ _reportStageFailure( name, phase, error ) {
46
+
47
+ console.error( `[Pipeline] Error in stage '${name}' (${phase}):`, error );
48
+
49
+ const key = `${name}:${phase}`;
50
+ if ( this._reportedStageFailures.has( key ) ) return;
51
+ this._reportedStageFailures.add( key );
52
+
53
+ this._issues?.record(
54
+ ISSUE_CODES.STAGE_RENDER_FAILED,
55
+ `stage "${name}" threw during ${phase} — the frame is incomplete`,
56
+ { stage: name, phase, cause: String( error?.message ?? error ) }
57
+ );
58
+
59
+ }
60
+
39
61
  /**
40
62
  * Create a new pipeline
41
63
  * @param {THREE.WebGLRenderer} renderer - Three.js renderer
42
64
  * @param {number} width - Viewport width
43
65
  * @param {number} height - Viewport height
66
+ * @param {Object} [options]
67
+ * @param {import('../EngineIssues.js').IssueLog} [options.issues] - records stage failures
44
68
  */
45
- constructor( renderer, width, height ) {
69
+ constructor( renderer, width, height, { issues = null } = {} ) {
46
70
 
47
71
  this.renderer = renderer;
72
+ this._issues = issues;
73
+
74
+ // A stage that throws does so every frame; record the first per stage, not 60/second.
75
+ this._reportedStageFailures = new Set();
48
76
  this.width = width;
49
77
  this.height = height;
50
78
 
@@ -215,8 +243,7 @@ export class RenderPipeline {
215
243
 
216
244
  } catch ( error ) {
217
245
 
218
- console.error( `[Pipeline] Error in stage '${stage.name}':`, error );
219
- // Continue pipeline execution despite error
246
+ this._reportStageFailure( stage.name, 'render', error );
220
247
 
221
248
  }
222
249
 
@@ -278,7 +305,7 @@ export class RenderPipeline {
278
305
 
279
306
  } catch ( error ) {
280
307
 
281
- console.error( `[Pipeline] Error resetting stage '${stage.name}':`, error );
308
+ this._reportStageFailure( stage.name, 'reset', error );
282
309
 
283
310
  }
284
311
 
@@ -16,6 +16,8 @@ import { BuildTimer } from './BuildTimer.js';
16
16
  import { getAssetConfig } from '../AssetConfig.js';
17
17
  import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
18
18
  import { extractSceneMetadata } from './SceneMetadata.js';
19
+ import { ISSUE_CODES } from '../EngineIssues.js';
20
+ import { getRenderProfile } from '../EngineDefaults.js';
19
21
 
20
22
  // Define supported file formats
21
23
  const SUPPORTED_FORMATS = {
@@ -54,7 +56,7 @@ function standInForSplit( source ) {
54
56
  */
55
57
  export class AssetLoader extends EventDispatcher {
56
58
 
57
- constructor( scene, camera, controls ) {
59
+ constructor( scene, camera, controls, { issues = null, profile = null } = {} ) {
58
60
 
59
61
  super();
60
62
  this.scene = scene;
@@ -78,6 +80,22 @@ export class AssetLoader extends EventDispatcher {
78
80
  this._loadingManager = new LoadingManager();
79
81
  this._loadCancelled = false;
80
82
 
83
+ this._issues = issues;
84
+ this._profile = profile ?? getRenderProfile();
85
+
86
+ // A glTF whose external texture 404s still loads. Only place the engine sees the URL.
87
+ // ZIP paths build their own managers and are not covered.
88
+ this._loadingManager.onError = ( url ) => {
89
+
90
+ if ( this._loadCancelled ) return;
91
+ this._issues?.record(
92
+ ISSUE_CODES.ASSET_UNREACHABLE,
93
+ `asset "${url}" could not be fetched — anything depending on it renders without it`,
94
+ { url }
95
+ );
96
+
97
+ };
98
+
81
99
  }
82
100
 
83
101
  /**
@@ -1744,9 +1762,10 @@ export class AssetLoader extends EventDispatcher {
1744
1762
  // Intensity is authored in Watts (Blender-style); emitted radiance
1745
1763
  // is computed at render time as power/(π·area). Blender emission
1746
1764
  // defaults: power-normalized, full Lambertian spread (π), rectangle.
1765
+ // Viewer tuning, not physics — see RENDER_PROFILES.
1747
1766
  const light = new RectAreaLight(
1748
1767
  new Color( ...userData.color ),
1749
- userData.intensity * 0.1,
1768
+ userData.intensity * this._profile.areaLightIntensityScale,
1750
1769
  userData.width,
1751
1770
  userData.height
1752
1771
  );
@@ -1879,6 +1898,10 @@ export class AssetLoader extends EventDispatcher {
1879
1898
  // Clear the internal listener map directly so handlers don't retain references.
1880
1899
  this._listeners = undefined;
1881
1900
 
1901
+ // onError captures `this`, and a manager outlives the loader via an in-flight fetch.
1902
+ this._loadingManager.onError = undefined;
1903
+ this._issues = null;
1904
+
1882
1905
  this.releaseTargetModel();
1883
1906
 
1884
1907
  }
@@ -33,6 +33,7 @@ export class SceneProcessor {
33
33
  * @param {boolean} [options.useFloat32Array=true] - Use Float32Array for triangle data
34
34
  * @param {string} [options.textureQuality='adaptive'] - Texture quality mode
35
35
  * @param {boolean} [options.enableTextureCache=true] - Enable texture caching
36
+ * @param {import('../EngineIssues.js').IssueLog} [options.issues] - forwarded to TextureCreator
36
37
  */
37
38
  constructor( options = {} ) {
38
39
 
@@ -160,7 +161,7 @@ export class SceneProcessor {
160
161
  } );
161
162
 
162
163
  // Create and configure texture creator
163
- this.textureCreator = new TextureCreator( { maxTextureSize: this.config.maxTextureSize } );
164
+ this.textureCreator = new TextureCreator( { maxTextureSize: this.config.maxTextureSize, issues: this.config.issues } );
164
165
  // The optimized TextureCreator will auto-detect capabilities and select optimal methods
165
166
 
166
167
  // Create emissive triangle builder for direct lighting