melonjs 20.4.0 → 20.6.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 (88) hide show
  1. package/build/application/defaultApplicationSettings.d.ts +1 -0
  2. package/build/application/defaultApplicationSettings.d.ts.map +1 -1
  3. package/build/application/settings.d.ts +37 -0
  4. package/build/application/settings.d.ts.map +1 -1
  5. package/build/audio/backend/sound.d.ts.map +1 -1
  6. package/build/audio/procedural.d.ts +7 -2
  7. package/build/audio/procedural.d.ts.map +1 -1
  8. package/build/audio/types.d.ts +22 -0
  9. package/build/audio/types.d.ts.map +1 -1
  10. package/build/camera/camera2d.d.ts +28 -0
  11. package/build/camera/camera2d.d.ts.map +1 -1
  12. package/build/camera/camera3d.d.ts +24 -3
  13. package/build/camera/camera3d.d.ts.map +1 -1
  14. package/build/geometries/polygon.d.ts +7 -1
  15. package/build/geometries/polygon.d.ts.map +1 -1
  16. package/build/index.d.ts +4 -0
  17. package/build/index.d.ts.map +1 -1
  18. package/build/index.js +1379 -226
  19. package/build/index.js.map +4 -4
  20. package/build/level/gltf/GLTFModel.d.ts +50 -7
  21. package/build/level/gltf/GLTFModel.d.ts.map +1 -1
  22. package/build/lighting/light3d.d.ts +20 -47
  23. package/build/lighting/light3d.d.ts.map +1 -1
  24. package/build/loader/loader.d.ts +106 -6
  25. package/build/loader/loader.d.ts.map +1 -1
  26. package/build/loader/loadingscreen.d.ts.map +1 -1
  27. package/build/renderable/animation.d.ts +21 -6
  28. package/build/renderable/animation.d.ts.map +1 -1
  29. package/build/renderable/container.d.ts +27 -5
  30. package/build/renderable/container.d.ts.map +1 -1
  31. package/build/renderable/instanced_mesh.d.ts +34 -9
  32. package/build/renderable/instanced_mesh.d.ts.map +1 -1
  33. package/build/renderable/mesh.d.ts +131 -94
  34. package/build/renderable/mesh.d.ts.map +1 -1
  35. package/build/renderable/renderable.d.ts +17 -0
  36. package/build/renderable/renderable.d.ts.map +1 -1
  37. package/build/renderable/sprite3d.d.ts +12 -2
  38. package/build/renderable/sprite3d.d.ts.map +1 -1
  39. package/build/renderable/text/bitmaptext.d.ts +32 -7
  40. package/build/renderable/text/bitmaptext.d.ts.map +1 -1
  41. package/build/renderable/text/text.d.ts +78 -3
  42. package/build/renderable/text/text.d.ts.map +1 -1
  43. package/build/renderable/text/textmetrics.d.ts +2 -0
  44. package/build/renderable/text/textmetrics.d.ts.map +1 -1
  45. package/build/renderable/text/textstyle.d.ts.map +1 -1
  46. package/build/state/stage.d.ts +21 -0
  47. package/build/state/stage.d.ts.map +1 -1
  48. package/build/system/device.d.ts +1 -1
  49. package/build/system/device.d.ts.map +1 -1
  50. package/build/system/save.d.ts +44 -1
  51. package/build/system/save.d.ts.map +1 -1
  52. package/build/video/effects/glsl_realization.d.ts.map +1 -1
  53. package/build/video/effects/mesh_splice.d.ts +2 -0
  54. package/build/video/effects/mesh_splice.d.ts.map +1 -0
  55. package/build/video/effects/shadereffect.d.ts +12 -128
  56. package/build/video/effects/shadereffect.d.ts.map +1 -1
  57. package/build/video/effects/wgsl_realization.d.ts.map +1 -1
  58. package/build/video/gradient.d.ts +19 -0
  59. package/build/video/gradient.d.ts.map +1 -1
  60. package/build/video/renderer.d.ts +44 -0
  61. package/build/video/renderer.d.ts.map +1 -1
  62. package/build/video/texture/noise_texture2d.d.ts +101 -34
  63. package/build/video/texture/noise_texture2d.d.ts.map +1 -1
  64. package/build/video/webgl/batchers/batcher.d.ts.map +1 -1
  65. package/build/video/webgl/batchers/mesh_batcher.d.ts.map +1 -1
  66. package/build/video/webgl/glshader.d.ts +7 -2
  67. package/build/video/webgl/glshader.d.ts.map +1 -1
  68. package/build/video/webgl/webgl_renderer.d.ts.map +1 -1
  69. package/build/video/webgpu/batchers/lit_mesh_batcher.d.ts.map +1 -1
  70. package/build/video/webgpu/batchers/mesh_batcher.d.ts +6 -1
  71. package/build/video/webgpu/batchers/mesh_batcher.d.ts.map +1 -1
  72. package/build/video/webgpu/effect_binding.d.ts.map +1 -1
  73. package/build/video/webgpu/pipeline/cache.d.ts.map +1 -1
  74. package/build/video/webgpu/webgpu_renderer.d.ts +1 -0
  75. package/build/video/webgpu/webgpu_renderer.d.ts.map +1 -1
  76. package/package.json +1 -1
  77. package/skills/melonjs-3d/SKILL.md +152 -3
  78. package/skills/melonjs-3d-assets/SKILL.md +139 -0
  79. package/skills/melonjs-audio/SKILL.md +17 -0
  80. package/skills/melonjs-camera-and-drawing/SKILL.md +45 -0
  81. package/skills/melonjs-effects-and-shaders/SKILL.md +130 -1
  82. package/skills/melonjs-loading-assets/SKILL.md +131 -1
  83. package/skills/melonjs-performance/SKILL.md +83 -1
  84. package/skills/melonjs-physics/SKILL.md +78 -0
  85. package/skills/melonjs-renderables/SKILL.md +63 -1
  86. package/skills/melonjs-renderer-backends/SKILL.md +19 -0
  87. package/skills/melonjs-scenes-and-state/SKILL.md +93 -0
  88. 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,71 @@ 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
