rayzee 9.2.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "9.2.0",
3
+ "version": "9.2.1",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -32,6 +32,7 @@ import { nameFromUrl } from './Storage/DownloadCache.js';
32
32
  import { fileIdentity, identityKey } from './Storage/identity.js';
33
33
  import { SETTING_SOURCE } from './RenderSettings.js';
34
34
  import { toneMapToRGBA8 } from './Processor/ToneMapCPU.js';
35
+ import { PackedToneMapper } from './Processor/ToneMapGPU.js';
35
36
  import { ColorManagement, setActiveColorManagement } from './Color/ColorManagement.js';
36
37
  import { getViewTransform } from './Color/ViewTransforms.js';
37
38
  import { AssetLoader } from './Processor/AssetLoader.js';
@@ -786,6 +787,8 @@ export class PathTracerApp extends EventDispatcher {
786
787
  this.color = null;
787
788
  this._textureReadback?.dispose();
788
789
  this._textureReadback = null;
790
+ this._readbackToneMapper?.dispose();
791
+ this._readbackToneMapper = null;
789
792
 
790
793
  this.interactionManager?.deselect?.();
791
794
  this.transformManager?.detach?.();
@@ -3016,6 +3019,24 @@ export class PathTracerApp extends EventDispatcher {
3016
3019
  // The pool over-allocates to the reserve, so the texture is larger than the frame.
3017
3020
  const { width, height } = stage;
3018
3021
 
3022
+ if ( colorSpace === 'srgb' ) {
3023
+
3024
+ const shown = source === 'display' ? this._displaySource( target ) : null;
3025
+ const tone = {
3026
+ exposure: this.renderer.toneMappingExposure,
3027
+ toneMapping: this.renderer.toneMapping,
3028
+ saturation: this.settings.get( 'saturation' ) ?? 1,
3029
+ preserveAlpha,
3030
+ };
3031
+
3032
+ const data = await this._toneMapOnGPU( shown?.texture ?? null, target, width, height, tone )
3033
+ ?? toneMapToRGBA8( shown
3034
+ ? await this._readTexture( shown.texture, width, height )
3035
+ : await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), tone );
3036
+ return { data, width, height, colorSpace, source: shown?.source ?? 'accumulation' };
3037
+
3038
+ }
3039
+
3019
3040
  const { data: linear, source: read } = source === 'display'
3020
3041
  ? await this._readDisplaySource( target, width, height )
3021
3042
  : { data: await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), source: 'accumulation' };
@@ -3031,25 +3052,42 @@ export class PathTracerApp extends EventDispatcher {
3031
3052
  // A named space is a delivery buffer, not a picture: scene-referred float in whatever the
3032
3053
  // config calls that space. Saving an EXR for a compositor and grading through an sRGB view
3033
3054
  // on screen are different questions, and this is the one that answers the first.
3034
- if ( named ) {
3055
+ const { rgba, colorSpace: got } = this.color.exportPixels( linear, colorSpace );
3056
+ return { data: rgba, width, height, colorSpace: got, source: read };
3035
3057
 
3036
- const { rgba, colorSpace: got } = this.color.exportPixels( linear, colorSpace );
3037
- return { data: rgba, width, height, colorSpace: got, source: read };
3058
+ }
3038
3059
 
3039
- }
3060
+ /**
3061
+ * Display bytes tone-mapped on the card, or null to do it on the CPU. On the CPU it took 1.4 s at
3062
+ * 4096×2160 on an M-series Mac and 10 s on a cloud L4's host; the GPU pass matches it within one
3063
+ * level (`tests/gpu/toneMapParity.test.js`).
3064
+ */
3065
+ async _toneMapOnGPU( shown, accumulation, width, height, tone ) {
3040
3066
 
3041
- return {
3042
- data: toneMapToRGBA8( linear, {
3043
- exposure: this.renderer.toneMappingExposure,
3044
- toneMapping: this.renderer.toneMapping,
3045
- saturation: this.settings.get( 'saturation' ) ?? 1,
3046
- preserveAlpha,
3047
- } ),
3048
- width,
3049
- height,
3050
- colorSpace,
3051
- source: read,
3052
- };
3067
+ const backend = this.renderer?.backend;
3068
+ if ( ! backend?.device ) return null;
3069
+
3070
+ try {
3071
+
3072
+ const texture = shown ? this._readbackPass().draw( shown, width, height ).texture : accumulation.textures[ 0 ];
3073
+ const gpuTexture = backend.get( texture ).texture;
3074
+ if ( ! gpuTexture ) return null;
3075
+
3076
+ this._readbackToneMapper ??= new PackedToneMapper( backend.device, 'rayzee:readback-tonemap', { input: 'texture' } );
3077
+ this._readbackToneMapper.ensureSize( width, height );
3078
+ return await this._readbackToneMapper.toRGBA8( gpuTexture, tone );
3079
+
3080
+ } catch ( error ) {
3081
+
3082
+ log.warn( `renderToBuffer: tone mapping on the GPU failed, doing it on the CPU: ${error?.message ?? error}` );
3083
+ return null;
3084
+
3085
+ } finally {
3086
+
3087
+ this._readbackToneMapper?.release();
3088
+ if ( shown ) this._textureReadback?.release();
3089
+
3090
+ }
3053
3091
 
3054
3092
  }
