@driftengine/core 4.8.1 → 4.8.3

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 (159) hide show
  1. package/CHANGELOG.json +142 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +8 -0
  5. package/dist/input/actionMap.d.ts +6 -1
  6. package/dist/input/actionMap.js +20 -7
  7. package/dist/input/input.d.ts +28 -0
  8. package/dist/input/input.js +71 -9
  9. package/dist/input/inputHarness.d.ts +9 -0
  10. package/dist/input/inputHarness.js +34 -2
  11. package/dist/render/backend/materialChanges.d.ts +7 -2
  12. package/dist/render/backend/materialChanges.js +8 -3
  13. package/dist/render/backend/webgl2/renderer.d.ts +75 -3
  14. package/dist/render/backend/webgl2/renderer.js +209 -22
  15. package/dist/render/backend/webgpu/buffers.d.ts +2 -0
  16. package/dist/render/backend/webgpu/buffers.js +2 -0
  17. package/dist/render/backend/webgpu/causticsPass.js +2 -1
  18. package/dist/render/backend/webgpu/depthPass.d.ts +13 -3
  19. package/dist/render/backend/webgpu/depthPass.js +4 -3
  20. package/dist/render/backend/webgpu/filmPass.js +2 -1
  21. package/dist/render/backend/webgpu/flatPass.d.ts +5 -0
  22. package/dist/render/backend/webgpu/flatPass.js +18 -8
  23. package/dist/render/backend/webgpu/linePass.js +2 -1
  24. package/dist/render/backend/webgpu/panelPass.js +2 -1
  25. package/dist/render/backend/webgpu/particlePass.js +2 -1
  26. package/dist/render/backend/webgpu/pipelineCache.d.ts +40 -8
  27. package/dist/render/backend/webgpu/pipelineCache.js +76 -9
  28. package/dist/render/backend/webgpu/plumePass.js +2 -1
  29. package/dist/render/backend/webgpu/postPass.d.ts +12 -2
  30. package/dist/render/backend/webgpu/postPass.js +19 -5
  31. package/dist/render/backend/webgpu/renderer.d.ts +66 -2
  32. package/dist/render/backend/webgpu/renderer.js +189 -38
  33. package/dist/render/backend/webgpu/sdfTextPass.js +2 -1
  34. package/dist/render/backend/webgpu/textPass.js +2 -1
  35. package/dist/render/backend/webgpu/waterPass.js +2 -1
  36. package/dist/render/backend/webgpu/windStreakPass.js +2 -1
  37. package/dist/render/boltBatch.js +2 -1
  38. package/dist/render/causticsRenderer.js +2 -1
  39. package/dist/render/cutoutCaster.d.ts +3 -1
  40. package/dist/render/cutoutCaster.js +4 -0
  41. package/dist/render/decalPass.js +2 -1
  42. package/dist/render/globalMediumPass.js +3 -2
  43. package/dist/render/lineBatch.js +5 -1
  44. package/dist/render/mapsWithoutUvs.d.ts +24 -0
  45. package/dist/render/mapsWithoutUvs.js +24 -0
  46. package/dist/render/mesh.d.ts +2 -0
  47. package/dist/render/mesh.js +3 -0
  48. package/dist/render/oitPass.js +2 -1
  49. package/dist/render/particleBatch.js +3 -2
  50. package/dist/render/particlePool.d.ts +21 -2
  51. package/dist/render/particlePool.js +72 -37
  52. package/dist/render/pass.d.ts +12 -0
  53. package/dist/render/plumeRenderer.js +3 -2
  54. package/dist/render/renderQuality.d.ts +16 -8
  55. package/dist/render/sceneCoverage.d.ts +54 -0
  56. package/dist/render/sceneCoverage.js +72 -0
  57. package/dist/render/sdfTextRenderer.js +2 -1
  58. package/dist/render/shaders/depth.js +3 -2
  59. package/dist/render/shaders/flat/glassTint.d.ts +28 -0
  60. package/dist/render/shaders/flat/glassTint.js +31 -0
  61. package/dist/render/shaders/flat/index.d.ts +12 -0
  62. package/dist/render/shaders/flat/index.js +13 -2
  63. package/dist/render/shaders/flat/main.d.ts +1 -1
  64. package/dist/render/shaders/flat/main.js +60 -25
  65. package/dist/render/shaders/flat/surfaceEffects.d.ts +1 -1
  66. package/dist/render/shaders/flat/surfaceEffects.js +6 -2
  67. package/dist/render/shaders/generated/depth.wgsl.d.ts +32 -12
  68. package/dist/render/shaders/generated/depth.wgsl.js +32 -12
  69. package/dist/render/shaders/generated/flat.wgsl.d.ts +89 -0
  70. package/dist/render/shaders/generated/flat.wgsl.js +127 -38
  71. package/dist/render/shaders/generated/line.wgsl.d.ts +1 -1
  72. package/dist/render/shaders/generated/line.wgsl.js +1 -1
  73. package/dist/render/shaders/generated/particle.wgsl.d.ts +3 -3
  74. package/dist/render/shaders/generated/particle.wgsl.js +3 -3
  75. package/dist/render/shaders/generated/rush.wgsl.d.ts +1 -1
  76. package/dist/render/shaders/generated/rush.wgsl.js +1 -1
  77. package/dist/render/shaders/generated/sdfText.wgsl.d.ts +1 -1
  78. package/dist/render/shaders/generated/sdfText.wgsl.js +1 -1
  79. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
  80. package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
  81. package/dist/render/shaders/line.d.ts +1 -1
  82. package/dist/render/shaders/outputTransform.d.ts +14 -1
  83. package/dist/render/shaders/outputTransform.js +23 -1
  84. package/dist/render/shaders/particle.d.ts +3 -3
  85. package/dist/render/shaders/recon/resolve.wgsl.js +15 -1
  86. package/dist/render/shaders/rush.js +13 -2
  87. package/dist/render/shaders/sdfText.d.ts +1 -1
  88. package/dist/render/shaders/temporalResolve.js +12 -5
  89. package/dist/render/ssrPass.js +2 -1
  90. package/dist/render/surfaceTexture.d.ts +13 -0
  91. package/dist/render/surfaceTexture.js +4 -0
  92. package/dist/render/textRenderer.js +2 -1
  93. package/dist/render/vertexDefaults.js +1 -0
  94. package/dist/render/viewModel.d.ts +29 -0
  95. package/dist/render/viewModel.js +35 -0
  96. package/dist/render/waterRenderer.js +2 -1
  97. package/dist/render/windStreakRenderer.js +2 -1
  98. package/package.json +6 -6
  99. package/src/index.ts +22 -1
  100. package/src/input/actionMap.ts +23 -8
  101. package/src/input/input.ts +79 -9
  102. package/src/input/inputHarness.ts +27 -2
  103. package/src/render/backend/materialChanges.ts +8 -3
  104. package/src/render/backend/webgl2/renderer.ts +223 -29
  105. package/src/render/backend/webgpu/buffers.ts +5 -0
  106. package/src/render/backend/webgpu/causticsPass.ts +2 -1
  107. package/src/render/backend/webgpu/depthPass.ts +4 -3
  108. package/src/render/backend/webgpu/filmPass.ts +2 -1
  109. package/src/render/backend/webgpu/flatPass.ts +43 -26
  110. package/src/render/backend/webgpu/linePass.ts +2 -1
  111. package/src/render/backend/webgpu/panelPass.ts +2 -1
  112. package/src/render/backend/webgpu/particlePass.ts +2 -1
  113. package/src/render/backend/webgpu/pipelineCache.ts +82 -8
  114. package/src/render/backend/webgpu/plumePass.ts +2 -1
  115. package/src/render/backend/webgpu/postPass.ts +20 -5
  116. package/src/render/backend/webgpu/renderer.ts +191 -37
  117. package/src/render/backend/webgpu/sdfTextPass.ts +2 -1
  118. package/src/render/backend/webgpu/textPass.ts +2 -1
  119. package/src/render/backend/webgpu/waterPass.ts +2 -1
  120. package/src/render/backend/webgpu/windStreakPass.ts +2 -1
  121. package/src/render/boltBatch.ts +2 -1
  122. package/src/render/causticsRenderer.ts +2 -1
  123. package/src/render/cutoutCaster.ts +15 -2
  124. package/src/render/decalPass.ts +2 -1
  125. package/src/render/globalMediumPass.ts +3 -2
  126. package/src/render/lineBatch.ts +3 -1
  127. package/src/render/mapsWithoutUvs.ts +43 -0
  128. package/src/render/mesh.ts +3 -0
  129. package/src/render/oitPass.ts +2 -1
  130. package/src/render/particleBatch.ts +3 -2
  131. package/src/render/particlePool.ts +107 -56
  132. package/src/render/pass.ts +12 -0
  133. package/src/render/plumeRenderer.ts +3 -2
  134. package/src/render/renderQuality.ts +16 -8
  135. package/src/render/sceneCoverage.ts +78 -0
  136. package/src/render/sdfTextRenderer.ts +2 -1
  137. package/src/render/shaders/depth.ts +3 -2
  138. package/src/render/shaders/flat/glassTint.ts +48 -0
  139. package/src/render/shaders/flat/index.ts +25 -2
  140. package/src/render/shaders/flat/main.ts +60 -25
  141. package/src/render/shaders/flat/surfaceEffects.ts +6 -2
  142. package/src/render/shaders/generated/depth.wgsl.ts +32 -12
  143. package/src/render/shaders/generated/flat.wgsl.ts +127 -38
  144. package/src/render/shaders/generated/line.wgsl.ts +1 -1
  145. package/src/render/shaders/generated/particle.wgsl.ts +3 -3
  146. package/src/render/shaders/generated/rush.wgsl.ts +1 -1
  147. package/src/render/shaders/generated/sdfText.wgsl.ts +1 -1
  148. package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
  149. package/src/render/shaders/outputTransform.ts +24 -1
  150. package/src/render/shaders/recon/resolve.wgsl.ts +15 -1
  151. package/src/render/shaders/rush.ts +13 -2
  152. package/src/render/shaders/temporalResolve.ts +12 -5
  153. package/src/render/ssrPass.ts +2 -1
  154. package/src/render/surfaceTexture.ts +16 -0
  155. package/src/render/textRenderer.ts +2 -1
  156. package/src/render/vertexDefaults.ts +1 -0
  157. package/src/render/viewModel.ts +41 -0
  158. package/src/render/waterRenderer.ts +2 -1
  159. package/src/render/windStreakRenderer.ts +2 -1
