melonjs 20.4.0 → 20.5.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.
Files changed (79) hide show
  1. package/build/audio/backend/sound.d.ts.map +1 -1
  2. package/build/audio/procedural.d.ts +7 -2
  3. package/build/audio/procedural.d.ts.map +1 -1
  4. package/build/audio/types.d.ts +22 -0
  5. package/build/audio/types.d.ts.map +1 -1
  6. package/build/camera/camera2d.d.ts +28 -0
  7. package/build/camera/camera2d.d.ts.map +1 -1
  8. package/build/camera/camera3d.d.ts +24 -3
  9. package/build/camera/camera3d.d.ts.map +1 -1
  10. package/build/geometries/polygon.d.ts +7 -1
  11. package/build/geometries/polygon.d.ts.map +1 -1
  12. package/build/index.d.ts +4 -0
  13. package/build/index.d.ts.map +1 -1
  14. package/build/index.js +1061 -166
  15. package/build/index.js.map +4 -4
  16. package/build/level/gltf/GLTFModel.d.ts +50 -7
  17. package/build/level/gltf/GLTFModel.d.ts.map +1 -1
  18. package/build/lighting/light3d.d.ts +20 -47
  19. package/build/lighting/light3d.d.ts.map +1 -1
  20. package/build/loader/loader.d.ts +106 -6
  21. package/build/loader/loader.d.ts.map +1 -1
  22. package/build/renderable/animation.d.ts +21 -6
  23. package/build/renderable/animation.d.ts.map +1 -1
  24. package/build/renderable/container.d.ts +27 -5
  25. package/build/renderable/container.d.ts.map +1 -1
  26. package/build/renderable/instanced_mesh.d.ts +34 -9
  27. package/build/renderable/instanced_mesh.d.ts.map +1 -1
  28. package/build/renderable/mesh.d.ts +131 -94
  29. package/build/renderable/mesh.d.ts.map +1 -1
  30. package/build/renderable/renderable.d.ts +17 -0
  31. package/build/renderable/renderable.d.ts.map +1 -1
  32. package/build/renderable/sprite3d.d.ts +12 -2
  33. package/build/renderable/sprite3d.d.ts.map +1 -1
  34. package/build/renderable/text/bitmaptext.d.ts +32 -7
  35. package/build/renderable/text/bitmaptext.d.ts.map +1 -1
  36. package/build/renderable/text/text.d.ts +78 -3
  37. package/build/renderable/text/text.d.ts.map +1 -1
  38. package/build/renderable/text/textmetrics.d.ts +2 -0
  39. package/build/renderable/text/textmetrics.d.ts.map +1 -1
  40. package/build/renderable/text/textstyle.d.ts.map +1 -1
  41. package/build/state/stage.d.ts +21 -0
  42. package/build/state/stage.d.ts.map +1 -1
  43. package/build/system/device.d.ts +1 -1
  44. package/build/system/device.d.ts.map +1 -1
  45. package/build/system/save.d.ts +44 -1
  46. package/build/system/save.d.ts.map +1 -1
  47. package/build/video/effects/glsl_realization.d.ts.map +1 -1
  48. package/build/video/effects/mesh_splice.d.ts +2 -0
  49. package/build/video/effects/mesh_splice.d.ts.map +1 -0
  50. package/build/video/effects/shadereffect.d.ts +12 -128
  51. package/build/video/effects/shadereffect.d.ts.map +1 -1
  52. package/build/video/effects/wgsl_realization.d.ts.map +1 -1
  53. package/build/video/gradient.d.ts +19 -0
  54. package/build/video/gradient.d.ts.map +1 -1
  55. package/build/video/renderer.d.ts.map +1 -1
  56. package/build/video/texture/noise_texture2d.d.ts +101 -34
  57. package/build/video/texture/noise_texture2d.d.ts.map +1 -1
  58. package/build/video/webgl/batchers/mesh_batcher.d.ts.map +1 -1
  59. package/build/video/webgl/glshader.d.ts +7 -2
  60. package/build/video/webgl/glshader.d.ts.map +1 -1
  61. package/build/video/webgl/webgl_renderer.d.ts.map +1 -1
  62. package/build/video/webgpu/batchers/lit_mesh_batcher.d.ts.map +1 -1
  63. package/build/video/webgpu/batchers/mesh_batcher.d.ts +6 -1
  64. package/build/video/webgpu/batchers/mesh_batcher.d.ts.map +1 -1
  65. package/build/video/webgpu/webgpu_renderer.d.ts +1 -0
  66. package/build/video/webgpu/webgpu_renderer.d.ts.map +1 -1
  67. package/package.json +1 -1
  68. package/skills/melonjs-3d/SKILL.md +152 -3
  69. package/skills/melonjs-3d-assets/SKILL.md +139 -0
  70. package/skills/melonjs-audio/SKILL.md +17 -0
  71. package/skills/melonjs-camera-and-drawing/SKILL.md +45 -0
  72. package/skills/melonjs-effects-and-shaders/SKILL.md +93 -1
  73. package/skills/melonjs-loading-assets/SKILL.md +97 -1
  74. package/skills/melonjs-performance/SKILL.md +55 -1
  75. package/skills/melonjs-physics/SKILL.md +78 -0
  76. package/skills/melonjs-renderables/SKILL.md +63 -1
  77. package/skills/melonjs-renderer-backends/SKILL.md +19 -0
  78. package/skills/melonjs-scenes-and-state/SKILL.md +56 -0
  79. package/skills/melonjs-ui-and-text/SKILL.md +124 -0
