rayzee 8.3.0 → 8.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,11 @@ import { EventDispatcher } from 'three';
2
2
  import { OIDNDenoiser } from '../Passes/OIDNDenoiser.js';
3
3
  import { AIUpscaler } from '../Passes/AIUpscaler.js';
4
4
  import { EngineEvents } from '../EngineEvents.js';
5
+ import { createLogger } from '../utils/Logger.js';
6
+
7
+ // The neural passes live in `../dlss/` but report through the manager that drives them, so they
8
+ // share one namespace: `rayzee.log.only( 'dlss' )` shows the whole chain.
9
+ const dlssLog = createLogger( 'dlss' );
5
10
  import { ENGINE_DEFAULTS as DEFAULT_STATE, ASVGF_QUALITY_PRESETS, NRD_DEFAULTS, NRD_QUALITY_PRESETS, NRD_PRESET_KEYS } from '../EngineDefaults.js';
6
11
 
7
12
  // A refresh slower than this is a slideshow, not a live view, so the cadence swaps to a cheaper
@@ -107,6 +112,17 @@ export class DenoisingManager extends EventDispatcher {
107
112
 
108
113
  this.denoiser = null;
109
114
  this.upscaler = null;
115
+ // Which model the AI upscaler runs. 'esrgan' is the ONNX chain; 'dlss' is the neural
116
+ // super-resolution pass, which is fixed at 2x and needs a denoised source.
117
+ this.upscalerBackend = 'esrgan';
118
+ this._dlssUpscaler = null;
119
+ // The neural-rendering (detail) pass is independent of the upscaler: it changes appearance,
120
+ // not resolution, and runs last because its result cannot be read back.
121
+ this.neuralRendering = false;
122
+ this.neuralRenderingSettings = {};
123
+ this._dlssNeural = null;
124
+ this._dlssModule = null;
125
+ this._neuralPostBusy = false;
110
126
 
111
127
  // The tier the finished image uses. The loaded tier is not always this one: while the
112
128
  // image is still accumulating we run a cheaper model (see previewQuality).
@@ -133,6 +149,7 @@ export class DenoisingManager extends EventDispatcher {
133
149
  this._holdWhileMoving = null;
134
150
  this._onReset = null;
135
151
  this._onPostProcessRefresh = null;
152
+ this._onDisplayRefresh = null;
136
153
 
137
154
  // Resolution tracking — used for canvas restoration on reset
138
155
  this._lastRenderWidth = 0;
@@ -786,6 +803,242 @@ export class DenoisingManager extends EventDispatcher {
786
803
 
787
804
  }
788
805
 
806
+ /**
807
+ * Selects the AI upscaler's model. 'dlss' is fixed at 2x and reads the denoised picture, so it
808
+ * is a no-op without a denoiser — feeding it raw Monte-Carlo noise measurably loses to bilinear.
809
+ *
810
+ * @param {'esrgan'|'dlss'} backend
811
+ */
812
+ setUpscalerBackend( backend ) {
813
+
814
+ const next = backend === 'dlss' ? 'dlss' : 'esrgan';
815
+ if ( next === this.upscalerBackend ) return;
816
+
817
+ this.upscalerBackend = next;
818
+ // Whatever is on screen came from the model being switched away from.
819
+ this._restoreRenderDisplay();
820
+ if ( next !== 'dlss' ) this._releaseDLSSUpscaler();
821
+
822
+ }
823
+
824
+ /**
825
+ * Puts the render canvas back in front and tells the host the output is the render size again.
826
+ *
827
+ * Guarded on the overlay actually being visible: `abort()` runs on every reset, which is every
828
+ * frame of a camera drag, and an unguarded event there would be a per-frame storm.
829
+ *
830
+ * @returns {boolean} whether anything was actually restored
831
+ */
832
+ _restoreRenderDisplay() {
833
+
834
+ const wasShowing = this.upscalerCanvas && this.upscalerCanvas.style.display !== 'none';
835
+
836
+ if ( this.upscalerCanvas ) this.upscalerCanvas.style.display = 'none';
837
+ if ( this.mainCanvas ) this.mainCanvas.style.opacity = '1';
838
+
839
+ if ( wasShowing ) {
840
+
841
+ const stage = this._stages?.pathTracer;
842
+ if ( stage?.width && stage?.height ) {
843
+
844
+ this.dispatchEvent( { type: 'resolution_changed', width: stage.width, height: stage.height } );
845
+
846
+ }
847
+
848
+ }
849
+
850
+ return !! wasShowing;
851
+
852
+ }
853
+
854
+ _releaseDLSSUpscaler() {
855
+
856
+ this._dlssUpscaler?.dispose();
857
+ this._dlssUpscaler = null;
858
+
859
+ // The frame packer lives on the module, not on the upscaler, and holds two buffers the size
860
+ // of the render. Nothing else reaches it, so this is its only release point. Guarded on the
861
+ // module having been loaded, so a teardown never pulls in 1 MB of runtime to call a no-op.
862
+ this._dlssModule?.releaseDenoisedReader();
863
+
864
+ }
865
+
866
+ /** Turns the neural-rendering (detail) pass on or off, and sets its appearance controls. */
867
+ setNeuralRendering( enabled, settings = null ) {
868
+
869
+ this.neuralRendering = !! enabled;
870
+ if ( settings ) this.neuralRenderingSettings = { ...this.neuralRenderingSettings, ...settings };
871
+ if ( ! this.neuralRendering ) {
872
+
873
+ this._releaseDLSSNeural();
874
+ this._restoreRenderDisplay();
875
+
876
+ }
877
+
878
+ }
879
+
880
+ _releaseDLSSNeural() {
881
+
882
+ this._dlssNeural?.dispose();
883
+ this._dlssNeural = null;
884
+
885
+ }
886
+
887
+ /**
888
+ * The neural post-chain over the picture the closing denoise just produced: super resolution
889
+ * first, then the detail pass, either or both.
890
+ *
891
+ * Order is forced by the models. Super resolution hands back light that can be fed onward; the
892
+ * detail pass writes an `rgba8unorm` texture with no `COPY_SRC`, so nothing can read its result
893
+ * and it has to be last — and it presents in its own display space rather than the engine's tone
894
+ * curve. Which shape super resolution returns therefore depends on what follows it: packed
895
+ * halves when the detail pass is next, display bytes when it is the end of the chain.
896
+ *
897
+ * Deliberately fire-and-forget: the render is already finished and a failure must not break it.
898
+ */
899
+ async _runNeuralPost( isStillComplete ) {
900
+
901
+ const wantSR = this.upscaler?.enabled && this.upscalerBackend === 'dlss';
902
+ const wantNR = this.neuralRendering;
903
+ if ( ! wantSR && ! wantNR ) return;
904
+
905
+ // A second completion can land while the first pass is still in flight — the models are
906
+ // created lazily and reused through fields, so two runs would race over the same handles.
907
+ if ( this._neuralPostBusy ) return;
908
+
909
+ if ( ! this.upscalerCanvas ) {
910
+
911
+ dlssLog.warn( 'neural pass skipped: the engine has no overlay canvas to present on' );
912
+ return;
913
+
914
+ }
915
+
916
+ if ( ! this.pipeline?.context?.getTexture( 'oidn:output' ) ) {
917
+
918
+ dlssLog.warn( 'neural pass skipped: no denoised picture — it needs OIDN' );
919
+ return;
920
+
921
+ }
922
+
923
+ this._neuralPostBusy = true;
924
+ this.dispatchEvent( { type: EngineEvents.UPSCALING_START } );
925
+
926
+ try {
927
+
928
+ const sr = await import( '../dlss/DLSSSuperRes.js' );
929
+ this._dlssModule = sr;
930
+
931
+ // The detail pass has a size ceiling — above it a run takes minutes and then loses every GPU
932
+ // device in the page. Running it FIRST is what keeps it under: at render size it sees a
933
+ // quarter of the pixels it would after a 2x upscale, and the render reserve already caps
934
+ // that at 2048. The check stays as a floor under a raised reserve.
935
+ const nrModule = wantNR ? await import( '../dlss/DLSSNeural.js' ) : null;
936
+ const srcSize = this.denoiser?._outTexSize;
937
+ const nrPixels = ( srcSize?.width ?? 0 ) * ( srcSize?.height ?? 0 );
938
+ const runNR = wantNR && nrPixels <= nrModule.DLSS_NR_MAX_PIXELS;
939
+
940
+ if ( wantNR && ! runNR ) {
941
+
942
+ dlssLog.warn(
943
+ `Neural rendering skipped: the render is ${( nrPixels / 1e6 ).toFixed( 1 )} MP, above the ` +
944
+ `${( nrModule.DLSS_NR_MAX_PIXELS / 1e6 ).toFixed( 1 )} MP the pass survives.` +
945
+ ( wantSR ? ' The upscale still ran.' : '' )
946
+ );
947
+ this._releaseDLSSNeural();
948
+
949
+ }
950
+
951
+ if ( ! wantSR && ! runNR ) return;
952
+
953
+ // Must be the effective value, not the settings one: AutoExposure overwrites
954
+ // `renderer.toneMappingExposure` every frame and never touches settings, so reading
955
+ // settings — as this did — silently ignores auto-exposure. Same helper OIDN uses.
956
+ const tone = {
957
+ exposure: this._getEffectiveExposure(),
958
+ toneMapping: this._getToneMapping(),
959
+ saturation: this._getSaturation?.() ?? 1,
960
+ };
961
+
962
+ // Scene-referred throughout. Exposure reaches the detail pass through its own `paper_white`
963
+ // and comes back out again, so the engine's tone curve is still the only one applied.
964
+ const source = await sr.readDenoisedHalf( this.denoiser );
965
+
966
+ if ( wantSR ) {
967
+
968
+ if ( this._dlssUpscaler
969
+ && ( this._dlssUpscaler.inputWidth !== source.width || this._dlssUpscaler.inputHeight !== source.height ) ) {
970
+
971
+ this._releaseDLSSUpscaler();
972
+
973
+ }
974
+
975
+ if ( ! this._dlssUpscaler ) {
976
+
977
+ this._dlssUpscaler = await sr.DLSSSuperRes.create( { width: source.width, height: source.height } );
978
+
979
+ }
980
+
981
+ }
982
+
983
+ let image = source;
984
+
985
+ // Detail first, upscale second. The detail pass hands back scene-referred light (see
986
+ // `app/public/dlss/PATCHES.md`), so the upscaler still gets the linear HDR it expects —
987
+ // which is what makes this order possible at all.
988
+ if ( runNR ) {
989
+
990
+ const result = await nrModule.enhanceFrame( {
991
+ source: image,
992
+ settings: this.neuralRenderingSettings,
993
+ instance: this._dlssNeural,
994
+ exposure: tone.exposure,
995
+ tone: wantSR ? null : tone,
996
+ } );
997
+ this._dlssNeural = result.instance;
998
+ image = result;
999
+
1000
+ }
1001
+
1002
+ if ( wantSR ) image = await this._dlssUpscaler.upscaleToRGBA8( image, tone );
1003
+
1004
+ if ( ! isStillComplete() ) {
1005
+
1006
+ this.dropDisplay();
1007
+ return;
1008
+
1009
+ }
1010
+
1011
+ sr.presentRGBA8( this.upscalerCanvas, image.rgba8, image.width, image.height );
1012
+
1013
+ if ( ! isStillComplete() ) {
1014
+
1015
+ this.dropDisplay();
1016
+ return;
1017
+
1018
+ }
1019
+
1020
+ // The 2D canvas sits BEHIND the render canvas in the DOM (`insertBefore`), so making it
1021
+ // `display:block` is not enough — the render canvas has to get out of the way. This is
1022
+ // what `AIUpscaler._revealOutput` does for the other backend, and `abort()` already
1023
+ // restores both sides.
1024
+ if ( this.mainCanvas ) this.mainCanvas.style.opacity = '0';
1025
+
1026
+ // The host shows the output dimensions; without this they keep reading the render size.
1027
+ this.dispatchEvent( { type: 'resolution_changed', width: image.width, height: image.height } );
1028
+
1029
+ } catch ( error ) {
1030
+
1031
+ dlssLog.warn( 'neural pass failed', error );
1032
+
1033
+ } finally {
1034
+
1035
+ this._neuralPostBusy = false;
1036
+ this.dispatchEvent( { type: EngineEvents.UPSCALING_END } );
1037
+
1038
+ }
1039
+
1040
+ }
1041
+
789
1042
  onRenderComplete( { isStillComplete, context } ) {
790
1043
 
791
1044
  // Remove any stale completion-chain listener from a previous render cycle
@@ -805,12 +1058,21 @@ export class DenoisingManager extends EventDispatcher {
805
1058
 
806
1059
  if ( ! isStillComplete() ) return;
807
1060
 
808
- if ( this.upscaler?.enabled ) {
1061
+ // The loop stops one tick after a render completes, so nothing is left to draw the closing
1062
+ // denoise. Ordered after `_denoiserEndHandler`, which publishes the picture.
1063
+ if ( closing ) this._onDisplayRefresh?.();
1064
+
1065
+ // One owner of the overlay canvas, so the neural chain and the ONNX upscaler are
1066
+ // exclusive. The neural chain also runs on its own when only its detail pass is on.
1067
+ if ( this.neuralRendering || ( this.upscaler?.enabled && this.upscalerBackend === 'dlss' ) ) {
809
1068
 
810
- this.upscaler.start();
1069
+ this._runNeuralPost( isStillComplete );
1070
+ return;
811
1071
 
812
1072
  }
813
1073
 
1074
+ if ( this.upscaler?.enabled ) this.upscaler.start();
1075
+
814
1076
  };
815
1077
 
816
1078
  if ( ! closing ) {
@@ -887,8 +1149,8 @@ export class DenoisingManager extends EventDispatcher {
887
1149
 
888
1150
  // The 2D canvas is the upscaler's alone: a reset means its enlarged result no longer
889
1151
  // describes anything, so the render canvas comes back.
890
- if ( mainCanvas ) mainCanvas.style.opacity = '1';
891
- if ( this.upscalerCanvas ) this.upscalerCanvas.style.display = 'none';
1152
+ this._restoreRenderDisplay();
1153
+ if ( mainCanvas && mainCanvas !== this.mainCanvas ) mainCanvas.style.opacity = '1';
892
1154
  if ( this.upscaler ) this.upscaler.abort();
893
1155
 
894
1156
  const moving = !! this._stages.pathTracer?.viewIsChanging;
@@ -917,9 +1179,7 @@ export class DenoisingManager extends EventDispatcher {
917
1179
  dropDisplay() {
918
1180
 
919
1181
  this._unpublishOutput();
920
-
921
- if ( this.upscalerCanvas ) this.upscalerCanvas.style.display = 'none';
922
- if ( this.mainCanvas ) this.mainCanvas.style.opacity = '1';
1182
+ this._restoreRenderDisplay();
923
1183
 
924
1184
  }
925
1185
 
@@ -927,6 +1187,10 @@ export class DenoisingManager extends EventDispatcher {
927
1187
 
928
1188
  // Remove pending completion-chain listener
929
1189
  this._cleanupCompletionListener();
1190
+ // Owns a second GPUDevice plus 3 MB of weights, so it must not outlive the manager.
1191
+ this._releaseDLSSUpscaler();
1192
+ this._releaseDLSSNeural();
1193
+
930
1194
  // Before the denoiser destroys the picture the Compositor would otherwise still sample.
931
1195
  this._unpublishOutput();
932
1196
 
@@ -947,6 +1211,10 @@ export class DenoisingManager extends EventDispatcher {
947
1211
  if ( this._upscalerEndHandler ) this.upscaler.removeEventListener( 'end', this._upscalerEndHandler );
948
1212
  this.upscaler.dispose();
949
1213
  this.upscaler = null;
1214
+ // Which model the AI upscaler runs. 'esrgan' is the ONNX chain; 'dlss' is the neural
1215
+ // super-resolution pass, which is fixed at 2x and needs a denoised source.
1216
+ this.upscalerBackend = 'esrgan';
1217
+ this._dlssUpscaler = null;
950
1218
 
951
1219
  }
952
1220
 
@@ -957,6 +1225,10 @@ export class DenoisingManager extends EventDispatcher {
957
1225
  this._upscalerProgressHandler = null;
958
1226
  this._upscalerEndHandler = null;
959
1227
 
1228
+ this._onReset = null;
1229
+ this._onPostProcessRefresh = null;
1230
+ this._onDisplayRefresh = null;
1231
+
960
1232
  if ( this.upscalerCanvas?.parentNode ) {
961
1233
 
962
1234
  this.upscalerCanvas.parentNode.removeChild( this.upscalerCanvas );
@@ -989,6 +1261,13 @@ export class DenoisingManager extends EventDispatcher {
989
1261
 
990
1262
  }
991
1263
 
1264
+ /** @param {Function} fn - () => void, redraws the finished frame without re-running the completion chain */
1265
+ setDisplayRefreshCallback( fn ) {
1266
+
1267
+ this._onDisplayRefresh = fn;
1268
+
1269
+ }
1270
+
992
1271
  /** @param {import('../RenderSettings.js').RenderSettings} settings */
993
1272
  setSettings( settings ) {
994
1273
 
@@ -1128,6 +1407,8 @@ export class DenoisingManager extends EventDispatcher {
1128
1407
  setUpscalerEnabled( enabled ) {
1129
1408
 
1130
1409
  if ( this.upscaler ) this.upscaler.enabled = enabled;
1410
+ // The enlarged picture describes a setting that is no longer on.
1411
+ if ( ! enabled ) this._restoreRenderDisplay();
1131
1412
 
1132
1413
  }
1133
1414
 
@@ -2,6 +2,7 @@ import {
2
2
  EventDispatcher, DirectionalLight, PointLight, SpotLight, RectAreaLight,
3
3
  Object3D, MathUtils
4
4
  } from 'three';
5
+ import { bakeAreaLightScale, lightPower, setLightPower } from '../LightUnits.js';
5
6
 
6
7
  /**
7
8
  * Manages scene lights: add, remove, transfer from mesh scene to WebGPU
@@ -37,12 +38,13 @@ export class LightManager extends EventDispatcher {
37
38
  */
38
39
  addLight( type ) {
39
40
 
41
+ // Authored as Blender Power in Watts (the Sun as W/m²), converted to the stored
42
+ // quantity by setLightPower() once the light exists — see LightUnits.js.
40
43
  const defaults = {
41
- // Power in Watts (Blender-style) for point/spot/area; Sun is W/m² strength.
42
- DirectionalLight: { position: [ 1, 1, 1 ], intensity: 1.0, color: '#ffffff' },
43
- PointLight: { position: [ 0, 2, 0 ], intensity: 1000, color: '#ffffff' },
44
- SpotLight: { position: [ 0, 1, 0 ], intensity: 1000, color: '#ffffff', angle: 15 },
45
- RectAreaLight: { position: [ 0, 2, 0 ], intensity: 100, color: '#ffffff', width: 2, height: 2 }
44
+ DirectionalLight: { position: [ 1, 1, 1 ], power: 1.0, color: '#ffffff' },
45
+ PointLight: { position: [ 0, 2, 0 ], power: 1000, color: '#ffffff' },
46
+ SpotLight: { position: [ 0, 1, 0 ], power: 1000, color: '#ffffff', angle: 15 },
47
+ RectAreaLight: { position: [ 0, 2, 0 ], power: 100, color: '#ffffff', width: 2, height: 2 }
46
48
  };
47
49
 
48
50
  const props = defaults[ type ];
@@ -52,17 +54,17 @@ export class LightManager extends EventDispatcher {
52
54
 
53
55
  if ( type === 'DirectionalLight' ) {
54
56
 
55
- light = new DirectionalLight( props.color, props.intensity );
57
+ light = new DirectionalLight( props.color, 1 );
56
58
  light.position.fromArray( props.position );
57
59
 
58
60
  } else if ( type === 'PointLight' ) {
59
61
 
60
- light = new PointLight( props.color, props.intensity );
62
+ light = new PointLight( props.color, 1 );
61
63
  light.position.fromArray( props.position );
62
64
 
63
65
  } else if ( type === 'SpotLight' ) {
64
66
 
65
- light = new SpotLight( props.color, props.intensity );
67
+ light = new SpotLight( props.color, 1 );
66
68
  light.position.fromArray( props.position );
67
69
  light.angle = MathUtils.degToRad( props.angle );
68
70
  const target = new Object3D();
@@ -71,7 +73,7 @@ export class LightManager extends EventDispatcher {
71
73
 
72
74
  } else if ( type === 'RectAreaLight' ) {
73
75
 
74
- light = new RectAreaLight( props.color, props.intensity, props.width, props.height );
76
+ light = new RectAreaLight( props.color, 1, props.width, props.height );
75
77
  light.position.fromArray( props.position );
76
78
  light.lookAt( 0, 0, 0 );
77
79
  // Blender-style emission defaults: power-normalized, full Lambertian
@@ -82,6 +84,8 @@ export class LightManager extends EventDispatcher {
82
84
 
83
85
  }
84
86
 
87
+ setLightPower( light, props.power );
88
+
85
89
  // Blender-style emission controls common to every light type.
86
90
  light.userData.temperature = 6500;
87
91
  light.userData.useTemperature = false;
@@ -178,13 +182,7 @@ export class LightManager extends EventDispatcher {
178
182
  light.getWorldQuaternion( cloned.quaternion );
179
183
  light.getWorldScale( cloned.scale );
180
184
 
181
- if ( cloned.isRectAreaLight ) {
182
-
183
- cloned.width *= cloned.scale.x;
184
- cloned.height *= cloned.scale.y;
185
- cloned.scale.set( 1, 1, 1 );
186
-
187
- }
185
+ if ( cloned.isRectAreaLight ) bakeAreaLightScale( cloned );
188
186
 
189
187
  if ( ( light.isSpotLight || light.isDirectionalLight ) && light.target ) {
190
188
 
@@ -338,7 +336,7 @@ export class LightManager extends EventDispatcher {
338
336
  name: light.name,
339
337
  type: light.type,
340
338
  visible: light.visible,
341
- intensity: light.intensity,
339
+ intensity: lightPower( light ), // Blender Power (W); the light stores the three.js quantity
342
340
  color: `#${light.color.getHexString()}`,
343
341
  position: [ light.position.x, light.position.y, light.position.z ],
344
342
  angle,