package/src/index.ts CHANGED
@@ -743,6 +743,21 @@ export {
743
743
  REVERSED_DEPTH,
744
744
  conventionalDepth,
745
745
  } from './render/depthConvention.ts';
746
+ /*
747
+ * **What a contributed pass's blended draw does to the scene's alpha**, which carries how much of
748
+ * each pixel is still the opaque surface: the share the composite's ambient occlusion may darken.
749
+ * A pass blending into the scene states one of these for its alpha. See `sceneCoverage.ts`.
750
+ */
751
+ export {
752
+ SCENE_ALPHA_COVERS,
753
+ SCENE_ALPHA_KEEPS,
754
+ SCENE_ALPHA_TRANSMITS,
755
+ blendCovering,
756
+ blendKeeping,
757
+ blendTransmitting,
758
+ } from './render/sceneCoverage.ts';
759
+ /* How much of the depth range `beginViewModel` takes when it is given no share. See `viewModel.ts`. */
760
+ export { VIEW_MODEL_DEPTH_SHARE } from './render/viewModel.ts';
746
761
  export type { ColliderSurfaceOptions } from './physics/colliderSurface.ts';
747
762
  export { BoxSurface } from './physics/boxSurface.ts';
748
763
  export { heightSurface } from './physics/heightSurface.ts';