3055
3093
 
@@ -3655,32 +3693,56 @@ export class PathTracerApp extends EventDispatcher {
3655
3693
  }
3656
3694
 
3657
3695
  /**
3658
- * What the Compositor is drawing from, as linear float. Falls back to the accumulation when no
3659
- * denoiser has published anything, which is also what the viewport shows then.
3696
+ * What a `'display'` read shows: the texture a denoiser published, or null for the raw
3697
+ * accumulation — recorded as a fallback when a denoiser is in use.
3660
3698
  * @private
3661
3699
  */
3662
- async _readDisplaySource( accumulation, width, height ) {
3700
+ _displaySource( accumulation ) {
3663
3701
 
3664
3702
  const context = this.pipeline?.context;
3665
3703
  const found = context && this.stages.compositor?.resolveLightSource( context );
3666
- if ( ! found || found.key === 'pathtracer:color' || found.texture === accumulation.texture ) {
3704
+ if ( found && found.key !== 'pathtracer:color' && found.texture !== accumulation.texture ) {
3667
3705
 
3668
- if ( this._denoiserInUse() ) {
3706
+ return { texture: found.texture, source: found.key.split( ':' )[ 0 ] };
3669
3707
 
3670
- this._issues.record(
3671
- ISSUE_CODES.OUTPUT_SOURCE_FALLBACK,
3672
- 'renderToBuffer: \'display\' was asked for, but no denoiser has published a picture — returned the raw accumulation',
3673
- { requested: 'display', read: 'accumulation' }
3674
- );
3708
+ }
3675
3709
 
3676
- }
3710
+ if ( this._denoiserInUse() ) {
3677
3711
 
3678
- return { data: await this.renderer.readRenderTargetPixelsAsync( accumulation, 0, 0, width, height, 0 ), source: 'accumulation' };
3712
+ this._issues.record(
3713
+ ISSUE_CODES.OUTPUT_SOURCE_FALLBACK,
3714
+ 'renderToBuffer: \'display\' was asked for, but no denoiser has published a picture — returned the raw accumulation',
3715
+ { requested: 'display', read: 'accumulation' }
3716
+ );
3679
3717
 
3680
3718
  }
3681
3719
 
3682
- this._textureReadback ??= new TextureReadback( this.renderer );
3683
- return { data: await this._textureReadback.read( found.texture, width, height ), source: found.key.split( ':' )[ 0 ] };
3720
+ return null;
3721
+
3722
+ }
3723
+
3724
+ /**
3725
+ * What the Compositor is drawing from, as linear float. Falls back to the accumulation when no
3726
+ * denoiser has published anything, which is also what the viewport shows then.
3727
+ * @private
3728
+ */
3729
+ async _readDisplaySource( accumulation, width, height ) {
3730
+
3731
+ const shown = this._displaySource( accumulation );
3732
+ if ( ! shown ) return { data: await this.renderer.readRenderTargetPixelsAsync( accumulation, 0, 0, width, height, 0 ), source: 'accumulation' };
3733
+ return { data: await this._readTexture( shown.texture, width, height ), source: shown.source };
3734
+
3735
+ }
3736
+
3737
+ _readbackPass() {
3738
+
3739
+ return ( this._textureReadback ??= new TextureReadback( this.renderer ) );
3740
+
3741
+ }
3742
+
3743
+ _readTexture( texture, width, height ) {
3744
+
3745
+ return this._readbackPass().read( texture, width, height );
3684
3746
 
3685
3747
  }
3686
3748
 
@@ -46,6 +46,25 @@ export class TextureReadback {
46
46
  */
47
47
  async read( source, width, height ) {
48
48
 
49
+ try {
50
+
51
+ return await this.renderer.readRenderTargetPixelsAsync( this.draw( source, width, height ), 0, 0, width, height );
52
+
53
+ } finally {
54
+
55
+ this.release();
56
+
57
+ }
58
+
59
+ }
60
+
61
+ /**
62
+ * Draws `source` into this pass's float target, for a caller that reads it on the GPU. Pair with
63
+ * `release()`.
64
+ * @returns {import('three').RenderTarget}
65
+ */
66
+ draw( source, width, height ) {
67
+
49
68
  this._ensureTarget( width, height );
50
69
 
51
70
  if ( this._source !== source ) {
@@ -63,19 +82,16 @@ export class TextureReadback {
63
82
  this.renderer.setRenderTarget( this.target );
64
83
  this.quad.render( this.renderer );
65
84
  this.renderer.setRenderTarget( previous );
85
+ return this.target;
66
86
 
67
- try {
68
-
69
- return await this.renderer.readRenderTargetPixelsAsync( this.target, 0, 0, width, height );
70
-
71
- } finally {
87
+ }
72
88
 
73
- // A float target the size of the frame — 132 MB at 4K — for something done once per
74
- // saved file. Not worth keeping between saves.
75
- this.target.dispose();
76
- this.target = null;
89
+ // A float target the size of the frame — 132 MB at 4K — for something done once per saved file.
90
+ // Not worth keeping between saves.
91
+ release() {
77
92
 
78
- }
93
+ this.target?.dispose();
94
+ this.target = null;
79
95
 
80
96
  }
81
97
 
@@ -39,7 +39,24 @@ function currentShader() {
39
39
  */
40
40
  export const TONE_MAP_WGSL = currentShader().wgsl;
41
41
 
42
- const packedWGSL = transformWGSL => /* wgsl */ `
42
+ // Where the linear colour comes from: a packed rgba16float buffer, or a float texture read by pixel.
43
+ const SOURCES = {
44
+ packed: /* wgsl */ `
45
+ @group(0) @binding(0) var<storage, read> src: array<u32>;
46
+
47
+ fn rayzee_source( x: u32, y: u32 ) -> vec4<f32> {
48
+ let a = ( y * params.width + x ) * 2u;
49
+ return vec4<f32>( unpack2x16float( src[ a ] ), unpack2x16float( src[ a + 1u ] ) );
50
+ }`,
51
+ texture: /* wgsl */ `
52
+ @group(0) @binding(0) var src: texture_2d<f32>;
53
+
54
+ fn rayzee_source( x: u32, y: u32 ) -> vec4<f32> {
55
+ return textureLoad( src, vec2<u32>( x, y ), 0 );
56
+ }`,
57
+ };
58
+
59
+ const toneMapPassWGSL = ( transformWGSL, input ) => /* wgsl */ `
43
60
  struct Params {
44
61
  width: u32,
45
62
  height: u32,
@@ -47,11 +64,12 @@ struct Params {
47
64
  flipY: u32,
48
65
  exposure: f32,
49
66
  saturation: f32,
67
+ alpha: u32,
50
68
  };
51
69
 
52
- @group(0) @binding(0) var<storage, read> src: array<u32>;
53
70
  @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
54
71
  @group(0) @binding(2) var<uniform> params: Params;
72
+ ${SOURCES[ input ]}
55
73
 
56
74
  ${transformWGSL}
57
75
 
@@ -62,30 +80,31 @@ fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
62
80
  var srcY = gid.y;
63
81
  if ( params.flipY == 1u ) { srcY = params.height - 1u - gid.y; }
64
82
 
65
- let a = ( srcY * params.width + gid.x ) * 2u;
66
- let rg = unpack2x16float( src[ a ] );
67
- let ba = unpack2x16float( src[ a + 1u ] );
68
-
69
- let mapped = rayzee_tone_map( vec3<f32>( rg.x, rg.y, ba.x ), params.mode, params.exposure, params.saturation );
83
+ let linear = rayzee_source( gid.x, srcY );
84
+ let mapped = rayzee_tone_map( linear.rgb, params.mode, params.exposure, params.saturation );
70
85
  let b8 = rayzee_to_u8( rayzee_encode( mapped, params.mode ) );
71
- dst[ gid.y * params.width + gid.x ] = b8.x | ( b8.y << 8u ) | ( b8.z << 16u ) | ( 255u << 24u );
86
+ var a8 = 255u;
87
+ if ( params.alpha == 1u ) { a8 = rayzee_to_u8( vec3<f32>( linear.a ) ).x; }
88
+ dst[ gid.y * params.width + gid.x ] = b8.x | ( b8.y << 8u ) | ( b8.z << 16u ) | ( a8 << 24u );
72
89
  }
73
90
  `;
74
91
 
75
- /** Uniform blocks round up to 16 bytes; the struct above is 24. */
92
+ /** Uniform blocks round up to 16 bytes; the struct above is 28. */
76
93
  const PARAMS_BYTES = 32;
77
94
 
78
95
  /**
79
- * Tone-maps a tightly packed rgba16float buffer (two `u32` per pixel) into RGBA bytes.
96
+ * Tone-maps a tightly packed rgba16float buffer (two `u32` per pixel), or with `input: 'texture'` a
97
+ * float texture read by pixel, into RGBA bytes.
80
98
  *
81
99
  * Bound to one device and one image size; `ensureSize()` reallocates when the size changes.
82
100
  */
83
101
  export class PackedToneMapper {
84
102
 
85
- constructor( device, label = 'rayzee:tonemap' ) {
103
+ constructor( device, label = 'rayzee:tonemap', { input = 'packed' } = {} ) {
86
104
 
87
105
  this.device = device;
88
106
  this.label = label;
107
+ this.input = input;
89
108
  this.width = 0;
90
109
  this.height = 0;
91
110
  this._pipeline = null;
@@ -120,7 +139,7 @@ export class PackedToneMapper {
120
139
  label: this.label,
121
140
  layout: 'auto',
122
141
  compute: {
123
- module: this.device.createShaderModule( { label: this.label, code: packedWGSL( wgsl ) } ),
142
+ module: this.device.createShaderModule( { label: this.label, code: toneMapPassWGSL( wgsl, this.input ) } ),
124
143
  entryPoint: 'main',
125
144
  },
126
145
  } );
@@ -206,15 +225,17 @@ export class PackedToneMapper {
206
225
  }
207
226
 
208
227
  /**
209
- * @param {GPUBuffer} src packed rgba16float, `width * height * 8` bytes, STORAGE-capable
228
+ * @param {GPUBuffer|GPUTexture} src packed rgba16float, `width * height * 8` bytes, STORAGE-capable;
229
+ * or with `input: 'texture'` a float texture at least `width × height`
210
230
  * @param {object} tone
211
231
  * @param {number} tone.exposure raw `renderer.toneMappingExposure`
212
232
  * @param {number} tone.toneMapping Three.js ToneMapping constant
213
233
  * @param {number} [tone.saturation=1]
214
234
  * @param {boolean} [tone.flipY=false]
235
+ * @param {boolean} [tone.preserveAlpha=false] - the source's alpha, rounded as the CPU rounds it; else 255
215
236
  * @returns {Promise<Uint8ClampedArray>} RGBA bytes, `width * height * 4`
216
237
  */
217
- async toRGBA8( src, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false } = {} ) {
238
+ async toRGBA8( src, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false, preserveAlpha = false } = {} ) {
218
239
 
219
240
  if ( this.disposed ) throw new Error( 'PackedToneMapper: disposed' );
220
241
  if ( ! this._storage ) throw new Error( 'PackedToneMapper: call ensureSize() first' );
@@ -232,6 +253,7 @@ export class PackedToneMapper {
232
253
  this._paramU32[ 3 ] = flipY ? 1 : 0;
233
254
  this._paramF32[ 4 ] = exposure;
234
255
  this._paramF32[ 5 ] = saturation;
256
+ this._paramU32[ 6 ] = preserveAlpha ? 1 : 0;
235
257
  this.device.queue.writeBuffer( this._params, 0, this._paramData );
236
258
 
237
259
  const tableEntries = [ ...this._tables.entries() ]
@@ -241,7 +263,7 @@ export class PackedToneMapper {
241
263
  layout: this._pipeline.getBindGroupLayout( 0 ),
242
264
  entries: [
243
265
  ...tableEntries,
244
- { binding: 0, resource: { buffer: src, size: width * height * 8 } },
266
+ { binding: 0, resource: this.input === 'texture' ? src.createView() : { buffer: src, size: width * height * 8 } },
245
267
  { binding: 1, resource: { buffer: this._storage } },
246
268
  { binding: 2, resource: { buffer: this._params } },
247
269
  ],
@@ -263,6 +285,14 @@ export class PackedToneMapper {
263
285
 
264
286
  }
265
287
 
288
+ /** Frees the size-dependent buffers and keeps the pipeline, for a mapper used now and then. */
289
+ release() {
290
+
291
+ this._releaseBuffers();
292
+ this.width = this.height = 0;
293
+
294
+ }
295
+
266
296
  _releaseBuffers() {
267
297
 
268
298
  this._storage?.destroy();
@@ -191,15 +191,37 @@ export class DownloadCache {
191
191
  if ( this._revalidating.has( key ) ) return;
192
192
  this._revalidating.add( key );
193
193
 
194
+ let changed = false;
195
+
194
196
  try {
195
197
 
196
- const head = await fetch( url, { ...fetchOptions, method: 'HEAD' } );
197
- if ( ! head.ok ) return;
198
+ // One byte, not HEAD: a CORS rule that allows GET — the engine's own asset host — refuses
199
+ // HEAD, and a single-range Range header needs no preflight.
200
+ const headers = new Headers( fetchOptions?.headers );
201
+ headers.set( 'Range', 'bytes=0-0' );
202
+ const probe = await fetch( url, { ...fetchOptions, headers } );
203
+ probe.body?.cancel().catch( () => {} );
204
+
205
+ if ( probe.ok ) {
206
+
207
+ const lastModified = header( probe, 'last-modified' );
208
+ // A 206 carries the size in Content-Range, unreadable cross-origin unless exposed; a server
209
+ // that ignores Range answers 200 with the whole length.
210
+ const length = probe.status === 206
211
+ ? Number( header( probe, 'content-range' )?.match( /\/(\d+)$/ )?.[ 1 ] ) || null
212
+ : Number( header( probe, 'content-length' ) ) || null;
213
+ changed = !! ( ( lastModified && extra.lastModified && lastModified !== extra.lastModified )
214
+ || ( length && extra.contentLength && length !== extra.contentLength ) );
198
215
 
199
- const lastModified = header( head, 'last-modified' );
200
- const length = Number( header( head, 'content-length' ) ) || null;
201
- const changed = ( lastModified && extra.lastModified && lastModified !== extra.lastModified )
202
- || ( length && extra.contentLength && length !== extra.contentLength );
216
+ }
217
+
218
+ } catch {
219
+
220
+ // Offline, or the server refuses the probe: keep serving the stored copy.
221
+
222
+ }
223
+
224
+ try {
203
225
 
204
226
  if ( changed ) {
205
227
 
@@ -208,13 +230,14 @@ export class DownloadCache {
208
230
 
209
231
  } else {
210
232
 
233
+ // Stamped when the check failed too, or a refusing server is asked again on every load.
211
234
  await this._area.patchMeta( key, { extra: { ...extra, checkedAt: Date.now() } } );
212
235
 
213
236
  }
214
237
 
215
238
  } catch {
216
239
 
217
- // Offline, or the server refuses HEAD: keep serving the stored copy.
240
+ // The stored copy stays.
218
241
 
219
242
  } finally {
220
243