rayzee 11.7.0 → 11.8.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 (59) hide show
  1. package/README.md +13 -0
  2. package/dist/{ArchiveImporter-DYcRXeKE.js → ArchiveImporter-_sJm5uaK.js} +1923 -1243
  3. package/dist/{BidirectionalIntegrator-BvjRpbTP.js → BidirectionalIntegrator-Dzt4Z97x.js} +290 -289
  4. package/dist/{ColorManagement-oeEk7oSu.js → ColorManagement-30MfpHwQ.js} +1 -1
  5. package/dist/{EngineIssues-CIZnXHsF.js → EngineIssues-xljoXM0E.js} +18 -8
  6. package/dist/{NeuralRetouch-ClaMiKYg.js → NeuralRetouch-BKQCsmEs.js} +2 -2
  7. package/dist/{NeuralSuperRes-WNEXun9s.js → NeuralSuperRes-C4ymq68e.js} +2 -2
  8. package/dist/{PathTracer-B9wZPPVL.js → PathTracer-C_to_i40.js} +3156 -3098
  9. package/dist/{PhysicalSky-DcjItqKi.js → PhysicalSky-bMHbm_7H.js} +1 -1
  10. package/dist/{RayzeeRenderer-p-NeE6E2.js → RayzeeRenderer-B_aB6VTJ.js} +370 -371
  11. package/dist/SceneMetadata-CSmlJW4c.js +183 -0
  12. package/dist/{ToneMapGPU-sAs8wdgI.js → ToneMapGPU-4GPwoG_c.js} +13 -6
  13. package/dist/addons/archives.es.js +1 -1
  14. package/dist/addons/bidirectional.es.js +1 -1
  15. package/dist/addons/color.es.js +1 -1
  16. package/dist/addons/physical-sky.es.js +1 -1
  17. package/dist/addons/storage.es.js +1 -1
  18. package/dist/{openStorage-Fe9BoS1_.js → openStorage-B085D9FR.js} +1 -1
  19. package/dist/rayzee-core.es.js +5 -5
  20. package/dist/rayzee.es.js +815 -796
  21. package/dist/rayzee.umd.js +106 -102
  22. package/dist/{sceneCachePolicy-B9w3BnaP.js → sceneCachePolicy-D_uBSbQF.js} +2 -2
  23. package/package.json +1 -1
  24. package/src/EngineIssues.js +20 -6
  25. package/src/Passes/AIUpscaler.js +4 -0
  26. package/src/Passes/OIDNDenoiser.js +8 -6
  27. package/src/PathTracerApp.js +10 -1
  28. package/src/Processor/ArchiveImporter.js +23 -6
  29. package/src/Processor/AssetLoader.js +15 -0
  30. package/src/Processor/LightSerializer.js +6 -2
  31. package/src/Processor/PBRT/PBRTMaterials.js +2 -1
  32. package/src/Processor/PBRT/PBRTSceneBuilder.js +1 -1
  33. package/src/Processor/PlatformImageLoader.js +9 -0
  34. package/src/Processor/TextureCreator.js +44 -4
  35. package/src/Processor/ToneMapCPU.js +34 -5
  36. package/src/Processor/ToneMapGPU.js +15 -9
  37. package/src/Processor/USD/USDScene.js +981 -17
  38. package/src/RayzeeRenderer.js +81 -26
  39. package/src/Stages/AutoExposure.js +21 -8
  40. package/src/Stages/Compositor.js +39 -1
  41. package/src/Stages/LocalExposure.js +44 -18
  42. package/src/Stages/NormalDepth.js +7 -3
  43. package/src/Stages/PathTracer.js +25 -1
  44. package/src/Stages/PathTracerStage.js +9 -1
  45. package/src/TSL/CameraRay.js +12 -1
  46. package/src/TSL/DebugKernel.js +3 -2
  47. package/src/TSL/FinalWriteKernel.js +4 -7
  48. package/src/TSL/GenerateKernel.js +6 -2
  49. package/src/TSL/LightSplatKernel.js +7 -4
  50. package/src/TSL/LightsCore.js +6 -2
  51. package/src/TSL/LightsSampling.js +45 -12
  52. package/src/TSL/SceneResources.js +11 -0
  53. package/src/TSL/ShadeKernel.js +1 -2
  54. package/src/integrators/BidirectionalIntegrator.js +1 -0
  55. package/src/managers/CameraManager.js +9 -0
  56. package/src/managers/DenoisingManager.js +1 -0
  57. package/src/managers/InteractionManager.js +6 -0
  58. package/src/managers/UniformManager.js +3 -1
  59. package/dist/SceneMetadata-D2Sh-0e-.js +0 -110
@@ -1,7 +1,7 @@
1
1
  import { t as e } from "./EngineEvents-CLckhYbL.js";
2
2
  import { a as t, i as n, r } from "./Logger-dDDGj9Av.js";
