@driftengine/core 4.8.6 → 4.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.json +71 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.js +1 -1
  5. package/dist/render/anisotropicLobe.d.ts +10 -0
  6. package/dist/render/anisotropicLobe.js +19 -0
  7. package/dist/render/areaLights.d.ts +35 -0
  8. package/dist/render/areaLights.js +35 -0
  9. package/dist/render/backend/api.d.ts +33 -3
  10. package/dist/render/backend/webgl2/renderer.d.ts +50 -14
  11. package/dist/render/backend/webgl2/renderer.js +141 -30
  12. package/dist/render/backend/webgl2/sceneCaptureTarget.d.ts +29 -0
  13. package/dist/render/backend/webgl2/sceneCaptureTarget.js +96 -0
  14. package/dist/render/backend/webgpu/particlePass.js +2 -1
  15. package/dist/render/backend/webgpu/renderer.d.ts +35 -1
  16. package/dist/render/backend/webgpu/renderer.js +155 -16
  17. package/dist/render/backend/webgpu/sceneCaptureTarget.d.ts +24 -0
  18. package/dist/render/backend/webgpu/sceneCaptureTarget.js +59 -0
  19. package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
  20. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +4 -1
  21. package/dist/render/backend/webgpu/surfaceTexturePass.js +34 -8
  22. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  23. package/dist/render/clusteredLights.d.ts +23 -5
  24. package/dist/render/clusteredLights.js +35 -8
  25. package/dist/render/driftLight/bake.d.ts +2 -0
  26. package/dist/render/driftLight/bake.js +1 -1
  27. package/dist/render/driftLight/falloff.d.ts +4 -2
  28. package/dist/render/driftLight/falloff.js +8 -2
  29. package/dist/render/gi/bounceLights.d.ts +3 -2
  30. package/dist/render/gi/bounceLights.js +8 -3
  31. package/dist/render/instanceCull.js +5 -0
  32. package/dist/render/instances.d.ts +19 -5
  33. package/dist/render/instances.js +9 -3
  34. package/dist/render/lightBudget.d.ts +12 -1
  35. package/dist/render/lightBudget.js +23 -3
  36. package/dist/render/lightmap.d.ts +23 -9
  37. package/dist/render/lightmap.js +40 -18
  38. package/dist/render/particlePool.d.ts +21 -0
  39. package/dist/render/particlePool.js +7 -0
  40. package/dist/render/pointLightSelection.d.ts +13 -0
  41. package/dist/render/pointLightSelection.js +3 -1
  42. package/dist/render/renderQuality.d.ts +8 -7
  43. package/dist/render/rendererHarness.js +3 -0
  44. package/dist/render/rgb9e5.d.ts +22 -0
  45. package/dist/render/rgb9e5.js +47 -0
  46. package/dist/render/sceneCapture.d.ts +29 -0
  47. package/dist/render/sceneCapture.js +36 -0
  48. package/dist/render/screenSpaceReflection.d.ts +27 -1
  49. package/dist/render/screenSpaceReflection.js +9 -0
  50. package/dist/render/shaders/bolt.d.ts +1 -1
  51. package/dist/render/shaders/bolt.js +7 -1
  52. package/dist/render/shaders/depth.js +11 -0
  53. package/dist/render/shaders/flat/anisotropic.d.ts +1 -1
  54. package/dist/render/shaders/flat/anisotropic.js +37 -3
  55. package/dist/render/shaders/flat/areaLight.d.ts +1 -1
  56. package/dist/render/shaders/flat/areaLight.js +46 -0
  57. package/dist/render/shaders/flat/glassTint.d.ts +3 -2
  58. package/dist/render/shaders/flat/glassTint.js +3 -1
  59. package/dist/render/shaders/flat/index.d.ts +5 -0
  60. package/dist/render/shaders/flat/index.js +16 -2
  61. package/dist/render/shaders/flat/lobes.d.ts +6 -0
  62. package/dist/render/shaders/flat/lobes.js +49 -43
  63. package/dist/render/shaders/flat/main.d.ts +1 -1
  64. package/dist/render/shaders/flat/main.js +57 -22
  65. package/dist/render/shaders/flat/models.d.ts +5 -2
  66. package/dist/render/shaders/flat/models.js +100 -16
  67. package/dist/render/shaders/flat/preamble.js +10 -3
  68. package/dist/render/shaders/generated/bolt.wgsl.d.ts +1 -1
  69. package/dist/render/shaders/generated/bolt.wgsl.js +1 -1
  70. package/dist/render/shaders/generated/depth.wgsl.d.ts +4 -4
  71. package/dist/render/shaders/generated/depth.wgsl.js +4 -4
  72. package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -0
  73. package/dist/render/shaders/generated/flat.wgsl.js +54 -38
  74. package/dist/render/shaders/generated/ssrTrace.wgsl.d.ts +11 -6
  75. package/dist/render/shaders/generated/ssrTrace.wgsl.js +11 -6
  76. package/dist/render/shaders/generated/water.wgsl.d.ts +23 -16
  77. package/dist/render/shaders/generated/water.wgsl.js +23 -16
  78. package/dist/render/shaders/gi/probeBake.wgsl.js +9 -5
  79. package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
  80. package/dist/render/shaders/recon/motion.wgsl.js +13 -2
  81. package/dist/render/shaders/ssrTrace.js +20 -1
  82. package/dist/render/shaders/water.d.ts +1 -1
  83. package/dist/render/shaders/water.js +12 -3
  84. package/dist/render/skinScatter.d.ts +3 -1
  85. package/dist/render/skinScatter.js +3 -1
  86. package/dist/render/ssrPass.js +1 -0
  87. package/dist/render/surfaceModel.d.ts +29 -8
  88. package/dist/render/surfaceModel.js +33 -9
  89. package/dist/render/surfaceTexture.d.ts +25 -1
  90. package/dist/render/surfaceTexture.js +27 -5
  91. package/package.json +6 -6
  92. package/src/index.ts +6 -2
  93. package/src/render/anisotropicLobe.ts +29 -0
  94. package/src/render/areaLights.ts +58 -0
  95. package/src/render/backend/api.ts +49 -2
  96. package/src/render/backend/webgl2/renderer.ts +192 -38
  97. package/src/render/backend/webgl2/sceneCaptureTarget.ts +130 -0
  98. package/src/render/backend/webgpu/particlePass.ts +2 -1
  99. package/src/render/backend/webgpu/renderer.ts +194 -19
  100. package/src/render/backend/webgpu/sceneCaptureTarget.ts +63 -0
  101. package/src/render/backend/webgpu/surfaceTexturePass.ts +44 -14
  102. package/src/render/clusteredLights.ts +41 -8
  103. package/src/render/driftLight/bake.ts +3 -1
  104. package/src/render/driftLight/falloff.ts +13 -2
  105. package/src/render/gi/bounceLights.ts +12 -3
  106. package/src/render/instanceCull.ts +4 -0
  107. package/src/render/instances.ts +26 -6
  108. package/src/render/lightBudget.ts +40 -2
  109. package/src/render/lightmap.ts +64 -23
  110. package/src/render/particlePool.ts +23 -0
  111. package/src/render/pointLightSelection.ts +20 -1
  112. package/src/render/renderQuality.ts +8 -7
  113. package/src/render/rendererHarness.ts +3 -0
  114. package/src/render/rgb9e5.ts +52 -0
  115. package/src/render/sceneCapture.ts +56 -0
  116. package/src/render/screenSpaceReflection.ts +33 -1
  117. package/src/render/shaders/bolt.ts +7 -1
  118. package/src/render/shaders/depth.ts +11 -0
  119. package/src/render/shaders/flat/anisotropic.ts +37 -3
  120. package/src/render/shaders/flat/areaLight.ts +46 -0
  121. package/src/render/shaders/flat/glassTint.ts +9 -2
  122. package/src/render/shaders/flat/index.ts +21 -2
  123. package/src/render/shaders/flat/lobes.ts +50 -43
  124. package/src/render/shaders/flat/main.ts +57 -22
  125. package/src/render/shaders/flat/models.ts +100 -16
  126. package/src/render/shaders/flat/preamble.ts +10 -3
  127. package/src/render/shaders/generated/bolt.wgsl.ts +1 -1
  128. package/src/render/shaders/generated/depth.wgsl.ts +4 -4
  129. package/src/render/shaders/generated/flat.wgsl.ts +54 -38
  130. package/src/render/shaders/generated/ssrTrace.wgsl.ts +11 -6
  131. package/src/render/shaders/generated/water.wgsl.ts +23 -16
  132. package/src/render/shaders/gi/probeBake.wgsl.ts +9 -5
  133. package/src/render/shaders/recon/motion.wgsl.ts +13 -2
  134. package/src/render/shaders/ssrTrace.ts +20 -1
  135. package/src/render/shaders/water.ts +12 -3
  136. package/src/render/skinScatter.ts +3 -1
  137. package/src/render/ssrPass.ts +1 -0
  138. package/src/render/surfaceModel.ts +47 -10
  139. package/src/render/surfaceTexture.ts +69 -9