@@ -864,7 +879,13 @@ export {
864
879
  export { InputSource } from './input/input.ts';
865
880
  export { isTypingTarget } from './input/typingTarget.ts';
866
881
  export type { TouchPoint, InputCallbacks } from './input/input.ts';
867
- export type { GamepadIdentity, GamepadView, InputOptions, InputSourceName } from './input/input.ts';
882
+ export type {
883
+ GamepadIdentity,
884
+ GamepadView,
885
+ InputOptions,
886
+ InputSourceName,
887
+ MouseButton,
888
+ } from './input/input.ts';
868
889
  export type { InputDevice } from './input/activeDevice.ts';
869
890
  export type { GamepadFamily } from './input/gamepadIdentity.ts';
870
891
  /* Positions rather than lettering, and the constant a consumer overrides to taste. */
@@ -1,5 +1,5 @@
1
1
  import type { KeyValueStore } from '../core/storage.ts';
2
- import type { InputSource } from './input.ts';
2
+ import type { InputSource, MouseButton } from './input.ts';
3
3
  import type { GamepadButton } from './gamepadMapping.ts';
4
4
 
5
5
  /**
@@ -18,12 +18,15 @@ import type { GamepadButton } from './gamepadMapping.ts';
18
18
  /** One thing that can satisfy an action. */
19
19
  export type Binding =
20
20
  | { readonly device: 'keyboard'; readonly code: string }
21
- | { readonly device: 'gamepad'; readonly button: GamepadButton };
21
+ | { readonly device: 'gamepad'; readonly button: GamepadButton }
22
+ | { readonly device: 'mouse'; readonly button: MouseButton };
22
23
 
23
24
  /** Jump, fire, pause: a state, bound to keys and buttons. */
24
25
  export interface DigitalAction {
25
26
  readonly keys?: readonly string[];
26
27
  readonly buttons?: readonly GamepadButton[];
28
+ /** Mouse buttons, pressed over the input source's target: fire on the left, aim on the right. */
29
+ readonly mouseButtons?: readonly MouseButton[];
27
30
  }
28
31
 
29
32
  /**
@@ -92,7 +95,9 @@ export class ActionMap {
92
95
  return this.any(action, (binding) =>
93
96
  binding.device === 'keyboard'
94
97
  ? this.input.isDown(binding.code)
95
- : (this.input.pad(0)?.down(binding.button) ?? false),
98
+ : binding.device === 'mouse'
99
+ ? this.input.mouseDown(binding.button)
100
+ : (this.input.pad(0)?.down(binding.button) ?? false),
96
101
  );
97
102
  }
98
103
 
@@ -101,7 +106,9 @@ export class ActionMap {
101
106
  return this.any(action, (binding) =>
102
107
  binding.device === 'keyboard'
103
108
  ? this.input.keyPressed(binding.code)
104
- : (this.input.pad(0)?.pressed(binding.button) ?? false),
109
+ : binding.device === 'mouse'
110
+ ? this.input.mousePressed(binding.button)
111
+ : (this.input.pad(0)?.pressed(binding.button) ?? false),
105
112
  );
106
113
  }
107
114
 
@@ -118,6 +125,8 @@ export class ActionMap {
118
125
  for (const binding of bindings) {
119
126
  if (binding.device === 'keyboard') {
120
127
  if (this.input.consumeKeyPress(binding.code)) return true;
128
+ } else if (binding.device === 'mouse') {
129
+ if (this.input.consumeMousePress(binding.button)) return true;
121
130
  } else if (this.input.pad(0)?.consumePress(binding.button) === true) {
122
131
  return true;
123
132
  }
@@ -354,16 +363,19 @@ function bindingsOf(definition: DigitalAction): Binding[] {
354
363
  const list: Binding[] = [];
355
364
  for (const code of definition.keys ?? []) list.push({ device: 'keyboard', code });
356
365
  for (const button of definition.buttons ?? []) list.push({ device: 'gamepad', button });
366
+ for (const button of definition.mouseButtons ?? []) list.push({ device: 'mouse', button });
357
367
  return list;
358
368
  }
359
369
 
360
370
  function sameBinding(a: Binding, b: Binding): boolean {
361
- if (a.device !== b.device) return false;
362
- return a.device === 'keyboard' && b.device === 'keyboard'
363
- ? a.code === b.code
364
- : a.device === 'gamepad' && b.device === 'gamepad' && a.button === b.button;
371
+ if (a.device === 'keyboard' || b.device === 'keyboard') {
372
+ return a.device === 'keyboard' && b.device === 'keyboard' && a.code === b.code;
373
+ }
374
+ return a.device === b.device && a.button === b.button;
365
375
  }
366
376
 
377
+ const MOUSE_BUTTONS: ReadonlySet<string> = new Set(['left', 'middle', 'right', 'back', 'forward']);
378
+
367
379
  function sameList(a: readonly Binding[], b: readonly Binding[]): boolean {
368
380
  return a.length === b.length && a.every((entry, at) => sameBinding(entry, b[at] as Binding));
369
381
  }
@@ -373,5 +385,8 @@ function isBinding(value: unknown): value is Binding {
373
385
  if (typeof value !== 'object' || value === null) return false;
374
386
  const candidate = value as { device?: unknown; code?: unknown; button?: unknown };
375
387
  if (candidate.device === 'keyboard') return typeof candidate.code === 'string';
388
+ if (candidate.device === 'mouse') {
389
+ return typeof candidate.button === 'string' && MOUSE_BUTTONS.has(candidate.button);
390
+ }
376
391
  return candidate.device === 'gamepad' && typeof candidate.button === 'string';
377
392
  }
@@ -104,6 +104,23 @@ export interface TouchPoint {
104
104
  /** The devices this source watches, and the granularity a consumer may switch off. */
105
105
  export type InputSourceName = 'keyboard' | 'mouse' | 'touch' | 'gamepad';
106
106
 
107
+ /** A mouse button by what it is, in `MouseEvent.button` order. */
108
+ export type MouseButton = 'left' | 'middle' | 'right' | 'back' | 'forward';
109
+
110
+ /** One bit a button, so the held, pressed and claimed states are integers and a read allocates nothing. */
111
+ const MOUSE_BIT: Readonly<Record<MouseButton, number>> = {
112
+ left: 1,
113
+ middle: 2,
114
+ right: 4,
115
+ back: 8,
116
+ forward: 16,
117
+ };
118
+
119
+ /** `MouseEvent.button` as a bit, or 0 for a button past the five this names. */
120
+ function mouseBit(button: number): number {
121
+ return button >= 0 && button < 5 ? 1 << button : 0;
122
+ }
123
+
107
124
  export interface InputOptions {
108
125
  /**
109
126
  * Turn a device off. Absent means on.
@@ -420,6 +437,19 @@ export class InputSource {
420
437
  private mouseDx = 0;
421
438
  private mouseDy = 0;
422
439
  private mouseButtonDown = false;
440
+ /**
441
+ * Each mouse button's own state, as the keyboard's sets are for keys: held, pressed since the
442
+ * last poll, claimed this frame, and waiting for somebody to act on it. Bits, by `MOUSE_BIT`.
443
+ *
444
+ * **Beside `mouseButtonDown`, not instead of it.** That flag is "a button is held", which is what
445
+ * an unlocked drag-to-look reads; a game binding fire to the left button and aim to the right
446
+ * needs to know which, and to see a release of one while the other stays down.
447
+ */
448
+ private mouseHeld = 0;
449
+ private mousePressedNow = 0;
450
+ private mousePressedNext = 0;
451
+ private mouseClaimed = 0;
452
+ private mouseUnclaimed = 0;
423
453
  private readonly disposers: Array<() => void> = [];
424
454
 
425
455
  /** Which device the player last actually used, and who wants telling when that changes. */
@@ -531,13 +561,21 @@ export class InputSource {
531
561
  this.keysUnclaimed.clear();
532
562
  });
533
563
 
534
- this.listen(target, 'mousedown', () => {
564
+ this.listen(target, 'mousedown', (e: MouseEvent) => {
535
565
  if (!this.enabled.mouse) return;
536
566
  this.mouseButtonDown = true;
567
+ const bit = mouseBit(e.button);
568
+ this.mouseHeld |= bit;
569
+ this.mousePressedNext |= bit;
570
+ this.mouseUnclaimed |= bit;
537
571
  this.active.use('mouse');
538
572
  });
539
- this.listen(window, 'mouseup', () => {
573
+ /* On the window, so a press that becomes a drag off the canvas still comes back up. */
574
+ this.listen(window, 'mouseup', (e: MouseEvent) => {
540
575
  this.mouseButtonDown = false;
576
+ const bit = mouseBit(e.button);
577
+ this.mouseHeld &= ~bit;
578
+ this.mouseUnclaimed &= ~bit;
541
579
  });
542
580
  /*
543
581
  * **Released by anything that can eat the mouseup, exactly as the keys are.**
@@ -559,15 +597,11 @@ export class InputSource {
559
597
  * `visibilitychange` as well as `blur`, because a tab switched away with a button held
560
598
  * comes back to a document that never saw the release either.
561
599
  */
562
- this.listen(window, 'blur', () => {
563
- this.mouseButtonDown = false;
564
- });
565
- this.listen(window, 'pointercancel', () => {
566
- this.mouseButtonDown = false;
567
- });
600
+ this.listen(window, 'blur', () => this.releaseMouse());
601
+ this.listen(window, 'pointercancel', () => this.releaseMouse());
568
602
  /* Not in `GlobalEventHandlersEventMap`, so it is bound directly and disposed by hand. */
569
603
  const onHidden = (): void => {
570
- if (document.visibilityState !== 'visible') this.mouseButtonDown = false;
604
+ if (document.visibilityState !== 'visible') this.releaseMouse();
571
605
  };
572
606
  document.addEventListener('visibilitychange', onHidden);
573
607
  this.disposers.push(() => document.removeEventListener('visibilitychange', onHidden));
@@ -676,6 +710,10 @@ export class InputSource {
676
710
  spent.clear();
677
711
  this.keysPressedNext = spent;
678
712
  this.keysClaimed.clear();
713
+ /* The mouse buttons' frame boundary, on the same poll and by the same rule. */
714
+ this.mousePressedNow = this.mousePressedNext;
715
+ this.mousePressedNext = 0;
716
+ this.mouseClaimed = 0;
679
717
 
680
718
  if (!this.enabled.gamepad) return;
681
719
  const source = (globalThis as { navigator?: { getGamepads?: () => (RawPad | null)[] } })
@@ -768,6 +806,38 @@ export class InputSource {
768
806
  return true;
769
807
  }
770
808
 
809
+ /**
810
+ * Whether a mouse button is held: pressed over the target and not yet released anywhere.
811
+ *
812
+ * A browser opens its context menu on the right button's release; a game binding that button
813
+ * cancels `contextmenu` on its canvas, which is a decision about the page and not this source's.
814
+ */
815
+ mouseDown(button: MouseButton): boolean {
816
+ return (this.mouseHeld & MOUSE_BIT[button]) !== 0;
817
+ }
818
+
819
+ /** Whether a mouse button went down since the last poll, as `keyPressed` is for a key. */
820
+ mousePressed(button: MouseButton): boolean {
821
+ const bit = MOUSE_BIT[button];
822
+ return (this.mousePressedNow & bit) !== 0 && (this.mouseClaimed & bit) === 0;
823
+ }
824
+
825
+ /** The same edge, claimed, as `consumeKeyPress` is for a key: true for exactly one caller. */
826
+ consumeMousePress(button: MouseButton): boolean {
827
+ const bit = MOUSE_BIT[button];
828
+ if ((this.mouseUnclaimed & bit) === 0) return false;
829
+ this.mouseUnclaimed &= ~bit;
830
+ this.mouseClaimed |= bit;
831
+ return true;
832
+ }
833
+
834
+ /** Every button up and every waiting press gone: what a lost focus or a cancelled pointer means. */
835
+ private releaseMouse(): void {
836
+ this.mouseButtonDown = false;
837
+ this.mouseHeld = 0;
838
+ this.mouseUnclaimed = 0;
839
+ }
840
+
771
841
  /**
772
842
  * Add a device-event subscriber. Multiple reusable control layers may listen
773
843
  * without overwriting one another; interpretation still happens outside the
@@ -96,9 +96,19 @@ export function fakeBrowser(id = 'Xbox 360 Controller (XInput STANDARD GAMEPAD)'
96
96
  const frameCallbacks: ((time: number) => void)[] = [];
97
97
  let nextFrameHandle = 1;
98
98
 
99
+ const targetListeners = new Map<string, ((event: unknown) => void)[]>();
99
100
  const target = {
100
- addEventListener: () => {},
101
- removeEventListener: () => {},
101
+ addEventListener: (type: string, fn: (event: unknown) => void) => {
102
+ const existing = targetListeners.get(type);
103
+ if (existing === undefined) targetListeners.set(type, [fn]);
104
+ else existing.push(fn);
105
+ },
106
+ removeEventListener: (type: string, fn: (event: unknown) => void) => {
107
+ const existing = targetListeners.get(type);
108
+ if (existing === undefined) return;
109
+ const at = existing.indexOf(fn);
110
+ if (at >= 0) existing.splice(at, 1);
111
+ },
102
112
  requestPointerLock: () => undefined,
103
113
  getBoundingClientRect: () => ({ left: 0, top: 0, width: 640, height: 480 }),
104
114
  } as unknown as HTMLElement;
@@ -204,6 +214,21 @@ export function fakeBrowser(id = 'Xbox 360 Controller (XInput STANDARD GAMEPAD)'
204
214
  keyUp(code: string): void {
205
215
  for (const fn of windowListeners.get('keyup') ?? []) fn({ code });
206
216
  },
217
+ /**
218
+ * Press a mouse button over the target, by `MouseEvent.button`: 0 left, 1 middle, 2 right.
219
+ * Pressed on the target and released on the window, as a browser delivers a drag that ends
220
+ * off the canvas.
221
+ */
222
+ mouseDown(button: number): void {
223
+ for (const fn of targetListeners.get('mousedown') ?? []) fn({ button, movementX: 0 });
224
+ },
225
+ mouseUp(button: number): void {
226
+ for (const fn of windowListeners.get('mouseup') ?? []) fn({ button });
227
+ },
228
+ /** The window losing focus, which eats every release that would have followed. */
229
+ blur(): void {
230
+ for (const fn of windowListeners.get('blur') ?? []) fn({});
231
+ },
207
232
  /** Run exactly one animation frame, which is what a poll rides on. */
208
233
  frame(): void {
209
234
  const pending = frameCallbacks.splice(0, frameCallbacks.length);
@@ -31,12 +31,17 @@ export interface DrawMaterialOptions {
31
31
  readonly refracting: boolean;
32
32
  }
33
33
 
34
- /** Whether a draw's options differ from the pass's, so it takes a material and leaves one to restore. */
35
- export function ownsMaterial(options: DrawMaterialOptions): boolean {
34
+ /**
35
+ * Whether a draw's options differ from the pass's, so it takes a material and leaves one to restore.
36
+ *
37
+ * `passFog` is how the surfaces around it meet the medium — `FOG_RECEDE` unless `setSurfaceFog`
38
+ * turned it off — so a translucent draw asking for what the pass already has takes nothing.
39
+ */
40
+ export function ownsMaterial(options: DrawMaterialOptions, passFog = FOG_RECEDE): boolean {
36
41
  return (
37
42
  options.opacity < 1 ||
38
43
  !options.lit ||
39
- options.fog !== FOG_RECEDE ||
44
+ options.fog !== passFog ||
40
45
  !options.toneMapped ||
41
46
  options.refracting
42
47
  );