rayzee 7.28.2 → 7.30.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.28.2",
3
+ "version": "7.30.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",
@@ -224,6 +224,10 @@ export const ENGINE_DEFAULTS = {
224
224
  asvgfQualityPreset: 'medium',
225
225
  showAsvgfHeatmap: false,
226
226
 
227
+ // NRD (ReBLUR port) real-time denoiser — see NRD_DEFAULTS / NRD_QUALITY_PRESETS.
228
+ nrdQualityPreset: 'medium',
229
+ nrdDebugMode: 0,
230
+
227
231
  // Auto-exposure settings
228
232
  autoExposure: false,
229
233
  autoExposureKeyValue: 0.18,
@@ -233,6 +237,16 @@ export const ENGINE_DEFAULTS = {
233
237
  autoExposureAdaptSpeedDark: 0.5,
234
238
  };
235
239
 
240
+ // Ray distance NormalDepth writes on a miss. Max finite half-float, so miss−miss diffs stay 0
241
+ // rather than Inf−Inf=NaN.
242
+ export const GBUFFER_MISS_DEPTH = 65504.0;
243
+ export const GBUFFER_MISS_THRESHOLD = 6e4;
244
+
245
+ // normHitDist = hitDist / (A + B·viewZ). A constant, not a uniform: the Shade write and the NRD
246
+ // decode must agree, and the normalization is a pure round trip.
247
+ export const NRD_HIT_DIST_A = 3.0;
248
+ export const NRD_HIT_DIST_B = 0.1;
249
+
236
250
  // Albedo demodulation safety floor. ASVGF and BilateralFilter MUST use the
237
251
  // same value — demod (`color / safeAlbedo`) and remod (`lighting * safeAlbedo`)
238
252
  // only round-trip exactly when both sides agree.
@@ -307,6 +321,67 @@ export const ASVGF_QUALITY_PRESETS = {
307
321
  }
308
322
  };
309
323
 
