@driftengine/core 4.8.1 → 4.8.2

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 (62) hide show
  1. package/CHANGELOG.json +86 -0
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.js +2 -0
  4. package/dist/input/actionMap.d.ts +6 -1
  5. package/dist/input/actionMap.js +20 -7
  6. package/dist/input/input.d.ts +28 -0
  7. package/dist/input/input.js +71 -9
  8. package/dist/input/inputHarness.d.ts +9 -0
  9. package/dist/input/inputHarness.js +34 -2
  10. package/dist/render/backend/webgl2/renderer.d.ts +52 -3
  11. package/dist/render/backend/webgl2/renderer.js +151 -5
  12. package/dist/render/backend/webgpu/buffers.d.ts +2 -0
  13. package/dist/render/backend/webgpu/buffers.js +2 -0
  14. package/dist/render/backend/webgpu/flatPass.js +16 -7
  15. package/dist/render/backend/webgpu/pipelineCache.d.ts +40 -8
  16. package/dist/render/backend/webgpu/pipelineCache.js +76 -9
  17. package/dist/render/backend/webgpu/renderer.d.ts +56 -1
  18. package/dist/render/backend/webgpu/renderer.js +151 -26
  19. package/dist/render/mapsWithoutUvs.d.ts +24 -0
  20. package/dist/render/mapsWithoutUvs.js +24 -0
  21. package/dist/render/mesh.d.ts +2 -0
  22. package/dist/render/mesh.js +3 -0
  23. package/dist/render/particlePool.d.ts +15 -0
  24. package/dist/render/particlePool.js +72 -37
  25. package/dist/render/pass.d.ts +12 -0
  26. package/dist/render/renderQuality.d.ts +7 -6
  27. package/dist/render/shaders/flat/glassTint.d.ts +28 -0
  28. package/dist/render/shaders/flat/glassTint.js +31 -0
  29. package/dist/render/shaders/flat/index.d.ts +12 -0
  30. package/dist/render/shaders/flat/index.js +7 -1
  31. package/dist/render/shaders/flat/main.d.ts +1 -1
  32. package/dist/render/shaders/flat/main.js +60 -25
  33. package/dist/render/shaders/flat/surfaceEffects.d.ts +1 -1
  34. package/dist/render/shaders/flat/surfaceEffects.js +6 -2
  35. package/dist/render/shaders/generated/flat.wgsl.d.ts +64 -0
  36. package/dist/render/shaders/generated/flat.wgsl.js +102 -38
  37. package/dist/render/surfaceTexture.d.ts +2 -0
  38. package/dist/render/surfaceTexture.js +4 -0
  39. package/dist/render/viewModel.d.ts +29 -0
  40. package/dist/render/viewModel.js +35 -0
  41. package/package.json +6 -6
  42. package/src/index.ts +9 -1
  43. package/src/input/actionMap.ts +23 -8
  44. package/src/input/input.ts +79 -9
  45. package/src/input/inputHarness.ts +27 -2
  46. package/src/render/backend/webgl2/renderer.ts +166 -13
  47. package/src/render/backend/webgpu/buffers.ts +5 -0
  48. package/src/render/backend/webgpu/flatPass.ts +41 -25
  49. package/src/render/backend/webgpu/pipelineCache.ts +82 -8
  50. package/src/render/backend/webgpu/renderer.ts +151 -26
  51. package/src/render/mapsWithoutUvs.ts +43 -0
  52. package/src/render/mesh.ts +3 -0
  53. package/src/render/particlePool.ts +101 -54
  54. package/src/render/pass.ts +12 -0
  55. package/src/render/renderQuality.ts +7 -6
  56. package/src/render/shaders/flat/glassTint.ts +48 -0
  57. package/src/render/shaders/flat/index.ts +19 -1
  58. package/src/render/shaders/flat/main.ts +60 -25
  59. package/src/render/shaders/flat/surfaceEffects.ts +6 -2
  60. package/src/render/shaders/generated/flat.wgsl.ts +102 -38
  61. package/src/render/surfaceTexture.ts +5 -0
  62. package/src/render/viewModel.ts +41 -0
@@ -257,6 +257,9 @@ import type { InstancedOptions, MeshInstances } from '../../instances.ts';
257
257
  import { cutoutOf } from '../../cutoutCaster.ts';
258
258
  import type { CutoutCaster } from '../../cutoutCaster.ts';
259
259
  import type { SurfaceTextureHandle } from '../api.ts';
260
+ import { materialHasMaps, noteMapsWithoutUvs } from '../../mapsWithoutUvs.ts';
261
+ import { VIEW_MODEL_DEPTH_SHARE, viewModelDepthRange } from '../../viewModel.ts';
262
+ import { noLitSwitches, type LitSwitch } from '../../shaders/flat/glassTint.ts';
260
263
 