@@ -25,6 +25,34 @@ app.viewport.addPostEffect(new VignetteEffect(app.renderer));
25
25
  `renderable.shader = …` is **deprecated since 19.2.0**, and its setter destroys
26
26
  whatever it replaces. Use `addPostEffect` / `getPostEffect` / `removePostEffect`.
27
27
 
28
+ ### A camera effect covers the HUD too
29
+
30
+ A camera's post-effect brackets the **entire** world draw — floating children
31
+ included. So the obvious way to write a full-screen pass also washes over every
32
+ HUD label in that world, which is rarely what you want.
33
+
34
+ To land a pass *after* the world but *before* the HUD, host the effect on a
35
+ screen-filling floating renderable ordered below the HUD's z instead:
36
+
37
+ ```js
38
+ const quad = new Sprite(0, 0, { image: anyImage });
39
+ quad.anchorPoint.set(0, 0);
40
+ quad.scale(viewW / anyImage.width, viewH / anyImage.height);
41
+ quad.floating = true; // its own uv is now screen space
42
+ quad.blendMode = "additive";
43
+ quad.addPostEffect(myEffect);
44
+ world.addChild(quad, HUD_Z - 10); // BELOW the labels, not above
45
+ ```
46
+
47
+ Two things follow from the quad filling the frame: its own `uv` is screen space
48
+ (so a pass like this needs none of the `screen_uv` / `screen_texture` builtins),
49
+ and the incoming `color` is the quad's own texture, which a body that paints
50
+ from scratch can ignore entirely.
51
+
52
+ Depth here is easy to get backwards — a higher z draws **later**, i.e. on top.
53
+ Verify it by returning a flat colour from the body for one frame: whatever it
54
+ tints is what the pass covers.
55
+
28
56
  ## Toggle with `enabled`, do not remove
29
57
 
30
58
  **`removePostEffect()` destroys the effect** — it calls `effect.destroy()` and
@@ -80,7 +108,34 @@ one `@group(3) @binding(0) var<uniform>` struct — so a single `setUniform` cal
80
108
  feeds whichever backend is live. `setTime` is a convenience for a `uTime`
81
109
  uniform and silently does nothing if the shader does not declare one.
82
110
 
83
- Shaders can also be preloaded as assets with loader type `"shader"`.
111
+ ### One effect is one program — prefer preloading, and share
112
+
113
+ A `ShaderEffect` compiles and links in its **constructor**, and the link is
114
+ checked with `getProgramParameter(LINK_STATUS)` straight after `linkProgram` —
115
+ a blocking call. So the cost is paid on whatever frame the effect is
116
+ constructed, and *n* identical effects cost it *n* times. `clone()` does not
117
+ avoid it: it constructs a new effect and links a new program.
118
+
119
+ Preload it instead. Loader type `"shader"` takes a `src` **or inline source via
120
+ `data`**, compiles at load time (inside the loading screen), and hands back a
121
+ **shared** instance:
122
+
123
+ ```js
124
+ loader.preload([{ name: "ramp", type: "shader", data: myFragmentBody }]);
125
+ // …then, for every label that wants it:
126
+ label.addPostEffect(loader.getShader("ramp"));
127
+ ```
128
+
129
+ Sharing one instance across renderables is safe *because* the loader sets
130
+ `effect.shared`. Without that flag a renderable's teardown destroys its post
131
+ effects, which would free the program out from under everything else still
132
+ using it. Uniforms live on the effect, so a shared instance means one set of
133
+ uniform values — fine when the look is uniform, and the reason to clone (and
134
+ pay a link) when it is not.
135
+
136
+ Note this covers **your** effects only. The engine's own mesh/quad shader
137
+ variants are compiled lazily per feature combination on first draw and cannot be
138
+ preloaded — see `melonjs-loading-assets` for the warm-up that covers those.
84
139
 
85
140
  ### Screen-reading builtins
86
141
 
@@ -109,6 +164,43 @@ Two places where a straight port really does differ:
109
164
  uniform (initialise it to `1.0`) and multiply vertical offsets by it; the
110
165
  renderer feeds `-1` on that path and `+1` everywhere else.
111
166
 
