rayzee 8.3.0 → 8.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,317 @@
1
+ /**
2
+ * The engine's tone curve in WGSL, and a pass that turns a packed rgba16float buffer into RGBA
3
+ * bytes on the card.
4
+ *
5
+ * A mirror of `ToneMapCPU.js` — same curves, same order (exposure, saturation, curve, sRGB
6
+ * transfer), same rounding. It exists because reading half-floats back and converting them in
7
+ * JavaScript costs more than the neural upscale itself at production sizes. `bench:tonemap`
8
+ * compares the two implementations on a real device and fails on any drift.
9
+ *
10
+ * ⚠️ Rounding is deliberately bug-compatible. `toneMapToRGBA8` writes `srgb * 255 + 0.5` into a
11
+ * `Uint8ClampedArray`, which rounds again — so the CPU has always been half a level bright. WGSL's
12
+ * `round()` is round-half-to-even like the clamped array, so `round( srgb * 255 + 0.5 )` reproduces
13
+ * it exactly, including black staying at 0. Dropping the extra half would shift every image against
14
+ * the OIDN and Real-ESRGAN readbacks, which still go through the CPU function.
15
+ */
16
+
17
+ import {
18
+ NoToneMapping, LinearToneMapping, ReinhardToneMapping,
19
+ CineonToneMapping, ACESFilmicToneMapping, AgXToneMapping, NeutralToneMapping
20
+ } from 'three';
21
+
22
+ /**
23
+ * WGSL functions: `rayzee_tone_map( linearRGB, mode, exposure, saturation )` and
24
+ * `rayzee_linear_to_srgb( rgb )`, plus `rayzee_to_u8( channel )`.
25
+ *
26
+ * `mode` is the Three.js ToneMapping constant, interpolated in from the import above so the two
27
+ * sides cannot disagree about which number means which curve.
28
+ */
29
+ export const TONE_MAP_WGSL = /* wgsl */ `
30
+ const TM_NONE: u32 = ${NoToneMapping}u;
31
+ const TM_LINEAR: u32 = ${LinearToneMapping}u;
32
+ const TM_REINHARD: u32 = ${ReinhardToneMapping}u;
33
+ const TM_CINEON: u32 = ${CineonToneMapping}u;
34
+ const TM_ACES: u32 = ${ACESFilmicToneMapping}u;
35
+ const TM_AGX: u32 = ${AgXToneMapping}u;
36
+ const TM_NEUTRAL: u32 = ${NeutralToneMapping}u;
37
+
38
+ fn tm_reinhard( c: vec3<f32> ) -> vec3<f32> {
39
+ return clamp( c / ( c + vec3<f32>( 1.0 ) ), vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
40
+ }
41
+
42
+ fn tm_cineon( c0: vec3<f32> ) -> vec3<f32> {
43
+ let c = max( c0 - vec3<f32>( 0.004 ), vec3<f32>( 0.0 ) );
44
+ let v = ( c * ( 6.2 * c + vec3<f32>( 0.5 ) ) ) / ( c * ( 6.2 * c + vec3<f32>( 1.7 ) ) + vec3<f32>( 0.06 ) );
45
+ return pow( max( v, vec3<f32>( 0.0 ) ), vec3<f32>( 2.2 ) );
46
+ }
47
+
48
+ fn tm_aces( c0: vec3<f32> ) -> vec3<f32> {
49
+ let c = c0 / 0.6;
50
+ let m_in = mat3x3<f32>(
51
+ vec3<f32>( 0.59719, 0.07600, 0.02840 ),
52
+ vec3<f32>( 0.35458, 0.90834, 0.13383 ),
53
+ vec3<f32>( 0.04823, 0.01566, 0.83777 ) );
54
+ let v = m_in * c;
55
+ let a = v * ( v + vec3<f32>( 0.0245786 ) ) - vec3<f32>( 0.000090537 );
56
+ let b = v * ( 0.983729 * v + vec3<f32>( 0.4329510 ) ) + vec3<f32>( 0.238081 );
57
+ let m_out = mat3x3<f32>(
58
+ vec3<f32>( 1.60475, -0.10208, -0.00327 ),
59
+ vec3<f32>( -0.53108, 1.10813, -0.07276 ),
60
+ vec3<f32>( -0.07367, -0.00605, 1.07602 ) );
61
+ return clamp( m_out * ( a / b ), vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
62
+ }
63
+
64
+ fn tm_agx( c0: vec3<f32> ) -> vec3<f32> {
65
+ let m_in = mat3x3<f32>(
66
+ vec3<f32>( 0.6274, 0.0691, 0.0164 ),
67
+ vec3<f32>( 0.3293, 0.9195, 0.0880 ),
68
+ vec3<f32>( 0.0433, 0.0113, 0.8956 ) );
69
+ let m_agx = mat3x3<f32>(
70
+ vec3<f32>( 0.856627153315983, 0.137318972929847, 0.11189821299995 ),
71
+ vec3<f32>( 0.0951212405381588, 0.761241990602591, 0.0767994186031903 ),
72
+ vec3<f32>( 0.0482516061458583, 0.101439036467562, 0.811302368396859 ) );
73
+
74
+ var v = m_agx * ( m_in * c0 );
75
+
76
+ let minEv = -12.47393;
77
+ let maxEv = 4.026069;
78
+ v = clamp( ( log2( max( v, vec3<f32>( 1e-10 ) ) ) - vec3<f32>( minEv ) ) / ( maxEv - minEv ),
79
+ vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
80
+
81
+ let x2 = v * v;
82
+ let x4 = x2 * x2;
83
+ v = 15.5 * x4 * x2 - 40.14 * x4 * v + 31.96 * x4 - 6.868 * x2 * v + 0.4298 * x2 + 0.1191 * v
84
+ - vec3<f32>( 0.00232 );
85
+
86
+ let m_out = mat3x3<f32>(
87
+ vec3<f32>( 1.1271005818144368, -0.1413297634984383, -0.14132976349843826 ),
88
+ vec3<f32>( -0.11060664309660323, 1.157823702216272, -0.11060664309660294 ),
89
+ vec3<f32>( -0.016493938717834573, -0.016493938717834257, 1.2519364065950405 ) );
90
+ let o = pow( max( m_out * v, vec3<f32>( 0.0 ) ), vec3<f32>( 2.2 ) );
91
+
92
+ let m_srgb = mat3x3<f32>(
93
+ vec3<f32>( 1.6605, -0.1246, -0.0182 ),
94
+ vec3<f32>( -0.5876, 1.1329, -0.1006 ),
95
+ vec3<f32>( -0.0728, -0.0083, 1.1187 ) );
96
+ return clamp( m_srgb * o, vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
97
+ }
98
+
99
+ fn tm_neutral( c0: vec3<f32> ) -> vec3<f32> {
100
+ let startCompression = 0.8 - 0.04;
101
+ let desaturation = 0.15;
102
+
103
+ let x = min( c0.r, min( c0.g, c0.b ) );
104
+ var offset = 0.04;
105
+ if ( x < 0.08 ) { offset = x - 6.25 * x * x; }
106
+ var c = c0 - vec3<f32>( offset );
107
+
108
+ let peak = max( c.r, max( c.g, c.b ) );
109
+ if ( peak < startCompression ) { return c; }
110
+
111
+ let d = 1.0 - startCompression;
112
+ let newPeak = 1.0 - d * d / ( peak + d - startCompression );
113
+ c = c * ( newPeak / peak );
114
+ let gFactor = 1.0 - 1.0 / ( desaturation * ( peak - newPeak ) + 1.0 );
115
+ return mix( c, vec3<f32>( newPeak ), gFactor );
116
+ }
117
+
118
+ // Three.js clamps the fragment output with max(0) before tone mapping, so the curves never see a
119
+ // negative channel. The saturation grade drives channels below zero on much of a typical frame, and
120
+ // AgX/Neutral mix negatives across channels instead of clipping them.
121
+ fn rayzee_tone_curve( color: vec3<f32>, mode: u32 ) -> vec3<f32> {
122
+ let c = max( color, vec3<f32>( 0.0 ) );
123
+ if ( mode == TM_REINHARD ) { return tm_reinhard( c ); }
124
+ if ( mode == TM_CINEON ) { return tm_cineon( c ); }
125
+ if ( mode == TM_ACES ) { return tm_aces( c ); }
126
+ if ( mode == TM_AGX ) { return tm_agx( c ); }
127
+ if ( mode == TM_NEUTRAL ) { return tm_neutral( c ); }
128
+ return clamp( c, vec3<f32>( 0.0 ), vec3<f32>( 1.0 ) );
129
+ }
130
+
131
+ fn rayzee_tone_map( linearRGB: vec3<f32>, mode: u32, exposure: f32, saturation: f32 ) -> vec3<f32> {
132
+ // Three.js returns early for NoToneMapping without applying exposure, so a readback that applied
133
+ // it would paint brighter than the viewport it replaces.
134
+ var c = linearRGB * select( exposure, 1.0, mode == TM_NONE );
135
+ if ( saturation != 1.0 ) {
136
+ let luma = vec3<f32>( dot( c, vec3<f32>( 0.2126, 0.7152, 0.0722 ) ) );
137
+ c = luma + ( c - luma ) * saturation;
138
+ }
139
+ return rayzee_tone_curve( c, mode );
140
+ }
141
+
142
+ fn rayzee_linear_to_srgb( c: vec3<f32> ) -> vec3<f32> {
143
+ return select(
144
+ 1.055 * pow( max( c, vec3<f32>( 0.0 ) ), vec3<f32>( 1.0 / 2.4 ) ) - vec3<f32>( 0.055 ),
145
+ 12.92 * c,
146
+ c <= vec3<f32>( 0.0031308 ) );
147
+ }
148
+
149
+ fn rayzee_to_u8( srgb: vec3<f32> ) -> vec3<u32> {
150
+ return vec3<u32>( clamp( round( srgb * 255.0 + vec3<f32>( 0.5 ) ), vec3<f32>( 0.0 ), vec3<f32>( 255.0 ) ) );
151
+ }
152
+ `;
153
+
154
+ const PACKED_WGSL = /* wgsl */ `
155
+ struct Params {
156
+ width: u32,
157
+ height: u32,
158
+ mode: u32,
159
+ flipY: u32,
160
+ exposure: f32,
161
+ saturation: f32,
162
+ };
163
+
164
+ @group(0) @binding(0) var<storage, read> src: array<u32>;
165
+ @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
166
+ @group(0) @binding(2) var<uniform> params: Params;
167
+
168
+ ${TONE_MAP_WGSL}
169
+
170
+ @compute @workgroup_size(8, 8)
171
+ fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
172
+ if ( gid.x >= params.width || gid.y >= params.height ) { return; }
173
+
174
+ var srcY = gid.y;
175
+ if ( params.flipY == 1u ) { srcY = params.height - 1u - gid.y; }
176
+
177
+ let a = ( srcY * params.width + gid.x ) * 2u;
178
+ let rg = unpack2x16float( src[ a ] );
179
+ let ba = unpack2x16float( src[ a + 1u ] );
180
+
181
+ let mapped = rayzee_tone_map( vec3<f32>( rg.x, rg.y, ba.x ), params.mode, params.exposure, params.saturation );
182
+ let b8 = rayzee_to_u8( rayzee_linear_to_srgb( mapped ) );
183
+ dst[ gid.y * params.width + gid.x ] = b8.x | ( b8.y << 8u ) | ( b8.z << 16u ) | ( 255u << 24u );
184
+ }
185
+ `;
186
+
187
+ /** Uniform blocks round up to 16 bytes; the struct above is 24. */
188
+ const PARAMS_BYTES = 32;
189
+
190
+ /**
191
+ * Tone-maps a tightly packed rgba16float buffer (two `u32` per pixel) into RGBA bytes.
192
+ *
193
+ * Bound to one device and one image size; `ensureSize()` reallocates when the size changes.
194
+ */
195
+ export class PackedToneMapper {
196
+
197
+ constructor( device, label = 'rayzee:tonemap' ) {
198
+
199
+ this.device = device;
200
+ this.label = label;
201
+ this.width = 0;
202
+ this.height = 0;
203
+ this._pipeline = null;
204
+ this._storage = null;
205
+ this._map = null;
206
+ this._params = null;
207
+ this._paramData = new ArrayBuffer( PARAMS_BYTES );
208
+ this._paramU32 = new Uint32Array( this._paramData );
209
+ this._paramF32 = new Float32Array( this._paramData );
210
+ this.disposed = false;
211
+
212
+ }
213
+
214
+ ensureSize( width, height ) {
215
+
216
+ if ( this.width === width && this.height === height && this._pipeline ) return;
217
+
218
+ this._releaseBuffers();
219
+
220
+ const bytes = width * height * 4;
221
+ this._storage = this.device.createBuffer( {
222
+ label: `${this.label}-out`,
223
+ size: bytes,
224
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
225
+ } );
226
+ this._map = this.device.createBuffer( {
227
+ label: `${this.label}-map`,
228
+ size: bytes,
229
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
230
+ } );
231
+ this._params = this.device.createBuffer( {
232
+ label: `${this.label}-params`,
233
+ size: PARAMS_BYTES,
234
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
235
+ } );
236
+
237
+ this._pipeline ??= this.device.createComputePipeline( {
238
+ label: this.label,
239
+ layout: 'auto',
240
+ compute: { module: this.device.createShaderModule( { label: this.label, code: PACKED_WGSL } ), entryPoint: 'main' },
241
+ } );
242
+
243
+ this.width = width;
244
+ this.height = height;
245
+
246
+ }
247
+
248
+ /**
249
+ * @param {GPUBuffer} src packed rgba16float, `width * height * 8` bytes, STORAGE-capable
250
+ * @param {object} tone
251
+ * @param {number} tone.exposure raw `renderer.toneMappingExposure`
252
+ * @param {number} tone.toneMapping Three.js ToneMapping constant
253
+ * @param {number} [tone.saturation=1]
254
+ * @param {boolean} [tone.flipY=false]
255
+ * @returns {Promise<Uint8ClampedArray>} RGBA bytes, `width * height * 4`
256
+ */
257
+ async toRGBA8( src, { exposure = 1, toneMapping = 0, saturation = 1, flipY = false } = {} ) {
258
+
259
+ if ( this.disposed ) throw new Error( 'PackedToneMapper: disposed' );
260
+ if ( ! this._pipeline ) throw new Error( 'PackedToneMapper: call ensureSize() first' );
261
+
262
+ const { width, height } = this;
263
+
264
+ this._paramU32[ 0 ] = width;
265
+ this._paramU32[ 1 ] = height;
266
+ this._paramU32[ 2 ] = toneMapping >>> 0;
267
+ this._paramU32[ 3 ] = flipY ? 1 : 0;
268
+ this._paramF32[ 4 ] = exposure;
269
+ this._paramF32[ 5 ] = saturation;
270
+ this.device.queue.writeBuffer( this._params, 0, this._paramData );
271
+
272
+ const group = this.device.createBindGroup( {
273
+ layout: this._pipeline.getBindGroupLayout( 0 ),
274
+ entries: [
275
+ { binding: 0, resource: { buffer: src, size: width * height * 8 } },
276
+ { binding: 1, resource: { buffer: this._storage } },
277
+ { binding: 2, resource: { buffer: this._params } },
278
+ ],
279
+ } );
280
+
281
+ const encoder = this.device.createCommandEncoder( { label: this.label } );
282
+ const pass = encoder.beginComputePass();
283
+ pass.setPipeline( this._pipeline );
284
+ pass.setBindGroup( 0, group );
285
+ pass.dispatchWorkgroups( Math.ceil( width / 8 ), Math.ceil( height / 8 ) );
286
+ pass.end();
287
+ encoder.copyBufferToBuffer( this._storage, 0, this._map, 0, width * height * 4 );
288
+ this.device.queue.submit( [ encoder.finish() ] );
289
+
290
+ await this._map.mapAsync( GPUMapMode.READ );
291
+ const bytes = new Uint8ClampedArray( this._map.getMappedRange().slice( 0 ) );
292
+ this._map.unmap();
293
+ return bytes;
294
+
295
+ }
296
+
297
+ _releaseBuffers() {
298
+
299
+ this._storage?.destroy();
300
+ this._map?.destroy();
301
+ this._params?.destroy();
302
+ this._storage = null;
303
+ this._map = null;
304
+ this._params = null;
305
+
306
+ }
307
+
308
+ dispose() {
309
+
310
+ if ( this.disposed ) return;
311
+ this.disposed = true;
312
+ this._releaseBuffers();
313
+ this._pipeline = null;
314
+
315
+ }
316
+
317
+ }
@@ -88,6 +88,8 @@ export class PathTracer extends PathTracerStage {
88
88
  this._lastBounceEnergy = null;
89
89
  // maxBounces the curve was measured at; the curve is ignored once this no longer matches (-1 = none).
90
90
  this._lastBounceCountsBudget = - 1;
91
+ // Loop length the curve covers; maxBounces alone is not it (free bounces extend the loop).
92
+ this._lastBounceCountsLoopBound = - 1;
91
93
  this._readbackPending = false;
92
94
  this._readbackEveryNFrames = 4;
93
95
  this._readbackFrameCounter = 0;
@@ -341,7 +343,7 @@ export class PathTracer extends PathTracerStage {
341
343
 
342
344
  const maxBounces = this.maxBounces.value;
343
345
  // Transmissive/SSS steps consume iterations without advancing camera-bounce depth, so the loop must run far enough for deep glass/subsurface walks (mirror PathTracerCore); the survivor curve + early-exit break it early on non-SSS scenes.
344
- const loopBound = maxBounces + this.transmissiveBounces.value + this.maxSubsurfaceSteps.value;
346
+ const loopBound = this._bounceLoopBound();
345
347
 
346
348
  const frameHeight = this._wfRenderHeight.value;
347
349
  const chunkRows = this._chunkRows;
@@ -356,9 +358,17 @@ export class PathTracer extends PathTracerStage {
356
358
  // new camera-bounce caps the counts are cap-independent, so trust it up to that cutoff (whole curve when
357
359
  // same/decreasing budget; only the [0, oldBudget) overlap when increasing). budget=-1 → cutoff 0 → full
358
360
  // dispatch everywhere. This avoids the full-work spike on every bounce-count change.
361
+ // A curve measured with a smaller free-bounce budget under-counts survivors at EVERY depth, not
362
+ // just past its tail, so sizing off it drops the deep glass paths the bigger budget bought.
363
+ if ( loopBound > this._lastBounceCountsLoopBound ) this._curveSizingValid = false;
364
+
359
365
  const curve = this._lastBounceCounts;
366
+ const curveMeasuredThisFar = maxBounces <= this._lastBounceCountsBudget
367
+ && loopBound <= this._lastBounceCountsLoopBound;
360
368
  const curveReliableUpto = ( singleChunk && curve )
361
- ? ( maxBounces <= this._lastBounceCountsBudget ? loopBound + 1 : this._lastBounceCountsBudget )
369
+ ? ( curveMeasuredThisFar
370
+ ? loopBound + 1
371
+ : Math.min( this._lastBounceCountsBudget, this._lastBounceCountsLoopBound ) )
362
372
  : 0;
363
373
 
364
374
  // Blender-style row-band streaming: the fixed-budget path pool processes the image in bands of ≤ chunkRows
@@ -698,6 +708,12 @@ export class PathTracer extends PathTracerStage {
698
708
 
699
709
  }
700
710
 
711
+ _bounceLoopBound() {
712
+
713
+ return this.maxBounces.value + this.transmissiveBounces.value + this.maxSubsurfaceSteps.value;
714
+
715
+ }
716
+
701
717
  // Async readback of the per-bounce snapshot every N frames; never awaited, so the early-exit uses past-frame data.
702
718
  _maybeReadbackCounters() {
703
719
 
@@ -722,7 +738,8 @@ export class PathTracer extends PathTracerStage {
722
738
  // the budget 3 -> 20 doubles the frame's dispatch work (13 -> 26 bounce iterations, 109 -> 214
723
739
  // compute passes) for as long as that lasts. Waiting out the N-frame cadence just extends it, so
724
740
  // re-measure on the next frame instead — same idiom as the mid-motion priming above.
725
- if ( this.maxBounces.value !== this._lastBounceCountsBudget ) {
741
+ if ( this.maxBounces.value !== this._lastBounceCountsBudget
742
+ || this._bounceLoopBound() !== this._lastBounceCountsLoopBound ) {
726
743
 
727
744
  this._readbackFrameCounter = this._readbackEveryNFrames;
728
745
 
@@ -738,6 +755,7 @@ export class PathTracer extends PathTracerStage {
738
755
  this._readbackPending = true;
739
756
  const gen = this._readbackGeneration;
740
757
  const budget = this.maxBounces.value;
758
+ const measuredLoopBound = this._bounceLoopBound();
741
759
  const n = this._queueManager.MAX_BOUNCE_SNAPSHOTS;
742
760
  this.renderer.getArrayBufferAsync( attr ).then( ( buf ) => {
743
761
 
@@ -750,6 +768,7 @@ export class PathTracer extends PathTracerStage {
750
768
  this._lastBounceCounts = all.subarray( 0, n );
751
769
  this._lastBounceEnergy = all.subarray( n, 2 * n );
752
770
  this._lastBounceCountsBudget = budget;
771
+ this._lastBounceCountsLoopBound = measuredLoopBound;
753
772
  this._curveSizingValid = true;
754
773
 
755
774
  }
@@ -837,6 +856,7 @@ export class PathTracer extends PathTracerStage {
837
856
  this._lastBounceCounts = null;
838
857
  this._lastBounceEnergy = null;
839
858
  this._lastBounceCountsBudget = - 1;
859
+ this._lastBounceCountsLoopBound = - 1;
840
860
  this._curveSizingValid = false;
841
861
  this._readbackFrameCounter = 0;
842
862
  this._readbackGeneration ++;
@@ -251,10 +251,15 @@ export const getDistanceAttenuation = /*@__PURE__*/ wgslFn( `
251
251
  }
252
252
  ` );
253
253
 
254
- // Spot light attenuation
254
+ // Spot cone falloff, Blender/Cycles' shape: the soft edge is a smoothstep over a band of
255
+ // COSINE width (1 - cosCone) * blend, not one that ends at the inner cone's angle. The two
256
+ // agree at blend 0 and 1 and diverge in between — three.js' ramp ran twice as wide at 0.5,
257
+ // leaving the penumbra up to 2x dark against a Cycles render of the same lamp.
255
258
  export const getSpotAttenuation = /*@__PURE__*/ wgslFn( `
256
- fn getSpotAttenuation( coneCosine: f32, penumbraCosine: f32, angleCosine: f32 ) -> f32 {
257
- return smoothstep( coneCosine, penumbraCosine, angleCosine );
259
+ fn getSpotAttenuation( coneCosine: f32, blend: f32, angleCosine: f32 ) -> f32 {
260
+ let smoothWidth = ( 1.0 - coneCosine ) * blend;
261
+ if ( smoothWidth <= 0.0 ) { return 1.0; }
262
+ return smoothstep( 0.0, 1.0, ( angleCosine - coneCosine ) / smoothWidth );
258
263
  }
259
264
  ` );
260
265
 
@@ -261,10 +261,7 @@ export const sampleSpotLightWithRadius = Fn( ( [ light, rayOrigin, lightSelectio
261
261
 
262
262
  If( ls_valid, () => {
263
263
 
264
- // Penumbra: inner cone angle = outerAngle * (1 - penumbra)
265
- // Clamp penumbraCosAngle > coneCosAngle to avoid smoothstep UB when penumbra = 0
266
- const penumbraCosAngle = cos( light.angle.mul( float( 1.0 ).sub( light.penumbra ) ) ).max( coneCosAngle.add( 1e-5 ) ).toVar();
267
- const coneAttenuation = getSpotAttenuation( { coneCosine: coneCosAngle, penumbraCosine: penumbraCosAngle, angleCosine: spotCosAngle } );
264
+ const coneAttenuation = getSpotAttenuation( { coneCosine: coneCosAngle, blend: light.penumbra, angleCosine: spotCosAngle } );
268
265
  const distanceAttenuation = getDistanceAttenuation( { lightDistance: lightDist, cutoffDistance: light.distance, decayExponent: light.decay } );
269
266
 
270
267
  // Gobo projection mask + IES photometric profile — both 1.0 when not assigned.