261
264
  /**
262
265
  * How many frames `firstFrameSettled` will ask before it stops asking.
@@ -1087,8 +1090,8 @@ export class WebGL2Renderer implements RendererApi {
1087
1090
 
1088
1091
  private presentedFrameCount = 0;
1089
1092
 
1090
- private readonly flatProgram: WebGLProgram;
1091
- private readonly flatUniforms: Record<string, WebGLUniformLocation>;
1093
+ private flatProgram: WebGLProgram;
1094
+ private flatUniforms: Record<string, WebGLUniformLocation>;
1092
1095
  /**
1093
1096
  * The lit fragment source every flat program is built from, and the budget it was built at.
1094
1097
  *
@@ -1099,7 +1102,7 @@ export class WebGL2Renderer implements RendererApi {
1099
1102
  * for a lazily compiled variant to end up at a different light budget from the one the renderer
1100
1103
  * uploads for, which is a picture nobody could explain.
1101
1104
  */
1102
- private readonly flatFragSource: string;
1105
+ private flatFragSource: string;
1103
1106
  /**
1104
1107
  * How many lights this renderer's shader has room for, after the device had its say.
1105
1108
  *
@@ -1683,6 +1686,33 @@ export class WebGL2Renderer implements RendererApi {
1683
1686
  * each such draw and handed back, where the other backend bakes it into a pipeline.
1684
1687
  */
1685
1688
  private materialDoubleSided = false;
1689
+ /** Whether the bound material has a map, for the warning a mesh with no coordinates earns. */
1690
+ private materialHasMaps = false;
1691
+ /** The material last set, so a lit program rebuilt for glass can be given it. */
1692
+ private currentMaterial: SurfaceMaterial | null = null;
1693
+ /**
1694
+ * The lit stage's switches: what the lit programs were compiled with (`litOn`), and what the
1695
+ * profile and the content have asked for since (`litWanted`).
1696
+ *
1697
+ * **Each is off until it is used** — glass until a pane is offered, a fixture until one is loaded,
1698
+ * the effects until a material carries a table, DriftLight until a volume is set — and then on for
1699
+ * good, rebuilt at the next `beginFrame`. Clustering is the profile's. The code costs the lit pass
1700
+ * registers on every surface whether it finds anything or not, and a world without the feature
1701
+ * draws the same picture without it. `PipelineCache.litSwitches` makes the same decision on the
1702
+ * other backend. The frame a feature is first used in draws without it; the rebuild is a
1703
+ * synchronous compile, and the next frame waits for it.
1704
+ */
1705
+ private readonly litOn = noLitSwitches();
1706
+ private readonly litWanted = noLitSwitches();
1707
+ private litAsked = false;
1708
+
1709
+ /** Ask for a switch, which the next `beginFrame` compiles in. Never glass with it turned off. */
1710
+ private askLit(feature: LitSwitch): void {
1711
+ if (this.litWanted[feature]) return;
1712
+ if (feature === 'GLASS_SHADOWS' && this.quality.glassShadows === 'off') return;
1713
+ this.litWanted[feature] = true;
1714
+ this.litAsked = true;
1715
+ }
1686
1716
 
1687
1717
  /**
1688
1718
  * The one sink handed to every caster enumeration.
@@ -1715,6 +1745,7 @@ export class WebGL2Renderer implements RendererApi {
1715
1745
  /* Glass: kept aside for its own two passes rather than dropped. See `glassCasters.ts`. */
1716
1746
  if (this.quality.glassShadows !== 'off' && glassOf(material, this.glassCasterScratch)) {
1717
1747
  this.glassCasters.recordMesh(mesh, model, material, this.glassCasterScratch);
1748
+ this.askLit('GLASS_SHADOWS');
1718
1749
  }
1719
1750
  return;
1720
1751
  }
