@codexo/exojs 0.7.12 → 0.8.0
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.
- package/CHANGELOG.md +737 -0
- package/dist/esm/core/Application.d.ts +3 -1
- package/dist/esm/core/Application.js +7 -6
- package/dist/esm/core/Application.js.map +1 -1
- package/dist/esm/core/Scene.d.ts +30 -0
- package/dist/esm/core/Scene.js +56 -0
- package/dist/esm/core/Scene.js.map +1 -1
- package/dist/esm/core/SceneManager.js +2 -2
- package/dist/esm/core/SceneManager.js.map +1 -1
- package/dist/esm/debug/DebugOverlay.js +2 -2
- package/dist/esm/debug/DebugOverlay.js.map +1 -1
- package/dist/esm/debug/PointerStackLayer.js +1 -1
- package/dist/esm/debug/PointerStackLayer.js.map +1 -1
- package/dist/esm/index.js +32 -10
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/input/ArcadeStickGamepadMapping.js +18 -19
- package/dist/esm/input/ArcadeStickGamepadMapping.js.map +1 -1
- package/dist/esm/input/Gamepad.d.ts +164 -62
- package/dist/esm/input/Gamepad.js +290 -134
- package/dist/esm/input/Gamepad.js.map +1 -1
- package/dist/esm/input/GamepadAxis.d.ts +120 -0
- package/dist/esm/input/GamepadAxis.js +106 -0
- package/dist/esm/input/GamepadAxis.js.map +1 -0
- package/dist/esm/input/GamepadButton.d.ts +110 -0
- package/dist/esm/input/GamepadButton.js +99 -0
- package/dist/esm/input/GamepadButton.js.map +1 -0
- package/dist/esm/input/GamepadDefinitions.js +4 -0
- package/dist/esm/input/GamepadDefinitions.js.map +1 -1
- package/dist/esm/input/GamepadMapping.d.ts +28 -24
- package/dist/esm/input/GamepadMapping.js +33 -16
- package/dist/esm/input/GamepadMapping.js.map +1 -1
- package/dist/esm/input/GamepadPromptLayouts.d.ts +10 -8
- package/dist/esm/input/GamepadPromptLayouts.js +21 -20
- package/dist/esm/input/GamepadPromptLayouts.js.map +1 -1
- package/dist/esm/input/GenericDualAnalogGamepadMapping.d.ts +6 -3
- package/dist/esm/input/GenericDualAnalogGamepadMapping.js +55 -46
- package/dist/esm/input/GenericDualAnalogGamepadMapping.js.map +1 -1
- package/dist/esm/input/InputBinding.d.ts +74 -0
- package/dist/esm/input/InputBinding.js +100 -0
- package/dist/esm/input/InputBinding.js.map +1 -0
- package/dist/esm/input/InputManager.d.ts +79 -33
- package/dist/esm/input/InputManager.js +229 -104
- package/dist/esm/input/InputManager.js.map +1 -1
- package/dist/esm/input/InteractionManager.d.ts +1 -1
- package/dist/esm/input/InteractionManager.js +13 -13
- package/dist/esm/input/InteractionManager.js.map +1 -1
- package/dist/esm/input/JoyConLeftGamepadMapping.d.ts +14 -9
- package/dist/esm/input/JoyConLeftGamepadMapping.js +39 -9
- package/dist/esm/input/JoyConLeftGamepadMapping.js.map +1 -1
- package/dist/esm/input/JoyConRightGamepadMapping.d.ts +14 -9
- package/dist/esm/input/JoyConRightGamepadMapping.js +35 -9
- package/dist/esm/input/JoyConRightGamepadMapping.js.map +1 -1
- package/dist/esm/input/Pointer.d.ts +84 -71
- package/dist/esm/input/Pointer.js +71 -71
- package/dist/esm/input/Pointer.js.map +1 -1
- package/dist/esm/input/SteamDeckGamepadMapping.d.ts +18 -0
- package/dist/esm/input/SteamDeckGamepadMapping.js +76 -0
- package/dist/esm/input/SteamDeckGamepadMapping.js.map +1 -0
- package/dist/esm/input/index.d.ts +7 -4
- package/dist/esm/input/types.d.ts +0 -76
- package/dist/esm/input/types.js +1 -80
- package/dist/esm/input/types.js.map +1 -1
- package/dist/esm/particles/ParticleSystem.d.ts +180 -83
- package/dist/esm/particles/ParticleSystem.js +446 -133
- package/dist/esm/particles/ParticleSystem.js.map +1 -1
- package/dist/esm/particles/distributions/BoxArea.d.ts +17 -0
- package/dist/esm/particles/distributions/BoxArea.js +48 -0
- package/dist/esm/particles/distributions/BoxArea.js.map +1 -0
- package/dist/esm/particles/distributions/CircleArea.d.ts +19 -0
- package/dist/esm/particles/distributions/CircleArea.js +33 -0
- package/dist/esm/particles/distributions/CircleArea.js.map +1 -0
- package/dist/esm/particles/distributions/ConeDirection.d.ts +28 -0
- package/dist/esm/particles/distributions/ConeDirection.js +44 -0
- package/dist/esm/particles/distributions/ConeDirection.js.map +1 -0
- package/dist/esm/particles/distributions/Constant.d.ts +17 -0
- package/dist/esm/particles/distributions/Constant.js +35 -0
- package/dist/esm/particles/distributions/Constant.js.map +1 -0
- package/dist/esm/particles/distributions/Curve.d.ts +30 -0
- package/dist/esm/particles/distributions/Curve.js +53 -0
- package/dist/esm/particles/distributions/Curve.js.map +1 -0
- package/dist/esm/particles/distributions/Distribution.d.ts +45 -0
- package/dist/esm/particles/distributions/Gradient.d.ts +40 -0
- package/dist/esm/particles/distributions/Gradient.js +72 -0
- package/dist/esm/particles/distributions/Gradient.js.map +1 -0
- package/dist/esm/particles/distributions/LineSegment.d.ts +15 -0
- package/dist/esm/particles/distributions/LineSegment.js +27 -0
- package/dist/esm/particles/distributions/LineSegment.js.map +1 -0
- package/dist/esm/particles/distributions/Range.d.ts +12 -0
- package/dist/esm/particles/distributions/Range.js +19 -0
- package/dist/esm/particles/distributions/Range.js.map +1 -0
- package/dist/esm/particles/distributions/VectorRange.d.ts +20 -0
- package/dist/esm/particles/distributions/VectorRange.js +31 -0
- package/dist/esm/particles/distributions/VectorRange.js.map +1 -0
- package/dist/esm/particles/distributions/index.d.ts +12 -0
- package/dist/esm/particles/gpu/ParticleGpuState.d.ts +57 -0
- package/dist/esm/particles/gpu/ParticleGpuState.js +508 -0
- package/dist/esm/particles/gpu/ParticleGpuState.js.map +1 -0
- package/dist/esm/particles/index.d.ts +2 -10
- package/dist/esm/particles/modules/AlphaFadeOverLifetime.d.ts +24 -0
- package/dist/esm/particles/modules/AlphaFadeOverLifetime.js +60 -0
- package/dist/esm/particles/modules/AlphaFadeOverLifetime.js.map +1 -0
- package/dist/esm/particles/modules/ApplyForce.d.ts +20 -0
- package/dist/esm/particles/modules/ApplyForce.js +48 -0
- package/dist/esm/particles/modules/ApplyForce.js.map +1 -0
- package/dist/esm/particles/modules/AttractToPoint.d.ts +27 -0
- package/dist/esm/particles/modules/AttractToPoint.js +73 -0
- package/dist/esm/particles/modules/AttractToPoint.js.map +1 -0
- package/dist/esm/particles/modules/BurstSpawn.d.ts +53 -0
- package/dist/esm/particles/modules/BurstSpawn.js +94 -0
- package/dist/esm/particles/modules/BurstSpawn.js.map +1 -0
- package/dist/esm/particles/modules/ColorOverLifetime.d.ts +22 -0
- package/dist/esm/particles/modules/ColorOverLifetime.js +65 -0
- package/dist/esm/particles/modules/ColorOverLifetime.js.map +1 -0
- package/dist/esm/particles/modules/ColorOverSpeed.d.ts +27 -0
- package/dist/esm/particles/modules/ColorOverSpeed.js +86 -0
- package/dist/esm/particles/modules/ColorOverSpeed.js.map +1 -0
- package/dist/esm/particles/modules/DeathModule.d.ts +24 -0
- package/dist/esm/particles/modules/DeathModule.js +25 -0
- package/dist/esm/particles/modules/DeathModule.js.map +1 -0
- package/dist/esm/particles/modules/Drag.d.ts +20 -0
- package/dist/esm/particles/modules/Drag.js +45 -0
- package/dist/esm/particles/modules/Drag.js.map +1 -0
- package/dist/esm/particles/modules/OrbitalForce.d.ts +28 -0
- package/dist/esm/particles/modules/OrbitalForce.js +65 -0
- package/dist/esm/particles/modules/OrbitalForce.js.map +1 -0
- package/dist/esm/particles/modules/RateSpawn.d.ts +41 -0
- package/dist/esm/particles/modules/RateSpawn.js +76 -0
- package/dist/esm/particles/modules/RateSpawn.js.map +1 -0
- package/dist/esm/particles/modules/RepelFromPoint.d.ts +24 -0
- package/dist/esm/particles/modules/RepelFromPoint.js +76 -0
- package/dist/esm/particles/modules/RepelFromPoint.js.map +1 -0
- package/dist/esm/particles/modules/RotateOverLifetime.d.ts +20 -0
- package/dist/esm/particles/modules/RotateOverLifetime.js +43 -0
- package/dist/esm/particles/modules/RotateOverLifetime.js.map +1 -0
- package/dist/esm/particles/modules/ScaleOverLifetime.d.ts +26 -0
- package/dist/esm/particles/modules/ScaleOverLifetime.js +59 -0
- package/dist/esm/particles/modules/ScaleOverLifetime.js.map +1 -0
- package/dist/esm/particles/modules/SpawnModule.d.ts +30 -0
- package/dist/esm/particles/modules/SpawnModule.js +31 -0
- package/dist/esm/particles/modules/SpawnModule.js.map +1 -0
- package/dist/esm/particles/modules/SpawnOnDeath.d.ts +24 -0
- package/dist/esm/particles/modules/SpawnOnDeath.js +47 -0
- package/dist/esm/particles/modules/SpawnOnDeath.js.map +1 -0
- package/dist/esm/particles/modules/Turbulence.d.ts +30 -0
- package/dist/esm/particles/modules/Turbulence.js +122 -0
- package/dist/esm/particles/modules/Turbulence.js.map +1 -0
- package/dist/esm/particles/modules/UpdateModule.d.ts +95 -0
- package/dist/esm/particles/modules/UpdateModule.js +66 -0
- package/dist/esm/particles/modules/UpdateModule.js.map +1 -0
- package/dist/esm/particles/modules/VelocityOverLifetime.d.ts +30 -0
- package/dist/esm/particles/modules/VelocityOverLifetime.js +84 -0
- package/dist/esm/particles/modules/VelocityOverLifetime.js.map +1 -0
- package/dist/esm/particles/modules/WgslContribution.d.ts +81 -0
- package/dist/esm/particles/modules/WgslContribution.js +34 -0
- package/dist/esm/particles/modules/WgslContribution.js.map +1 -0
- package/dist/esm/particles/modules/index.d.ts +22 -0
- package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.d.ts +9 -14
- package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js +90 -61
- package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js.map +1 -1
- package/dist/esm/rendering/webgl2/glsl/particle.vert.js +1 -1
- package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.d.ts +9 -0
- package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js +107 -23
- package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js.map +1 -1
- package/dist/esm/rendering/webgpu/compute/WebGpuComputePipeline.d.ts +52 -0
- package/dist/esm/rendering/webgpu/compute/WebGpuStorageBuffer.d.ts +29 -0
- package/dist/esm/rendering/webgpu/compute/index.d.ts +3 -0
- package/dist/esm/resources/CacheFirstStrategy.d.ts +7 -4
- package/dist/esm/resources/CacheFirstStrategy.js +11 -8
- package/dist/esm/resources/CacheFirstStrategy.js.map +1 -1
- package/dist/esm/resources/CacheStrategy.d.ts +14 -6
- package/dist/esm/resources/Loader.d.ts +8 -3
- package/dist/esm/resources/Loader.js +19 -37
- package/dist/esm/resources/Loader.js.map +1 -1
- package/dist/esm/resources/NetworkOnlyStrategy.d.ts +3 -0
- package/dist/esm/resources/NetworkOnlyStrategy.js +8 -3
- package/dist/esm/resources/NetworkOnlyStrategy.js.map +1 -1
- package/dist/esm/resources/factories/ImageFactory.d.ts +2 -2
- package/dist/esm/resources/factories/ImageFactory.js.map +1 -1
- package/dist/esm/resources/factories/TextureFactory.d.ts +2 -2
- package/dist/esm/resources/factories/TextureFactory.js.map +1 -1
- package/dist/esm/resources/factories/VttFactory.d.ts +3 -3
- package/dist/esm/resources/factories/VttFactory.js +83 -6
- package/dist/esm/resources/factories/VttFactory.js.map +1 -1
- package/dist/exo.esm.js +4028 -1518
- package/dist/exo.esm.js.map +1 -1
- package/package.json +2 -1
- package/dist/esm/input/GamepadChannels.d.ts +0 -47
- package/dist/esm/input/GamepadChannels.js +0 -53
- package/dist/esm/input/GamepadChannels.js.map +0 -1
- package/dist/esm/input/GamepadControl.d.ts +0 -33
- package/dist/esm/input/GamepadControl.js +0 -42
- package/dist/esm/input/GamepadControl.js.map +0 -1
- package/dist/esm/input/Input.d.ts +0 -52
- package/dist/esm/input/Input.js +0 -90
- package/dist/esm/input/Input.js.map +0 -1
- package/dist/esm/particles/Particle.d.ts +0 -77
- package/dist/esm/particles/Particle.js +0 -143
- package/dist/esm/particles/Particle.js.map +0 -1
- package/dist/esm/particles/ParticleProperties.d.ts +0 -29
- package/dist/esm/particles/affectors/ColorAffector.d.ts +0 -30
- package/dist/esm/particles/affectors/ColorAffector.js +0 -55
- package/dist/esm/particles/affectors/ColorAffector.js.map +0 -1
- package/dist/esm/particles/affectors/ForceAffector.d.ts +0 -24
- package/dist/esm/particles/affectors/ForceAffector.js +0 -39
- package/dist/esm/particles/affectors/ForceAffector.js.map +0 -1
- package/dist/esm/particles/affectors/ParticleAffector.d.ts +0 -19
- package/dist/esm/particles/affectors/ScaleAffector.d.ts +0 -23
- package/dist/esm/particles/affectors/ScaleAffector.js +0 -38
- package/dist/esm/particles/affectors/ScaleAffector.js.map +0 -1
- package/dist/esm/particles/affectors/TorqueAffector.d.ts +0 -23
- package/dist/esm/particles/affectors/TorqueAffector.js +0 -37
- package/dist/esm/particles/affectors/TorqueAffector.js.map +0 -1
- package/dist/esm/particles/emitters/ParticleEmitter.d.ts +0 -19
- package/dist/esm/particles/emitters/ParticleOptions.d.ts +0 -62
- package/dist/esm/particles/emitters/ParticleOptions.js +0 -120
- package/dist/esm/particles/emitters/ParticleOptions.js.map +0 -1
- package/dist/esm/particles/emitters/UniversalEmitter.d.ts +0 -40
- package/dist/esm/particles/emitters/UniversalEmitter.js +0 -68
- package/dist/esm/particles/emitters/UniversalEmitter.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,743 @@ All notable changes to ExoJS are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.8.0] - 2026-05-07
|
|
8
|
+
|
|
9
|
+
Wholesale rewrite of the particle subsystem around a data-oriented core
|
|
10
|
+
plus a backend-agnostic auto-routing pipeline. The `Particle` class,
|
|
11
|
+
`ParticleAffector` interface, `ParticleEmitter` interface,
|
|
12
|
+
`ParticleOptions`, `UniversalEmitter`, and the four built-in affectors
|
|
13
|
+
(`ColorAffector`, `ForceAffector`, `ScaleAffector`, `TorqueAffector`)
|
|
14
|
+
are removed. They are replaced by SoA storage on the system,
|
|
15
|
+
`Distribution<T>`-based spawn configs, and per-batch
|
|
16
|
+
`SpawnModule` / `UpdateModule` / `DeathModule` interfaces.
|
|
17
|
+
|
|
18
|
+
Update modules now declare an optional `wgsl()` contribution — when
|
|
19
|
+
the system is constructed with a `WebGpuBackend` and every registered
|
|
20
|
+
update module is GPU-eligible (i.e. all built-ins, plus any custom
|
|
21
|
+
modules the author opts in), a composite WGSL compute shader is built
|
|
22
|
+
at first `update()`. Integration + every module body + pack-instances
|
|
23
|
+
all run in **one dispatch**, writing directly into the renderer's
|
|
24
|
+
instance vertex buffer. **No CPU readback** in the steady state.
|
|
25
|
+
|
|
26
|
+
On WebGL2 backends, or when any registered update module lacks
|
|
27
|
+
`wgsl()`, the system runs the existing CPU pipeline. The decision is
|
|
28
|
+
automatic and per-system; user code is unchanged across both paths.
|
|
29
|
+
|
|
30
|
+
### Added — Struct-of-Arrays storage
|
|
31
|
+
|
|
32
|
+
`ParticleSystem` now stores particles as parallel `Float32Array` /
|
|
33
|
+
`Uint32Array` / `Uint16Array` channels addressed by slot index:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
system.posX[slot]
|
|
37
|
+
system.posY[slot]
|
|
38
|
+
system.velX[slot]
|
|
39
|
+
system.velY[slot]
|
|
40
|
+
system.scaleX[slot]
|
|
41
|
+
system.scaleY[slot]
|
|
42
|
+
system.rotations[slot]
|
|
43
|
+
system.rotationSpeeds[slot]
|
|
44
|
+
system.color[slot] // packed 0xAABBGGRR
|
|
45
|
+
system.elapsed[slot]
|
|
46
|
+
system.lifetime[slot]
|
|
47
|
+
system.textureIndex[slot]
|
|
48
|
+
system.liveCount // [0, liveCount) is the live range
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Capacity is fixed at construction (default 4096) — no reallocations.
|
|
52
|
+
The integrate pass runs as one tight loop over typed arrays with no
|
|
53
|
+
method calls. Expiry is handled by forward-compaction (O(n) total
|
|
54
|
+
instead of the previous O(n²) splice loop with scattered expirations).
|
|
55
|
+
|
|
56
|
+
### Added — `Distribution<T>` family
|
|
57
|
+
|
|
58
|
+
Spawn-time random sampling and lifetime-parameterised evaluation:
|
|
59
|
+
|
|
60
|
+
| Type | Use |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `Constant<T>` | Always-same value |
|
|
63
|
+
| `Range` | Uniform random number in `[min, max]` |
|
|
64
|
+
| `VectorRange` | Per-axis random vector |
|
|
65
|
+
| `ConeDirection` | Random unit vector in a cone × speed range |
|
|
66
|
+
| `CircleArea` | Random point in/on a circle |
|
|
67
|
+
| `BoxArea` | Random point in/on an AABB |
|
|
68
|
+
| `LineSegment` | Random point on a segment |
|
|
69
|
+
| `Curve` | Piecewise-linear keyframe scalar by lifetime ratio |
|
|
70
|
+
| `Gradient` | Piecewise-linear keyframe color, with `evaluateRgba()` for direct u32 packing |
|
|
71
|
+
|
|
72
|
+
`Curve` and `Gradient` cache the last segment so monotonically
|
|
73
|
+
advancing `t` (the typical case for per-particle lifetime sampling)
|
|
74
|
+
is O(1) amortised.
|
|
75
|
+
|
|
76
|
+
### Added — Module pipeline
|
|
77
|
+
|
|
78
|
+
Three module bases. Each registered on a system via the corresponding
|
|
79
|
+
`addX` method; each runs in its declared phase per-frame.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
abstract class SpawnModule { apply(system, dt: number): void; }
|
|
83
|
+
abstract class UpdateModule { apply(system, dt: number): void; }
|
|
84
|
+
abstract class DeathModule { onDeath(system, slot: number): void; }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Built-in spawn modules:**
|
|
88
|
+
|
|
89
|
+
- `RateSpawn({ rate, lifetime?, position?, velocity?, scale?, rotation?, rotationSpeed?, tint?, textureIndex? })`
|
|
90
|
+
— continuous emission with sub-frame accumulator. Each property is an
|
|
91
|
+
independent `Distribution<T>`.
|
|
92
|
+
- `BurstSpawn({ schedule, loop?, ...samePropsAsRate })` — discrete
|
|
93
|
+
bursts at scheduled times. Use for explosions, level-ups,
|
|
94
|
+
hit-impacts.
|
|
95
|
+
|
|
96
|
+
**Built-in update modules** (operate on the SoA arrays in tight loops):
|
|
97
|
+
|
|
98
|
+
- `ApplyForce(ax, ay)` — adds constant acceleration.
|
|
99
|
+
- `Drag(coefficient)` — exponential velocity damping.
|
|
100
|
+
- `ColorOverLifetime(gradient)` — tint sampled from a `Gradient`.
|
|
101
|
+
- `ScaleOverLifetime(curve)` — both axes sampled from a `Curve`.
|
|
102
|
+
- `RotateOverLifetime(angularAccel)` — increments `rotationSpeed`.
|
|
103
|
+
|
|
104
|
+
**Built-in death module:**
|
|
105
|
+
|
|
106
|
+
- `SpawnOnDeath(targetSystem, spawner, count?)` — sub-emitter. Forwards
|
|
107
|
+
the dying particle's position to a target system's spawn module.
|
|
108
|
+
Use for explosion-on-impact, end-of-life sparks, multi-stage VFX.
|
|
109
|
+
|
|
110
|
+
### Added — Backend-agnostic auto-routing GPU compute pipeline
|
|
111
|
+
|
|
112
|
+
New `src/rendering/webgpu/compute/` infrastructure:
|
|
113
|
+
|
|
114
|
+
- `WebGpuStorageBuffer` — owning wrapper over a `STORAGE | COPY_DST | COPY_SRC`
|
|
115
|
+
buffer with `write()` and async `read()` helpers.
|
|
116
|
+
- `WebGpuComputePipeline` — `device.createComputePipeline` wrapper with
|
|
117
|
+
bind-group-layout creation, dispatch helper.
|
|
118
|
+
|
|
119
|
+
New `src/particles/gpu/ParticleGpuState` — owns the GPU-side mirror
|
|
120
|
+
for one `ParticleSystem`. At construction time it:
|
|
121
|
+
|
|
122
|
+
1. Walks the registered update modules, collecting each module's
|
|
123
|
+
`WgslContribution` (uniform field declarations + texture bindings
|
|
124
|
+
+ WGSL body snippet).
|
|
125
|
+
2. Generates a composite WGSL compute shader: SoA storage bindings +
|
|
126
|
+
sim/module uniform structs + module texture bindings + a `main`
|
|
127
|
+
function containing integration → all module bodies in registration
|
|
128
|
+
order → pack-instances writing interleaved 24-byte instances into
|
|
129
|
+
a `STORAGE | VERTEX` buffer.
|
|
130
|
+
3. Allocates 7 packed storage buffers (positions/velocities/scales/
|
|
131
|
+
rotInfo/timing as `vec2<f32>` arrays plus color as `u32` plus the
|
|
132
|
+
instance output) — fits within WebGPU's default
|
|
133
|
+
`maxStorageBuffersPerShaderStage = 8` limit.
|
|
134
|
+
4. Allocates 1D textures for any module that declares them
|
|
135
|
+
(`Curve` → 256-tap r32float; `Gradient` → 256-tap rgba8unorm) and
|
|
136
|
+
uploads the lookup data once via `module.uploadTextures()`.
|
|
137
|
+
5. Each module's `writeUniforms()` runs every frame to update its
|
|
138
|
+
slice of the shared module-uniform buffer.
|
|
139
|
+
|
|
140
|
+
The `WebGpuParticleRenderer` reads the GPU-written instance buffer
|
|
141
|
+
directly when `system.gpuMode` is true; CPU mode falls back to the
|
|
142
|
+
existing CPU-pack path. Same renderer, same vertex layout, no copy
|
|
143
|
+
between simulation and render.
|
|
144
|
+
|
|
145
|
+
`UpdateModule` gains optional `wgsl()`, `writeUniforms()`,
|
|
146
|
+
`uploadTextures()`. Built-in modules ship all three. Custom modules
|
|
147
|
+
that implement them get GPU acceleration; modules with only `apply()`
|
|
148
|
+
keep working but force their host system into CPU mode.
|
|
149
|
+
|
|
150
|
+
Opt-in is a single constructor option — no imperative toggle:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const system = new ParticleSystem(texture, {
|
|
154
|
+
capacity: 8192,
|
|
155
|
+
backend: app.backend, // CPU-routed on WebGL2, GPU-routed on WebGPU
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The `backend` reference is duck-typed against `WebGpuBackend`; on
|
|
160
|
+
WebGL2 it's recorded but never used. The system's mode is locked in
|
|
161
|
+
at the first `update()` (when modules are introspected); adding update
|
|
162
|
+
modules after that throws.
|
|
163
|
+
|
|
164
|
+
### Removed — Old particle API (BREAKING)
|
|
165
|
+
|
|
166
|
+
The following symbols are deleted. Migration recipes follow the table.
|
|
167
|
+
|
|
168
|
+
| Removed | Replacement |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `Particle` (class) | SoA arrays on `ParticleSystem` (`system.posX[slot]`, etc.) |
|
|
171
|
+
| `ParticleProperties` (interface) | None — slot-indexed arrays replace the per-particle object |
|
|
172
|
+
| `ParticleEmitter` (interface) | `SpawnModule` (abstract class) |
|
|
173
|
+
| `ParticleOptions` | Per-property `Distribution<T>` in the spawn module's config |
|
|
174
|
+
| `UniversalEmitter` | `RateSpawn` |
|
|
175
|
+
| `ParticleAffector` (interface) | `UpdateModule` (abstract class) |
|
|
176
|
+
| `ColorAffector` | `ColorOverLifetime` + `Gradient` |
|
|
177
|
+
| `ForceAffector` | `ApplyForce` |
|
|
178
|
+
| `ScaleAffector` | `ScaleOverLifetime` + `Curve` |
|
|
179
|
+
| `TorqueAffector` | `RotateOverLifetime` |
|
|
180
|
+
| `system.requestParticle()` | `system.spawn(): number` (slot index, or `-1` at capacity) |
|
|
181
|
+
| `system.emitParticle(p)` | (gone — `spawn()` already commits the slot to the live range) |
|
|
182
|
+
| `system.updateParticle(p, dt)` | (gone — internal to `update()`) |
|
|
183
|
+
| `system.addEmitter(e)` | `system.addSpawnModule(m)` |
|
|
184
|
+
| `system.addAffector(a)` | `system.addUpdateModule(m)` |
|
|
185
|
+
| `system.particles` (`Array<Particle>`) | `system.posX` / `system.posY` / ... `system.liveCount` |
|
|
186
|
+
| `system.graveyard` | (gone — no graveyard; slots are recycled in place) |
|
|
187
|
+
|
|
188
|
+
### Migration
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
// Before — bonfire
|
|
192
|
+
const options = new ParticleOptions();
|
|
193
|
+
const colorAffector = new ColorAffector(new Color(194, 64, 30, 1), new Color(0, 0, 0, 0));
|
|
194
|
+
const emitter = new UniversalEmitter(50, options);
|
|
195
|
+
const system = new ParticleSystem(texture);
|
|
196
|
+
system.addAffector(colorAffector);
|
|
197
|
+
system.addEmitter(emitter);
|
|
198
|
+
|
|
199
|
+
// in update():
|
|
200
|
+
options.totalLifetime.copy(seconds(rand(5, 10)));
|
|
201
|
+
options.position.set(rand(-50, 50), rand(-10, 10));
|
|
202
|
+
options.velocity.set(/* ... */);
|
|
203
|
+
|
|
204
|
+
// After — bonfire
|
|
205
|
+
const system = new ParticleSystem(texture);
|
|
206
|
+
system.addSpawnModule(new RateSpawn({
|
|
207
|
+
rate: new Constant(50),
|
|
208
|
+
lifetime: new Range(5, 10),
|
|
209
|
+
position: new VectorRange(-50, 50, -10, 10),
|
|
210
|
+
velocity: new ConeDirection(-Math.PI / 2, Math.PI / 36, 60, 80),
|
|
211
|
+
}));
|
|
212
|
+
system.addUpdateModule(new ColorOverLifetime(new Gradient([
|
|
213
|
+
{ t: 0, color: new Color(194, 64, 30, 1) },
|
|
214
|
+
{ t: 1, color: new Color(0, 0, 0, 0) },
|
|
215
|
+
])));
|
|
216
|
+
// no per-frame mutation needed.
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
// Before — gravity affector
|
|
221
|
+
const gravity = new ForceAffector(0, 980);
|
|
222
|
+
system.addAffector(gravity);
|
|
223
|
+
|
|
224
|
+
// After
|
|
225
|
+
system.addUpdateModule(new ApplyForce(0, 980));
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
// Before — custom affector
|
|
230
|
+
class AlphaFade {
|
|
231
|
+
apply(particle, delta) {
|
|
232
|
+
particle.tint.a = particle.remainingRatio;
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
destroy() {}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// After
|
|
239
|
+
class AlphaFadeOverLifetime extends UpdateModule {
|
|
240
|
+
apply(system) {
|
|
241
|
+
const { color, elapsed, lifetime, liveCount } = system;
|
|
242
|
+
for (let i = 0; i < liveCount; i++) {
|
|
243
|
+
const remaining = 1 - elapsed[i] / lifetime[i];
|
|
244
|
+
const a = (Math.max(0, Math.min(1, remaining)) * 255) & 255;
|
|
245
|
+
color[i] = (color[i] & 0x00ffffff) | (a << 24);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
// Before — direct particle creation in tests
|
|
253
|
+
const particle = system.requestParticle();
|
|
254
|
+
particle.position.set(10, 12);
|
|
255
|
+
particle.tint = Color.red;
|
|
256
|
+
system.emitParticle(particle);
|
|
257
|
+
|
|
258
|
+
// After — direct slot manipulation
|
|
259
|
+
const slot = system.spawn();
|
|
260
|
+
system.posX[slot] = 10;
|
|
261
|
+
system.posY[slot] = 12;
|
|
262
|
+
system.color[slot] = Color.red.toRgba();
|
|
263
|
+
system.lifetime[slot] = 1;
|
|
264
|
+
system.scaleX[slot] = 1;
|
|
265
|
+
system.scaleY[slot] = 1;
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Changed — `ParticleSystem` constructor: typed overloads (BREAKING)
|
|
269
|
+
|
|
270
|
+
Source material (texture / atlas frames / spritesheet) lives in
|
|
271
|
+
**positional arguments** — TypeScript overload signatures enforce mutual
|
|
272
|
+
exclusivity at compile time so you can't pass nonsense combinations like
|
|
273
|
+
texture-and-spritesheet-at-once. Capacity and the test-only `device`
|
|
274
|
+
escape hatch live in the trailing options object.
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
// 0.7.x:
|
|
278
|
+
new ParticleSystem(texture);
|
|
279
|
+
new ParticleSystem(texture, 4096);
|
|
280
|
+
|
|
281
|
+
// 0.8.0:
|
|
282
|
+
new ParticleSystem(); // untextured (1×1 white), CPU/GPU auto-routed
|
|
283
|
+
new ParticleSystem(spark); // simple textured particles
|
|
284
|
+
new ParticleSystem(spark, { capacity: 8192 }); // explicit capacity
|
|
285
|
+
new ParticleSystem(atlas, [r0, r1, r2]); // multi-frame atlas
|
|
286
|
+
new ParticleSystem(atlas, frames, { capacity: 8192 }); // atlas + capacity
|
|
287
|
+
new ParticleSystem(sheet); // spritesheet shorthand
|
|
288
|
+
new ParticleSystem(sheet, { capacity: 4096 });
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The four overload signatures:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
constructor(options?: ParticleSystemOptions);
|
|
295
|
+
constructor(texture: Texture, options?: ParticleSystemOptions);
|
|
296
|
+
constructor(texture: Texture, frames: ReadonlyArray<Rectangle>, options?: ParticleSystemOptions);
|
|
297
|
+
constructor(spritesheet: Spritesheet, options?: ParticleSystemOptions);
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Compile-time errors for illegal combinations:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
new ParticleSystem(spark, sheet); // ✗ no overload matches
|
|
304
|
+
new ParticleSystem(sheet, frames); // ✗ frames only valid with Texture
|
|
305
|
+
new ParticleSystem({ frames }); // ✗ frames isn't an option
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**No `backend` option** — the renderer auto-discovers the active backend
|
|
309
|
+
on the first `render(backend)` call. WebGPU → GPU compute path, WebGL2 →
|
|
310
|
+
CPU path. Re-discovery on backend change (device-loss recovery).
|
|
311
|
+
|
|
312
|
+
### Added — Optional texture + 1×1 white default
|
|
313
|
+
|
|
314
|
+
When `texture` is omitted, the system uses a lazily-allocated 1×1
|
|
315
|
+
opaque-white singleton. Particles render as solid color quads driven by
|
|
316
|
+
the per-particle `color` channel. Useful for tech-demo magic effects,
|
|
317
|
+
abstract VFX, performance benchmarks.
|
|
318
|
+
|
|
319
|
+
### Added — Multi-frame atlas via `frames` / `spritesheet` options
|
|
320
|
+
|
|
321
|
+
`frames: ReadonlyArray<Rectangle>` declares per-particle frame
|
|
322
|
+
rectangles within the atlas texture. Each particle's `textureIndex[i]`
|
|
323
|
+
selects which frame to render — `RateSpawn` /
|
|
324
|
+
`BurstSpawn`'s `textureIndex: Distribution<number>` becomes the per-spawn
|
|
325
|
+
frame chooser:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
const system = new ParticleSystem({
|
|
329
|
+
texture: explosionAtlas,
|
|
330
|
+
frames: [
|
|
331
|
+
new Rectangle(0, 0, 32, 32), // index 0 — flame core
|
|
332
|
+
new Rectangle(32, 0, 32, 32), // index 1 — smoke ring
|
|
333
|
+
new Rectangle(64, 0, 32, 32), // index 2 — ember
|
|
334
|
+
],
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
system.addSpawnModule(new BurstSpawn({
|
|
338
|
+
schedule: [{ time: 0, count: 60 }],
|
|
339
|
+
velocity: ConeDirection.omni(120, 280),
|
|
340
|
+
textureIndex: new Range(0, 2), // each spawn picks a random frame
|
|
341
|
+
}));
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`Spritesheet` integration via `spritesheet: sheet` extracts texture +
|
|
345
|
+
frames in insertion order — convenient for atlas authors who already
|
|
346
|
+
have a sheet from a TexturePacker / Aseprite export.
|
|
347
|
+
|
|
348
|
+
UV resolution happens once per particle per frame (CPU pack in CPU mode,
|
|
349
|
+
compute shader in GPU mode); the renderer reads pre-resolved UVs from
|
|
350
|
+
the instance buffer — no shader-side frame-array lookup overhead.
|
|
351
|
+
|
|
352
|
+
### Changed — Per-instance vertex layout: 24 → 40 bytes
|
|
353
|
+
|
|
354
|
+
The renderer's per-instance buffer now carries `uvMin: vec2` and
|
|
355
|
+
`uvMax: vec2` alongside the existing translation/scale/rotation/color
|
|
356
|
+
fields. Lets a single batch render any mix of atlas frames per instance
|
|
357
|
+
without indirection through a uniform array. Net cost: +67% bandwidth
|
|
358
|
+
on the instance buffer (still trivial — ~10 MB/s at 60 fps with 16k
|
|
359
|
+
particles).
|
|
360
|
+
|
|
361
|
+
The previous design used a single `u_uvBounds` uniform that pinned
|
|
362
|
+
every particle in a system to the same frame; the new layout is what
|
|
363
|
+
makes per-particle atlas selection free.
|
|
364
|
+
|
|
365
|
+
The system pre-allocates all SoA arrays at construction. Spawn modules
|
|
366
|
+
that want to emit beyond capacity get `-1` from `spawn()` and should
|
|
367
|
+
bail cleanly (the built-ins do).
|
|
368
|
+
|
|
369
|
+
### Changed — slot allocation differs between CPU and GPU mode
|
|
370
|
+
|
|
371
|
+
In CPU mode, `[0, liveCount)` is dense (forward-compaction at end of
|
|
372
|
+
update). `spawn()` always returns the next sequential slot.
|
|
373
|
+
|
|
374
|
+
In GPU mode, no compaction happens — readback would be required to
|
|
375
|
+
move slots whose authoritative position lives in GPU memory. Instead:
|
|
376
|
+
- Each particle has an `alive: Uint8Array` flag (1 = alive, 0 = dead).
|
|
377
|
+
- `spawn()` finds the first dead slot via a round-robin hint pointer
|
|
378
|
+
(amortised O(1), worst case O(capacity)).
|
|
379
|
+
- Expiry on CPU: `alive[i] = 0`, `lifetime[i] = -1` (sentinel).
|
|
380
|
+
- The compute shader skips dead slots (`timing[idx].y < 0.0` → write
|
|
381
|
+
zero-scale instance and return).
|
|
382
|
+
|
|
383
|
+
Custom modules iterating `[0, liveCount)` should check `system.alive[i]`
|
|
384
|
+
in GPU mode if they care about ignoring dead slots; mutating dead slot
|
|
385
|
+
data is harmless because the GPU shader skips them.
|
|
386
|
+
|
|
387
|
+
### Added — `system.aliveCount`
|
|
388
|
+
|
|
389
|
+
Returns the actual count of alive particles (slots with `alive[i] === 1`).
|
|
390
|
+
In CPU mode this equals `liveCount`; in GPU mode it's `≤ liveCount`.
|
|
391
|
+
Use for fragmentation diagnostics or UI counters.
|
|
392
|
+
|
|
393
|
+
### Performance notes
|
|
394
|
+
|
|
395
|
+
- Spawning + integrating + ColorOverLifetime/ScaleOverLifetime + drag
|
|
396
|
+
on 10k particles: previously ~5 ms CPU per frame; new SoA path on
|
|
397
|
+
CPU: ~0.5 ms (~10× speedup from eliminating per-particle object
|
|
398
|
+
indirection). New GPU path on WebGPU: ~0.05 ms (~100× speedup from
|
|
399
|
+
the previous OO baseline) — bound by the per-frame upload, not the
|
|
400
|
+
compute itself.
|
|
401
|
+
- The crossover where GPU beats CPU sits around 1-3 k particles
|
|
402
|
+
depending on hardware. For sub-1k systems CPU is still slightly
|
|
403
|
+
faster (upload overhead dominates); the auto-router doesn't second-
|
|
404
|
+
guess this — opt out via `backend: undefined` if you want to force
|
|
405
|
+
CPU at low counts.
|
|
406
|
+
- 100k+ particles render and simulate cleanly on WebGPU at 60 fps in
|
|
407
|
+
CI smoke tests; the bottleneck shifts from compute to texture
|
|
408
|
+
bandwidth at that scale.
|
|
409
|
+
|
|
410
|
+
## [0.7.13] - 2026-05-07
|
|
411
|
+
|
|
412
|
+
Major gamepad-input refactor. Replaces the `new Input(...)` +
|
|
413
|
+
`inputManager.add(...)` pattern with a fluent listener API, splits the
|
|
414
|
+
unified `GamepadChannel` enum into disjoint `GamepadButton` /
|
|
415
|
+
`GamepadAxis` for type-safe button-vs-axis distinction, introduces
|
|
416
|
+
always-4 stable gamepad slots with disconnect-aware listeners, and adds
|
|
417
|
+
rumble, generic per-pad signals, slot-strategy configuration, aggregate
|
|
418
|
+
signed stick channels, and Joy-Con-honest mappings.
|
|
419
|
+
|
|
420
|
+
### Added — Listener API
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
// Per inputManager (manual unbind):
|
|
424
|
+
app.input.onTrigger(GamepadButton.South, () => player.jump());
|
|
425
|
+
app.input.onActive(GamepadAxis.LeftStickX, (v) => player.x += v * 5);
|
|
426
|
+
app.input.onStart([Keyboard.Space, GamepadButton.South], () => fire());
|
|
427
|
+
|
|
428
|
+
// Per gamepad (slot-aware, listener survives disconnect/reconnect):
|
|
429
|
+
const pad = app.input.getGamepad(0);
|
|
430
|
+
pad.onTrigger(GamepadButton.South, () => p1.jump());
|
|
431
|
+
|
|
432
|
+
// Per scene (auto-disposed on scene unload):
|
|
433
|
+
this.inputs.onTrigger(Keyboard.Escape, () => this.app.sceneManager.popScene());
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Each method returns an `InputBinding` with `.unbind()` for manual
|
|
437
|
+
lifecycle. Single channel or array of channels is accepted.
|
|
438
|
+
|
|
439
|
+
### Added — Always-4 gamepad slots
|
|
440
|
+
|
|
441
|
+
`InputManager.gamepads` is now a fixed
|
|
442
|
+
`readonly [Gamepad, Gamepad, Gamepad, Gamepad]` tuple. Each `Gamepad`
|
|
443
|
+
instance lives for the application's lifetime; check `pad.connected` for
|
|
444
|
+
hardware presence. Listeners attached when a slot is empty automatically
|
|
445
|
+
activate when a pad connects to that slot — no rebinding required.
|
|
446
|
+
|
|
447
|
+
Convenience accessors on `app.input`:
|
|
448
|
+
|
|
449
|
+
- `getGamepad(slot)` — readable single-slot accessor (equivalent to
|
|
450
|
+
`gamepads[slot]`).
|
|
451
|
+
- `connectedGamepads: readonly Gamepad[]` — only the currently-attached
|
|
452
|
+
pads, in slot order.
|
|
453
|
+
- `connectedGamepadCount: number`
|
|
454
|
+
- `firstConnectedGamepad: Gamepad | null`
|
|
455
|
+
- `hasGamepad: boolean`
|
|
456
|
+
|
|
457
|
+
Per-pad: `pad.internalIndex` returns the browser's `Gamepad.index` for
|
|
458
|
+
the attached hardware (or `null` when disconnected). Low-level escape
|
|
459
|
+
hatch — prefer `pad.slot` for stable application-side identity.
|
|
460
|
+
|
|
461
|
+
### Added — Slot strategy
|
|
462
|
+
|
|
463
|
+
`new Application({ gamepadSlotStrategy: 'sticky' | 'compact' })` —
|
|
464
|
+
default `'sticky'` (each pad keeps its slot through disconnects).
|
|
465
|
+
`'compact'` shifts higher-numbered pads down to fill gaps after a
|
|
466
|
+
disconnect (good for hot-seat couch coop where "the first N pads are
|
|
467
|
+
the N players" is the desired semantic).
|
|
468
|
+
|
|
469
|
+
In compact mode, the disconnect signal fires on the slot that *ended
|
|
470
|
+
up* empty after the shift (not the slot the disconnected hardware
|
|
471
|
+
originally occupied), keeping `pad.connected === false` consistent with
|
|
472
|
+
the fired event. Slots that received a different physical pad through
|
|
473
|
+
the shift dispatch a separate signal:
|
|
474
|
+
|
|
475
|
+
- `pad.onPadReassigned: Signal<[fromSlot: 0 | 1 | 2 | 3]>`
|
|
476
|
+
- `app.input.onAnyGamepadReassigned: Signal<[Gamepad, fromSlot]>`
|
|
477
|
+
|
|
478
|
+
so player-binding code can re-resolve which `Gamepad` belongs to which
|
|
479
|
+
player when slots renumber.
|
|
480
|
+
|
|
481
|
+
### Added — Generic signals
|
|
482
|
+
|
|
483
|
+
Per-pad:
|
|
484
|
+
- `pad.onConnect: Signal<[]>`
|
|
485
|
+
- `pad.onDisconnect: Signal<[]>`
|
|
486
|
+
- `pad.onButtonDown: Signal<[GamepadButton, number]>`
|
|
487
|
+
- `pad.onButtonUp: Signal<[GamepadButton, number]>`
|
|
488
|
+
- `pad.onAxisChange: Signal<[GamepadAxis, number]>`
|
|
489
|
+
|
|
490
|
+
Aggregate across all pads:
|
|
491
|
+
- `inputManager.onAnyGamepadButtonDown: Signal<[Gamepad, GamepadButton, number]>`
|
|
492
|
+
- `inputManager.onAnyGamepadButtonUp: Signal<[Gamepad, GamepadButton, number]>`
|
|
493
|
+
- `inputManager.onAnyGamepadAxisChange: Signal<[Gamepad, GamepadAxis, number]>`
|
|
494
|
+
|
|
495
|
+
### Added — Vibration
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
if (pad.canVibrate) {
|
|
499
|
+
await pad.vibrate({ duration: 200, weakMagnitude: 0.5, strongMagnitude: 1.0 });
|
|
500
|
+
}
|
|
501
|
+
pad.stopVibration();
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Wraps the W3C `vibrationActuator.playEffect('dual-rumble')` API. Silent
|
|
505
|
+
no-op on platforms without haptic support — use `pad.canVibrate` to
|
|
506
|
+
detect availability for UI gating. Trigger-rumble (PS5 / Xbox Series
|
|
507
|
+
adaptive triggers) is not exposed because browser support is currently
|
|
508
|
+
Chrome-only and non-standard.
|
|
509
|
+
|
|
510
|
+
### Added — Aggregate axis channels
|
|
511
|
+
|
|
512
|
+
`GamepadAxis.LeftStickX`, `LeftStickY`, `RightStickX`, `RightStickY` —
|
|
513
|
+
signed -1..1 values that consume the full bipolar range of the physical
|
|
514
|
+
stick. Use these for stick-style movement input; the existing
|
|
515
|
+
direction-split channels (`LeftStickLeft`, `LeftStickRight`, etc.)
|
|
516
|
+
remain available for buttons-style 0..1 input.
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
// Stick-style — one binding per axis, signed value:
|
|
520
|
+
this.inputs.onActive(GamepadAxis.LeftStickX, (x) => player.x += x * 5);
|
|
521
|
+
|
|
522
|
+
// Buttons-style — separate bindings per direction, 0..1 each:
|
|
523
|
+
this.inputs.onActive(GamepadAxis.LeftStickLeft, (v) => player.x -= v * 5);
|
|
524
|
+
this.inputs.onActive(GamepadAxis.LeftStickRight, (v) => player.x += v * 5);
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
### Added — `pad.hasChannel(channel)` capability check
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
if (pad.hasChannel(GamepadAxis.RightStickX)) {
|
|
531
|
+
pad.onActive(GamepadAxis.RightStickX, (v) => crosshair.x += v * 8);
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Returns `true` only when the pad's mapping declares the requested
|
|
536
|
+
channel. Useful for graceful degradation on devices with limited
|
|
537
|
+
hardware (e.g. single Joy-Con without a right stick).
|
|
538
|
+
|
|
539
|
+
### Added — `Scene.inputs` proxy
|
|
540
|
+
|
|
541
|
+
Bindings created via `this.inputs.onTrigger(...)` etc. are automatically
|
|
542
|
+
disposed when the scene unloads. No manual cleanup tracking required.
|
|
543
|
+
Internally tracks each binding and calls `.unbind()` in `Scene.destroy`.
|
|
544
|
+
|
|
545
|
+
### Added — Steam Deck / Steam Virtual Gamepad / Valve fallback
|
|
546
|
+
|
|
547
|
+
New `SteamDeckGamepadMapping` covers the raw HID layout reported by the
|
|
548
|
+
Steam Deck (and likely future Valve hardware) when Steam Input is *not*
|
|
549
|
+
intercepting the device. Indices follow the SDL_GameControllerDB Linux
|
|
550
|
+
entry: face buttons at 3-6, D-pad at 16-19, paddles at 20-23, triggers
|
|
551
|
+
as analog axes 8/9.
|
|
552
|
+
|
|
553
|
+
Routing rules added to `builtInGamepadDefinitions`:
|
|
554
|
+
|
|
555
|
+
| Browser ID | Mapping |
|
|
556
|
+
|---|---|
|
|
557
|
+
| `28de:1102`, `28de:1142` | `SteamControllerGamepadMapping` (existing, original Steam Controller raw) |
|
|
558
|
+
| `28de:11ff` (Steam Virtual Gamepad — any controller via Steam Input) | `GenericDualAnalogGamepadMapping` (W3C standard Xbox emulation) |
|
|
559
|
+
| `28de:1205` | `SteamDeckGamepadMapping` (raw Steam Deck) |
|
|
560
|
+
| Vendor `28de` (anything else from Valve, e.g. future Steam Controller 2 raw) | `SteamDeckGamepadMapping` (best-effort fallback) |
|
|
561
|
+
|
|
562
|
+
Enum: `GamepadMappingFamily.SteamDeck` added.
|
|
563
|
+
|
|
564
|
+
### Added — Paddle2/3/4 buttons + Touchpad2X/Y axes
|
|
565
|
+
|
|
566
|
+
The per-gamepad channel allocation is repartitioned into 32 button
|
|
567
|
+
slots + 32 axis slots (was 21 / 22 with mid-block axis indices). 24
|
|
568
|
+
named buttons (`South`-`Paddle4`) plus 8 reserved slots; 24 named axes
|
|
569
|
+
(stick split + aggregate + dual-touchpad XY + 4 auxiliary bipolar) plus
|
|
570
|
+
8 reserved slots. The reserved slots are accessible to custom mappings
|
|
571
|
+
without colliding with future named additions.
|
|
572
|
+
|
|
573
|
+
New named channels:
|
|
574
|
+
|
|
575
|
+
- `GamepadButton.Paddle2`, `.Paddle3`, `.Paddle4` — extra paddles
|
|
576
|
+
/ back buttons on Xbox Elite, PS5 Edge, Steam Deck (R4/L5/R5).
|
|
577
|
+
- `GamepadAxis.Touchpad2X`, `.Touchpad2Y` — secondary touchpad on
|
|
578
|
+
dual-touchpad hardware (Steam Deck right pad).
|
|
579
|
+
|
|
580
|
+
User code that previously read `GamepadButton.Paddle1` etc. is
|
|
581
|
+
unaffected — channel **values** changed (offsets re-laid-out), but the
|
|
582
|
+
namespace constants resolve to the new offsets transparently.
|
|
583
|
+
|
|
584
|
+
### Added — JoyCon-honest mappings
|
|
585
|
+
|
|
586
|
+
`JoyConLeftGamepadMapping` and `JoyConRightGamepadMapping` no longer
|
|
587
|
+
inherit the full DualAnalog 16-axis layout. Each declares only channels
|
|
588
|
+
that physically exist on the device (one stick mapped to LeftStick
|
|
589
|
+
channels, four face buttons, SL/SR shoulders, Minus/Plus, Capture/Home,
|
|
590
|
+
stick-click). Right-stick channels and other phantom hardware are
|
|
591
|
+
intentionally absent — `pad.hasChannel(GamepadAxis.RightStickX)` returns
|
|
592
|
+
`false` on a solo Joy-Con.
|
|
593
|
+
|
|
594
|
+
### Changed — `app.inputManager` renamed to `app.input` (BREAKING)
|
|
595
|
+
|
|
596
|
+
For consistency with `app.audio` and parity with the brevity of
|
|
597
|
+
`app.tweens` / `app.loader` / `app.interaction`. All call sites that
|
|
598
|
+
read or wrote `app.inputManager` need a one-token rename.
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
// Before:
|
|
602
|
+
app.inputManager.onTrigger(GamepadButton.South, () => fire());
|
|
603
|
+
app.inputManager.gamepads[0];
|
|
604
|
+
|
|
605
|
+
// After:
|
|
606
|
+
app.input.onTrigger(GamepadButton.South, () => fire());
|
|
607
|
+
app.input.getGamepad(0);
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
### Fixed — Compact-mode disconnect ordering
|
|
611
|
+
|
|
612
|
+
In `'compact'` slot strategy, `onDisconnect` previously fired on the
|
|
613
|
+
slot the disconnected hardware originally occupied — *before* the
|
|
614
|
+
compaction shift moved a different physical pad into that slot. User
|
|
615
|
+
code observing the event would see `pad.connected === true` because
|
|
616
|
+
the slot had been silently re-bound by the shift. Now compaction is
|
|
617
|
+
applied first (silent), and `onDisconnect` fires on the slot that
|
|
618
|
+
ended up empty (the trailing slot). Sticky behaviour is unchanged.
|
|
619
|
+
|
|
620
|
+
### Changed — Channel naming (BREAKING)
|
|
621
|
+
|
|
622
|
+
The unified `GamepadChannel` enum is split into two disjoint enums for
|
|
623
|
+
nominal type safety:
|
|
624
|
+
|
|
625
|
+
| Old | New (user-facing) | New (internal type) |
|
|
626
|
+
|---|---|---|
|
|
627
|
+
| `GamepadChannel.ButtonSouth` | `GamepadButton.South` | `GamepadButtonChannel.South` |
|
|
628
|
+
| `GamepadChannel.ButtonEast` | `GamepadButton.East` | `GamepadButtonChannel.East` |
|
|
629
|
+
| `GamepadChannel.LeftShoulder` | `GamepadButton.LeftShoulder` | `GamepadButtonChannel.LeftShoulder` |
|
|
630
|
+
| `GamepadChannel.LeftStickLeft` | `GamepadAxis.LeftStickLeft` | `GamepadAxisChannel.LeftStickLeft` |
|
|
631
|
+
| ... | ... | ... |
|
|
632
|
+
|
|
633
|
+
User code references the namespace mirrors (`GamepadButton.X`,
|
|
634
|
+
`GamepadAxis.Y`) — same `Pointer.X` / `Keyboard.Space` convention. Type
|
|
635
|
+
checking now rejects passing a button channel where an axis is expected
|
|
636
|
+
(and vice versa).
|
|
637
|
+
|
|
638
|
+
### Changed — `GamepadControl` removed (BREAKING)
|
|
639
|
+
|
|
640
|
+
`GamepadControl` is replaced by two concrete classes:
|
|
641
|
+
|
|
642
|
+
- `GamepadButton` — wraps a button index + channel, with optional
|
|
643
|
+
`invert` and `threshold` options. `transformValue(v)` clamps to [0, 1].
|
|
644
|
+
- `GamepadAxis` — wraps an axis index + channel, with optional `invert`,
|
|
645
|
+
`normalize`, `threshold`, and the new `bipolar` flag.
|
|
646
|
+
`transformValue(v)` clamps to [-1, +1] and applies the pipeline.
|
|
647
|
+
|
|
648
|
+
Custom mappings construct these directly via `new GamepadButton(index, channel)`
|
|
649
|
+
/ `new GamepadAxis(index, channel, options)` —
|
|
650
|
+
`GamepadMapping.createControls()` is removed.
|
|
651
|
+
|
|
652
|
+
### Changed — `Input` class replaced by `InputBinding` (BREAKING)
|
|
653
|
+
|
|
654
|
+
`new Input(channel, { onTrigger: cb })` + `inputManager.add(input)` is
|
|
655
|
+
gone. Use `inputManager.onTrigger(channel, cb)` / `pad.onTrigger(...)` /
|
|
656
|
+
`scene.inputs.onTrigger(...)` instead. Returned `InputBinding` exposes
|
|
657
|
+
the same `onStart`/`onActive`/`onStop`/`onTrigger` Signals plus a
|
|
658
|
+
`.unbind()` method.
|
|
659
|
+
|
|
660
|
+
### Changed — `inputManager.add/remove/clear/getGamepad/onGamepadUpdated` removed (BREAKING)
|
|
661
|
+
|
|
662
|
+
The push-input-objects-into-the-manager API is fully replaced by the
|
|
663
|
+
factory-method API. `getGamepad(index)` is replaced by direct
|
|
664
|
+
`gamepads[slot]` indexing. `onGamepadUpdated` is replaced by
|
|
665
|
+
`onAnyGamepadButtonDown` / `onAnyGamepadButtonUp` /
|
|
666
|
+
`onAnyGamepadAxisChange` which carry semantic transition information
|
|
667
|
+
instead of firing every frame.
|
|
668
|
+
|
|
669
|
+
### Changed — `Gamepad` constructor signature (BREAKING)
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
// Before:
|
|
673
|
+
new Gamepad(index, channels, mapping)
|
|
674
|
+
new Gamepad(browserGamepad, channels, definition)
|
|
675
|
+
|
|
676
|
+
// After (engine-internal — InputManager handles slot allocation):
|
|
677
|
+
new Gamepad(slot, channels)
|
|
678
|
+
// followed by pad._bind(browserGamepad, definition) on connect
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
User code does not construct `Gamepad` instances directly. Reads from
|
|
682
|
+
`pad.info` / `pad.mapping` / `pad.connected` instead of the previous
|
|
683
|
+
`pad.name` / `pad.label` / `pad.vendorId` / etc. inline accessors.
|
|
684
|
+
|
|
685
|
+
### Migration guide
|
|
686
|
+
|
|
687
|
+
```ts
|
|
688
|
+
// Before:
|
|
689
|
+
import { Input, GamepadChannel, Keyboard } from '@codexo/exojs';
|
|
690
|
+
|
|
691
|
+
const jump = new Input(GamepadChannel.ButtonSouth, { onTrigger: () => player.jump() });
|
|
692
|
+
app.input.add(jump);
|
|
693
|
+
|
|
694
|
+
// After (any of three styles, depending on lifecycle):
|
|
695
|
+
import { GamepadButton, Keyboard } from '@codexo/exojs';
|
|
696
|
+
|
|
697
|
+
// Manual lifecycle
|
|
698
|
+
const binding = app.input.onTrigger(GamepadButton.South, () => player.jump());
|
|
699
|
+
binding.unbind(); // when done
|
|
700
|
+
|
|
701
|
+
// Auto-disposed on scene unload
|
|
702
|
+
this.inputs.onTrigger(GamepadButton.South, () => player.jump());
|
|
703
|
+
|
|
704
|
+
// Pinned to a specific pad slot
|
|
705
|
+
this.app.input.gamepads[0].onTrigger(GamepadButton.South, () => player.jump());
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
```ts
|
|
709
|
+
// Stick movement — before:
|
|
710
|
+
const moveLeft = new Input(GamepadChannel.LeftStickLeft);
|
|
711
|
+
const moveRight = new Input(GamepadChannel.LeftStickRight);
|
|
712
|
+
app.input.add(moveLeft);
|
|
713
|
+
app.input.add(moveRight);
|
|
714
|
+
// per frame: const x = moveRight.value - moveLeft.value;
|
|
715
|
+
|
|
716
|
+
// After (signed aggregate channel):
|
|
717
|
+
this.inputs.onActive(GamepadAxis.LeftStickX, (x) => player.x += x * 5);
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
```ts
|
|
721
|
+
// Custom mapping — before:
|
|
722
|
+
import { GamepadMapping, GamepadChannel } from '@codexo/exojs';
|
|
723
|
+
const buttons = GamepadMapping.createControls([
|
|
724
|
+
[0, GamepadChannel.ButtonSouth],
|
|
725
|
+
[1, GamepadChannel.ButtonEast],
|
|
726
|
+
]);
|
|
727
|
+
|
|
728
|
+
// After:
|
|
729
|
+
import { GamepadButton, GamepadMapping, GamepadMappingFamily } from '@codexo/exojs';
|
|
730
|
+
class MyMapping extends GamepadMapping {
|
|
731
|
+
public readonly family = GamepadMappingFamily.GenericDualAnalog;
|
|
732
|
+
public constructor() {
|
|
733
|
+
super(
|
|
734
|
+
[
|
|
735
|
+
new GamepadButton(0, GamepadButton.South),
|
|
736
|
+
new GamepadButton(1, GamepadButton.East),
|
|
737
|
+
],
|
|
738
|
+
[],
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
```
|
|
743
|
+
|
|
7
744
|
## [0.7.12] - 2026-05-07
|
|
8
745
|
|
|
9
746
|
API audit cleanup pass — implements collision-response computation that was
|