@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
package/CHANGELOG.json CHANGED
@@ -1,4 +1,90 @@
1
1
  [
2
+ {
3
+ "version": "4.8.2",
4
+ "date": "2026-10-03",
5
+ "entries": [
6
+ {
7
+ "kind": "fixed",
8
+ "short": "A component a script names to `drift/ecs` by a string counts as the access the schedule checks, so `reads Hunger` for a count is no longer called unused.",
9
+ "text": "`ecs.count(world, \"Hunger\")` reaches a schedule that refuses a component the system did not declare, and the compiler never counted the name: the declaration the host demanded was called unused (DS0291), and leaving it out compiled clean and was refused once per tick. DriftScript 1.17.0 lets a capability say which parameter names a component and how, and `drift/ecs` marks `has`, `read`, `count`, `at`, `findNearest`, `query` and `view` as reads and `attach`, `detach` and `write` as writes, so a string literal there counts exactly as `e.Hunger` does. `without` is not marked, as an exclusion never looks inside the component. driftscript 1.17.0, pinned in four manifests: the editor carries the pin too, which is now said where the others are. Reported from a game."
10
+ },
11
+ {
12
+ "kind": "fixed",
13
+ "short": "The lit shader carries glass, profiles and cookies, effects tables, DriftLight and clustering only once a scene uses them, so a phone no longer pays for all of it on every pixel.",
14
+ "text": "From 4.5.0 every lit pipeline compiled in the coloured-glass shadow lookups, and since then the measured-profile and cookie reads, the effects-table load, DriftLight's volume reads and the clustered arm of the light loop, whether a scene used any of them or not. Each sat behind a uniform branch, which keeps its code and its registers; the clustered arm also ran a view transform, two logarithms and a texel fetch on every lit pixel. On a desktop GPU none of this showed; on a phone the point-lit scenes fell to 10 to 20 fps while the sea and the voxel sandbox held, which is what pointed at the lit pass. On the phone that reported it, 4.8.2 runs them smoothly where 4.8.1 crawled. Each is now a pipeline constant the device compiles away, off until it is used and then rebuilt on, once: glass when a shadow pass is first offered a pane, fixtures when a profile or cookie is loaded, effects when a material carries a table, DriftLight when a volume is set; clustering follows `clusteredLights`. On WebGPU the rebuilt set swaps in together when it has compiled, so no frame mixes the two; on WebGL2 it is a synchronous recompile at the next `beginFrame`. Measured with RADV's own statistics, the lit shader went from 10,134 instructions and 144 registers to 4,641 and 96 on WebGL2, and from 10,544 and 120 to 5,276 and 96 on WebGPU, below 4.4.2's 5,335 and 5,859. Every published scene is pixel-identical to 4.8.1 on both backends, and so are the pages that turn each feature on: glass under the sun, a lamp and a rectangle, a profile, an effects table, a DriftLight volume and clustered lighting. What it costs is the frames between a feature's first use and its rebuild, which draw without it."
15
+ },
16
+ {
17
+ "kind": "fixed",
18
+ "short": "On WebGPU a panel or a contributed pass drawn after `endFrame` with the frame graph on is drawn, where it was lost to a destroyed canvas texture.",
19
+ "text": "With the graph on, a verb drawn after `endFrame` records rather than opening the overlay pass, and only an opening queued the microtask that submits it before the browser presents. `fillPanel` and `drawPass` recorded and queued nothing, so their work waited for the next `beginFrame`, whose replay drew onto a canvas texture already presented: \"Destroyed texture used in a submit\", and no interface. Recording after the present now queues the submission as opening it does. Reported from a game drawing its HUD over the finished frame, which had turned the graph off to get it back."
20
+ },
21
+ {
22
+ "kind": "fixed",
23
+ "short": "A contributed pass is told the format and samples of the pass it lands in, and ui2d's sprite pass draws after `endFrame` on WebGPU, where it was refused.",
24
+ "text": "`PassDevice` says what the frame is, the scene target at the scene's samples, and a pass drawn after `endFrame` lands on the canvas at one sample, so a pipeline built from the device was refused there. The WebGPU `PassContext` now carries `format`, `depthFormat` and `samples` for the pass the draw lands in, read from the same cache a mesh drawn there uses, and `@driftengine/ui2d`'s sprite pass keeps a pipeline per target and chooses at the draw. A game had registered its HUD's sprite pass for the canvas by hand to get round it. Code that builds a `PassContext` itself, which is test code, now supplies the three."
25
+ },
26
+ {
27
+ "kind": "fixed",
28
+ "short": "A ragdoll turns each joint by the bone that starts at it, so a skinned limb follows its body, where it trailed by a bone.",
29
+ "text": "`writePose` turned joint j by the body standing on the bone that ends at j, and a skinned limb follows the joint it starts from: the forearm is weighted to the elbow. So a limb was drawn in line with the bone before it, 0.47 m from its body at the hands in a doll synced and never stepped, as reported from a game. Each joint is now turned by its first bone below, and a joint with none below it rides the bone it hangs off; `drive` steers the same bone the write reads, and `sync` carries a bone's twist about its own length, which a wrist with a thumb beside the fingers needs. Translations are each parent's own frame, where they were world offsets that only agreed with a rig unrotated at rest. A root's rotation is written in the frame of the node placed at `rootX`, `rootY` and `rootZ`. The chains the tests were built from were straight and unrotated, which is why none of this showed; an arm with real rotations now stands beside them."
30
+ },
31
+ {
32
+ "kind": "fixed",
33
+ "short": "A character controller accelerates and brakes along a diagonal as fast as along an axis, where it gained speed √2 times as fast.",
34
+ "text": "The wanted velocity was clamped as a vector and then approached one world axis at a time, each by the whole step, so a diagonal from rest had 2.83 m/s three ticks in where a straight run had 2.0, and stopped √2 times as fast. It now moves toward the wanted velocity by at most the step in length, and lands on it exactly. What it gives up is that each axis no longer reaches its target on its own schedule: turning from one direction to another now curves rather than squaring the corner."
35
+ },
36
+ {
37
+ "kind": "added",
38
+ "short": "An action can be bound to mouse buttons, `{ device: 'mouse', button: 'left' }`, held, pressed and claimed like a key.",
39
+ "text": "`DigitalAction.mouseButtons` and the `mouse` binding device, saved and loaded with the rest, and `InputSource.mouseDown`, `mousePressed` and `consumeMousePress` per button, with the keyboard's edge rules: a press is pressed over the source's target, released anywhere, and a lost focus or a cancelled pointer releases every button. `MouseButton` is `left`, `middle`, `right`, `back` or `forward`. A binding narrowed as keyboard-or-else-gamepad no longer compiles, since there is a third device; the input example and the gamepad page are corrected. A game read `mousedown` itself beside the actions it bound everything else through."
40
+ },
41
+ {
42
+ "kind": "added",
43
+ "short": "`beginViewModel` and `endViewModel` draw a first person's arms and weapon in the nearest sliver of depth, so they never go into a wall and the world's depth is kept.",
44
+ "text": "What is drawn between the two lands in the nearest share of the depth range, 1% by default (`VIEW_MODEL_DEPTH_SHARE`): the viewport's depth bounds on WebGPU and `gl.depthRange` on WebGL2, at whichever end the context's convention puts near. The world's depth is not cleared, so ambient occlusion, depth of field and fog still see the world behind the weapon, which clearing it inside an inset did not. `endFrame` closes a view model left open. Its pixels take no motion of their own under temporal reconstruction, so a view model is drawn with multisampling. A game had squeezed the depth through a camera of its own for want of this."
45
+ },
46
+ {
47
+ "kind": "fixed",
48
+ "short": "`ParticlePool.emit` draws a particle the frame it is born, where it waited for the next `update`.",
49
+ "text": "The drawn streams are compacted live particles and only `update` rebuilt them, so a burst emitted after the frame's update, a muzzle flash or a hit's sparks, appeared a frame late, and a pool updated only while it had something live never showed its first particle. `emit` now writes the particle into both streams at once, into the entry of the particle it replaces where the ring is full, and onto the end otherwise. A game called `update(0)` before every draw to see its own effects."
50
+ },
51
+ {
52
+ "kind": "fixed",
53
+ "short": "The tools overlay draws its panels on a background under their titles, the console draws where it has scrolled, and Ctrl+Shift+Z redoes.",
54
+ "text": "The overlay gives each panel a dark background and a title row `PANEL_TITLE_HEIGHT` pixels tall, so the tools example no longer lays a backdrop under each site; colours with alpha reach the painter as `#rrggbbaa`, which its contract always allowed. The console copies its scroll to the node the overlay draws and takes the window from the height its panel is given, so it shows the newest lines, a wheel moves them and a click picks the row under the pointer; the overlay takes a wheel over a panel whether or not the panel made a command of it, so the page no longer scrolls underneath. Ctrl+Shift+Z redoes with the capital a browser reports for it. An `f32` field reads as the shortest decimal that stores as the same float, `0.55` rather than `0.550000011920929`. A `LockstepSession` is handed to `observeSession` and `sessionReadout` as it is, through a public `rewindDepth`, and the package README edits through `setFieldCommand` where its example did not compile."
55
+ },
56
+ {
57
+ "kind": "changed",
58
+ "short": "`World.add` and `definePrefab` refuse a field the component does not have, where the value was dropped without a word.",
59
+ "text": "A store fills every field its schema declares and never looked at the other keys, so a misspelt field was dropped and the field it meant took its default; one consumer's had been dropped since the component was written. An add now refuses one by name and lists the fields there are, before anything is stored, as `write` and a component's declared defaults already did, and `definePrefab` refuses one when the prefab is defined rather than at its first spawn. A scene read back is migrated onto the current schema first, so a renamed field in a save still loads."
60
+ },
61
+ {
62
+ "kind": "fixed",
63
+ "short": "An interface tree draws and hit-tests by layer, so a raised popup covers a later sibling and takes the pointer there.",
64
+ "text": "`UiNode.layer` was sorted by `layerOrder` and read by nothing that draws or points: a dropdown raised over the panel its control sits in was drawn under a later sibling and the pointer reached the sibling through it. `drawUiTree` now draws layer by layer, lowest first and tree order within one, each with the clips it would have had, and `uiHitTest` searches from the highest layer down. A tree with no raised node is drawn and searched in one walk, as before."
65
+ },
66
+ {
67
+ "kind": "fixed",
68
+ "short": "An audio mix that is not running hands a recording no stream, so a clip started before a gesture keeps its picture instead of half a second of it.",
69
+ "text": "`AudioGraph.captureStream` answered a live track from a suspended context, which never carries a sample, and a recorder given one stalled on it: a recording started from a script click kept 19 frames of 120 and no sound. It answers `null` until the context runs, and the tap after."
70
+ },
71
+ {
72
+ "kind": "fixed",
73
+ "short": "`beginInset` is documented as returning the aspect it returns, and answers the asked-for box's aspect on a lost WebGPU device instead of 0.",
74
+ "text": "The WebGPU renderer's comment said it returned the inset's height in device pixels, which both backends stopped doing; and with no pass to draw into it returned 0, a projection of infinities in the caller's camera. It answers the aspect of the box asked for there."
75
+ },
76
+ {
77
+ "kind": "added",
78
+ "short": "A mesh with no texture coordinates drawn with an albedo, normal or emissive map is said once, where the maps were ignored in silence.",
79
+ "text": "Every map on such a mesh reads one texel, so an emissive map made nothing glow and the frame was identical with and without it. The first such draw of each mesh says so, on both backends. A one-colour ORM map is not counted: read at one texel it is the constant roughness and metalness it was bound for."
80
+ },
81
+ {
82
+ "kind": "fixed",
83
+ "short": "`probeXrSupport` takes the WebGL2 context a game draws with, without a cast, and `XrRuntime` is exported.",
84
+ "text": "Its parameter was a type whose one member is optional, which TypeScript treats as weak, and the DOM types do not declare `makeXRCompatible` on `WebGL2RenderingContext`, so the context every game holds did not compile until it was cast. It takes any object and asks for the method. `XrRuntime`, what `HostServices.xr` takes, is exported from `@driftengine/script`."
85
+ }
86
+ ]
87
+ },
2
88
  {
3
89
  "version": "4.8.1",
4
90
  "date": "2026-10-03",
package/dist/index.d.ts CHANGED
@@ -277,6 +277,7 @@ export type { GroundSurface, RibbonSurfaceOptions, SurfaceHit } from './physics/
277
277
  export { CompositeSurface } from './physics/compositeSurface.ts';
278
278
  export { colliderSurface } from './physics/colliderSurface.ts';
279
279
  export { DEPTH_CLEAR, DEPTH_COMPARE, DEPTH_COMPARE_EQUAL, DEPTH_FORMAT, DEPTH_OFFSET_SIGN, REVERSED_DEPTH, conventionalDepth, } from './render/depthConvention.ts';
280
+ export { VIEW_MODEL_DEPTH_SHARE } from './render/viewModel.ts';
280
281
  export type { ColliderSurfaceOptions } from './physics/colliderSurface.ts';
281
282
  export { BoxSurface } from './physics/boxSurface.ts';
282
283
  export { heightSurface } from './physics/heightSurface.ts';
@@ -307,7 +308,7 @@ export { AXIS_X, AXIS_Y, AXIS_Z, ColliderSet, aabbFromCenter, bodyBounds, boxCol
307
308
  export { InputSource } from './input/input.ts';
308
309
  export { isTypingTarget } from './input/typingTarget.ts';
309
310
  export type { TouchPoint, InputCallbacks } from './input/input.ts';
310
- export type { GamepadIdentity, GamepadView, InputOptions, InputSourceName } from './input/input.ts';
311
+ export type { GamepadIdentity, GamepadView, InputOptions, InputSourceName, MouseButton, } from './input/input.ts';
311
312
  export type { InputDevice } from './input/activeDevice.ts';
312
313
  export type { GamepadFamily } from './input/gamepadIdentity.ts';
313
314
  export { DEFAULT_DEADZONE } from './input/gamepadMapping.ts';
package/dist/index.js CHANGED
@@ -338,6 +338,8 @@ export { colliderSurface } from './physics/colliderSurface.js';
338
338
  * `EXT_clip_control`.
339
339
  */
340
340
  export { DEPTH_CLEAR, DEPTH_COMPARE, DEPTH_COMPARE_EQUAL, DEPTH_FORMAT, DEPTH_OFFSET_SIGN, REVERSED_DEPTH, conventionalDepth, } from './render/depthConvention.js';
341
+ /* How much of the depth range `beginViewModel` takes when it is given no share. See `viewModel.ts`. */
342
+ export { VIEW_MODEL_DEPTH_SHARE } from './render/viewModel.js';
341
343
  export { BoxSurface } from './physics/boxSurface.js';
342
344
  export { heightSurface } from './physics/heightSurface.js';
343
345
  export { DebugLines } from './render/debugLines.js';
@@ -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
  * Actions a game asks about, and the bindings that satisfy them.
@@ -20,11 +20,16 @@ export type Binding = {
20
20
  } | {
21
21
  readonly device: 'gamepad';
22
22
  readonly button: GamepadButton;
23
+ } | {
24
+ readonly device: 'mouse';
25
+ readonly button: MouseButton;
23
26
  };
24
27
  /** Jump, fire, pause: a state, bound to keys and buttons. */
25
28
  export interface DigitalAction {
26
29
  readonly keys?: readonly string[];
27
30
  readonly buttons?: readonly GamepadButton[];
31
+ /** Mouse buttons, pressed over the input source's target: fire on the left, aim on the right. */
32
+ readonly mouseButtons?: readonly MouseButton[];
28
33
  }
29
34
  /**
30
35
  * Move, look: a direction rather than a state.
@@ -26,13 +26,17 @@ export class ActionMap {
26
26
  down(action) {
27
27
  return this.any(action, (binding) => binding.device === 'keyboard'
28
28
  ? this.input.isDown(binding.code)
29
- : (this.input.pad(0)?.down(binding.button) ?? false));
29
+ : binding.device === 'mouse'
30
+ ? this.input.mouseDown(binding.button)
31
+ : (this.input.pad(0)?.down(binding.button) ?? false));
30
32
  }
31
33
  /** Whether anything bound to it went down since the last poll. Readable by anything. */
32
34
  pressed(action) {
33
35
  return this.any(action, (binding) => binding.device === 'keyboard'
34
36
  ? this.input.keyPressed(binding.code)
35
- : (this.input.pad(0)?.pressed(binding.button) ?? false));
37
+ : binding.device === 'mouse'
38
+ ? this.input.mousePressed(binding.button)
39
+ : (this.input.pad(0)?.pressed(binding.button) ?? false));
36
40
  }
37
41
  /**
38
42
  * The same edge, claimed: true for exactly one caller.
@@ -50,6 +54,10 @@ export class ActionMap {
50
54
  if (this.input.consumeKeyPress(binding.code))
51
55
  return true;
52
56
  }
57
+ else if (binding.device === 'mouse') {
58
+ if (this.input.consumeMousePress(binding.button))
59
+ return true;
60
+ }
53
61
  else if (this.input.pad(0)?.consumePress(binding.button) === true) {
54
62
  return true;
55
63
  }
@@ -291,15 +299,17 @@ function bindingsOf(definition) {
291
299
  list.push({ device: 'keyboard', code });
292
300
  for (const button of definition.buttons ?? [])
293
301
  list.push({ device: 'gamepad', button });
302
+ for (const button of definition.mouseButtons ?? [])
303
+ list.push({ device: 'mouse', button });
294
304
  return list;
295
305
  }
296
306
  function sameBinding(a, b) {
297
- if (a.device !== b.device)
298
- return false;
299
- return a.device === 'keyboard' && b.device === 'keyboard'
300
- ? a.code === b.code
301
- : a.device === 'gamepad' && b.device === 'gamepad' && a.button === b.button;
307
+ if (a.device === 'keyboard' || b.device === 'keyboard') {
308
+ return a.device === 'keyboard' && b.device === 'keyboard' && a.code === b.code;
309
+ }
310
+ return a.device === b.device && a.button === b.button;
302
311
  }
312
+ const MOUSE_BUTTONS = new Set(['left', 'middle', 'right', 'back', 'forward']);
303
313
  function sameList(a, b) {
304
314
  return a.length === b.length && a.every((entry, at) => sameBinding(entry, b[at]));
305
315
  }
@@ -310,5 +320,8 @@ function isBinding(value) {
310
320
  const candidate = value;
311
321
  if (candidate.device === 'keyboard')
312
322
  return typeof candidate.code === 'string';
323
+ if (candidate.device === 'mouse') {
324
+ return typeof candidate.button === 'string' && MOUSE_BUTTONS.has(candidate.button);
325
+ }
313
326
  return candidate.device === 'gamepad' && typeof candidate.button === 'string';
314
327
  }
@@ -34,6 +34,8 @@ export interface TouchPoint {
34
34
  }
35
35
  /** The devices this source watches, and the granularity a consumer may switch off. */
36
36
  export type InputSourceName = 'keyboard' | 'mouse' | 'touch' | 'gamepad';
37
+ /** A mouse button by what it is, in `MouseEvent.button` order. */
38
+ export type MouseButton = 'left' | 'middle' | 'right' | 'back' | 'forward';
37
39
  export interface InputOptions {
38
40
  /**
39
41
  * Turn a device off. Absent means on.
@@ -147,6 +149,19 @@ export declare class InputSource {
147
149
  private mouseDx;
148
150
  private mouseDy;
149
151
  private mouseButtonDown;
152
+ /**
153
+ * Each mouse button's own state, as the keyboard's sets are for keys: held, pressed since the
154
+ * last poll, claimed this frame, and waiting for somebody to act on it. Bits, by `MOUSE_BIT`.
155
+ *
156
+ * **Beside `mouseButtonDown`, not instead of it.** That flag is "a button is held", which is what
157
+ * an unlocked drag-to-look reads; a game binding fire to the left button and aim to the right
158
+ * needs to know which, and to see a release of one while the other stays down.
159
+ */
160
+ private mouseHeld;
161
+ private mousePressedNow;
162
+ private mousePressedNext;
163
+ private mouseClaimed;
164
+ private mouseUnclaimed;
150
165
  private readonly disposers;
151
166
  /** Which device the player last actually used, and who wants telling when that changes. */
152
167
  private readonly active;
@@ -262,6 +277,19 @@ export declare class InputSource {
262
277
  keyPressed(code: string): boolean;
263
278
  /** The same edge, claimed: true for exactly one caller, then gone for everybody. */
264
279
  consumeKeyPress(code: string): boolean;
280
+ /**
281
+ * Whether a mouse button is held: pressed over the target and not yet released anywhere.
282
+ *
283
+ * A browser opens its context menu on the right button's release; a game binding that button
284
+ * cancels `contextmenu` on its canvas, which is a decision about the page and not this source's.
285
+ */
286
+ mouseDown(button: MouseButton): boolean;
287
+ /** Whether a mouse button went down since the last poll, as `keyPressed` is for a key. */
288
+ mousePressed(button: MouseButton): boolean;
289
+ /** The same edge, claimed, as `consumeKeyPress` is for a key: true for exactly one caller. */
290
+ consumeMousePress(button: MouseButton): boolean;
291
+ /** Every button up and every waiting press gone: what a lost focus or a cancelled pointer means. */
292
+ private releaseMouse;
265
293
  /**
266
294
  * Add a device-event subscriber. Multiple reusable control layers may listen
267
295
  * without overwriting one another; interpretation still happens outside the
@@ -81,6 +81,18 @@ export function askForPointerLock(request) {
81
81
  }
82
82
  });
83
83
  }
84
+ /** One bit a button, so the held, pressed and claimed states are integers and a read allocates nothing. */
85
+ const MOUSE_BIT = {
86
+ left: 1,
87
+ middle: 2,
88
+ right: 4,
89
+ back: 8,
90
+ forward: 16,
91
+ };
92
+ /** `MouseEvent.button` as a bit, or 0 for a button past the five this names. */
93
+ function mouseBit(button) {
94
+ return button >= 0 && button < 5 ? 1 << button : 0;
95
+ }
84
96
  /** Scratch for the deadzone, at module scope because `axis` is a per-frame read. */
85
97
  const deadzoned = { x: 0, y: 0 };
86
98
  /** Four is past generous for local play, and the array is preallocated to it. */
@@ -261,6 +273,19 @@ export class InputSource {
261
273
  mouseDx = 0;
262
274
  mouseDy = 0;
263
275
  mouseButtonDown = false;
276
+ /**
277
+ * Each mouse button's own state, as the keyboard's sets are for keys: held, pressed since the
278
+ * last poll, claimed this frame, and waiting for somebody to act on it. Bits, by `MOUSE_BIT`.
279
+ *
280
+ * **Beside `mouseButtonDown`, not instead of it.** That flag is "a button is held", which is what
281
+ * an unlocked drag-to-look reads; a game binding fire to the left button and aim to the right
282
+ * needs to know which, and to see a release of one while the other stays down.
283
+ */
284
+ mouseHeld = 0;
285
+ mousePressedNow = 0;
286
+ mousePressedNext = 0;
287
+ mouseClaimed = 0;
288
+ mouseUnclaimed = 0;
264
289
  disposers = [];
265
290
  /** Which device the player last actually used, and who wants telling when that changes. */
266
291
  active = new ActiveDevice();
@@ -369,14 +394,22 @@ export class InputSource {
369
394
  this.keys.clear();
370
395
  this.keysUnclaimed.clear();
371
396
  });
372
- this.listen(target, 'mousedown', () => {
397
+ this.listen(target, 'mousedown', (e) => {
373
398
  if (!this.enabled.mouse)
374
399
  return;
375
400
  this.mouseButtonDown = true;
401
+ const bit = mouseBit(e.button);
402
+ this.mouseHeld |= bit;
403
+ this.mousePressedNext |= bit;
404
+ this.mouseUnclaimed |= bit;
376
405
  this.active.use('mouse');
377
406
  });
378
- this.listen(window, 'mouseup', () => {
407
+ /* On the window, so a press that becomes a drag off the canvas still comes back up. */
408
+ this.listen(window, 'mouseup', (e) => {
379
409
  this.mouseButtonDown = false;
410
+ const bit = mouseBit(e.button);
411
+ this.mouseHeld &= ~bit;
412
+ this.mouseUnclaimed &= ~bit;
380
413
  });
381
414
  /*
382
415
  * **Released by anything that can eat the mouseup, exactly as the keys are.**
@@ -398,16 +431,12 @@ export class InputSource {
398
431
  * `visibilitychange` as well as `blur`, because a tab switched away with a button held
399
432
  * comes back to a document that never saw the release either.
400
433
  */
401
- this.listen(window, 'blur', () => {
402
- this.mouseButtonDown = false;
403
- });
404
- this.listen(window, 'pointercancel', () => {
405
- this.mouseButtonDown = false;
406
- });
434
+ this.listen(window, 'blur', () => this.releaseMouse());
435
+ this.listen(window, 'pointercancel', () => this.releaseMouse());
407
436
  /* Not in `GlobalEventHandlersEventMap`, so it is bound directly and disposed by hand. */
408
437
  const onHidden = () => {
409
438
  if (document.visibilityState !== 'visible')
410
- this.mouseButtonDown = false;
439
+ this.releaseMouse();
411
440
  };
412
441
  document.addEventListener('visibilitychange', onHidden);
413
442
  this.disposers.push(() => document.removeEventListener('visibilitychange', onHidden));
@@ -508,6 +537,10 @@ export class InputSource {
508
537
  spent.clear();
509
538
  this.keysPressedNext = spent;
510
539
  this.keysClaimed.clear();
540
+ /* The mouse buttons' frame boundary, on the same poll and by the same rule. */
541
+ this.mousePressedNow = this.mousePressedNext;
542
+ this.mousePressedNext = 0;
543
+ this.mouseClaimed = 0;
511
544
  if (!this.enabled.gamepad)
512
545
  return;
513
546
  const source = globalThis
@@ -601,6 +634,35 @@ export class InputSource {
601
634
  this.keysClaimed.add(code);
602
635
  return true;
603
636
  }
637
+ /**
638
+ * Whether a mouse button is held: pressed over the target and not yet released anywhere.
639
+ *
640
+ * A browser opens its context menu on the right button's release; a game binding that button
641
+ * cancels `contextmenu` on its canvas, which is a decision about the page and not this source's.
642
+ */
643
+ mouseDown(button) {
644
+ return (this.mouseHeld & MOUSE_BIT[button]) !== 0;
645
+ }
646
+ /** Whether a mouse button went down since the last poll, as `keyPressed` is for a key. */
647
+ mousePressed(button) {
648
+ const bit = MOUSE_BIT[button];
649
+ return (this.mousePressedNow & bit) !== 0 && (this.mouseClaimed & bit) === 0;
650
+ }
651
+ /** The same edge, claimed, as `consumeKeyPress` is for a key: true for exactly one caller. */
652
+ consumeMousePress(button) {
653
+ const bit = MOUSE_BIT[button];
654
+ if ((this.mouseUnclaimed & bit) === 0)
655
+ return false;
656
+ this.mouseUnclaimed &= ~bit;
657
+ this.mouseClaimed |= bit;
658
+ return true;
659
+ }
660
+ /** Every button up and every waiting press gone: what a lost focus or a cancelled pointer means. */
661
+ releaseMouse() {
662
+ this.mouseButtonDown = false;
663
+ this.mouseHeld = 0;
664
+ this.mouseUnclaimed = 0;
665
+ }
604
666
  /**
605
667
  * Add a device-event subscriber. Multiple reusable control layers may listen
606
668
  * without overwriting one another; interpretation still happens outside the
@@ -36,6 +36,15 @@ export declare function fakeBrowser(id?: string): {
36
36
  /** Press a key, as the window would deliver it. `target` is nothing a game is typing into. */
37
37
  keyDown(code: string): void;
38
38
  keyUp(code: string): void;
39
+ /**
40
+ * Press a mouse button over the target, by `MouseEvent.button`: 0 left, 1 middle, 2 right.
41
+ * Pressed on the target and released on the window, as a browser delivers a drag that ends
42
+ * off the canvas.
43
+ */
44
+ mouseDown(button: number): void;
45
+ mouseUp(button: number): void;
46
+ /** The window losing focus, which eats every release that would have followed. */
47
+ blur(): void;
39
48
  /** Run exactly one animation frame, which is what a poll rides on. */
40
49
  frame(): void;
41
50
  restore(): void;
@@ -60,9 +60,23 @@ export function fakeBrowser(id = 'Xbox 360 Controller (XInput STANDARD GAMEPAD)'
60
60
  };
61
61
  const frameCallbacks = [];
62
62
  let nextFrameHandle = 1;
63
+ const targetListeners = new Map();
63
64
  const target = {
64
- addEventListener: () => { },
65
- removeEventListener: () => { },
65
+ addEventListener: (type, fn) => {
66
+ const existing = targetListeners.get(type);
67
+ if (existing === undefined)
68
+ targetListeners.set(type, [fn]);
69
+ else
70
+ existing.push(fn);
71
+ },
72
+ removeEventListener: (type, fn) => {
73
+ const existing = targetListeners.get(type);
74
+ if (existing === undefined)
75
+ return;
76
+ const at = existing.indexOf(fn);
77
+ if (at >= 0)
78
+ existing.splice(at, 1);
79
+ },
66
80
  requestPointerLock: () => undefined,
67
81
  getBoundingClientRect: () => ({ left: 0, top: 0, width: 640, height: 480 }),
68
82
  };
@@ -169,6 +183,24 @@ export function fakeBrowser(id = 'Xbox 360 Controller (XInput STANDARD GAMEPAD)'
169
183
  for (const fn of windowListeners.get('keyup') ?? [])
170
184
  fn({ code });
171
185
  },
186
+ /**
187
+ * Press a mouse button over the target, by `MouseEvent.button`: 0 left, 1 middle, 2 right.
188
+ * Pressed on the target and released on the window, as a browser delivers a drag that ends
189
+ * off the canvas.
190
+ */
191
+ mouseDown(button) {
192
+ for (const fn of targetListeners.get('mousedown') ?? [])
193
+ fn({ button, movementX: 0 });
194
+ },
195
+ mouseUp(button) {
196
+ for (const fn of windowListeners.get('mouseup') ?? [])
197
+ fn({ button });
198
+ },
199
+ /** The window losing focus, which eats every release that would have followed. */
200
+ blur() {
201
+ for (const fn of windowListeners.get('blur') ?? [])
202
+ fn({});
203
+ },
172
204
  /** Run exactly one animation frame, which is what a poll rides on. */
173
205
  frame() {
174
206
  const pending = frameCallbacks.splice(0, frameCallbacks.length);
@@ -781,8 +781,8 @@ export declare class WebGL2Renderer implements RendererApi {
781
781
  */
782
782
  get presentedFrames(): number;
783
783
  private presentedFrameCount;
784
- private readonly flatProgram;
785
- private readonly flatUniforms;
784
+ private flatProgram;
785
+ private flatUniforms;
786
786
  /**
787
787
  * The lit fragment source every flat program is built from, and the budget it was built at.
788
788
  *
@@ -793,7 +793,7 @@ export declare class WebGL2Renderer implements RendererApi {
793
793
  * for a lazily compiled variant to end up at a different light budget from the one the renderer
794
794
  * uploads for, which is a picture nobody could explain.
795
795
  */
796
- private readonly flatFragSource;
796
+ private flatFragSource;
797
797
  /**
798
798
  * How many lights this renderer's shader has room for, after the device had its say.
799
799
  *
@@ -1294,6 +1294,27 @@ export declare class WebGL2Renderer implements RendererApi {
1294
1294
  * each such draw and handed back, where the other backend bakes it into a pipeline.
1295
1295
  */
1296
1296
  private materialDoubleSided;
1297
+ /** Whether the bound material has a map, for the warning a mesh with no coordinates earns. */
1298
+ private materialHasMaps;
1299
+ /** The material last set, so a lit program rebuilt for glass can be given it. */
1300
+ private currentMaterial;
1301
+ /**
1302
+ * The lit stage's switches: what the lit programs were compiled with (`litOn`), and what the
1303
+ * profile and the content have asked for since (`litWanted`).
1304
+ *
1305
+ * **Each is off until it is used** — glass until a pane is offered, a fixture until one is loaded,
1306
+ * the effects until a material carries a table, DriftLight until a volume is set — and then on for
1307
+ * good, rebuilt at the next `beginFrame`. Clustering is the profile's. The code costs the lit pass
1308
+ * registers on every surface whether it finds anything or not, and a world without the feature
1309
+ * draws the same picture without it. `PipelineCache.litSwitches` makes the same decision on the
1310
+ * other backend. The frame a feature is first used in draws without it; the rebuild is a
1311
+ * synchronous compile, and the next frame waits for it.
1312
+ */
1313
+ private readonly litOn;
1314
+ private readonly litWanted;
1315
+ private litAsked;
1316
+ /** Ask for a switch, which the next `beginFrame` compiles in. Never glass with it turned off. */
1317
+ private askLit;
1297
1318
  /**
1298
1319
  * The one sink handed to every caster enumeration.
1299
1320
  *
@@ -2523,6 +2544,17 @@ export declare class WebGL2Renderer implements RendererApi {
2523
2544
  * rather than propagating. Only a shader that will not link at *any* budget throws, and it
2524
2545
  * throws a sentence with the device's numbers in it rather than a bare driver string.
2525
2546
  */
2547
+ /** The lit fragment source this renderer's profile compiles, at a light budget. */
2548
+ private flatFragAt;
2549
+ /**
2550
+ * Every lit program that exists, compiled again with the switches asked for since, at the budget
2551
+ * the renderer already uploads for, and given the pass and the material the old one held.
2552
+ *
2553
+ * At `beginFrame`, not where a feature was first used: a pane is offered inside a shadow pass
2554
+ * with the depth program bound, and a program swapped under it would draw the next caster with
2555
+ * the lit one.
2556
+ */
2557
+ private rebuildLit;
2526
2558
  private fitFlatProgram;
2527
2559
  /**
2528
2560
  * How many point lights this renderer's shader shades at once.
@@ -2716,6 +2748,23 @@ export declare class WebGL2Renderer implements RendererApi {
2716
2748
  fillPanel(rect: InsetRect, color: Vec3, alpha: number): void;
2717
2749
  /** Give the whole canvas back. Safe to call without a matching `beginInset`. */
2718
2750
  endInset(): void;
2751
+ /**
2752
+ * Draw what follows as a **view model**, in the nearest sliver of depth, until `endViewModel`.
2753
+ *
2754
+ * A first-person view model — arms and a held weapon — drawn with the frame's camera, or with one
2755
+ * of its own through `bindMeshPass`, lands in front of anything past the near plane without the
2756
+ * world's depth being cleared, so every pass that reads depth afterwards still sees the world.
2757
+ * `share` is how much of the range it takes, 1% by default. See `viewModel.ts` for what it gives
2758
+ * up, which includes temporal reconstruction: draw a view model with multisampling.
2759
+ *
2760
+ * Not inside an inset: an inset has its own depth, cleared for it, and needs no squeeze.
2761
+ */
2762
+ beginViewModel(share?: number): void;
2763
+ /** Give the whole depth range back. Safe to call without a matching `beginViewModel`. */
2764
+ endViewModel(): void;
2765
+ /** Whether a view model is being drawn, so `endFrame` can close one the caller left open. */
2766
+ private viewModelActive;
2767
+ private readonly viewModelRange;
2719
2768
  /**
2720
2769
  * How much speed blur the frame about to be drawn should resolve with, 0 to 1.
2721
2770
  *