three-gpu-pathtracer 0.0.26 → 0.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-gpu-pathtracer",
3
- "version": "0.0.26",
3
+ "version": "0.0.27",
4
4
  "description": "Path tracing renderer and utilities for three.js built on top of three-mesh-bvh.",
5
5
  "module": "src/index.js",
6
6
  "main": "build/index.umd.cjs",
@@ -43,7 +43,7 @@
43
43
  "devDependencies": {
44
44
  "@lookingglass/webxr": "^0.6.0",
45
45
  "@monogrid/gainmap-js": "^3.0.5",
46
- "@pmndrs/upscaler": "^0.2.0",
46
+ "@pmndrs/upscaler": "^0.3.0",
47
47
  "@types/node": "^24.3.1",
48
48
  "@types/three": "^0.185.1",
49
49
  "@typescript-eslint/eslint-plugin": "^8.40.0",
@@ -78,7 +78,8 @@ export function getLights( scene ) {
78
78
  c.isRectAreaLight ||
79
79
  c.isSpotLight ||
80
80
  c.isPointLight ||
81
- c.isDirectionalLight
81
+ c.isDirectionalLight ||
82
+ c.isSunLight
82
83
  ) {
83
84
 
84
85
  lights.push( c );
@@ -2,6 +2,7 @@ import { DataTexture, RGBAFormat, ClampToEdgeWrapping, FloatType, Vector3, Quate
2
2
  import { bufferToHash } from '../utils/bufferToHash.js';
3
3
 
4
4
  const LIGHT_PIXELS = 6;
5
+ const VISIBLE_TO_CAMERA_OFFSET = 11;
5
6
  const RECT_AREA_LIGHT = 0;
6
7
  const CIRC_AREA_LIGHT = 1;
7
8
  const SPOT_LIGHT = 2;
@@ -82,7 +83,7 @@ export class LightsInfoUniformStruct {
82
83
 
83
84
  type = SPOT_LIGHT;
84
85
 
85
- } else if ( l.isDirectionalLight ) {
86
+ } else if ( l.isDirectionalLight || l.isSunLight ) {
86
87
 
87
88
  type = DIR_LIGHT;
88
89
 
@@ -207,8 +208,22 @@ export class LightsInfoUniformStruct {
207
208
  floatArray[ baseIndex + ( index ++ ) ] = target.y;
208
209
  floatArray[ baseIndex + ( index ++ ) ] = target.z;
209
210
 
211
+ } else if ( l.isSunLight ) {
212
+
213
+ // the sun has no target and shines from its position toward the origin
214
+ target.setFromMatrixPosition( l.matrixWorld ).normalize();
215
+
216
+ // sample 3
217
+ // u vector
218
+ floatArray[ baseIndex + ( index ++ ) ] = target.x;
219
+ floatArray[ baseIndex + ( index ++ ) ] = target.y;
220
+ floatArray[ baseIndex + ( index ++ ) ] = target.z;
221
+
210
222
  }
211
223
 
224
+ // whether camera rays see the light
225
+ floatArray[ baseIndex + VISIBLE_TO_CAMERA_OFFSET ] = l.visibleToCameraRays ? 1 : 0;
226
+
212
227
  }
213
228
 
214
229
  this.count = lights.length;
@@ -6,10 +6,12 @@ import {
6
6
  NoBlending,
7
7
  Color,
8
8
  Vector4,
9
+ Matrix3,
9
10
  } from 'three/webgpu';
10
11
 
11
12
  const MAX_TEXTURE_SIZE = 4096;
12
13
  const _prevClearColor = new Color();
14
+ const _prevMatrix = new Matrix3();
13
15
 
14
16
  function getTextureHash( texture ) {
15
17
 
@@ -438,21 +440,40 @@ export class AtlasTexture {
438
440
  for ( let i = 0, l = textures.length; i < l; i ++ ) {
439
441
 
440
442
  const { x, y, w, h, page } = placements[ i ];
441
-
442
- // Clone the source so we get an independent texture handle
443
- const texture = textures[ i ].clone();
444
- texture.matrixAutoUpdate = false;
445
- texture.matrix.identity();
446
-
447
- quadMesh.material.map = texture;
443
+ const texture = textures[ i ];
448
444
 
449
445
  // three.js uses the render target viewport / scissor
450
446
  renderTarget.viewport.set( x, y, w, h );
451
447
  renderTarget.scissor.set( x, y, w, h );
452
448
  renderer.setRenderTarget( renderTarget, page );
453
- quadMesh.render( renderer );
454
449
 
455
- texture.dispose();
450
+ if ( texture.isRenderTargetTexture ) {
451
+
452
+ // a render target's content only lives on the GPU, so draw it directly
453
+ const prevAutoUpdate = texture.matrixAutoUpdate;
454
+ _prevMatrix.copy( texture.matrix );
455
+ texture.matrix.identity();
456
+ texture.matrixAutoUpdate = false;
457
+
458
+ quadMesh.material.map = texture;
459
+ quadMesh.render( renderer );
460
+
461
+ texture.matrixAutoUpdate = prevAutoUpdate;
462
+ texture.matrix.copy( _prevMatrix );
463
+
464
+ } else {
465
+
466
+ // Clone the source so we get an independent texture handle
467
+ const clone = texture.clone();
468
+ clone.matrixAutoUpdate = false;
469
+ clone.matrix.identity();
470
+
471
+ quadMesh.material.map = clone;
472
+ quadMesh.render( renderer );
473
+
474
+ clone.dispose();
475
+
476
+ }
456
477
 
457
478
  }
458
479
 
@@ -1,5 +1,5 @@
1
1
  import { Matrix3 } from 'three/webgpu';
2
- import { texture, sampler, uniform, wgslFn } from 'three/tsl';
2
+ import { texture, sampler, uniform, wgslFn, PI } from 'three/tsl';
3
3
  import { EquirectHdrInfoUniform } from '../uniforms/EquirectHdrInfoUniform.js';
4
4
  import { wgslTagFn } from 'three-mesh-bvh/webgpu';
5
5
  import { constants, environmentSampleStruct } from './nodes/structs.wgsl.js';
@@ -126,6 +126,16 @@ export class EquirectHdrInfoNode extends EquirectHdrInfoUniform {
126
126
  }
127
127
  `;
128
128
 
129
+ // the environment's average irradiance, for weighing it against the lights
130
+ this.getWeight = wgslTagFn/* wgsl */`
131
+ fn getEnvWeight() -> f32 {
132
+
133
+ let resolution = textureDimensions( ${ mapNode } );
134
+ return ${ intensityNode } * ${ PI } * ${ PI } * ${ totalSumNode } / ( 2.0 * f32( resolution.x * resolution.y ) );
135
+
136
+ }
137
+ `;
138
+
129
139
  this.getDirPdf = wgslTagFn/* wgsl */`
130
140
  fn getEnvDirPdf( direction: vec3f ) -> f32 {
131
141
 
@@ -5,6 +5,7 @@ import { AtlasTexture } from './AtlasTexture.js';
5
5
  import { LightsInfoUniformStruct } from '../uniforms/LightsInfoUniformStruct.js';
6
6
  import { lightStruct, lightRecordStruct } from './nodes/structs.wgsl.js';
7
7
  import { sampleTexelFunc } from './nodes/utils.wgsl.js';
8
+ import { luminanceFn } from './nodes/sampling.wgsl.js';
8
9
  import {
9
10
  RECT_AREA_LIGHT_TYPE,
10
11
  CIRC_AREA_LIGHT_TYPE,
@@ -17,6 +18,8 @@ import {
17
18
  randomAreaLightSampleFn,
18
19
  randomSpotLightSampleFn,
19
20
  getSpotAttenuationFn,
21
+ getDistanceAttenuationFn,
22
+ getSpotLightNearestFn,
20
23
  } from './nodes/lights.wgsl.js';
21
24
 
22
25
  export class LightsInfoNode extends LightsInfoUniformStruct {
@@ -65,12 +68,13 @@ export class LightsInfoNode extends LightsInfoUniformStruct {
65
68
  const src = this.tex.image.data;
66
69
  this.buffer.array.set( src.subarray( 0, count * stride ) );
67
70
 
68
- // rewrite the int fields ( lightType, iesProfile ) as i32 bits
71
+ // rewrite the int fields ( lightType, visibleToCameraRays, iesProfile ) as i32 bits
69
72
  const intView = new Int32Array( this.buffer.array.buffer );
70
73
  for ( let i = 0; i < count; i ++ ) {
71
74
 
72
75
  const base = i * stride;
73
76
  intView[ base + 3 ] = Math.round( src[ base + 3 ] );
77
+ intView[ base + 11 ] = Math.round( src[ base + 11 ] );
74
78
  intView[ base + 21 ] = Math.round( src[ base + 21 ] );
75
79
 
76
80
  }
@@ -84,12 +88,146 @@ export class LightsInfoNode extends LightsInfoUniformStruct {
84
88
 
85
89
  _initFns() {
86
90
 
87
- const { bufferNode, iesProfilesNode, iesInfoNode } = this;
91
+ const { bufferNode, countNode, iesProfilesNode, iesInfoNode } = this;
88
92
 
89
93
  // profiles are sampled out of an atlas so filtering must resolve tile-relative wrapping
90
94
  const sampleIesTexelFn = sampleTexelFunc( iesInfoNode, iesProfilesNode, 'sampleIesTexel' );
91
95
 
92
- // uniformly pick a light and sample it
96
+ this.isLightVisibleToCamera = wgslTagFn/* wgsl */`
97
+ fn isLightVisibleToCamera( index: u32 ) -> bool {
98
+
99
+ return ${ bufferNode }[ index ].visibleToCameraRays != 0;
100
+
101
+ }
102
+ `;
103
+
104
+ // A cheap estimate of how much light reaches a position from a light, ignoring occlusion and
105
+ // the surface orientation, used to choose which light to sample.
106
+ // TODO: weigh lights below the surface's horizon to zero using its normal, skipped for
107
+ // transmissive surfaces. Forward hit MIS would then need the previous vertex's normal too.
108
+ this.getLightWeight = wgslTagFn/* wgsl */`
109
+ fn getLightWeight( index: u32, position: vec3f ) -> f32 {
110
+
111
+ let light = ${ bufferNode }[ index ];
112
+ let power = ${ luminanceFn }( light.color * light.intensity );
113
+ if ( light.lightType == ${ SPOT_LIGHT_TYPE } ) {
114
+
115
+ // the most any point on the spot's disc lights the position. Ies profiles are not
116
+ // evaluated here
117
+ let nearest = ${ getSpotLightNearestFn }( light, position );
118
+ var attenuation = 1.0;
119
+ if ( light.iesProfile < 0 ) {
120
+
121
+ attenuation = ${ getSpotAttenuationFn }( light.coneCos, light.penumbraCos, nearest.x );
122
+
123
+ }
124
+
125
+ return power * attenuation * ${ getDistanceAttenuationFn }( nearest.y, light.distance, light.decay );
126
+
127
+ } else if ( light.lightType == ${ POINT_LIGHT_TYPE } ) {
128
+
129
+ // the point light's world position is packed into the u slot
130
+ let dist = length( light.u - position );
131
+ return power * ${ getDistanceAttenuationFn }( dist, light.distance, light.decay );
132
+
133
+ } else if ( light.lightType == ${ DIR_LIGHT_TYPE } ) {
134
+
135
+ return power;
136
+
137
+ } else {
138
+
139
+ let toLight = light.position - position;
140
+ let distSq = dot( toLight, toLight );
141
+ if ( distSq == 0.0 ) {
142
+
143
+ return 0.0;
144
+
145
+ }
146
+
147
+ // area lights only emit from their front face, and the distance is held above the size
148
+ // of the light so positions on or near it do not dominate
149
+ let normal = normalize( cross( light.u, light.v ) );
150
+ let cosTheta = dot( toLight, normal ) * inverseSqrt( distSq );
151
+ return power * light.area * max( cosTheta, 0.0 ) / max( distSq, light.area );
152
+
153
+ }
154
+
155
+ }
156
+ `;
157
+
158
+ this.getLightsWeight = wgslTagFn/* wgsl */`
159
+ fn getLightsWeight( position: vec3f ) -> f32 {
160
+
161
+ var total = 0.0;
162
+ for ( var i = 0u; i < ${ countNode }; i ++ ) {
163
+
164
+ total += ${ this.getLightWeight }( i, position );
165
+
166
+ }
167
+
168
+ return total;
169
+
170
+ }
171
+ `;
172
+
173
+ // Chooses a light, or the environment when the returned index equals the light count, in
174
+ // proportion to the estimated weights. The pdf is zero when nothing can light the position.
175
+ this.selectLight = wgslTagFn/* wgsl */`
176
+ fn selectLight( position: vec3f, envWeight: f32, r: f32, selectionPdf: ptr<function, f32> ) -> u32 {
177
+
178
+ let count = ${ countNode };
179
+ let totalWeight = ${ this.getLightsWeight }( position ) + envWeight;
180
+ if ( totalWeight <= 0.0 ) {
181
+
182
+ *selectionPdf = 0.0;
183
+ return 0u;
184
+
185
+ }
186
+
187
+ let targetWeight = r * totalWeight;
188
+ var accumulated = 0.0;
189
+ var lastWeighted = 0u;
190
+ var index = count;
191
+ for ( var i = 0u; i < count; i ++ ) {
192
+
193
+ let lightWeight = ${ this.getLightWeight }( i, position );
194
+ accumulated += lightWeight;
195
+ if ( lightWeight > 0.0 ) {
196
+
197
+ lastWeighted = i;
198
+
199
+ }
200
+
201
+ if ( targetWeight < accumulated ) {
202
+
203
+ index = i;
204
+ break;
205
+
206
+ }
207
+
208
+ }
209
+
210
+ // rounding can carry the target past the last light when there is no environment
211
+ if ( index == count && envWeight <= 0.0 ) {
212
+
213
+ index = lastWeighted;
214
+
215
+ }
216
+
217
+ var weight = envWeight;
218
+ if ( index < count ) {
219
+
220
+ weight = ${ this.getLightWeight }( index, position );
221
+
222
+ }
223
+
224
+ *selectionPdf = weight / totalWeight;
225
+ return index;
226
+
227
+ }
228
+ `;
229
+
230
+ // sample the light at the given index
93
231
  this.randomLightSample = wgslTagFn/* wgsl */`
94
232
  fn randomLightSample( lightIndex: u32, rayOrigin: vec3f, ruv: vec2f ) -> ${ lightRecordStruct } {
95
233
 
@@ -271,6 +271,7 @@ export class MegaKernelPathTracer extends PathTracerBackend {
271
271
  // TODO: dispose of all buffers
272
272
  this.envInfo.dispose();
273
273
  this.lightsInfo.dispose();
274
+ this.kernel.dispose();
274
275
 
275
276
  }
276
277
 
@@ -208,6 +208,8 @@ export class PathTracerBackend {
208
208
  this.outputTarget.dispose();
209
209
  this.prevOutputTarget.dispose();
210
210
  this.sampleCountTarget.dispose();
211
+ this.sampleCountClearKernel.dispose();
212
+ this.outputTargetClearKernel.dispose();
211
213
 
212
214
  this._renderTask = null;
213
215
 
@@ -96,6 +96,8 @@ export class TurquinTexture extends Storage3DTexture {
96
96
 
97
97
  }
98
98
 
99
+ kernel.dispose();
100
+
99
101
  // fns
100
102
  // function that maps the given value from range [a0, a1] to [b0, b1]
101
103
  const mapLinearClampedFn = wgslTagFn/* wgsl */`
@@ -257,6 +257,19 @@ export class WaveFrontPathTracer extends PathTracerBackend {
257
257
  this.rayDispatchConverter.outputDispatch.dispose();
258
258
  this.shadowDispatchConverter.outputDispatch.dispose();
259
259
 
260
+ this.populatePixelIndicesKernel.dispose();
261
+ this.logicKernel.dispose();
262
+ this.materialKernel.dispose();
263
+ this.traceRayKernel.dispose();
264
+ this.traceShadowRayKernel.dispose();
265
+ this.rayDispatchConverter.dispose();
266
+ this.shadowDispatchConverter.dispose();
267
+ this.primeSampleCountersKernel.dispose();
268
+ this.tallySampleCountsKernel.dispose();
269
+ this.resetSlotsKernel.dispose();
270
+ this.copyRayDataKernel.dispose();
271
+ this.zeroDispatchKernel.dispose();
272
+
260
273
  }
261
274
 
262
275
  // The queue holds every pixel not owned by a slot. It is sized to the full pixel count so slots
@@ -151,7 +151,7 @@ export class WebGPUPathTracer {
151
151
  set maxBounces( v ) {
152
152
 
153
153
  this._pathTracer.maxBounces = v;
154
- this._pathTracer.reset();
154
+ this.reset();
155
155
 
156
156
  }
157
157
 
@@ -188,7 +188,7 @@ export class WebGPUPathTracer {
188
188
  set maxTransparentBounces( v ) {
189
189
 
190
190
  this._pathTracer.maxTransparentBounces = v;
191
- this._pathTracer.reset();
191
+ this.reset();
192
192
 
193
193
  }
194
194
 
@@ -1275,14 +1275,20 @@ export class WebGPUPathTracer {
1275
1275
  this._bvhData.textureAtlas.dispose();
1276
1276
  this._environmentCache.dispose();
1277
1277
  this._backgroundCache.dispose();
1278
- this._blitQuad.dispose();
1279
1278
  this._lowResTarget.dispose();
1279
+
1280
+ // FullScreenQuad.dispose only disposes the shared geometry, not the material
1281
+ this._blitQuad.dispose();
1282
+ this._blitQuad.material.dispose();
1280
1283
  this._atlasDebugQuad?.dispose();
1284
+ this._atlasDebugQuad?.material.dispose();
1281
1285
  this._sampleDensityQuad?.dispose();
1286
+ this._sampleDensityQuad?.material.dispose();
1282
1287
 
1283
1288
  if ( this._debugBoundsQuad !== undefined ) {
1284
1289
 
1285
1290
  this._debugBoundsQuad.dispose();
1291
+ this._debugBoundsQuad.material.dispose();
1286
1292
 
1287
1293
  }
1288
1294
 
@@ -73,11 +73,21 @@ export class ComputeKernel {
73
73
 
74
74
  // forward the call parameters so they remain accessible via "computeNode.parameters"
75
75
  node.parameters = this._fn.parameters;
76
+
77
+ // release the renderer's pipeline and bindings for the kernel being replaced
78
+ this.kernel?.dispose();
76
79
  this.kernel = node.computeKernel( [ x, y, z ] );
77
80
  return this;
78
81
 
79
82
  }
80
83
 
84
+ dispose() {
85
+
86
+ this.kernel?.dispose();
87
+ this.kernel = null;
88
+
89
+ }
90
+
81
91
  getDispatchSize( tx = 1, ty = 1, tz = 1, target = [] ) {
82
92
 
83
93
  const [ wgx, wgy, wgz ] = this.workgroupSize;
@@ -52,6 +52,7 @@ export class PathTracerMegaKernel extends ComputeKernel {
52
52
 
53
53
  const raycastOutput = proxy( 'bvhData.value.fns.raycastFirstHit.outputType', params );
54
54
  const raycastFirstHitFn = proxyFn( 'bvhData.value.fns.raycastFirstHit', params );
55
+ const raycastShadowFn = proxyFn( 'bvhData.value.fns.raycastShadow', params );
55
56
  const sampleTrianglePointFn = proxyFn( 'bvhData.value.fns.sampleTrianglePoint', params );
56
57
  const getSurfaceRecordFn = proxyFn( 'bvhData.value.fns.getSurfaceRecord', params );
57
58
  const getCameraRayFn = proxyFn( 'bvhData.value.fns.getCameraRay', params );
@@ -64,12 +65,17 @@ export class PathTracerMegaKernel extends ComputeKernel {
64
65
  const sampleEnvColor = proxy( 'envInfo.value.sampleColor', params );
65
66
  const sampleEnvDir = proxy( 'envInfo.value.sampleDir', params );
66
67
  const getEnvDirPdf = proxy( 'envInfo.value.getDirPdf', params );
68
+ const getEnvWeight = proxy( 'envInfo.value.getWeight', params );
67
69
  const sampleBackground = proxy( 'backgroundInfo.value.sampleColor', params );
68
70
 
69
71
  // analytic scene lights pulled off the lightsInfo provider ( LightsInfoNode )
70
72
  const lightsCountNode = proxy( 'lightsInfo.value.countNode', params );
71
73
  const randomLightSampleFn = proxyFn( 'lightsInfo.value.randomLightSample', params );
72
74
  const intersectLightAtIndexFn = proxyFn( 'lightsInfo.value.intersectLightAtIndex', params );
75
+ const getLightWeightFn = proxyFn( 'lightsInfo.value.getLightWeight', params );
76
+ const getLightsWeightFn = proxyFn( 'lightsInfo.value.getLightsWeight', params );
77
+ const selectLightFn = proxyFn( 'lightsInfo.value.selectLight', params );
78
+ const isLightVisibleToCameraFn = proxyFn( 'lightsInfo.value.isLightVisibleToCamera', params );
73
79
 
74
80
  const shader = wgslTagFn/* wgsl */`
75
81
 
@@ -146,45 +152,58 @@ export class PathTracerMegaKernel extends ComputeKernel {
146
152
  // A negative value marks a sampled wavelength whose RGB reconstruction weight has not been applied yet.
147
153
  var dispersionWavelength = - mix( ${ DISPERSION_MIN_WAVELENGTH }.0, ${ DISPERSION_MAX_WAVELENGTH }.0, ${ rand1 }( ${ RNG_INDEX_DISPERSION_WAVELENGTH } ) );
148
154
 
149
- // one-sample NEE selects between the analytic lights and the environment -
150
- // lightsDenom is the number of options
155
+ // one-sample NEE chooses between the analytic lights and the environment
151
156
  let envActive = ${ envTotalSumNode } > 0.0 && ${ envIntensityNode } > 0.0;
152
157
  let lightsCount = ${ lightsCountNode };
153
- var lightsDenom = f32( lightsCount );
158
+ var envWeight = 0.0;
154
159
  if ( envActive ) {
155
160
 
156
- lightsDenom += 1.0;
161
+ envWeight = ${ getEnvWeight }();
157
162
 
158
163
  }
159
164
 
165
+ // added last, since a camera ray miss overwrites the pixel with the background
166
+ var lightHits = vec3f( 0.0 );
167
+ var cameraHitLight = false;
168
+
160
169
  for ( var bounce = 0u; bounce < maxBounces; bounce ++ ) {
161
170
 
162
171
  var hitResult: ${ raycastOutput };
163
172
  let didHit = ${ raycastFirstHitFn }( ray, &hitResult );
164
173
  let surfaceDist = select( ${ LIGHT_FAR_DISTANCE }, hitResult.dist, didHit );
165
174
 
166
- // forward hits: a bsdf-sampled ray that lands on a area light. MIS-weighted
167
- // only when NEE is also sampling the lights
168
- if ( bounce > 0u ) {
175
+ // NEE's total weight at the vertex this ray left, for MIS. Camera rays have none
176
+ let isMISWeighted = misEnabled != 0u && bounce > 0u;
177
+ var lightTotalWeight = 0.0;
178
+ if ( isMISWeighted ) {
169
179
 
170
- for ( var li = 0u; li < lightsCount; li ++ ) {
180
+ lightTotalWeight = ${ getLightsWeightFn }( ray.origin ) + envWeight;
171
181
 
172
- var lightRec: ${ lightRecordStruct };
173
- if ( ${ intersectLightAtIndexFn }( ray.origin, ray.direction, li, &lightRec ) && lightRec.dist < surfaceDist ) {
182
+ }
174
183
 
175
- var misWeight = 1.0;
176
- if ( misEnabled != 0u ) {
184
+ // forward hits on area lights. Camera rays only see visibleToCameraRays lights, at full weight
185
+ for ( var li = 0u; li < lightsCount; li ++ ) {
177
186
 
178
- let lightPdf = lightRec.pdf / lightsDenom;
179
- misWeight = ${ misHeuristicFn }( bsdfPdf, lightPdf );
187
+ if ( bounce == 0u && ! ${ isLightVisibleToCameraFn }( li ) ) {
180
188
 
181
- }
189
+ continue;
190
+
191
+ }
182
192
 
183
- let lightHit = ${ clampPathContributionFunc }( lightRec.emission * throughputColor * misWeight, bounce, clampDirect, clampIndirect );
184
- resultColor += vec4f( lightHit, 0.0 );
193
+ var lightRec: ${ lightRecordStruct };
194
+ if ( ${ intersectLightAtIndexFn }( ray.origin, ray.direction, li, &lightRec ) && lightRec.dist < surfaceDist ) {
195
+
196
+ var misWeight = 1.0;
197
+ if ( isMISWeighted ) {
198
+
199
+ let selectionPdf = ${ getLightWeightFn }( li, ray.origin ) / lightTotalWeight;
200
+ misWeight = ${ misHeuristicFn }( bsdfPdf, lightRec.pdf * selectionPdf );
185
201
 
186
202
  }
187
203
 
204
+ lightHits += ${ clampPathContributionFunc }( lightRec.emission * throughputColor * misWeight, bounce, clampDirect, clampIndirect );
205
+ cameraHitLight = cameraHitLight || bounce == 0u;
206
+
188
207
  }
189
208
 
190
209
  }
@@ -263,30 +282,31 @@ export class PathTracerMegaKernel extends ComputeKernel {
263
282
  let emission = ${ clampPathContributionFunc }( throughputColor * surface.emission, bounce, clampDirect, clampIndirect );
264
283
  resultColor += vec4f( emission, 0.0 );
265
284
 
266
- // next event estimation: draw one light or the environment, each with
267
- // probability 1 / lightsDenom
268
- if ( misEnabled != 0u && lightsDenom > 0.0 ) {
285
+ // next event estimation: choose one light or the environment by its estimated
286
+ // contribution
287
+ if ( misEnabled != 0u ) {
269
288
 
270
- // pick one light or the environment with a single sample
271
- // TODO: importance-sample the selection by light intensity and solid angle
272
289
  let ruv = ${ rand3 }( ${ RNG_INDEX_DIRECT_LIGHT_SAMPLE } );
273
- let lightIndex = min( u32( ruv.x * lightsDenom ), u32( lightsDenom ) - 1u );
274
- var lightRec: ${ lightRecordStruct };
275
- if ( envActive && lightIndex == lightsCount ) {
276
-
277
- // the environment, sampled from its CDF, as a light of kind ENVIRONMENT
278
- let envSample = ${ sampleEnvDir }( ruv.yz );
279
- lightRec.direction = envSample.direction;
280
- lightRec.emission = envSample.color;
281
- lightRec.pdf = envSample.pdf;
282
- lightRec.dist = ${ LIGHT_FAR_DISTANCE };
283
- lightRec.lightType = ${ ENVIRONMENT_LIGHT_TYPE };
290
+ var selectionPdf = 0.0;
291
+ let lightIndex = ${ selectLightFn }( vertexData.position.xyz, envWeight, ruv.x, &selectionPdf );
292
+ if ( selectionPdf > 0.0 ) {
284
293
 
285
- } else {
294
+ var lightRec: ${ lightRecordStruct };
295
+ if ( envActive && lightIndex == lightsCount ) {
286
296
 
287
- lightRec = ${ randomLightSampleFn }( lightIndex, vertexData.position.xyz, ruv.yz );
297
+ // the environment, sampled from its CDF, as a light of kind ENVIRONMENT
298
+ let envSample = ${ sampleEnvDir }( ruv.yz );
299
+ lightRec.direction = envSample.direction;
300
+ lightRec.emission = envSample.color;
301
+ lightRec.pdf = envSample.pdf;
302
+ lightRec.dist = ${ LIGHT_FAR_DISTANCE };
303
+ lightRec.lightType = ${ ENVIRONMENT_LIGHT_TYPE };
288
304
 
289
- }
305
+ } else {
306
+
307
+ lightRec = ${ randomLightSampleFn }( lightIndex, vertexData.position.xyz, ruv.yz );
308
+
309
+ }
290
310
 
291
311
  if ( lightRec.pdf > 0.0 ) {
292
312
 
@@ -296,20 +316,21 @@ export class PathTracerMegaKernel extends ComputeKernel {
296
316
  var shadowRay: ${ rayStruct };
297
317
  shadowRay.origin = ${ offsetRayOriginFunc }( vertexData.position.xyz, lightRec.direction, hitResult.normal );
298
318
  shadowRay.direction = lightRec.direction;
299
- shadowRay.maxDist = lightRec.dist - ${ LIGHT_EPSILON };
319
+ shadowRay.maxDist = lightRec.dist * ( 1.0 - ${ LIGHT_EPSILON } );
300
320
 
301
321
  // opaque occlusion up to the light distance. A shadow-specific any hit
302
322
  // traversal could support tinted shadows from transmissive and partially
303
323
  // opaque objects
304
324
  var shadowHit: ${ raycastOutput };
305
- let occluded = ${ raycastFirstHitFn }( shadowRay, &shadowHit );
325
+ let occluded = ${ raycastShadowFn }( shadowRay, &shadowHit );
306
326
  if ( ! occluded ) {
307
327
 
308
- var lightPdf = lightRec.pdf;
309
- lightPdf /= lightsDenom;
328
+ let lightPdf = lightRec.pdf * selectionPdf;
310
329
 
311
- // env + area lights are also bsdf-sampled, so MIS-weight them - punctual lights take full weight
312
- let misWeight = select( 1.0, ${ misHeuristicFn }( lightPdf, evalRec.pdf ), ${ isMISWeightLightFn }( lightRec.lightType ) );
330
+ // env + area lights are also bsdf-sampled, so MIS-weight them - punctual lights take full weight,
331
+ // or when a bsdf ray could never reach the light
332
+ let isMISWeighted = ${ isMISWeightLightFn }( lightRec.lightType ) && ! shadowHit.forwardBlocked;
333
+ let misWeight = select( 1.0, ${ misHeuristicFn }( lightPdf, evalRec.pdf ), isMISWeighted );
313
334
  let directLight = throughputColor * lightRec.emission * evalRec.color * misWeight / lightPdf;
314
335
  let contribution = ${ clampPathContributionFunc }( directLight, bounce + 1u, clampDirect, clampIndirect );
315
336
  resultColor += vec4f( contribution, 0.0 );
@@ -320,6 +341,8 @@ export class PathTracerMegaKernel extends ComputeKernel {
320
341
 
321
342
  }
322
343
 
344
+ }
345
+
323
346
  }
324
347
 
325
348
  let scatterRec = ${ bsdfSampleFn }( view, surface );
@@ -370,10 +393,10 @@ export class PathTracerMegaKernel extends ComputeKernel {
370
393
  if ( bounce > 0u && ! isFullyTransmissive ) {
371
394
 
372
395
  var misWeight = 1.0;
373
- if ( misEnabled != 0u && envActive ) {
396
+ if ( isMISWeighted && envActive ) {
374
397
 
375
398
  // match the env pdf scaling used by the NEE selection so the two estimators balance
376
- let envPdf = ${ getEnvDirPdf }( ray.direction ) / lightsDenom;
399
+ let envPdf = ${ getEnvDirPdf }( ray.direction ) * envWeight / lightTotalWeight;
377
400
  misWeight = ${ misHeuristicFn }( bsdfPdf, envPdf );
378
401
 
379
402
  }
@@ -450,6 +473,14 @@ export class PathTracerMegaKernel extends ComputeKernel {
450
473
 
451
474
  }
452
475
 
476
+ // visible lights are opaque against a transparent background
477
+ resultColor += vec4f( lightHits, 0.0 );
478
+ if ( cameraHitLight ) {
479
+
480
+ resultColor.a = 1.0;
481
+
482
+ }
483
+
453
484
  let nextSampleCount = sampleCount + 1;
454
485
  // store the color rows top down to match a rasterized render target
455
486
  let colorIndex = vec2u( indexUV.x, targetDimensions.y - 1u - indexUV.y );