@@ -44,7 +44,7 @@ import { COOKIE_TILE } from '../../lightBudget.js';
44
44
  import { FULL_LIGHT_BUDGET } from '../../uniformVectorBudget.js';
45
45
  import { toHalfFloats } from '../../halfFloat.js';
46
46
  import { DEFAULT_SOURCE_RADIUS, FACE_COUNT, createResolvedPointShadows, } from '../../pointShadowImage.js';
47
- import { MAX_AREA_LIGHTS } from '../../areaLights.js';
47
+ import { MAX_AREA_LIGHTS, packAreaSizesAndDoors } from '../../areaLights.js';
48
48
  import { areaShadowLayerCount, castingRange, createResolvedAreaShadows, firstAreaShadowLayer, AreaShadowSet, } from '../../areaShadowSet.js';
49
49
  import { createBakeScratch, runPointShadowBakes, selectCastingLights, } from '../../pointShadowBudget.js';
50
50
  import { POINT_SHADOW_SAMPLER, PointShadowSystem, } from '../../pointShadowSystem.js';
@@ -54,9 +54,11 @@ import { createPanelBindGroup, createPanelBindGroupLayout, createPanelCorners, p
54
54
  import { createGpuTextCube, createTextBindGroup, createTextBindGroupLayout, GpuText, textPipeline, TEXT_FRAG_FIELDS, TEXT_FRAG_SIZE, TEXT_VERT_FIELDS, TEXT_VERT_SIZE, } from './textPass.js';
55
55
  import { deviceSnappedCellSize, deviceSnappedOrigin } from '../../textLayout.js';
56
56
  import { GpuSurfaceTexture } from './surfaceTexturePass.js';
57
+ import { GpuSceneCaptureTarget } from './sceneCaptureTarget.js';
58
+ import { sceneCaptureTexels } from '../../sceneCapture.js';
57
59
  import { gpuCompressedFormats } from './compressedUpload.js';
58
60
  import { GpuClothBinding, GpuClothParticles, clothStandIn } from './clothTextures.js';
59
- import { MODEL_PARAM_FLOATS, eyeAxisInWorld, packModel } from '../../surfaceModel.js';
61
+ import { MODEL_PARAM_FLOATS, eyeAxisInWorld, packModel, skinProfileDistance, } from '../../surfaceModel.js';
60
62
  import { validateClothBinding } from '../../clothBindingData.js';
61
63
  import { createSdfTextBindGroup, createSdfTextBindGroupLayout, GpuSdfText, sdfTextPipeline, SDF_TEXT_FRAG_FIELDS, SDF_TEXT_FRAG_SIZE, SDF_TEXT_VERT_FIELDS, SDF_TEXT_VERT_SIZE, } from './sdfTextPass.js';
62
64
  import { createFilmBindGroup, createFilmBindGroupLayout, filmPipeline, FILM_FRAG_FIELDS, FILM_FRAG_SIZE, FILM_VERT_FIELDS, FILM_VERT_SIZE, } from './filmPass.js';
@@ -2227,6 +2229,97 @@ export class WebGPURenderer {
2227
2229
  mipmap: false,
2228
2230
  });
2229
2231
  }
2232
+ /**
2233
+ * A texture the scene can be drawn into, for a material to show. See `RendererApi` and
2234
+ * `sceneCapture.ts`. At the world's own format, which under `hdrScene` is half floats.
2235
+ */
2236
+ createSceneCapture(width, height) {
2237
+ const texture = new GpuSurfaceTexture(this.surface.device, this.pipelines, sceneCaptureTexels(width, height, this.quality.hdrScene), { wrap: 'clamp', mipmap: false });
2238
+ this.captureTargets.set(texture, new GpuSceneCaptureTarget(this.surface.device, width, height));
2239
+ return texture;
2240
+ }
2241
+ /** Each capture's attachments, beside the texture a material binds. */
2242
+ captureTargets = new WeakMap();
2243
+ /**
2244
+ * Whether a scene capture is being drawn: an off-screen pass as a probe bake is, so the frame's
2245
+ * camera, jitter, motion, refraction copy, skin's spread and late routing are left alone, but
2246
+ * lit by the probes and DriftLight as the frame is. See `captureScene`.
2247
+ */
2248
+ capturePassActive = false;
2249
+ /**
2250
+ * Draw the scene into a capture from `camera`. See `RendererApi.captureScene`.
2251
+ *
2252
+ * **Its own encoder, as a probe bake's**: the frame's pass is set aside and restored, the rings
2253
+ * are marked and rewound around the submit, and the frame graph is flushed at both boundaries so
2254
+ * no recorded draw crosses one. **Radiance**: the output transform and exposure are held off.
2255
+ */
2256
+ captureScene(capture, camera, clearColor, draw) {
2257
+ if (this.surface.lost)
2258
+ return false;
2259
+ const target = this.captureTargets.get(capture);
2260
+ const resolve = capture.layerView();
2261
+ if (target === undefined || resolve === null)
2262
+ return false;
2263
+ camera.updateMatrices(target.width / target.height);
2264
+ const { device } = this.surface;
2265
+ const { depth, colorMsaa } = target.attachments(this.samples, this.pipelines.format);
2266
+ const savedPass = this.pass;
2267
+ if (this.quality.frameGraph)
2268
+ this.flushGraph();
2269
+ const savedEncoder = this.encoder;
2270
+ this.markRings();
2271
+ const skinMark = this.skinPalettes.mark();
2272
+ const frameEpoch = this.encoderEpoch;
2273
+ this.encoderEpoch = ++this.encoderEpochs;
2274
+ const encoder = device.createCommandEncoder({ label: 'capture' });
2275
+ this.encoder = encoder;
2276
+ this.capturePassActive = true;
2277
+ const heldGrade = this.perFrameInts[this.materialField('uOutputTransform')] ?? 0;
2278
+ const heldExposure = this.perFrameFloats[this.materialField('uOutputExposure')] ?? 1;
2279
+ this.perFrameInts[this.materialField('uOutputTransform')] = 0;
2280
+ this.perFrameFloats[this.materialField('uOutputExposure')] = 1;
2281
+ this.materials.dirty();
2282
+ const pass = encoder.beginRenderPass({
2283
+ label: 'capture',
2284
+ colorAttachments: [
2285
+ {
2286
+ view: colorMsaa ?? resolve,
2287
+ resolveTarget: colorMsaa === null ? undefined : resolve,
2288
+ loadOp: 'clear',
2289
+ storeOp: resolvedStoreOp(colorMsaa !== null, true, this.quality.discardResolvedAttachments),
2290
+ clearValue: { r: clearColor[0], g: clearColor[1], b: clearColor[2], a: 1 },
2291
+ },
2292
+ ],
2293
+ depthStencilAttachment: {
2294
+ view: depth,
2295
+ depthClearValue: DEPTH_CLEAR,
2296
+ depthLoadOp: 'clear',
2297
+ depthStoreOp: 'discard',
2298
+ },
2299
+ });
2300
+ this.pass = pass;
2301
+ try {
2302
+ draw(camera);
2303
+ /* The callback's draws replayed into this pass before it ends: see `bakeProbe`. */
2304
+ if (this.quality.frameGraph)
2305
+ this.flushGraph();
2306
+ }
2307
+ finally {
2308
+ pass.end();
2309
+ this.pass = null;
2310
+ this.flushRings();
2311
+ device.queue.submit([encoder.finish()]);
2312
+ this.rewindRings(skinMark);
2313
+ this.encoderEpoch = frameEpoch;
2314
+ this.encoder = savedEncoder;
2315
+ this.pass = savedPass;
2316
+ this.perFrameInts[this.materialField('uOutputTransform')] = heldGrade;
2317
+ this.perFrameFloats[this.materialField('uOutputExposure')] = heldExposure;
2318
+ this.materials.dirty();
2319
+ this.capturePassActive = false;
2320
+ }
2321
+ return true;
2322
+ }
2230
2323
  /**
2231
2324
  * GPU textures an `updateSurfaceTexture` replaced, destroyed when the next frame begins.
2232
2325
  *
@@ -2280,6 +2373,8 @@ export class WebGPURenderer {
2280
2373
  this.modelMap = null;
2281
2374
  this.forgetBindingsOf(texture);
2282
2375
  texture.dispose();
2376
+ this.captureTargets.get(texture)?.dispose();
2377
+ this.captureTargets.delete(texture);
2283
2378
  }
2284
2379
  /**
2285
2380
  * Drop every bind group that may hold `texture`'s view, and rebuild the ones bound now.
@@ -2458,7 +2553,7 @@ export class WebGPURenderer {
2458
2553
  const model = material?.model;
2459
2554
  warnUnpagedLightmap(model, modelMap);
2460
2555
  if (model?.kind === 'skin' && this.skinScatter !== null) {
2461
- this.skinScatter.setProfile(model.profile, model.scatterColor[0] * model.radius, model.scatterColor[1] * model.radius, model.scatterColor[2] * model.radius);
2556
+ this.skinScatter.setProfile(model.profile, skinProfileDistance(model, 0), skinProfileDistance(model, 1), skinProfileDistance(model, 2));
2462
2557
  }
2463
2558
  this.material((f, i) => {
2464
2559
  /* The material's four switches, one row: albedo, ORM map, emissive map, two-sided. */