+ ### Declare a level's effects with that level's assets
130
+
131
+ An effect the loader knows about can be prepared before anything draws it; an
132
+ effect built inline cannot, because it does not exist until the code that
133
+ constructs it runs — which is the stage or level you were hoping to speed up.
134
+ That is the practical reason to prefer the asset form even when the source is a
135
+ string in your own module:
136
+
137
+ ```js
138
+ // with the level's other assets, not in the stage that uses them.
139
+ // `preload()` rather than `load()` — preload is what warms the shaders it
140
+ // brought in, and it can be called again per level, not just at boot
141
+ await loader.preload([
142
+ { name: "ripples", type: "shader", src: {
143
+ glsl: "assets/level2/ripples.glsl",
144
+ wgsl: "assets/level2/ripples.wgsl",
145
+ }},
146
+ // …that level's textures, maps and audio alongside it
147
+ ], undefined, false); // `false`: stay on this stage, no loading screen
148
+ ```
149
+
150
+ `src` takes the dual-language pair, so one asset carries both realizations and
151
+ the two backends cannot drift apart. `data` takes inline source the same way.
152
+
153
+ With `prewarm` on (the default since 20.6), `loader.preload()` warms on its way
154
+ through — automatically, on every call — and compiles the GPU-side objects for
155
+ every shader it holds — on WebGPU that is the
156
+ WGSL module, which is otherwise built inside the draw path the first time the
157
+ effect is used. Effects constructed inline are not in the loader, so nothing can
158
+ bring them forward.
159
+
160
+ One caveat if you are moving an inline effect to a file: a template literal can
161
+ interpolate constants into the source (`vec2(${ASPECT}, 1.0)`), and a static
162
+ file cannot. Promote those to uniforms rather than duplicating the numbers
163
+ across the GLSL and WGSL copies — duplicated constants are exactly how the two
164
+ backends drift.
165
+
166
+ Sharing one instance across renderables is safe *because* the loader sets
167
+ `effect.shared`. Without that flag a renderable's teardown destroys its post
168
+ effects, which would free the program out from under everything else still
169
+ using it. Uniforms live on the effect, so a shared instance means one set of
170
+ uniform values — fine when the look is uniform, and the reason to clone (and
171
+ pay a link) when it is not.
172
+
173
+ Note this covers **your** effects only. The engine's own mesh/quad shader
174
+ variants are compiled lazily per feature combination on first draw and cannot be
175
+ preloaded — see `melonjs-loading-assets` for the warm-up that covers those.
84
176
 
85
177
  ### Screen-reading builtins
86
178
 