167
+ ## A ShaderEffect cannot shade a mesh on WebGPU
168
+
169
+ The single-effect fast path swaps the renderable's program outright instead of
170
+ rendering through an offscreen target. On WebGL that works for a `Mesh` too, so
171
+ a custom mesh shader is a `ShaderEffect` away. **On WebGPU it does not** — the
172
+ renderer says so in its own source, warns once, and then keeps the built-in
173
+ mesh shading.
174
+
175
+ Nothing throws. The mesh simply draws with its plain texture, which looks like
176
+ a broken shader, a bad binding, or a texture that failed to load — and since
177
+ `video.AUTO` prefers WebGPU where it is available, this is the default outcome
178
+ on most desktops. It cost several hours here: the symptom was a water surface
179
+ rendering flat white, and alpha, bindings, transparency and lighting were all
180
+ eliminated before the backend was.
181
+
182
+ 2D renderables are unaffected: `Sprite`, `Text` and the rest go through the
183
+ quad path, which hosts effects on both backends. So a gradient fill for text,
184
+ or a refraction over a sprite, is fine — it is specifically the mesh path.
185
+
186
+ If you need a genuinely custom mesh shader that runs everywhere, supply a full
187
+ dual-language `GLShader` rather than a fragment body, and declare the fog
188
+ uniforms yourself (see `melonjs-3d`).
189
+
190
+ ## Extra textures bind in setTexture order
191
+
192
+ `setTexture("uFoo", …)` assigns bindings **1 and 2** to the first extra
193
+ texture, **3 and 4** to the second, and so on. The WGSL must match:
194
+
195
+ ```wgsl
196
+ @group(3) @binding(1) var uFoo : texture_2d<f32>;
197
+ @group(3) @binding(2) var uFooSampler : sampler;
198
+ ```
199
+
200
+ Copying a two-texture effect and deleting one leaves the survivor declared at
201
+ 3/4 while it is bound at 1/2 — the program fails to build and the effect is
202
+ silently dropped.
203
+
112
204
  ## Blend modes
113
205
 
114
206
  Fourteen modes; thirteen of them are honoured by all three renderers:
@@ -46,11 +46,107 @@ The type strings are melonJS-specific — this is not a generic loader:
46
46
  | `"binary"` | raw data, bitmap font `.fnt` descriptors |
47
47
  | `"fontface"` | web fonts (`.ttf`, `.woff`) |
48
48
  | `"aseprite"` | Aseprite JSON + image |
49
- | `"shader"` | GLSL/WGSL shader sources |
49
+ | `"shader"` | GLSL/WGSL shader sources — `src` **or** inline via `data` |
50
50
  | `"obj"` / `"mtl"` | Wavefront models and materials |
51
51
  | `"gltf"` / `"glb"` | glTF scenes |
52
52
  | `"js"` | scripts |
53
53
 
