rayzee 9.1.2 → 9.2.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.
Files changed (49) hide show
  1. package/README.md +176 -16
  2. package/dist/{NeuralRetouch-BRquqfmZ.js → NeuralRetouch-CYgkQY8Z.js} +1 -1
  3. package/dist/{NeuralSuperRes-CbNvQjCw.js → NeuralSuperRes-PkgDLQcD.js} +72 -40
  4. package/dist/{ToneMapGPU-DpUZDMbQ.js → ToneMapGPU-B6XIe3DU.js} +102 -92
  5. package/dist/rayzee.es.js +6209 -5571
  6. package/dist/rayzee.umd.js +137 -126
  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 +511 -136
  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/TextureReadback.js +26 -10
  28. package/src/Processor/ToneMapGPU.js +45 -15
  29. package/src/Processor/Workers/PackWorker.js +28 -0
  30. package/src/Stages/Compositor.js +25 -6
  31. package/src/Stages/PathTracer.js +142 -25
  32. package/src/Stages/PathTracerStage.js +6 -96
  33. package/src/Storage/DownloadCache.js +30 -7
  34. package/src/TSL/DebugKernel.js +1 -1
  35. package/src/TSL/GenerateKernel.js +2 -5
  36. package/src/TSL/Random.js +3 -120
  37. package/src/TSL/ShadeKernel.js +3 -3
  38. package/src/index.js +2 -1
  39. package/src/managers/CameraManager.js +2 -2
  40. package/src/managers/DenoisingManager.js +76 -7
  41. package/src/managers/GoboManager.js +8 -1
  42. package/src/managers/IESManager.js +13 -2
  43. package/src/managers/InteractionManager.js +1 -1
  44. package/src/managers/LightManager.js +31 -0
  45. package/src/managers/OverlayManager.js +11 -1
  46. package/src/managers/VideoRenderManager.js +8 -0
  47. package/src/node/NodeWorker.js +144 -0
  48. package/src/node/index.js +9 -0
  49. package/src/node/nodePlatform.js +86 -0
@@ -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
 
@@ -109,6 +109,37 @@ export class LightManager extends EventDispatcher {
109
109
 
110
110
  }
111
111
 
