@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
@@ -337,6 +337,8 @@ import {
337
337
  import type { GlobalMediumOptions } from '../../globalMedium.ts';
338
338
  import type { MeshData } from '../../mesh.ts';
339
339
  import { castsDepth, glassOf } from '../../shadowCasters.ts';
340
+ import { materialHasMaps, noteMapsWithoutUvs } from '../../mapsWithoutUvs.ts';
341
+ import { VIEW_MODEL_DEPTH_SHARE, viewModelDepthRange } from '../../viewModel.ts';
340
342
  import { GlassCasterList, type GlassReplaySink } from '../../glassCasters.ts';
341
343
  import { GpuGlassTint } from './glassTintPass.ts';
342
344
  import {
@@ -379,7 +381,7 @@ import {
379
381
  createDepthBindGroupLayout,
380
382
  depthPipeline,
381
383
  } from './depthPass.ts';
382
- import { PipelineCache } from './pipelineCache.ts';
384
+ import { PipelineCache, type LitSwitch } from './pipelineCache.ts';
383
385
  import {
384
386
  SKY_FIELDS,
385
387
  SKY_UNIFORM_SIZE,
@@ -2805,6 +2807,8 @@ export class WebGPURenderer implements RendererApi {
2805
2807
  private readonly uvScale = new Float32Array([1, 1]);
2806
2808
  /** Whether the bound material is seen from both faces. See `SurfaceMaterial.doubleSided`. */
2807
2809
  private materialDoubleSided = false;
2810
+ /** Whether the bound material has a map, for the warning a mesh with no coordinates earns. */
2811
+ private materialHasMaps = false;
2808
2812
 
2809
2813
  createSurfaceTexture(
2810
2814
  source: TexImageSource,
@@ -2937,6 +2941,7 @@ export class WebGPURenderer implements RendererApi {
2937
2941
  this.uvScale[0] = material?.uScale ?? 1;
2938
2942
  this.uvScale[1] = material?.vScale ?? 1;
2939
2943
  this.materialDoubleSided = material?.doubleSided === true;
2944
+ this.materialHasMaps = materialHasMaps(material);
2940
2945
  const normal = (material?.normal ?? null) as GpuSurfaceTexture | null;
2941
2946
  const orm = (material?.orm ?? null) as GpuSurfaceTexture | null;
2942
2947
  const emissiveMap = (material?.emissive ?? null) as GpuSurfaceTexture | null;
@@ -5450,6 +5455,27 @@ export class WebGPURenderer implements RendererApi {
5450
5455
  */
5451
5456
  /** The glass the open shadow pass was offered, kept for its own passes: `glassCasters.ts`. */
5452
5457
  private readonly glassCasters = new GlassCasterList();
5458
+ /** Whether a glass caster has ever been offered, which is when lit pipelines start reading it. */
5459
+ private glassSeen = false;
5460
+
5461
+ /**
5462
+ * The first glass caster switches every lit pipeline to reading glass. See
5463
+ * `PipelineCache.litSwitches`: until a pane exists the lookups would find nothing, and they cost
5464
+ * the lit pass on every surface whether they find anything or not.
5465
+ */
5466
+ private noteGlassCaster(): void {
5467
+ if (this.glassSeen) return;
5468
+ this.glassSeen = true;
5469
+ this.enableLit('GLASS_SHADOWS');
5470
+ }
5471
+
5472
+ /** One of the lit stage's switches on, in every cache a lit pipeline can come from. */
5473
+ private enableLit(feature: LitSwitch): void {
5474
+ if (this.pipelines.litSwitches[feature]) return;
5475
+ void this.pipelines.enable(feature);
5476
+ if (this.overlayPipelines !== this.pipelines) void this.overlayPipelines.enable(feature);
5477
+ if (this.latePipelines !== this.pipelines) void this.latePipelines.enable(feature);
5478
+ }
5453
5479
  /** One resolution every glass caster is read through, so recording one allocates nothing. */
5454
5480
  private readonly glassCasterScratch: ResolvedGlass = {
5455
5481
  transmission: 0,
@@ -5462,6 +5488,7 @@ export class WebGPURenderer implements RendererApi {
5462
5488
  /* Glass: kept aside for its own two passes rather than dropped. See `glassCasters.ts`. */
5463
5489
  if (this.quality.glassShadows !== 'off' && glassOf(material, this.glassCasterScratch)) {
5464
5490
  this.glassCasters.recordMesh(mesh, model, material, this.glassCasterScratch);
5491
+ this.noteGlassCaster();
5465
5492
  }
5466
5493
  return;
5467
5494
  }
@@ -5517,6 +5544,7 @@ export class WebGPURenderer implements RendererApi {
5517
5544
  if (!this.drawingGlassDepth && !castsDepth(material)) {
5518
5545
  if (this.quality.glassShadows !== 'off' && glassOf(material, this.glassCasterScratch)) {
5519
5546
  this.glassCasters.recordInstanced(batch, data, material, this.glassCasterScratch);
5547
+ this.noteGlassCaster();
5520
5548
  }
5521
5549
  return;
5522
5550
  }
@@ -5572,6 +5600,7 @@ export class WebGPURenderer implements RendererApi {
5572
5600
  if (!this.drawingGlassDepth && !castsDepth(material)) {
5573
5601
  if (this.quality.glassShadows !== 'off' && glassOf(material, this.glassCasterScratch)) {
5574
5602
  this.glassCasters.recordSkinned(mesh, model, palette, material, this.glassCasterScratch);
5603
+ this.noteGlassCaster();
5575
5604
  }
5576
5605
  return;
5577
5606
  }
@@ -5968,6 +5997,7 @@ export class WebGPURenderer implements RendererApi {
5968
5997
  quality.screenEffects ? sceneColorFormat(quality.hdrScene) : surface.format,
5969
5998
  this.samples,
5970
5999
  glassShadows,
6000
+ quality.clusteredLights,
5971
6001
  );
5972
6002
  /*
5973
6003
  * The canvas' own state, for the pass `ensurePass` reopens after `endFrame`.
@@ -5980,11 +6010,11 @@ export class WebGPURenderer implements RendererApi {
5980
6010
  this.overlayPipelines =
5981
6011
  this.pipelines.format === surface.format && this.samples === 1
5982
6012
  ? this.pipelines
5983
- : new PipelineCache(device, surface.format, 1, glassShadows);
6013
+ : new PipelineCache(device, surface.format, 1, glassShadows, quality.clusteredLights);
5984
6014
  this.latePipelines =
5985
6015
  this.pipelines.format === RECON_HISTORY_FORMAT && this.samples === 1
5986
6016
  ? this.pipelines
5987
- : new PipelineCache(device, RECON_HISTORY_FORMAT, 1, glassShadows);
6017
+ : new PipelineCache(device, RECON_HISTORY_FORMAT, 1, glassShadows, quality.clusteredLights);
5988
6018
  this.issueBound = this.issueCommand.bind(this);
5989
6019
  this.pipelineTargets =
5990
6020
  this.overlayPipelines === this.pipelines
@@ -6288,7 +6318,10 @@ export class WebGPURenderer implements RendererApi {
6288
6318
  /* The albedo's effects table, or a one-texel stand-in the shader's size test reads as none.
6289
6319
  The albedo is the group's key, so its table needs no key of its own. */
6290
6320
  if (name === 'uSurfaceEffects') {
6291
- return { view: this.albedo?.effectsView ?? blankView, sampler: blankSampler };
6321
+ const effects = this.albedo?.effectsView ?? null;
6322
+ /* The first table bound is when the lit pipelines start reading one. See `litSwitches`. */
6323
+ if (effects !== null) this.enableLit('SURFACE_EFFECTS');
6324
+ return { view: effects ?? blankView, sampler: blankSampler };
6292
6325
  }
6293
6326
  /*
6294
6327
  * The room, once a bake has filled it. Unbaked reads as the blank cube: nothing to mirror.
@@ -7752,6 +7785,7 @@ export class WebGPURenderer implements RendererApi {
7752
7785
  i[at('uLightingEnabled')] = 1;
7753
7786
  i[at('uFogEnabled')] = 1;
7754
7787
  i[at('uAlbedoEnabled')] = 0;
7788
+ this.materialHasMaps = false;
7755
7789
 
7756
7790
  /*
7757
7791
  * **The camera medium, which this pass did not upload until 2026-08-29.**
@@ -8284,6 +8318,7 @@ export class WebGPURenderer implements RendererApi {
8284
8318
  * where it happened to pass, write motion for a surface no pixel of the frame belongs to.
8285
8319
  */
8286
8320
  if (this.tracksMotionNow()) this.recordDrawMotion(mesh, model, previousModel);
8321
+ noteMapsWithoutUvs(mesh, mesh.hasUvs, this.materialHasMaps);
8287
8322
  this.submitMesh(mesh, model, tint, 1, false, { depthLayer });
8288
8323
  }
8289
8324
 
@@ -8389,6 +8424,7 @@ export class WebGPURenderer implements RendererApi {
8389
8424
  return;
8390
8425
  }
8391
8426
 
8427
+ noteMapsWithoutUvs(mesh, mesh.hasUvs, this.materialHasMaps);
8392
8428
  /* The tint travels in the options here, where `drawMesh` takes it positionally — see
8393
8429
  `TranslucentMeshOptions.tint` for why, and for the fade it was added for. */
8394
8430
  this.submitMesh(
@@ -12598,6 +12634,7 @@ export class WebGPURenderer implements RendererApi {
12598
12634
  command.instances = 1;
12599
12635
  command.indirect = null;
12600
12636
  recordNode(this.arena, VERB_DRAW, reads, writes, at, 0);
12637
+ if (this.framePresented) this.queueOverlayFlush();
12601
12638
  return command;
12602
12639
  }
12603
12640
 
@@ -12613,11 +12650,7 @@ export class WebGPURenderer implements RendererApi {
12613
12650
  if (verb === VERB_PASS) {
12614
12651
  const definition = passAt(this.passes, nodeState(this.arena, at));
12615
12652
  if (definition === undefined) return;
12616
- this.passContext.pass = pass;
12617
- /* The frame's grade, so a contributed pass can obey the 2026-08-17 rule. See `PassContext`. */
12618
- this.passContext.outputTransform = this.gradeCode();
12619
- this.passContext.outputExposure = this.gradeExposure();
12620
- definition.draw(this.passContext as PassContext);
12653
+ definition.draw(this.fillPassContext(pass));
12621
12654
  return;
12622
12655
  }
12623
12656
  if (verb !== VERB_DRAW) return;
@@ -12704,6 +12737,16 @@ export class WebGPURenderer implements RendererApi {
12704
12737
  }
12705
12738
 
12706
12739
  private openPass(): GPURenderPassEncoder | null {
12740
+ const was = this.pass;
12741
+ const pass = this.openAnyPass();
12742
+ /* A pass opened while a view model is drawn starts with the whole depth range: squeeze it. */
12743
+ if (this.viewModelActive && pass !== null && pass !== was && !this.reflectionPassActive) {
12744
+ this.squeezeViewport(pass);
12745
+ }
12746
+ return pass;
12747
+ }
12748
+
12749
+ private openAnyPass(): GPURenderPassEncoder | null {
12707
12750
  /* While the replay is running this is the only pass there is. See `oitEncoder`. */
12708
12751
  if (this.oitEncoder !== null) return this.oitEncoder;
12709
12752
  /*
@@ -12797,19 +12840,31 @@ export class WebGPURenderer implements RendererApi {
12797
12840
  });
12798
12841
  this.overlayActive = true;
12799
12842
 
12800
- /*
12801
- * **Submitted on a microtask, which runs before the browser presents.** There is no second
12802
- * `endFrame` to hang this on — a consumer thinks the frame is over — and presentation happens
12803
- * when the task ends, after the microtask checkpoint. Anything that needs the pixels sooner
12804
- * flushes synchronously; `copyRegionTo` is the one that does.
12805
- */
12806
- if (!this.overlayFlushQueued) {
12807
- this.overlayFlushQueued = true;
12808
- queueMicrotask(() => this.flushOverlay());
12809
- }
12843
+ this.queueOverlayFlush();
12810
12844
  return this.pass;
12811
12845
  }
12812
12846
 
12847
+ /**
12848
+ * Submit what is drawn after the present on a microtask, which runs before the browser presents.
12849
+ *
12850
+ * There is no second `endFrame` to hang this on — a consumer thinks the frame is over — and
12851
+ * presentation happens when the task ends, after the microtask checkpoint. Anything that needs
12852
+ * the pixels sooner flushes synchronously; `copyRegionTo` is the one that does.
12853
+ *
12854
+ * **Queued wherever such work begins: where the overlay pass opens, and where a verb records.**
12855
+ * With the graph on a verb records rather than opening a pass, and until 4.8.2 only an opening
12856
+ * queued this, so `fillPanel` and a contributed pass drawn after `endFrame` waited for the next
12857
+ * `beginFrame` — whose replay drew onto a canvas texture already presented and destroyed.
12858
+ */
12859
+ private queueOverlayFlush(): void {
12860
+ if (this.overlayFlushQueued) return;
12861
+ this.overlayFlushQueued = true;
12862
+ queueMicrotask(this.flushOverlayBound);
12863
+ }
12864
+
12865
+ /** `flushOverlay` bound once, so queueing it each frame allocates nothing. */
12866
+ private readonly flushOverlayBound = (): void => this.flushOverlay();
12867
+
12813
12868
  /**
12814
12869
  * The cache whose pipelines match the pass this draw will land in.
12815
12870
  *
@@ -12873,6 +12928,8 @@ export class WebGPURenderer implements RendererApi {
12873
12928
  }
12874
12929
 
12875
12930
  endFrame(): void {
12931
+ /* A view model left open would squeeze every pass after the world, the composite included. */
12932
+ if (this.viewModelActive) this.endViewModel();
12876
12933
  this.inFrame = false;
12877
12934
  if (this.surface.lost || this.encoder === null) return;
12878
12935
  /*
@@ -13341,14 +13398,40 @@ export class WebGPURenderer implements RendererApi {
13341
13398
  outputTransform: number;
13342
13399
  outputExposure: number;
13343
13400
  jitter: Float32Array;
13401
+ format: GPUTextureFormat;
13402
+ depthFormat: GPUTextureFormat;
13403
+ samples: number;
13344
13404
  } = {
13345
13405
  backend: 'webgpu',
13346
13406
  pass: null,
13347
13407
  outputTransform: 0,
13348
13408
  outputExposure: 1,
13349
13409
  jitter: this.passJitter,
13410
+ format: 'rgba8unorm',
13411
+ depthFormat: DEPTH_FORMAT,
13412
+ samples: 1,
13350
13413
  };
13351
13414
 
13415
+ /**
13416
+ * Fill the context a contributed pass draws with, for the pass it is about to draw into.
13417
+ *
13418
+ * **The attachments as well as the encoder**: a pass drawn after `endFrame` lands on the canvas
13419
+ * at one sample where the frame's own is the scene target at the scene's samples, and a pipeline
13420
+ * built for the one is refused by the other. The cache a mesh drawn here would use is the answer,
13421
+ * so the two cannot disagree.
13422
+ */
13423
+ private fillPassContext(pass: GPURenderPassEncoder): PassContext {
13424
+ const target = this.targetPipelines();
13425
+ const context = this.passContext;
13426
+ context.pass = pass;
13427
+ /* The frame's grade, so a contributed pass can obey the 2026-08-17 rule. See `PassContext`. */
13428
+ context.outputTransform = this.gradeCode();
13429
+ context.outputExposure = this.gradeExposure();
13430
+ context.format = target.format;
13431
+ context.samples = target.sampleCount;
13432
+ return context as PassContext;
13433
+ }
13434
+
13352
13435
  /**
13353
13436
  * Let something outside this file draw into the frame. See `Renderer.registerPass`.
13354
13437
  *
@@ -13502,11 +13585,7 @@ export class WebGPURenderer implements RendererApi {
13502
13585
  if (!this.quality.frameGraph) {
13503
13586
  const pass = this.openPass();
13504
13587
  if (pass === null) return;
13505
- this.passContext.pass = pass;
13506
- /* The frame's grade, so a contributed pass can obey the 2026-08-17 rule. See `PassContext`. */
13507
- this.passContext.outputTransform = this.gradeCode();
13508
- this.passContext.outputExposure = this.gradeExposure();
13509
- definition.draw(this.passContext as PassContext);
13588
+ definition.draw(this.fillPassContext(pass));
13510
13589
  return;
13511
13590
  }
13512
13591
  recordNode(
@@ -13517,6 +13596,7 @@ export class WebGPURenderer implements RendererApi {
13517
13596
  handle,
13518
13597
  0,
13519
13598
  );
13599
+ if (this.framePresented) this.queueOverlayFlush();
13520
13600
  }
13521
13601
 
13522
13602
  /** Let go of a pass. A handle kept past this draws nothing; see `PassHandle`. */
@@ -13728,7 +13808,10 @@ export class WebGPURenderer implements RendererApi {
13728
13808
  * **The clear is a drawn quad rather than a clear**, because WebGPU clears whole attachments
13729
13809
  * and cannot confine one to a scissor. See `insetPass.ts`.
13730
13810
  *
13731
- * Returns the drawing-buffer height of the inset, as the other backend does.
13811
+ * Returns the **aspect** of the inset, width over height in the pixels its viewport got, as the
13812
+ * other backend does — for `camera.updateMatrices`. With no pass to draw into, a lost device,
13813
+ * it is the aspect of the box asked for: there are no pixels to measure, and a zero would be a
13814
+ * projection of infinities in the caller's camera.
13732
13815
  */
13733
13816
  beginInset(rect: InsetRect, clearColor: Vec3 | null): number {
13734
13817
  /*
@@ -13738,7 +13821,8 @@ export class WebGPURenderer implements RendererApi {
13738
13821
  */
13739
13822
  if (this.quality.frameGraph) this.flushGraph();
13740
13823
  const pass = this.openPass();
13741
- if (this.surface.lost || pass === null) return 0;
13824
+ if (this.surface.lost || pass === null)
13825
+ return Math.max(rect.width, 1) / Math.max(rect.height, 1);
13742
13826
  const canvas = this.surface.context.canvas as HTMLCanvasElement;
13743
13827
  const cssWidth = Math.max(canvas.clientWidth, 1);
13744
13828
  const cssHeight = Math.max(canvas.clientHeight, 1);
@@ -13843,6 +13927,43 @@ export class WebGPURenderer implements RendererApi {
13843
13927
  return w / h;
13844
13928
  }
13845
13929
 
13930
+ /**
13931
+ * Draw what follows as a **view model**, in the nearest sliver of depth, until `endViewModel`.
13932
+ *
13933
+ * The twin of the WebGL2 method, which carries the reasons. Here the squeeze is the viewport's
13934
+ * depth bounds, which are pass state applied when a draw is issued, so both ends flush the graph
13935
+ * as an inset's do, and a pass opened in between is given the bounds again in `openPass`.
13936
+ */
13937
+ beginViewModel(share = VIEW_MODEL_DEPTH_SHARE): void {
13938
+ if (this.quality.frameGraph) this.flushGraph();
13939
+ viewModelDepthRange(this.reversedDepth, share, this.viewModelRange);
13940
+ this.viewModelActive = true;
13941
+ const pass = this.openPass();
13942
+ if (this.surface.lost || pass === null) return;
13943
+ this.squeezeViewport(pass);
13944
+ }
13945
+
13946
+ /** Give the whole depth range back. Safe to call without a matching `beginViewModel`. */
13947
+ endViewModel(): void {
13948
+ if (!this.viewModelActive) return;
13949
+ if (this.quality.frameGraph) this.flushGraph();
13950
+ this.viewModelActive = false;
13951
+ const pass = this.pass;
13952
+ if (this.surface.lost || pass === null) return;
13953
+ const [width, height] = this.passSize();
13954
+ pass.setViewport(0, 0, width, height, 0, 1);
13955
+ }
13956
+
13957
+ /** The view model's depth bounds on the whole of the pass. */
13958
+ private squeezeViewport(pass: GPURenderPassEncoder): void {
13959
+ const [width, height] = this.passSize();
13960
+ pass.setViewport(0, 0, width, height, this.viewModelRange[0], this.viewModelRange[1]);
13961
+ }
13962
+
13963
+ /** Whether a view model is being drawn: `openPass` re-applies it, `endFrame` closes it. */
13964
+ private viewModelActive = false;
13965
+ private readonly viewModelRange: [number, number] = [0, 1];
13966
+
13846
13967
  /** Give the whole canvas back. Safe to call without a matching `beginInset`. */
13847
13968
  endInset(): void {
13848
13969
  /*
@@ -15030,6 +15151,8 @@ export class WebGPURenderer implements RendererApi {
15030
15151
  this.iesRows = this.iesAtlas.height;
15031
15152
  this.iesPlanes = this.iesAtlas.planes;
15032
15153
  this.cookieTiles = this.cookieImages.length;
15154
+ /* The first profile or cookie is when the lit pipelines start reading them. See `litSwitches`. */
15155
+ if (this.iesRows > 1 || this.cookieTiles > 0) this.enableLit('LIGHT_FIXTURES');
15033
15156
  return this.fixtureView;
15034
15157
  }
15035
15158
 
@@ -15125,6 +15248,8 @@ export class WebGPURenderer implements RendererApi {
15125
15248
  );
15126
15249
  this.driftIndex = index;
15127
15250
  this.driftAtlas = atlas;
15251
+ /* The first volume is when the lit pipelines start reading one. See `litSwitches`. */
15252
+ this.enableLit('DRIFT_LIGHT');
15128
15253
  this.driftIndexView = index.createView({ dimension: '3d' });
15129
15254
  this.driftAtlasView = atlas.createView({ dimension: '3d' });
15130
15255
  this.bakeLightField = {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Saying, once a mesh, that a material's maps cannot be read on it.
3
+ *
4
+ * **A mesh with no texture coordinates reads one constant coordinate**, so every map bound to it
5
+ * samples a single texel: an emissive map makes nothing glow, a normal map tilts nothing, and the
6
+ * frame is identical pixel for pixel with and without the material's images. Found when an example's
7
+ * burning logs never glowed — `MeshBuilder` writes no coordinates unless `build({ planarUvs: true })`
8
+ * asks — and nothing said why. One decision for both backends, each calling it from its draw.
9
+ *
10
+ * Once per mesh, held weakly: a mesh drawn every frame is said once, and a disposed one is
11
+ * forgotten. **What it gives up** is a material shared between a textured mesh and an untextured one
12
+ * on purpose, which is said once for the untextured one and is then quiet.
13
+ */
14
+ import type { SurfaceMaterial } from './surfaceTexture.ts';
15
+
16
+ const told = new WeakSet<object>();
17
+
18
+ /**
19
+ * Whether a material binds a map that is a picture: albedo, normal or emissive.
20
+ *
21
+ * **Not ORM**, because a one-colour ORM map is how a draw sets a constant roughness and metalness,
22
+ * and read at one texel it is exactly that constant — the materials example draws its spheres so.
23
+ * The three counted here are pictures nobody binds to be read at one point.
24
+ */
25
+ export function materialHasMaps(material: SurfaceMaterial<unknown> | null): boolean {
26
+ if (material === null) return false;
27
+ return (
28
+ (material.albedo ?? null) !== null ||
29
+ (material.normal ?? null) !== null ||
30
+ (material.emissive ?? null) !== null
31
+ );
32
+ }
33
+
34
+ /** Warn the first time `mesh`, which has no coordinates, is drawn with a picture map bound. */
35
+ export function noteMapsWithoutUvs(mesh: object, hasUvs: boolean, materialHasMaps: boolean): void {
36
+ if (hasUvs || !materialHasMaps || told.has(mesh)) return;
37
+ told.add(mesh);
38
+ console.warn(
39
+ '[driftengine] a mesh with no texture coordinates is drawn with an albedo, normal or emissive ' +
40
+ 'map, which it cannot read: every map samples one texel. Give the mesh `uvs` — ' +
41
+ '`MeshBuilder.build({ planarUvs: true })` writes them — or draw it without the maps.',
42
+ );
43
+ }
@@ -136,6 +136,8 @@ export class Mesh {
136
136
  readonly bounds: Bounds = createBounds();
137
137
  /** Whether this mesh carries a rig, which decides which flat program draws it. */
138
138
  readonly isSkinned: boolean;
139
+ /** Whether it has texture coordinates, without which a material's maps read one texel. */
140
+ readonly hasUvs: boolean;
139
141
 
140
142
  /**
141
143
  * This mesh's morph deltas, or null for geometry that does not deform.
@@ -190,6 +192,7 @@ export class Mesh {
190
192
  constructor(gl: WebGL2RenderingContext, data: MeshData, dynamic = false, spread = false) {
191
193
  validateMeshData(data);
192
194
  this.spread = spread;
195
+ this.hasUvs = data.uvs !== undefined;
193
196
  boundsOfPositions(data.positions, this.bounds);
194
197
  this.positionFloats = data.positions.length;
195
198
  const vao = gl.createVertexArray();
@@ -117,6 +117,15 @@ export class ParticlePool {
117
117
  * together.
118
118
  */
119
119
  private readonly seedOf: Float32Array;
120
+ /**
121
+ * Where each slot's particle sits in the drawn streams, or -1 where it sits nowhere.
122
+ *
123
+ * **What lets `emit` draw a particle the frame it is born.** The streams are compacted, so a
124
+ * slot's entry is wherever `update` last put it; an emit into a slot that has one overwrites
125
+ * that entry, and an emit into one that has none appends. Without it a new particle waited for
126
+ * the next `update` to be drawn at all.
127
+ */
128
+ private readonly drawnAt: Int32Array;
120
129
  private next = 0;
121
130
 
122
131
  constructor(private readonly options: ParticlePoolOptions) {
@@ -138,6 +147,7 @@ export class ParticlePool {
138
147
  this.spin = new Float32Array(capacity);
139
148
  this.scale = new Float32Array(capacity).fill(1);
140
149
  this.seedOf = new Float32Array(capacity);
150
+ this.drawnAt = new Int32Array(capacity).fill(-1);
141
151
  }
142
152
 
143
153
  /** How many particles are alive. */
@@ -184,6 +194,18 @@ export class ParticlePool {
184
194
  this.spin[i] = (hash - Math.floor(hash)) * Math.PI * 2;
185
195
  this.scale[i] = sizeScale;
186
196
  this.seedOf[i] = seed;
197
+ /*
198
+ * Drawn now, at birth, rather than at the next `update`: into the entry this slot already
199
+ * has when it is taking over a particle that was drawn, and onto the end otherwise.
200
+ */
201
+ const held = this.drawnAt[i] as number;
202
+ const at = held >= 0 ? held : this.particles.count;
203
+ this.writeDrawn(i, at, 0, x, y, z, vx, vy, vz);
204
+ if (held < 0) {
205
+ this.drawnAt[i] = at;
206
+ this.instances.count = at + 1;
207
+ this.particles.count = at + 1;
208
+ }
187
209
  }
188
210
 
189
211
  /**
@@ -198,11 +220,15 @@ export class ParticlePool {
198
220
  let count = 0;
199
221
  for (let i = 0; i < this.age.length; i++) {
200
222
  const age = this.age[i] as number;
201
- if (age === Infinity) continue;
223
+ if (age === Infinity) {
224
+ this.drawnAt[i] = -1;
225
+ continue;
226
+ }
202
227
  const life = this.life[i] as number;
203
228
  const next = age + dt;
204
229
  if (next >= life) {
205
230
  this.age[i] = Infinity;
231
+ this.drawnAt[i] = -1;
206
232
  continue;
207
233
  }
208
234
  this.age[i] = next;
@@ -222,68 +248,89 @@ export class ParticlePool {
222
248
  this.y[i] = py;
223
249
  this.z[i] = pz;
224
250
 
225
- const t = next / life;
226
- const size = (o.sizeStart + (o.sizeEnd - o.sizeStart) * t) * (this.scale[i] as number);
227
- const r =
228
- (o.colorStart[0] as number) + ((o.colorEnd[0] as number) - (o.colorStart[0] as number)) * t;
229
- const g =
230
- (o.colorStart[1] as number) + ((o.colorEnd[1] as number) - (o.colorStart[1] as number)) * t;
231
- const b =
232
- (o.colorStart[2] as number) + ((o.colorEnd[2] as number) - (o.colorStart[2] as number)) * t;
233
- /*
234
- * Colour carries the fade rather than an alpha channel: the instanced
235
- * path multiplies a tint into the base mesh and has no per-instance
236
- * opacity, and for an additive puff darkening to nothing *is* fading out.
237
- * It also means the effect needs no blend-state change.
238
- */
239
- const fade = 1 - t * t;
240
- writeInstance(
241
- this.instances,
242
- count,
243
- px,
244
- py,
245
- pz,
246
- size,
247
- this.spin[i] as number,
248
- r * fade,
249
- g * fade,
250
- b * fade,
251
- 0,
252
- 0,
253
- 0,
254
- );
255
- /*
256
- * The same particle for a material that has a real opacity channel. Colour
257
- * is written *unfaded* here, because dimming it is the scatter path's
258
- * workaround and doing both would fade twice — a puff that vanished at half
259
- * its life while still occluding what was behind it.
260
- */
261
- const p = this.particles;
262
- const alphaStart = o.alphaStart ?? 1;
263
- const alphaEnd = o.alphaEnd ?? 0;
264
- p.positions[count * 3] = px;
265
- p.positions[count * 3 + 1] = py;
266
- p.positions[count * 3 + 2] = pz;
267
- p.sizes[count] = size;
268
- p.spins[count] = this.spin[i] as number;
269
- p.colors[count * 3] = r;
270
- p.colors[count * 3 + 1] = g;
271
- p.colors[count * 3 + 2] = b;
272
- p.alphas[count] = alphaStart + (alphaEnd - alphaStart) * t;
273
- p.ages[count] = t;
274
- p.seeds[count] = this.seedOf[i] as number;
275
- p.velocities[count * 3] = vx;
276
- p.velocities[count * 3 + 1] = vy;
277
- p.velocities[count * 3 + 2] = vz;
251
+ this.drawnAt[i] = count;
252
+ this.writeDrawn(i, count, next / life, px, py, pz, vx, vy, vz);
278
253
  count++;
279
254
  }
280
255
  this.instances.count = count;
281
256
  this.particles.count = count;
282
257
  }
283
258
 
259
+ /**
260
+ * One particle's entry in both drawn streams, at `t` of its life: what `update` writes for every
261
+ * live particle and `emit` for a new one, so the two cannot disagree about what a particle looks
262
+ * like.
263
+ */
264
+ private writeDrawn(
265
+ i: number,
266
+ count: number,
267
+ t: number,
268
+ px: number,
269
+ py: number,
270
+ pz: number,
271
+ vx: number,
272
+ vy: number,
273
+ vz: number,
274
+ ): void {
275
+ const o = this.options;
276
+ const size = (o.sizeStart + (o.sizeEnd - o.sizeStart) * t) * (this.scale[i] as number);
277
+ const r =
278
+ (o.colorStart[0] as number) + ((o.colorEnd[0] as number) - (o.colorStart[0] as number)) * t;
279
+ const g =
280
+ (o.colorStart[1] as number) + ((o.colorEnd[1] as number) - (o.colorStart[1] as number)) * t;
281
+ const b =
282
+ (o.colorStart[2] as number) + ((o.colorEnd[2] as number) - (o.colorStart[2] as number)) * t;
283
+ /*
284
+ * Colour carries the fade rather than an alpha channel: the instanced
285
+ * path multiplies a tint into the base mesh and has no per-instance
286
+ * opacity, and for an additive puff darkening to nothing *is* fading out.
287
+ * It also means the effect needs no blend-state change.
288
+ */
289
+ const fade = 1 - t * t;
290
+ writeInstance(
291
+ this.instances,
292
+ count,
293
+ px,
294
+ py,
295
+ pz,
296
+ size,
297
+ this.spin[i] as number,
298
+ r * fade,
299
+ g * fade,
300
+ b * fade,
301
+ 0,
302
+ 0,
303
+ 0,
304
+ );
305
+ /*
306
+ * The same particle for a material that has a real opacity channel. Colour
307
+ * is written *unfaded* here, because dimming it is the scatter path's
308
+ * workaround and doing both would fade twice — a puff that vanished at half
309
+ * its life while still occluding what was behind it.
310
+ */
311
+ const p = this.particles;
312
+ const alphaStart = o.alphaStart ?? 1;
313
+ const alphaEnd = o.alphaEnd ?? 0;
314
+ p.positions[count * 3] = px;
315
+ p.positions[count * 3 + 1] = py;
316
+ p.positions[count * 3 + 2] = pz;
317
+ p.sizes[count] = size;
318
+ p.spins[count] = this.spin[i] as number;
319
+ p.colors[count * 3] = r;
320
+ p.colors[count * 3 + 1] = g;
321
+ p.colors[count * 3 + 2] = b;
322
+ p.alphas[count] = alphaStart + (alphaEnd - alphaStart) * t;
323
+ p.ages[count] = t;
324
+ p.seeds[count] = this.seedOf[i] as number;
325
+ p.velocities[count * 3] = vx;
326
+ p.velocities[count * 3 + 1] = vy;
327
+ p.velocities[count * 3 + 2] = vz;
328
+ }
329
+
284
330
  /** Drop everything, for a respawn or a scene change. */
285
331
  clear(): void {
286
332
  this.age.fill(Infinity);
333
+ this.drawnAt.fill(-1);
287
334
  this.instances.count = 0;
288
335
  this.particles.count = 0;
289
336
  this.next = 0;
@@ -125,6 +125,18 @@ export type PassContext = {
125
125
  readonly pass: GPURenderPassEncoder;
126
126
  /** The frame's jitter, the same array `PrepareContext.jitter` held. */
127
127
  readonly jitter: Float32Array;
128
+ /**
129
+ * The colour format, depth format and sample count of the pass this draw lands in, which a
130
+ * pipeline set on it must match exactly.
131
+ *
132
+ * **Not always the device's.** `PassDevice` says what the frame is; a pass drawn after
133
+ * `endFrame` lands on the canvas, at the canvas's format and one sample, where the frame is
134
+ * the scene target at the scene's samples. A pass that is drawn in both places keeps a
135
+ * pipeline for each and chooses here; one built only from the device is refused by the other.
136
+ */
137
+ readonly format: GPUTextureFormat;
138
+ readonly depthFormat: GPUTextureFormat;
139
+ readonly samples: number;
128
140
  }
129
141
  );
130
142
 
@@ -194,12 +194,13 @@ export interface RenderQuality {
194
194
  * existed: a pane casts nothing at all.
195
195
  *
196
196
  * Nothing is allocated until a glass caster first reaches a shadow, so a scene with no glass pays
197
- * no memory and no pass whatever this says. **The lit shader is the exception**: `'full'` and
198
- * `'half'` compile the glass lookup into it, and a lookup that finds no glass and returns at its
199
- * first line still costs the lit pass its registers — measured on the courtyard at night, 0.27 ms
200
- * at 720p on WebGPU, 0.42 ms on WebGL2 and 1.2 ms at 4K. `'off'` compiles none of it, so a world
201
- * that never casts through glass is that much faster for saying so. What would make `'full'` wrong
202
- * is a phone budget with a lot of glass; that is what `'half'` is for. See `glassShadow.ts`.
197
+ * no memory and no pass whatever this says. **Nor any shader, since 4.8.2**: the lit shader is
198
+ * built without the glass lookup and rebuilt with it when the first glass caster reaches a shadow.
199
+ * A lookup that finds no glass still cost the lit pass its registers — measured on the courtyard at
200
+ * night, 0.27 ms at 720p on WebGPU, 0.42 ms on WebGL2 and 1.2 ms at 4K — and from 4.5.0 to 4.8.1
201
+ * every scene paid it, glass or none: about 30% of the lit shader's instructions, which on a phone
202
+ * was the lit pass. `'off'` never rebuilds. What would make `'full'` wrong is a phone budget with a
203
+ * lot of glass; that is what `'half'` is for. See `glassShadow.ts`.
203
204
  */
204
205
  readonly glassShadows: GlassShadows;
205
206
  /** Horizontal world-space reach over which a directional shadow dissolves. */