54
+ ## What belongs in the manifest — and what cannot
55
+
56
+ Preloading is not only for things with a URL. Three tiers, and the middle one
57
+ is the one people miss.
58
+
59
+ **1. Assets with a source.** Everything in the type table above. Note `"shader"`
60
+ takes **inline source** as well as a `src`, via the `data` field:
61
+
62
+ ```js
63
+ import { myRamp } from "./myRamp"; // a GLSL fragment body, in your own module
64
+
65
+ loader.preload([
66
+ { name: "ramp", type: "shader", data: myRamp },
67
+ ]);
68
+ const fx = loader.getShader("ramp"); // shared, already compiled
69
+ ```
70
+
71
+ This matters more than it looks. A `ShaderEffect` compiles its program in its
72
+ **constructor**, and `compileProgram` calls `getProgramParameter(LINK_STATUS)`
73
+ immediately after `linkProgram` — a blocking call that forces the driver to
74
+ finish. So building one during a scene costs that link on the frame it appears,
75
+ and building three identical ones costs it three times. Preloaded as an asset it
76
+ is compiled at load time, behind the loading screen, and the loader hands out a
77
+ **shared** instance (`effect.shared === true`), which is what stops one
78
+ renderable's teardown freeing it out from under the others. `clone()` does NOT
79
+ help here — it constructs a new effect, and therefore links a new program.
80
+
81
+ **2. Derived assets — build once, after the preload.** Canvases you bake,
82
+ `NoiseTexture2d`, `TextureAtlas`: the loader cannot carry these, but they must
83
+ not be rebuilt per scene either. The renderer's texture cache is keyed by the
84
+ **image object**, so a freshly baked canvas is a brand new GPU texture with a
85
+ new mip chain — rebuild one per stage and a title → play → game-over cycle
86
+ uploads four copies of the same pixels. Build them once after `preload`
87
+ resolves and share them, the way the platformer example builds its
88
+ `TextureAtlas` into a module the scenes read from.
89
+
90
+ **3. The engine's own shader variants — not preloadable at all.** The mesh
91
+ batcher compiles a program per *feature combination* (lit, instanced, instance
92
+ colours, instance data, fog — fog joins the key), lazily, on the first draw that
93
+ needs it. There is no loader type for these and no API to warm the cache: the
94
+ only lever is to **draw** that combination once while the loading screen is
95
+ still up, then throw the scene away — the compiled programs stay in the
96
+ renderer. Worth doing for a heavy 3D scene, where first-draw linking is easily a
97
+ few hundred ms and lands as a freeze on the first frame of the first level.
98
+
99
+ ```js
100
+ await loader.preload(resources);
101
+ buildScene(app); // same geometry the level uses
102
+ await twoFrames(); // event.GAME_AFTER_DRAW
103
+ removeOnlyWhatYouAdded(app); // keep the programs, drop the scene
104
+ state.change(state.PLAY);
105
+ ```
106
+
107
+ **It borrows the LOADING stage's world, and that has three teeth**, all learned
108
+ the hard way:
109
+
110
+ - **Never tear down with `app.world.reset()`.** It clears *every* child of that
111
+ world, the loading screen's own included, and the screen goes blank for the
112
+ rest of the load. Snapshot `app.world.children` first and remove only what
113
+ you added.
114
+ - **Restore anything the warm-up changes that the loading screen shows** — the
115
+ renderer's `backgroundColor` above all, or the warm-up flashes its own empty
116
+ first frame.
117
+ - **Under a `Camera3d` it will hide the built-in loading screen, and there is no
118
+ clean way around it** — see below.
119
+
120
+ ### `DefaultLoadingScreen` lives in the WORLD, not on the screen
121
+
122
+ The built-in loading screen is written for a `Camera2d`. Its progress bar and
123
+ logo are added with `app.world.addChild(bar, 1)` / `addChild(logo, 2)` and are
124
+ **`floating === false`** — ordinary world-space renderables. They read as an
125
+ overlay only because that stage's world is otherwise empty.
126
+
127
+ So with `cameraClass: Camera3d`, anything you add to that world is in the scene
128
+ *with* them, and a 3D scene simply swallows them: the depth buffer puts terrain
129
+ in front of a bar sitting at z = 1. What does NOT rescue it, all tried:
130
+
131
+ - **Raising their `z`** — for a mesh on the `Camera3d` path `pos.z` IS world
132
+ depth, so the scene's own z values are geometry, not layering, and the bar
133
+ still loses to the depth test.
134
+ - **`moveToTop()`** — it early-returns for a child already at index 0, which the
135
+ loading screen's children always are, having been added before anything else.
136
+ It silently does nothing.
137
+ - **Flipping them to `floating = true`** for the duration — the scene still
138
+ covers them.
139
+
140
+ If you want a warm-up (or any 3D behind a loading screen), write your own
141
+ loading stage and draw its progress UI as **floating** renderables over the
142
+ scene. That is also the nicer result: the player watches the level assemble
143
+ instead of a logo. Budget it as real work, not a one-liner — and weigh it
144
+ against what the warm-up actually saves.
145
+
146
+ One more limit: a warm-up only covers the combinations it actually draws.
147
+ Renderables the real level has and the warm-up does not will still link on
148
+ entry.
149
+
54
150
  ## Two conventions that produce 404s
55
151
 
56
152
  **Audio `src` is a directory.** The loader appends the asset name plus each
@@ -81,7 +81,16 @@ Two rules that cause subtle bugs when missed:
81
81
  successfully recycled object never sees it. Pair event subscriptions with
82
82
  `onActivateEvent` / `onDeactivateEvent` instead, or you leak handlers.
83
83
 
84
- Engine classes are poolable too: `pool.pull("me.Tween", target)`.
84
+ Engine classes are poolable too: `pool.pull("Tween", target)`. The canonical
85
+ names are unprefixed — `Entity`, `Collectable`, `Trigger`, `Light2d`,
86
+ `Particle`, `Sprite`, `NineSliceSprite`, `Renderable`, `Text`, `BitmapText`,
87
+ `ImageLayer`, `Tween`, `ColorLayer`.
88
+
89
+ `pool.register` additionally aliases every name under an `me.` prefix, pointing
90
+ at the same entry, so `pool.pull("me.Tween")` resolves identically — and the
91
+ same alias is registered with the Tiled object factory, which is why a map
92
+ authored against melonJS 1.x still finds its classes. Prefer the unprefixed
93
+ name in new code; do not "correct" an `me.`-prefixed one, it is not broken.
85
94
 
86
95
  ## `scale()` is multiplicative
87
96
 
@@ -151,6 +160,49 @@ queries one at a time, usually not. See `melonjs-3d` for the decision table.
151
160
  GPU release. Images, by contrast, only lose their loader-side reference; the
152
161
  renderer's texture cache is not purged by unloading.
153
162
 