@@ -109,6 +201,43 @@ Two places where a straight port really does differ:
109
201
  uniform (initialise it to `1.0`) and multiply vertical offsets by it; the
110
202
  renderer feeds `-1` on that path and `+1` everywhere else.
111
203
 
204
+ ## A ShaderEffect cannot shade a mesh on WebGPU
205
+
206
+ The single-effect fast path swaps the renderable's program outright instead of
207
+ rendering through an offscreen target. On WebGL that works for a `Mesh` too, so
208
+ a custom mesh shader is a `ShaderEffect` away. **On WebGPU it does not** — the
209
+ renderer says so in its own source, warns once, and then keeps the built-in
210
+ mesh shading.
211
+
212
+ Nothing throws. The mesh simply draws with its plain texture, which looks like
213
+ a broken shader, a bad binding, or a texture that failed to load — and since
214
+ `video.AUTO` prefers WebGPU where it is available, this is the default outcome
215
+ on most desktops. It cost several hours here: the symptom was a water surface
216
+ rendering flat white, and alpha, bindings, transparency and lighting were all
217
+ eliminated before the backend was.
218
+
219
+ 2D renderables are unaffected: `Sprite`, `Text` and the rest go through the
220
+ quad path, which hosts effects on both backends. So a gradient fill for text,
221
+ or a refraction over a sprite, is fine — it is specifically the mesh path.
222
+
223
+ If you need a genuinely custom mesh shader that runs everywhere, supply a full
224
+ dual-language `GLShader` rather than a fragment body, and declare the fog
225
+ uniforms yourself (see `melonjs-3d`).
226
+
227
+ ## Extra textures bind in setTexture order
228
+
229
+ `setTexture("uFoo", …)` assigns bindings **1 and 2** to the first extra
230
+ texture, **3 and 4** to the second, and so on. The WGSL must match:
231
+
232
+ ```wgsl
233
+ @group(3) @binding(1) var uFoo : texture_2d<f32>;
234
+ @group(3) @binding(2) var uFooSampler : sampler;
235
+ ```
236
+
237
+ Copying a two-texture effect and deleting one leaves the survivor declared at
238
+ 3/4 while it is bound at 1/2 — the program fails to build and the effect is
239
+ silently dropped.
240
+
112
241
  ## Blend modes
113
242
 
114
243
  Fourteen modes; thirteen of them are honoured by all three renderers:
