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.
- package/README.md +5 -0
- package/dist/assets/TexturesWorker-DBqGmVdR.js.map +1 -1
- package/dist/rayzee.es.js +2421 -2078
- package/dist/rayzee.es.js.map +1 -1
- package/dist/rayzee.umd.js +55 -52
- package/dist/rayzee.umd.js.map +1 -1
- package/package.json +2 -2
- package/src/EngineDefaults.js +3 -0
- package/src/PathTracerApp.js +18 -8
- package/src/Pipeline/RenderStage.js +3 -0
- package/src/Processor/IESParser.js +340 -0
- package/src/Processor/LightSerializer.js +32 -4
- package/src/Processor/SceneProcessor.js +0 -1
- package/src/Processor/ShaderBuilder.js +40 -1
- package/src/Processor/Workers/TexturesWorker.js +1 -1
- package/src/RenderSettings.js +3 -0
- package/src/Stages/NormalDepth.js +3 -19
- package/src/Stages/PathTracer.js +15 -9
- package/src/TSL/BVHTraversal.js +4 -6
- package/src/TSL/Common.js +1 -1
- package/src/TSL/Debugger.js +0 -2
- package/src/TSL/EmissiveSampling.js +20 -22
- package/src/TSL/Environment.js +60 -14
- package/src/TSL/Fresnel.js +13 -4
- package/src/TSL/LightsCore.js +238 -5
- package/src/TSL/LightsDirect.js +16 -5
- package/src/TSL/LightsIndirect.js +4 -37
- package/src/TSL/LightsSampling.js +119 -185
- package/src/TSL/MaterialEvaluation.js +25 -14
- package/src/TSL/MaterialProperties.js +14 -34
- package/src/TSL/MaterialTransmission.js +18 -37
- package/src/TSL/PathTracer.js +25 -7
- package/src/TSL/PathTracerCore.js +144 -139
- package/src/TSL/Struct.js +7 -1
- package/src/TSL/TextureSampling.js +2 -2
- package/src/index.js +2 -0
- package/src/managers/AnimationManager.js +3 -6
- package/src/managers/DenoisingManager.js +1 -1
- package/src/managers/GoboManager.js +277 -0
- package/src/managers/IESManager.js +268 -0
- package/src/managers/LightManager.js +33 -1
- package/src/managers/TransformManager.js +3 -3
- 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
|
|
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": {
|
package/src/EngineDefaults.js
CHANGED
|
@@ -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
|
package/src/PathTracerApp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { WebGPURenderer, RectAreaLightNode,
|
|
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
|
|
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
|
|
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
|
|
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 /
|
|
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 /
|
|
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
|
|
116
|
+
} catch {
|
|
117
117
|
|
|
118
118
|
console.warn( 'Failed to allocate chunk buffer, reducing dimensions' );
|
|
119
119
|
const reducedDimensions = calculateReducedDimensions( textures, maxTextureSize );
|
package/src/RenderSettings.js
CHANGED
|
@@ -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 },
|