@@ -2503,7 +2598,10 @@ export class WebGPURenderer {
2503
2598
  f[emissive + 1] = asked?.[1] ?? 1;
2504
2599
  f[emissive + 2] = asked?.[2] ?? 1;
2505
2600
  /* The model's numbers and whether its map is bound. See `packModel`. */
2506
- packModel(material?.model ?? null, modelMap !== null, this.modelParams);
2601
+ packModel(material?.model ?? null, modelMap !== null, this.modelParams, material?.physicalSpecular === true);
2602
+ /* The first physical highlight asked for is when the lit pipelines start carrying one. */
2603
+ if (material?.physicalSpecular === true)
2604
+ this.enableLit('PHYSICAL_SPECULAR');
2507
2605
  f.set(this.modelParams, this.materialField('uModelParams'));
2508
2606
  });
2509
2607
  /*
@@ -3321,6 +3419,30 @@ export class WebGPURenderer {
3321
3419
  return;
3322
3420
  const device = this.surface.device;
3323
3421
  device.queue.writeBuffer(batch.instances, 0, batch.staging, 0, live * PARTICLE_INSTANCE_FLOATS);
3422
+ this.drawParticleInstances(batch, batch.instances, live, camera, env, timeSeconds);
3423
+ }
3424
+ /**
3425
+ * Draw particles a caller's own compute wrote, where they lie. See `DeviceParticles`.
3426
+ *
3427
+ * **The pool's draw with its instance stream swapped**: the same material, blend, flipbook, soft
3428
+ * edge and fog, reading the caller's buffer in place of the one the pool uploads into — so nothing
3429
+ * crosses back to the CPU. Not sorted, and a count past what the buffer holds is cut to it rather
3430
+ * than handed to a draw the device would refuse with the frame.
3431
+ */
3432
+ drawDeviceParticles(batch, particles, camera, env, timeSeconds) {
3433
+ const fits = Math.floor(particles.buffer.size / (PARTICLE_INSTANCE_FLOATS * 4));
3434
+ const live = Math.min(Math.max(0, Math.floor(particles.count)), fits);
3435
+ /* Not above zero covers a NaN as well: a count or a size that is no number draws nothing. */
3436
+ if (!this.canDraw() || !(live > 0))
3437
+ return;
3438
+ if (batch.drawnInEncoder === this.encoderEpoch)
3439
+ return;
3440
+ batch.drawnInEncoder = this.encoderEpoch;
3441
+ this.drawParticleInstances(batch, particles.buffer, live, camera, env, timeSeconds);
3442
+ }
3443
+ /** A pool's uniforms for this view, and its draw from `instances`: both entry points' tail. */
3444
+ drawParticleInstances(batch, instances, live, camera, env, timeSeconds) {
3445
+ const device = this.surface.device;
3324
3446
  const v = batch.vertFloats;
3325
3447
  const atV = (name) => (PARTICLE_VERT_FIELDS[name]?.offset ?? -4) / 4;
3326
3448
  mat4.multiply(this.correctedViewProj, CLIP_CORRECTION, camera.viewProjection);
@@ -3392,7 +3514,7 @@ export class WebGPURenderer {
3392
3514
  particleCommand.pipeline = particlePipe;
3393
3515
  particleCommand.bindGroup = group;
3394
3516
  particleCommand.vertexBuffers[0] = batch.corners;
3395
- particleCommand.vertexBuffers[1] = batch.instances;
3517
+ particleCommand.vertexBuffers[1] = instances;
3396
3518
  particleCommand.vertexCount = 2;
3397
3519
  particleCommand.indexBuffer = batch.indexBuffer;
3398
3520
  particleCommand.indexed = true;
@@ -3406,7 +3528,7 @@ export class WebGPURenderer {
3406
3528
  pass.setPipeline(particlePipe);
3407
3529
  pass.setBindGroup(0, group);
3408
3530
  pass.setVertexBuffer(0, batch.corners);
3409
- pass.setVertexBuffer(1, batch.instances);
3531
+ pass.setVertexBuffer(1, instances);
3410
3532
  pass.setIndexBuffer(batch.indexBuffer, 'uint32');
3411
3533
  pass.drawIndexed(PARTICLE_INDICES, live);
3412
3534
  }
@@ -3424,7 +3546,10 @@ export class WebGPURenderer {
3424
3546
  const fields = batch.fields;
3425
3547
  const atF = (name) => (fields[name]?.offset ?? -4) / 4;
3426
3548
  let depth = null;
3427
- if (batch.softDepth > 0 && !this.reflectionPassActive && !this.probePassActive) {
3549
+ if (batch.softDepth > 0 &&
3550
+ !this.reflectionPassActive &&
3551
+ !this.probePassActive &&
3552
+ !this.capturePassActive) {
3428
3553
  if (this.openPass() !== null && this.takeVolumeDepth())
3429
3554
  depth = this.resolvedDepthView;
3430
3555
  if (depth === null && !this.warnedSpriteDepth) {
@@ -3717,6 +3842,7 @@ export class WebGPURenderer {
3717
3842
  scatterInto(f, WATER_FRAG_FIELDS['uLightRadius'], lights.radii, 1);
3718
3843
  scatterInto(f, WATER_FRAG_FIELDS['uLightSourceRadius'], lights.sourceRadii, 1);
3719
3844
  scatterInto(f, WATER_FRAG_FIELDS['uLightWeight'], lights.weights, 1);
3845
+ scatterInto(f, WATER_FRAG_FIELDS['uLightCone'], lights.conesAndFalloffs, 4);
3720
3846
  const medium = resolveAtmosphere(env, camera.position[1] ?? 0, this.quality.underwaterAtmosphere, this.medium);
3721
3847
  f.set(medium.fogColor, atF('uFogColor'));
3722
3848
  f[atF('uFogDensity')] = medium.fogDensity;
@@ -4509,8 +4635,11 @@ export class WebGPURenderer {
4509
4635
  cookies: new Float32Array(0),
4510
4636
  heldSourceRadii: new Float32Array(MAX_POINT_LIGHTS),
4511
4637
  axesAndChannels: new Float32Array(MAX_POINT_LIGHTS * 4),
4638
+ conesAndFalloffs: new Float32Array(MAX_POINT_LIGHTS * 4),
4512
4639
  heldWeights: new Float32Array(MAX_POINT_LIGHTS),
4513
4640
  };
4641
+ /** The fixed arm's rectangles as `uAreaLightSize` holds them. See `packAreaSizesAndDoors`. */
4642
+ areaSizesAndDoors = new Float32Array(MAX_AREA_LIGHTS * 4);
4514
4643
  /** Refilled per frame rather than allocated; see `resolveAtmosphere`. */
4515
4644
  medium = {
4516
4645
  fogColor: new Float32Array(3),
@@ -6566,7 +6695,7 @@ export class WebGPURenderer {
6566
6695
  * the viewer sees, and bounding it through a mirrored camera puts it in the wrong half of the
6567
6696
  * screen.
6568
6697
  */
6569
- if (!this.reflectionPassActive) {
6698
+ if (!this.reflectionPassActive && !this.capturePassActive) {
6570
6699
  mat4.copy(this.frameRawViewProj, camera.viewProjection);
6571
6700
  /* The eye in world space, which the resolve's normals are turned to face. */
6572
6701
  this.reconEye.set(camera.position);
@@ -6600,7 +6729,8 @@ export class WebGPURenderer {
6600
6729
  * second temporal frame stepped the sequence twice a frame, so the world saw every other
6601
6730
  * position and the anti-flicker's period never started.
6602
6731
  */
6603
- if (this.framePresented)
6732
+ /* A capture has no resolve to undo a jitter and is no frame of the sequence. */
6733
+ if (this.framePresented || this.capturePassActive)
6604
6734
  this.viewProj = this.correctedViewProj;
6605
6735
  else
6606
6736
  this.jitterFrame();
@@ -6928,7 +7058,8 @@ export class WebGPURenderer {
6928
7058
  * uniform added on one backend is bound on both in the same change.
6929
7059
  */
6930
7060
  scatterInto(f, this.fragment.fields['uLightDir'], lights.directions, 3);
6931
- scatterInto(f, this.fragment.fields['uLightCone'], lights.coneCos, 2);
7061
+ /* The cone and, in z, the light's own falloff exponent. See `conesAndFalloffs`. */
7062
+ scatterInto(f, this.fragment.fields['uLightCone'], lights.conesAndFalloffs, 4);
6932
7063
  scatterInto(f, this.fragment.fields['uLightIesProfile'], lights.iesProfiles, 1);
6933
7064
  /* The azimuth and, in w, the light's channels. See `axesAndChannels`. */
6934
7065
  scatterInto(f, this.fragment.fields['uLightIesAxis'], lights.axesAndChannels, 4);
@@ -6949,7 +7080,9 @@ export class WebGPURenderer {
6949
7080
  scatterInto(f, this.fragment.fields['uAreaLightColor'], area.colors, 3);
6950
7081
  scatterInto(f, this.fragment.fields['uAreaLightRight'], area.right, 3);
6951
7082
  scatterInto(f, this.fragment.fields['uAreaLightUp'], area.up, 3);
6952
- scatterInto(f, this.fragment.fields['uAreaLightSize'], area.sizes, 2);
7083
+ /* The half extents and, in zw, the barn doors. See `packAreaSizesAndDoors`. */
7084
+ packAreaSizesAndDoors(area, this.areaSizesAndDoors);
7085
+ scatterInto(f, this.fragment.fields['uAreaLightSize'], this.areaSizesAndDoors, 4);
6953
7086
  scatterInto(f, this.fragment.fields['uAreaLightTwoSided'], area.twoSided, 1);
6954
7087
  }
6955
7088
  /*
@@ -9631,7 +9764,7 @@ export class WebGPURenderer {
9631
9764
  drawsLate() {
9632
9765
  return (this.latePass !== null &&
9633
9766
  this.oitEncoder === null &&
9634
- routesLate(this.reconstructing, this.reflectionPassActive, this.probePassActive, this.framePresented));
9767
+ routesLate(this.reconstructing, this.reflectionPassActive, this.probePassActive || this.capturePassActive, this.framePresented));
9635
9768
  }
9636
9769
  /**
9637
9770
  * The command a blended draw records into: the late pool while reconstructing, the frame graph
@@ -9693,7 +9826,7 @@ export class WebGPURenderer {
9693
9826
  }
9694
9827
  /** Whether this draw lands in the reconstructed picture. See `lateRouting.ts`. */
9695
9828
  tracksMotionNow() {
9696
- return tracksMotion(this.reconstructing, this.reflectionPassActive, this.probePassActive, this.framePresented);
9829
+ return tracksMotion(this.reconstructing, this.reflectionPassActive, this.probePassActive || this.capturePassActive, this.framePresented);
9697
9830
  }
9698
9831
  /**
9699
9832
  * What this draw's motion is, recorded for the motion pass.
@@ -10094,6 +10227,8 @@ export class WebGPURenderer {
10094
10227
  f.set(surface.axis, base + at('uSsrAxis'));
10095
10228
  f.set(surface.tint, base + at('uSsrTint'));
10096
10229
  f[base + at('uSsrStrength')] = surface.strength;
10230
+ f[base + at('uSsrFresnel')] = surface.fresnel ? 1 : 0;
10231
+ f[base + at('uSsrFresnel') + 1] = surface.roughness;
10097
10232
  f[base + at('uSsrFacingCos')] = surface.facingCos;
10098
10233
  f[base + at('uSsrReach')] = surface.reachM;
10099
10234
  f[base + at('uSsrThickness')] = surface.thicknessM;
@@ -10654,7 +10789,7 @@ export class WebGPURenderer {
10654
10789
  * glass, and a frame that refracts nothing breaks no pass at all.
10655
10790
  */
10656
10791
  takeRefractSnapshot() {
10657
- if (this.reflectionPassActive || this.probePassActive)
10792
+ if (this.reflectionPassActive || this.probePassActive || this.capturePassActive)
10658
10793
  return false;
10659
10794
  if (this.refractSnapshotTaken)
10660
10795
  return true;
@@ -10737,6 +10872,7 @@ export class WebGPURenderer {
10737
10872
  !this.skinScattered &&
10738
10873
  !this.reflectionPassActive &&
10739
10874
  !this.probePassActive &&
10875
+ !this.capturePassActive &&
10740
10876
  !this.framePresented &&
10741
10877
  !this.overlayActive &&
10742
10878
  this.oitMode === 'none');
@@ -10794,6 +10930,8 @@ export class WebGPURenderer {
10794
10930
  return;
10795
10931
  if (this.reflectionPassActive || this.probePassActive || this.oitReplaying)
10796
10932
  return;
10933
+ if (this.capturePassActive)
10934
+ return;
10797
10935
  const encoder = this.encoder;
10798
10936
  const depth = this.depthView;
10799
10937
  const resolved = this.resolvedDepthView;
@@ -10851,7 +10989,7 @@ export class WebGPURenderer {
10851
10989
  * own terms: `resolvedDepthView` holds the *frame's* depth, and clamping a mirrored beam
10852
10990
  * against it would stop it at geometry that is not in front of it.
10853
10991
  */
10854
- if (this.reflectionPassActive)
10992
+ if (this.reflectionPassActive || this.capturePassActive)
10855
10993
  return false;
10856
10994
  if (this.resolvedDepthView === null)
10857
10995
  return false;
@@ -11408,8 +11546,9 @@ export class WebGPURenderer {
11408
11546
  * any future branch added to one belongs in the other.
11409
11547
  */
11410
11548
  targetPipelines() {
11411
- if (this.probePassActive || this.reflectionPassActive)
11549
+ if (this.probePassActive || this.reflectionPassActive || this.capturePassActive) {
11412
11550
  return this.pipelines;
11551
+ }
11413
11552
  return this.overlayActive || this.framePresented ? this.overlayPipelines : this.pipelines;
11414
11553
  }
11415
11554
  /** End and submit a reopened pass. Safe to call when there is none. */
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A scene capture's attachments on WebGPU: the depth and, above one sample, the multisampled colour
3
+ * its pass draws into and resolves from — the capture's own texture being the resolve, since **a
4
+ * texture cannot be both multisampled and sampled**. At the world's sample count and format, because
5
+ * a capture draws the world with the world's pipelines: a pipeline whose count or format disagrees
6
+ * with its attachment is rejected at `finish` and takes the whole capture with it. Remade only if
7
+ * either has changed since. See `sceneCapture.ts` for what a capture is.
8
+ */
9
+ export declare class GpuSceneCaptureTarget {
10
+ private readonly device;
11
+ readonly width: number;
12
+ readonly height: number;
13
+ private depth;
14
+ private depthView;
15
+ private colorMsaa;
16
+ private colorMsaaView;
17
+ private key;
18
+ constructor(device: GPUDevice, width: number, height: number);
19
+ attachments(samples: number, format: GPUTextureFormat): {
20
+ depth: GPUTextureView;
21
+ colorMsaa: GPUTextureView | null;
22
+ };
23
+ dispose(): void;
24
+ }
@@ -0,0 +1,59 @@
1
+ import { DEPTH_FORMAT } from '../../depthConvention.js';
2
+ /**
3
+ * A scene capture's attachments on WebGPU: the depth and, above one sample, the multisampled colour
4
+ * its pass draws into and resolves from — the capture's own texture being the resolve, since **a
5
+ * texture cannot be both multisampled and sampled**. At the world's sample count and format, because
6
+ * a capture draws the world with the world's pipelines: a pipeline whose count or format disagrees
7
+ * with its attachment is rejected at `finish` and takes the whole capture with it. Remade only if
8
+ * either has changed since. See `sceneCapture.ts` for what a capture is.
9
+ */
10
+ export class GpuSceneCaptureTarget {
11
+ device;
12
+ width;
13
+ height;
14
+ depth = null;
15
+ depthView = null;
16
+ colorMsaa = null;
17
+ colorMsaaView = null;
18
+ key = '';
19
+ constructor(device, width, height) {
20
+ this.device = device;
21
+ this.width = width;
22
+ this.height = height;
23
+ }
24
+ attachments(samples, format) {
25
+ const key = `${samples}:${format}`;
26
+ if (this.depthView === null || key !== this.key) {
27
+ this.dispose();
28
+ this.key = key;
29
+ this.depth = this.device.createTexture({
30
+ label: 'capture.depth',
31
+ size: [this.width, this.height],
32
+ format: DEPTH_FORMAT,
33
+ sampleCount: samples,
34
+ usage: 0x10, // RENDER_ATTACHMENT
35
+ });
36
+ this.depthView = this.depth.createView();
37
+ this.colorMsaa =
38
+ samples > 1
39
+ ? this.device.createTexture({
40
+ label: 'capture.colorMsaa',
41
+ size: [this.width, this.height],
42
+ format,
43
+ sampleCount: samples,
44
+ usage: 0x10, // RENDER_ATTACHMENT
45
+ })
46
+ : null;
47
+ this.colorMsaaView = this.colorMsaa?.createView() ?? null;
48
+ }
49
+ return { depth: this.depthView, colorMsaa: this.colorMsaaView };
50
+ }
51
+ dispose() {
52
+ this.depth?.destroy();
53
+ this.colorMsaa?.destroy();
54
+ this.depth = null;
55
+ this.depthView = null;
56
+ this.colorMsaa = null;
57
+ this.colorMsaaView = null;
58
+ }
59
+ }
@@ -20,4 +20,4 @@
20
20
  * sort. `scripts/cluster-check.mjs` is what holds that, and the 2026-08-17 rule about two
21
21
  * implementations of one decision is why it exists at all.
22
22
  */
23
- export declare const CLUSTER_BIN_WGSL = "\nconst CLUSTER_X: u32 = 16u;\nconst CLUSTER_Y: u32 = 9u;\nconst CLUSTER_Z: u32 = 24u;\nconst CLUSTER_COUNT: u32 = 3456u;\nconst CLUSTER_TEXELS: u32 = 20u;\nconst MAX_LIGHTS_PER_CLUSTER: u32 = 76u;\nconst LIGHT_TEXELS: u32 = 5u;\nconst LIGHT_REGION_TEXELS: u32 = 1600u;\nconst TABLE_WIDTH: u32 = 320u;\n\nstruct Params {\n view: mat4x4<f32>,\n /** near, far, tan(fovY/2), aspect. */\n frustum: vec4<f32>,\n lightCount: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n}\n\n@group(0) @binding(0) var<uniform> params: Params;\n/**\n * The records as `writeLightRecord` lays them out, as bits. **`u32`, not `f32`**, because one\n * slot holds two halves, and a zero weight read as a float would be a subnormal a GPU is free to\n * flush on the way through; read as bits, the copy into the table cannot change them.\n */\n@group(0) @binding(1) var<storage, read> lights: array<vec4<u32>>;\n/**\n * Write-only, which is all core WGSL offers and all the fixed-slot layout needs.\n *\n * A read-modify-write list would need a storage buffer and an atomic counter, and it is the reason\n * a cluster owns a fixed run rather than appending into a shared one.\n */\n@group(0) @binding(2) var table: texture_storage_2d<rgba32uint, write>;\n\n/** A flat texel index to its place in the table. TABLE_WIDTH divides both regions exactly. */\nfn texelAt(texel: u32) -> vec2<i32> {\n return vec2<i32>(i32(texel % TABLE_WIDTH), i32(texel / TABLE_WIDTH));\n}\n\nfn sliceNearDepth(slice: f32) -> f32 {\n return params.frustum.x * pow(params.frustum.y / params.frustum.x, slice / f32(CLUSTER_Z));\n}\n\n/** Squared distance from a point to an axis-aligned box, zero inside it. */\nfn distanceSqToBounds(p: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> f32 {\n let d = max(max(lo - p, p - hi), vec3<f32>(0.0));\n return dot(d, d);\n}\n\n/** A light's view-space centre, with depth positive along the view direction. */\nfn viewOf(light: u32) -> vec3<f32> {\n let world = bitcast<vec4<f32>>(lights[light * LIGHT_TEXELS]);\n let v = params.view * vec4<f32>(world.xyz, 1.0);\n return vec3<f32>(v.x, v.y, -v.z);\n}\n\n/**\n * Squared distance from a light's view-space centre to a point.\n *\n * **Squared, matching `centreDistanceSq` in `clusteredLights.ts` rather than taking a root.** The\n * ranking is identical either way, and a square root is one more operation for the two\n * implementations to round differently.\n */\nfn centreDistanceSq(light: u32, centre: vec3<f32>) -> f32 {\n let d = viewOf(light) - centre;\n return dot(d, d);\n}\n\n/**\n * Whether a spot's cone misses this froxel: `coneBoxGap` in `clusteredLights.ts`, the same\n * arithmetic in the same order \u2014 the froxel's bounding sphere against the infinite cone, its apex\n * included, which can keep a light the shader would not draw and never drop one it would. A light\n * with no cone carries a cosine of -2 and never reaches the test.\n */\nfn outsideCone(light: u32, apex: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> bool {\n let cosOuter = bitcast<f32>(lights[light * LIGHT_TEXELS + 3u].y);\n if (cosOuter <= -1.0) {\n return false;\n }\n let world = bitcast<vec3<f32>>(lights[light * LIGHT_TEXELS + 2u].yzw);\n let a = (params.view * vec4<f32>(world, 0.0)).xyz;\n let axis = vec3<f32>(a.x, a.y, -a.z);\n let len = length(axis);\n /* The shader measures the cone against the direction as given, so a long one is a wider cone. */\n if (len <= 1e-6 || cosOuter / len <= -1.0) {\n return false;\n }\n let cosA = cosOuter / len;\n let sinA = sqrt(max(0.0, 1.0 - cosA * cosA));\n let unit = axis / len;\n let extent = (hi - lo) * 0.5;\n let toCentre = (lo + hi) * 0.5 - apex;\n let along = dot(toCentre, unit);\n let lengthSq = dot(toCentre, toCentre);\n let across = sqrt(max(lengthSq - along * along, 0.0));\n let reach = sqrt(lengthSq);\n var distance = cosA * across - along * sinA;\n if (along < -sinA * reach) {\n distance = reach;\n }\n return distance - length(extent) > 0.0;\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let id = gid.x;\n\n /*\n * The light records, written by the first invocations that have one.\n *\n * **Two jobs in one kernel, and it is deliberate.** The records have to reach the same texture\n * the clusters do, because the lit pass reads both through one texture unit; writing them from\n * the CPU instead would mean a partial texture upload racing a dispatch over the same resource.\n * There are always more clusters than lights, so every record has an invocation.\n */\n if (id < params.lightCount) {\n let record = id * LIGHT_TEXELS;\n for (var t: u32 = 0u; t < LIGHT_TEXELS; t = t + 1u) {\n textureStore(table, texelAt(record + t), lights[record + t]);\n }\n }\n\n if (id >= CLUSTER_COUNT) {\n return;\n }\n\n let k = id / (CLUSTER_X * CLUSTER_Y);\n let j = (id - k * CLUSTER_X * CLUSTER_Y) / CLUSTER_X;\n let i = id - k * CLUSTER_X * CLUSTER_Y - j * CLUSTER_X;\n\n /* The AABB of the froxel's eight corners, exactly as `clusterViewBounds` derives it. */\n let zNear = sliceNearDepth(f32(k));\n let zFar = sliceNearDepth(f32(k + 1u));\n let xLo = -1.0 + 2.0 * f32(i) / f32(CLUSTER_X);\n let xHi = -1.0 + 2.0 * f32(i + 1u) / f32(CLUSTER_X);\n let yLo = -1.0 + 2.0 * f32(j) / f32(CLUSTER_Y);\n let yHi = -1.0 + 2.0 * f32(j + 1u) / f32(CLUSTER_Y);\n let halfHNear = zNear * params.frustum.z;\n let halfHFar = zFar * params.frustum.z;\n let halfWNear = halfHNear * params.frustum.w;\n let halfWFar = halfHFar * params.frustum.w;\n\n let lo = vec3<f32>(\n min(min(xLo * halfWNear, xLo * halfWFar), min(xHi * halfWNear, xHi * halfWFar)),\n min(min(yLo * halfHNear, yLo * halfHFar), min(yHi * halfHNear, yHi * halfHFar)),\n zNear,\n );\n let hi = vec3<f32>(\n max(max(xLo * halfWNear, xLo * halfWFar), max(xHi * halfWNear, xHi * halfWFar)),\n max(max(yLo * halfHNear, yLo * halfHFar), max(yHi * halfHNear, yHi * halfHFar)),\n zFar,\n );\n let centre = (lo + hi) * 0.5;\n\n var indices: array<u32, MAX_LIGHTS_PER_CLUSTER>;\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n indices[n] = 0u;\n }\n var count: u32 = 0u;\n\n /* Increasing light index, which is what makes the list sorted without a sort. */\n for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {\n /* A rectangle carries its reach negated, which is how the lit pass tells it from a lamp. */\n let signedRadius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);\n let radius = abs(signedRadius);\n let v = viewOf(light);\n if (v.z + radius <= 0.0) {\n continue;\n }\n if (distanceSqToBounds(v, lo, hi) > radius * radius) {\n continue;\n }\n if (signedRadius >= 0.0 && outsideCone(light, v, lo, hi)) {\n continue;\n }\n\n if (count < MAX_LIGHTS_PER_CLUSTER) {\n indices[count] = light;\n count = count + 1u;\n continue;\n }\n\n /*\n * Full. Drop the farthest from the cluster's centre if this one is nearer.\n *\n * **The only float comparison that decides anything here**, and the reason the *order* is an\n * integer: a float rank cannot be relied on to agree between this and the JavaScript binner,\n * so it is confined to membership, where the conformance script forgives a light sitting\n * within epsilon of a boundary.\n */\n var worstSlot: i32 = -1;\n var worst = centreDistanceSq(light, centre);\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let d = centreDistanceSq(indices[n], centre);\n if (d > worst) {\n worst = d;\n worstSlot = i32(n);\n }\n }\n if (worstSlot < 0) {\n continue;\n }\n indices[u32(worstSlot)] = light;\n\n /* Insertion sort over at most 28 integers, so the bytes stay ascending after a replacement. */\n for (var n: u32 = 1u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let value = indices[n];\n var m: i32 = i32(n) - 1;\n while (m >= 0 && indices[u32(m)] > value) {\n indices[u32(m) + 1u] = indices[u32(m)];\n m = m - 1;\n }\n indices[u32(m + 1)] = value;\n }\n }\n\n let base = LIGHT_REGION_TEXELS + id * CLUSTER_TEXELS;\n textureStore(table, texelAt(base), vec4<u32>(count, 0u, 0u, 0u));\n for (var t: u32 = 0u; t < CLUSTER_TEXELS - 1u; t = t + 1u) {\n textureStore(\n table,\n texelAt(base + 1u + t),\n vec4<u32>(indices[t * 4u], indices[t * 4u + 1u], indices[t * 4u + 2u], indices[t * 4u + 3u]),\n );\n }\n}\n";
23
+ export declare const CLUSTER_BIN_WGSL = "\nconst CLUSTER_X: u32 = 16u;\nconst CLUSTER_Y: u32 = 9u;\nconst CLUSTER_Z: u32 = 24u;\nconst CLUSTER_COUNT: u32 = 3456u;\nconst CLUSTER_TEXELS: u32 = 20u;\nconst MAX_LIGHTS_PER_CLUSTER: u32 = 76u;\nconst LIGHT_TEXELS: u32 = 6u;\nconst LIGHT_REGION_TEXELS: u32 = 1920u;\nconst TABLE_WIDTH: u32 = 320u;\n\nstruct Params {\n view: mat4x4<f32>,\n /** near, far, tan(fovY/2), aspect. */\n frustum: vec4<f32>,\n lightCount: u32,\n pad0: u32,\n pad1: u32,\n pad2: u32,\n}\n\n@group(0) @binding(0) var<uniform> params: Params;\n/**\n * The records as `writeLightRecord` lays them out, as bits. **`u32`, not `f32`**, because one\n * slot holds two halves, and a zero weight read as a float would be a subnormal a GPU is free to\n * flush on the way through; read as bits, the copy into the table cannot change them.\n */\n@group(0) @binding(1) var<storage, read> lights: array<vec4<u32>>;\n/**\n * Write-only, which is all core WGSL offers and all the fixed-slot layout needs.\n *\n * A read-modify-write list would need a storage buffer and an atomic counter, and it is the reason\n * a cluster owns a fixed run rather than appending into a shared one.\n */\n@group(0) @binding(2) var table: texture_storage_2d<rgba32uint, write>;\n\n/** A flat texel index to its place in the table. TABLE_WIDTH divides both regions exactly. */\nfn texelAt(texel: u32) -> vec2<i32> {\n return vec2<i32>(i32(texel % TABLE_WIDTH), i32(texel / TABLE_WIDTH));\n}\n\nfn sliceNearDepth(slice: f32) -> f32 {\n return params.frustum.x * pow(params.frustum.y / params.frustum.x, slice / f32(CLUSTER_Z));\n}\n\n/** Squared distance from a point to an axis-aligned box, zero inside it. */\nfn distanceSqToBounds(p: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> f32 {\n let d = max(max(lo - p, p - hi), vec3<f32>(0.0));\n return dot(d, d);\n}\n\n/** A light's view-space centre, with depth positive along the view direction. */\nfn viewOf(light: u32) -> vec3<f32> {\n let world = bitcast<vec4<f32>>(lights[light * LIGHT_TEXELS]);\n let v = params.view * vec4<f32>(world.xyz, 1.0);\n return vec3<f32>(v.x, v.y, -v.z);\n}\n\n/**\n * Squared distance from a light's view-space centre to a point.\n *\n * **Squared, matching `centreDistanceSq` in `clusteredLights.ts` rather than taking a root.** The\n * ranking is identical either way, and a square root is one more operation for the two\n * implementations to round differently.\n */\nfn centreDistanceSq(light: u32, centre: vec3<f32>) -> f32 {\n let d = viewOf(light) - centre;\n return dot(d, d);\n}\n\n/**\n * Whether a spot's cone misses this froxel: `coneBoxGap` in `clusteredLights.ts`, the same\n * arithmetic in the same order \u2014 the froxel's bounding sphere against the infinite cone, its apex\n * included, which can keep a light the shader would not draw and never drop one it would. A light\n * with no cone carries a cosine of -2 and never reaches the test.\n */\nfn outsideCone(light: u32, apex: vec3<f32>, lo: vec3<f32>, hi: vec3<f32>) -> bool {\n let cosOuter = bitcast<f32>(lights[light * LIGHT_TEXELS + 3u].y);\n if (cosOuter <= -1.0) {\n return false;\n }\n let world = bitcast<vec3<f32>>(lights[light * LIGHT_TEXELS + 2u].yzw);\n let a = (params.view * vec4<f32>(world, 0.0)).xyz;\n let axis = vec3<f32>(a.x, a.y, -a.z);\n let len = length(axis);\n /* The shader measures the cone against the direction as given, so a long one is a wider cone. */\n if (len <= 1e-6 || cosOuter / len <= -1.0) {\n return false;\n }\n let cosA = cosOuter / len;\n let sinA = sqrt(max(0.0, 1.0 - cosA * cosA));\n let unit = axis / len;\n let extent = (hi - lo) * 0.5;\n let toCentre = (lo + hi) * 0.5 - apex;\n let along = dot(toCentre, unit);\n let lengthSq = dot(toCentre, toCentre);\n let across = sqrt(max(lengthSq - along * along, 0.0));\n let reach = sqrt(lengthSq);\n var distance = cosA * across - along * sinA;\n if (along < -sinA * reach) {\n distance = reach;\n }\n return distance - length(extent) > 0.0;\n}\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n let id = gid.x;\n\n /*\n * The light records, written by the first invocations that have one.\n *\n * **Two jobs in one kernel, and it is deliberate.** The records have to reach the same texture\n * the clusters do, because the lit pass reads both through one texture unit; writing them from\n * the CPU instead would mean a partial texture upload racing a dispatch over the same resource.\n * There are always more clusters than lights, so every record has an invocation.\n */\n if (id < params.lightCount) {\n let record = id * LIGHT_TEXELS;\n for (var t: u32 = 0u; t < LIGHT_TEXELS; t = t + 1u) {\n textureStore(table, texelAt(record + t), lights[record + t]);\n }\n }\n\n if (id >= CLUSTER_COUNT) {\n return;\n }\n\n let k = id / (CLUSTER_X * CLUSTER_Y);\n let j = (id - k * CLUSTER_X * CLUSTER_Y) / CLUSTER_X;\n let i = id - k * CLUSTER_X * CLUSTER_Y - j * CLUSTER_X;\n\n /* The AABB of the froxel's eight corners, exactly as `clusterViewBounds` derives it. */\n let zNear = sliceNearDepth(f32(k));\n let zFar = sliceNearDepth(f32(k + 1u));\n let xLo = -1.0 + 2.0 * f32(i) / f32(CLUSTER_X);\n let xHi = -1.0 + 2.0 * f32(i + 1u) / f32(CLUSTER_X);\n let yLo = -1.0 + 2.0 * f32(j) / f32(CLUSTER_Y);\n let yHi = -1.0 + 2.0 * f32(j + 1u) / f32(CLUSTER_Y);\n let halfHNear = zNear * params.frustum.z;\n let halfHFar = zFar * params.frustum.z;\n let halfWNear = halfHNear * params.frustum.w;\n let halfWFar = halfHFar * params.frustum.w;\n\n let lo = vec3<f32>(\n min(min(xLo * halfWNear, xLo * halfWFar), min(xHi * halfWNear, xHi * halfWFar)),\n min(min(yLo * halfHNear, yLo * halfHFar), min(yHi * halfHNear, yHi * halfHFar)),\n zNear,\n );\n let hi = vec3<f32>(\n max(max(xLo * halfWNear, xLo * halfWFar), max(xHi * halfWNear, xHi * halfWFar)),\n max(max(yLo * halfHNear, yLo * halfHFar), max(yHi * halfHNear, yHi * halfHFar)),\n zFar,\n );\n let centre = (lo + hi) * 0.5;\n\n var indices: array<u32, MAX_LIGHTS_PER_CLUSTER>;\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n indices[n] = 0u;\n }\n var count: u32 = 0u;\n\n /* Increasing light index, which is what makes the list sorted without a sort. */\n for (var light: u32 = 0u; light < params.lightCount; light = light + 1u) {\n /* A rectangle carries its reach negated, which is how the lit pass tells it from a lamp. */\n let signedRadius = bitcast<f32>(lights[light * LIGHT_TEXELS].w);\n let radius = abs(signedRadius);\n let v = viewOf(light);\n if (v.z + radius <= 0.0) {\n continue;\n }\n if (distanceSqToBounds(v, lo, hi) > radius * radius) {\n continue;\n }\n if (signedRadius >= 0.0 && outsideCone(light, v, lo, hi)) {\n continue;\n }\n\n if (count < MAX_LIGHTS_PER_CLUSTER) {\n indices[count] = light;\n count = count + 1u;\n continue;\n }\n\n /*\n * Full. Drop the farthest from the cluster's centre if this one is nearer.\n *\n * **The only float comparison that decides anything here**, and the reason the *order* is an\n * integer: a float rank cannot be relied on to agree between this and the JavaScript binner,\n * so it is confined to membership, where the conformance script forgives a light sitting\n * within epsilon of a boundary.\n */\n var worstSlot: i32 = -1;\n var worst = centreDistanceSq(light, centre);\n for (var n: u32 = 0u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let d = centreDistanceSq(indices[n], centre);\n if (d > worst) {\n worst = d;\n worstSlot = i32(n);\n }\n }\n if (worstSlot < 0) {\n continue;\n }\n indices[u32(worstSlot)] = light;\n\n /* Insertion sort over at most 28 integers, so the bytes stay ascending after a replacement. */\n for (var n: u32 = 1u; n < MAX_LIGHTS_PER_CLUSTER; n = n + 1u) {\n let value = indices[n];\n var m: i32 = i32(n) - 1;\n while (m >= 0 && indices[u32(m)] > value) {\n indices[u32(m) + 1u] = indices[u32(m)];\n m = m - 1;\n }\n indices[u32(m + 1)] = value;\n }\n }\n\n let base = LIGHT_REGION_TEXELS + id * CLUSTER_TEXELS;\n textureStore(table, texelAt(base), vec4<u32>(count, 0u, 0u, 0u));\n for (var t: u32 = 0u; t < CLUSTER_TEXELS - 1u; t = t + 1u) {\n textureStore(\n table,\n texelAt(base + 1u + t),\n vec4<u32>(indices[t * 4u], indices[t * 4u + 1u], indices[t * 4u + 2u], indices[t * 4u + 3u]),\n );\n }\n}\n";
@@ -1,5 +1,6 @@
1
1
  import type { SurfaceTextureOptions } from '../../surfaceTexture.ts';
2
2
  import type { LightmapTexels } from '../../lightmap.ts';
3
+ import type { SceneCaptureTexels } from '../../sceneCapture.ts';
3
4
  import type { CompressedTextureFormat, SurfaceSource } from '../../compressedSource.ts';
4
5
  import type { PipelineCache } from './pipelineCache.ts';
5
6
  /** How many levels an image of this size has, counting the one it arrives with. */
@@ -63,11 +64,13 @@ export declare class GpuSurfaceTexture {
63
64
  effectsView: GPUTextureView | null;
64
65
  /** The block format a compressed texture holds, or null for one uploaded from images. */
65
66
  private readonly blockFormat;
66
- constructor(device: GPUDevice, pipelines: PipelineCache, source: SurfaceSource | readonly SurfaceSource[] | LightmapTexels, options?: SurfaceTextureOptions, compressed?: readonly CompressedTextureFormat[]);
67
+ constructor(device: GPUDevice, pipelines: PipelineCache, source: SurfaceSource | readonly SurfaceSource[] | LightmapTexels | SceneCaptureTexels, options?: SurfaceTextureOptions, compressed?: readonly CompressedTextureFormat[]);
67
68
  /** A view of every layer, as the shaders' `texture_2d_array` bindings read it. */
68
69
  private arrayView;
69
70
  /** The view every binding of this image holds. A new one when `update` changes the size. */
70
71
  get view(): GPUTextureView;
72
+ /** The only layer as a plain 2D view, which a scene capture's pass resolves into. */
73
+ layerView(): GPUTextureView | null;
71
74
  /**
72
75
  * Replace the pixels, keeping the handle and its sampler.
73
76
  *
@@ -1,6 +1,7 @@
1
1
  import { SURFACE_EFFECT_TEXELS, packSurfaceEffects } from '../../surfaceEffects.js';
2
2
  import { toHalfFloats } from '../../halfFloat.js';
3
3
  import { isLightmapTexels } from '../../lightmap.js';
4
+ import { isSceneCaptureTexels } from '../../sceneCapture.js';
4
5
  import { layerSize, refuseArrayUpdate, sourceSize } from '../../textureSource.js';
5
6
  import { compressedLayers, planBlocks, refuseBlockUpdate } from '../../compressedSource.js';
6
7
  import { createCompressedTexture } from './compressedUpload.js';
@@ -156,24 +157,44 @@ export class GpuSurfaceTexture {
156
157
  constructor(device, pipelines, source, options = {}, compressed = []) {
157
158
  this.device = device;
158
159
  this.pipelines = pipelines;
159
- /* A lightmap's page: half floats, uploaded as they are. See `lightmap.ts`. */
160
+ /* A scene capture's target: one empty layer the world is drawn into. See `sceneCapture.ts`. */
161
+ const capture = isSceneCaptureTexels(source) ? source : null;
162
+ /* A lightmap's page: two layers of bytes, uploaded as they are. See `lightmap.ts`. */
160
163
  const baked = isLightmapTexels(source) ? source : null;
161
- const listed = (baked !== null ? [] : Array.isArray(source) ? source : [source]);
164
+ const listed = (baked !== null || capture !== null ? [] : Array.isArray(source) ? source : [source]);
162
165
  const blocks = compressedLayers(listed);
163
166
  const srgb = (options.colorSpace ?? 'linear') === 'srgb';
164
167
  /* `SRGB8_ALPHA8`'s equivalent. Decoded in the sampler, before filtering, which is the only
165
- place it is correct — `surfaceTexture.ts` makes the argument in full. */
166
- this.format = baked !== null ? 'rgba16float' : srgb ? 'rgba8unorm-srgb' : 'rgba8unorm';
167
- if (baked !== null) {
168
+ place it is correct — `surfaceTexture.ts` makes the argument in full. A capture takes the
169
+ world's own format, which its pass resolves into. */
170
+ this.format =
171
+ capture !== null
172
+ ? pipelines.format
173
+ : srgb && baked === null
174
+ ? 'rgba8unorm-srgb'
175
+ : 'rgba8unorm';
176
+ if (capture !== null) {
177
+ this.layers = 1;
178
+ this.blockFormat = null;
179
+ this.mipmapped = false;
180
+ this.width = capture.width;
181
+ this.height = capture.height;
182
+ this.levels = 1;
183
+ this.texture = this.allocate();
184
+ }
185
+ else if (baked !== null) {
168
186
  /* No chain: a page is read at its own resolution, and a level of it would blend regions. */
169
- this.layers = baked.layers;
187
+ this.layers = baked.layers.length;
170
188
  this.blockFormat = null;
171
189
  this.mipmapped = false;
172
190
  this.width = baked.width;
173
191
  this.height = baked.height;
174
192
  this.levels = 1;
175
193
  this.texture = this.allocate();
176
- device.queue.writeTexture({ texture: this.texture }, baked.texels, { bytesPerRow: baked.width * 8, rowsPerImage: baked.height }, { width: baked.width, height: baked.height, depthOrArrayLayers: baked.layers });
194
+ /* A layer at a time, so a page's arrays are uploaded where they lie rather than joined. */
195
+ for (let layer = 0; layer < this.layers; layer++) {
196
+ device.queue.writeTexture({ texture: this.texture, origin: [0, 0, layer] }, baked.layers[layer], { bytesPerRow: baked.width * 4, rowsPerImage: baked.height }, { width: baked.width, height: baked.height, depthOrArrayLayers: 1 });
197
+ }
177
198
  }
178
199
  else if (blocks !== null) {
179
200
  /* Refused before anything is allocated; the stored chain, or level 0 alone where no chain
@@ -230,8 +251,9 @@ export class GpuSurfaceTexture {
230
251
  ? Math.max(1, Math.floor(options.anisotropy ?? DEFAULT_ANISOTROPY))
231
252
  : 1,
232
253
  });
233
- if (blocks === null && baked === null)
254
+ if (blocks === null && baked === null && capture === null) {
234
255
  this.upload(listed);
256
+ }
235
257
  /* Half floats, as `renderer.ts` uploads it: filterable, so the ordinary float layout takes it,
236
258
  and read by `textureLoad` alone, so the filter never runs. */
237
259
  const effects = options.effects ?? [];
@@ -255,6 +277,10 @@ export class GpuSurfaceTexture {
255
277
  get view() {
256
278
  return this.current;
257
279
  }
280
+ /** The only layer as a plain 2D view, which a scene capture's pass resolves into. */
281
+ layerView() {
282
+ return (this.texture?.createView({ dimension: '2d', baseArrayLayer: 0, arrayLayerCount: 1 }) ?? null);
283
+ }
258
284
  /**
259
285
  * Replace the pixels, keeping the handle and its sampler.
260
286
  *
@@ -11,7 +11,7 @@ import type { UniformFields } from './scatterPass.ts';
11
11
  export declare const WATER_VERT_FIELDS: UniformFields;
12
12
  export declare const WATER_VERT_SIZE: 208;
13
13
  export declare const WATER_FRAG_FIELDS: UniformFields;
14
- export declare const WATER_FRAG_SIZE: 1248;
14
+ export declare const WATER_FRAG_SIZE: 1504;
15
15
  /** The two meshes and the numbers the grid decided, behind the surface's opaque handle. */
16
16
  export interface GpuWater {
17
17
  /** The ocean: near sheet and far skirt in one buffer. */