163
+ ## The first frame of a scene is not like the others
164
+
165
+ A freeze the first time a level appears, and never again, is almost always
166
+ **shader linking**, not your scene build. Measure before assuming: procedural
167
+ geometry is usually trivial next to it.
168
+
169
+ Programs are created lazily. The mesh batcher keys a variant on the feature
170
+ combination it is asked for (lit, instanced, instance colours, instance data,
171
+ fog) and links it on the first draw that needs it — and `compileProgram` calls
172
+ `getProgramParameter(LINK_STATUS)` right after `linkProgram`, which blocks until
173
+ the driver finishes. That whole cost lands inside one frame.
174
+
175
+ How to tell them apart, all from the page:
176
+
177
+ ```js
178
+ // 1. is it the build, or the draw? time your own setup
179
+ const t0 = performance.now(); buildLevel(); console.log(performance.now() - t0);
180
+
181
+ // 2. how long is the bad frame? rAF deltas, not averages
182
+ let last = performance.now();
183
+ const tick = () => { const n = performance.now();
184
+ if (n - last > 40) console.log("long frame", (n - last).toFixed(0));
185
+ last = n; requestAnimationFrame(tick); };
186
+
187
+ // 3. is it programs? count them, and when
188
+ const gl = WebGL2RenderingContext.prototype, link = gl.linkProgram;
189
+ gl.linkProgram = function (p) { console.count("linkProgram"); return link.call(this, p); };
190
+ ```
191
+
192
+ The tell is that the second entry into the same scene is cheap: one measured
193
+ case went 852ms of stall (worst frame 479ms) on first entry to 277ms (worst
194
+ 102ms) on the second, because the programs were already cached.
195
+
196
+ Two things follow. Cut the number of programs — identical `ShaderEffect`s each
197
+ link their own, so preload one as a `"shader"` asset and share it. And pay what
198
+ remains behind the loading screen by drawing the scene once there and discarding
199
+ it (see `melonjs-loading-assets`).
200
+
201
+ Do not profile this on a software rasterizer — headless Chromium falls back to
202
+ SwiftShader, where the absolute numbers are wildly pessimistic and the JS
203
+ profile shows the time as `(program)`, outside JS entirely. Ratios transfer;
204
+ milliseconds do not.
205
+
154
206
  ## Where the costs actually are
155
207
 
156
208
  In rough order for a typical 2D game:
@@ -175,6 +227,8 @@ In rough order for a typical 2D game:
175
227
  | everything updates even when idle | `update()` returning `true` unconditionally |
176
228
  | memory grows with long music | missing `stream: true` |
177
229
  | slow only on some machines | Canvas fallback — no GPU tilemap path |
230
+ | a freeze the first time a level shows, never again | shader variants linking on first draw — warm them behind the loading screen |
231
+ | the same scene stutters once per entry | a derived texture rebuilt per stage: the texture cache keys on the image object, so each rebuild is a new upload |
178
232
 
179
233
  ## Related skills
180
234
 
@@ -120,6 +120,19 @@ this.body.setVelocity(vx, vy);
120
120
  this.body.force.x = this.body.maxVel.x;
