@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/CHANGELOG.json CHANGED
@@ -1,4 +1,146 @@
1
1
  [
2
+ {
3
+ "version": "4.8.3",
4
+ "date": "2026-10-04",
5
+ "entries": [
6
+ {
7
+ "kind": "fixed",
8
+ "short": "Ambient occlusion no longer darkens smoke, glass, water or text by the corner behind them: it darkens only the part of a pixel they let through, on both backends.",
9
+ "text": "Occlusion is applied in the composite from the depth the opaque world leaves, after every translucent draw has landed, and a particle or a pane writes no depth, so a puff of smoke in front of a wall was darkened by the corner behind it: reported as walls and figures behind a cloud showing through it as dark outlines, and nothing at all with occlusion off. The scene's alpha now carries how much of each pixel is still the opaque surface. The frame clears it to 1, an opaque draw writes 1, and every blended draw states what it does to it: what covers (glass, smoke, water, lines, text, panels, sprites, splats) multiplies it by what it lets through, what transmits (the global medium, the order-independent composite) by its own transmittance, and what adds light or is a layer of the surface (caustics, light volumes, bolts, a wet film, decals, reflections) leaves it alone. The composite darkens that share and no more, and the temporal resolve and the reconstruction carry it through. Measured on both backends, plain, with temporal antialiasing and with DriftTR, against a darkened join: a pane at 0.85 opacity keeps 0.15 of the darkening behind it and a cluster of smoke none, while the open join and every frame with occlusion off are unchanged to the pixel. What it gives up is light added in front of an occluded corner, which is darkened with the surface it lands on as before, and the GPU-driven pipeline's own blended surfaces, which still take the occlusion behind them because its blit's alpha is the mask that says where it drew. A pass of your own states its blending with `SCENE_ALPHA_COVERS`, `SCENE_ALPHA_TRANSMITS` or `SCENE_ALPHA_KEEPS` on WebGPU, and `blendCovering`, `blendTransmitting` or `blendKeeping` on WebGL2."
10
+ },
11
+ {
12
+ "kind": "fixed",
13
+ "short": "A particle's size is documented as what it always was, a half-width from the centre to an edge: a particle of size 0.5 is a metre across.",
14
+ "text": "`ParticleInstances.sizes` and a pool's `sizeStart` and `sizeEnd` were documented as metres across, and the quad spans twice the size, a corner at minus and plus one on each axis, so a caller who sized by the documentation drew every particle twice as wide as meant. The drawing is unchanged, because every effect already tuned against it would have halved; the type, the pool's options and the manual now say half-width."
15
+ },
16
+ {
17
+ "kind": "fixed",
18
+ "short": "A bone too short for a body no longer cuts a ragdoll in two: what hangs below it is jointed to the bone above, and stops colliding with the bones it meets there.",
19
+ "text": "A rig often puts one joint exactly on another, a collar on the chest joint or a helper on the hips, and a bone shorter than `minLength` gets no body. What hung below such a bone was jointed to nothing, because a bone was jointed to the bone ending at its parent joint and that joint had none: in a limp fall the spine above a collar flew 3.9 m off the hips. And it collided with every bone it met at that point, because the shared-end rule compared parent joints by index, so capsules out of one point separated whichever way rounding said and a reaction depended on which way the character faced. A short bone is now transparent: each joint resolves to the nearest one above it that a body ends at, for jointing and for collision alike, and where a short bone lies between, the joint is anchored where the two bones meet at rest, so a still doll does not move on its first tick."
20
+ },
21
+ {
22
+ "kind": "fixed",
23
+ "short": "A ragdoll draws every bone under a joint on its own body, so after a fall a second thigh or a clavicle no longer hangs off the body beside it.",
24
+ "text": "`writePose` turned a joint by its first bone below and placed every child by the rig's offset from that turn, so at a joint with several bones below it, hips over a spine and two thighs, a chest over a neck and two clavicles, every branch but the first was swung by the first one's rotation and drawn off its own body. Reported from a game as a foot 0.24 m and an arm 0.17 m from their bodies after a fall; a test fall measured a thigh 97.5 cm out. Each joint is now placed along its own bone as that bone's body holds it, at the rig's length and in the parent's frame, which for the first branch is the rig's offset exactly. Lengths never stretch. A joint with no bone of its own keeps the rig's offset."
25
+ },
26
+ {
27
+ "kind": "added",
28
+ "short": "`setSurfaceFog(false)` takes the next opaque draws out of the fog, so a floor can recede into the medium while figures standing on it keep their colour.",
29
+ "text": "Opaque draws could not opt out of the medium: `drawTranslucentMesh` had `fog: false` and the shader a per-draw switch, and nothing reached it for `drawMesh`. A ported game darkening its floor with distance had to darken its figures with it, or leave the far floor 18% too bright. `setSurfaceFog(enabled)` is a surface dial like `setSurfaceGrain`: material state for the draws that follow, read by instanced and skinned draws too, and restored by `bindMeshPass`. A translucent draw's own `fog` is measured against it and puts it back. Verified on both backends with two identical far pillars, one fogged and one not, and a pane between them asking for fog under the dial."
30
+ },
31
+ {
32
+ "kind": "added",
33
+ "short": "`outputTransform: 'shoulder'` keeps sRGB's midtones and eases only the highlights, by their brightest channel, so an overbright red stays red instead of turning pink.",
34
+ "text": "A fourth output transform. `'srgb'` clips each channel at 1, so an overbright colour shifts hue on its way to white: `(1.6, 0.4, 0.2)` clips to `(1, 0.4, 0.2)`, pinker and wider. `'shoulder'` leaves everything below 0.8 alone and eases the brightest channel toward 1, as `0.8 + 0.2e / (e + 0.2)` of an excess `e`, scaling the other two with it, so the same colour becomes `(0.96, 0.24, 0.12)`. It is the curve a ported game's own resolve applies, and it has none of ACES's toe or desaturation. One definition shared by the forward passes and the resolve, measured on both backends with the composite on and off: `(250, 134, 97)` against sRGB's `(255, 170, 124)`."
35
+ },
36
+ {
37
+ "kind": "added",
38
+ "short": "Materials take `uOffset` and `vOffset`, so one quad draws any cell of a flipbook or an atlas, and a cutout's shadow is cut from the same cell.",
39
+ "text": "`SurfaceMaterial` had a UV scale and no offset, so a flipbook could not choose its cell through the material and a game built one quad mesh per cell, 106 meshes for four strips. `uOffset` and `vOffset` are added after the scale, `uv * scale + offset`, for every map of the material, and they reach the cutout depth and shadow passes, so a card's shadow has the holes of the cell its colour shows. Both backends, mesh and instanced draws."
40
+ },
41
+ {
42
+ "kind": "added",
43
+ "short": "`AudioGraph.restart(fromSec)` starts every stem from a point in the track, locked together, for music held to a game's own clock.",
44
+ "text": "The transport could only go back to the top, so music held to a frame counter, which has to resume where that counter is after a skip, ran its own source into the music bus outside every stem's lock. `restart(fromSec)` stops the stems and starts them all from that point at one instant, wrapped by each stem's length, and returns how long until it is heard. `restart()` is unchanged."
45
+ },
46
+ {
47
+ "kind": "changed",
48
+ "short": "A sound slot registered without a synth is required: if none of its files loads, `load` rejects naming it, after every other slot has loaded.",
49
+ "text": "Every slot had to carry a synthesised stand-in, which is right for a footstep and wrong for a score that has to be the file or nothing, so such a score was decoded outside the registry. `SoundSource.synth` is optional now. A slot without one is required: when none of its candidates loads it is listed in `unbuilt`, and `load` still settles every other slot before rejecting with an error naming each missing required slot and the files it tried. A slot with a synth behaves as before."
50
+ },
51
+ {
52
+ "kind": "fixed",
53
+ "short": "A WebSocket or WebRTC transport reads a text frame as its UTF-8 bytes, and counts a frame it cannot read as dropped, where both vanished in silence.",
54
+ "text": "`WebSocketTransport` and `WebRtcTransport` discarded any message that arrived as a string, without counting it in `dropped`, on the ground that a payload through UTF-8 loses its high bytes. That is true of binary data sent as text and not of a peer that speaks text: a text frame is lossless UTF-8 by the protocol, and a client against a server sending text received nothing at all. Text now arrives as exactly the bytes the sender wrote, anything else unreadable is counted, and both transports share the one rule."
55
+ }
56
+ ]
57
+ },
58
+ {
59
+ "version": "4.8.2",
60
+ "date": "2026-10-03",
61
+ "entries": [
62
+ {
63
+ "kind": "fixed",
64
+ "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.",
65
+ "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."
66
+ },
67
+ {
68
+ "kind": "fixed",
69
+ "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.",
70
+ "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."
71
+ },
72
+ {
73
+ "kind": "fixed",
74
+ "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.",
75
+ "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."
76
+ },
77
+ {
78
+ "kind": "fixed",
79
+ "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.",
80
+ "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."
81
+ },
82
+ {
83
+ "kind": "fixed",
84
+ "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.",
85
+ "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."
86
+ },
87
+ {
88
+ "kind": "fixed",
89
+ "short": "A character controller accelerates and brakes along a diagonal as fast as along an axis, where it gained speed √2 times as fast.",
90
+ "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."
91
+ },
92
+ {
93
+ "kind": "added",
94
+ "short": "An action can be bound to mouse buttons, `{ device: 'mouse', button: 'left' }`, held, pressed and claimed like a key.",
95
+ "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."
96
+ },
97
+ {
98
+ "kind": "added",
99
+ "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.",
100
+ "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."
101
+ },
102
+ {
103
+ "kind": "fixed",
104
+ "short": "`ParticlePool.emit` draws a particle the frame it is born, where it waited for the next `update`.",
105
+ "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."
106
+ },
107
+ {
108
+ "kind": "fixed",
109
+ "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.",
110
+ "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."
111
+ },
112
+ {
113
+ "kind": "changed",
114
+ "short": "`World.add` and `definePrefab` refuse a field the component does not have, where the value was dropped without a word.",
115
+ "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."
116
+ },
117
+ {
118
+ "kind": "fixed",
119
+ "short": "An interface tree draws and hit-tests by layer, so a raised popup covers a later sibling and takes the pointer there.",
120
+ "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."
121
+ },
122
+ {
123
+ "kind": "fixed",
124
+ "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.",
125
+ "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."
126
+ },
127
+ {
128
+ "kind": "fixed",
129
+ "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.",
130
+ "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."
131
+ },
132
+ {
133
+ "kind": "added",
134
+ "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.",
135
+ "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."
136
+ },
137
+ {
138
+ "kind": "fixed",
139
+ "short": "`probeXrSupport` takes the WebGL2 context a game draws with, without a cast, and `XrRuntime` is exported.",
140
+ "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`."
141
+ }
142
+ ]
143
+ },
2
144
  {
3
145
  "version": "4.8.1",
4
146
  "date": "2026-10-03",
package/README.md CHANGED
@@ -6,7 +6,7 @@ carries the loop, the renderer, cameras, input, the scene graph, geometry, text
6
6
  and the whole of [`@driftengine/physics`](https://driftengine.dev/docs/simulation/rigid-bodies),
7
7
  which it re-exports. The rest of the engine is separate packages you add when you need them.
8
8
 
9
- **Cost: 699.0 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`,
9
+ **Cost: 710.5 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`,
10
10
  which fails the build if the figure drifts by more than 3%.
11
11
 
12
12
  The manual is at **[driftengine.dev/docs](https://driftengine.dev/docs)**, with an example running
package/dist/index.d.ts CHANGED
@@ -277,6 +277,8 @@ 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 { SCENE_ALPHA_COVERS, SCENE_ALPHA_KEEPS, SCENE_ALPHA_TRANSMITS, blendCovering, blendKeeping, blendTransmitting, } from './render/sceneCoverage.ts';
281
+ export { VIEW_MODEL_DEPTH_SHARE } from './render/viewModel.ts';
280
282
  export type { ColliderSurfaceOptions } from './physics/colliderSurface.ts';
281
283
  export { BoxSurface } from './physics/boxSurface.ts';
282
284
  export { heightSurface } from './physics/heightSurface.ts';
@@ -307,7 +309,7 @@ export { AXIS_X, AXIS_Y, AXIS_Z, ColliderSet, aabbFromCenter, bodyBounds, boxCol
307
309
  export { InputSource } from './input/input.ts';
308
310
  export { isTypingTarget } from './input/typingTarget.ts';
309
311
  export type { TouchPoint, InputCallbacks } from './input/input.ts';
310
- export type { GamepadIdentity, GamepadView, InputOptions, InputSourceName } from './input/input.ts';
312
+ export type { GamepadIdentity, GamepadView, InputOptions, InputSourceName, MouseButton, } from './input/input.ts';
311
313
  export type { InputDevice } from './input/activeDevice.ts';
312
314
  export type { GamepadFamily } from './input/gamepadIdentity.ts';
313
315
  export { DEFAULT_DEADZONE } from './input/gamepadMapping.ts';
package/dist/index.js CHANGED
@@ -338,6 +338,14 @@ 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
+ /*
342
+ * **What a contributed pass's blended draw does to the scene's alpha**, which carries how much of
343
+ * each pixel is still the opaque surface: the share the composite's ambient occlusion may darken.
344
+ * A pass blending into the scene states one of these for its alpha. See `sceneCoverage.ts`.
345
+ */
346
+ export { SCENE_ALPHA_COVERS, SCENE_ALPHA_KEEPS, SCENE_ALPHA_TRANSMITS, blendCovering, blendKeeping, blendTransmitting, } from './render/sceneCoverage.js';
347
+ /* How much of the depth range `beginViewModel` takes when it is given no share. See `viewModel.ts`. */
348
+ export { VIEW_MODEL_DEPTH_SHARE } from './render/viewModel.js';
341
349
  export { BoxSurface } from './physics/boxSurface.js';
342
350
  export { heightSurface } from './physics/heightSurface.js';
343
351
  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);
@@ -27,8 +27,13 @@ export interface DrawMaterialOptions {
27
27
  /** Whether the draw refracts this time, which a backend decides by whether it has a snapshot. */
28
28
  readonly refracting: boolean;
29
29
  }
30
- /** Whether a draw's options differ from the pass's, so it takes a material and leaves one to restore. */
31
- export declare function ownsMaterial(options: DrawMaterialOptions): boolean;
30
+ /**
31
+ * Whether a draw's options differ from the pass's, so it takes a material and leaves one to restore.
32
+ *
33
+ * `passFog` is how the surfaces around it meet the medium — `FOG_RECEDE` unless `setSurfaceFog`
34
+ * turned it off — so a translucent draw asking for what the pass already has takes nothing.
35
+ */
36
+ export declare function ownsMaterial(options: DrawMaterialOptions, passFog?: number): boolean;
32
37
  /** Whether the next draw shares the open material or opens a new one. */
33
38
  export declare class MaterialChanges {
34
39
  /**
@@ -18,11 +18,16 @@
18
18
  * slot on the backend that keeps slots, and comparing every field would cost more than the slot.
19
19
  */
20
20
  import { FOG_RECEDE } from './drawFog.js';
21
- /** Whether a draw's options differ from the pass's, so it takes a material and leaves one to restore. */
22
- export function ownsMaterial(options) {
21
+ /**
22
+ * Whether a draw's options differ from the pass's, so it takes a material and leaves one to restore.
23
+ *
24
+ * `passFog` is how the surfaces around it meet the medium — `FOG_RECEDE` unless `setSurfaceFog`
25
+ * turned it off — so a translucent draw asking for what the pass already has takes nothing.
26
+ */
27
+ export function ownsMaterial(options, passFog = FOG_RECEDE) {
23
28
  return (options.opacity < 1 ||
24
29
  !options.lit ||
25
- options.fog !== FOG_RECEDE ||
30
+ options.fog !== passFog ||
26
31
  !options.toneMapped ||
27
32
  options.refracting);
28
33
  }