rayzee 6.0.1 → 6.2.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 (43) hide show
  1. package/README.md +5 -0
  2. package/dist/assets/TexturesWorker-DBqGmVdR.js.map +1 -1
  3. package/dist/rayzee.es.js +2421 -2078
  4. package/dist/rayzee.es.js.map +1 -1
  5. package/dist/rayzee.umd.js +55 -52
  6. package/dist/rayzee.umd.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/EngineDefaults.js +3 -0
  9. package/src/PathTracerApp.js +18 -8
  10. package/src/Pipeline/RenderStage.js +3 -0
  11. package/src/Processor/IESParser.js +340 -0
  12. package/src/Processor/LightSerializer.js +32 -4
  13. package/src/Processor/SceneProcessor.js +0 -1
  14. package/src/Processor/ShaderBuilder.js +40 -1
  15. package/src/Processor/Workers/TexturesWorker.js +1 -1
  16. package/src/RenderSettings.js +3 -0
  17. package/src/Stages/NormalDepth.js +3 -19
  18. package/src/Stages/PathTracer.js +15 -9
  19. package/src/TSL/BVHTraversal.js +4 -6
  20. package/src/TSL/Common.js +1 -1
  21. package/src/TSL/Debugger.js +0 -2
  22. package/src/TSL/EmissiveSampling.js +20 -22
  23. package/src/TSL/Environment.js +60 -14
  24. package/src/TSL/Fresnel.js +13 -4
  25. package/src/TSL/LightsCore.js +238 -5
  26. package/src/TSL/LightsDirect.js +16 -5
  27. package/src/TSL/LightsIndirect.js +4 -37
  28. package/src/TSL/LightsSampling.js +119 -185
  29. package/src/TSL/MaterialEvaluation.js +25 -14
  30. package/src/TSL/MaterialProperties.js +14 -34
  31. package/src/TSL/MaterialTransmission.js +18 -37
  32. package/src/TSL/PathTracer.js +25 -7
  33. package/src/TSL/PathTracerCore.js +144 -139
  34. package/src/TSL/Struct.js +7 -1
  35. package/src/TSL/TextureSampling.js +2 -2
  36. package/src/index.js +2 -0
  37. package/src/managers/AnimationManager.js +3 -6
  38. package/src/managers/DenoisingManager.js +1 -1
  39. package/src/managers/GoboManager.js +277 -0
  40. package/src/managers/IESManager.js +268 -0
  41. package/src/managers/LightManager.js +33 -1
  42. package/src/managers/TransformManager.js +3 -3
  43. package/src/managers/UniformManager.js +5 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "6.0.1",
3
+ "version": "6.2.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",
@@ -30,7 +30,7 @@
30
30
  "author": "Atul Mourya",
31
31
  "license": "MIT",
32
32
  "scripts": {
33
- "build": "vite build",
33
+ "build": "vite build && node scripts/update-size-badge.mjs",
34
34
  "prepublishOnly": "npm run build"
35
35
  },