121
121
  ```
122
122
 
123
+ `maxVel` is what turns a force into a speed, so the two are set together. Use
124
+ the setters rather than writing the vectors — both take an optional THIRD
125
+ argument for the depth axis, left unchanged when omitted, so a 2D call keeps
126
+ its behaviour:
127
+
128
+ ```js
129
+ this.body.setMaxVelocity(3, 15); // walk speed, jump speed
130
+ this.body.setFriction(0.4, 0); // ground drag, none vertically
131
+ ```
132
+
133
+ `friction` here is per-axis per-step velocity damping, not a surface
134
+ coefficient — see the built-in quirks below.
135
+
123
136
  Under matter and planck, `syncFromPhysics()` copies the engine body's position
124
137
  back onto `renderable.pos` after every step, so a direct write is simply erased
125
138
  — no error. The built-in world integrates `pos` in place instead, so a write
@@ -227,6 +240,69 @@ Two prerequisites that produce empty results rather than errors:
227
240
  - On the **built-in** adapter, the broadphase is cleared and rebuilt inside
228
241
  `world.update()`, so querying before the first update returns nothing.
229
242
 
243
+ ## 3D collision: `Box3d` and the Z pushback
244
+
245
+ The narrowphase is not 2D-only. `Box3d` is the one shape with a depth extent,
246
+ and a `Box3d`-vs-`Box3d` contact is resolved in three dimensions — it is the
247
+ only pair in the engine that can push back along **Z**.
248
+
249
+ ```js
250
+ import { Box3d } from "melonjs";
251
+
252
+ // centre offset from the body, then half-extents on each axis
253
+ rock.body.addShape(new Box3d(0, 0, 0, 40, 60, 40));
254
+ boat.body.addShape(new Box3d(0, 0, 0, 30, 40, 60));
255
+ ```
256
+
257
+ Reach for this for anything moving in the XZ plane under a `Camera3d` — a
258
+ runner dodging obstacles, a 2.5D platformer, pickups on a course. Hand-rolling
259
+ a distance check there is the usual mistake, and it throws away the
260
+ penetration vector the response already carries.
261
+
262
+ **Read the Z axis off the response, not `overlapN`.** The MTV is a single
263
+ axis, so exactly one of `overlapN.x`, `overlapN.y` and `overlapNZ` is
264
+ non-zero:
265
+
266
+ ```js
267
+ onCollision(response, other) {
268
+ if (response.overlapNZ !== 0) {
269
+ // a depth-only contact: overlapN / overlapV are BOTH zero here
270
+ this.pos.z -= response.overlapZ;
271
+ }
272
+ return true;
273
+ }
274
+ ```
275
+
276
+ That is deliberate: a collision resolved along Z leaves the 2D fields at zero,
277
+ so an existing 2D `onCollision` applies no push rather than a wrong one.
278
+
279
+ **Mixed pairs degrade to 2D.** `Box3d` against a `Polygon`, `Rectangle`,
280
+ `RoundRect` or `Ellipse` tests the box's XY footprint and treats the planar
281
+ shape as unbounded along Z. Only `Box3d`-vs-`Box3d` gives a depth result, so
282
+ give BOTH sides a `Box3d` when you want one.
283
+
284
+ This is the narrowphase and is independent of the 3D broadphase queries above
285
+ — `querySphere` / `raycast3d` need `world.sortOn === "depth"`; `Box3d` shapes
286
+ do not.
287
+
288
+ **Bounds are what the broadphase sorts by, and they come from the SHAPE.**
289
+ Every item is filed by its renderable's 2D `getBounds()`, so an object with no
290
+ extent cannot be placed and silently collides with nothing. A `Mesh` takes its
291
+ extent from its geometry and a `GLTFModel` from its part meshes, so both report
292
+ where they are without being told. A body's shapes do **not** contribute, so a
293
+ custom collidable built on a bare `Container` has no extent until you give it
294
+ one:
295
+
296
+ ```js
297
+ // a container reports an EMPTY bounds until it has a size of its own
298
+ group.resize(width, height);
299
+ ```
300
+
301
+ `enableChildBoundsUpdate` also gives it one, but it re-measures the whole child
302
+ list on every update — reach for it only when the extent genuinely IS the
303
+ members' union and moves with them (a flock, a squad), not to give a fixed
304
+ group a size.
305
+
230
306
  ## Built-in world quirks
231
307
 
232
308
  These are specific to the default world and surprise people arriving from a
@@ -308,6 +384,8 @@ use them. `adapter.capabilities` (`constraints`,
308
384
  | crash or a null body when spawning from `onCollisionStart` | world mutated during contact dispatch (locked world under planck) |
309
385
  | `raycast` / `queryAABB` finds nothing | `isKinematic` still `true`, or no `world.update` yet (built-in) |
310
386
  | `querySphere` / `raycast3d` is not a function | 2D adapter, or built-in without a 3D broadphase (`sortOn !== "depth"`) |
387
+ | a 3D contact reports no overlap to push against | read `overlapNZ` / `overlapZ` — a Z-axis MTV leaves `overlapN` / `overlapV` at zero |
388
+ | two 3D objects never separate in depth | one of them carries a planar shape; a mixed pair is tested as the box's XY footprint, unbounded along Z |
311
389
  | `response.depth` / `response.normal` are `undefined` | reading a legacy `onCollision` response — it carries `overlap` / `overlapN` |
312
390
  | `onCollisionEnd` handler throws on `response` | built-in dispatches it with `undefined` |
313
391
  | off-screen bodies stop simulating | built-in gating on `inViewport`; set `alwaysUpdate` |
@@ -145,7 +145,36 @@ at `enabled === false` and no-ops every method rather than throwing — and
145
145
  backend at runtime, an effect that works on your machine may do nothing on
146
146
  another.
147
147
 
148
- ## 5. `Vector3d.set(x, y)` silently zeroes `z`
148
+ ## 5. Opacity cascades; `setChildsProperty` assigns
149
+
150
+ `alpha` composes down the tree. Fading a container fades everything inside
151
+ it, and the values multiply — a child at `0.5` inside a parent at `0.5` draws
152
+ at `0.25`. Each renderable keeps its own value; the composition happens at
153
+ draw time and is undone when the renderable is finished.
154
+
155
+ ```js
156
+ model.alpha = 0.3; // fades the whole rig
157
+ container.setChildsProperty("alpha", 0.3, true); // REWRITES each child's alpha
158
+ ```
159
+
160
+ The two are not interchangeable. `setChildsProperty` is a bulk assignment: it
161
+ changes what the children *are*, so their own `alpha` now reads `0.3` and
162
+ stays that way after the container goes back to full. Reach for it when you
163
+ mean to change the children, and for `alpha` when you mean to fade a subtree.
164
+
165
+ Do not assign alpha down a hierarchy by hand. The renderer composes it as it
166
+ walks the tree, so setting it at more than one level multiplies: in a rig
167
+ nested three deep, `0.3` applied at every level lands parts on `0.3`, `0.09`
168
+ and `0.027` depending where they sit. The symptom does not look like an
169
+ opacity problem — a model whose parts hold *different* opacities, and which
170
+ appears to change opacity as it turns, because turning changes which parts
171
+ face the camera.
172
+
173
+ A useful consequence: a `GLTFModel` is a `Container`, so `model.alpha` fades
174
+ every part of a loaded rig — which is what you want for a ghost, a fade-in,
175
+ or a reflection.
176
+
177
+ ## 6. `Vector3d.set(x, y)` silently zeroes `z`
149
178
 
150
179
  ```js