324
+ // NRD ReBLUR port (Stages/NRD.js). Names follow nrd::ReblurSettings so NVIDIA's tuning notes apply.
325
+ export const NRD_DEFAULTS = {
326
+ maxAccumulatedFrameNum: 30,
327
+ maxFastAccumulatedFrameNum: 6,
328
+ maxStabilizedFrameNum: 63,
329
+ historyFixFrameNum: 3,
330
+ historyFixBasePixelStride: 14,
331
+ prepassBlurRadius: 30,
332
+ minBlurRadius: 1,
333
+ maxBlurRadius: 30,
334
+ lobeAngleFraction: 0.15,
335
+ roughnessFraction: 0.15,
336
+ planeDistanceSensitivity: 0.02,
337
+ minHitDistanceWeight: 0.1,
338
+ fastHistoryClampingSigmaScale: 2.0,
339
+ fireflySuppressorMinRelativeScale: 2.0,
340
+ enableAntiFirefly: true,
341
+ antilagLuminanceSigmaScale: 2.0,
342
+ antilagLuminanceSensitivity: 3.0,
343
+ disocclusionThreshold: 0.01,
344
+ convergenceS: 1.0,
345
+ convergenceB: 0.2,
346
+ convergenceP: 0.8,
347
+ // Share of the lobe the normal weight accepts before any history exists. NRD's own constant is
348
+ // 0.75, which its source flags as probably too much; at that width it smears curved surfaces.
349
+ lobeVolumePercent: 0.1,
350
+ // Progressive handover: input sample count at which the denoiser passes the render through untouched.
351
+ // 0 = 2 · maxAccumulatedFrameNum.
352
+ handoverFrames: 0,
353
+ };
354
+
355
+ // Keys a preset may override. Applying one resets every key to its default first, so a preset only
356
+ // states its deltas and `medium` can be empty.
357
+ export const NRD_PRESET_KEYS = [
358
+ 'maxAccumulatedFrameNum', 'maxFastAccumulatedFrameNum', 'maxStabilizedFrameNum',
359
+ 'historyFixFrameNum', 'prepassBlurRadius', 'maxBlurRadius', 'enableAntiFirefly',
360
+ ];
361
+
362
+ export const NRD_QUALITY_PRESETS = {
363
+ // Short history + no pre-pass: most responsive, noisiest.
364
+ low: {
365
+ maxAccumulatedFrameNum: 16,
366
+ maxFastAccumulatedFrameNum: 4,
367
+ maxStabilizedFrameNum: 16,
368
+ historyFixFrameNum: 2,
369
+ prepassBlurRadius: 0,
370
+ maxBlurRadius: 20,
371
+ enableAntiFirefly: false,
372
+ },
373
+ // nrd::ReblurSettings defaults.
374
+ medium: {},
375
+ // Longer history and wider kernels: smoother, more lag on lighting change.
376
+ high: {
377
+ maxAccumulatedFrameNum: 45,
378
+ maxFastAccumulatedFrameNum: 8,
379
+ historyFixFrameNum: 4,
380
+ prepassBlurRadius: 40,
381
+ maxBlurRadius: 40,
382
+ },
383
+ };
384
+
310
385
  export const CAMERA_RANGES = {
311
386
  fov: {
312
387
  min: 10,
@@ -9,6 +9,7 @@ import { PathTracer } from './Stages/PathTracer.js';
9
9
  import { NormalDepth } from './Stages/NormalDepth.js';
10
10
  import { MotionVector } from './Stages/MotionVector.js';
11
11
  import { ASVGF } from './Stages/ASVGF.js';
12
+ import { NRD } from './Stages/NRD.js';
12
13
  import { Variance } from './Stages/Variance.js';
13
14
  import { BilateralFilter } from './Stages/BilateralFilter.js';
14
15
  import { EdgeFilter } from './Stages/EdgeFilter.js';
@@ -524,7 +525,12 @@ export class PathTracerApp extends EventDispatcher {
524
525
  if ( this.pipeline ) {
525
526
 
526
527
  this.pipeline.reset();
527
- if ( ! soft ) this.pipeline.eventBus.emit( 'asvgf:reset' );
528
+ if ( ! soft ) {
529
+
530
+ this.pipeline.eventBus.emit( 'asvgf:reset' );
531
+ this.pipeline.eventBus.emit( 'denoiser:reset' );
532
+
533
+ }
528
534
 
529
535
  }
530
536
 
@@ -2857,6 +2863,7 @@ export class PathTracerApp extends EventDispatcher {
2857
2863
  this.pipeline.addStage( this.stages.pathTracer );
2858
2864
  this.pipeline.addStage( this.stages.normalDepth );
2859
2865
  this.pipeline.addStage( this.stages.motionVector );
2866
+ this.pipeline.addStage( this.stages.nrd );
2860
2867
  this.pipeline.addStage( this.stages.asvgf );
2861
2868
  this.pipeline.addStage( this.stages.variance );
2862
2869
  this.pipeline.addStage( this.stages.bilateralFilter );
@@ -3090,6 +3097,7 @@ export class PathTracerApp extends EventDispatcher {
3090
3097
  pathTracer: this.stages.pathTracer
3091
3098
  } );
3092
3099
  this.stages.asvgf = new ASVGF( this.renderer, { enabled: false } );
3100
+ this.stages.nrd = new NRD( this.renderer, { enabled: false, pathTracer: this.stages.pathTracer } );
3093
3101
  this.stages.variance = new Variance( this.renderer, { enabled: false } );
3094
3102
  this.stages.bilateralFilter = new BilateralFilter( this.renderer, { enabled: false } );
3095
3103
  this.stages.edgeFilter = new EdgeFilter( this.renderer, { enabled: false } );
@@ -3114,6 +3122,7 @@ export class PathTracerApp extends EventDispatcher {
3114
3122
  normalDepth: this.stages.normalDepth,
3115
3123
  motionVector: this.stages.motionVector,
3116
3124
  asvgf: this.stages.asvgf,
3125
+ nrd: this.stages.nrd,
3117
3126
  variance: this.stages.variance,
3118
3127
  bilateralFilter: this.stages.bilateralFilter,
3119
3128
  edgeFilter: this.stages.edgeFilter,
@@ -16,9 +16,10 @@ export const RAY_STRIDE = 7;
16
16
  export const HIT_STRIDE = 2;
17
17
  // Per-pixel G-buffer (first-hit MRT staging): 1 uvec4/pixel — half-packed normal/depth/albedo
18
18
  // (pack2x16, no f32 bitcast); read by FinalWrite:
19
- // .x=packSnorm2x16(normal.xy) .y=packSnorm2x16(normal.z, depth) .z=packUnorm2x16(albedo.rg) .w=packUnorm2x16(albedo.b, 0)
19
+ // .x=packSnorm2x16(normal.xy) .y=packSnorm2x16(normal.z, depth) .z=packUnorm2x16(albedo.rg)
20
+ // .w=packUnorm2x16(albedo.b, normHitDist)
21
+ // normHitDist is written by Shade; writeGBuffer preserves it so the DDFA albedo commits can't zero it.
20
22
  // Separate buffer from RAY (per-pixel, not per-ray×S) — written by Generate/Shade bounce-0.
21
- // (A second lane reserved for an A-SVGF Tier-1 surface ID was removed — write-only, never read.)
22
23
  export const GBUFFER_STRIDE = 1;
23
24
 
24
25
  export const RAY = {
@@ -162,15 +163,35 @@ export const readRayRadiance = ( buf, id ) =>
162
163
  // One uvec4 per pixel (stride 1); AoS base = pixelIndex * GBUFFER_STRIDE.
163
164
  const gbLane = ( pixelIndex ) => uint( pixelIndex ).mul( GBUFFER_STRIDE );
164
165
 
165
- export const writeGBuffer = ( buf, pixelIndex, normal, depth, albedo ) =>
166
- buf.element( gbLane( pixelIndex ) ).assign( uvec4(
166
+ // hitDist = null keeps the lane's current normHitDist.
167
+ export const writeGBuffer = ( buf, pixelIndex, normal, depth, albedo, hitDist = null ) => {
168
+
169
+ const lane = gbLane( pixelIndex );
170
+ const h = hitDist === null ? unpackUnorm2x16( buf.element( lane ).w ).y : float( hitDist );
171
+ return buf.element( lane ).assign( uvec4(
167
172
  packSnorm2x16( vec2( normal.x, normal.y ) ),
168
173
  packSnorm2x16( vec2( normal.z, depth ) ),
169
174
  packUnorm2x16( vec2( albedo.x, albedo.y ) ),
170
- packUnorm2x16( vec2( albedo.z, 0.0 ) ),
175
+ packUnorm2x16( vec2( albedo.z, h ) ),
176
+ ) );
177
+
178
+ };
179
+
180
+ export const writeGBufferHitDist = ( buf, pixelIndex, normHitDist ) => {
181
+
182
+ const lane = gbLane( pixelIndex );
183
+ const cur = buf.element( lane ).toVar();
184
+ return buf.element( lane ).assign( uvec4(
185
+ cur.x, cur.y, cur.z,
186
+ packUnorm2x16( vec2( unpackUnorm2x16( cur.w ).x, normHitDist ) ),
171
187
  ) );
188
+
189
+ };
190
+
172
191
  export const readGBuffer = ( buf, pixelIndex ) => buf.element( gbLane( pixelIndex ) );
173
192
 
193
+ export const gbDecodeHitDist = ( packed ) => unpackUnorm2x16( packed.w ).y;
194
+
174
195
  // Decode for FinalWrite. normalDepth.xyz matches the prior path (normal*0.5+0.5), .w = raw depth.
175
196
  export const gbDecodeNormalDepth = ( packed ) => {
176
197
 
@@ -836,7 +836,7 @@ export class SceneProcessor {
836
836
  // (the prior model-load path omitted this, leaving albedo un-decoded / too bright).
837
837
  const buildBucket = ( list, srgb ) => list.length === 0
838
838
  ? Promise.resolve( null )
839
- : this.textureCreator.createTexturesToDataTexture( list ).then( tex => {
839
+ : this.textureCreator.createTexturesToDataTexture( list, { srgbPool: srgb } ).then( tex => {
840
840
 
841
841
  if ( tex && srgb ) tex.colorSpace = SRGBColorSpace;
842
842
  return tex;
@@ -19,17 +19,21 @@ import { createLogger, fmt } from '../utils/Logger.js';
19
19
 
20
20
  const log = createLogger( 'gpu' );
21
21
 
22
- function createWriteStorageTex() {
22
+ // Compute-output StorageTexture at the reserved size, never resized (see above). LinearFilter is
23
+ // load-bearing: textureLoad codegen requires it on a StorageTexture.
24
+ export function createStorageTexture( type = FloatType, filter = LinearFilter ) {
23
25
 
24
26
  const tex = new StorageTexture( MAX_STORAGE_TEXTURE_SIZE, MAX_STORAGE_TEXTURE_SIZE );
25
- tex.type = FloatType;
27
+ tex.type = type;
26
28
  tex.format = RGBAFormat;
27
- tex.minFilter = LinearFilter;
28
- tex.magFilter = LinearFilter;
29
+ tex.minFilter = filter;
30
+ tex.magFilter = filter;
29
31
  return tex;
30
32
 
31
33
  }
32
34
 
35
+ const createWriteStorageTex = () => createStorageTexture();
36
+
33
37
  export class StorageTexturePool {
34
38
 
35
39
  constructor( width, height ) {
@@ -1,7 +1,38 @@
1
- import { DataArrayTexture, RGBAFormat, LinearFilter, UnsignedByteType, SRGBColorSpace, RepeatWrapping } from "three";
1
+ import { DataArrayTexture, RGBAFormat, LinearFilter, UnsignedByteType, SRGBColorSpace, LinearSRGBColorSpace, RepeatWrapping } from "three";
2
2
  import { TEXTURE_CONSTANTS, MEMORY_CONSTANTS, DEFAULT_TEXTURE_MATRIX, MATERIAL_DATA_LAYOUT, normalizeAttenuationDistance } from '../EngineDefaults.js';
3
3
  import TexturesWorker from './Workers/TexturesWorker.js?worker&inline';
4
4
  import { ISSUE_CODES } from '../EngineIssues.js';
5
+ import { linearToSRGB } from './ToneMapCPU.js';
6
+ import { createLogger } from '../utils/Logger.js';
7
+
8
+ const log = createLogger( 'textures' );
9
+
10
+ // A bucket array carries ONE colorSpace and the shader reads each slot from a fixed pool, so a
11
+ // texture disagreeing with its pool cannot be re-routed — its pixels are re-encoded instead.
12
+ const TRANSFER_LUT = { toSRGB: null, toLinear: null };
13
+
14
+ function transferLUT( direction ) {
15
+
16
+ if ( ! TRANSFER_LUT[ direction ] ) {
17
+
18
+ const lut = new Uint8Array( 256 );
19
+ for ( let i = 0; i < 256; i ++ ) {
20
+
21
+ const c = i / 255;
22
+ const v = direction === 'toSRGB'
23
+ ? linearToSRGB( c )
24
+ : ( c <= 0.04045 ? c / 12.92 : Math.pow( ( c + 0.055 ) / 1.055, 2.4 ) );
25
+ lut[ i ] = Math.max( 0, Math.min( 255, Math.round( v * 255 ) ) );
26
+
27
+ }
28
+
29
+ TRANSFER_LUT[ direction ] = lut;
30
+
31
+ }
32
+
33
+ return TRANSFER_LUT[ direction ];
34
+
35
+ }
5
36
 
6
37
  // Canvas pooling for efficient reuse of canvas elements
7
38
  class CanvasPool {
@@ -541,13 +572,76 @@ export class TextureCreator {
541
572
 
542
573
  }
543
574
 
575
+ /**
576
+ * Re-encodes any layer whose declared colour space disagrees with its pool, so the GPU's
577
+ * decode — or absence of one — lands on the values the author stored. An untagged texture
578
+ * keeps its pool's convention, which is what every loader produces for glTF.
579
+ *
580
+ * @param {import('three').DataArrayTexture} arrayTex
581
+ * @param {Array<import('three').Texture>} textures - pre-normalization list, in layer order
582
+ * @param {boolean} srgbPool - true when the array is tagged SRGBColorSpace
583
+ */
584
+ _harmonizeTransfer( arrayTex, textures, srgbPool ) {
585
+
586
+ const data = arrayTex?.image?.data;
587
+ if ( ! data ) return;
588
+
589
+ // Mirrors _normalizeTexturesForProcessing's skip rule, which is what sets layer order.
590
+ const kept = textures.filter( t => t?.image );
591
+ const want = srgbPool ? SRGBColorSpace : LinearSRGBColorSpace;
592
+ const layerSize = arrayTex.image.width * arrayTex.image.height * 4;
593
+ const converted = [];
594
+
595
+ for ( let layer = 0; layer < kept.length; layer ++ ) {
596
+
597
+ const declared = kept[ layer ].colorSpace;
598
+ // Only an explicit disagreement is acted on; NoColorSpace defers to the pool.
599
+ if ( declared !== SRGBColorSpace && declared !== LinearSRGBColorSpace ) continue;
600
+ if ( declared === want ) continue;
601
+
602
+ const lut = transferLUT( srgbPool ? 'toSRGB' : 'toLinear' );
603
+ const start = layer * layerSize;
604
+ const end = Math.min( start + layerSize, data.length );
605
+ // RGB only — alpha is never transfer-encoded.
606
+ for ( let i = start; i < end; i += 4 ) {
607
+
608
+ data[ i ] = lut[ data[ i ] ];
609
+ data[ i + 1 ] = lut[ data[ i + 1 ] ];
610
+ data[ i + 2 ] = lut[ data[ i + 2 ] ];
611
+
612
+ }
613
+
614
+ converted.push( kept[ layer ].name || kept[ layer ].source?.uuid?.slice( 0, 8 ) || `layer ${layer}` );
615
+
616
+ }
617
+
618
+ if ( converted.length === 0 ) return;
619
+
620
+ arrayTex.needsUpdate = true;
621
+ const names = converted.slice( 0, 4 ).join( ', ' ) + ( converted.length > 4 ? `, +${converted.length - 4} more` : '' );
622
+
623
+ if ( srgbPool ) {
624
+
625
+ log.info( `re-encoded ${converted.length}/${kept.length} linear-declared colour map(s) to sRGB so the GPU decode round-trips: ${names}` );
626
+
627
+ } else {
628
+
629
+ // Storing a decoded value in an 8-bit linear array is the lossy direction, and a
630
+ // transfer-encoded data map is almost always an authoring slip rather than intent.
631
+ log.warn( `decoded ${converted.length}/${kept.length} sRGB-declared data map(s) to linear — precision is lost in the darks, check the asset: ${names}` );
632
+
633
+ }
634
+
635
+ }
636
+
544
637
  // Unified texture processing with strategy selection
545
- async createTexturesToDataTexture( textures ) {
638
+ async createTexturesToDataTexture( textures, { srgbPool = false } = {} ) {
546
639
 
547
640
  if ( ! textures || textures.length === 0 ) return null;
548
641
 
549
- // Check cache first
550
- const cacheKey = this.textureCache.generateHash( textures );
642
+ // Pool is part of the identity: the same list packed for the colour pool and the data pool
643
+ // gets different pixels (see _harmonizeTransfer), so one key must not serve both.
644
+ const cacheKey = `${srgbPool ? 's' : 'l'}:${this.textureCache.generateHash( textures )}`;
551
645
  const cached = this.textureCache.get( cacheKey );
552
646
  if ( cached ) return cached;
553
647
 
@@ -579,6 +673,7 @@ export class TextureCreator {
579
673
  // Cache successful result
580
674
  if ( result ) {
581
675
 
676
+ this._harmonizeTransfer( result, textures, srgbPool );
582
677
  this.textureCache.set( cacheKey, result );
583
678
 
584
679
  }
@@ -592,7 +687,9 @@ export class TextureCreator {
592
687
  'worker texture processing failed — retrying on the main thread',
593
688
  { cause: String( error?.message ?? error ) }
594
689
  );
595
- return await this.processOnMainThreadSync( normalized );
690
+ const fallback = await this.processOnMainThreadSync( normalized );
691
+ this._harmonizeTransfer( fallback, textures, srgbPool );
692
+ return fallback;
596
693
 
597
694
  } finally {
598
695
 
@@ -182,10 +182,10 @@ export class RenderSettings extends EventDispatcher {
182
182
 
183
183
  if ( ! isPanorama ) return;
184
184
 
185
- // ASVGF is driven entirely by MotionVector, which unprojects through
186
- // projectionMatrixInverse — meaningless once every pixel is its own direction.
187
- // Fall back to the spatial-only denoiser rather than leaving no strategy.
188
- if ( denoisingManager?.denoiserStrategy === 'asvgf' ) denoisingManager.setDenoiserStrategy( 'edgeaware' );
185
+ // MotionVector unprojects through projectionMatrixInverse, which is meaningless once
186
+ // every pixel is its own direction. Fall back to the spatial-only denoiser rather
187
+ // than leaving no strategy.
188
+ if ( denoisingManager?.requiresMotionVectors ) denoisingManager.setDenoiserStrategy( 'edgeaware' );
189
189
  // Auto-focus raycasts via Raycaster.setFromCamera, which only knows the frustum.
190
190
  cameraManager?.setAutoFocusMode( 'manual' );
191
191
 
@@ -70,6 +70,7 @@ export class Compositor extends RenderStage {
70
70
  || context.getTexture( 'edgeFiltering:output' )
71
71
  || context.getTexture( 'bilateralFiltering:output' )
72
72
  || context.getTexture( 'asvgf:output' )
73
+ || context.getTexture( 'nrd:output' )
73
74
  || context.getTexture( 'pathtracer:color' );
74
75
 
75
76
  }