3
3
  import { f as i, v as a, y as o } from "./BufferLayout-xPRH9jYD.js";
4
- import { n as s } from "./EngineIssues-CIZnXHsF.js";
4
+ import { n as s } from "./EngineIssues-xljoXM0E.js";
5
5
  import { n as c, r as l } from "./shared-BGBcSxBQ.js";
6
6
  import { n as u } from "./AssetConfig-DSEntE65.js";
7
7
  import { BackSide as d, BufferAttribute as f, Color as p, DoubleSide as m, FrontSide as h, Matrix3 as g, Matrix4 as _, RGBAFormat as v, Vector2 as y, Vector3 as b } from "three";
@@ -1594,7 +1594,7 @@ var vt = class {
1594
1594
  samples: this.samples.slice()
1595
1595
  };
1596
1596
  }
1597
- }, yt = "11.7.0", $ = 1e4, bt = 15e5;
1597
+ }, yt = "11.8.0", $ = 1e4, bt = 15e5;
1598
1598
  function xt(e, t, n = $) {
1599
1599
  return e >= n && t / bt < e / 3;
1600
1600
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "11.7.0",
3
+ "version": "11.8.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -6,6 +6,9 @@
6
6
  */
7
7
  export const ISSUE_CODES = Object.freeze( {
8
8
  ADAPTER_SOFTWARE: 'adapter.software',
9
+ ADAPTER_UNAVAILABLE: 'adapter.unavailable',
10
+ DEVICE_UNAVAILABLE: 'device.unavailable',
11
+ WEBGPU_UNAVAILABLE: 'webgpu.unavailable',
9
12
  ASSET_UNREACHABLE: 'asset.unreachable',
10
13
  ASSET_AMBIGUOUS_ENTRY: 'asset.ambiguous_entry',
11
14
  ASSET_ARCHIVE_TOO_LARGE: 'asset.archive_too_large',
@@ -42,9 +45,9 @@ export const ISSUE_SEVERITY = Object.freeze( { ERROR: 'error', WARNING: 'warning
42
45
 
43
46
  export class EngineIssueError extends Error {
44
47
 
45
- constructor( issue ) {
48
+ constructor( issue, options ) {
46
49
 
47
- super( `[${issue.code}] ${issue.message}` );
50
+ super( `[${issue.code}] ${issue.message}`, options );
48
51
  this.name = 'EngineIssueError';
49
52
  this.code = issue.code;
50
53
  this.detail = issue.detail;
@@ -67,13 +70,24 @@ export class IssueLog {
67
70
  /** @throws {EngineIssueError} when strict and severity is 'error' */
68
71
  record( code, message, detail = {}, severity = ISSUE_SEVERITY.ERROR ) {
69
72
 
70
- const issue = Object.freeze( { code, message, detail, severity, at: Date.now() } );
71
- this._entries.push( issue );
73
+ const issue = this._add( code, message, detail, severity );
74
+ if ( this.strict && severity === ISSUE_SEVERITY.ERROR ) throw new EngineIssueError( issue );
75
+ return issue;
72
76
 
73
- this._onIssue?.( issue );
77
+ }
74
78
 
75
- if ( this.strict && severity === ISSUE_SEVERITY.ERROR ) throw new EngineIssueError( issue );
79
+ /** For what nothing can continue past: records an error and throws it, strict or not. */
80
+ fail( code, message, detail = {}, cause ) {
76
81
 
82
+ throw new EngineIssueError( this._add( code, message, detail, ISSUE_SEVERITY.ERROR ), cause === undefined ? undefined : { cause } );
83
+
84
+ }
85
+
86
+ _add( code, message, detail, severity ) {
87
+
88
+ const issue = Object.freeze( { code, message, detail, severity, at: Date.now() } );
89
+ this._entries.push( issue );
90
+ this._onIssue?.( issue );
77
91
  return issue;
78
92
 
79
93
  }
@@ -78,6 +78,7 @@ export class AIUpscaler extends EventDispatcher {
78
78
  * @param {Function} [options.getGPUTextures] - Returns { color: GPUTexture } for HDR path
79
79
  * @param {Function} [options.getExposure] - Returns current exposure multiplier
80
80
  * @param {Function} [options.getToneMapping] - Returns Three.js ToneMapping constant
81
+ * @param {Function} [options.getTransparentBackground] - Whether the HDR path keeps the render's coverage as alpha
81
82
  * @param {number} [options.tileSize] - Override default tile size
82
83
  */
83
84
  constructor( output, renderer, options = {} ) {
@@ -101,6 +102,7 @@ export class AIUpscaler extends EventDispatcher {
101
102
  this.getToneMapping = options.getToneMapping || ( () => ACESFilmicToneMapping );
102
103
  this.getSaturation = options.getSaturation || ( () => 1.0 );
103
104
  this.getDisplayGain = options.getDisplayGain || ( () => null );
105
+ this.getTransparentBackground = options.getTransparentBackground || ( () => false );
104
106
 
105
107
  // Configuration
106
108
  this.enabled = false;
@@ -506,6 +508,8 @@ export class AIUpscaler extends EventDispatcher {
506
508
  gain: this.getDisplayGain(),
507
509
  } );
508
510
  this._toneMapper.release();
511
+ // The render's alpha is coverage; the canvas shows an opaque backdrop opaque, and so does the upscale.
512
+ if ( ! this.getTransparentBackground() ) planes.fill( 1, 3 * width * height );
509
513
 
510
514
  return { planes, width, height, isHDR: true };
511
515
 
@@ -443,10 +443,10 @@ export class OIDNDenoiser extends EventDispatcher {
443
443
 
444
444
  } while ( this._reloadPending );
445
445
 
446
- // Fire a start() that arrived mid-load. Guard on unet+enabled so a failed load
447
- // or a disable during loading doesn't kick off a denoise. Outside the loop, so a
448
- // no-op reload still releases it — it used to be stranded by the early return below.
449
- if ( this._pendingStart && this.unet && this.enabled ) {
446
+ // Fire a start() that arrived mid-load, unless it was disabled meanwhile. After a failed
447
+ // load too: it denoises nothing but still sends 'end', which its caller waits for. Outside
448
+ // the loop, so a no-op reload still releases it — it used to be stranded by the early return below.
449
+ if ( this._pendingStart && this.enabled ) {
450
450
 
451
451
  const continuous = this._pendingStartContinuous;
452
452
  this._pendingStart = false;
@@ -617,8 +617,6 @@ export class OIDNDenoiser extends EventDispatcher {
617
617
 
618
618
  async execute( continuous = false ) {
619
619
 
620
- if ( ! this.enabled || ! this.unet ) return false;
621
-
622
620
  // Create abort controller for this execution
623
621
  const runId = ++ this._runId;
624
622
  this.state.abortController = new AbortController();
@@ -626,6 +624,10 @@ export class OIDNDenoiser extends EventDispatcher {
626
624
 
627
625
  try {
628
626
 
627
+ // Inside the try, so the finally ends what start() announced: with no weights (a failed
628
+ // load) the Denoising badge, the closing upscaler and a video export waited forever.
629
+ if ( ! this.enabled || ! this.unet ) return false;
630
+
629
631
  // The GPU path bails out early — no textures yet, no device — by returning false, and
630
632
  // a caller that read that as success published a picture nothing had written to.
631
633
  return await this._executeUNetGPU( continuous ) !== false;
@@ -5,6 +5,7 @@ import { MotionVector } from './Stages/MotionVector.js';
5
5
  import { NRD } from './Stages/NRD.js';
6
6
  import { AutoExposure, AUTO_EXPOSURE_DEFAULTS } from './Stages/AutoExposure.js';
7
7
  import { LocalExposure, LOCAL_EXPOSURE_DEFAULTS } from './Stages/LocalExposure.js';
8
+ import { backdropGain } from './Processor/ToneMapCPU.js';
8
9
  import { PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, modePresetSettings } from './EngineDefaults.js';
9
10
  import { createLogger } from './utils/Logger.js';
10
11
  import { EngineEvents } from './EngineEvents.js';
@@ -323,6 +324,7 @@ export class PathTracerApp extends RayzeeRenderer {
323
324
 
324
325
  if ( this.stages.autoExposure ) this.stages.autoExposure.setCompensation( value );
325
326
  else core.applyExposure( value );
327
+ this.stages.compositor?.setBackdropExposure( value );
326
328
 
327
329
  },
328
330
  onCameraProjection: ( value ) => {
@@ -423,7 +425,12 @@ export class PathTracerApp extends RayzeeRenderer {
423
425
  const le = this.stages.localExposure;
424
426
  // A readback outside the loop (headless) can come before the loop would have built it from this image.
425
427
  if ( le?.wantsBuild ) le.build( this.pipeline.context );
426
- return le?.toneGain() ?? null;
428
+ const local = le?.toneGain() ?? null;
429
+ if ( this.pipeline?.context.getState( 'pathtracer:backdrop' ) !== 'color' ) return local;
430
+
431
+ // As the compositor shows it: a solid backdrop at the manual exposure, untouched by auto and local exposure.
432
+ const { toneMappingExposure, toneMapping } = this.renderer;
433
+ return { ...local, backdrop: backdropGain( this.settings.get( 'exposure' ) ?? 1, toneMappingExposure, toneMapping ) };
427
434
 
428
435
  }
429
436
 
@@ -1130,6 +1137,8 @@ export class PathTracerApp extends RayzeeRenderer {
1130
1137
  if ( ! ae ) return;
1131
1138
 
1132
1139
  ae.setCompensation( this.settings.get( 'exposure' ) ?? 1 );
1140
+ // A solid backdrop stays at the manual exposure while auto exposure moves the picture.
1141
+ this.stages.compositor.setBackdropExposure( this.settings.get( 'exposure' ) ?? 1 );
1133
1142
  if ( this.stages.localExposure ) this.stages.localExposure.onChange = () => this._refreshFinished();
1134
1143
  ae.onChange = () => this._refreshFinished();
1135
1144
 
@@ -26,9 +26,10 @@ import { loadPBRTScene, pickEntryPath, VirtualFS, PBRT_BUILD_REVISION } from './
26
26
  import { loadUSDScene, listUSDRootLayers, listUSDParts, USDFiles, USDStage, USD_LAYER } from './USD/index.js';
27
27
  import { pfmTexture } from './PBRT/PFM.js';
28
28
  import { extractSceneMetadata } from './SceneMetadata.js';
29
- import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
29
+ import { ISSUE_CODES, ISSUE_SEVERITY, EngineIssueError } from '../EngineIssues.js';
30
30
  import { ARCHIVE_FORMATS } from './archiveFormats.js';
31
- import { withHostWorker } from '../Platform.js';
31
+ import { withHostWorker, getPlatform } from '../Platform.js';
32
+ import { platformImageFromBytes } from './PlatformImageLoader.js';
32
33
 
33
34
  const MTL_TEXTURE_TIMEOUT_MS = 30000;
34
35
  const MAIN_MODEL_FILES = [ 'scene.gltf', 'scene.glb', 'model.gltf', 'model.glb', 'main.gltf', 'main.glb', 'asset.gltf', 'asset.glb' ];
@@ -646,6 +647,7 @@ export class ArchiveImporter {
646
647
 
647
648
  const stored = graphKey ? await this._loadStoredGraph( graphKey, source, { imageFromBytes, envFromBytes } ) : null;
648
649
  const built = stored ?? await loadPBRTScene( { vfs: zip, source, entryPath, plyParser, imageFromBytes, envFromBytes, ...shape } );
650
+ this.loader._throwDeferred();
649
651
 
650
652
  const { group, environment, animations, report, warnings, meshCount, entryPath: loadedEntry, candidates,
651
653
  frames, parseMs, buildMs, triangleCount, placementCount, mergedShapes, skippedForBudget,
@@ -729,7 +731,20 @@ export class ArchiveImporter {
729
731
  /** A texture map from an archive, tagged with its path so a stored scene can read it back. @private */
730
732
  async _archiveImage( bytes, fname ) {
731
733
 
732
- const texture = await this._pbrtTextureFromBytes( bytes, fname );
734
+ let texture;
735
+ try {
736
+
737
+ texture = await this._pbrtTextureFromBytes( bytes, fname );
738
+
739
+ } catch ( error ) {
740
+
741
+ // As a glTF's image: its surfaces render untextured, and a strict host hears of it at the end of the load.
742
+ if ( error instanceof EngineIssueError ) throw error;
743
+ this.loader._reportImageFailure( fname, error );
744
+ return null;
745
+
746
+ }
747
+
733
748
  if ( texture ) texture.userData[ ARCHIVE_PATH ] = fname;
734
749
  return texture;
735
750
 
@@ -803,6 +818,7 @@ export class ArchiveImporter {
803
818
  maxTriangles: options.maxTriangles, maxPlacements: options.maxPlacements, mergeShapesAbove: options.mergeShapesAbove,
804
819
  curveSteps: options.curveSteps, curveSides: options.curveSides, curveTolerance: options.curveTolerance,
805
820
  } );
821
+ this.loader._throwDeferred();
806
822
 
807
823
  const { group, environment, warnings, triangleCount, placementCount, skippedForBudget, meshCount, parseMs, buildMs, fitNote } = built;
808
824
  this.lastUSDStats = { parseMs, buildMs, loaderMs: performance.now() - start, triangleCount, placementCount, skippedForBudget, meshCount, fitNote };
@@ -915,9 +931,10 @@ export class ArchiveImporter {
915
931
 
916
932
  } else {
917
933
 
918
- // png / jpg / webp / gif / bmp
919
- const bitmap = await createImageBitmap( new Blob( [ bytes ] ) );
920
- tex = new Texture( bitmap );
934
+ // png / jpg / webp / gif / bmp: the host's decoder where it has one (Node), else the browser's.
935
+ if ( getPlatform().decodeImage ) tex = await platformImageFromBytes( bytes, fname );
936
+ else if ( typeof createImageBitmap === 'function' ) tex = new Texture( await createImageBitmap( new Blob( [ bytes ] ) ) );
937
+ else throw new Error( 'no image decoder in this runtime: configurePlatform( nodePlatform( { decodeImage } ) ) — see "Running in Node"' );
921
938
  tex.colorSpace = SRGBColorSpace;
922
939
 
923
940
  }
@@ -52,6 +52,20 @@ function standInForSplit( source ) {
52
52
 
53
53
  }
54
54
 
55
+ // glTF's znear is a camera's near clipping plane (Blender writes its clip start there): the path tracer honours it.
56
+ export const cameraClipPlugin = {
57
+ name: 'RAYZEE_camera_clip',
58
+ afterRoot( result ) {
59
+
60
+ for ( const scene of result.scenes ) scene.traverse( ( object ) => {
61
+
62
+ if ( object.isCamera && object.near > 0 ) object.userData.clipStart = object.near;
63
+
64
+ } );
65
+
66
+ },
67
+ };
68
+
55
69
  // Luminous efficacy glTF, three.js and Blender's exporter all assume (PBR_WATTS_TO_LUMENS).
56
70
  const LUMENS_PER_WATT = 683;
57
71
 
@@ -768,6 +782,7 @@ export class AssetLoader extends EventDispatcher {
768
782
  if ( getPlatform().decodeImage ) loader.register( ( parser ) => platformImagesPlugin( parser, onImageFailure ) );
769
783
  else if ( ! hasImageDecoder() ) loader.register( ( parser ) => missingImageDecoderPlugin( parser, onImageFailure ) );
770
784
  loader.register( ( parser ) => diffuseTransmissionPlugin( parser ) );
785
+ loader.register( () => cameraClipPlugin );
771
786
 
772
787
  return loader;
773
788
 
@@ -32,6 +32,9 @@ function effectiveEmission( light ) {
32
32
 
33
33
  }
34
34
 
35
+ // A point or spot light's radius (Blender's Radius, USD's SphereLight radius): `userData.radius`, in scene units.
36
+ const lightRadius = ( light ) => Math.max( 0, Number( light.userData?.radius ) || 0 );
37
+
35
38
  export class LightSerializer {
36
39
 
37
40
  constructor() {
@@ -216,7 +219,8 @@ export class LightSerializer {
216
219
  em.r, em.g, em.b, // color (3) — incl. temperature tint
217
220
  em.intensity * INV_4PI, // radiant intensity W/sr = power(W)/4π, incl. exposure (1)
218
221
  light.distance || 0.0, // cutoff distance (0 = infinite) (1)
219
- light.decay !== undefined ? light.decay : 2.0 // decay exponent (1)
222
+ light.decay !== undefined ? light.decay : 2.0, // decay exponent (1)
223
+ lightRadius( light ), // radius: a disc this size facing the lit point, 0 = a point (1)
220
224
  ],
221
225
  importance: importance,
222
226
  light: light
@@ -266,7 +270,7 @@ export class LightSerializer {
266
270
  goboIntensity, // signed gobo strength (1)
267
271
  iesIndex, // IES profile index, -1 = none (1)
268
272
  iesIntensity, // IES blend [0,1] (1)
269
- 0.0, // reserved (1)
273
+ lightRadius( light ), // radius, as a point light's (1)
270
274
  0.0, // reserved (1) — keeps stride at 20 (vec4 aligned)
271
275
  ],
272
276
  importance: importance,
@@ -393,7 +393,8 @@ export async function buildMaterial( def, ctx ) {
393
393
  case 'metal': {
394
394
 
395
395
  await applyConductorColor( mat, params, ctx, '', applyAlbedo, setColor );
396
- await applyRoughness( mat, 'roughness', params, 0.1, ctx );
396
+ // pbrt's roughness defaults to 0, a mirror.
397
+ await applyRoughness( mat, 'roughness', params, 0, ctx );
397
398
  break;
398
399
 
399
400
  }
@@ -108,7 +108,7 @@ const DEFAULT_CURVE_SIDES = { flat: 1, ribbon: 1, cylinder: 2 };
108
108
  const DEFAULT_CURVE_TOLERANCE = 0.05;
109
109
 
110
110
  /** Bumped whenever the same scene files build a different graph, so a stored graph is not reused. */
111
- export const PBRT_BUILD_REVISION = 10;
111
+ export const PBRT_BUILD_REVISION = 11;
112
112
 
113
113
  function samePlacements( a, b ) {
114
114
 
@@ -97,6 +97,15 @@ export async function loadPlatformImage( url, mimeType = mimeTypeOf( { uri: url
97
97
 
98
98
  }
99
99
 
100
+ /** An image file's bytes as a texture, decoded by the platform: {@link loadPlatformImage} without the fetch. */
101
+ export async function platformImageFromBytes( bytes, filename ) {
102
+
103
+ const texture = pixelTexture( await decodeBytes( bytes, mimeTypeOf( { uri: filename } ) ), { shared: true } );
104
+ texture.flipY = true;
105
+ return texture;
106
+
107
+ }
108
+
100
109
  /**
101
110
  * A GLTFLoader plugin for a runtime with no way to decode an image (Node without `decodeImage`): each image fails with
102
111
  * what to configure, where three.js's own path threw `self is not defined` and took the whole load down.
@@ -1,4 +1,4 @@
1
- import { DataArrayTexture, RGBAFormat, LinearFilter, UnsignedByteType, SRGBColorSpace, LinearSRGBColorSpace, RepeatWrapping, FloatType, HalfFloatType } from "three";
1
+ import { DataArrayTexture, RGBAFormat, RGFormat, RedFormat, LinearFilter, UnsignedByteType, UnsignedShortType, SRGBColorSpace, LinearSRGBColorSpace, RepeatWrapping, FloatType, HalfFloatType, DataUtils } from "three";
2
2
  import { MATERIAL_DATA_LAYOUT, BVH_LEAF_MARKERS, assertBVHIndexFits, bvhIndexView } from './BufferLayout.js';
3
3
  import { alignBucketWidth, TEXTURE_CONSTANTS } from './TextureBuckets.js';
4
4
  import { packMaterial } from './MaterialPacking.js';
@@ -1260,7 +1260,7 @@ export class TextureCreator {
1260
1260
 
1261
1261
  const image = texture?.image;
1262
1262
  const pixels = texture?.isCompressedTexture ? texture.mipmaps?.[ 0 ] : image;
1263
- const data = pixels?.data;
1263
+ const data = texture?.isCompressedTexture ? pixels?.data : rgba8Pixels( texture );
1264
1264
  if ( data && pixels.width > 0 && pixels.height > 0 && data.byteLength >= pixels.width * pixels.height * 4 ) {
1265
1265
 
1266
1266
  return { data: new Uint8Array( data.buffer, data.byteOffset, pixels.width * pixels.height * 4 ), width: pixels.width, height: pixels.height, flipY: texture.flipY !== false };
@@ -1817,7 +1817,8 @@ export class TextureCreator {
1817
1817
 
1818
1818
  const idx = normalized.length;
1819
1819
  normalized.push( null );
1820
- bitmapJobs.push( { index: idx, flipY: tex.flipY, promise: _rawPixelsToBitmap( tex.image.data, tex.image.width, tex.image.height ) } );
1820
+ const pixels = rgba8Pixels( tex );
1821
+ bitmapJobs.push( { index: idx, flipY: tex.flipY, promise: pixels ? _rawPixelsToBitmap( pixels, tex.image.width, tex.image.height ) : Promise.reject( new Error( 'pixels in a layout the texture arrays cannot take' ) ) } );
1821
1822
  continue;
1822
1823
 
1823
1824
  }
@@ -1900,10 +1901,49 @@ export class TextureCreator {
1900
1901
  // ── Helpers ──────────────────────────────────────────────────────────
1901
1902
 
1902
1903
  /** Convert raw RGBA pixel data to an ImageBitmap (zero-copy Uint8ClampedArray view). */
1903
- function _rawPixelsToBitmap( data, width, height ) {
1904
+ // Async, so a bad layer rejects into its placeholder instead of throwing through the whole bucket.
1905
+ async function _rawPixelsToBitmap( data, width, height ) {
1904
1906
 
1905
1907
  const clamped = new Uint8ClampedArray( data.buffer, data.byteOffset, data.byteLength );
1906
1908
  return createImageBitmap( new ImageData( clamped, width, height ) );
1907
1909
 
1908
1910
  }
1909
1911
 
1912
+ /**
1913
+ * A data texture's pixels as RGBA8 (the arrays are 8-bit), or null. Float and half-float maps — an archive's EXR, HDR or
1914
+ * PFM — clamp to [0, 1]; one channel fills grey. Read as bytes, a float buffer was noise in Node and failed the bucket in
1915
+ * the browser.
1916
+ */
1917
+ export function rgba8Pixels( texture ) {
1918
+
1919
+ const image = texture?.image;
1920
+ const data = image?.data;
1921
+ if ( ! data || ! ( image.width > 0 ) || ! ( image.height > 0 ) ) return null;
1922
+ const count = image.width * image.height;
1923
+ if ( data instanceof Uint8Array || data instanceof Uint8ClampedArray ) {
1924
+
1925
+ return data.byteLength >= count * 4 ? new Uint8Array( data.buffer, data.byteOffset, count * 4 ) : null;
1926
+
1927
+ }
1928
+
1929
+ const channels = { [ RGBAFormat ]: 4, [ RGFormat ]: 2, [ RedFormat ]: 1 }[ texture.format ] ?? Math.round( data.length / count );
1930
+ if ( ! ( channels >= 1 && channels <= 4 ) || data.length < count * channels ) return null;
1931
+ const half = texture.type === HalfFloatType;
1932
+ const scale = texture.type === UnsignedShortType ? 1 / 65535 : 1;
1933
+ const out = new Uint8Array( count * 4 );
1934
+ for ( let i = 0; i < count; i ++ ) {
1935
+
1936
+ for ( let c = 0; c < 4; c ++ ) {
1937
+
1938
+ const raw = c < channels ? data[ i * channels + c ] : null;
1939
+ const v = raw === null ? ( c === 3 ? 1 : out[ i * 4 ] / 255 ) : ( half ? DataUtils.fromHalfFloat( raw ) : raw * scale );
1940
+ out[ i * 4 + c ] = Math.max( 0, Math.min( 255, Math.round( v * 255 ) ) );
1941
+
1942
+ }
1943
+
1944
+ }
1945
+
1946
+ return out;
1947
+
1948
+ }
1949
+
@@ -98,6 +98,26 @@ export function effectiveExposure( exposure, toneMapping ) {
98
98
 
99
99
  }
100
100
 
101
+ /**
102
+ * The gain that shows a pixel of a solid backdrop at `heldAt` while the picture is shown at `exposure`: auto
103
+ * exposure does not move the backdrop. 1 under a view that applies no exposure.
104
+ */
105
+ export function backdropGain( heldAt, exposure, toneMapping ) {
106
+
107
+ return effectiveExposure( heldAt, toneMapping ) / effectiveExposure( exposure, toneMapping );
108
+
109
+ }
110
+
111
+ /**
112
+ * A pixel's gain once the backdrop is held: `backdrop` where the camera saw only the backdrop (coverage 0), the
113
+ * scene's own gain where it saw the scene, blended by coverage between. The GPU pass computes the same.
114
+ */
115
+ export function heldGain( gain, backdrop, coverage ) {
116
+
117
+ return gain + ( backdrop - gain ) * ( 1 - Math.min( Math.max( coverage, 0 ), 1 ) );
118
+
119
+ }
120
+
101
121
  /** Rec.709 luminance coefficients (same as Display / Common.js). */
102
122
  const LUM_R = 0.2126, LUM_G = 0.7152, LUM_B = 0.0722;
103
123
 
@@ -128,8 +148,9 @@ export function applySaturation( out, saturation ) {
128
148
  * @param {number} options.toneMapping - a registered view transform id
129
149
  * @param {number} [options.saturation=1]
130
150
  * @param {boolean} [options.preserveAlpha=false]
131
- * @param {{fn: function(number, number, number, number): number, width: number, height: number}} [options.pixelGain] -
132
- * the twin of the GPU pass's `gain`: `fn( u, v, luminance, log2 exposure )` per pixel, before the curve
151
+ * @param {{fn?: function(number, number, number, number): number, width: number, height: number, backdrop?: number}} [options.pixelGain] -
152
+ * the twin of the GPU pass's `gain`: `fn( u, v, luminance, log2 exposure )` per pixel, before the curve, and
153
+ * `backdrop` the gain a solid backdrop is held at (`heldGain`)
133
154
  * @returns {Uint8ClampedArray} RGBA bytes
134
155
  */
135
156
  export function toneMapToRGBA8( linear, { exposure, toneMapping, saturation = 1, preserveAlpha = false, pixelGain = null } ) {
@@ -146,9 +167,17 @@ export function toneMapToRGBA8( linear, { exposure, toneMapping, saturation = 1,
146
167
  let g = gain;
147
168
  if ( pixelGain ) {
148
169
 
149
- const p = i >> 2, x = p % pixelGain.width, y = ( p - x ) / pixelGain.width;
150
- const lum = linear[ i ] * 0.2126 + linear[ i + 1 ] * 0.7152 + linear[ i + 2 ] * 0.0722;
151
- g *= pixelGain.fn( ( x + 0.5 ) / pixelGain.width, ( y + 0.5 ) / pixelGain.height, lum, logExposure );
170
+ let pg = 1;
171
+ if ( pixelGain.fn ) {
172
+
173
+ const p = i >> 2, x = p % pixelGain.width, y = ( p - x ) / pixelGain.width;
174
+ const lum = linear[ i ] * 0.2126 + linear[ i + 1 ] * 0.7152 + linear[ i + 2 ] * 0.0722;
175
+ pg = pixelGain.fn( ( x + 0.5 ) / pixelGain.width, ( y + 0.5 ) / pixelGain.height, lum, logExposure );
176
+
177
+ }
178
+
179
+ if ( pixelGain.backdrop > 0 ) pg = heldGain( pg, pixelGain.backdrop, linear[ i + 3 ] );
180
+ g *= pg;
152
181
 
153
182
  }
154
183
 
@@ -97,6 +97,7 @@ struct Params {
97
97
  exposure: f32,
98
98
  saturation: f32,
99
99
  alpha: u32,
100
+ backdrop: f32,
100
101
  };
101
102
 
102
103
  @group(0) @binding(2) var<uniform> params: Params;
@@ -115,12 +116,15 @@ fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
115
116
 
116
117
  let linear = rayzee_source( gid.x, srcY );
117
118
  let uv = vec2<f32>( ( f32( gid.x ) + 0.5 ) / f32( params.width ), ( f32( gid.y ) + 0.5 ) / f32( params.height ) );
118
- let rgb = linear.rgb${gainWGSL ? ' * rayzee_gain( uv, linear.rgb, params.exposure )' : ''};
119
+ var g = ${gainWGSL ? 'rayzee_gain( uv, linear.rgb, params.exposure )' : '1.0'};
120
+ // A solid backdrop is held at its own gain, blended by the coverage in alpha (ToneMapCPU's heldGain).
121
+ if ( params.backdrop > 0.0 ) { g = g + ( params.backdrop - g ) * ( 1.0 - clamp( linear.a, 0.0, 1.0 ) ); }
122
+ let rgb = linear.rgb * g;
119
123
  rayzee_store( gid.y * params.width + gid.x, linear, rayzee_tone_map( rgb, params.mode, params.exposure, params.saturation ) );
120
124
  }
121
125
  `;
122
126
 
123
- /** Uniform blocks round up to 16 bytes; the struct above is 28. */
127
+ /** Uniform blocks round up to 16 bytes; the struct above is 32. */
124
128
  const PARAMS_BYTES = 32;
125
129
 
126
130
  /**
@@ -171,7 +175,7 @@ export class PackedToneMapper {
171
175
 
172
176
  }
173
177
 
174
- const key = gain?.key ?? '';
178
+ const key = gain?.wgsl ? gain.key : '';
175
179
  let pipeline = this._pipelines.get( key );
176
180
  if ( pipeline ) return pipeline;
177
181
 
@@ -275,9 +279,10 @@ export class PackedToneMapper {
275
279
  * @param {number} [tone.saturation=1]
276
280
  * @param {boolean} [tone.flipY=false]
277
281
  * @param {boolean} [tone.preserveAlpha=false] - the source's alpha, rounded as the CPU rounds it; else 255
278
- * @param {{key: string, wgsl: string, entries: GPUBindGroupEntry[], device?: GPUDevice, copyTo?: Function}} [tone.gain] - a
279
- * per-pixel gain before the curve: WGSL defining `rayzee_gain( uv, linear, exposure ) -> f32` over bind group 1.
280
- * One on another device is copied to this one for the run (`copyTo`).
282
+ * @param {{key?: string, wgsl?: string, entries?: GPUBindGroupEntry[], device?: GPUDevice, copyTo?: Function, backdrop?: number}} [tone.gain] - a
283
+ * per-pixel gain before the curve: WGSL defining `rayzee_gain( uv, linear, exposure ) -> f32` over bind group 1,
284
+ * and/or `backdrop`, the gain a solid backdrop is held at (coverage in alpha). One on another device is copied
285
+ * to this one for the run (`copyTo`).
281
286
  * @returns {Promise<Uint8ClampedArray>} RGBA bytes, `width * height * 4`
282
287
  */
283
288
  async toRGBA8( src, tone ) {
@@ -302,12 +307,12 @@ export class PackedToneMapper {
302
307
  async _run( src, tone = {} ) {
303
308
 
304
309
  const gain = tone.gain;
305
- if ( ! gain?.device || gain.device === this.device ) return this._dispatch( src, tone );
310
+ if ( ! gain?.wgsl || ! gain.device || gain.device === this.device ) return this._dispatch( src, tone );
306
311
 
307
312
  const copy = await gain.copyTo( this.device );
308
313
  try {
309
314
 
310
- return await this._dispatch( src, { ...tone, gain: copy } );
315
+ return await this._dispatch( src, { ...tone, gain: { ...copy, backdrop: gain.backdrop } } );
311
316
 
312
317
  } finally {
313
318
 
@@ -337,6 +342,7 @@ export class PackedToneMapper {
337
342
  this._paramF32[ 4 ] = exposure;
338
343
  this._paramF32[ 5 ] = saturation;
339
344
  this._paramU32[ 6 ] = preserveAlpha ? 1 : 0;
345
+ this._paramF32[ 7 ] = gain?.backdrop > 0 ? gain.backdrop : 0;
340
346
  this.device.queue.writeBuffer( this._params, 0, this._paramData );
341
347
 
342
348
  const tableEntries = [ ...this._tables.entries() ]
@@ -356,7 +362,7 @@ export class PackedToneMapper {
356
362
  const pass = encoder.beginComputePass();
357
363
  pass.setPipeline( pipeline );
358
364
  pass.setBindGroup( 0, group );
359
- if ( gain ) pass.setBindGroup( 1, this.device.createBindGroup( { layout: pipeline.getBindGroupLayout( 1 ), entries: gain.entries } ) );
365
+ if ( gain?.wgsl ) pass.setBindGroup( 1, this.device.createBindGroup( { layout: pipeline.getBindGroupLayout( 1 ), entries: gain.entries } ) );
360
366
  pass.dispatchWorkgroups( Math.ceil( width / 8 ), Math.ceil( height / 8 ) );
361
367
  pass.end();
362
368
  encoder.copyBufferToBuffer( this._storage, 0, this._map, 0, bytes );