112
+ /**
113
+ * The light the path tracer traces, by UUID from getAll(). A model's own lights are copied in
114
+ * at load, so editing those does nothing: edit this one, then call sync().
115
+ * @param {string} uuid
116
+ * @returns {?import('three').Light}
117
+ */
118
+ getLight( uuid ) {
119
+
120
+ const light = this.scene.getObjectByProperty( 'uuid', uuid );
121
+ return light?.isLight ? light : null;
122
+
123
+ }
124
+
125
+ /**
126
+ * Sets a traced light's intensity (watts, or W/m² for directional — see the README) and uploads it.
127
+ * @param {string} uuid
128
+ * @param {number} intensity
129
+ * @returns {boolean} false when no traced light has that UUID
130
+ */
131
+ setIntensity( uuid, intensity ) {
132
+
133
+ const light = this.getLight( uuid );
134
+ if ( ! light ) return false;
135
+
136
+ light.intensity = intensity;
137
+ this.updateLights();
138
+ this._onReset?.();
139
+ return true;
140
+
141
+ }
142
+
112
143
  /**
113
144
  * Removes a light by UUID.
114
145
  * @param {string} uuid
@@ -3,6 +3,7 @@ import { OutlineHelper } from './helpers/OutlineHelper.js';
3
3
  import { ViewOverlayRenderer } from './helpers/ViewOverlayRenderer.js';
4
4
  import { createOverlayCanvas, viewPixelSize } from './helpers/overlaySurface.js';
5
5
  import { createLogger } from '../utils/Logger.js';
6
+ import { EngineEvents } from '../EngineEvents.js';
6
7
 
7
8
  const log = createLogger( 'overlay' );
8
9
 
@@ -84,6 +85,15 @@ export class OverlayManager {
84
85
 
85
86
  }
86
87
 
88
+ /** The overlay surface while it is shown: one presented image and three's output buffers. */
89
+ gpuResources() {
90
+
91
+ const view = this._viewOverlay;
92
+ if ( ! view?.isVisible ) return [];
93
+ return [ { bytes: view.canvas.width * view.canvas.height * 4 }, ...( view.renderer?._frameBufferTargets?.values() ?? [] ) ];
94
+
95
+ }
96
+
87
97
  /**
88
98
  * Sets the SceneHelpers instance used for 3D overlay rendering.
89
99
  * @param {import('../SceneHelpers.js').SceneHelpers} helperScene
@@ -149,7 +159,7 @@ export class OverlayManager {
149
159
 
150
160
  tileHelper.setRenderSize( renderWidth || 1, renderHeight || 1 );
151
161
 
152
- app.addEventListener( 'resolution_changed', ( e ) => {
162
+ app.addEventListener( EngineEvents.RESOLUTION_CHANGED, ( e ) => {
153
163
 
154
164
  tileHelper.setRenderSize( e.width, e.height );
155
165
 
@@ -219,6 +219,14 @@ export class VideoRenderManager {
219
219
 
220
220
  while ( ! pathTracer.isComplete && ! this._cancelled ) {
221
221
 
222
+ const wait = pathTracer.readbackWait();
223
+ if ( wait ) {
224
+
225
+ await wait;
226
+ continue;
227
+
228
+ }
229
+
222
230
  app.cameraManager.camera.updateMatrixWorld();
223
231
  app.pipeline.render();
224
232
 
@@ -0,0 +1,144 @@
1
+ import { Worker as ThreadWorker } from 'node:worker_threads';
2
+ import { Buffer, resolveObjectURL } from 'node:buffer';
3
+
4
+ // Evaluated inside the thread: a dedicated worker's `self` over parentPort, then the module. Messages
5
+ // that arrive before the module has installed its handler are held, not dropped. No require(): a
6
+ // thread inherits --input-type=module, which evaluates this as an ES module.
7
+ const BOOTSTRAP = `
8
+ const { parentPort } = process.getBuiltinModule( 'node:worker_threads' );
9
+ const listeners = { message: new Set(), error: new Set(), messageerror: new Set() };
10
+ const held = [];
11
+ let ready = false;
12
+
13
+ const transferable = ( list ) => ( list ?? [] ).filter( ( item ) => item instanceof ArrayBuffer || item?.constructor?.name === 'MessagePort' );
14
+ const deliver = ( data ) => {
15
+
16
+ const event = { type: 'message', data };
17
+ globalThis.onmessage?.( event );
18
+ for ( const fn of listeners.message ) fn( event );
19
+
20
+ };
21
+
22
+ globalThis.self = globalThis;
23
+ globalThis.onmessage = null;
24
+ globalThis.postMessage = ( data, transfer ) => parentPort.postMessage( data, transferable( Array.isArray( transfer ) ? transfer : transfer?.transfer ) );
25
+ globalThis.addEventListener = ( type, fn ) => listeners[ type ]?.add( fn );
26
+ globalThis.removeEventListener = ( type, fn ) => listeners[ type ]?.delete( fn );
27
+ globalThis.close = () => process.exit( 0 );
28
+
29
+ parentPort.on( 'message', ( message ) => {
30
+
31
+ if ( message?.__rayzeeModule !== undefined ) {
32
+
33
+ const release = () => {
34
+
35
+ ready = true;
36
+ for ( const data of held.splice( 0 ) ) deliver( data );
37
+
38
+ };
39
+
40
+ // A classic worker is a script in the global scope (three's Draco decoder is one).
41
+ if ( message.classic ) {
42
+
43
+ process.getBuiltinModule( 'node:vm' ).runInThisContext( message.__rayzeeModule );
44
+ release();
45
+
46
+ } else {
47
+
48
+ import( 'data:text/javascript;charset=utf-8,' + encodeURIComponent( message.__rayzeeModule ) ).then( release, ( error ) => { throw error; } );
49
+
50
+ }
51
+
52
+ return;
53
+
54
+ }
55
+
56
+ if ( ready ) deliver( message );
57
+ else held.push( message );
58
+
59
+ } );
60
+ `;
61
+
62
+ const DATA_URL = /^data:([^,]*?)(;base64)?,(.*)$/s;
63
+
64
+ function readModuleSource( url ) {
65
+
66
+ const href = String( url );
67
+
68
+ if ( href.startsWith( 'blob:' ) ) {
69
+
70
+ const blob = resolveObjectURL( href );
71
+ if ( ! blob ) throw new Error( `NodeWorker: no blob behind ${href}` );
72
+ return blob.text();
73
+
74
+ }
75
+
76
+ const match = DATA_URL.exec( href );
77
+ if ( ! match ) throw new Error( `NodeWorker: only data: and blob: worker URLs are supported, got ${href.slice( 0, 64 )}` );
78
+ const [ , , base64, body ] = match;
79
+ return base64 ? Buffer.from( body, 'base64' ).toString( 'utf8' ) : decodeURIComponent( body );
80
+
81
+ }
82
+
83
+ /**
84
+ * The Web Worker API over `worker_threads`, for hosts with no browser (see nodePlatform). It runs
85
+ * `data:` and `blob:` workers, module or classic: the engine's own, which the published build inlines,
86
+ * and three.js's. Those call the global `Worker`, so a model using Draco or KTX2 also needs
87
+ * `globalThis.Worker = NodeWorker`. ArrayBuffers transfer and
88
+ * SharedArrayBuffers share as in a browser; an ImageBitmap cannot exist here to transfer.
89
+ */
90
+ export class NodeWorker {
91
+
92
+ constructor( url, options = {} ) {
93
+
94
+ this.onmessage = null;
95
+ this.onerror = null;
96
+ this.onmessageerror = null;
97
+ this._listeners = { message: new Set(), error: new Set(), messageerror: new Set() };
98
+
99
+ this._thread = new ThreadWorker( BOOTSTRAP, { eval: true, name: options.name } );
100
+ this._thread.on( 'message', ( data ) => this._emit( 'message', { type: 'message', data } ) );
101
+ this._thread.on( 'messageerror', ( error ) => this._emit( 'messageerror', { type: 'messageerror', error } ) );
102
+ this._thread.on( 'error', ( error ) => this._emit( 'error', { type: 'error', error, message: error?.message ?? String( error ) } ) );
103
+
104
+ // Anything the caller posts before the module lands is held in the thread, in order.
105
+ const source = readModuleSource( url );
106
+ const load = ( code ) => this._thread.postMessage( { __rayzeeModule: code, classic: options.type !== 'module' } );
107
+ if ( typeof source === 'string' ) load( source );
108
+ else source.then( load, ( error ) => this._emit( 'error', { type: 'error', error, message: error.message } ) );
109
+
110
+ }
111
+
112
+ postMessage( data, transfer ) {
113
+
114
+ const list = Array.isArray( transfer ) ? transfer : transfer?.transfer;
115
+ this._thread.postMessage( data, ( list ?? [] ).filter( ( item ) => item instanceof ArrayBuffer ) );
116
+
117
+ }
118
+
119
+ addEventListener( type, fn ) {
120
+
121
+ this._listeners[ type ]?.add( fn );
122
+
123
+ }
124
+
125
+ removeEventListener( type, fn ) {
126
+
127
+ this._listeners[ type ]?.delete( fn );
128
+
129
+ }
130
+
131
+ terminate() {
132
+
133
+ this._thread.terminate();
134
+
135
+ }
136
+
137
+ _emit( type, event ) {
138
+
139
+ this[ `on${type}` ]?.( event );
140
+ for ( const fn of this._listeners[ type ] ) fn( event );
141
+
142
+ }
143
+
144
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Node-only helpers, kept out of the main entry so a browser bundle never sees `node:` imports.
3
+ *
4
+ * import { configurePlatform, openHeadless } from 'rayzee';
5
+ * import { nodePlatform } from 'rayzee/node';
6
+ * configurePlatform( nodePlatform( { decodeImage } ) );
7
+ */
8
+ export { NodeWorker } from './NodeWorker.js';
9
+ export { nodePlatform } from './nodePlatform.js';
@@ -0,0 +1,86 @@
1
+ import { NodeWorker } from './NodeWorker.js';
2
+
3
+ // three.js's FileLoader constructs one on every chunk of a streamed download.
4
+ class NodeProgressEvent extends Event {
5
+
6
+ constructor( type, { lengthComputable = false, loaded = 0, total = 0 } = {} ) {
7
+
8
+ super( type );
9
+ this.lengthComputable = lengthComputable;
10
+ this.loaded = loaded;
11
+ this.total = total;
12
+
13
+ }
14
+
15
+ }
16
+
17
+ const SHARED_UPLOAD_CHUNK = 64 * 2 ** 20;
18
+ const WRAPPED = Symbol.for( 'rayzee.sharedUploads' );
19
+
20
+ function sharedBytes( data, elementOffset = 0, elementCount = undefined ) {
21
+
22
+ const isView = ArrayBuffer.isView( data );
23
+ const backing = isView ? data.buffer : data;
24
+ if ( ! ( backing instanceof SharedArrayBuffer ) ) return null;
25
+
26
+ const size = isView ? data.BYTES_PER_ELEMENT ?? 1 : 1;
27
+ const start = ( isView ? data.byteOffset : 0 ) + elementOffset * size;
28
+ const length = elementCount !== undefined ? elementCount * size : ( isView ? data.byteLength : backing.byteLength ) - elementOffset * size;
29
+ return new Uint8Array( backing, start, length );
30
+
31
+ }
32
+
33
+ // dawn.node (webgpu 0.6.1) segfaults writing from a SharedArrayBuffer, which WebGPU allows and the
34
+ // engine's triangle and BVH stores are. Such uploads are copied out first, 64 MB at a time.
35
+ function copySharedUploads() {
36
+
37
+ const Queue = globalThis.GPUQueue;
38
+ if ( ! Queue ) throw new Error( 'nodePlatform(): install the webgpu globals first — Object.assign( globalThis, globals )' );
39
+ if ( Queue.prototype[ WRAPPED ] ) return;
40
+
41
+ const { writeBuffer, writeTexture } = Queue.prototype;
42
+
43
+ Queue.prototype.writeBuffer = function ( ...args ) {
44
+
45
+ const [ buffer, bufferOffset, data, dataOffset, size ] = args;
46
+ const bytes = sharedBytes( data, dataOffset ?? 0, size );
47
+ if ( ! bytes ) return writeBuffer.apply( this, args );
48
+
49
+ for ( let at = 0; at < bytes.length; at += SHARED_UPLOAD_CHUNK ) {
50
+
51
+ writeBuffer.call( this, buffer, bufferOffset + at, bytes.slice( at, at + SHARED_UPLOAD_CHUNK ) );
52
+
53
+ }
54
+
55
+ };
56
+
57
+ Queue.prototype.writeTexture = function ( ...args ) {
58
+
59
+ const bytes = sharedBytes( args[ 1 ] );
60
+ if ( bytes ) args[ 1 ] = bytes.slice();
61
+ return writeTexture.apply( this, args );
62
+
63
+ };
64
+
65
+ Queue.prototype[ WRAPPED ] = true;
66
+
67
+ }
68
+
69
+ /**
70
+ * Everything the engine needs from Node, for `configurePlatform( nodePlatform( { decodeImage } ) )`:
71
+ * the worker class and the host's image decoder — the engine carries none. It also defines
72
+ * `ProgressEvent` where the runtime lacks it, the one browser class three.js's loaders construct.
73
+ * WebGPU itself is the host's: install the `webgpu` package's globals and `navigator.gpu` first, since
74
+ * its queue is wrapped here to copy uploads out of shared memory.
75
+ *
76
+ * @param {Object} [options]
77
+ * @param {( bytes: Uint8Array, mimeType: string ) => Promise<{data: Uint8Array, width: number, height: number}>} [options.decodeImage]
78
+ * PNG/JPEG/WebP bytes to RGBA8 pixels, top row first. Without it a model's textures cannot load.
79
+ */
80
+ export function nodePlatform( { decodeImage } = {} ) {
81
+
82
+ globalThis.ProgressEvent ??= NodeProgressEvent;
83
+ copySharedUploads();
84
+ return { Worker: NodeWorker, decodeImage: decodeImage ?? null };
85
+
86
+ }