rayzee 7.4.1 → 7.5.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.
@@ -29,6 +29,7 @@ import {
29
29
  sqrt,
30
30
  cos,
31
31
  sin,
32
+ tan,
32
33
  dot,
33
34
  cross,
34
35
  normalize,
@@ -56,6 +57,8 @@ import {
56
57
  sampleSpotGoboMask,
57
58
  sampleDirectionalGoboMask,
58
59
  sampleIESProfile,
60
+ sampleSphQuad,
61
+ sphQuadSolidAngle,
59
62
  } from './LightsCore.js';
60
63
 
61
64
  import { MISStrategy, DotProducts, DirectLightingDual } from './Struct.js';
@@ -90,7 +93,49 @@ const TWO_PI = 2.0 * PI;
90
93
  // Light Sampling Functions
91
94
  // =============================================================================
92
95
 
93
- // Enhanced area light sampling - rectangle
96
+ // Blender-style area-light spread: linearly attenuates emission as the receiver
97
+ // moves off the light's axis (gridded-softbox look). cosAngle = dot(-dir, normal),
98
+ // the emission cosine. spread = π (default) → full Lambertian hemisphere, no
99
+ // attenuation. Mirrors Cycles' area_light_spread_attenuation.
100
+ export const areaLightSpreadAttenuation = Fn( ( [ cosAngle, spread ] ) => {
101
+
102
+ const att = float( 1.0 ).toVar();
103
+
104
+ // Only attenuate for narrowed cones; at the π default tan(π/2)=∞ → skip.
105
+ If( spread.lessThan( float( PI ).sub( 1e-3 ) ).and( cosAngle.greaterThan( 0.0 ) ), () => {
106
+
107
+ const halfSpread = spread.mul( 0.5 ).toVar();
108
+ const tanHalf = tan( halfSpread ).toVar();
109
+ const sinA = sqrt( max( float( 1.0 ).sub( cosAngle.mul( cosAngle ) ), 0.0 ) );
110
+ const tanA = sinA.div( max( cosAngle, 1e-4 ) );
111
+ // Flux renormalization so narrowing the beam doesn't dim total power.
112
+ const normSpread = select(
113
+ halfSpread.greaterThan( 0.05 ),
114
+ float( 1.0 ).div( max( tanHalf.sub( halfSpread ), 1e-6 ) ),
115
+ float( 3.0 ).div( max( halfSpread.mul( halfSpread ).mul( halfSpread ), 1e-9 ) ),
116
+ );
117
+ att.assign( max( tanHalf.sub( tanA ).mul( normSpread ), 0.0 ) );
118
+
119
+ } );
120
+
121
+ return att;
122
+
123
+ } );
124
+
125
+ // Emitted radiance for an area light (Blender/Cycles parity):
126
+ // L = color · power · (1/π) · (normalize ? 1/area : 1)
127
+ // The 1/π is the Lambertian emitter normalization; the 1/area (Normalize ON)
128
+ // keeps total radiant power constant as the light is resized.
129
+ export const areaLightRadiance = Fn( ( [ light ] ) => {
130
+
131
+ const invArea = select( light.normalize.greaterThan( 0.5 ), float( 1.0 ).div( max( light.area, 1e-10 ) ), float( 1.0 ) );
132
+ return light.color.mul( light.intensity ).mul( PI_INV ).mul( invArea );
133
+
134
+ } );
135
+
136
+ // Area light sampling — rectangle uses spherical-rectangle (Ureña 2013)
137
+ // solid-angle sampling (pdf = 1/S); disk/ellipse and degenerate rects fall back
138
+ // to uniform-area sampling with the dist²/(area·cos) area→solid-angle pdf.
94
139
  export const sampleRectAreaLight = Fn( ( [ light, rayOrigin, ruv, lightSelectionPdf ] ) => {
95
140
 
96
141
  // Result variables (no early return in TSL)
@@ -104,12 +149,49 @@ export const sampleRectAreaLight = Fn( ( [ light, rayOrigin, ruv, lightSelection
104
149
  // Validate light area to prevent NaN
105
150
  If( light.area.greaterThan( 0.0 ), () => {
106
151
 
107
- // Sample random position on rectangle (u/v are half-vectors, so map [0,1] → [-1,1])
108
- const randomPos = light.position
109
- .add( light.u.mul( ruv.x.mul( 2.0 ).sub( 1.0 ) ) )
110
- .add( light.v.mul( ruv.y.mul( 2.0 ).sub( 1.0 ) ) );
152
+ const sampledPos = vec3( 0.0 ).toVar();
153
+ // >0 pdf already in solid-angle measure (spherical-rect); <0 → use area conversion.
154
+ const solidAnglePdf = float( - 1.0 ).toVar();
155
+
156
+ If( light.shape.greaterThan( 0.5 ), () => {
157
+
158
+ // Disk / ellipse: uniform sample on the unit disk, mapped through the
159
+ // half-vectors. PDF handled via the area→solid-angle conversion below.
160
+ const r = sqrt( ruv.x ).toVar();
161
+ const theta = float( TWO_PI ).mul( ruv.y ).toVar();
162
+ sampledPos.assign(
163
+ light.position
164
+ .add( light.u.mul( r.mul( cos( theta ) ) ) )
165
+ .add( light.v.mul( r.mul( sin( theta ) ) ) )
166
+ );
167
+
168
+ } ).Else( () => {
169
+
170
+ // Rectangle: spherical-rectangle solid-angle sampling.
171
+ const corner = light.position.sub( light.u ).sub( light.v );
172
+ const ex = light.u.mul( 2.0 );
173
+ const ey = light.v.mul( 2.0 );
174
+ const q = sampleSphQuad( rayOrigin, corner, ex, ey, ruv ).toVar();
175
+
176
+ If( q.w.greaterThan( 0.0 ), () => {
177
+
178
+ sampledPos.assign( q.xyz );
179
+ solidAnglePdf.assign( q.w );
180
+
181
+ } ).Else( () => {
182
+
183
+ // Degenerate / tiny solid angle → uniform-area fallback.
184
+ sampledPos.assign(
185
+ light.position
186
+ .add( light.u.mul( ruv.x.mul( 2.0 ).sub( 1.0 ) ) )
187
+ .add( light.v.mul( ruv.y.mul( 2.0 ).sub( 1.0 ) ) )
188
+ );
111
189
 
112
- const toLight = randomPos.sub( rayOrigin ).toVar();
190
+ } );
191
+
192
+ } );
193
+
194
+ const toLight = sampledPos.sub( rayOrigin ).toVar();
113
195
  const lightDistSq = dot( toLight, toLight ).toVar();
114
196
 
115
197
  // Guard against zero distance
@@ -117,17 +199,17 @@ export const sampleRectAreaLight = Fn( ( [ light, rayOrigin, ruv, lightSelection
117
199
 
118
200
  const dist = sqrt( lightDistSq ).toVar();
119
201
  const direction = toLight.div( dist ).toVar();
120
- const lightNormal = normalize( cross( light.u, light.v ) ).toVar();
121
- const cosAngle = dot( direction.negate(), lightNormal ).toVar();
202
+ const cosAngle = dot( direction.negate(), light.normal ).toVar();
122
203
 
123
204
  ls_lightType.assign( int( LIGHT_TYPE_AREA ) );
124
- ls_emission.assign( light.color.mul( light.intensity ) );
125
205
  ls_distance.assign( dist );
126
206
  ls_direction.assign( direction );
127
- // Guard division: ensure denominator is never zero
128
- ls_pdf.assign(
129
- lightDistSq.div( max( light.area.mul( max( cosAngle, 0.001 ) ), 1e-10 ) ).mul( lightSelectionPdf )
130
- );
207
+ ls_emission.assign( areaLightRadiance( light ).mul( areaLightSpreadAttenuation( cosAngle, light.spread ) ) );
208
+
209
+ // Spherical-rect: pdf = 1/S (solid angle). Else: dist²/(area·cos).
210
+ const areaPdf = lightDistSq.div( max( light.area.mul( max( cosAngle, 0.001 ) ), 1e-10 ) );
211
+ const dirPdf = select( solidAnglePdf.greaterThan( 0.0 ), solidAnglePdf, areaPdf );
212
+ ls_pdf.assign( dirPdf.mul( lightSelectionPdf ) );
131
213
  ls_valid.assign( cosAngle.greaterThan( 0.0 ) );
132
214
 
133
215
  } );
@@ -679,6 +761,91 @@ export const calculateMaterialPDF = Fn( ( [ viewDir, lightDir, normal, material
679
761
 
680
762
  } );
681
763
 
764
+ // Total light-selection weight at a shading point, replicating the NEE reservoir's
765
+ // importance accumulation (same per-type importance fns, same 16-light cap + order).
766
+ // Used to reconstruct the exact NEE selection pdf for MIS on the BSDF-hit path —
767
+ // the analogue of Cycles' light_tree_pdf re-walk. Without it, the BSDF-hit MIS
768
+ // partner assumes uniform 1/N selection, which disagrees with the importance-
769
+ // weighted NEE selection and biases the power-heuristic weights.
770
+ export const computeTotalLightImportance = Fn( ( [
771
+ rayOrigin, normal, material, bounceIndex,
772
+ directionalLightsBuffer, numDirectionalLights,
773
+ areaLightsBuffer, numAreaLights,
774
+ pointLightsBuffer, numPointLights,
775
+ spotLightsBuffer, numSpotLights,
776
+ ] ) => {
777
+
778
+ const totalWeight = float( 0.0 ).toVar();
779
+ const lightIndex = int( 0 ).toVar();
780
+
781
+ If( numDirectionalLights.greaterThan( int( 0 ) ), () => {
782
+
783
+ Loop( { start: int( 0 ), end: numDirectionalLights, type: 'int', condition: '<' }, ( { i } ) => {
784
+
785
+ If( lightIndex.lessThan( int( 16 ) ), () => {
786
+
787
+ const light = DirectionalLight.wrap( getDirectionalLight( directionalLightsBuffer, i ) );
788
+ totalWeight.addAssign( calculateDirectionalLightImportance( light, normal, material, bounceIndex ) );
789
+ lightIndex.addAssign( 1 );
790
+
791
+ } );
792
+
793
+ } );
794
+
795
+ } );
796
+
797
+ If( numAreaLights.greaterThan( int( 0 ) ), () => {
798
+
799
+ Loop( { start: int( 0 ), end: numAreaLights, type: 'int', condition: '<' }, ( { i } ) => {
800
+
801
+ If( lightIndex.lessThan( int( 16 ) ), () => {
802
+
803
+ const light = AreaLight.wrap( getAreaLight( areaLightsBuffer, i ) );
804
+ totalWeight.addAssign( select( light.intensity.greaterThan( 0.0 ), estimateLightImportance( light, rayOrigin, normal, material ), float( 0.0 ) ) );
805
+ lightIndex.addAssign( 1 );
806
+
807
+ } );
808
+
809
+ } );
810
+
811
+ } );
812
+
813
+ If( numPointLights.greaterThan( int( 0 ) ), () => {
814
+
815
+ Loop( { start: int( 0 ), end: numPointLights, type: 'int', condition: '<' }, ( { i } ) => {
816
+
817
+ If( lightIndex.lessThan( int( 16 ) ), () => {
818
+
819
+ const light = PointLight.wrap( getPointLight( pointLightsBuffer, i ) );
820
+ totalWeight.addAssign( calculatePointLightImportance( light, rayOrigin, normal, material ) );
821
+ lightIndex.addAssign( 1 );
822
+
823
+ } );
824
+
825
+ } );
826
+
827
+ } );
828
+
829
+ If( numSpotLights.greaterThan( int( 0 ) ), () => {
830
+
831
+ Loop( { start: int( 0 ), end: numSpotLights, type: 'int', condition: '<' }, ( { i } ) => {
832
+
833
+ If( lightIndex.lessThan( int( 16 ) ), () => {
834
+
835
+ const light = SpotLight.wrap( getSpotLight( spotLightsBuffer, i ) );
836
+ totalWeight.addAssign( calculateSpotLightImportance( light, rayOrigin, normal, material ) );
837
+ lightIndex.addAssign( 1 );
838
+
839
+ } );
840
+
841
+ } );
842
+
843
+ } );
844
+
845
+ return totalWeight;
846
+
847
+ } );
848
+
682
849
  // =============================================================================
683
850
  // Unified Direct Lighting System
684
851
  // =============================================================================
@@ -936,15 +1103,41 @@ export const calculateDirectLightingUnified = Fn( ( [
936
1103
  If( lightFacing.greaterThan( 0.0 ), () => {
937
1104
 
938
1105
  const lightDistSq = hitDistance.mul( hitDistance );
939
- // Guard division
940
- const lightPdf = lightDistSq.div( max( light.area.mul( lightFacing ), EPSILON ) ).toVar();
941
- lightPdf.divAssign( max( float( totalLights ), 1.0 ) );
1106
+
1107
+ // Directional pdf must match the NEE sampler: 1/S (spherical-rect)
1108
+ // for rectangles, dist²/(area·cos) for disk/ellipse + degenerate rects.
1109
+ const corner = light.position.sub( light.u ).sub( light.v );
1110
+ const sAngle = sphQuadSolidAngle( rayOrigin, corner, light.u.mul( 2.0 ), light.v.mul( 2.0 ) ).toVar();
1111
+ const areaPdf = lightDistSq.div( max( light.area.mul( lightFacing ), EPSILON ) );
1112
+ const dirPdf = select(
1113
+ light.shape.lessThan( 0.5 ).and( sAngle.greaterThan( 1e-5 ) ),
1114
+ float( 1.0 ).div( max( sAngle, 1e-10 ) ),
1115
+ areaPdf,
1116
+ ).toVar();
1117
+
1118
+ // Selection pdf consistent with the importance-weighted NEE reservoir
1119
+ // (Cycles light_tree_pdf re-walk). Falls back to uniform 1/N only when
1120
+ // total importance is zero, matching the NEE fallback.
1121
+ const selImp = estimateLightImportance( light, rayOrigin, hitNormal, material );
1122
+ const totalImp = computeTotalLightImportance(
1123
+ rayOrigin, hitNormal, material, bounceIndex,
1124
+ directionalLightsBuffer, numDirectionalLights,
1125
+ areaLightsBuffer, numAreaLights,
1126
+ pointLightsBuffer, numPointLights,
1127
+ spotLightsBuffer, numSpotLights,
1128
+ ).toVar();
1129
+ const selPdf = select(
1130
+ totalImp.greaterThan( 0.0 ),
1131
+ selImp.div( max( totalImp, 1e-10 ) ),
1132
+ float( 1.0 ).div( max( float( totalLights ), 1.0 ) ),
1133
+ );
1134
+ const lightPdf = dirPdf.mul( selPdf ).toVar();
942
1135
 
943
1136
  const brdfPdfWeighted = brdfSamplePdf.mul( brdfWeight );
944
1137
  const lightPdfWeighted = lightPdf.mul( lightWeight );
945
1138
  const misW = powerHeuristic( { pdf1: brdfPdfWeighted, pdf2: lightPdfWeighted } ).toVar();
946
1139
 
947
- const lightEmission = light.color.mul( light.intensity );
1140
+ const lightEmission = areaLightRadiance( light ).mul( areaLightSpreadAttenuation( lightFacing, light.spread ) );
948
1141
  // Base contribution WITHOUT visibility (see discrete-light site).
949
1142
  const baseContribution = lightEmission.mul( brdfSampleValue ).mul( NoL ).mul( misW ).div( max( brdfSamplePdf, 1e-10 ) ).mul( totalSamplingWeight ).div( max( brdfWeight, 1e-10 ) );
950
1143
  totalContribution.addAssign( baseContribution.mul( visibility ) );
@@ -38,10 +38,11 @@ export class LightManager extends EventDispatcher {
38
38
  addLight( type ) {
39
39
 
40
40
  const defaults = {
41
+ // Power in Watts (Blender-style) for point/spot/area; Sun is W/m² strength.
41
42
  DirectionalLight: { position: [ 1, 1, 1 ], intensity: 1.0, color: '#ffffff' },
42
- PointLight: { position: [ 0, 2, 0 ], intensity: 100, color: '#ffffff' },
43
- SpotLight: { position: [ 0, 1, 0 ], intensity: 300, color: '#ffffff', angle: 15 },
44
- RectAreaLight: { position: [ 0, 2, 0 ], intensity: 500, color: '#ffffff', width: 2, height: 2 }
43
+ PointLight: { position: [ 0, 2, 0 ], intensity: 1000, color: '#ffffff' },
44
+ SpotLight: { position: [ 0, 1, 0 ], intensity: 1000, color: '#ffffff', angle: 15 },
45
+ RectAreaLight: { position: [ 0, 2, 0 ], intensity: 100, color: '#ffffff', width: 2, height: 2 }
45
46
  };
46
47
 
47
48
  const props = defaults[ type ];
@@ -73,9 +74,19 @@ export class LightManager extends EventDispatcher {
73
74
  light = new RectAreaLight( props.color, props.intensity, props.width, props.height );
74
75
  light.position.fromArray( props.position );
75
76
  light.lookAt( 0, 0, 0 );
77
+ // Blender-style emission defaults: power-normalized, full Lambertian
78
+ // hemisphere (spread = π), rectangular shape.
79
+ light.userData.normalize = true;
80
+ light.userData.spread = Math.PI;
81
+ light.userData.shape = 'rectangle';
76
82
 
77
83
  }
78
84
 
85
+ // Blender-style emission controls common to every light type.
86
+ light.userData.temperature = 6500;
87
+ light.userData.useTemperature = false;
88
+ light.userData.exposure = 0;
89
+
79
90
  const count = this.scene.getObjectsByProperty( 'isLight', true ).length;
80
91
  light.name = `${type.replace( 'Light', '' )} ${count + 1}`;
81
92
  this.scene.add( light );
@@ -322,13 +333,24 @@ export class LightManager extends EventDispatcher {
322
333
  intensity: light.intensity,
323
334
  color: `#${light.color.getHexString()}`,
324
335
  position: [ light.position.x, light.position.y, light.position.z ],
325
- angle
336
+ angle,
337
+ // Emission controls common to all light types.
338
+ temperature: light.userData?.temperature ?? 6500,
339
+ useTemperature: light.userData?.useTemperature ?? false,
340
+ exposure: light.userData?.exposure ?? 0
326
341
  };
327
342
 
328
343
  if ( light.type === 'RectAreaLight' ) {
329
344
 
330
345
  descriptor.width = light.width;
331
346
  descriptor.height = light.height;
347
+ descriptor.normalize = light.userData?.normalize ?? true;
348
+ descriptor.spread = MathUtils.radToDeg( light.userData?.spread ?? Math.PI ); // degrees for UI
349
+ const rawShape = light.userData?.shape;
350
+ descriptor.shape = ( rawShape === 'square' || rawShape === 'rectangle' || rawShape === 'disk' || rawShape === 'ellipse' )
351
+ ? rawShape
352
+ : rawShape === 1 ? 'ellipse'
353
+ : 'rectangle'; // 'rect', undefined, 0 → rectangle
332
354
  const dir = light.getWorldDirection( light.position.clone() );
333
355
  descriptor.target = [ light.position.x + dir.x, light.position.y + dir.y, light.position.z + dir.z ];
334
356
 
@@ -212,7 +212,7 @@ export class UniformManager {
212
212
  // Light buffer nodes - pre-allocate for up to 16 lights per type (shader hard cap)
213
213
  this._lightBuffers = {
214
214
  directional: uniformArray( new Float32Array( 12 * 16 ), 'float' ),
215
- area: uniformArray( new Float32Array( 13 * 16 ), 'float' ),
215
+ area: uniformArray( new Float32Array( 16 * 16 ), 'float' ),
216
216
  point: uniformArray( new Float32Array( 9 * 16 ), 'float' ),
217
217
  spot: uniformArray( new Float32Array( 20 * 16 ), 'float' ),
218
218
  };