@@ -46,11 +46,141 @@ 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
+ **After preloading, switch to a stage of your own.** `preload()` puts you in
60
+ `state.LOADING`, and that stage is transitional: build your scene in the
61
+ callback and stay there, and nothing ever destroys it, so the built-in loading
62
+ screen's logo and progress bar remain on top of your game. See
63
+ `melonjs-scenes-and-state`.
64
+
65
+ **1. Assets with a source.** Everything in the type table above. Note `"shader"`
66
+ takes **inline source** as well as a `src`, via the `data` field:
67
+
68
+ ```js
69
+ import { myRamp } from "./myRamp"; // a GLSL fragment body, in your own module
70
+
71
+ loader.preload([
72
+ { name: "ramp", type: "shader", data: myRamp },
73
+ ]);
74
+ const fx = loader.getShader("ramp"); // shared, already compiled
75
+ ```
76
+
77
+ This matters more than it looks. A `ShaderEffect` compiles its program in its
78
+ **constructor**, and `compileProgram` calls `getProgramParameter(LINK_STATUS)`
79
+ immediately after `linkProgram` — a blocking call that forces the driver to
80
+ finish. So building one during a scene costs that link on the frame it appears,
81
+ and building three identical ones costs it three times. Preloaded as an asset it
82
+ is compiled at load time, behind the loading screen, and the loader hands out a
83
+ **shared** instance (`effect.shared === true`), which is what stops one
84
+ renderable's teardown freeing it out from under the others. `clone()` does NOT
85
+ help here — it constructs a new effect, and therefore links a new program.
86
+
87
+ **2. Derived assets — build once, after the preload.** Canvases you bake,
88
+ `NoiseTexture2d`, `TextureAtlas`: the loader cannot carry these, but they must
89
+ not be rebuilt per scene either. The renderer's texture cache is keyed by the
90
+ **image object**, so a freshly baked canvas is a brand new GPU texture with a
91
+ new mip chain — rebuild one per stage and a title → play → game-over cycle
92
+ uploads four copies of the same pixels. Build them once after `preload`
93
+ resolves and share them, the way the platformer example builds its
94
+ `TextureAtlas` into a module the scenes read from.
95
+
96
+ **3. The engine's own shader programs — warmed for you, since 20.6.** The mesh
97
+ batcher compiles a program per *feature combination* (instance colours, instance
98
+ data, fog — fog joins the key), lazily, on the first draw that needs it.
99
+ `prewarm` builds them during `preload()` instead, behind the loading
100
+ screen:
101
+
102
+ ```js
103
+ const app = new Application(1280, 720, {
104
+ // on by default since 20.6 — named here only to show where it lives
105
+ prewarm: true,
106
+ });
107
+ ```
108
+
109
+ **You do not call this yourself.** `preload()` calls `renderer.prewarm()` once
110
+ the assets are in and **before** `LOADER_COMPLETE` fires, so it happens while
111
+ the progress bar is still up. Every `preload()` warms, not just the first, so a
112
+ per-level preload warms whatever that level brought with it.
113
+
114
+ The one precondition: **a game that never calls `loader.preload()` is never
115
+ warmed**, because that is the only hook. If you load assets another way, call
116
+ `await app.renderer.prewarm()` yourself. On
117
+ WebGL that links the mesh batchers' variants, for the unlit tier and the lit
118
+ one that inherits from it; on WebGPU it compiles the
119
+ WGSL module of every shader the loader holds — which is the reason to declare
120
+ effects as assets rather than build them inline (see above).
121
+
122
+ Cost to a game that uses none of it is about 60ms of preload on a desktop GPU.
123
+ Set it to `false` only for a target where linking is slow enough that the
124
+ preload itself would suffer.
125
+
126
+ **What it does not do.** It makes the shaders *ready* earlier. It does not
127
+ remove the hitch on a scene's first frame, because most of that frame is
128
+ first-**submit** work — texture residency, buffer uploads, the driver
129
+ specializing against real bindings — which compiling a program does not touch.
130
+ For that, the lever is still to **draw** the scene once while the loading screen
131
+ is up and then throw it away; the warmed GPU state stays behind.
132
+
133
+ ```js
134
+ await loader.preload(resources);
135
+ buildScene(app); // same geometry the level uses
136
+ await twoFrames(); // event.GAME_AFTER_DRAW
137
+ removeOnlyWhatYouAdded(app); // keep the programs, drop the scene
138
+ state.change(state.PLAY);
139
+ ```
140
+
141
+ **It borrows the LOADING stage's world, and that has three teeth**, all learned
142
+ the hard way:
143
+
144
+ - **Never tear down with `app.world.reset()`.** It clears *every* child of that
145
+ world, the loading screen's own included, and the screen goes blank for the
146
+ rest of the load. Snapshot `app.world.children` first and remove only what
147
+ you added.
148
+ - **Restore anything the warm-up changes that the loading screen shows** — the
149
+ renderer's `backgroundColor` above all, or the warm-up flashes its own empty
150
+ first frame.
151
+ - **Under a `Camera3d` it will hide the built-in loading screen, and there is no
152
+ clean way around it** — see below.
153
+
154
+ ### `DefaultLoadingScreen` lives in the WORLD, not on the screen
155
+
156
+ The built-in loading screen is written for a `Camera2d`. Its progress bar and
157
+ logo are added with `app.world.addChild(bar, 1)` / `addChild(logo, 2)` and are
158
+ **`floating === false`** — ordinary world-space renderables. They read as an
159
+ overlay only because that stage's world is otherwise empty.
160
+
161
+ So with `cameraClass: Camera3d`, anything you add to that world is in the scene
162
+ *with* them, and a 3D scene simply swallows them: the depth buffer puts terrain
163
+ in front of a bar sitting at z = 1. What does NOT rescue it, all tried:
164
+
165
+ - **Raising their `z`** — for a mesh on the `Camera3d` path `pos.z` IS world
166
+ depth, so the scene's own z values are geometry, not layering, and the bar
167
+ still loses to the depth test.
168
+ - **`moveToTop()`** — it early-returns for a child already at index 0, which the
169
+ loading screen's children always are, having been added before anything else.
170
+ It silently does nothing.
171
+ - **Flipping them to `floating = true`** for the duration — the scene still
172
+ covers them.
173
+
174
+ If you want a warm-up (or any 3D behind a loading screen), write your own
175
+ loading stage and draw its progress UI as **floating** renderables over the
176
+ scene. That is also the nicer result: the player watches the level assemble
177
+ instead of a logo. Budget it as real work, not a one-liner — and weigh it
178
+ against what the warm-up actually saves.
179
+
180
+ One more limit: a warm-up only covers the combinations it actually draws.
181
+ Renderables the real level has and the warm-up does not will still link on
182
+ entry.
183
+
54
184
  ## Two conventions that produce 404s
