rayzee 7.22.7 → 7.22.9

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.22.7",
3
+ "version": "7.22.9",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -604,7 +604,8 @@ export const PRODUCTION_RENDER_CONFIG = {
604
604
  // so easy scenes finish well under it while hard GI scenes use the full budget.
605
605
  maxSamples: 150, bounces: 20, transmissiveBounces: 8, maxSubsurfaceSteps: 64,
606
606
  renderMode: 1, enableAlphaShadows: true,
607
- enableOIDN: true, oidnQuality: 'balance',
607
+ // 'high' is the only tier that reaches OIDN's _large weights (calb_cnrm); ~2x denoise cost.
608
+ enableOIDN: true, oidnQuality: 'high',
608
609
  interactionModeEnabled: false,
609
610
  // 0.94 against the eroded count ≈ the old raw-count 0.98; erosion holds the fraction a few points lower.
610
611
  useAdaptiveSampling: true,
@@ -52,20 +52,65 @@ import { getAssetConfig } from '../AssetConfig.js';
52
52
  const _tmOut = new Float32Array( 3 );
53
53
 
54
54
  const MODEL_CONFIG = {
55
- // clean-aux models — first-hit albedo/normal are deterministic per pixel
55
+ // No cleanAux flag in oidn-web, so the blob is it — and must match what setCleanAuxNormal
56
+ // feeds: `fast` point-samples the normal (noisy aux), balance/high accumulate it. calb_cnrm
57
+ // is also the only model with a `_large` blob.
56
58
  QUALITY_MODELS: {
57
59
  fast: 'rt_hdr_alb_nrm_small',
58
- balance: 'rt_hdr_alb_nrm',
60
+ balance: 'rt_hdr_calb_cnrm',
59
61
  high: 'rt_hdr_calb_cnrm_large'
60
62
  },
61
63
  DEFAULT_OPTIONS: {
62
64
  enableOIDN: true,
63
65
  oidnQuality: 'fast',
64
66
  debugGbufferMaps: true,
65
- tileSize: 256
67
+ // Tiles run at tileSize + 2*overlap (96px, 112 for _large), so small tiles pay that padding
68
+ // every tile: 256 puts 3.06x the pixels through the UNet at 512². Measured 3.64x faster.
69
+ tileSize: 512
66
70
  }
67
71
  };
68
72
 
73
+ // Stands in for OIDN's inputScale, which oidn-web only applies on its CPU path — the GPUBuffer
74
+ // path leaves the uniform at 1.0. Equivalent because oidn-web does PUForward(col*inputScale) in
75
+ // and PUInverse(..)/inputScale out, so pre-multiplying here and dividing it back out of the
76
+ // output exposure round-trips. Alpha is preserved for _cacheInputAlpha.
77
+ const COLOR_SCALE_WGSL = /* wgsl */`
78
+ @group(0) @binding(0) var<storage, read_write> col: array<vec4<f32>>;
79
+ @group(0) @binding(1) var<uniform> scale: f32;
80
+
81
+ @compute @workgroup_size(64)
82
+ fn main( @builtin(global_invocation_id) gid: vec3<u32>,
83
+ @builtin(num_workgroups) nwg: vec3<u32> ) {
84
+ let i = gid.y * nwg.x * 64u + gid.x;
85
+ if ( i >= arrayLength( &col ) ) { return; }
86
+ let c = col[ i ];
87
+ col[ i ] = vec4<f32>( c.xyz * scale, c.w );
88
+ }
89
+ `;
90
+
91
+ const SCALE_WG_SIZE = 64;
92
+ // 1D would exceed maxComputeWorkgroupsPerDimension (65535) past ~2048², so the dispatch is 2D.
93
+ const SCALE_MAX_WG_X = 32768;
94
+
95
+ // OIDN's autoexposure, mirroring oidn-web's avgLogLum. Whole-frame, so it cannot seam per tile.
96
+ const OIDN_AUTOEXPOSURE_KEY = 0.18;
97
+
98
+ function oidnInputScale( f32, pixelCount ) {
99
+
100
+ let logSum = 0;
101
+ for ( let i = 0; i < pixelCount; i ++ ) {
102
+
103
+ const o = i * 4;
104
+ const lum = 0.212671 * f32[ o ] + 0.71516 * f32[ o + 1 ] + 0.072169 * f32[ o + 2 ];
105
+ logSum += Math.log2( Math.max( lum, 0 ) + 0.0001 );
106
+
107
+ }
108
+
109
+ const scale = OIDN_AUTOEXPOSURE_KEY / Math.pow( 2, logSum / pixelCount );
110
+ return Number.isFinite( scale ) && scale > 0 ? scale : 1.0;
111
+
112
+ }
113
+
69
114
  export class OIDNDenoiser extends EventDispatcher {
70
115
 
71
116
  constructor( output, renderer, scene, camera, options = {} ) {
@@ -110,6 +155,11 @@ export class OIDNDenoiser extends EventDispatcher {
110
155
  // order guarantees the overwrites are serialized).
111
156
  this._gpuInputPadBuffer = null;
112
157
  this._gpuInputPaddedRowBytes = 0;
158
+ // 1.0 until the first readback.
159
+ this._colorScalePipeline = null;
160
+ this._colorScaleBindGroup = null;
161
+ this._colorScaleUniform = null;
162
+ this._oidnInputScale = 1.0;
113
163
  // Pooled MAP_READ staging buffer for _cacheInputAlpha. Only allocated
114
164
  // when transparent-background readback is used, destroyed on resolution
115
165
  // change or dispose. Same spirit as r184's ReadbackBuffer — we can't use
@@ -130,6 +180,8 @@ export class OIDNDenoiser extends EventDispatcher {
130
180
  this.quality = this.config.oidnQuality;
131
181
  this.debugGbufferMaps = this.config.debugGbufferMaps;
132
182
  this.tileSize = this.config.tileSize;
183
+ // tileSize actually baked into the live UNet, so a runtime change forces a rebuild.
184
+ this._activeTileSize = 0;
133
185
 
134
186
  // State management
135
187
  this.state = {
@@ -212,8 +264,8 @@ export class OIDNDenoiser extends EventDispatcher {
212
264
  this.state.isLoading = true;
213
265
  const tzaUrl = this._generateTzaUrl();
214
266
 
215
- // Skip setup if URL hasn't changed
216
- if ( this.currentTZAUrl === tzaUrl && this.unet ) {
267
+ // maxTileSize is a constructor arg, so comparing the URL alone left tileSize changes inert.
268
+ if ( this.currentTZAUrl === tzaUrl && this._activeTileSize === this.tileSize && this.unet ) {
217
269
 
218
270
  this.state.isLoading = false;
219
271
  return;
@@ -251,6 +303,7 @@ export class OIDNDenoiser extends EventDispatcher {
251
303
  } );
252
304
 
253
305
  this.currentTZAUrl = tzaUrl;
306
+ this._activeTileSize = this.tileSize;
254
307
  this.dispatchEvent( { type: 'loaded' } );
255
308
  log.debug( 'UNet weights loaded:', tzaUrl );
256
309
 
@@ -477,21 +530,18 @@ export class OIDNDenoiser extends EventDispatcher {
477
530
 
478
531
  copyTex( textures.color, this._gpuInputBuffers.color );
479
532
  copyTex( textures.albedo, this._gpuInputBuffers.albedo );
533
+ // The normal stays [0,1]-encoded. This contradicts OIDN's API ("must be in the [-1,1]
534
+ // range") but is right for this port: OIDN's own getNormal (cpu_input_process.isph) does
535
+ // `value*0.5+0.5` before the network, and oidn-web omits that remap. Decoding to [-1,1]
536
+ // measured 0.878 -> 2.439 denoise ratio at 64 spp. Do not "fix" it.
480
537
  copyTex( textures.normal, this._gpuInputBuffers.normal );
481
538
 
482
539
  device.queue.submit( [ encoder.finish() ] );
483
540
 
484
- // Cache alpha channel from input color buffer when transparent background is enabled.
485
- // OIDN only processes RGB — the alpha channel is lost, so we read it before denoising.
486
- if ( this.getTransparentBackground() ) {
487
-
488
- await this._cacheInputAlpha( device, width, height );
489
-
490
- } else {
491
-
492
- this._cachedAlpha = null;
493
-
494
- }
541
+ // One readback serves the autoexposure scale and the alpha OIDN discards. Must precede the scale.
542
+ await this._readbackColor( device, width, height );
543
+ if ( globalThis.__OIDN_NO_INPUT_SCALE ) this._oidnInputScale = 1.0;
544
+ this._applyColorScale( device, width * height );
495
545
 
496
546
  // Draw the current noisy frame as the base — denoised tiles paint on top progressively
497
547
  this.ctx.drawImage( this.input, 0, 0, width, height );
@@ -571,6 +621,10 @@ export class OIDNDenoiser extends EventDispatcher {
571
621
 
572
622
  this._alphaReadbackBuffer?.destroy();
573
623
  this._alphaReadbackMapped = false;
624
+ // Bind group holds the destroyed buffer; the pipeline is device-scoped and survives.
625
+ this._colorScaleBindGroup = null;
626
+ this._colorScaleUniform?.destroy();
627
+ this._colorScaleUniform = null;
574
628
  this._gpuInputBuffers = { color: null, albedo: null, normal: null };
575
629
  this._gpuInputPadBuffer = null;
576
630
  this._gpuInputPaddedRowBytes = 0;
@@ -580,10 +634,10 @@ export class OIDNDenoiser extends EventDispatcher {
580
634
  }
581
635
 
582
636
  /**
583
- * Reads the alpha channel from the input color GPU buffer and caches it as a Uint8Array.
584
- * Called before OIDN denoising when transparent background is enabled.
637
+ * Reads back the colour buffer for the autoexposure scale, plus alpha when transparent
638
+ * background is on (OIDN discards alpha).
585
639
  */
586
- async _cacheInputAlpha( device, width, height ) {
640
+ async _readbackColor( device, width, height ) {
587
641
 
588
642
  const byteSize = width * height * 16; // rgba32float, tightly packed
589
643
 
@@ -620,13 +674,20 @@ export class OIDNDenoiser extends EventDispatcher {
620
674
  }
621
675
 
622
676
  const f32 = new Float32Array( staging.getMappedRange() );
623
-
624
- // Extract alpha channel as uint8 (pre-multiplied is not needed — alpha is 0 or 1)
625
677
  const pixelCount = width * height;
626
- const alpha = new Uint8Array( pixelCount );
627
- for ( let i = 0; i < pixelCount; i ++ ) {
628
678
 
629
- alpha[ i ] = Math.min( Math.max( f32[ i * 4 + 3 ] * 255, 0 ), 255 ) | 0;
679
+ this._oidnInputScale = oidnInputScale( f32, pixelCount );
680
+
681
+ let alpha = null;
682
+ if ( this.getTransparentBackground() ) {
683
+
684
+ // Alpha as uint8; pre-multiplication is not needed — alpha is 0 or 1
685
+ alpha = new Uint8Array( pixelCount );
686
+ for ( let i = 0; i < pixelCount; i ++ ) {
687
+
688
+ alpha[ i ] = Math.min( Math.max( f32[ i * 4 + 3 ] * 255, 0 ), 255 ) | 0;
689
+
690
+ }
630
691
 
631
692
  }
632
693
 
@@ -638,6 +699,81 @@ export class OIDNDenoiser extends EventDispatcher {
638
699
 
639
700
  }
640
701
 
702
+ /** Display exposure with the input scale divided back out — output arrives pre-multiplied. */
703
+ _outputExposure() {
704
+
705
+ return this.getExposure() / this._oidnInputScale;
706
+
707
+ }
708
+
709
+ /** Pre-multiplies the colour buffer by the autoexposure scale. */
710
+ _applyColorScale( device, pixelCount ) {
711
+
712
+ const scale = this._oidnInputScale;
713
+ if ( ! this._ensureColorScalePipeline( device ) ) return;
714
+
715
+ device.queue.writeBuffer( this._colorScaleUniform, 0, new Float32Array( [ scale ] ) );
716
+
717
+ const wgTotal = Math.ceil( pixelCount / SCALE_WG_SIZE );
718
+ const wgX = Math.min( wgTotal, SCALE_MAX_WG_X );
719
+ const wgY = Math.ceil( wgTotal / wgX );
720
+
721
+ const encoder = device.createCommandEncoder( { label: 'oidn-color-scale' } );
722
+ const pass = encoder.beginComputePass( { label: 'oidn-color-scale' } );
723
+ pass.setPipeline( this._colorScalePipeline );
724
+ pass.setBindGroup( 0, this._colorScaleBindGroup );
725
+ pass.dispatchWorkgroups( wgX, wgY, 1 );
726
+ pass.end();
727
+ device.queue.submit( [ encoder.finish() ] );
728
+
729
+ }
730
+
731
+ /** @returns {GPUComputePipeline|null} */
732
+ _ensureColorScalePipeline( device ) {
733
+
734
+ const buffer = this._gpuInputBuffers.color;
735
+ if ( ! device || ! buffer ) return null;
736
+
737
+ if ( ! this._colorScalePipeline ) {
738
+
739
+ this._colorScalePipeline = device.createComputePipeline( {
740
+ label: 'oidn-color-scale',
741
+ layout: 'auto',
742
+ compute: {
743
+ module: device.createShaderModule( { label: 'oidn-color-scale', code: COLOR_SCALE_WGSL } ),
744
+ entryPoint: 'main'
745
+ }
746
+ } );
747
+
748
+ }
749
+
750
+ if ( ! this._colorScaleUniform ) {
751
+
752
+ this._colorScaleUniform = device.createBuffer( {
753
+ label: 'oidn-color-scale-uniform',
754
+ size: 16, // min uniform binding size
755
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
756
+ } );
757
+
758
+ }
759
+
760
+ if ( ! this._colorScaleBindGroup ) {
761
+
762
+ this._colorScaleBindGroup = device.createBindGroup( {
763
+ label: 'oidn-color-scale',
764
+ layout: this._colorScalePipeline.getBindGroupLayout( 0 ),
765
+ entries: [
766
+ { binding: 0, resource: { buffer } },
767
+ { binding: 1, resource: { buffer: this._colorScaleUniform } }
768
+ ]
769
+ } );
770
+
771
+ }
772
+
773
+ return this._colorScalePipeline;
774
+
775
+ }
776
+
641
777
  /**
642
778
  * Promise wrapper around tileExecute for the GPU path.
643
779
  * Outputs a GPUBuffer — copied to a staging buffer then converted to ImageData for the 2D canvas.
@@ -754,7 +890,7 @@ export class OIDNDenoiser extends EventDispatcher {
754
890
 
755
891
  const f32 = new Float32Array( staging.getMappedRange() );
756
892
  const tileImageData = new ImageData( clampedW, clampedH );
757
- const exposure = this.getExposure();
893
+ const exposure = this._outputExposure();
758
894
  const saturation = this.getSaturation();
759
895
  const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
760
896
  const alpha = this._cachedAlpha;
@@ -856,7 +992,7 @@ export class OIDNDenoiser extends EventDispatcher {
856
992
  const float32 = new Float32Array( stagingBuffer.getMappedRange() );
857
993
 
858
994
  const imageData = new ImageData( width, height );
859
- const exposure = this.getExposure();
995
+ const exposure = this._outputExposure();
860
996
  const saturation = this.getSaturation();
861
997
  const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
862
998
  const alpha = this._cachedAlpha;
@@ -962,6 +1098,7 @@ export class OIDNDenoiser extends EventDispatcher {
962
1098
  // TFJS tears down the cached backend's buffers/textures.
963
1099
  removeOidnTfjsBackend();
964
1100
  this._destroyGPUInputBuffers();
1101
+ this._colorScalePipeline = null;
965
1102
 
966
1103
  // Clean up DOM
967
1104
  if ( this.output?.parentNode ) {
@@ -8,7 +8,7 @@ import { Ray, HitInfo, RayTracingMaterial, UVCache } from '../TSL/Struct.js';
8
8
  import { traverseBVH } from '../TSL/BVHTraversal.js';
9
9
  import { cameraRayDirection } from '../TSL/CameraRay.js';
10
10
  import { getMaterial } from '../TSL/Common.js';
11
- import { computeUVCache, processNormal, processBump, buildBucketTextureNodes, refreshBucketTextureNodes, setMaterialBucketTextures } from '../TSL/TextureSampling.js';
11
+ import { computeUVCache, processNormal, processBump, triangleUVTangent, buildBucketTextureNodes, refreshBucketTextureNodes, setMaterialBucketTextures } from '../TSL/TextureSampling.js';
12
12
 
13
13
  /**
14
14
  * NormalDepth — primary-ray G-buffer for SVGF gates.
@@ -263,7 +263,16 @@ export class NormalDepth extends RenderStage {
263
263
  getMaterial( hit.materialIndex, matStorage )
264
264
  ).toVar();
265
265
  const uvCache = UVCache.wrap( computeUVCache( hit.uv, material ) ).toVar();
266
- const mapped = processNormal( hit.normal, material, uvCache ).toVar();
266
+ // Same UV tangent frame the Shade kernel uses, or the aux normal disagrees
267
+ // with the shading normal it is supposed to guide the denoiser with.
268
+ const uvTangent = vec4( 0.0 ).toVar();
269
+ If( material.normalMapIndex.greaterThanEqual( int( 0 ) ), () => {
270
+
271
+ uvTangent.assign( triangleUVTangent( triStorage, hit.triangleIndex, hit.normal, material.normalTransform ) );
272
+
273
+ } );
274
+
275
+ const mapped = processNormal( hit.normal, material, uvCache, uvTangent ).toVar();
267
276
  shadingNormal.assign( processBump( mapped, material, uvCache ) );
268
277
 
269
278
  } );
@@ -202,7 +202,7 @@ export const TraceDebugMode = Fn( ( [
202
202
 
203
203
  // Sample all textures to get emissive
204
204
  const matSamples = MaterialSamples.wrap( sampleAllMaterialTextures(
205
- material, hitInfo.uv, hitInfo.normal,
205
+ material, hitInfo.uv, hitInfo.normal, vec4( 0.0 ),
206
206
  ) ).toVar();
207
207
 
208
208
  const emissiveColor = matSamples.emissive.toVar();
@@ -246,7 +246,7 @@ export const TraceDebugMode = Fn( ( [
246
246
 
247
247
  // Get material-mapped normal (same as what's used in main shader)
248
248
  const matSamples = MaterialSamples.wrap( sampleAllMaterialTextures(
249
- material, hitInfo.uv, hitInfo.normal,
249
+ material, hitInfo.uv, hitInfo.normal, vec4( 0.0 ),
250
250
  ) ).toVar();
251
251
 
252
252
  const worldNormal = normalize( matSamples.normal );
@@ -292,7 +292,7 @@ export const TraceDebugMode = Fn( ( [
292
292
  const material = RayTracingMaterial.wrap( getMaterial( hitInfo.materialIndex, materialBuffer ) ).toVar();
293
293
 
294
294
  const matSamples = MaterialSamples.wrap( sampleAllMaterialTextures(
295
- material, hitInfo.uv, hitInfo.normal,
295
+ material, hitInfo.uv, hitInfo.normal, vec4( 0.0 ),
296
296
  ) ).toVar();
297
297
 
298
298
  const objectColor = matSamples.albedo.rgb;
@@ -318,7 +318,7 @@ export const TraceDebugMode = Fn( ( [
318
318
  // Get primary surface material
319
319
  const material = RayTracingMaterial.wrap( getMaterial( hitInfo.materialIndex, materialBuffer ) ).toVar();
320
320
  const matSamples = MaterialSamples.wrap( sampleAllMaterialTextures(
321
- material, hitInfo.uv, hitInfo.normal,
321
+ material, hitInfo.uv, hitInfo.normal, vec4( 0.0 ),
322
322
  ) ).toVar();
323
323
 
324
324
  const albedoA = matSamples.albedo.rgb;
@@ -364,7 +364,7 @@ export const TraceDebugMode = Fn( ( [
364
364
  // Bounce hit surface B — evaluate its contribution
365
365
  const materialB = RayTracingMaterial.wrap( getMaterial( bounceHit.materialIndex, materialBuffer ) ).toVar();
366
366
  const matSamplesB = MaterialSamples.wrap( sampleAllMaterialTextures(
367
- materialB, bounceHit.uv, bounceHit.normal,
367
+ materialB, bounceHit.uv, bounceHit.normal, vec4( 0.0 ),
368
368
  ) ).toVar();
369
369
 
370
370
  // Emissive contribution from surface B
@@ -17,7 +17,7 @@ import {
17
17
  import { sampleEnvironment, sampleEquirectProbability, sampleEquirect, groundProjectedEnvDir } from './Environment.js';
18
18
  import { getMaterial, powerHeuristic, balanceHeuristic, classifyMaterial, REC709_LUMINANCE_COEFFICIENTS, PI_INV, EPSILON, diffuseGroundMaterial } from './Common.js';
19
19
  import { cosineWeightedSample } from './MaterialSampling.js';
20
- import { sampleAllMaterialTextures, processAnisotropyMap, applyExtensionMaps, getTransformedUV } from './TextureSampling.js';
20
+ import { sampleAllMaterialTextures, processAnisotropyMap, applyExtensionMaps, getTransformedUV, triangleUVTangent } from './TextureSampling.js';
21
21
  import { evaluateMaterialResponse } from './MaterialEvaluation.js';
22
22
  import { calculateDirectLightingUnified, calculateMaterialPDF } from './LightsSampling.js';
23
23
  import { traceShadowRay, calculateRayOffset } from './LightsDirect.js';
@@ -616,8 +616,17 @@ export function buildShadeKernel( params ) {
616
616
  }
617
617
  );
618
618
 
619
+ // Normal-map tangent frame. Only paid for when there is a normal map: the arbitrary
620
+ // cross(up, N) fallback inside processNormal is what runs otherwise.
621
+ const uvTangent = vec4( 0.0 ).toVar();
622
+ If( material.normalMapIndex.greaterThanEqual( int( 0 ) ), () => {
623
+
624
+ uvTangent.assign( triangleUVTangent( triangleBuffer, int( hitTriIdx ), N, material.normalTransform ) );
625
+
626
+ } );
627
+
619
628
  const matSamples = MaterialSamples.wrap( sampleAllMaterialTextures(
620
- material, samplingUV, N,
629
+ material, samplingUV, N, uvTangent,
621
630
  ) ).toVar();
622
631
 
623
632
  // DDFA: snapshot the UNCLAMPED roughness (before the min-0.05 clamp below) for the specular↔diffuse
@@ -1,4 +1,4 @@
1
- import { Fn, wgslFn, float, vec2, vec3, vec4, int, If, normalize, cross, abs, atan, mix, clamp, texture, textureSize } from 'three/tsl';
1
+ import { Fn, wgslFn, float, vec2, vec3, vec4, int, If, normalize, cross, dot, length, sign, abs, atan, mix, clamp, texture, textureSize } from 'three/tsl';
2
2
  import { DataArrayTexture, LinearFilter } from 'three';
3
3
 
4
4
  import {
@@ -6,6 +6,7 @@ import {
6
6
  MaterialSamples,
7
7
  ExtMapResult,
8
8
  } from './Struct.js';
9
+ import { getDatafromStorageBuffer } from './Common.js';
9
10
  import { TEXTURE_CONSTANTS } from '../EngineDefaults.js';
10
11
 
11
12
  // ================================================================================
@@ -382,7 +383,80 @@ export const processMetalnessRoughness = Fn( ( [ material, uvCache ] ) => {
382
383
 
383
384
  } );
384
385
 
385
- export const processNormal = Fn( ( [ geometryNormal, material, uvCache ] ) => {
386
+ // (t[1][1], t[0][1]) — the row of the inverse 2x2 linear part that maps dP/du onto dP/du',
387
+ // up to the determinant, which drops out under the normalize that follows.
388
+ const uvTransformJacobian = /*@__PURE__*/ wgslFn( `
389
+ fn uvTransformJacobian( t: mat3x3f ) -> vec2f {
390
+ return vec2f( t[1][1], t[0][1] );
391
+ }
392
+ ` );
393
+
394
+ /**
395
+ * Build the UV tangent for one triangle, as vec4( T.xyz, handedness ).
396
+ *
397
+ * Returned in the *transformed* UV space of `transform`, since that is the space the normal
398
+ * map is sampled in — the inverse of the transform's 2x2 linear part maps dP/du back onto
399
+ * dP/du'. A pure repeat/scale leaves the direction alone; a rotated transform does not.
400
+ * w == 0 signals a degenerate parameterisation, which processNormal treats as "no frame".
401
+ *
402
+ * @param {Node} triangleBuffer packed triangle storage (8 vec4 stride)
403
+ * @param {Node} triIndex absolute triangle index
404
+ * @param {Node} geometryNormal shading-space normal, for the handedness test
405
+ * @param {Node} transform the normal map's mat3 UV transform
406
+ */
407
+ export const triangleUVTangent = Fn( ( [ triangleBuffer, triIndex, geometryNormal, transform ] ) => {
408
+
409
+ const S = int( 8 );
410
+ const pA = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 0 ), S ).xyz;
411
+ const e1 = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 1 ), S ).xyz.sub( pA );
412
+ const e2 = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 2 ), S ).xyz.sub( pA );
413
+
414
+ const uvAB = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 6 ), S );
415
+ const uvC = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 7 ), S ).xy;
416
+ const d1 = uvAB.zw.sub( uvAB.xy );
417
+ const d2 = uvC.sub( uvAB.xy );
418
+
419
+ const det = d1.x.mul( d2.y ).sub( d1.y.mul( d2.x ) );
420
+ const result = vec4( 0.0 ).toVar();
421
+
422
+ If( abs( det ).greaterThan( 1e-12 ), () => {
423
+
424
+ const r = float( 1.0 ).div( det );
425
+ const T = e1.mul( d2.y ).sub( e2.mul( d1.y ) ).mul( r ).toVar();
426
+ const B = e2.mul( d1.x ).sub( e1.mul( d2.x ) ).mul( r ).toVar();
427
+
428
+ // Re-express in the map's transformed UV space (inverse of the 2x2 linear part).
429
+ const uvJ = uvTransformJacobian( { t: transform } );
430
+ const Tt = T.mul( uvJ.x ).sub( B.mul( uvJ.y ) ).toVar();
431
+
432
+ If( length( Tt ).greaterThan( 1e-12 ), () => {
433
+
434
+ T.assign( Tt );
435
+
436
+ } );
437
+
438
+ If( length( T ).greaterThan( 1e-12 ), () => {
439
+
440
+ result.assign( vec4( normalize( T ), sign( dot( cross( geometryNormal, T ), B ) ) ) );
441
+
442
+ } );
443
+
444
+ } );
445
+
446
+ return result;
447
+
448
+ } );
449
+
450
+ /**
451
+ * Per-triangle tangent frame from the UV parameterisation.
452
+ *
453
+ * `uvTangent` is vec4( T.xyz, handedness ); w == 0 means "no usable frame" (degenerate or
454
+ * missing UVs) and falls back to the arbitrary cross(up, N) frame. A frame unrelated to the
455
+ * texture's U axis rotates the whole perturbation: on a vertical fluted-glass panel the
456
+ * arbitrary frame comes out 90 deg off, so the rib normals tilt up/down instead of across
457
+ * the ribs.
458
+ */
459
+ export const processNormal = Fn( ( [ geometryNormal, material, uvCache, uvTangent ] ) => {
386
460
 
387
461
  const result = geometryNormal.toVar();
388
462
 
@@ -393,10 +467,25 @@ export const processNormal = Fn( ( [ geometryNormal, material, uvCache ] ) => {
393
467
  normalMap.x.mulAssign( material.normalScale.x );
394
468
  normalMap.y.assign( normalMap.y.negate().mul( material.normalScale.x ) );
395
469
 
396
- // Fast TBN construction
397
- const up = abs( geometryNormal.z ).lessThan( 0.999 ).select( vec3( 0.0, 0.0, 1.0 ), vec3( 1.0, 0.0, 0.0 ) );
398
- const tangent = normalize( cross( up, geometryNormal ) );
399
- const bitangent = cross( geometryNormal, tangent );
470
+ const tangent = vec3( 0.0 ).toVar();
471
+ const handedness = float( 1.0 ).toVar();
472
+
473
+ // Gram-Schmidt against the interpolated normal, which is not exactly triangle-planar.
474
+ const perp = uvTangent.xyz.sub( geometryNormal.mul( dot( geometryNormal, uvTangent.xyz ) ) ).toVar();
475
+
476
+ If( abs( uvTangent.w ).greaterThan( 0.5 ).and( length( perp ).greaterThan( 1e-6 ) ), () => {
477
+
478
+ tangent.assign( normalize( perp ) );
479
+ handedness.assign( uvTangent.w );
480
+
481
+ } ).Else( () => {
482
+
483
+ const up = abs( geometryNormal.z ).lessThan( 0.999 ).select( vec3( 0.0, 0.0, 1.0 ), vec3( 1.0, 0.0, 0.0 ) );
484
+ tangent.assign( normalize( cross( up, geometryNormal ) ) );
485
+
486
+ } );
487
+
488
+ const bitangent = cross( geometryNormal, tangent ).mul( handedness );
400
489
 
401
490
  result.assign( normalize(
402
491
  tangent.mul( normalMap.x ).add( bitangent.mul( normalMap.y ) ).add( geometryNormal.mul( normalMap.z ) )
@@ -563,7 +652,7 @@ export const processEmissive = Fn( ( [ material, uvCache ] ) => {
563
652
  // MAIN BATCHED SAMPLING FUNCTION
564
653
  // ================================================================================
565
654
 
566
- export const sampleAllMaterialTextures = Fn( ( [ material, uv, geometryNormal ] ) => {
655
+ export const sampleAllMaterialTextures = Fn( ( [ material, uv, geometryNormal, uvTangent ] ) => {
567
656
 
568
657
  const albedo = vec4( 0.0 ).toVar();
569
658
  const emissive = vec3( 0.0 ).toVar();
@@ -591,7 +680,7 @@ export const sampleAllMaterialTextures = Fn( ( [ material, uv, geometryNormal ]
591
680
  metalness.assign( metalRough.x );
592
681
  roughness.assign( metalRough.y );
593
682
 
594
- const currentNormal = processNormal( geometryNormal, material, uvCache ).toVar();
683
+ const currentNormal = processNormal( geometryNormal, material, uvCache, uvTangent ).toVar();
595
684
  normal.assign( processBump( currentNormal, material, uvCache ) );
596
685
 
597
686
  emissive.assign( processEmissive( material, uvCache ) );