@@ -1756,6 +1787,7 @@ export class WebGL2Renderer implements RendererApi {
1756
1787
  if (!this.drawingGlassDepth && !castsDepth(material)) {
1757
1788
  if (this.quality.glassShadows !== 'off' && glassOf(material, this.glassCasterScratch)) {
1758
1789
  this.glassCasters.recordInstanced(batch, data, material, this.glassCasterScratch);
1790
+ this.askLit('GLASS_SHADOWS');
1759
1791
  }
1760
1792
  return;
1761
1793
  }
@@ -1789,6 +1821,7 @@ export class WebGL2Renderer implements RendererApi {
1789
1821
  if (!this.drawingGlassDepth && !castsDepth(material)) {
1790
1822
  if (this.quality.glassShadows !== 'off' && glassOf(material, this.glassCasterScratch)) {
1791
1823
  this.glassCasters.recordSkinned(mesh, model, palette, material, this.glassCasterScratch);
1824
+ this.askLit('GLASS_SHADOWS');
1792
1825
  }
1793
1826
  return;
1794
1827
  }
@@ -2297,6 +2330,8 @@ export class WebGL2Renderer implements RendererApi {
2297
2330
  this.iesRows = this.iesAtlas.height;
2298
2331
  this.iesPlanes = this.iesAtlas.planes;
2299
2332
  this.cookieTiles = this.cookieImages.length;
2333
+ /* The first profile or cookie is when the lit programs start reading them. See `litOn`. */
2334
+ if (this.iesRows > 1 || this.cookieTiles > 0) this.askLit('LIGHT_FIXTURES');
2300
2335
  gl.bindTexture(gl.TEXTURE_2D, null);
2301
2336
  gl.activeTexture(gl.TEXTURE0);
2302
2337
  }
@@ -2714,6 +2749,9 @@ export class WebGL2Renderer implements RendererApi {
2714
2749
  * Built for the profile this renderer resolved, not for every profile at once — so a
2715
2750
  * world with shadows off compiles none of the shadow path. See `shaders/flat.ts`.
2716
2751
  */
2752
+ /* The one switch the profile decides; the rest wait for their content. See `litOn`. */
2753
+ this.litOn.CLUSTERED_LIGHTS = this.quality.clusteredLights;
2754
+ this.litWanted.CLUSTERED_LIGHTS = this.quality.clusteredLights;
2717
2755
  const fitted = this.fitFlatProgram(gl);
2718
2756
  this.flatProgram = fitted.program;
2719
2757
  this.flatFragSource = fitted.source;
@@ -3306,6 +3344,8 @@ export class WebGL2Renderer implements RendererApi {
3306
3344
  this.materials.dirty();
3307
3345
  this.currentSurfaceTexture = material?.albedo ?? null;
3308
3346
  this.materialDoubleSided = material?.doubleSided === true;
3347
+ this.materialHasMaps = materialHasMaps(material);
3348
+ this.currentMaterial = material;
3309
3349
  /*
3310
3350
  * Written to every flat program, because material state persists across draws and a skinned
3311
3351
  * draw is entitled to the material the caller set before it. A second program holding none of
@@ -3422,6 +3462,8 @@ export class WebGL2Renderer implements RendererApi {
3422
3462
  gl.bindTexture(gl.TEXTURE_2D, this.emptyTexture2D);
3423
3463
  } else {
3424
3464
  albedo.bindEffects(gl, SURFACE_EFFECTS_TEXTURE_UNIT, this.emptyTexture2D);
3465
+ /* The first table bound is when the lit programs start reading one. See `litOn`. */
3466
+ if (albedo.hasEffects) this.askLit('SURFACE_EFFECTS');
3425
3467
  }
3426
3468
  gl.uniform1i(u['uSurfaceEffects'] ?? null, SURFACE_EFFECTS_TEXTURE_UNIT);
3427
3469
  if (albedo === null) {
@@ -5317,21 +5359,91 @@ export class WebGL2Renderer implements RendererApi {
5317
5359
  * rather than propagating. Only a shader that will not link at *any* budget throws, and it
5318
5360
  * throws a sentence with the device's numbers in it rather than a bare driver string.
5319
5361
  */
5320
- private fitFlatProgram(gl: WebGL2RenderingContext): {
5321
- program: WebGLProgram;
5322
- source: string;
5323
- budget: LightBudget;
5324
- } {
5325
- const variant = {
5362
+ /** The lit fragment source this renderer's profile compiles, at a light budget. */
5363
+ private flatFragAt(budget: LightBudget): string {
5364
+ return flatFrag({
5326
5365
  pointShadows: this.quality.pointShadows,
5327
5366
  directionalShadows: this.quality.directionalShadows,
5328
5367
  environmentProbe: this.probeCapture !== null,
5329
5368
  nightEmissive: this.quality.nightEmissive,
5330
- /* Off compiles none of the glass lookup: see `FlatShaderOptions.glassShadows`. */
5331
- glassShadows: this.quality.glassShadows !== 'off',
5369
+ /* Each off until it is used: see `litOn`. */
5370
+ glassShadows: this.litOn.GLASS_SHADOWS,
5371
+ clusteredLights: this.litOn.CLUSTERED_LIGHTS,
5372
+ lightFixtures: this.litOn.LIGHT_FIXTURES,
5373
+ surfaceEffects: this.litOn.SURFACE_EFFECTS,
5374
+ driftLight: this.litOn.DRIFT_LIGHT,
5375
+ maxLights: budget.maxLights,
5376
+ maxAreaLights: budget.maxAreaLights,
5377
+ });
5378
+ }
5379
+
5380
+ /**
5381
+ * Every lit program that exists, compiled again with the switches asked for since, at the budget
5382
+ * the renderer already uploads for, and given the pass and the material the old one held.
5383
+ *
5384
+ * At `beginFrame`, not where a feature was first used: a pane is offered inside a shadow pass
5385
+ * with the depth program bound, and a program swapped under it would draw the next caster with
5386
+ * the lit one.
5387
+ */
5388
+ private rebuildLit(): void {
5389
+ const { gl } = this;
5390
+ Object.assign(this.litOn, this.litWanted);
5391
+ this.flatFragSource = this.flatFragAt(this.lightBudget);
5392
+ const recompile = (
5393
+ old: WebGLProgram | null,
5394
+ skinned: boolean,
5395
+ morphed: boolean,
5396
+ instanced: boolean,
5397
+ label: string,
5398
+ ): { program: WebGLProgram; uniforms: Record<string, WebGLUniformLocation> } | null => {
5399
+ if (old === null) return null;
5400
+ const program = compileProgram(
5401
+ gl,
5402
+ flatVert({ skinned, morphed, instanced }),
5403
+ this.flatFragSource,
5404
+ label,
5405
+ );
5406
+ gl.deleteProgram(old);
5407
+ return { program, uniforms: uniformLocations(gl, program, label) };
5332
5408
  };
5333
- const build = (budget: LightBudget): string =>
5334
- flatFrag({ ...variant, maxLights: budget.maxLights, maxAreaLights: budget.maxAreaLights });
5409
+ const plain = recompile(this.flatProgram, false, false, false, 'flat');
5410
+ if (plain !== null) {
5411
+ this.flatProgram = plain.program;
5412
+ this.flatUniforms = plain.uniforms;
5413
+ }
5414
+ const skinned = recompile(this.flatSkinnedProgram, true, false, false, 'flat.skinned');
5415
+ this.flatSkinnedProgram = skinned?.program ?? null;
5416
+ this.flatSkinnedUniforms = skinned?.uniforms ?? null;
5417
+ const morphed = recompile(this.flatMorphedProgram, false, true, false, 'flat.morphed');
5418
+ this.flatMorphedProgram = morphed?.program ?? null;
5419
+ this.flatMorphedUniforms = morphed?.uniforms ?? null;
5420
+ const both = recompile(this.flatBothProgram, true, true, false, 'flat.morphedskinned');
5421
+ this.flatBothProgram = both?.program ?? null;
5422
+ this.flatBothUniforms = both?.uniforms ?? null;
5423
+ const instanced = recompile(this.flatInstancedProgram, false, false, true, 'flat.instanced');
5424
+ this.flatInstancedProgram = instanced?.program ?? null;
5425
+ this.flatInstancedUniforms = instanced?.uniforms ?? null;
5426
+
5427
+ this.flatTargets.length = 0;
5428
+ for (const target of [plain, skinned, morphed, both, instanced]) {
5429
+ if (target !== null) this.flatTargets.push(target);
5430
+ }
5431
+ const camera = this.lastPassCamera;
5432
+ const env = this.lastPassEnv;
5433
+ for (const target of this.flatTargets) {
5434
+ gl.useProgram(target.program);
5435
+ if (camera !== null && env !== null) this.writeMeshPassState(target.uniforms, camera, env);
5436
+ this.writeMaterialState(target.uniforms, this.currentMaterial);
5437
+ }
5438
+ this.useFlatProgram();
5439
+ }
5440
+
5441
+ private fitFlatProgram(gl: WebGL2RenderingContext): {
5442
+ program: WebGLProgram;
5443
+ source: string;
5444
+ budget: LightBudget;
5445
+ } {
5446
+ const build = (budget: LightBudget): string => this.flatFragAt(budget);
5335
5447
  const vert = flatVert({ skinned: false, morphed: false, instanced: false });
5336
5448
  const limit = gl.getParameter(gl.MAX_FRAGMENT_UNIFORM_VECTORS) as number;
5337
5449
  const ceiling: LightBudget = {
@@ -5989,6 +6101,35 @@ export class WebGL2Renderer implements RendererApi {
5989
6101
  this.restoreViewport();
5990
6102
  }
5991
6103
 
6104
+ /**
6105
+ * Draw what follows as a **view model**, in the nearest sliver of depth, until `endViewModel`.
6106
+ *
6107
+ * A first-person view model — arms and a held weapon — drawn with the frame's camera, or with one
6108
+ * of its own through `bindMeshPass`, lands in front of anything past the near plane without the
6109
+ * world's depth being cleared, so every pass that reads depth afterwards still sees the world.
6110
+ * `share` is how much of the range it takes, 1% by default. See `viewModel.ts` for what it gives
6111
+ * up, which includes temporal reconstruction: draw a view model with multisampling.
6112
+ *
6113
+ * Not inside an inset: an inset has its own depth, cleared for it, and needs no squeeze.
6114
+ */
6115
+ beginViewModel(share = VIEW_MODEL_DEPTH_SHARE): void {
6116
+ if (this.contextLost) return;
6117
+ viewModelDepthRange(this.reversedDepth, share, this.viewModelRange);
6118
+ this.gl.depthRange(this.viewModelRange[0], this.viewModelRange[1]);
6119
+ this.viewModelActive = true;
6120
+ }
6121
+
6122
+ /** Give the whole depth range back. Safe to call without a matching `beginViewModel`. */
6123
+ endViewModel(): void {
6124
+ this.viewModelActive = false;
6125
+ if (this.contextLost) return;
6126
+ this.gl.depthRange(0, 1);
6127
+ }
6128
+
6129
+ /** Whether a view model is being drawn, so `endFrame` can close one the caller left open. */
6130
+ private viewModelActive = false;
6131
+ private readonly viewModelRange: [number, number] = [0, 1];
6132
+
5992
6133
  /**
5993
6134
  * How much speed blur the frame about to be drawn should resolve with, 0 to 1.
5994
6135
  *
@@ -6508,6 +6649,10 @@ export class WebGL2Renderer implements RendererApi {
6508
6649
 
6509
6650
  beginFrame(clearColor: Vec3): void {
6510
6651
  if (this.contextLost) return;
6652
+ if (this.litAsked) {
6653
+ this.litAsked = false;
6654
+ this.rebuildLit();
6655
+ }
6511
6656
  this.budget.reset();
6512
6657
  this.materials.dirty();
6513
6658
  /*
@@ -6584,6 +6729,8 @@ export class WebGL2Renderer implements RendererApi {
6584
6729
  */
6585
6730
  endFrame(): void {
6586
6731
  if (this.contextLost) return;
6732
+ /* A view model left open would squeeze every pass after the world, the composite included. */
6733
+ if (this.viewModelActive) this.endViewModel();
6587
6734
 
6588
6735
  /*
6589
6736
  * Camera motion blur, built from the camera the frame was *rendered* with.
@@ -7240,6 +7387,8 @@ export class WebGL2Renderer implements RendererApi {
7240
7387
  gl.bindTexture(gl.TEXTURE_3D, null);
7241
7388
  this.driftIndex = index;
7242
7389
  this.driftAtlas = atlas;
7390
+ /* The first volume is when the lit programs start reading one. See `litOn`. */
7391
+ this.askLit('DRIFT_LIGHT');
7243
7392
  }
7244
7393
 
7245
7394
  /** The field's uniforms and volumes for this pass, or the stand-ins that switch it off. */
@@ -7563,6 +7712,8 @@ export class WebGL2Renderer implements RendererApi {
7563
7712
  gl.uniform1f(u['uAlbedoCutout'] ?? null, 0);
7564
7713
  gl.uniform1i(u['uDoubleSided'] ?? null, 0);
7565
7714
  this.materialDoubleSided = false;
7715
+ this.materialHasMaps = false;
7716
+ this.currentMaterial = null;
7566
7717
  this.currentSurfaceTexture = null;
7567
7718
 
7568
7719
  /*
@@ -7689,6 +7840,7 @@ export class WebGL2Renderer implements RendererApi {
7689
7840
  /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `Mesh.complete`
7690
7841
  and `Mesh.indexCount`. */
7691
7842
  if (!mesh.complete || mesh.indexCount === 0) return;
7843
+ noteMapsWithoutUvs(mesh, mesh.hasUvs, this.materialHasMaps);
7692
7844
 
7693
7845
  /*
7694
7846
  * Skipped where the flag asks and the bounds say so.
@@ -7877,6 +8029,7 @@ export class WebGL2Renderer implements RendererApi {
7877
8029
  /* Geometry that has not all arrived is not drawn, nor a mesh with none. See `Mesh.complete`
7878
8030
  and `Mesh.indexCount`. */
7879
8031
  if (!mesh.complete || mesh.indexCount === 0) return;
8032
+ noteMapsWithoutUvs(mesh, mesh.hasUvs, this.materialHasMaps);
7880
8033
 
7881
8034
  if (opacity <= 0) return;
7882
8035
 
@@ -200,6 +200,8 @@ export interface GpuMesh {
200
200
  * the key is built before the present map is consulted.
201
201
  */
202
202
  readonly isSkinned: boolean;
203
+ /** Whether it has texture coordinates, without which a material's maps read one texel. */
204
+ readonly hasUvs: boolean;
203
205
  /**
204
206
  * Whether this mesh carries the per-vertex channel, which an instanced draw cannot.
205
207
  *
@@ -425,6 +427,7 @@ export function createGpuMeshIncremental(
425
427
  progress,
426
428
  hasTangents: data.tangents !== undefined,
427
429
  isSkinned: data.joints !== undefined,
430
+ hasUvs: data.uvs !== undefined,
428
431
  skinOffsets: skinOffsetsOf(supplied),
429
432
  hasChannel: data.channel !== undefined,
430
433
  morph:
@@ -595,6 +598,7 @@ function gpuMeshOf(parts: {
595
598
  readonly progress: { readonly uploaded: boolean };
596
599
  readonly hasTangents: boolean;
597
600
  readonly isSkinned: boolean;
601
+ readonly hasUvs: boolean;
598
602
  readonly skinOffsets: GpuMesh['skinOffsets'];
599
603
  readonly hasChannel: boolean;
600
604
  readonly morph: MorphTexture | null;
@@ -614,6 +618,7 @@ function gpuMeshOf(parts: {
614
618
  },
615
619
  hasTangents: parts.hasTangents,
616
620
  isSkinned: parts.isSkinned,
621
+ hasUvs: parts.hasUvs,
617
622
  hasChannel: parts.hasChannel,
618
623
  morph: parts.morph,
619
624
  update: parts.update,
@@ -5,7 +5,7 @@ import {
5
5
  FLAT_VERT_WGSL,
6
6
  } from '../../shaders/generated/flat.wgsl.ts';
7
7
  import { vertexBufferLayouts } from './buffers.ts';
8
- import type { PipelineCache } from './pipelineCache.ts';
8
+ import type { LitSwitch, PipelineCache } from './pipelineCache.ts';
9
9
  import { shaderModule } from './shaderModules.ts';
10
10
 
11
11
  /** The material maps the flat pass reads: `2d-array` colour, filtered, not shadow maps. */
@@ -199,17 +199,27 @@ export function flatFragmentBindings(variant: FlatVariant): StageBindings {
199
199
  }
200
200
 
201
201
  /**
202
- * The lit fragment stage's constants, set from the cache: the glass switch, keyed by its **id**.
202
+ * The lit fragment stage's constants, set from the cache's switches, keyed by each one's **id**.
203
203
  *
204
204
  * Not by its name, because an override declared with `@id` answers to the id alone — WebGPU's
205
205
  * "pipeline-overridable constant identifier string" — and naga writes one on every specialisation
206
206
  * constant. Keyed by name, Chrome refused every lit pipeline: *Pipeline overridable constant
207
- * "GLASS_SHADOWS" not found*, and the frame drew nothing.
207
+ * "GLASS_SHADOWS" not found*, and the frame drew nothing. Every override the variant declares is
208
+ * set, so a switch added to the shader and not to the cache is refused here rather than defaulted.
208
209
  */
209
210
  function flatFragmentConstants(variant: FlatVariant, cache: PipelineCache): Record<string, number> {
210
- const id = flatFragmentBindings(variant).overrides?.['GLASS_SHADOWS'];
211
- if (id === undefined) throw new Error(`flatPass: variant "${variant}" has no glass switch`);
212
- return { [String(id)]: cache.glassShadows ? 1 : 0 };
211
+ const overrides = flatFragmentBindings(variant).overrides;
212
+ if (overrides?.['GLASS_SHADOWS'] === undefined) {
213
+ throw new Error(`flatPass: variant "${variant}" has no glass switch`);
214
+ }
215
+ const constants: Record<string, number> = {};
216
+ for (const [name, id] of Object.entries(overrides)) {
217
+ if (!(name in cache.litSwitches)) {
218
+ throw new Error(`flatPass: variant "${variant}" declares a switch "${name}" no cache sets`);
219
+ }
220
+ constants[String(id)] = cache.litSwitches[name as LitSwitch] ? 1 : 0;
221
+ }
222
+ return constants;
213
223
  }
214
224
 
215
225
  /** The depth format both backends compare with. */
@@ -507,23 +517,26 @@ export function flatPipeline(
507
517
  /** Culls nothing, for a surface seen from both faces. The key must carry it: `|2s`. */
508
518
  doubleSided = false,
509
519
  ): GPURenderPipeline {
510
- return cache.get(key, () =>
511
- flatDescriptor(
512
- cache,
513
- device,
514
- layout,
515
- variant,
516
- key,
517
- present,
518
- translucent,
519
- skinned,
520
- morphed,
521
- depthWrite,
522
- depthLayer,
523
- instanced,
524
- oit,
525
- doubleSided,
526
- ),
520
+ return cache.get(
521
+ key,
522
+ () =>
523
+ flatDescriptor(
524
+ cache,
525
+ device,
526
+ layout,
527
+ variant,
528
+ key,
529
+ present,
530
+ translucent,
531
+ skinned,
532
+ morphed,
533
+ depthWrite,
534
+ depthLayer,
535
+ instanced,
536
+ oit,
537
+ doubleSided,
538
+ ),
539
+ true,
527
540
  );
528
541
  }
529
542
 
@@ -549,8 +562,11 @@ export function flatPipelineAsync(
549
562
  /** Whether this pipeline reads morph deltas. See `FlatVertexOptions`. */
550
563
  morphed = false,
551
564
  ): Promise<GPURenderPipeline> {
552
- return cache.getAsync(key, () =>
553
- flatDescriptor(cache, device, layout, variant, key, present, translucent, skinned, morphed),
565
+ return cache.getAsync(
566
+ key,
567
+ () =>
568
+ flatDescriptor(cache, device, layout, variant, key, present, translucent, skinned, morphed),
569
+ true,
554
570
  );
555
571
  }
556
572
 
@@ -1,3 +1,7 @@
1
+ import { noLitSwitches, type LitSwitch } from '../../shaders/flat/glassTint.ts';
2
+
3
+ export type { LitSwitch };
4
+
1
5
  /**
2
6
  * Render pipelines, built once and looked up by name.
3
7
  *
@@ -35,15 +39,30 @@ export class PipelineCache {
35
39
  readonly sampleCount: number;
36
40
 
37
41
  /**
38
- * Whether the lit pipelines here read glass in their shadows: `glassShadows` other than `'off'`.
42
+ * Which of the lit stage's switches the pipelines built now have on: the pipeline-overridable
43
+ * constants the generated lit shader branches on (`GLASS_SHADOWS` and `litSwitchesGlsl`), which
44
+ * the device compiles away where they are off. Read by `flatPass` when it describes a pipeline.
39
45
  *
40
- * **Carried by the cache for the reason the format is**: one value every lit pipeline is built
41
- * with, chosen once by the renderer. It is the `GLASS_SHADOWS` override the generated lit shader
42
- * branches on (see `FlatShaderOptions.glassShadows`), so off specialises the lookups away rather
43
- * than leaving code that returns at its first line and still costs the pass its registers.
46
+ * **Each is code a scene pays for in every lit pixel's registers whether it uses it or not**, so
47
+ * each is off until it is used: glass until the renderer is offered a pane, a fixture until one is
48
+ * loaded, the effects until a material carries a table, DriftLight until a volume is set — and
49
+ * then on for good, through `enable`. Clustering is the profile's, fixed at construction. Every
50
+ * scene paid for all of it from 4.5.0 to 4.8.1, and on a phone that was the lit pass: about 30%
51
+ * of the lit shader's instructions for glass alone, measured on RADV. **What it costs** is the
52
+ * frames between a feature's first use and its recompiled pipelines, which draw without it.
44
53
  */
54
+ readonly litSwitches: Record<LitSwitch, boolean> = noLitSwitches();
55
+
56
+ /** Whether glass may ever be switched on here: `glassShadows` other than `'off'`. */
45
57
  readonly glassShadows: boolean;
46
58
 
59
+ /** How to describe each lit pipeline again, so `enable` can rebuild every one. */
60
+ private readonly litDescribers = new Map<string, () => GPURenderPipelineDescriptor>();
61
+ /** Bumped by every switch, so a lit build started before one is not kept after it. */
62
+ private litGeneration = 0;
63
+ /** The last rebuild, which the next one waits for so they land in the order they were asked. */
64
+ private litRebuild: Promise<void> = Promise.resolve();
65
+
47
66
  private readonly device: GPUDevice;
48
67
  private readonly pipelines = new Map<string, GPURenderPipeline>();
49
68
  /**
@@ -62,11 +81,19 @@ export class PipelineCache {
62
81
  */
63
82
  private readonly compiling = new Map<string, Promise<GPURenderPipeline>>();
64
83
 
65
- constructor(device: GPUDevice, format: GPUTextureFormat, sampleCount = 1, glassShadows = true) {
84
+ constructor(
85
+ device: GPUDevice,
86
+ format: GPUTextureFormat,
87
+ sampleCount = 1,
88
+ glassShadows = true,
89
+ /** The profile's `clusteredLights`, which compiles the clustered arm in or leaves it out. */
90
+ clusteredLights = false,
91
+ ) {
66
92
  this.device = device;
67
93
  this.format = format;
68
94
  this.sampleCount = sampleCount;
69
95
  this.glassShadows = glassShadows;
96
+ this.litSwitches.CLUSTERED_LIGHTS = clusteredLights;
70
97
  }
71
98
 
72
99
  /** How many distinct pipelines have been built. Read by tests and diagnostics. */
@@ -85,11 +112,15 @@ export class PipelineCache {
85
112
  async getAsync(
86
113
  key: string,
87
114
  describe: () => GPURenderPipelineDescriptor,
115
+ /** Whether this is a lit pipeline, which `enable` rebuilds. See `litSwitches`. */
116
+ lit = false,
88
117
  ): Promise<GPURenderPipeline> {
118
+ if (lit) this.litDescribers.set(key, describe);
89
119
  const existing = this.pipelines.get(key);
90
120
  if (existing !== undefined) return existing;
91
121
  const inFlight = this.compiling.get(key);
92
122
  if (inFlight !== undefined) return inFlight;
123
+ const generation = this.litGeneration;
93
124
 
94
125
  /*
95
126
  * **Falls back where the async form is missing.** It is part of the standard, but a device
@@ -103,7 +134,8 @@ export class PipelineCache {
103
134
  ? this.device.createRenderPipelineAsync(describe())
104
135
  : Promise.resolve(this.device.createRenderPipeline(describe()));
105
136
  const promise = build.then((built) => {
106
- this.pipelines.set(key, built);
137
+ /* A lit pipeline started before a switch is not kept: the switch rebuilds it. */
138
+ if (!lit || generation === this.litGeneration) this.pipelines.set(key, built);
107
139
  this.compiling.delete(key);
108
140
  return built;
109
141
  });
@@ -142,7 +174,13 @@ export class PipelineCache {
142
174
  * too**: a nested object with vertex buffer layouts and blend state, constructed to be
143
175
  * thrown away on every hit. Passing a function means a hit costs one `Map` lookup.
144
176
  */
145
- get(key: string, describe: () => GPURenderPipelineDescriptor): GPURenderPipeline {
177
+ get(
178
+ key: string,
179
+ describe: () => GPURenderPipelineDescriptor,
180
+ /** Whether this is a lit pipeline, which `enable` rebuilds. See `litSwitches`. */
181
+ lit = false,
182
+ ): GPURenderPipeline {
183
+ if (lit) this.litDescribers.set(key, describe);
146
184
  const existing = this.pipelines.get(key);
147
185
  if (existing !== undefined) return existing;
148
186
  const built = this.device.createRenderPipeline(describe());
@@ -150,6 +188,42 @@ export class PipelineCache {
150
188
  return built;
151
189
  }
152
190
 
191
+ /**
192
+ * Turn one of the lit stage's switches on, for every lit pipeline built from now on and every one
193
+ * already built. See `litSwitches`.
194
+ *
195
+ * **The rebuilt set lands at once**, when every one of them has compiled, so no frame draws some
196
+ * surfaces with a feature and others without. Until then the pipelines already built go on drawing
197
+ * as they were. Once only: a switch is not turned off again when the last user of it leaves,
198
+ * because a second switch would cost a second compile for a saving the first use showed was not
199
+ * needed. Glass is never turned on where the profile asked for no glass shadows.
200
+ */
201
+ enable(feature: LitSwitch): Promise<void> {
202
+ if (this.litSwitches[feature]) return this.litRebuild;
203
+ if (feature === 'GLASS_SHADOWS' && !this.glassShadows) return this.litRebuild;
204
+ this.litSwitches[feature] = true;
205
+ this.litGeneration += 1;
206
+ const generation = this.litGeneration;
207
+ this.litRebuild = this.litRebuild.then(async () => {
208
+ /* A later switch has its own rebuild, which describes every pipeline with this one on too. */
209
+ if (generation !== this.litGeneration) return;
210
+ const rebuilt = [...this.litDescribers].map(([key, describe]) => {
211
+ const descriptor = describe();
212
+ const build =
213
+ typeof this.device.createRenderPipelineAsync === 'function'
214
+ ? this.device.createRenderPipelineAsync(descriptor)
215
+ : Promise.resolve(this.device.createRenderPipeline(descriptor));
216
+ return build.then((pipeline) => [key, pipeline] as const);
217
+ });
218
+ const results = await Promise.allSettled(rebuilt);
219
+ if (generation !== this.litGeneration) return;
220
+ for (const result of results) {
221
+ if (result.status === 'fulfilled') this.pipelines.set(result.value[0], result.value[1]);
222
+ }
223
+ });
224
+ return this.litRebuild;
225
+ }
226
+
153
227
  /**
154
228
  * Drop every pipeline.
155
229
  *