36
36
  "peerDependencies": {
@@ -20,6 +20,9 @@ export const ENGINE_DEFAULTS = {
20
20
  environmentIntensity: 1,
21
21
  backgroundIntensity: 1,
22
22
  environmentRotation: 270.0,
23
+ groundProjectionEnabled: false,
24
+ groundProjectionRadius: 100,
25
+ groundProjectionHeight: 15,
23
26
  globalIlluminationIntensity: 1,
24
27
 
25
28
  // Environment Mode System
@@ -1,7 +1,7 @@
1
- import { WebGPURenderer, RectAreaLightNode, LinearSRGBColorSpace, SRGBColorSpace } from 'three/webgpu';
1
+ import { WebGPURenderer, RectAreaLightNode, SRGBColorSpace } from 'three/webgpu';
2
2
  import { texture as _tslTexture, cubeTexture as _tslCubeTexture } from 'three/tsl';
3
3
  import {
4
- ACESFilmicToneMapping, Scene, EventDispatcher, TimestampQuery
4
+ ACESFilmicToneMapping, Scene, EventDispatcher
5
5
  } from 'three';
6
6
  import { RectAreaLightTexturesLib } from 'three/addons/lights/RectAreaLightTexturesLib.js';
7
7
  import { SceneHelpers } from './SceneHelpers.js';
@@ -30,6 +30,8 @@ import { SceneProcessor } from './Processor/SceneProcessor.js';
30
30
  import { RenderSettings } from './RenderSettings.js';
31
31
  import { CameraManager } from './managers/CameraManager.js';
32
32
  import { LightManager } from './managers/LightManager.js';
33
+ import { GoboManager } from './managers/GoboManager.js';
34
+ import { IESManager } from './managers/IESManager.js';
33
35
  import { DenoisingManager } from './managers/DenoisingManager.js';
34
36
  import { OverlayManager } from './managers/OverlayManager.js';
35
37
  import { AnimationManager } from './managers/AnimationManager.js';
@@ -118,6 +120,10 @@ export class PathTracerApp extends EventDispatcher {
118
120
  this.cameraManager = null;
119
121
  /** @type {LightManager} */
120
122
  this.lightManager = null;
123
+ /** @type {GoboManager} */
124
+ this.goboManager = null;
125
+ /** @type {IESManager} */
126
+ this.iesManager = null;
121
127
  /** @type {DenoisingManager} */
122
128
  this.denoisingManager = null;
123
129
  /** @type {OverlayManager} */
@@ -331,9 +337,6 @@ export class PathTracerApp extends EventDispatcher {
331
337
  this._renderHelperOverlay();
332
338
  this.dispatchEvent( { type: EngineEvents.FRAME } );
333
339
 
334
- this.renderer.resolveTimestampsAsync?.( TimestampQuery.RENDER );
335
- this.renderer.resolveTimestampsAsync?.( TimestampQuery.COMPUTE );
336
-
337
340
  }
338
341
 
339
342
  /**
@@ -427,6 +430,8 @@ export class PathTracerApp extends EventDispatcher {
427
430
  this.transformManager?.dispose();
428
431
  this.overlayManager?.dispose();
429
432
  this.lightManager?.dispose();
433
+ this.goboManager?.dispose();
434
+ this.iesManager?.dispose();
430
435
  this.denoisingManager?.dispose();
431
436
  this.interactionManager?.dispose();
432
437
  this.cameraManager?.dispose();
@@ -1208,7 +1213,6 @@ export class PathTracerApp extends EventDispatcher {
1208
1213
 
1209
1214
  RectAreaLightNode.setLTC( RectAreaLightTexturesLib.init() );
1210
1215
 
1211
- this.renderer.workingColorSpace = SRGBColorSpace;
1212
1216
  this.renderer.outputColorSpace = SRGBColorSpace;
1213
1217
  this.renderer.toneMapping = ACESFilmicToneMapping;
1214
1218
  this.renderer.toneMappingExposure = 1.0;
@@ -1288,6 +1292,12 @@ export class PathTracerApp extends EventDispatcher {
1288
1292
  this.lightManager = new LightManager( this.scene, this._sceneHelpers, this.stages.pathTracer, {
1289
1293
  onReset: () => this.reset(),
1290
1294
  } );
1295
+ this.goboManager = new GoboManager( this.stages.pathTracer, {
1296
+ onReset: () => this.reset(),
1297
+ } );
1298
+ this.iesManager = new IESManager( this.stages.pathTracer, {
1299
+ onReset: () => this.reset(),
1300
+ } );
1291
1301
  this._setupDenoisingManager();
1292
1302
  this._setupOverlayManager();
1293
1303
 
@@ -1438,7 +1448,7 @@ export class PathTracerApp extends EventDispatcher {
1438
1448
  if ( animations.length > 0 ) {
1439
1449
 
1440
1450
  const mixerRoot = this.assetLoader?.targetModel || this.meshScene;
1441
- this.animationManager.init( this.meshScene, mixerRoot, this._sdf.meshes, animations, this._sdf.triangleCount );
1451
+ this.animationManager.init( this.meshScene, mixerRoot, this._sdf.meshes, animations );
1442
1452
  this.animationManager.onFinished = () => {
1443
1453
 
1444
1454
  this._animRefitInFlight = false;
@@ -1448,7 +1458,7 @@ export class PathTracerApp extends EventDispatcher {
1448
1458
 
1449
1459
  }
1450
1460
 
1451
- this.transformManager?.setMeshData( this._sdf.meshes, this._sdf.triangleCount );
1461
+ this.transformManager?.setMeshData( this._sdf.meshes );
1452
1462
 
1453
1463
  }
1454
1464
 
@@ -188,6 +188,7 @@ export class RenderStage {
188
188
  * @param {PipelineContext} context - Shared pipeline context
189
189
  * @returns {boolean} True if stage should execute
190
190
  */
191
+ // eslint-disable-next-line no-unused-vars
191
192
  shouldExecute( context ) {
192
193
 
193
194
  // Default: always execute
@@ -203,6 +204,7 @@ export class RenderStage {
203
204
  * @param {THREE.RenderTarget} [writeBuffer] - Optional output buffer
204
205
  * @throws {Error} If not implemented
205
206
  */
207
+ // eslint-disable-next-line no-unused-vars
206
208
  render( context, writeBuffer ) {
207
209
 
208
210
  throw new Error( `render() must be implemented in ${this.name}` );
@@ -223,6 +225,7 @@ export class RenderStage {
223
225
  * @param {number} width - New width
224
226
  * @param {number} height - New height
225
227
  */
228
+ // eslint-disable-next-line no-unused-vars
226
229
  setSize( width, height ) {
227
230
  // Override if needed
228
231
  }
@@ -0,0 +1,340 @@
1
+ /**
2
+ * IESParser.js
3
+ * Parser for IESNA LM-63 photometric data files (.ies).
4
+ *
5
+ * Returns the candela grid (one row per vertical angle), the angle lists,
6
+ * and the peak candela for normalisation.
7
+ *
8
+ * Reference: IESNA LM-63-2002 standard.
9
+ */
10
+
11
+ /**
12
+ * @typedef {Object} IESProfile
13
+ * @property {number[]} verticalAngles - degrees, length V
14
+ * @property {number[]} horizontalAngles - degrees, length H
15
+ * @property {Float32Array[]} candela - row-major [V][H], H values per row
16
+ * @property {number} maxCandela - peak intensity across the grid
17
+ * @property {number} lumens - lumens per lamp
18
+ * @property {number} photometricType - 1 = C (most common), 2 = B, 3 = A
19
+ * @property {string} [name] - optional source-file basename
20
+ */
21
+
22
+ /**
23
+ * Parses an IES file's text content into a numeric profile.
24
+ * @param {string} text - raw .ies file contents
25
+ * @param {string} [name] - optional identifier used in error messages
26
+ * @returns {IESProfile}
27
+ */
28
+ export function parseIES( text, name = 'ies' ) {
29
+
30
+ // Strip the header (everything up to and including the TILT line).
31
+ const tiltIdx = text.search( /TILT\s*=/i );
32
+ if ( tiltIdx < 0 ) throw new Error( `IES (${name}): missing TILT line` );
33
+
34
+ const headerEnd = text.indexOf( '\n', tiltIdx );
35
+ if ( headerEnd < 0 ) throw new Error( `IES (${name}): truncated TILT line` );
36
+
37
+ const tiltMatch = text.slice( tiltIdx, headerEnd ).match( /TILT\s*=\s*(\w+)/i );
38
+ const tilt = tiltMatch ? tiltMatch[ 1 ].toUpperCase() : 'NONE';
39
+
40
+ let body = text.slice( headerEnd );
41
+
42
+ // TILT=INCLUDE has an inline tilt-data block we need to skip. The block has:
43
+ // <lampToLuminaireGeometry>
44
+ // <numTiltAngles>
45
+ // <tiltAngles ...>
46
+ // <multipliers ...>
47
+ if ( tilt === 'INCLUDE' ) {
48
+
49
+ const tokens = tokenize( body );
50
+ // First token = geometry (1, 2, or 3)
51
+ // Second token = number of pairs
52
+ const numPairs = Number( tokens[ 1 ] );
53
+ // Skip: geometry + count + 2 * numPairs values
54
+ const skip = 2 + 2 * numPairs;
55
+ body = remainderAfterTokens( body, skip );
56
+
57
+ }
58
+
59
+ const tokens = tokenize( body );
60
+ let i = 0;
61
+ const next = () => Number( tokens[ i ++ ] );
62
+
63
+ const lampCount = next();
64
+ const lumens = next();
65
+ const multiplier = next();
66
+ const numVertAngles = next();
67
+ const numHorizAngles = next();
68
+ const photometricType = next();
69
+ /* const unitsType = */ next();
70
+ /* width */ next();
71
+ /* length */ next();
72
+ /* height */ next();
73
+ const ballastFactor = next();
74
+ /* futureUse */ next();
75
+ /* inputWatts */ next();
76
+
77
+ if ( ! Number.isFinite( numVertAngles ) || numVertAngles <= 0 ) {
78
+
79
+ throw new Error( `IES (${name}): invalid vertical angle count ${numVertAngles}` );
80
+
81
+ }
82
+
83
+ if ( ! Number.isFinite( numHorizAngles ) || numHorizAngles <= 0 ) {
84
+
85
+ throw new Error( `IES (${name}): invalid horizontal angle count ${numHorizAngles}` );
86
+
87
+ }
88
+
89
+ const verticalAngles = new Array( numVertAngles );
90
+ for ( let v = 0; v < numVertAngles; v ++ ) verticalAngles[ v ] = next();
91
+
92
+ const horizontalAngles = new Array( numHorizAngles );
93
+ for ( let h = 0; h < numHorizAngles; h ++ ) horizontalAngles[ h ] = next();
94
+
95
+ // Candela grid: in LM-63 the order is [horizontal][vertical] — for each
96
+ // horizontal plane, all vertical samples are listed before the next plane.
97
+ // We store as candela[v][h] for direct (theta, phi) lookups in the shader.
98
+ const candela = new Array( numVertAngles );
99
+ for ( let v = 0; v < numVertAngles; v ++ ) candela[ v ] = new Float32Array( numHorizAngles );
100
+
101
+ const scale = multiplier * ballastFactor;
102
+ let peak = 0;
103
+
104
+ for ( let h = 0; h < numHorizAngles; h ++ ) {
105
+
106
+ for ( let v = 0; v < numVertAngles; v ++ ) {
107
+
108
+ const cd = next() * scale;
109
+ candela[ v ][ h ] = cd;
110
+ if ( cd > peak ) peak = cd;
111
+
112
+ }
113
+
114
+ }
115
+
116
+ return {
117
+ verticalAngles,
118
+ horizontalAngles,
119
+ candela,
120
+ maxCandela: peak,
121
+ lumens: lumens * lampCount,
122
+ photometricType,
123
+ name,
124
+ };
125
+
126
+ }
127
+
128
+ /**
129
+ * Resample an IESProfile onto a fixed-size 2D grid suitable for a
130
+ * DataArrayTexture layer. Values are normalised to [0,1] by `maxCandela`
131
+ * so the shader gets a pure shape multiplier; absolute intensity scaling
132
+ * stays in `light.intensity`.
133
+ *
134
+ * UV convention:
135
+ * U = horizontal angle / 360 (0..1)
136
+ * V = vertical angle / 180 (0..1, 0 = bulb axis, 1 = opposite axis)
137
+ *
138
+ * Profiles that are rotationally symmetric (single horizontal sample, or all
139
+ * H angles identical) replicate the row across the U axis so the shader can
140
+ * sample uniformly without a symmetry flag.
141
+ *
142
+ * @param {IESProfile} profile
143
+ * @param {number} width - texture width in samples (horizontal/U axis)
144
+ * @param {number} height - texture height in samples (vertical/V axis)
145
+ * @returns {Uint8Array} width*height bytes, R channel only
146
+ */
147
+ export function resampleIESToGrid( profile, width, height ) {
148
+
149
+ const data = new Uint8Array( width * height );
150
+ const { verticalAngles: vA, horizontalAngles: hA, candela, maxCandela } = profile;
151
+
152
+ if ( maxCandela <= 0 ) return data; // dead profile → zeros
153
+
154
+ const vMaxDeg = vA[ vA.length - 1 ];
155
+ const hMaxDeg = hA[ hA.length - 1 ];
156
+ const hMin = hA[ 0 ];
157
+ const rotationallySymmetric = hA.length === 1 || hMaxDeg === hMin;
158
+
159
+ for ( let py = 0; py < height; py ++ ) {
160
+
161
+ // V coordinate → vertical angle in degrees. Texture covers [0, vMaxDeg].
162
+ const tV = ( py + 0.5 ) / height;
163
+ const vDeg = tV * vMaxDeg;
164
+ const v0 = lowerBoundIdx( vA, vDeg );
165
+ const v1 = Math.min( v0 + 1, vA.length - 1 );
166
+ const vSpan = vA[ v1 ] - vA[ v0 ];
167
+ const vt = vSpan > 0 ? ( vDeg - vA[ v0 ] ) / vSpan : 0;
168
+
169
+ for ( let px = 0; px < width; px ++ ) {
170
+
171
+ let cd;
172
+
173
+ if ( rotationallySymmetric ) {
174
+
175
+ // Single column → just bilerp on V axis.
176
+ cd = lerp( candela[ v0 ][ 0 ], candela[ v1 ][ 0 ], vt );
177
+
178
+ } else {
179
+
180
+ const tH = ( px + 0.5 ) / width;
181
+ const hDeg = hMin + tH * ( hMaxDeg - hMin );
182
+ const h0 = lowerBoundIdx( hA, hDeg );
183
+ const h1 = Math.min( h0 + 1, hA.length - 1 );
184
+ const hSpan = hA[ h1 ] - hA[ h0 ];
185
+ const ht = hSpan > 0 ? ( hDeg - hA[ h0 ] ) / hSpan : 0;
186
+
187
+ const c00 = candela[ v0 ][ h0 ];
188
+ const c10 = candela[ v0 ][ h1 ];
189
+ const c01 = candela[ v1 ][ h0 ];
190
+ const c11 = candela[ v1 ][ h1 ];
191
+ cd = lerp( lerp( c00, c10, ht ), lerp( c01, c11, ht ), vt );
192
+
193
+ }
194
+
195
+ const norm = Math.min( 1, Math.max( 0, cd / maxCandela ) );
196
+ data[ py * width + px ] = Math.round( norm * 255 );
197
+
198
+ }
199
+
200
+ }
201
+
202
+ return data;
203
+
204
+ }
205
+
206
+ /**
207
+ * Estimate a sensible spot-light cone half-angle from an IES profile.
208
+ *
209
+ * Returns the smallest vertical angle (radians) where the average candela
210
+ * across the horizontal samples drops below `threshold * maxCandela`. The
211
+ * resulting cone snugly bounds the profile's meaningful emission.
212
+ *
213
+ * @param {IESProfile} profile
214
+ * @param {number} [threshold=0.1] - intensity ratio considered "off"
215
+ * @returns {number} suggested half-angle in radians, clamped to [5°, 89°]
216
+ */
217
+ export function deriveIESBeamAngle( profile, threshold = 0.1 ) {
218
+
219
+ const { verticalAngles: vA, horizontalAngles: hA, candela, maxCandela } = profile;
220
+ const fallback = Math.PI / 4;
221
+ if ( maxCandela <= 0 || ! vA?.length ) return fallback;
222
+
223
+ const cutoff = maxCandela * threshold;
224
+ const hCount = hA.length;
225
+
226
+ // Scan from on-axis outward — works correctly for monotonically falling
227
+ // beams (the common case). For peaks-off-axis profiles (rare) this just
228
+ // picks the first low-intensity ring outside the centre.
229
+ let crossingDeg = vA[ vA.length - 1 ];
230
+ let everAboveCutoff = false;
231
+ for ( let v = 0; v < vA.length; v ++ ) {
232
+
233
+ let avg = 0;
234
+ for ( let h = 0; h < hCount; h ++ ) avg += candela[ v ][ h ];
235
+ avg /= hCount;
236
+
237
+ if ( avg >= cutoff ) everAboveCutoff = true;
238
+ if ( everAboveCutoff && avg < cutoff ) {
239
+
240
+ crossingDeg = vA[ v ];
241
+ break;
242
+
243
+ }
244
+
245
+ }
246
+
247
+ const rad = crossingDeg * Math.PI / 180;
248
+ // Clamp to [5°, 89°] so it stays useful for both very tight and diffuse profiles.
249
+ return Math.min( Math.max( rad, 5 * Math.PI / 180 ), 89 * Math.PI / 180 );
250
+
251
+ }
252
+
253
+ /**
254
+ * Estimate a sensible penumbra factor [0,1] from an IES profile, matching
255
+ * the three.js spot-light convention (0 = sharp edge, 1 = entire cone is the
256
+ * transition band).
257
+ *
258
+ * Penumbra = 1 - (innerAngle / outerAngle), where:
259
+ * outerAngle = `cutoff * maxCandela` crossing (use the value from deriveIESBeamAngle)
260
+ * innerAngle = where the candela average first drops below `peakRatio * maxCandela`
261
+ *
262
+ * For tightly-clamped beams (innerAngle ≈ outerAngle) this returns near-zero;
263
+ * for soft profiles with a long tail it returns a high value.
264
+ *
265
+ * @param {IESProfile} profile
266
+ * @param {number} outerAngleRad - the cone half-angle returned by deriveIESBeamAngle
267
+ * @param {number} [peakRatio=0.7] - intensity ratio considered "still hot"
268
+ * @returns {number} penumbra in [0,1]
269
+ */
270
+ export function deriveIESPenumbra( profile, outerAngleRad, peakRatio = 0.7 ) {
271
+
272
+ const { verticalAngles: vA, horizontalAngles: hA, candela, maxCandela } = profile;
273
+ if ( maxCandela <= 0 || ! vA?.length || outerAngleRad <= 0 ) return 0;
274
+
275
+ const hotCutoff = maxCandela * peakRatio;
276
+ const hCount = hA.length;
277
+ let innerDeg = 0;
278
+
279
+ for ( let v = 0; v < vA.length; v ++ ) {
280
+
281
+ let avg = 0;
282
+ for ( let h = 0; h < hCount; h ++ ) avg += candela[ v ][ h ];
283
+ avg /= hCount;
284
+ if ( avg >= hotCutoff ) innerDeg = vA[ v ]; else break;
285
+
286
+ }
287
+
288
+ const outerDeg = outerAngleRad * 180 / Math.PI;
289
+ const penumbra = outerDeg > 0 ? 1 - ( innerDeg / outerDeg ) : 0;
290
+ return Math.min( Math.max( penumbra, 0 ), 1 );
291
+
292
+ }
293
+
294
+ // ── helpers ─────────────────────────────────────────────────────────
295
+
296
+ function tokenize( text ) {
297
+
298
+ return text.split( /\s+/ ).filter( s => s.length > 0 );
299
+
300
+ }
301
+
302
+ function remainderAfterTokens( text, count ) {
303
+
304
+ const re = /\S+/g;
305
+ let m;
306
+ let n = 0;
307
+ while ( ( m = re.exec( text ) ) !== null ) {
308
+
309
+ n ++;
310
+ if ( n === count ) return text.slice( re.lastIndex );
311
+
312
+ }
313
+
314
+ return '';
315
+
316
+ }
317
+
318
+ function lowerBoundIdx( arr, x ) {
319
+
320
+ if ( x <= arr[ 0 ] ) return 0;
321
+ if ( x >= arr[ arr.length - 1 ] ) return arr.length - 1;
322
+
323
+ let lo = 0;
324
+ let hi = arr.length - 1;
325
+ while ( hi - lo > 1 ) {
326
+
327
+ const mid = ( lo + hi ) >> 1;
328
+ if ( arr[ mid ] <= x ) lo = mid; else hi = mid;
329
+
330
+ }
331
+
332
+ return lo;
333
+
334
+ }
335
+
336
+ function lerp( a, b, t ) {
337
+
338
+ return a + ( b - a ) * t;
339
+
340
+ }
@@ -90,13 +90,24 @@ export class LightSerializer {
90
90
  // Get angle parameter from light (default to 0 for sharp shadows)
91
91
  const angle = light.userData.angle || light.angle || 0.0; // In radians
92
92
 
93
+ // Optional projection mask. Sign of intensity carries the inverted flag.
94
+ const gobo = light.userData?.gobo;
95
+ const goboIndex = ( gobo && Number.isInteger( gobo.index ) ) ? gobo.index : - 1;
96
+ const rawIntensity = ( gobo && typeof gobo.intensity === 'number' ) ? gobo.intensity : 1.0;
97
+ const goboIntensity = ( gobo && gobo.inverted ) ? - Math.abs( rawIntensity ) : Math.abs( rawIntensity );
98
+ const goboScale = ( gobo && typeof gobo.scale === 'number' ) ? gobo.scale : 5.0;
99
+
93
100
  // Store in cache with importance
94
101
  this.directionalLightCache.push( {
95
102
  data: [
96
103
  direction.x, direction.y, direction.z, // direction toward light (3)
97
104
  light.color.r, light.color.g, light.color.b, // color (3)
98
105
  light.intensity, // intensity (1)
99
- angle // angular diameter in radians (1)
106
+ angle, // angular diameter in radians (1)
107
+ goboIndex, // gobo layer index, -1 = none (1)
108
+ goboIntensity, // signed gobo strength (1)
109
+ goboScale, // world units per gobo tile (1)
110
+ 0.0, // reserved (1) — padding to keep stride at 12
100
111
  ],
101
112
  importance: importance,
102
113
  light: light
@@ -176,6 +187,17 @@ export class LightSerializer {
176
187
  // Calculate importance for sorting
177
188
  const importance = this.calculateLightImportance( light, 'spot' );
178
189
 
190
+ // Optional projection mask ("gobo"). Sign of goboIntensity carries inverted flag.
191
+ const gobo = light.userData?.gobo;
192
+ const goboIndex = ( gobo && Number.isInteger( gobo.index ) ) ? gobo.index : - 1;
193
+ const rawGoboIntensity = ( gobo && typeof gobo.intensity === 'number' ) ? gobo.intensity : 1.0;
194
+ const goboIntensity = ( gobo && gobo.inverted ) ? - Math.abs( rawGoboIntensity ) : Math.abs( rawGoboIntensity );
195
+
196
+ // Optional IES photometric profile. Stored on light.userData.ies by IESManager.
197
+ const ies = light.userData?.ies;
198
+ const iesIndex = ( ies && Number.isInteger( ies.index ) ) ? ies.index : - 1;
199
+ const iesIntensity = ( ies && typeof ies.intensity === 'number' ) ? ies.intensity : 1.0;
200
+
179
201
  // Store in cache with importance
180
202
  this.spotLightCache.push( {
181
203
  data: [
@@ -186,7 +208,13 @@ export class LightSerializer {
186
208
  light.angle || Math.PI / 4, // cone half-angle in radians (1)
187
209
  light.penumbra || 0.0, // penumbra [0,1] (1)
188
210
  light.distance || 0.0, // cutoff distance (0 = infinite) (1)
189
- light.decay !== undefined ? light.decay : 2.0 // decay exponent (1)
211
+ light.decay !== undefined ? light.decay : 2.0, // decay exponent (1)
212
+ goboIndex, // gobo layer index, -1 = none (1)
213
+ goboIntensity, // signed gobo strength (1)
214
+ iesIndex, // IES profile index, -1 = none (1)
215
+ iesIntensity, // IES blend [0,1] (1)
216
+ 0.0, // reserved (1)
217
+ 0.0, // reserved (1) — keeps stride at 20 (vec4 aligned)
190
218
  ],
191
219
  importance: importance,
192
220
  light: light
@@ -261,10 +289,10 @@ export class LightSerializer {
261
289
  updateShaderUniforms( material ) {
262
290
 
263
291
  // Divide flat array lengths by per-light stride to get actual light counts
264
- const directionalCount = Math.floor( this.lightData.directional.length / 8 );
292
+ const directionalCount = Math.floor( this.lightData.directional.length / 12 );
265
293
  const areaCount = Math.floor( this.lightData.rectArea.length / 13 );
266
294
  const pointCount = Math.floor( this.lightData.point.length / 9 );
267
- const spotCount = Math.floor( this.lightData.spot.length / 14 );
295
+ const spotCount = Math.floor( this.lightData.spot.length / 20 );
268
296
 
269
297
  // Update light counts in shader defines
270
298
  material.defines.MAX_DIRECTIONAL_LIGHTS = directionalCount;
@@ -1,5 +1,4 @@
1
1
  // SceneProcessor.js - Processes scene geometry into GPU-ready data (BVH, textures, materials)
2
- import { Color } from "three";
3
2
  import { BVHBuilder } from './BVHBuilder.js';
4
3
  import { BVHRefitter } from './BVHRefitter.js';
5
4
  import { buildBVHParallel, shouldUseParallelBuild } from './ParallelBVHBuilder.js';
@@ -17,6 +17,7 @@ import { TextureNode } from 'three/webgpu';
17
17
  import { LinearFilter, DataArrayTexture } from 'three';
18
18
  import { pathTracerMain } from '../TSL/PathTracer.js';
19
19
  import { setShadowAlbedoMaps, setAlphaShadowsUniform } from '../TSL/LightsDirect.js';
20
+ import { setGoboMapsTexture, setIESProfilesTexture } from '../TSL/LightsCore.js';
20
21
  import { BuildTimer } from './BuildTimer.js';
21
22
 
22
23
  const WG_SIZE = 8;
@@ -126,11 +127,38 @@ export class ShaderBuilder {
126
127
  if ( mat.roughnessMaps && nodes.roughnessMapsTex ) nodes.roughnessMapsTex.value = mat.roughnessMaps;
127
128
  if ( mat.emissiveMaps && nodes.emissiveMapsTex ) nodes.emissiveMapsTex.value = mat.emissiveMaps;
128
129
  if ( mat.displacementMaps && nodes.displacementMapsTex ) nodes.displacementMapsTex.value = mat.displacementMaps;
130
+ if ( stage.goboMaps && nodes.goboMapsTex ) nodes.goboMapsTex.value = stage.goboMaps;
131
+ if ( stage.iesProfiles && nodes.iesProfilesTex ) nodes.iesProfilesTex.value = stage.iesProfiles;
129
132
 
130
133
  console.log( 'ShaderBuilder: Scene textures updated in-place' );
131
134
 
132
135
  }
133
136
 
137
+ /**
138
+ * Swap the spot light gobo texture in-place. The TSL graph closes over the
139
+ * texture node, so we only need to update the underlying .value.
140
+ * @param {DataArrayTexture | null} tex
141
+ */
142
+ updateGoboMaps( tex ) {
143
+
144
+ const nodes = this._sceneTextureNodes;
145
+ if ( ! nodes || ! nodes.goboMapsTex ) return;
146
+ if ( tex ) nodes.goboMapsTex.value = tex;
147
+
148
+ }
149
+
150
+ /**
151
+ * Swap the spot light IES profile texture in-place.
152
+ * @param {DataArrayTexture | null} tex
153
+ */
154
+ updateIESProfiles( tex ) {
155
+
156
+ const nodes = this._sceneTextureNodes;
157
+ if ( ! nodes || ! nodes.iesProfilesTex ) return;
158
+ if ( tex ) nodes.iesProfilesTex.value = tex;
159
+
160
+ }
161
+
134
162
  getSceneTextureNodes() {
135
163
 
136
164
  return this._sceneTextureNodes;
@@ -276,6 +304,14 @@ export class ShaderBuilder {
276
304
  const emissiveMapsTex = mat.emissiveMaps ? texture( mat.emissiveMaps ) : createArrayPlaceholder();
277
305
  const displacementMapsTex = mat.displacementMaps ? texture( mat.displacementMaps ) : createArrayPlaceholder();
278
306
 
307
+ // Spot light gobo array — placeholder until GoboManager populates it.
308
+ const goboMapsTex = stage.goboMaps ? texture( stage.goboMaps ) : createArrayPlaceholder();
309
+ setGoboMapsTexture( goboMapsTex );
310
+
311
+ // Spot light IES profiles array — placeholder until IESManager populates it.
312
+ const iesProfilesTex = stage.iesProfiles ? texture( stage.iesProfiles ) : createArrayPlaceholder();
313
+ setIESProfilesTexture( iesProfilesTex );
314
+
279
315
  // Set albedo texture array for alpha-aware shadow rays (module-level in LightsDirect.js).
280
316
  // Always pass the texture node (real or placeholder) so alpha-cutout code is emitted
281
317
  // into the shader at graph construction time. Runtime albedoMapIndex >= 0 guards sampling.
@@ -286,6 +322,7 @@ export class ShaderBuilder {
286
322
  envTex, adaptiveSamplingTex, envCDFStorage,
287
323
  albedoMapsTex, normalMapsTex, bumpMapsTex,
288
324
  metalnessMapsTex, roughnessMapsTex, emissiveMapsTex, displacementMapsTex,
325
+ goboMapsTex, iesProfilesTex,
289
326
  };
290
327
 
291
328
  this._sceneTextureNodes = result;
@@ -369,6 +406,9 @@ export class ShaderBuilder {
369
406
  envResolution: stage.envResolution,
370
407
  enableEnvironmentLight: stage.enableEnvironment,
371
408
  useEnvMapIS: stage.useEnvMapIS,
409
+ groundProjectionEnabled: stage.groundProjectionEnabled,
410
+ groundProjectionRadius: stage.groundProjectionRadius,
411
+ groundProjectionHeight: stage.groundProjectionHeight,
372
412
  maxBounceCount: stage.maxBounces,
373
413
  transmissiveBounces: stage.transmissiveBounces,
374
414
  showBackground: stage.showBackground,
@@ -376,7 +416,6 @@ export class ShaderBuilder {
376
416
  backgroundIntensity: stage.backgroundIntensity,
377
417
  fireflyThreshold: stage.fireflyThreshold,
378
418
  globalIlluminationIntensity: stage.globalIlluminationIntensity,
379
- totalTriangleCount: stage.totalTriangleCount,
380
419
  enableEmissiveTriangleSampling: stage.enableEmissiveTriangleSampling,
381
420
  emissiveTriangleBuffer: lightBufferStorage,
382
421
  emissiveTriangleCount: stage.emissiveTriangleCount,
@@ -113,7 +113,7 @@ async function processTexturesInChunks( textures, maxTextureSize, method ) {
113
113
 
114
114
  chunkBuffer = new Uint8Array( chunkBufferSize );
115
115
 
116
- } catch ( error ) {
116
+ } catch {
117
117
 
118
118
  console.warn( 'Failed to allocate chunk buffer, reducing dimensions' );
119
119
  const reducedDimensions = calculateReducedDimensions( textures, maxTextureSize );
@@ -22,6 +22,9 @@ const SETTING_ROUTES = {
22
22
  backgroundIntensity: { uniform: 'backgroundIntensity', reset: true },
23
23
  showBackground: { uniform: 'showBackground', reset: true },
24
24
  enableEnvironment: { uniform: 'enableEnvironment', reset: true },
25
+ groundProjectionEnabled: { uniform: 'groundProjectionEnabled', reset: true },
26
+ groundProjectionRadius: { uniform: 'groundProjectionRadius', reset: true },
27
+ groundProjectionHeight: { uniform: 'groundProjectionHeight', reset: true },
25
28
  globalIlluminationIntensity: { uniform: 'globalIlluminationIntensity', reset: true },
26
29
  enableDOF: { uniform: 'enableDOF', reset: true },
27
30
  focusDistance: { uniform: 'focusDistance', reset: false },