151
180
  v.set(10, 20); // z becomes 0, not "unchanged"
@@ -155,6 +184,37 @@ v.set(10, 20, v.z); // keep it
155
184
  `pos` is an `ObservableVector3d`, whose `set(x = 0, y = 0, z = 0)` does the same
156
185
  — so `this.pos.set(x, y)` wipes the object's depth.
157
186
 
187
+ ## 7. A settings key the class never reads is silently ignored
188
+
189
+ A settings object is a plain literal: nothing rejects a key, so a name that
190
+ belongs somewhere else does nothing at all and says nothing about it.
191
+
192
+ ```js
193
+ // WRONG — only the RENDERER reads a `blendMode` setting, never a renderable
194
+ const glow = new Sprite3d(x, y, { image: "sun", blendMode: "additive" });
195
+
196
+ // right: it is a property
197
+ glow.blendMode = "additive";
198
+ ```
199
+
200
+ The same shape catches `transparent` on a `GLTFModel` (its parts carry it —
201
+ `model.setChildsProperty("transparent", true, true)`) and any `Mesh` setting
202
+ handed to a class that forwards only a curated subset. If a visual option
203
+ appears to do nothing, check whether the class actually reads it before
204
+ tuning the value.
205
+
206
+ ## 8. `isRenderable` does not hide anything
207
+
208
+ It gates `updateBounds`, not drawing. Setting it `false` leaves the object on
209
+ screen and merely stops its bounds tracking, which is worse than doing nothing.
210
+
211
+ ```js
212
+ sprite.isRenderable = false; // WRONG — still drawn, bounds now stale
213
+ sprite.alpha = 0; // hides it
214
+ ```
215
+
216
+ Use `alpha` for a blink, or remove the child for a long absence.
217
+
158
218
  ## Update and draw
159
219
 
160
220
  ```js
@@ -207,6 +267,8 @@ Hand-rolled equivalents miss the batching and the multi-backend support.
207
267
  | custom draw offset by half the size | centred `anchorPoint` default not zeroed |
208
268
  | spatial query never finds an object | `isKinematic` left `true` — not in the broadphase |
209
269
  | `z` unexpectedly 0 after a `set` | `Vector3d.set(x, y)` defaults `z` to 0 |
270
+ | a settings option appears to do nothing | the class never reads that key — see section 7 |
271
+ | `isRenderable = false` did not hide the object | it gates bounds, not drawing — use `alpha` |
210
272
 
211
273
  ## Related skills
212
274
 
@@ -132,6 +132,14 @@ event.on(event.ONCONTEXT_RESTORED, (renderer) => {
132
132
  });
133
133
  ```
134
134
 
135
+ ## Test on the backend your users get, not the one your harness picks
136
+
137
+ `video.AUTO` prefers WebGPU where it exists. Headless Chromium usually has no
138
+ GPU adapter and falls back to WebGL, so an automated suite can pass every run
139
+ while the WebGPU path is broken — the two differ in real, throwing ways (see
140
+ below). Pin the backend explicitly when it matters: `renderer: video.WEBGPU`,
141
+ or the `#webgpu` / `#webgl` URI fragment on a page you can open by hand.
142
+
135
143
  ## Practical differences
136
144
 
137
145
  - **Frame capture orientation differs**: row 0 of a `toFrameTexture()` capture is