55
185
 
56
186
  **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,77 @@ 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
+ Since 20.6 the engine's own programs are built during `preload()` instead —
176
+ `loader.preload()` calls `renderer.prewarm()` itself, on every call, when the
177
+ application's `prewarm` setting is on, which is the default. So on a current
178
+ version this is one cause you can rule out before looking further, **unless the
179
+ game does not use the preloader at all**, which is the one way to miss it — see `melonjs-loading-assets`. It does not
180
+ cover effects you construct inline, and it does not cover the rest of what a
181
+ first frame pays for, below.
182
+
183
+ How to tell them apart, all from the page:
184
+
185
+ ```js
186
+ // 1. is it the build, or the draw? time your own setup
187
+ const t0 = performance.now(); buildLevel(); console.log(performance.now() - t0);
188
+
189
+ // 2. how long is the bad frame? rAF deltas, not averages
190
+ let last = performance.now();
191
+ const tick = () => { const n = performance.now();
192
+ if (n - last > 40) console.log("long frame", (n - last).toFixed(0));
193
+ last = n; requestAnimationFrame(tick); };
194
+
195
+ // 3. is it programs? count them, and when
196
+ const gl = WebGL2RenderingContext.prototype, link = gl.linkProgram;
197
+ gl.linkProgram = function (p) { console.count("linkProgram"); return link.call(this, p); };
198
+ ```
199
+
200
+ The tell is that the second entry into the same scene is cheap: one measured
201
+ case went 852ms of stall (worst frame 479ms) on first entry to 277ms (worst
202
+ 102ms) on the second.
203
+
204
+ **Careful with the conclusion, though — "the programs were cached" is the
205
+ tempting reading and it is wrong.** Prewarming the engine's own programs ahead
206
+ of that first entry moves the worst frame by about 3ms, measured. So the 575ms
207
+ is not program linking: it is everything else the first *submit* pays for —
208
+ texture residency, buffer uploads, and the driver specializing a pipeline
209
+ against real bindings — all of which the second entry finds already done.
210
+
211
+ That distinction decides which lever to reach for. Linking is what `prewarm`
212
+ covers and what shrinking your shader count helps. The rest only goes away if
213
+ the geometry is actually **drawn** once beforehand.
214
+
215
+ Three things follow.
216
+
217
+ - **Cut the number of programs.** Identical `ShaderEffect`s each link their own,
218
+ so preload one as a `"shader"` asset and share it.
219
+ - **Declare effects as assets, not inline**, so the loader can prepare them
220
+ before a scene draws — an inline effect does not exist until the stage that
221
+ builds it runs.
222
+ - **Pay what remains behind the loading screen** by drawing the scene once there
223
+ and discarding it (see `melonjs-loading-assets`). Linking is only part of a
224
+ first frame: texture residency, buffer uploads and the driver specializing
225
+ against real bindings all land on the first *submit*, and only an actual draw
226
+ front-loads those. A measured case on a fast desktop GPU had shader prewarming
227
+ move the worst frame by ~3ms while a warm-up draw moved it by hundreds.
228
+
229
+ Do not profile this on a software rasterizer — headless Chromium falls back to
230
+ SwiftShader, where the absolute numbers are wildly pessimistic and the JS
231
+ profile shows the time as `(program)`, outside JS entirely. Ratios transfer;
232
+ milliseconds do not.
233
+
154
234
  ## Where the costs actually are
155
235
 
156
236
  In rough order for a typical 2D game:
@@ -175,6 +255,8 @@ In rough order for a typical 2D game:
175
255
  | everything updates even when idle | `update()` returning `true` unconditionally |
176
256
  | memory grows with long music | missing `stream: true` |
177
257
  | slow only on some machines | Canvas fallback — no GPU tilemap path |
258
+ | a freeze the first time a level shows, never again | shader variants linking on first draw — warm them behind the loading screen |
259
+ | 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
260
 
179
261
  ## Related skills
180
262
 
@@ -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