@@ -147,6 +155,15 @@ event.on(event.ONCONTEXT_RESTORED, (renderer) => {
147
155
  accents, ruinous for hundreds of objects. `normal`, `additive`, `multiply`,
148
156
  `screen` and `exclusion` are ordinary fixed-function state and cost nothing
149
157
  extra.
158
+ - **WebGPU rejects a zero-sized image source; WebGL shrugs at it.** Uploading a
159
+ 0-width or 0-height canvas throws
160
+ `OperationError: GPUQueue.copyExternalImageToTexture: External image state is
161
+ not valid`, from a stack that reads `drawImage → addQuad → getBinding`. WebGL
162
+ uploads the same source without complaint, so this is a class of bug that
163
+ cannot appear on one backend and stops the game dead on the other. The usual
164
+ source is a `Text` whose string is empty or whitespace-only — it measures to
165
+ nothing, so its glyph canvas has no width. Give a placeholder label a real
166
+ glyph and hide it with `alpha`/position instead of blanking its text.
150
167
  - **3D meshes do not honour blend modes.** The mesh pass draws with its own
151
168
  depth/blend state; asking for one of the advanced modes logs a one-time
152
169
  `blend mode "…" is not supported for 3D meshes` warning and falls back to
@@ -163,6 +180,8 @@ event.on(event.ONCONTEXT_RESTORED, (renderer) => {
163
180
  | a solid box where an effect should be | carrier drawn while the effect is disabled |
164
181
  | nothing renders at all on an old device | `video.WEBGL` pinned; WebGL 1 is rejected, not downgraded |
165
182
  | ported shader is vertically flipped | the WebGL capture is bottom-up, the WebGPU one top-down |
183
+ | `copyExternalImageToTexture: External image state is not valid` | a zero-sized upload source — most often a `Text` with an empty or whitespace-only string |
184
+ | runs on the test machine, dies on the dev's | the harness fell back to WebGL and never exercised WebGPU |
166
185
  | a blended mode is ignored on a mesh | meshes fall back to `"normal"`; look for the one-time warning |
167
186
  | textures vanish after the tab is restored | cached raw GPU handles not re-acquired on context restore |
168
187
 
@@ -74,6 +74,45 @@ app.freeze(150); // brief hit-stop (proxies state.fr
74
74
  `state.change`. The `"mask"` form needs its fourth argument, an `Ellipse` or
75
75
  `Polygon`; without one it warns and falls back to a direct switch.
76
76
 
77
+ ### A Stage's own `update()` is NOT paused
78
+
79
+ This one is easy to get wrong and hard to see. `Application.update` calls both
80
+ of these **unconditionally**:
81
+
82
+ ```js
83
+ this.isDirty = this.world.update(this.updateDelta);
84
+ this.isDirty = state.current().update(this.updateDelta) || this.isDirty;
85
+ ```
86
+
87
+ Only `Container.update` consults the pause — it reads `state.isPaused()` and
88
+ skips every child that is not `updateWhenPaused`. So world children (entities,
89
+ sprites, meshes, a `GLTFModel`) freeze correctly, while **the Stage subclass's
90
+ own `update()` keeps running**.
91
+
92
+ A game that keeps its logic in entities pauses for free and never notices. A
93
+ game that drives the simulation from `Stage.update` — common for an endless
94
+ runner, where one object moves and the world scrolls past it — keeps simulating
95
+ through `pauseOnBlur` with its world frozen. The signature is bizarre and very
96
+ misleading: the scenery scrolls on, the player character stands still in world
97
+ space and slides off the screen, and it all snaps back on focus. It reads as a
98
+ culling or animation bug, not a pause bug.
99
+
100
+ Guard it explicitly:
101
+
102
+ ```js
103
+ update(dt) {
104
+ super.update(dt);
105
+ if (state.isPaused()) {
106
+ return true; // world children are already frozen for you
107
+ }
108
+ // …simulation…
109
+ }
110
+ ```
111
+
112
+ Worth knowing that `pauseOnBlur` (default `true`) fires on a window `blur`, not
113
+ only on tab `visibilitychange` — so clicking another window on the same screen
114
+ triggers it while the canvas is still fully visible.
115
+
77
116
  ## The update loop
78
117
 
79
118
  There is no loop you own. Logic goes in `update(dt)` overrides, or a
@@ -160,6 +199,21 @@ save.add({ hiscore: 0 }); // once; idempotent, loads any stored value
160
199
  save.hiscore = 1200; // plain assignment writes to localStorage
161
200
  ```
162
201
 
202
+ **In TypeScript, keep what `add()` returns.** The namespace carries a
203
+ `[key: string]: unknown` index for keys registered anywhere, so reading
204
+ `save.hiscore` off it gives you `unknown`. `add()` hands back the same
205
+ namespace typed with the keys you just registered, which is the handle to
206
+ keep:
207
+
208
+ ```ts
209
+ const store = save.add({ hiscore: 0, lives: 3 });
210
+ store.hiscore = 1200; // number, no cast
211
+ if (score > store.hiscore) { /* ... */ }
212
+ ```
213
+
214
+ Chained calls accumulate, so `save.add({ a: 0 }).add({ b: "" })` is typed with
215
+ both.
216
+
163
217
  ## Symptom → cause
164
218
 
165
219
  | symptom | cause |
@@ -169,6 +223,8 @@ save.hiscore = 1200; // plain assignment writes to localStorage
169
223
  | a tween finishes in 1000 ms whatever you pass | `to()` takes `{ duration }`, not a number |
170
224
  | object frozen while its state updates | `update()` not returning `true` |
171
225
  | timers fire behind a pause menu | `window.setTimeout` instead of `timer.setTimeout` |
226
+ | the world freezes on blur but the game keeps advancing | simulation lives in `Stage.update`, which the pause does not gate — only `Container.update` checks `state.isPaused()` |
227
+ | the player character is left behind and scrolls off after a lost window | same cause: the stage advanced the camera while its world children were paused |
172
228
  | a tween does nothing | `.start()` never called |
173
229
  | effect stops during a hit-stop | tween needs `updateWhenPaused = true` |
174
230
  | crash after a stage switch | callback ran during teardown — guard with `state.isCurrent` |