diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index e94a95b0e2..b90f91870a 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "melonjs",
- "version": "20.9.0",
+ "version": "20.10.0",
"description": "Build games with melonJS — 24 guides to the 2D, 2.5D and 3D HTML5 game engine, its conventions and idioms, so generated code runs the first time.",
"author": {
"name": "melonJS",
diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md
index e02dfa6f8b..cbe96d4456 100644
--- a/packages/melonjs/CHANGELOG.md
+++ b/packages/melonjs/CHANGELOG.md
@@ -1,5 +1,22 @@
# Changelog
+## [20.10.0] (melonJS 2) - _unreleased_
+
+**Highlights:** a game no longer stutters the first time an effect, a blend mode or a new kind of object appears. Shaders, pipelines and render targets are now prepared when the object is added, or during `loader.preload()`, rather than in the frame that first draws it, where a single first draw was measured at up to 140ms. Dropping a batch of meshes no longer stalls the frame either.
+
+### Fixed
+- WebGL: releasing retained geometry no longer stalls the frame, a stutter whenever a scene dropped a batch of meshes. Teardown asked a blocking `is*` query about every buffer and vertex array before deleting it, a round trip per object, and now only asks once the context has been lost, the only case where the answer can be no
+- WebGL: a `ShaderEffect` hosted on a `Mesh` is compiled during `loader.preload()`, on every call including a later one that brings a level's own effects, rather than on the frame it first draws. Preloading the shader built its standalone program, but a mesh host splices the body into the mesh shader, which is a different program again, so a game that declared every effect in its manifest still stuttered the first time each one appeared
+- WebGPU: a post effect's pipelines are compiled when the effect is attached (`addPostEffect`, assigning `shader`, or the tone curve `setToneMapping` builds), off the frame, rather than in the frame that first draws it, a stutter measured at up to 140ms per effect on desktop. Attaching effects when a pool or a stage is built, rather than when the object is shown, gives the compile time to finish
+- WebGL: a `ShaderEffect` hosted on a `Mesh` is also linked when it is attached, so an effect built inline and never preloaded no longer stalls its first draw either. The links are spread over the following frames, so a stage building a pool of effect-carrying meshes does not pay for all of them in one
+- Effects: switching a disabled effect on no longer creates a render target in that frame. A chain gets its targets for every effect attached to it, enabled or not, when the effect is attached
+- Rendering: adding a renderable to the world starts preparing its draw, so one added before it is first shown no longer pays for it on that frame. On WebGPU that is the pipeline for its blend mode, culling and transparency, compiled off the frame, and on both GPU backends an advanced blend mode (`overlay`, `difference` and the rest) gets its blend shader and offscreen target, a first draw measured at up to 136ms
+- WebGL and WebGPU: the shader warm-up runs again after a lost context or device is restored. It never did, so every shader went back to compiling on the frame it next drew
+- WebGPU: the mip-chain generator is built during `loader.preload()` when textures are filtered (`textureFilter` resolving to `"linear"`), instead of on the frame the first mipmapped texture is uploaded
+- Sprite: a video the browser refuses to start, as it does before the user has interacted with the page, no longer logs an uncaught error on every frame. The refusal is handled once, and playback starts on the next click, touch or key press
+- WebGL: ETC1 compressed textures no longer draw black. They were uploaded with a call ETC1 does not accept, which left the texture empty; they now upload as ETC2, which reads the same data, or level by level when the browser only supports ETC1
+- WebGPU: the smallest mip levels of a compressed texture (the 2×2 and 1×1 levels of a 4×4-block format) are no longer rejected and left empty. Each upload now covers whole blocks, as WebGPU requires
+
## [20.9.0] (melonJS 2) - _2026-10-18_
### Added
diff --git a/packages/melonjs/package.json b/packages/melonjs/package.json
index 13e8c7671a..a97095ec7c 100644
--- a/packages/melonjs/package.json
+++ b/packages/melonjs/package.json
@@ -1,6 +1,6 @@
{
"name": "melonjs",
- "version": "20.9.0",
+ "version": "20.10.0",
"description": "melonJS Game Engine",
"homepage": "http://www.melonjs.org/",
"type": "module",
diff --git a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md
index 87f013e80e..9d0015ddee 100644
--- a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md
+++ b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md
@@ -453,8 +453,15 @@ With `prewarm` on (the default since 20.6), `loader.preload()` warms on its way
through — automatically, on every call — and compiles the GPU-side objects for
every shader it holds — on WebGPU that is the
WGSL module, which is otherwise built inside the draw path the first time the
-effect is used. Effects constructed inline are not in the loader, so nothing can
-bring them forward.
+effect is used.
+
+Since 20.10 the rest is warmed when the effect is **attached**, inline effects
+included: `addPostEffect` (or assigning `shader`) compiles the pipelines that
+effect will draw with on that renderable — on WebGPU the costly part, keyed by
+the renderable's blend mode and target as well as the shader — and on WebGL
+links the spliced program when the host is a mesh. It finishes off the frame,
+so attach when you build (a pool, a stage starting), not when you show: an
+effect attached and drawn in the same frame still compiles on that frame.
One caveat if you are moving an inline effect to a file: a template literal can
interpolate constants into the source (`vec2(${ASPECT}, 1.0)`), and a static
@@ -470,8 +477,8 @@ uniform values — fine when the look is uniform, and the reason to clone (and
pay a link) when it is not.
Note this covers **your** effects only. The engine's own mesh/quad shader
-variants are compiled lazily per feature combination on first draw and cannot be
-preloaded — see `melonjs-loading-assets` for the warm-up that covers those.
+variants are covered by the loading-screen warm-up and by the per-renderable one
+— see `melonjs-loading-assets`.
### Screen-reading builtins
diff --git a/packages/melonjs/skills/melonjs-loading-assets/SKILL.md b/packages/melonjs/skills/melonjs-loading-assets/SKILL.md
index 9f18c6da69..e724f32df1 100644
--- a/packages/melonjs/skills/melonjs-loading-assets/SKILL.md
+++ b/packages/melonjs/skills/melonjs-loading-assets/SKILL.md
@@ -111,13 +111,31 @@ the assets are in and **before** `LOADER_COMPLETE` fires, so it happens while
the progress bar is still up. Every `preload()` warms, not just the first, so a
per-level preload warms whatever that level brought with it.
-The one precondition: **a game that never calls `loader.preload()` is never
-warmed**, because that is the only hook. If you load assets another way, call
+The one precondition for this part: **a game that never calls
+`loader.preload()` does not get it**. If you load assets another way, call
`await app.renderer.prewarm()` yourself. On
WebGL that links the mesh batchers' variants, for the unlit tier and the lit
one that inherits from it; on WebGPU it compiles the
-WGSL module of every shader the loader holds — which is the reason to declare
-effects as assets rather than build them inline (see above).
+WGSL module of every shader the loader holds, and the mip-chain blitter when
+textures are filtered.
+
+**4. Everything that depends on the renderable — warmed as you build, since
+20.10.** The rest cannot be done at preload, because it depends on what draws:
+on WebGPU a *pipeline* is keyed by blend mode, culling, transparency and the
+format of the target as well as by shader, and that compile is the expensive
+part (tens of milliseconds, up to 140ms measured, per effect). So, behind the
+same `prewarm` setting, the engine warms a renderable when it gains an effect
+(`addPostEffect`, or assigning `shader`) and when it is added to the world:
+its effect pipelines, its own draw, the render targets its chain composites
+through, the advanced-blend shader if it uses one. Inline effects are covered
+too; on WebGL that means linking a mesh-hosted effect's spliced program.
+
+That work starts after your code has run and finishes off the frame, so it
+needs **at least a frame between setting a renderable up and drawing it**. The
+pattern that gets it for free is the one you want anyway: build pools (and
+attach their effects) when the stage starts, then show and hide them during
+play. Something created and drawn in the same frame still compiles on that
+frame.
Cost to a game that uses none of it is about 60ms of preload on a desktop GPU.
Set it to `false` only for a target where linking is slow enough that the
diff --git a/packages/melonjs/skills/melonjs-performance/SKILL.md b/packages/melonjs/skills/melonjs-performance/SKILL.md
index 98647b968e..5084a0ac48 100644
--- a/packages/melonjs/skills/melonjs-performance/SKILL.md
+++ b/packages/melonjs/skills/melonjs-performance/SKILL.md
@@ -245,9 +245,12 @@ Since 20.6 the engine's own programs are built during `preload()` instead —
`loader.preload()` calls `renderer.prewarm()` itself, on every call, when the
application's `prewarm` setting is on, which is the default. So on a current
version this is one cause you can rule out before looking further, **unless the
-game does not use the preloader at all**, which is the one way to miss it — see `melonjs-loading-assets`. It does not
-cover effects you construct inline, and it does not cover the rest of what a
-first frame pays for, below.
+game does not use the preloader at all**, which is the one way to miss it — see `melonjs-loading-assets`. Since 20.10 the
+pipelines that depend on the renderable (effects, blend modes, render targets)
+are warmed when an effect is attached or a renderable is added to the world, as
+long as that happens at least a frame before it is drawn: a pool built at stage
+start is covered, an object created and drawn in the same frame is not. None of
+this covers the rest of what a first frame pays for, below.
How to tell them apart, all from the page:
@@ -364,6 +367,7 @@ In rough order for a typical 2D game:
| memory grows with long music | missing `stream: true` |
| slow only on some machines | Canvas fallback — no GPU tilemap path |
| a freeze the first time a level shows, never again | shader variants linking on first draw — warm them behind the loading screen |
+| a freeze the first time an effect appears mid-play (a shield, a hit flash) | the effect attached in the same frame it is drawn — attach it when the pool is built, so the warm-up has a frame to finish |
| 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 |
## Related skills
diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts
index 24b20a3a0f..9f41983a70 100644
--- a/packages/melonjs/src/application/settings.ts
+++ b/packages/melonjs/src/application/settings.ts
@@ -279,10 +279,13 @@ export type ApplicationSettings = {
* screen, instead of paying for it on the frame that first draws (GPU
* backends — the Canvas renderer has nothing to warm and ignores this).
*
- * Today that means the built-in shader programs, which is all
- * {@link Renderer#prewarm} does. Named for the intent rather than the
- * current contents: anything else worth doing before the first frame
- * belongs behind the same switch, and should not need a second setting.
+ * Named for the intent rather than the contents: anything worth doing
+ * before the first frame belongs behind this one switch. Today that is
+ * {@link Renderer#prewarm} during `loader.preload()` (the built-in shader
+ * programs, and the shaders declared as assets), and then, as the game
+ * runs, warming each renderable when it gains a post effect or is added
+ * to the world: its effect pipelines, its own draw, the render targets an
+ * effect chain composites through. That second part needs no preload.
*
* A GPU backend does not finish a shader when handed the source; it
* finishes it the first time something is drawn with it. That puts the
@@ -299,10 +302,11 @@ export type ApplicationSettings = {
* `false` for a target where linking is slow enough that the preload
* itself would suffer.
*
- * The warm-up is run by `loader.preload()` — every call, not just the
- * first — so a game that preloads gets it automatically and a game that
- * never calls `preload()` never gets it at all. Call
- * {@link Renderer#prewarm} directly in that case.
+ * The loading-screen warm-up is run by `loader.preload()` — every call,
+ * not just the first — so a game that preloads gets it automatically and
+ * a game that never calls `preload()` does not. Call
+ * {@link Renderer#prewarm} directly in that case. It runs again on its
+ * own after a lost context or device is restored.
*
* Shaders declared as assets (`{type: "shader"}`) are covered too, so a
* level's own effects are warmed by declaring them alongside that level's
diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js
index 1dce2feab2..f551f3d0b2 100644
--- a/packages/melonjs/src/renderable/container.js
+++ b/packages/melonjs/src/renderable/container.js
@@ -538,6 +538,9 @@ export default class Container extends Renderable {
registerChildBody(cchild, worldContainer);
});
}
+ // it will draw from the next frame on: build what that draw needs
+ // now (honours the `prewarm` setting)
+ worldContainer?.app?.renderer?._queueWarm?.(child);
}
if (
@@ -608,6 +611,8 @@ export default class Container extends Renderable {
registerChildBody(cchild, worldContainer);
});
}
+ // see addChild
+ worldContainer?.app?.renderer?._queueWarm?.(child);
}
if (
diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js
index 7d33b657c0..5091029283 100644
--- a/packages/melonjs/src/renderable/renderable.js
+++ b/packages/melonjs/src/renderable/renderable.js
@@ -695,11 +695,18 @@ export default class Renderable extends Rect {
this.postEffects.length = 0;
} else {
this.postEffects = [value];
+ value._renderer?._queueWarm?.(this);
}
}
/**
* Add a post-processing shader effect to this renderable.
+ *
+ * With the `prewarm` application setting on (the default), what the
+ * effect will need to draw here is compiled from this call, off the
+ * frame, rather than on the frame the renderable first draws with it.
+ * That takes a moment to finish, so attach effects when the renderable
+ * is built (a pool, a stage starting) rather than when it is shown.
* @param {GLShader|ShaderEffect} effect - the effect to add
* @returns {GLShader|ShaderEffect} the added effect
* @example
@@ -707,6 +714,9 @@ export default class Renderable extends Rect {
*/
addPostEffect(effect) {
this.postEffects.push(effect);
+ // compile what this effect will need now rather than on the frame the
+ // renderable first draws with it (honours the `prewarm` setting)
+ effect?._renderer?._queueWarm?.(this);
return effect;
}
diff --git a/packages/melonjs/src/renderable/sprite.js b/packages/melonjs/src/renderable/sprite.js
index 3f3dc50a43..1a0ca21e32 100644
--- a/packages/melonjs/src/renderable/sprite.js
+++ b/packages/melonjs/src/renderable/sprite.js
@@ -16,6 +16,10 @@ import Renderable from "./renderable.js";
// flicker interval in ms (~15 flashes per second)
const FLICKER_INTERVAL_MS = 33;
+// the gestures a browser counts as the user allowing playback, the same set
+// the audio backend listens for to unlock sound
+const VIDEO_GESTURES = ["touchstart", "touchend", "click", "keydown"];
+
/**
* additional import for TypeScript
* @import {TextureAtlas} from "./../video/texture/atlas.js";
@@ -265,6 +269,9 @@ export default class Sprite extends Renderable {
*/
this.removeStatePauseListener = on(STATE_PAUSE, () => {
this.image.pause();
+ // a paused game starts nothing: update() asks again once
+ // it runs, as it always has
+ this._cancelVideoPlay();
});
// Keep a video-frame callback pending while this sprite is
@@ -282,6 +289,24 @@ export default class Sprite extends Renderable {
* @internal
*/
this._lastVideoFrameVersion = -1;
+ /**
+ * a `play()` is in flight, or was refused until a user gesture
+ * @ignore
+ * @internal
+ */
+ this._videoPlayPending = false;
+ /**
+ * bumped to orphan an in-flight `play()` request
+ * @ignore
+ * @internal
+ */
+ this._videoPlayRequest = 0;
+ /**
+ * the document listener waiting for that gesture, while armed
+ * @ignore
+ * @internal
+ */
+ this._videoGestureListener = undefined;
if (typeof this.image.requestVideoFrameCallback === "function") {
this.image.version ??= 0;
const tick = () => {
@@ -877,8 +902,8 @@ export default class Sprite extends Renderable {
if (this.isVideo) {
if (this.animationpause) {
this.image.pause();
- } else if (this.image.paused) {
- this.image.play();
+ } else if (this.image.paused && this._videoPlayPending !== true) {
+ this._playVideo();
}
// set-only (never assign false — that would stomp a dirty flag
// set earlier in the tick, e.g. by a tween on a paused sprite)
@@ -1009,6 +1034,93 @@ export default class Sprite extends Renderable {
);
}
+ /**
+ * Start the video and handle the browser's answer. Before the user has
+ * interacted with the page, browsers refuse playback: calling `play()`
+ * again on every frame only logged an uncaught error per frame, so a
+ * refusal now waits for the next gesture instead. Any other failure
+ * (e.g. a `pause()` interrupting the request) retries on the next frame.
+ * @ignore
+ * @internal
+ */
+ _playVideo() {
+ const request = ++this._videoPlayRequest;
+ this._videoPlayPending = true;
+ // `Promise.resolve`: browsers before the promise-returning `play()`
+ // return undefined
+ Promise.resolve(this.image.play()).then(
+ () => {
+ this._videoPlayPending = false;
+ },
+ (error) => {
+ if (request !== this._videoPlayRequest) {
+ // orphaned by a pause or destroy() while it was pending
+ return;
+ }
+ if (error?.name === "NotAllowedError") {
+ this._awaitVideoGesture();
+ } else {
+ this._videoPlayPending = false;
+ }
+ },
+ );
+ }
+
+ /**
+ * Wait for the next user gesture and start the video from inside its
+ * handler, the moment a browser that refused playback allows it.
+ * @ignore
+ * @internal
+ */
+ _awaitVideoGesture() {
+ if (this._videoGestureListener !== undefined) {
+ return;
+ }
+ this._videoGestureListener = () => {
+ this._removeVideoGestureListener();
+ this._videoPlayPending = false;
+ if (this.animationpause !== true && this.image.paused) {
+ this._playVideo();
+ }
+ };
+ for (const type of VIDEO_GESTURES) {
+ globalThis.document.addEventListener(
+ type,
+ this._videoGestureListener,
+ true,
+ );
+ }
+ }
+
+ /**
+ * Drop a pending `play()` request and stop waiting for a gesture.
+ * @ignore
+ * @internal
+ */
+ _cancelVideoPlay() {
+ this._videoPlayRequest++;
+ this._videoPlayPending = false;
+ this._removeVideoGestureListener();
+ }
+
+ /**
+ * @ignore
+ * @internal
+ */
+ _removeVideoGestureListener() {
+ if (this._videoGestureListener === undefined) {
+ return;
+ }
+ for (const type of VIDEO_GESTURES) {
+ globalThis.document.removeEventListener(
+ type,
+ this._videoGestureListener,
+ true,
+ );
+ }
+ this._videoGestureListener = undefined;
+ }
+
/**
* Destroy function
* @ignore
@@ -1021,6 +1133,7 @@ export default class Sprite extends Renderable {
this.offset = undefined;
if (this.isVideo) {
this.removeStatePauseListener();
+ this._cancelVideoPlay();
if (typeof this._videoFrameHandle !== "undefined") {
this.image.cancelVideoFrameCallback(this._videoFrameHandle);
// drop the frame-counter stamp so a later non-Sprite consumer
diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js
index cbb6c07c7f..e9179a26b9 100644
--- a/packages/melonjs/src/video/renderer.js
+++ b/packages/melonjs/src/video/renderer.js
@@ -3,6 +3,7 @@ import { Color } from "./../math/color.ts";
import { Matrix3d } from "../math/matrix3d.ts";
import { Vector2d } from "../math/vector2d.ts";
import { CANVAS_ONRESIZE, emit } from "../system/event.ts";
+import { isAdvancedBlendMode } from "./blendmodes.js";
import { createCanvas } from "./canvas_factory.js";
import ToneMappingEffect, { CURVES } from "./effects/tonemap.js";
import { Gradient } from "./gradient.js";
@@ -440,12 +441,25 @@ export default class Renderer {
*
* Two consequences worth knowing:
*
- * - **A game that never calls `loader.preload()` is never warmed.** There
- * is no other hook. Call this yourself if you load assets some other way
- * and still want the warm-up.
+ * - **A game that never calls `loader.preload()` never gets this part.**
+ * Call it yourself if you load assets some other way and still want it.
* - It is deliberately NOT called from `Application.init()`. That runs
* before anything is on screen, so warming there would only lengthen a
* blank page instead of hiding behind a progress bar.
+ *
+ * This is the half that needs no renderable. The other half cannot run
+ * here, because what it compiles depends on the renderable drawing: under
+ * WebGPU a pipeline is keyed by blend mode, culling, transparency and the
+ * format of the target as well as by shader. So, behind the same
+ * `prewarm` setting, the renderer also warms a renderable when it gains
+ * a post effect ({@link Renderable#addPostEffect}) and when it is added
+ * to the world ({@link Container#addChild}): its effect pipelines, its
+ * own draw, the render targets its effect chain composites through, and
+ * the advanced blend machinery if its blend mode needs it. That work is
+ * started after the caller's code has run and finishes off the frame, so
+ * it pays off when a renderable is set up at least a frame before it is
+ * first drawn: build pools and attach their effects when a stage starts,
+ * not when the object is first shown.
* @returns {Promise} settles when the built-in programs are ready
* @example
* // the usual case: nothing to write. `preload()` warms on the way through
@@ -1542,6 +1556,174 @@ export default class Renderer {
return effects;
}
+ /**
+ * How many effects a renderable's chain has to have render targets for:
+ * every one attached, not just the ones enabled right now.
+ *
+ * Sizing the pool by the enabled count meant the ping-pong target was
+ * only created on the frame a second effect was switched on, which is a
+ * frame in the middle of play by construction (a hit flash, a power-up),
+ * and creating a target there is a stall in that frame. A disabled effect
+ * is one the game intends to turn on, so its target is made up front.
+ * @param {Renderable} renderable - the renderable whose chain this is
+ * @param {ShaderEffect[]} effects - its enabled chain, from `_effectChainFor`
+ * @returns {number} the number of chain slots to provision
+ * @ignore
+ * @internal
+ */
+ _postEffectCapacity(renderable, effects) {
+ return Math.max(effects.length, renderable.postEffects.length);
+ }
+
+ /**
+ * Warm, ahead of its first draw, what a renderable will need: the
+ * pipelines, programs and render targets that would otherwise be built
+ * on the frame it first shows up.
+ *
+ * Called when a renderable gains a post effect
+ * ({@link Renderable#addPostEffect}) and when it enters the world
+ * ({@link Container#addChild}), the two moments its draw becomes
+ * knowable. Deferred to a microtask so it runs once the caller's own code
+ * has finished, which is when the rest of the picture exists: a
+ * renderable usually gets its effect before it is added to the world,
+ * sized, or given its blend mode, and those all decide which pipeline its
+ * draw will ask for. Several requests in one task collapse into one
+ * warm-up per renderable.
+ *
+ * Honours the `prewarm` setting, the same switch as the loading-screen
+ * warm-up.
+ * @param {Renderable} renderable - the renderable to warm for
+ * @ignore
+ * @internal
+ */
+ _queueWarm(renderable) {
+ // no shader language means nothing to compile (the Canvas backend)
+ if (this.settings?.prewarm !== true || this.shaderLanguage == null) {
+ return;
+ }
+ if (this._warmHosts === undefined) {
+ /**
+ * renderables waiting for the next post-effect warm-up
+ * @type {Set}
+ * @ignore
+ * @internal
+ */
+ this._warmHosts = new Set();
+ }
+ if (this._warmHosts.size === 0) {
+ queueMicrotask(() => {
+ this._flushWarm();
+ });
+ }
+ this._warmHosts.add(renderable);
+ }
+
+ /**
+ * Run the warm-ups queued by {@link Renderer#_queueWarm}.
+ * @ignore
+ * @internal
+ */
+ _flushWarm() {
+ const hosts = [...this._warmHosts];
+ this._warmHosts.clear();
+ for (const host of hosts) {
+ // A warm-up that fails changes nothing: the draw builds what it
+ // needs on first use, exactly as it did before there was one.
+ try {
+ this._warmRenderable(host);
+ } catch {
+ // deliberately silent, see above
+ }
+ }
+ }
+
+ /**
+ * Warm one renderable, and everything under it when it is a container:
+ * adding a container to the world brings its whole subtree in with it,
+ * without an `addChild` per descendant.
+ * @param {Renderable} renderable - the renderable to warm for
+ * @ignore
+ * @internal
+ */
+ _warmRenderable(renderable) {
+ // a renderable destroyed between being queued and this running has
+ // no effects left, and nothing here builds anything without a draw
+ // to build for
+ if (renderable == null) {
+ return;
+ }
+ if (renderable.postEffects?.length > 0) {
+ this._warmPostEffects(renderable);
+ }
+ this._warmDraw(renderable);
+ if (isAdvancedBlendMode(renderable.blendMode)) {
+ this._warmAdvancedBlend();
+ }
+ const children = renderable.children;
+ if (Array.isArray(children)) {
+ for (const child of children) {
+ this._warmRenderable(child);
+ }
+ }
+ }
+
+ /**
+ * Warm everything one renderable's post effects will need. Each backend
+ * knows what that is; this base has no shaders, so nothing.
+ * @param {Renderable} renderable - the renderable to warm for
+ * @ignore
+ * @internal
+ */
+ // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
+ _warmPostEffects(renderable) {}
+
+ /**
+ * Warm the renderable's own draw, the one it makes without any effect.
+ * Only meaningful where that is a pipeline keyed by draw state; nothing
+ * to do on this base.
+ * @param {Renderable} renderable - the renderable to warm for
+ * @ignore
+ * @internal
+ */
+ // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
+ _warmDraw(renderable) {}
+
+ /**
+ * Warm the advanced blend modes' machinery: the blend shader, built on
+ * the first draw that uses one of those modes, and its offscreen target.
+ * Nothing to do on this base.
+ * @ignore
+ * @internal
+ */
+ _warmAdvancedBlend() {}
+
+ /**
+ * Whether a renderable draws through `drawMesh`, where a lone effect is
+ * spliced into the mesh shader instead of being drawn as a quad.
+ * Duck-typed on fields only a `Mesh` carries, so the renderer does not
+ * have to import the renderable classes. An instanced mesh never hosts
+ * an effect: its shading is a separate family that knows nothing of one.
+ * @param {Renderable} renderable - the renderable to test
+ * @returns {boolean} true for a non-instanced mesh
+ * @ignore
+ * @internal
+ */
+ _hostsMeshEffect(renderable) {
+ return (
+ typeof renderable.cullBackFaces === "boolean" &&
+ renderable.indices !== undefined &&
+ renderable.instanceLayout === undefined
+ );
+ }
+
+ /**
+ * Warm the tone curve's pipeline once it exists. Nothing to do on a
+ * backend that compiles it in its constructor.
+ * @ignore
+ * @internal
+ */
+ _warmToneMap() {}
+
/**
* Set the tone curve applied at the end of the camera's chain.
*
@@ -1636,6 +1818,7 @@ export default class Renderer {
// renderer-owned: a camera tearing its own effects down must not
// free the program every other camera is still using
this._toneMapEffect.shared = true;
+ this._warmToneMap();
}
this._toneMapEffect.setExposure(this.settings.toneMappingExposure ?? 1);
this._toneMapEffect.setWhite(this.settings.toneMappingWhite ?? 0);
diff --git a/packages/melonjs/src/video/rendertarget/render_target_pool.js b/packages/melonjs/src/video/rendertarget/render_target_pool.js
index e5dd96b482..991679d368 100644
--- a/packages/melonjs/src/video/rendertarget/render_target_pool.js
+++ b/packages/melonjs/src/video/rendertarget/render_target_pool.js
@@ -106,6 +106,27 @@ export default class RenderTargetPool {
return rt;
}
+ /**
+ * Create, ahead of the pass that will use them, the targets a top-level
+ * chain needs: the camera pair, or the first sprite pair. Targets that
+ * already exist are left as they are (the pass resizes them when it
+ * begins), and the active-pass stack is not touched, so this is safe
+ * outside any pass.
+ * @param {boolean} isCamera - true for the camera pair (0/1), false for the first sprite pair (2/3)
+ * @param {number} effectCount - the number of effects in the chain
+ * @param {number} width - target width in pixels
+ * @param {number} height - target height in pixels
+ */
+ reserve(isCamera, effectCount, width, height) {
+ const base = isCamera ? 0 : 2;
+ if (!this._pool[base]) {
+ this.get(base, width, height);
+ }
+ if (effectCount > 1 && !this._pool[base + 1]) {
+ this.get(base + 1, width, height);
+ }
+ }
+
/**
* Get the capture render target for the current active pass.
* @returns {RenderTarget|undefined} the capture target, or undefined if no active pass
diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js
index 7934f0d3ba..bce8c53ca0 100644
--- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js
@@ -6,11 +6,6 @@ import { WebGLBatcher } from "./batcher.js";
* @import {TextureAtlas} from "./../../texture/atlas.js";
*/
-/**
- * Base class for batchers that manage WebGL textures and material properties.
- * Provides texture creation, binding, uploading, and deletion.
- * @category Rendering
- */
/**
* Immutable-storage bookkeeping: `texStorage2D` allocations can never be
* respecified, so each GL texture's allocated shape is recorded here and a
@@ -32,6 +27,16 @@ function mipLevels(w, h) {
return Math.floor(Math.log2(Math.max(w, h))) + 1;
}
+// ETC1 is the one compressed format `compressedTexSubImage2D` refuses, and
+// ETC2's RGB8 format decodes ETC1 data unchanged
+const COMPRESSED_RGB_ETC1_WEBGL = 0x8d64;
+const COMPRESSED_RGB8_ETC2 = 0x9274;
+
+/**
+ * Base class for batchers that manage WebGL textures and material properties.
+ * Provides texture creation, binding, uploading, and deletion.
+ * @category Rendering
+ */
export class MaterialBatcher extends WebGLBatcher {
/**
* Initialize the textured batcher
@@ -231,6 +236,20 @@ export class MaterialBatcher extends WebGLBatcher {
const resourceOwned =
pixels !== null && typeof pixels.upload === "function";
const compressed = pixels !== null && pixels.compressed === true;
+ // ETC1 refuses `compressedTexSubImage2D`, so it cannot fill immutable
+ // storage: uploaded that way the texture stayed empty and drew black.
+ // ETC2 decodes the same bytes and takes the immutable path like every
+ // other format; with ETC1 alone the texture stays mutable and each
+ // level is filled with `compressedTexImage2D`, the one upload it allows
+ let compressedFormat = compressed ? pixels.format : undefined;
+ let mutableETC1 = false;
+ if (compressedFormat === COMPRESSED_RGB_ETC1_WEBGL) {
+ if (this.renderer.getSupportedCompressedTextureFormats().etc2) {
+ compressedFormat = COMPRESSED_RGB8_ETC2;
+ } else {
+ mutableETC1 = true;
+ }
+ }
if (!resourceOwned) {
const storageW = Math.max(1, w | 0);
const storageH = Math.max(1, h | 0);
@@ -239,7 +258,7 @@ export class MaterialBatcher extends WebGLBatcher {
: mipmap === true
? mipLevels(storageW, storageH)
: 1;
- const format = compressed ? pixels.format : gl.RGBA8;
+ const format = compressed ? compressedFormat : gl.RGBA8;
let storage = immutableStorage.get(currentTexture);
if (
storage &&
@@ -257,7 +276,7 @@ export class MaterialBatcher extends WebGLBatcher {
this.boundTextures[unit] = currentTexture;
storage = undefined;
}
- if (!storage) {
+ if (!storage && !mutableETC1) {
gl.texStorage2D(gl.TEXTURE_2D, levels, format, storageW, storageH);
immutableStorage.set(currentTexture, {
width: storageW,
@@ -290,18 +309,39 @@ export class MaterialBatcher extends WebGLBatcher {
// levels, so the chain is inherently complete — the old
// TEXTURE_MAX_LEVEL cap is subsumed by the `levels` parameter
const mipmaps = pixels.mipmaps;
- for (let i = 0; i < mipmaps.length; i++) {
- gl.compressedTexSubImage2D(
+ if (mutableETC1) {
+ // ...except the mutable ETC1 texture, complete only when capped
+ // to the levels it was given
+ gl.texParameteri(
gl.TEXTURE_2D,
- i,
- 0,
- 0,
- mipmaps[i].width,
- mipmaps[i].height,
- pixels.format,
- mipmaps[i].data,
+ gl.TEXTURE_MAX_LEVEL,
+ mipmaps.length - 1,
);
}
+ for (let i = 0; i < mipmaps.length; i++) {
+ if (mutableETC1) {
+ gl.compressedTexImage2D(
+ gl.TEXTURE_2D,
+ i,
+ compressedFormat,
+ mipmaps[i].width,
+ mipmaps[i].height,
+ 0,
+ mipmaps[i].data,
+ );
+ } else {
+ gl.compressedTexSubImage2D(
+ gl.TEXTURE_2D,
+ i,
+ 0,
+ 0,
+ mipmaps[i].width,
+ mipmaps[i].height,
+ compressedFormat,
+ mipmaps[i].data,
+ );
+ }
+ }
} else if (pixels === null) {
// allocation without data — WebGL guarantees zero-initialized
// storage, so texStorage2D alone is the blank texture
diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
index a09677a1fa..0beeda2525 100644
--- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
@@ -72,6 +72,28 @@ const _FOG_COLOR = new Float32Array(3);
const _FOG_PARAMS = new Float32Array(4);
const _FOG_HEIGHT = new Float32Array(4);
+/**
+ * Give the page a frame between two shader links during a warm-up.
+ *
+ * Linking is synchronous in WebGL and a dozen links in one task is a dropped
+ * frame. `setTimeout` is throttled to a tick a second in a background tab,
+ * though, and twenty-two of them would push `LOADER_COMPLETE` tens of seconds
+ * out for anyone who opened the game and switched away. Nothing is being
+ * drawn in a hidden tab, so there is no frame to be kind to: link straight
+ * through and only yield when the page is actually visible.
+ * @returns {Promise} settles on the next task, or now when hidden
+ * @ignore
+ * @internal
+ */
+function prewarmYield() {
+ if (globalThis.document?.hidden === true) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+}
+
/**
* A WebGL Batcher for rendering textured triangle meshes.
* Uses indexed drawing to efficiently render arbitrary triangle geometry.
@@ -859,7 +881,22 @@ export default class MeshBatcher extends MaterialBatcher {
* @internal
*/
effectShaderFor(effect) {
- const fog = this._fogDefine();
+ return this._effectShader(effect, this._fogDefine());
+ }
+
+ /**
+ * The spliced program for one effect under one fog state.
+ *
+ * Split out of {@link MeshBatcher#effectShaderFor} so `prewarm` can build
+ * BOTH fog variants at load, rather than only the one the current scene
+ * happens to be using.
+ * @param {object} effect - the hosted ShaderEffect
+ * @param {string} fog - the fog define, or ""
+ * @returns {GLShader|null} the spliced program, or null if it will not build
+ * @ignore
+ * @internal
+ */
+ _effectShader(effect, fog) {
const sources = this._shaderSources();
const fragment = spliceEffect(sources.fragment, effect._meshBody);
if (fragment === null) {
@@ -1017,26 +1054,14 @@ export default class MeshBatcher extends MaterialBatcher {
* @ignore
* @internal
*/
- async prewarm() {
- // `setTimeout` is throttled to a tick a second in a background tab, and
- // twenty-two of them would push `LOADER_COMPLETE` tens of seconds out
- // for anyone who opened the game and switched away. Nothing is being
- // drawn in a hidden tab, so there is no frame to be kind to: link
- // straight through and only yield when the page is actually visible.
- const yieldToFrame = () => {
- if (globalThis.document?.hidden === true) {
- return Promise.resolve();
- }
- return new Promise((resolve) => {
- setTimeout(resolve, 0);
- });
- };
- // A yield is a window in which the application can be destroyed or the
- // context lost. Building into a batcher that has been torn down leaks a
- // `GLShader` per remaining variant, each subscribed to the global
- // context-loss events with nothing left to destroy it.
+ /**
+ * @param {object[]} [effects] - hosted ShaderEffects to warm the spliced
+ * mesh variants for, normally every preloaded `{type: "shader"}` asset
+ */
+ async prewarm(effects = []) {
+ const yieldToFrame = prewarmYield;
const alive = () => {
- return this.gl !== null && this.gl?.isContextLost?.() !== true;
+ return this._prewarmAlive();
};
const sources = this._shaderSources();
const instanced = this._instancedShaderSources();
@@ -1079,6 +1104,56 @@ export default class MeshBatcher extends MaterialBatcher {
return;
}
}
+ await this.prewarmEffects(effects);
+ }
+
+ /**
+ * Build the spliced mesh variant of each effect, in both fog states.
+ *
+ * A ShaderEffect hosted on a mesh does NOT draw with the program its
+ * asset compiled to: the body is spliced into the mesh shader, which is a
+ * different program again, and it was built on the frame the effect first
+ * drew. Preloading the shader looked like it covered this and did not, so
+ * a game that declared every effect in its manifest still stuttered the
+ * first time each one appeared. Warming them here is what makes the
+ * preload mean what it looks like it means.
+ *
+ * Separate from {@link MeshBatcher#prewarm} because the two halves have
+ * different lifetimes: the batcher's own variants are built once, while
+ * the effects grow with every preload.
+ * @param {object[]} [effects] - hosted ShaderEffects, normally the
+ * preloaded `{type: "shader"}` assets
+ * @returns {Promise} settles once every variant is linked
+ * @ignore
+ * @internal
+ */
+ async prewarmEffects(effects = []) {
+ for (const fogDefine of ["", "#define FOG\n"]) {
+ for (const effect of effects) {
+ if (effect?._meshBody === undefined) {
+ continue;
+ }
+ this._effectShader(effect, fogDefine);
+ await prewarmYield();
+ if (!this._prewarmAlive()) {
+ return;
+ }
+ }
+ }
+ }
+
+ /**
+ * Whether a warm-up may keep building into this batcher. A yield is a
+ * window in which the application can be destroyed or the context lost,
+ * and building into a batcher that has been torn down leaks a `GLShader`
+ * per remaining variant, each subscribed to the global context-loss
+ * events with nothing left to destroy it.
+ * @returns {boolean} true while the context is live
+ * @ignore
+ * @internal
+ */
+ _prewarmAlive() {
+ return this.gl !== null && this.gl?.isContextLost?.() !== true;
}
/**
diff --git a/packages/melonjs/src/video/webgl/buffer/deletable.js b/packages/melonjs/src/video/webgl/buffer/deletable.js
new file mode 100644
index 0000000000..4c2771054c
--- /dev/null
+++ b/packages/melonjs/src/video/webgl/buffer/deletable.js
@@ -0,0 +1,54 @@
+/**
+ * Whether a GL object handle is safe to delete.
+ *
+ * Deleting counts as *using* an object, so deleting one whose context has
+ * gone raises `INVALID_OPERATION`. The `is*` queries answer that exactly —
+ * they report false for a handle that died with a previous context, and
+ * asking is itself error-free — which is why teardown used to probe with
+ * one.
+ *
+ * They are also SYNCHRONOUS: each blocks until the driver answers. Teardown
+ * runs per object, so a scene dropping a batch of retained meshes pays a
+ * round trip for every buffer and vertex array in it. Measured on a 3D
+ * example, those probes were the single largest cost in the frames that
+ * stuttered, ahead of shader compilation.
+ *
+ * The probe only ever has something to say about a context that has been
+ * lost at least once: any other context still owns every handle made against
+ * it. So it is skipped until that happens, and {@link markContextLost} is
+ * what turns it back on. A game that never loses its context never pays for
+ * the question.
+ */
+
+/** contexts that have been lost at least once, so a handle may be dead */
+const lostOnce = new WeakSet();
+
+/**
+ * Record that a context has been lost, so handles made before it are probed
+ * before deletion from here on.
+ * @param {WebGL2RenderingContext} gl - the context that was lost
+ */
+export function markContextLost(gl) {
+ if (gl) {
+ lostOnce.add(gl);
+ }
+}
+
+/**
+ * Whether `handle` can be deleted without raising `INVALID_OPERATION`.
+ * @param {WebGL2RenderingContext} gl - the owning context
+ * @param {Function} probe - the matching `is*` query, e.g. `gl.isBuffer`
+ * @param {object} handle - the GL object
+ * @returns {boolean} true when deletion is safe
+ */
+export function deletable(gl, probe, handle) {
+ if (gl.isContextLost() === true) {
+ // nothing survived, and deleting now would raise
+ return false;
+ }
+ if (lostOnce.has(gl) === false) {
+ // this context has never dropped anything, so the handle is live
+ return true;
+ }
+ return probe.call(gl, handle) === true;
+}
diff --git a/packages/melonjs/src/video/webgl/buffer/instance_buffer.js b/packages/melonjs/src/video/webgl/buffer/instance_buffer.js
index 14db5c258f..746f83c064 100644
--- a/packages/melonjs/src/video/webgl/buffer/instance_buffer.js
+++ b/packages/melonjs/src/video/webgl/buffer/instance_buffer.js
@@ -1,3 +1,4 @@
+import { deletable } from "./deletable.js";
/**
* The GPU half of an {@link InstancedMesh}'s instance records — one
* `ARRAY_BUFFER` holding the packed per-instance transforms (and the opt-in
@@ -81,14 +82,18 @@ export default class WebGLInstanceBuffer {
}
/**
- * Release the GL buffer. Safe to call twice, and on a lost context — the
- * `isBuffer` probe is itself error-free, whereas deleting a handle whose
- * context died raises INVALID_OPERATION.
+ * Release the GL buffer. Safe to call twice, and on a lost context:
+ * deleting a handle whose context died raises INVALID_OPERATION.
*/
destroy() {
const gl = this.gl;
if (this.buffer !== null) {
- if (gl.isBuffer(this.buffer)) {
+ // Not `isBuffer`: the `is*` queries are SYNCHRONOUS and block until
+ // the driver answers, and this runs per buffer as geometry is torn
+ // down. What matters here is only whether deleting is legal, which a
+ // lost context decides, and `isContextLost` answers without a round
+ // trip. See `VertexState#release` for the measurement.
+ if (deletable(gl, gl.isBuffer, this.buffer)) {
gl.deleteBuffer(this.buffer);
}
this.buffer = null;
diff --git a/packages/melonjs/src/video/webgl/buffer/retained_geometry.js b/packages/melonjs/src/video/webgl/buffer/retained_geometry.js
index 4f0d66ed9f..7440121dbe 100644
--- a/packages/melonjs/src/video/webgl/buffer/retained_geometry.js
+++ b/packages/melonjs/src/video/webgl/buffer/retained_geometry.js
@@ -1,3 +1,4 @@
+import { deletable } from "./deletable.js";
import WebGLVertexState from "./vertexstate.js";
/**
@@ -150,18 +151,22 @@ export default class RetainedGeometry {
this.vertexState.destroy();
this.vertexState = null;
}
- // `isBuffer` is false for a handle belonging to a context that has since
- // been lost, and asking is itself error-free. Deleting such a handle
- // after the context came back raises INVALID_OPERATION — the objects
- // died with the old context, so there is nothing left to release.
+ // Deleting a handle belonging to a context that has since been lost
+ // raises INVALID_OPERATION: the objects died with the old context, so
+ // there is nothing left to release.
+ // Not `isBuffer`: the `is*` queries are SYNCHRONOUS and block until
+ // the driver answers, and this runs per buffer as geometry is torn
+ // down. What matters here is only whether deleting is legal, which a
+ // lost context decides, and `isContextLost` answers without a round
+ // trip. See `VertexState#release` for the measurement.
if (this.vertexBuffer !== null) {
- if (gl.isBuffer(this.vertexBuffer)) {
+ if (deletable(gl, gl.isBuffer, this.vertexBuffer)) {
gl.deleteBuffer(this.vertexBuffer);
}
this.vertexBuffer = null;
}
if (this.glIndexBuffer !== null) {
- if (gl.isBuffer(this.glIndexBuffer)) {
+ if (deletable(gl, gl.isBuffer, this.glIndexBuffer)) {
gl.deleteBuffer(this.glIndexBuffer);
}
this.glIndexBuffer = null;
diff --git a/packages/melonjs/src/video/webgl/buffer/uniformblock.js b/packages/melonjs/src/video/webgl/buffer/uniformblock.js
index ae2b233f06..621f46ff33 100644
--- a/packages/melonjs/src/video/webgl/buffer/uniformblock.js
+++ b/packages/melonjs/src/video/webgl/buffer/uniformblock.js
@@ -1,3 +1,4 @@
+import { deletable } from "./deletable.js";
/**
* A uniform buffer bound to a named block in one or more shader programs.
*
@@ -184,9 +185,12 @@ export default class UniformBlock {
destroy() {
const gl = this.gl;
if (this.buffer !== null) {
- // `isBuffer` is false for a handle from a context that has since
- // been lost, and deleting such a handle raises INVALID_OPERATION
- if (gl.isBuffer(this.buffer)) {
+ // Not `isBuffer`: the `is*` queries are SYNCHRONOUS and block until
+ // the driver answers, and this runs per buffer as geometry is torn
+ // down. What matters here is only whether deleting is legal, which a
+ // lost context decides, and `isContextLost` answers without a round
+ // trip. See `VertexState#release` for the measurement.
+ if (deletable(gl, gl.isBuffer, this.buffer)) {
gl.deleteBuffer(this.buffer);
}
this.buffer = null;
diff --git a/packages/melonjs/src/video/webgl/buffer/vertexstate.js b/packages/melonjs/src/video/webgl/buffer/vertexstate.js
index 55b3710acd..e9917d9008 100644
--- a/packages/melonjs/src/video/webgl/buffer/vertexstate.js
+++ b/packages/melonjs/src/video/webgl/buffer/vertexstate.js
@@ -1,3 +1,5 @@
+import { deletable } from "./deletable.js";
+
/**
* Normalize a descriptor into the list-of-buffer-layouts form.
*
@@ -217,12 +219,15 @@ export default class WebGLVertexState {
/**
* Delete the underlying vertex array object, if any. Idempotent, and
* safe on a lost context: deleting an object belonging to a lost
- * context would queue `INVALID_OPERATION` (deletion counts as *using*
- * it — only the `is*` queries are exempt), so probe first.
+ * context would queue `INVALID_OPERATION`, because deletion counts as
+ * *using* it.
+ *
+ * The probe behind `deletable` is synchronous, so it is skipped unless
+ * this context has actually lost one — see `buffer/deletable.js`.
*/
release() {
if (this.handle !== null) {
- if (this.gl.isVertexArray(this.handle)) {
+ if (deletable(this.gl, this.gl.isVertexArray, this.handle)) {
this.gl.deleteVertexArray(this.handle);
}
this.handle = null;
diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js
index 73311aa25d..bf7be75e5a 100644
--- a/packages/melonjs/src/video/webgl/webgl_renderer.js
+++ b/packages/melonjs/src/video/webgl/webgl_renderer.js
@@ -1,3 +1,4 @@
+import { shaderList } from "../../loader/cache.js";
import { Color, colorPool } from "./../../math/color.ts";
import { Matrix3d } from "../../math/matrix3d.ts";
import { Bounds } from "../../physics/bounds.ts";
@@ -38,6 +39,7 @@ import LitQuadBatcher from "./batchers/lit_quad_batcher";
import MeshBatcher from "./batchers/mesh_batcher";
import PrimitiveBatcher from "./batchers/primitive_batcher";
import QuadBatcher from "./batchers/quad_batcher";
+import { markContextLost } from "./buffer/deletable.js";
import { createLightUniformScratch, packLights } from "./lighting/pack.ts";
import OrthogonalTMXLayerGPURenderer from "./renderers/tmxlayer/orthogonal.js";
import { WebGLTextureStore } from "./texture/store.js";
@@ -113,6 +115,15 @@ function initBlendEnums(gl) {
};
}
+/**
+ * How long, in milliseconds, the mesh-effect warm-up may link programs in
+ * one task before giving the page a frame. Linking is synchronous; a budget
+ * keeps a stage's worth of it from landing in a single frame.
+ * @ignore
+ * @internal
+ */
+const MESH_EFFECT_LINK_BUDGET = 4;
+
/**
* a WebGL renderer object
* @category Rendering
@@ -522,6 +533,11 @@ export default class WebGLRenderer extends Renderer {
(e) => {
e.preventDefault();
this.isContextValid = false;
+ // From here on a handle may belong to the dead context, so
+ // teardown has to probe before deleting. Until this happens
+ // the probe is skipped, because it is synchronous and every
+ // released buffer would pay for it. See `buffer/deletable.js`.
+ markContextLost(this.gl);
// driver auto-frees lost resources; just drop the ref
this.vertexBuffer = null;
emit(ONCONTEXT_LOST, this);
@@ -609,6 +625,12 @@ export default class WebGLRenderer extends Renderer {
// work already done for the rest of the session and the
// variants silently go back to being built on first draw
this._prewarmed = undefined;
+ this._prewarmedEffects = new WeakSet();
+ // and nothing else would rebuild them: the loader only warms
+ // on a preload, and the game has long finished preloading
+ if (this.settings.prewarm === true) {
+ this.prewarm().catch(() => {});
+ }
}
};
this.onCanvasResize = (width, height) => {
@@ -893,10 +915,29 @@ export default class WebGLRenderer extends Renderer {
* @see {@link Renderer#prewarm}
*/
prewarm() {
+ // Every preloaded `{type: "shader"}` asset, so a mesh-hosted effect's
+ // spliced variant is built here rather than on the frame it first
+ // draws. The WebGPU backend already warms this list; the splice is
+ // what WebGL has to do on top of it.
+ //
+ // Two halves with different lifetimes. The batchers' own variants are
+ // a fixed set, built once per context, so that half is memoized. The
+ // effects are NOT: `shaderList` grows with every preload, and loading
+ // a level's effects with that level is the pattern this is for. Only
+ // memoizing the whole call meant a second preload warmed none of its
+ // shaders, silently, with the lazy path covering for it.
+ this._prewarmedEffects ??= new WeakSet();
+ const effects = Object.values(shaderList).filter((effect) => {
+ return effect != null && !this._prewarmedEffects.has(effect);
+ });
+ for (const effect of effects) {
+ this._prewarmedEffects.add(effect);
+ }
+ const batchers = [...this.batchers.values()];
if (this._prewarmed === undefined) {
this._prewarmed = Promise.all(
- [...this.batchers.values()].map((batcher) => {
- return batcher.prewarm();
+ batchers.map((batcher) => {
+ return batcher.prewarm(effects);
}),
).then(() => {
// the batchers each leave their own program bound; put the
@@ -904,11 +945,204 @@ export default class WebGLRenderer extends Renderer {
// next draw inherits whichever batcher finished last
this.setBatcher("quad");
});
+ } else if (effects.length > 0) {
+ // chained, so a warm-up never runs while the previous one is
+ // still yielding between links
+ this._prewarmed = this._prewarmed.then(() => {
+ return Promise.all(
+ batchers.map((batcher) => {
+ return batcher.prewarmEffects?.(effects);
+ }),
+ ).then(() => {
+ this.setBatcher("quad");
+ });
+ });
}
return this._prewarmed;
}
+ /**
+ * Warm what one renderable's post effects will need on this backend.
+ *
+ * An effect's own program is linked when the effect is constructed, so
+ * what is left is what depends on where it is drawn: a mesh does not
+ * draw an effect with that program but with the effect spliced into the
+ * mesh shader, built on first draw; and a chain composites through
+ * render targets, built on the frame the chain first runs or grows a
+ * second effect.
+ * @param {Renderable} renderable - the renderable to warm for
+ * @override
+ * @ignore
+ * @internal
+ */
+ _warmPostEffects(renderable) {
+ const gl = this.gl;
+ if (gl === null || gl.isContextLost?.() === true) {
+ return;
+ }
+ const effects = renderable.postEffects.filter((fx) => {
+ return fx != null;
+ });
+ if (effects.length === 0) {
+ return;
+ }
+
+ // Render targets: only outside any effect pass, because making a
+ // framebuffer rebinds the screen framebuffer when it is done, and a
+ // pass in progress has its own bound. A warm-up runs after the
+ // caller's task, which is between frames in practice.
+ const isCamera = renderable._postEffectManaged === true;
+ if (
+ this._effectPassDepth === 0 &&
+ (isCamera ||
+ effects.length > 1 ||
+ renderable.postEffectNeedsCapture === true)
+ ) {
+ const { width, height } = this.getCanvas();
+ this.flush();
+ this._renderTargetPool.reserve(
+ isCamera,
+ this._postEffectCapacity(renderable, effects),
+ width,
+ height,
+ );
+ // creating a target binds (then clears) TEXTURE0, as in
+ // `beginPostEffect`
+ this.invalidateTextureUnit(0);
+ }
+
+ if (!this._hostsMeshEffect(renderable)) {
+ return;
+ }
+ const batcher = this.batchers.get(
+ renderable.lit === true ? "litMesh" : "mesh",
+ );
+ if (batcher === undefined) {
+ return;
+ }
+ // Queued rather than linked here. Linking is synchronous, and a stage
+ // that builds a pool of effect-carrying meshes up front (projectiles,
+ // impact flashes) would pay every link in one frame, a spike of its
+ // own. Each clone of an effect is its own program, so a pool of
+ // twenty is twenty links.
+ this._meshEffectWarmQueue ??= [];
+ for (const fx of effects) {
+ this._meshEffectWarmQueue.push(batcher, fx);
+ }
+ if (this._meshEffectWarmScheduled !== true) {
+ this._meshEffectWarmScheduled = true;
+ queueMicrotask(() => {
+ this._linkQueuedMeshEffects();
+ });
+ }
+ }
+
+ /**
+ * Link the spliced mesh programs queued by `_warmPostEffects`, a few
+ * milliseconds' worth per task, so a pool's worth spreads over the next
+ * frames instead of landing in one. A draw that comes first simply links
+ * its own program, as it would have without the warm-up.
+ * @ignore
+ * @internal
+ */
+ _linkQueuedMeshEffects() {
+ const queue = this._meshEffectWarmQueue;
+ const gl = this.gl;
+ if (gl === null || gl.isContextLost?.() === true) {
+ queue.length = 0;
+ this._meshEffectWarmScheduled = false;
+ return;
+ }
+ const start = globalThis.performance.now();
+ const previous = this.currentProgram;
+ try {
+ while (
+ queue.length > 0 &&
+ globalThis.performance.now() - start < MESH_EFFECT_LINK_BUDGET
+ ) {
+ const batcher = queue.shift();
+ const fx = queue.shift();
+ if (fx.destroyed !== true && batcher.canHostEffect?.(fx) === true) {
+ batcher.effectShaderFor(fx);
+ }
+ }
+ } finally {
+ this._restoreProgram(previous);
+ }
+ if (queue.length === 0) {
+ this._meshEffectWarmScheduled = false;
+ } else if (globalThis.document?.hidden === true) {
+ // a hidden tab throttles timers to one a second and draws nothing,
+ // so there is no frame to protect: finish now
+ this._linkQueuedMeshEffects();
+ } else {
+ setTimeout(() => {
+ this._linkQueuedMeshEffects();
+ }, 0);
+ }
+ }
+
+ /**
+ * Build the advanced blend shader and its offscreen target before the
+ * first draw in one of those modes, which otherwise compiles the shader
+ * and creates the target in the middle of that frame.
+ * @override
+ * @ignore
+ * @internal
+ */
+ _warmAdvancedBlend() {
+ const gl = this.gl;
+ if (gl === null || gl.isContextLost?.() === true) {
+ return;
+ }
+ if (typeof this._advancedBlendEffect === "undefined") {
+ const previous = this.currentProgram;
+ try {
+ this._advancedBlendEffect = new BlendEffect(this);
+ } finally {
+ this._restoreProgram(previous);
+ }
+ }
+ // the target only outside any pass or bracket, for the same reason as
+ // the effect targets in `_warmPostEffects`
+ if (
+ typeof this._advancedBlendTarget === "undefined" &&
+ this._effectPassDepth === 0 &&
+ this._advancedBlendOpen !== true
+ ) {
+ const { width, height } = this.getCanvas();
+ this.flush();
+ // the same target `_openAdvancedBlend` makes, see the note there
+ this._advancedBlendTarget = new WebGLRenderTarget(gl, width, height, {
+ samples: 0,
+ format: gl.RGBA8,
+ });
+ this.invalidateTextureUnit(0);
+ }
+ }
+
+ /**
+ * Put back the program a warm-up found bound. Linking leaves the new
+ * program bound, and the batchers trust what they last bound, so a
+ * warm-up between two draws would otherwise hand the next one a
+ * stranger's program.
+ * @param {WebGLProgram|undefined} previous - what was bound before
+ * @ignore
+ * @internal
+ */
+ _restoreProgram(previous) {
+ if (previous != null && this.currentProgram !== previous) {
+ this.gl.useProgram(previous);
+ this.currentProgram = previous;
+ }
+ }
+
destroy() {
+ // a warm-up slice still pending must not link into batchers that are
+ // about to be torn down
+ if (this._meshEffectWarmQueue !== undefined) {
+ this._meshEffectWarmQueue.length = 0;
+ }
// renderer-owned, so no camera's teardown will have freed it
this._toneMapEffect?.destroy?.();
/**
@@ -1756,7 +1990,12 @@ export default class WebGLRenderer extends Renderer {
savedProjection.copy(this.projectionMatrix);
this._effectPassDepth++;
- const rt = this._renderTargetPool.begin(isCamera, effects.length, w, h);
+ const rt = this._renderTargetPool.begin(
+ isCamera,
+ this._postEffectCapacity(renderable, effects),
+ w,
+ h,
+ );
// FBO creation/resize binds (then nulls) TEXTURE0 — invalidate EVERY
// batcher's cache for that unit, not just the current one: a batcher
// that isn't current right now would otherwise keep trusting its stale
diff --git a/packages/melonjs/src/video/webgpu/pipeline/cache.js b/packages/melonjs/src/video/webgpu/pipeline/cache.js
index e76839408b..7ee8d22063 100644
--- a/packages/melonjs/src/video/webgpu/pipeline/cache.js
+++ b/packages/melonjs/src/video/webgpu/pipeline/cache.js
@@ -39,6 +39,28 @@ function gpuBlendState(mode, premultipliedAlpha) {
*/
export const MAX_QUAD_TEXTURES = 8;
+/**
+ * How many pipelines {@link WebGPUPipelineCache#warm} compiles at once.
+ *
+ * Asynchronous creation moves the compile off the frame, but it still runs
+ * in the browser's GPU process, which is also the process presenting frames.
+ * Handing it every pipeline a scene could want in one go starves the frames
+ * in between, which is the stutter the warm-up exists to remove, so the rest
+ * queue behind this many.
+ * @ignore
+ * @internal
+ */
+const MAX_WARM_IN_FLIGHT = 16;
+
+/**
+ * How many mid-frame pipeline keys {@link WebGPUPipelineCache#inFrameMissKeys}
+ * keeps, newest last. Enough to read a scene's misses off, small enough that
+ * a game creating pipelines forever does not grow it forever.
+ * @ignore
+ * @internal
+ */
+const MISS_KEYS_KEPT = 64;
+
/**
* The depth-stencil attachment format every pass and every pipeline of this
* backend declares. Attached from day one — masks use the stencil half now,
@@ -154,6 +176,46 @@ export default class WebGPUPipelineCache {
/** @type {Map} */
this.vertexLayouts = new Map();
+ /**
+ * `true` between the renderer's frame start and its submit. A pipeline
+ * created while it is set was compiled in the middle of a frame, which
+ * is the first-use stall {@link WebGPUPipelineCache#warm} exists to
+ * prevent, so {@link WebGPUPipelineCache#get} counts it.
+ * @type {boolean}
+ */
+ this.inFrame = false;
+ /**
+ * pipelines created in the middle of a frame since this cache was
+ * built: the number a warm-up drives toward zero
+ * @type {number}
+ */
+ this.inFrameMisses = 0;
+ /**
+ * the keys behind {@link WebGPUPipelineCache#inFrameMisses}, newest
+ * last, capped at the last {@link MISS_KEYS_KEPT}
+ * @type {string[]}
+ */
+ this.inFrameMissKeys = [];
+ /**
+ * warm-ups still compiling, by key, so a second request for the same
+ * pipeline waits on the first rather than compiling it twice
+ * @type {Map>}
+ */
+ this.pending = new Map();
+ /** @type {{run: Function, resolve: Function}[]} */
+ this.warmQueue = [];
+ this.warmInFlight = 0;
+ /** @type {Map} format|sampleCount → pipelines */
+ this.touchQueue = new Map();
+ /** @type {Map} */
+ this.scratchTargets = new Map();
+ /**
+ * bumped by {@link WebGPUPipelineCache#clear}: a warm-up that started
+ * before a clear must not put its pipeline back into the emptied cache
+ * @type {number}
+ */
+ this.generation = 0;
+
// ---- bind-group layouts (shared by pipelines and resource caches) --
this.frameLayout = device.createBindGroupLayout({
label: "melonJS frame globals layout",
@@ -448,31 +510,46 @@ export default class WebGPUPipelineCache {
}
/**
- * Fetch (building on first use) the pipeline for the given state tuple.
- * @param {string} shaderKey - "quad" | "primitive" | "clear"
+ * The cache key for a pipeline state tuple.
+ *
+ * The ONE place a key is composed. A draw looks its pipeline up through
+ * {@link WebGPUPipelineCache#get} and a warm-up builds one ahead of time
+ * through {@link WebGPUPipelineCache#warm}, and both come here, so the two
+ * cannot drift into building the same pipeline under two names: a warm-up
+ * whose key differs from the draw's by one character compiles a pipeline
+ * nothing will ever ask for, and the draw still compiles its own.
+ *
+ * The colour format and sample count default to the cache's CURRENT ones,
+ * which the renderer rewrites per render target. A warm-up runs outside
+ * any pass, where those describe whatever was bound last, so it passes
+ * the target it is warming for explicitly.
+ * @param {string} shaderKey - "quad" | "primitive" | "clear" | a registered family
* @param {string} topology - portable topology name ("triangle-list", …)
* @param {string} blendMode - blend mode (normalized internally)
* @param {boolean} premultipliedAlpha - source premultiplication flag
* @param {string} [stencilMode="none"] - "none" | "write" | "test" | "tag" | "mark"
- * @param {{cullMode: string, frontFace: string, depthWrite?: boolean, depthTest?: boolean, fog?: boolean}} [meshState] - mesh pass
- * state: its presence switches the depth half of the attachment on —
- * depth writes enabled, "less-equal" testing (the GL mesh mode's
- * LEQUAL, keeping coplanar geometry stable) — and sets the per-mesh
- * face-culling axes. Omitted by the whole 2D tier, whose keys and
- * descriptors stay byte-identical to a build without a mesh path.
- * @returns {GPURenderPipeline} the pipeline
+ * @param {object} [meshState] - the mesh pass state, see {@link WebGPUPipelineCache#get}
+ * @param {string} [format] - colour target format, the current one by default
+ * @param {number} [sampleCount] - pass sample count, the current one by default
+ * @returns {string} the key
*/
- get(
+ keyFor(
shaderKey,
topology,
blendMode,
premultipliedAlpha,
stencilMode = "none",
meshState,
+ format = this.format,
+ sampleCount = this.sampleCount,
) {
const blend = normalizeBlendMode(blendMode);
- const pma = premultipliedAlpha !== false;
- let key = `${shaderKey}|${topology}|${blend}|${pma ? 1 : 0}|${stencilMode}|${this.format}|${this.sampleCount}`;
+ // `"none"` writes the source as-is, so the premultiplied flag selects
+ // nothing in the descriptor. Keying on it anyway split one pipeline in
+ // two on a flag that is runtime state (whatever drew last leaves it),
+ // and a warm-up cannot know which half a later draw will land on.
+ const pma = blend === "none" || premultipliedAlpha !== false;
+ let key = `${shaderKey}|${topology}|${blend}|${pma ? 1 : 0}|${stencilMode}|${format}|${sampleCount}`;
if (meshState) {
key += `|mesh:${meshState.cullMode}:${meshState.frontFace}`;
// Appended ONLY when it differs from the default. Adding it
@@ -496,37 +573,301 @@ export default class WebGPUPipelineCache {
key += "|fog";
}
}
- // booleans are not valid override values in the WebGPU API — the
- // constants record takes numbers, and 0/1 specialize a `bool` override
- const fogFlag = meshState?.fog === true ? 1 : 0;
+ return key;
+ }
+
+ /**
+ * Fetch (building on first use) the pipeline for the given state tuple.
+ * @param {string} shaderKey - "quad" | "primitive" | "clear"
+ * @param {string} topology - portable topology name ("triangle-list", …)
+ * @param {string} blendMode - blend mode (normalized internally)
+ * @param {boolean} premultipliedAlpha - source premultiplication flag
+ * @param {string} [stencilMode="none"] - "none" | "write" | "test" | "tag" | "mark"
+ * @param {{cullMode: string, frontFace: string, depthWrite?: boolean, depthTest?: boolean, fog?: boolean}} [meshState] - mesh pass
+ * state: its presence switches the depth half of the attachment on —
+ * depth writes enabled, "less-equal" testing (the GL mesh mode's
+ * LEQUAL, keeping coplanar geometry stable) — and sets the per-mesh
+ * face-culling axes. Omitted by the whole 2D tier, whose keys and
+ * descriptors stay byte-identical to a build without a mesh path.
+ * @returns {GPURenderPipeline} the pipeline
+ */
+ get(
+ shaderKey,
+ topology,
+ blendMode,
+ premultipliedAlpha,
+ stencilMode = "none",
+ meshState,
+ ) {
+ const key = this.keyFor(
+ shaderKey,
+ topology,
+ blendMode,
+ premultipliedAlpha,
+ stencilMode,
+ meshState,
+ );
let pipeline = this.pipelines.get(key);
if (typeof pipeline === "undefined") {
- const stencil = STENCIL_STATES[stencilMode] ?? STENCIL_STATES.none;
- // registered families may alias a built-in vertex layout (effect
- // blits ride the frozen quad layout)
- const vertexLayout = this.vertexLayouts.get(
- this.vertexLayoutAliases.get(shaderKey) ?? shaderKey,
- );
- const primitive = {
- topology,
- stripIndexFormat: topology.endsWith("-strip") ? "uint32" : undefined,
- };
- if (meshState) {
- primitive.cullMode = meshState.cullMode;
- primitive.frontFace = meshState.frontFace;
- }
pipeline = this.device.createRenderPipeline(
- this._descriptor(key, shaderKey, blend, pma, stencil, primitive, {
- vertexLayout,
+ this._descriptor(
+ key,
+ shaderKey,
+ topology,
+ blendMode,
+ premultipliedAlpha,
+ stencilMode,
meshState,
- fogFlag,
- }),
+ this.format,
+ this.sampleCount,
+ ),
);
this.pipelines.set(key, pipeline);
+ if (this.inFrame === true) {
+ this.inFrameMisses++;
+ this.inFrameMissKeys.push(key);
+ if (this.inFrameMissKeys.length > MISS_KEYS_KEPT) {
+ this.inFrameMissKeys.shift();
+ }
+ }
}
return pipeline;
}
+ /**
+ * Build a pipeline ahead of the draw that will need it, without blocking.
+ *
+ * Takes the same tuple as {@link WebGPUPipelineCache#get}, plus the colour
+ * format and sample count of the target the draw will land on, since a
+ * warm-up runs outside any pass. Compiles through
+ * `createRenderPipelineAsync` where the device has it, so the work
+ * happens off the frame, and files the result under the same key `get`
+ * will look up. Asking twice for one pipeline compiles it once.
+ *
+ * Never rejects. A pipeline that fails to build is left for the draw to
+ * build and report, exactly as it would have without the warm-up.
+ * @param {string} shaderKey - the family, already registered
+ * @param {string} topology - portable topology name
+ * @param {string} blendMode - blend mode
+ * @param {boolean} premultipliedAlpha - source premultiplication flag
+ * @param {string} stencilMode - stencil mode
+ * @param {object|undefined} meshState - mesh pass state, read NOW: a later
+ * change to the object does not reach the pipeline being built
+ * @param {string} format - colour target format the draw will use
+ * @param {number} sampleCount - sample count the draw will use
+ * @returns {Promise} settles once the pipeline is in the cache, or
+ * has failed to get there
+ */
+ warm(
+ shaderKey,
+ topology,
+ blendMode,
+ premultipliedAlpha,
+ stencilMode,
+ meshState,
+ format,
+ sampleCount,
+ ) {
+ const key = this.keyFor(
+ shaderKey,
+ topology,
+ blendMode,
+ premultipliedAlpha,
+ stencilMode,
+ meshState,
+ format,
+ sampleCount,
+ );
+ if (this.pipelines.has(key)) {
+ return Promise.resolve();
+ }
+ const inFlight = this.pending.get(key);
+ if (inFlight !== undefined) {
+ return inFlight;
+ }
+ // the descriptor is assembled now, not when the queue reaches it:
+ // `meshState` is a batcher's scratch object, rewritten per draw
+ const descriptor = this._descriptor(
+ key,
+ shaderKey,
+ topology,
+ blendMode,
+ premultipliedAlpha,
+ stencilMode,
+ meshState,
+ format,
+ sampleCount,
+ );
+ const generation = this.generation;
+ const promise = new Promise((resolve) => {
+ this.warmQueue.push({
+ run: () => {
+ return typeof this.device.createRenderPipelineAsync === "function"
+ ? this.device.createRenderPipelineAsync(descriptor)
+ : this.device.createRenderPipeline(descriptor);
+ },
+ resolve: (pipeline) => {
+ this.pending.delete(key);
+ // a draw that could not wait built its own copy meanwhile;
+ // keep that one, the draws already recorded hold it
+ if (
+ pipeline !== undefined &&
+ generation === this.generation &&
+ !this.pipelines.has(key)
+ ) {
+ this.pipelines.set(key, pipeline);
+ this._touch(pipeline, format, sampleCount);
+ }
+ resolve();
+ },
+ });
+ });
+ this.pending.set(key, promise);
+ this._pumpWarmQueue();
+ return promise;
+ }
+
+ /**
+ * Start queued warm-ups until {@link MAX_WARM_IN_FLIGHT} are compiling.
+ * @ignore
+ * @internal
+ */
+ _pumpWarmQueue() {
+ while (
+ this.warmInFlight < MAX_WARM_IN_FLIGHT &&
+ this.warmQueue.length > 0
+ ) {
+ const job = this.warmQueue.shift();
+ this.warmInFlight++;
+ // `then(run)` rather than `run()`: a synchronous throw from the
+ // device (the fallback path, or a lost device) becomes a rejection
+ // handled below instead of escaping into the caller
+ Promise.resolve()
+ .then(job.run)
+ .then(job.resolve, () => {
+ job.resolve(undefined);
+ })
+ .finally(() => {
+ this.warmInFlight--;
+ this._pumpWarmQueue();
+ });
+ }
+ }
+
+ /**
+ * Bind a freshly warmed pipeline once, in a pass of its own, so it is
+ * compiled now rather than at the first draw that uses it.
+ *
+ * Some implementations accept `createRenderPipelineAsync` and still defer
+ * the native compile to the first `setPipeline`, which puts it right back
+ * in the frame the warm-up was meant to keep it out of. Setting it on a
+ * throwaway 1×1 pass makes that first use happen here, while nothing is
+ * on screen waiting for it. Where the compile already happened this is
+ * one cheap bind.
+ * @param {GPURenderPipeline} pipeline - the pipeline to bind
+ * @param {string} format - its colour format
+ * @param {number} sampleCount - its sample count
+ * @ignore
+ * @internal
+ */
+ _touch(pipeline, format, sampleCount) {
+ const target = `${format}|${sampleCount}`;
+ let batch = this.touchQueue.get(target);
+ if (batch === undefined) {
+ batch = [];
+ this.touchQueue.set(target, batch);
+ }
+ batch.push(pipeline);
+ if (this.touchQueue.size === 1 && batch.length === 1) {
+ // one submit for everything that resolves in the same tick
+ queueMicrotask(() => {
+ this._flushTouches();
+ });
+ }
+ }
+
+ /**
+ * Record and submit the passes queued by {@link WebGPUPipelineCache#_touch}.
+ * @ignore
+ * @internal
+ */
+ _flushTouches() {
+ const device = this.device;
+ const batches = [...this.touchQueue];
+ this.touchQueue.clear();
+ if (typeof device.createCommandEncoder !== "function") {
+ return;
+ }
+ try {
+ const encoder = device.createCommandEncoder({
+ label: "melonJS pipeline warm-up",
+ });
+ for (const [target, pipelines] of batches) {
+ const [format, samples] = target.split("|");
+ const scratch = this._scratchTarget(format, Number(samples));
+ const pass = encoder.beginRenderPass({
+ label: "melonJS pipeline warm-up",
+ colorAttachments: [
+ { view: scratch.colorView, loadOp: "clear", storeOp: "discard" },
+ ],
+ depthStencilAttachment: {
+ view: scratch.depthView,
+ depthClearValue: 1,
+ depthLoadOp: "clear",
+ depthStoreOp: "discard",
+ stencilLoadOp: "clear",
+ stencilStoreOp: "discard",
+ },
+ });
+ for (const pipeline of pipelines) {
+ pass.setPipeline(pipeline);
+ }
+ pass.end();
+ }
+ device.queue.submit([encoder.finish()]);
+ } catch {
+ // a lost device mid-warm-up: the draws will rebuild on the new one
+ }
+ }
+
+ /**
+ * The 1×1 colour + depth-stencil pair a warm-up pass binds for one
+ * target shape, built once per shape.
+ * @param {string} format - colour format
+ * @param {number} sampleCount - sample count
+ * @returns {{colorView: GPUTextureView, depthView: GPUTextureView}} the views
+ * @ignore
+ * @internal
+ */
+ _scratchTarget(format, sampleCount) {
+ const key = `${format}|${sampleCount}`;
+ let scratch = this.scratchTargets.get(key);
+ if (scratch === undefined) {
+ const color = this.device.createTexture({
+ label: "melonJS warm-up target",
+ size: [1, 1],
+ format,
+ sampleCount,
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
+ });
+ const depth = this.device.createTexture({
+ label: "melonJS warm-up depth",
+ size: [1, 1],
+ format: DEPTH_STENCIL_FORMAT,
+ sampleCount,
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
+ });
+ scratch = {
+ color,
+ depth,
+ colorView: color.createView(),
+ depthView: depth.createView(),
+ };
+ this.scratchTargets.set(key, scratch);
+ }
+ return scratch;
+ }
+
/**
* Assemble a pipeline descriptor.
*
@@ -537,17 +878,47 @@ export default class WebGPUPipelineCache {
* silently builds a second one under a key nothing will hit again.
* @param {string} key - the cache key, used as the label
* @param {string} shaderKey - the module family
- * @param {string} blend - normalized blend mode
- * @param {boolean} pma - premultiplied alpha
- * @param {object} stencil - the resolved stencil state
- * @param {object} primitive - the resolved primitive state
- * @param {object} opts - `{vertexLayout, meshState, fogFlag}`
+ * @param {string} topology - portable topology name
+ * @param {string} blendMode - blend mode (normalized here)
+ * @param {boolean} premultipliedAlpha - source premultiplication flag
+ * @param {string} stencilMode - stencil mode
+ * @param {object|undefined} meshState - mesh pass state
+ * @param {string} format - colour target format
+ * @param {number} sampleCount - pass sample count
* @returns {GPURenderPipelineDescriptor} the descriptor
* @ignore
* @internal
*/
- _descriptor(key, shaderKey, blend, pma, stencil, primitive, opts) {
- const { vertexLayout, meshState, fogFlag } = opts;
+ _descriptor(
+ key,
+ shaderKey,
+ topology,
+ blendMode,
+ premultipliedAlpha,
+ stencilMode,
+ meshState,
+ format,
+ sampleCount,
+ ) {
+ const blend = normalizeBlendMode(blendMode);
+ const pma = premultipliedAlpha !== false;
+ const stencil = STENCIL_STATES[stencilMode] ?? STENCIL_STATES.none;
+ // registered families may alias a built-in vertex layout (effect
+ // blits ride the frozen quad layout)
+ const vertexLayout = this.vertexLayouts.get(
+ this.vertexLayoutAliases.get(shaderKey) ?? shaderKey,
+ );
+ const primitive = {
+ topology,
+ stripIndexFormat: topology.endsWith("-strip") ? "uint32" : undefined,
+ };
+ if (meshState) {
+ primitive.cullMode = meshState.cullMode;
+ primitive.frontFace = meshState.frontFace;
+ }
+ // booleans are not valid override values in the WebGPU API — the
+ // constants record takes numbers, and 0/1 specialize a `bool` override
+ const fogFlag = meshState?.fog === true ? 1 : 0;
return {
label: `melonJS ${key}`,
layout: this.pipelineLayouts[shaderKey],
@@ -563,7 +934,7 @@ export default class WebGPUPipelineCache {
...(meshState ? { constants: { enable_fog: fogFlag } } : {}),
targets: [
{
- format: this.format,
+ format,
blend:
shaderKey === "clear" ? undefined : gpuBlendState(blend, pma),
writeMask: stencil.colorWriteMask,
@@ -588,7 +959,7 @@ export default class WebGPUPipelineCache {
stencilReadMask: stencil.readMask,
stencilWriteMask: stencil.writeMask,
},
- multisample: { count: this.sampleCount },
+ multisample: { count: sampleCount },
};
}
@@ -598,5 +969,19 @@ export default class WebGPUPipelineCache {
*/
clear() {
this.pipelines.clear();
+ // warm-ups still compiling belong to the device being dropped. The
+ // generation bump keeps those out of the emptied cache; the queued
+ // ones are settled here so nothing awaiting them waits forever
+ this.generation++;
+ for (const job of this.warmQueue.splice(0)) {
+ job.resolve(undefined);
+ }
+ this.pending.clear();
+ this.touchQueue.clear();
+ for (const scratch of this.scratchTargets.values()) {
+ scratch.color.destroy?.();
+ scratch.depth.destroy?.();
+ }
+ this.scratchTargets.clear();
}
}
diff --git a/packages/melonjs/src/video/webgpu/texture/compressed.js b/packages/melonjs/src/video/webgpu/texture/compressed.js
index c07a4a5f9d..22ab116c95 100644
--- a/packages/melonjs/src/video/webgpu/texture/compressed.js
+++ b/packages/melonjs/src/video/webgpu/texture/compressed.js
@@ -88,7 +88,11 @@ export function uploadCompressedTexture(device, texture, image, metrics) {
bytesPerRow: blocksPerRow * metrics.bytes,
rowsPerImage: rows,
},
- [mip.width, mip.height],
+ // whole blocks: a level smaller than one block (the 2×2 and 1×1
+ // tail of a 4×4-block chain) is stored padded to a full block,
+ // and a copy of its logical size fails validation, leaving the
+ // level empty
+ [blocksPerRow * metrics.blockW, rows * metrics.blockH],
);
}
}
diff --git a/packages/melonjs/src/video/webgpu/texture/store.js b/packages/melonjs/src/video/webgpu/texture/store.js
index 783c9a5ff3..9f55606f2b 100644
--- a/packages/melonjs/src/video/webgpu/texture/store.js
+++ b/packages/melonjs/src/video/webgpu/texture/store.js
@@ -314,6 +314,78 @@ export default class WebGPUTextureStore extends TextureStore {
return this.bindGroupFor(record, texture, wrap, options.mipmaps === true);
}
+ /**
+ * Build the mip-chain blitter, once. Split out of
+ * {@link WebGPUTextureStore#generateMipmaps} so the warm-up can build it
+ * before the first mipmapped texture is uploaded, which is otherwise in
+ * the middle of the frame that first draws a mesh.
+ * @ignore
+ * @internal
+ */
+ ensureMipPipeline() {
+ if (typeof this.mipPipeline !== "undefined") {
+ return;
+ }
+ const device = this.device;
+ const module = device.createShaderModule({
+ label: "melonJS mip blit",
+ code: mipblitWGSL,
+ });
+ this.mipLayout = device.createBindGroupLayout({
+ label: "melonJS mip blit layout",
+ entries: [
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
+ ],
+ });
+ // deliberately NOT a pipeline-cache family: mip passes have no
+ // depth-stencil attachment and target the texture format, both
+ // of which every cached pipeline hard-declares otherwise
+ this.mipPipeline = device.createRenderPipeline({
+ label: "melonJS mip blit",
+ layout: device.createPipelineLayout({
+ bindGroupLayouts: [this.mipLayout],
+ }),
+ vertex: { module, entryPoint: "vertex_main" },
+ fragment: {
+ module,
+ entryPoint: "fragment_main",
+ targets: [{ format: "rgba8unorm" }],
+ },
+ primitive: { topology: "triangle-list" },
+ });
+ this.mipSampler = device.createSampler({
+ label: "melonJS mip blit sampler",
+ magFilter: "linear",
+ minFilter: "linear",
+ });
+ }
+
+ /**
+ * Build the mip-chain blitter and run it once, on a throwaway 2×2
+ * texture, so its native compile has happened before the first real
+ * texture needs it. Creating a pipeline is not enough on every
+ * implementation: some defer the compile to first use, so this uses it.
+ * @ignore
+ * @internal
+ */
+ warmMipmaps() {
+ if (typeof this.mipPipeline !== "undefined") {
+ return;
+ }
+ const texture = this.device.createTexture({
+ label: "melonJS mip warm-up",
+ size: [2, 2],
+ format: "rgba8unorm",
+ mipLevelCount: 2,
+ usage:
+ GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
+ });
+ this.generateMipmaps({ texture, mipLevelCount: 2 });
+ // safe straight after the submit: the queued pass keeps what it uses
+ texture.destroy();
+ }
+
/**
* Build the full mip chain of a freshly-uploaded record: one blit pass
* per level, each rendering the previous level with linear filtering.
@@ -326,40 +398,7 @@ export default class WebGPUTextureStore extends TextureStore {
*/
generateMipmaps(record) {
const device = this.device;
- if (typeof this.mipPipeline === "undefined") {
- const module = device.createShaderModule({
- label: "melonJS mip blit",
- code: mipblitWGSL,
- });
- this.mipLayout = device.createBindGroupLayout({
- label: "melonJS mip blit layout",
- entries: [
- { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
- ],
- });
- // deliberately NOT a pipeline-cache family: mip passes have no
- // depth-stencil attachment and target the texture format, both
- // of which every cached pipeline hard-declares otherwise
- this.mipPipeline = device.createRenderPipeline({
- label: "melonJS mip blit",
- layout: device.createPipelineLayout({
- bindGroupLayouts: [this.mipLayout],
- }),
- vertex: { module, entryPoint: "vertex_main" },
- fragment: {
- module,
- entryPoint: "fragment_main",
- targets: [{ format: "rgba8unorm" }],
- },
- primitive: { topology: "triangle-list" },
- });
- this.mipSampler = device.createSampler({
- label: "melonJS mip blit sampler",
- magFilter: "linear",
- minFilter: "linear",
- });
- }
+ this.ensureMipPipeline();
const encoder = device.createCommandEncoder({ label: "melonJS mipgen" });
for (let level = 1; level < record.mipLevelCount; level++) {
const bindGroup = device.createBindGroup({
diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js
index 2585eb0643..9945493c60 100644
--- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js
+++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js
@@ -743,6 +743,9 @@ export default class WebGPURenderer extends Renderer {
this.uniformRing.reset();
this.effectUniformArena.reset();
this.frameId++;
+ // from here to the submit, a pipeline built on demand is a compile
+ // paid inside the frame — the cache counts those
+ this.pipelineCache.inFrame = true;
// a frame always begins on the canvas, outside any effect bracket
// (an exception that escaped between begin/endPostEffect last frame
// must not leave the projection stack wound up)
@@ -1255,6 +1258,438 @@ export default class WebGPURenderer extends Renderer {
return frame;
}
+ /**
+ * The post-effect render-target pool, created on first use.
+ * @returns {RenderTargetPool} the pool
+ * @ignore
+ * @internal
+ */
+ _postEffectPool() {
+ // capture targets rasterize the scene — under antiAlias they carry
+ // the pass sample count so MSAA composes with post-effects (the GL
+ // twin resolves via blitFramebuffer). Ping-pong intermediates draw
+ // screen-aligned effect quads only and stay single-sampled.
+ /**
+ * @ignore
+ * @internal
+ */
+ this._renderTargetPool ??= new RenderTargetPool(
+ (w, h, isCapture, index) => {
+ // Indices 0/1 are the CAMERA chain; 2 and up are sprite chains.
+ // Only the camera pair takes the half-float format, and that is
+ // a correctness limit rather than thrift: a sprite chain's final
+ // blit composites source-over with `ONE_MINUS_SRC_ALPHA`, and
+ // additive blending accumulates ALPHA past 1 in a float target,
+ // so that factor goes negative and subtracts the backdrop. The
+ // camera chain blits without that factor, and is the documented
+ // place for these effects anyway.
+ return new WebGPURenderTarget(this, w, h, {
+ sampleCount: isCapture === true ? this.canvasSampleCount : 1,
+ format: index < 2 ? this._colorFormat : this.preferredFormat,
+ });
+ },
+ );
+ return this._renderTargetPool;
+ }
+
+ /**
+ * The pipeline-cache family of an effect's WGSL realization, building its
+ * module and layouts first if this device has not seen it yet.
+ * @param {ShaderEffect} effect - an effect with a valid WGSL realization
+ * @returns {string} the family key
+ * @ignore
+ * @internal
+ */
+ _effectFamily(effect) {
+ const realization = effect.wgslRealization;
+ if (
+ realization.gpu === null ||
+ realization.gpu.epoch !== this.pipelineCache.epoch
+ ) {
+ buildEffectGPU(this, effect, realization);
+ }
+ return realization.gpu.key;
+ }
+
+ /**
+ * Build, off the frame, every pipeline a renderable's post effects can
+ * draw with, and the render targets its chain will composite through.
+ *
+ * Which pipeline an effect draws with depends on where it sits: an
+ * effect alone on a sprite draws the sprite itself (blended with the
+ * sprite's own mode), one alone on a mesh is spliced into the mesh
+ * shader, and one in a chain is a full-screen blit into either the next
+ * offscreen target or the one below. Its colour format depends on that
+ * target too, and under `hdr` the camera's targets are half-float while
+ * the canvas is not. So this warms every position the effect can reach
+ * with the effects ATTACHED, enabled or not, since turning one on moves
+ * the others: enabling a camera's second effect sends its first one
+ * from the canvas into the ping-pong target, a different pipeline.
+ *
+ * Each key is composed by the same function the draw uses, from the
+ * renderable's own state (blend mode, culling, transparency, fog), so
+ * what is built here is what the draw will ask for.
+ * @param {Renderable} renderable - the renderable to warm for
+ * @override
+ * @ignore
+ * @internal
+ */
+ _warmPostEffects(renderable) {
+ if (!this._canWarm()) {
+ return;
+ }
+ const cache = this.pipelineCache;
+ const effects = renderable.postEffects.filter((fx) => {
+ return fx?.wgslRealization?.valid === true;
+ });
+ const count = effects.length;
+ if (count === 0) {
+ return;
+ }
+ const isCamera = renderable._postEffectManaged === true;
+ const needsCapture = renderable.postEffectNeedsCapture === true;
+ const pooled = isCamera || count > 1 || needsCapture;
+ const canvas = [this.canvasFormat, this.canvasSampleCount];
+ const scene = this._sceneTargets();
+ const blit = (family, blendMode, [format, sampleCount]) => {
+ void cache.warm(
+ family,
+ "triangle-list",
+ blendMode,
+ true,
+ "none",
+ undefined,
+ format,
+ sampleCount,
+ );
+ };
+
+ if (pooled) {
+ // the targets a chain composites through, made now rather than
+ // on the frame the chain first runs or grows a second effect
+ const pool = this._postEffectPool();
+ const { width, height } = this.getCanvas();
+ pool.reserve(
+ isCamera,
+ this._postEffectCapacity(renderable, effects),
+ width,
+ height,
+ );
+ }
+
+ if (isCamera) {
+ for (const fx of effects) {
+ const family = this._effectFamily(fx);
+ // mid-chain (another effect, or the tone curve, after it) into
+ // the camera's ping-pong target, or last onto the canvas —
+ // toggling either neighbour moves it between the two
+ blit(family, "none", [this._colorFormat, 1]);
+ blit(family, "none", canvas);
+ }
+ this._warmToneMap();
+ return;
+ }
+
+ const blendMode = renderable.blendMode ?? "normal";
+ const meshHost = this._hostsMeshEffect(renderable);
+ for (let i = 0; i < count; i++) {
+ const fx = effects[i];
+ const family = this._effectFamily(fx);
+ if (!needsCapture) {
+ // alone on the renderable (the only effect, or the only one
+ // left enabled): no offscreen pass, the effect IS the draw
+ if (meshHost) {
+ this._warmHostedEffect(renderable, fx, scene);
+ } else {
+ for (const target of scene) {
+ blit(family, blendMode, target);
+ }
+ }
+ }
+ if (pooled) {
+ // last in the chain: composited back with the renderable's
+ // own blend mode, onto whatever was below it
+ for (const target of scene) {
+ blit(family, blendMode, target);
+ }
+ // mid-chain: into the chain's own ping-pong target
+ if (i < count - 1) {
+ blit(family, "none", [this.preferredFormat, 1]);
+ }
+ }
+ }
+ if (pooled && !meshHost) {
+ // and the renderable's own content, drawn into the chain's capture
+ // target before any effect runs
+ blit("quad", blendMode, [this.preferredFormat, this.canvasSampleCount]);
+ }
+ }
+
+ /**
+ * The `[format, sampleCount]` targets an ordinary renderable can be drawn
+ * into: the canvas, or the camera's capture target when the camera runs
+ * an effect chain of its own. The two differ only in format, and only
+ * under `hdr`, so this is usually one target.
+ * @returns {Array>} the targets
+ * @ignore
+ * @internal
+ */
+ _sceneTargets() {
+ const canvas = [this.canvasFormat, this.canvasSampleCount];
+ return this._colorFormat === this.canvasFormat
+ ? [canvas]
+ : [canvas, [this._colorFormat, this.canvasSampleCount]];
+ }
+
+ /**
+ * Whether the warm-ups can run: a live device and a pipeline cache.
+ * @returns {boolean} true when ready
+ * @ignore
+ * @internal
+ */
+ _canWarm() {
+ return (
+ this.pipelineCache != null &&
+ typeof this.device !== "undefined" &&
+ this.isContextValid !== false
+ );
+ }
+
+ /**
+ * The mesh batcher a mesh draws through.
+ * @param {object} mesh - the mesh
+ * @returns {object|undefined} the batcher
+ * @ignore
+ * @internal
+ */
+ _meshBatcherFor(mesh) {
+ return this.batchers.get(
+ mesh.lit === true && this.batchers.has("litMesh") ? "litMesh" : "mesh",
+ );
+ }
+
+ /**
+ * Warm the pipelines a mesh will draw with when it hosts an effect: the
+ * effect spliced into the mesh shader.
+ * @param {object} mesh - the hosting mesh
+ * @param {ShaderEffect} effect - the effect it hosts
+ * @param {Array>} scene - the targets it can land on
+ * @ignore
+ * @internal
+ */
+ _warmHostedEffect(mesh, effect, scene) {
+ const realization = effect.wgslRealization;
+ if (
+ meshHostingBlocker(realization.builtins, realization.textures, "wgsl") !==
+ null
+ ) {
+ return;
+ }
+ const batcher = this._meshBatcherFor(mesh);
+ if (
+ batcher === undefined ||
+ realization.meshModule(batcher.shaderSource?.() ?? "") === null
+ ) {
+ return;
+ }
+ // the family is registered by the batcher's own lookup, so the warm-up
+ // and the draw cannot disagree on which module and layout it is
+ const previous = batcher.hostedEffect;
+ let family;
+ batcher.hostedEffect = effect;
+ try {
+ family = batcher.activeShaderKey();
+ } finally {
+ batcher.hostedEffect = previous;
+ }
+ this._warmMeshDraw(mesh, family, scene);
+ }
+
+ /**
+ * Warm the pipelines one mesh draw resolves to, keyed on the mesh's own
+ * culling, depth test and transparency exactly as `drawMesh` keys them:
+ * the opaque pass, the transparent pass, or both when `transparent` is
+ * left unset (blended whenever the mesh is faded).
+ * @param {object} mesh - the mesh
+ * @param {string} family - the pipeline family it draws with
+ * @param {Array>} scene - the targets it can land on
+ * @ignore
+ * @internal
+ */
+ _warmMeshDraw(mesh, family, scene) {
+ const culling = mesh.cullBackFaces === true;
+ const state = {
+ cullMode: culling ? "back" : "none",
+ // the retained path's winding rule, see `drawMesh`
+ frontFace:
+ culling &&
+ this.supportsRetainedMesh === true &&
+ mesh.rightHanded !== true
+ ? "cw"
+ : "ccw",
+ depthWrite: undefined,
+ depthTest: mesh.depthTest === false ? false : undefined,
+ fog: this._fog3d != null ? true : undefined,
+ };
+ const blendedOnly = mesh.transparent === true || mesh._blendedDraw === true;
+ const blended = mesh.transparent !== false || mesh._blendedDraw === true;
+ for (const [format, sampleCount] of scene) {
+ if (!blendedOnly) {
+ void this.pipelineCache.warm(
+ family,
+ "triangle-list",
+ "none",
+ true,
+ "none",
+ state,
+ format,
+ sampleCount,
+ );
+ }
+ if (blended) {
+ // the transparent pass: blended, premultiplied, no depth write
+ void this.pipelineCache.warm(
+ family,
+ "triangle-list",
+ mesh._blendedDraw === true ? "normal" : mesh.blendMode,
+ true,
+ "none",
+ { ...state, depthWrite: false },
+ format,
+ sampleCount,
+ );
+ }
+ }
+ }
+
+ /**
+ * Warm the renderable's own draw: a mesh through its mesh family, and
+ * everything else that draws a texture through the quad family, each with
+ * the renderable's blend mode. Containers draw nothing of their own, and
+ * a camera is a chain, warmed with its effects.
+ * @param {Renderable} renderable - the renderable to warm for
+ * @override
+ * @ignore
+ * @internal
+ */
+ _warmDraw(renderable) {
+ if (
+ !this._canWarm() ||
+ renderable._postEffectManaged === true ||
+ Array.isArray(renderable.children)
+ ) {
+ return;
+ }
+ const scene = this._sceneTargets();
+ if (this._hostsMeshEffect(renderable)) {
+ const batcher = this._meshBatcherFor(renderable);
+ if (batcher?.shaderKey !== undefined) {
+ this._warmMeshDraw(renderable, batcher.shaderKey, scene);
+ }
+ return;
+ }
+ if (renderable.instanceLayout !== undefined) {
+ // instanced families are keyed per record layout, built on draw
+ return;
+ }
+ const blendMode = renderable.blendMode ?? "normal";
+ for (const [format, sampleCount] of scene) {
+ void this.pipelineCache.warm(
+ "quad",
+ "triangle-list",
+ blendMode,
+ true,
+ "none",
+ undefined,
+ format,
+ sampleCount,
+ );
+ }
+ }
+
+ /**
+ * Build the advanced blend shader and its offscreen target before the
+ * first draw in one of those modes, and warm the two pipelines a bracket
+ * uses: the draw itself into the 8-bit offscreen, and the composite back
+ * onto the scene.
+ * @override
+ * @ignore
+ * @internal
+ */
+ _warmAdvancedBlend() {
+ if (!this._canWarm()) {
+ return;
+ }
+ if (typeof this._advancedBlendEffect === "undefined") {
+ this._advancedBlendEffect = new BlendEffect(this);
+ }
+ const effect = this._advancedBlendEffect;
+ if (effect.enabled !== true || effect.wgslRealization?.valid !== true) {
+ return;
+ }
+ const { width, height } = this.getCanvas();
+ if (typeof this._advancedBlendTarget === "undefined") {
+ // the same target `_openAdvancedBlend` makes, see the note there
+ this._advancedBlendTarget = new WebGPURenderTarget(this, width, height, {
+ sampleCount: 1,
+ format: this.preferredFormat,
+ });
+ }
+ // the renderable drawn into the offscreen: an advanced mode has no
+ // blend state of its own, so it lands source-over
+ void this.pipelineCache.warm(
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ this.preferredFormat,
+ 1,
+ );
+ const family = this._effectFamily(effect);
+ for (const [format, sampleCount] of this._sceneTargets()) {
+ void this.pipelineCache.warm(
+ family,
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ format,
+ sampleCount,
+ );
+ }
+ }
+
+ /**
+ * Warm the tone curve's pipeline: always the last step of a camera chain,
+ * so a full-screen blit onto the canvas with blending off.
+ * @override
+ * @ignore
+ * @internal
+ */
+ _warmToneMap() {
+ const toneMap = this._toneMapEffect;
+ if (
+ this.settings?.prewarm !== true ||
+ toneMap?.wgslRealization?.valid !== true ||
+ !this._canWarm()
+ ) {
+ return;
+ }
+ void this.pipelineCache.warm(
+ this._effectFamily(toneMap),
+ "triangle-list",
+ "none",
+ true,
+ "none",
+ undefined,
+ this.canvasFormat,
+ this.canvasSampleCount,
+ );
+ }
+
/**
* Begin a post-effect pass for the given renderable — the WebGPU
* realization of the WebGL FBO path: the renderable's whole content
@@ -1296,33 +1731,9 @@ export default class WebGPURenderer extends Renderer {
savedProjection.copy(this.projectionMatrix);
this.effectPassDepth++;
- // capture targets rasterize the scene — under antiAlias they carry
- // the pass sample count so MSAA composes with post-effects (the GL
- // twin resolves via blitFramebuffer). Ping-pong intermediates draw
- // screen-aligned effect quads only and stay single-sampled.
- /**
- * @ignore
- * @internal
- */
- this._renderTargetPool ??= new RenderTargetPool(
- (w, h, isCapture, index) => {
- // Indices 0/1 are the CAMERA chain; 2 and up are sprite chains.
- // Only the camera pair takes the half-float format, and that is
- // a correctness limit rather than thrift: a sprite chain's final
- // blit composites source-over with `ONE_MINUS_SRC_ALPHA`, and
- // additive blending accumulates ALPHA past 1 in a float target,
- // so that factor goes negative and subtracts the backdrop. The
- // camera chain blits without that factor, and is the documented
- // place for these effects anyway.
- return new WebGPURenderTarget(this, w, h, {
- sampleCount: isCapture === true ? this.canvasSampleCount : 1,
- format: index < 2 ? this._colorFormat : this.preferredFormat,
- });
- },
- );
- const rt = this._renderTargetPool.begin(
+ const rt = this._postEffectPool().begin(
isCamera,
- effects.length,
+ this._postEffectCapacity(renderable, effects),
canvas.width,
canvas.height,
);
@@ -1636,23 +2047,24 @@ export default class WebGPURenderer extends Renderer {
* WebGPU splits what WebGL fuses into one program. The **modules** for
* the engine's own families — quad, blit, primitive, clear, and the mesh
* tiers — are already created when the pipeline cache and the batchers
- * are constructed, so there is nothing lazy there to warm. A **pipeline**
- * is lazy, but it is keyed by draw state (blend, cull, depth-write) as
- * well as by shader, so the set a scene will ask for cannot be known from
- * here — and creating one costs almost nothing anyway, because WebGPU
- * records the intent and leaves the work to first use.
+ * are constructed. A **pipeline** is where the real compile happens, and
+ * it is the expensive part: tens of milliseconds each, paid in the frame
+ * that first draws with it. But it is keyed by draw state (blend, cull,
+ * depth write) and by the format of the target being drawn into, as well
+ * as by shader, so the set a scene will ask for cannot be known from
+ * here. Those are warmed when an effect is attached to a renderable
+ * ({@link Renderable#addPostEffect}), which is the first moment the
+ * renderable that decides them is known.
*
- * What IS both lazy and knowable is a `ShaderEffect`'s module:
- * `prepareEffectBinding` builds it, and it is only ever called from
- * inside the draw path. Every effect the loader compiled from a
- * `{type: "shader"}` asset is already in `shaderList`, so those are
- * exactly the ones that can be brought forward — which is what makes
- * declaring a level's effects alongside that level's assets worth doing.
- *
- * Effects constructed inline when a stage builds are not here yet and
- * cannot be warmed; that is a property of when they come into existence,
- * not a limitation of this method.
- * @returns {Promise} settles once those modules are built
+ * What this does warm is what needs no renderable. A `ShaderEffect`'s
+ * module: `prepareEffectBinding` builds it, and only ever from inside the
+ * draw path. Every effect the loader compiled from a `{type: "shader"}`
+ * asset is already in `shaderList`, so those can be brought forward —
+ * which is what makes declaring a level's effects alongside that level's
+ * assets worth doing. And the mip-chain blitter every mipmapped texture
+ * goes through on upload, when textures are filtered (`textureFilter`
+ * resolving to `"linear"`; nearest filtering never builds a chain).
+ * @returns {Promise} settles once those are built
* @see {@link Renderer#prewarm}
*/
prewarm() {
@@ -1683,6 +2095,25 @@ export default class WebGPURenderer extends Renderer {
buildEffectGPU(this, effect, realization);
}
}
+ if (this.getDefaultTextureFilter() === "linear") {
+ this.textureStore.warmMipmaps();
+ }
+ // the full-target clear every frame opens with, in each target
+ // format a frame draws into
+ if (this._canWarm()) {
+ for (const [format, sampleCount] of this._sceneTargets()) {
+ void this.pipelineCache.warm(
+ "clear",
+ "triangle-list",
+ "none",
+ true,
+ "none",
+ undefined,
+ format,
+ sampleCount,
+ );
+ }
+ }
});
}
@@ -1709,6 +2140,9 @@ export default class WebGPURenderer extends Renderer {
this.frameTextureView = null;
this.frameTexture = null;
}
+ if (this.pipelineCache != null) {
+ this.pipelineCache.inFrame = false;
+ }
this.destroyRetiredTextures();
}
@@ -1731,6 +2165,9 @@ export default class WebGPURenderer extends Renderer {
}
this.frameTextureView = null;
this.frameTexture = null;
+ if (this.pipelineCache != null) {
+ this.pipelineCache.inFrame = false;
+ }
// an abandoned frame may have died inside a post-effect bracket —
// never leave an offscreen target active for the next frame
this.currentRenderTarget = null;
@@ -3938,6 +4375,11 @@ export default class WebGPURenderer extends Renderer {
batcher.init(this);
}
emit(ONCONTEXT_RESTORED, this);
+ // everything warmed died with the old device, and the loader only
+ // warms on a preload, which the game has long finished
+ if (this.settings.prewarm === true) {
+ this.prewarm().catch(() => {});
+ }
}
/**
diff --git a/packages/melonjs/tests/prewarm-effects.spec.js b/packages/melonjs/tests/prewarm-effects.spec.js
new file mode 100644
index 0000000000..773278ca28
--- /dev/null
+++ b/packages/melonjs/tests/prewarm-effects.spec.js
@@ -0,0 +1,286 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { Mesh, ShaderEffect } from "../src/index.js";
+import { shaderList } from "../src/loader/cache.js";
+import {
+ deletable,
+ markContextLost,
+} from "../src/video/webgl/buffer/deletable.js";
+import {
+ getWebGLRenderer,
+ releaseWebGLRenderer,
+ requireWebGL,
+} from "./helpers/webgl-context.js";
+
+/**
+ * Two first-use costs that a preload looked like it covered and did not.
+ *
+ * Both were found by profiling INSIDE the frames that stuttered on a 3D
+ * example, where they were the two largest entries: the teardown probes
+ * ahead of shader compilation.
+ */
+describe("first-use costs a preload should have paid", () => {
+ describe("a mesh-hosted effect's spliced variant", () => {
+ let renderer;
+
+ beforeAll(async () => {
+ renderer = await getWebGLRenderer(256, 256);
+ });
+
+ afterAll(() => {
+ releaseWebGLRenderer(renderer);
+ });
+
+ it("is built by prewarm, not on the frame the effect first draws", async (ctx) => {
+ requireWebGL(ctx, renderer);
+ const batcher = renderer.batchers.get("mesh");
+ const effect = new ShaderEffect(
+ renderer,
+ "vec4 apply(vec4 color, vec2 uv) { return color * 0.5; }",
+ );
+
+ batcher.shaderVariants.clear();
+ await batcher.prewarm([effect]);
+
+ // A ShaderEffect hosted on a mesh does not draw with the program
+ // its asset compiled to — the body is spliced into the MESH
+ // shader, which is a different program again. Both fog states,
+ // because the splice inherits the fog define.
+ const keys = [...batcher.shaderVariants.keys()];
+ expect(keys).toContain(`effect|${effect._effectId}|0`);
+ expect(keys).toContain(`effect|${effect._effectId}|1`);
+ effect.destroy?.();
+ });
+
+ it("still builds on demand when prewarm never runs", async (ctx) => {
+ requireWebGL(ctx, renderer);
+ const batcher = renderer.batchers.get("mesh");
+ const effect = new ShaderEffect(
+ renderer,
+ "vec4 apply(vec4 color, vec2 uv) { return color * 0.25; }",
+ );
+
+ // prewarm is strictly ADDITIVE: a game that never preloads must
+ // still get its program, merely later
+ batcher.shaderVariants.clear();
+ const shader = batcher.effectShaderFor(effect);
+ expect(shader).not.toBeNull();
+ effect.destroy?.();
+ });
+ });
+
+ describe("a second preload's effects", () => {
+ let renderer;
+
+ beforeAll(async () => {
+ renderer = await getWebGLRenderer(256, 256);
+ });
+
+ afterAll(() => {
+ for (const name of ["firstLevel", "secondLevel"]) {
+ shaderList[name]?.destroy?.();
+ delete shaderList[name];
+ }
+ releaseWebGLRenderer(renderer);
+ });
+
+ it("are warmed by that preload, not only the first one's", async (ctx) => {
+ requireWebGL(ctx, renderer);
+ const batcher = renderer.batchers.get("mesh");
+ // what the loader leaves behind for two `{type: "shader"}` assets,
+ // one per level
+ shaderList.firstLevel = new ShaderEffect(
+ renderer,
+ "vec4 apply(vec4 color, vec2 uv) { return color * 0.4; }",
+ );
+ await renderer.prewarm();
+ shaderList.secondLevel = new ShaderEffect(
+ renderer,
+ "vec4 apply(vec4 color, vec2 uv) { return color * 0.6; }",
+ );
+ await renderer.prewarm();
+ // the regression: the whole call was memoized, so the second
+ // level's effect went back to being built on its first draw
+ const id = shaderList.secondLevel._effectId;
+ expect(batcher.shaderVariants.has(`effect|${id}|0`)).toBe(true);
+ expect(batcher.shaderVariants.has(`effect|${id}|1`)).toBe(true);
+ });
+
+ it("does not rebuild the batchers' own variants on every preload", async (ctx) => {
+ requireWebGL(ctx, renderer);
+ const batcher = renderer.batchers.get("mesh");
+ await renderer.prewarm();
+ const fog = batcher.shaderVariants.get("mesh|fog");
+ // a later preload with nothing new reuses the warm-up it already did
+ const again = renderer.prewarm();
+ await again;
+ expect(batcher.shaderVariants.get("mesh|fog")).toBe(fog);
+ expect(renderer.prewarm()).toBe(again);
+ });
+ });
+
+ describe("warming at attach time", () => {
+ let renderer;
+
+ beforeAll(async () => {
+ renderer = await getWebGLRenderer(256, 256);
+ });
+
+ afterAll(() => {
+ releaseWebGLRenderer(renderer);
+ });
+
+ const quadMesh = () => {
+ return new Mesh(0, 0, {
+ vertices: new Float32Array([
+ -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0,
+ ]),
+ uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
+ indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
+ width: 32,
+ height: 32,
+ });
+ };
+
+ /** let the warm-up's microtask and its link slices run */
+ const settle = async () => {
+ for (let i = 0; i < 5; i++) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+ }
+ };
+
+ it("links a mesh host's spliced program before it first draws", async (ctx) => {
+ requireWebGL(ctx, renderer);
+ const batcher = renderer.batchers.get("mesh");
+ const mesh = quadMesh();
+ const effect = new ShaderEffect(
+ renderer,
+ "vec4 apply(vec4 color, vec2 uv) { return color * 0.3; }",
+ );
+ mesh.addPostEffect(effect);
+ const key = `effect|${effect._effectId}|0`;
+ // not inside the caller's code: it may still be configuring the mesh
+ expect(batcher.shaderVariants.has(key)).toBe(false);
+ await settle();
+ expect(batcher.shaderVariants.has(key)).toBe(true);
+ mesh.destroy();
+ });
+
+ it("puts back the program it found bound", (ctx) => {
+ // linking leaves the new program bound, and a batcher trusts what it
+ // last bound: left alone, the next draw would use a stranger's.
+ // Driven by hand rather than by waiting, so no frame of the running
+ // application can rebind in between and hide the difference.
+ requireWebGL(ctx, renderer);
+ const batcher = renderer.batchers.get("mesh");
+ const mesh = quadMesh();
+ const effect = new ShaderEffect(
+ renderer,
+ "vec4 apply(vec4 color, vec2 uv) { return color * 0.7; }",
+ );
+ mesh.postEffects.push(effect);
+ renderer.setBatcher("quad");
+ const bound = renderer.currentProgram;
+ renderer._warmPostEffects(mesh);
+ renderer._linkQueuedMeshEffects();
+ expect(batcher.shaderVariants.has(`effect|${effect._effectId}|0`)).toBe(
+ true,
+ );
+ expect(renderer.currentProgram).toBe(bound);
+ expect(renderer.gl.getParameter(renderer.gl.CURRENT_PROGRAM)).toBe(bound);
+ mesh.destroy();
+ });
+
+ it("makes a chain's render targets ahead of its first pass", async (ctx) => {
+ requireWebGL(ctx, renderer);
+ renderer._renderTargetPool.destroy();
+ const camera = {
+ postEffects: [{ enabled: true }, { enabled: false }],
+ _postEffectManaged: true,
+ };
+ renderer._warmPostEffects(camera);
+ // the capture target and, for the disabled second effect, the
+ // ping-pong it will need the moment it is switched on
+ expect(renderer._renderTargetPool._pool[0]).toBeDefined();
+ expect(renderer._renderTargetPool._pool[1]).toBeDefined();
+ });
+
+ it("builds the advanced blend shader and target ahead of their first draw", async (ctx) => {
+ requireWebGL(ctx, renderer);
+ renderer.setBatcher("quad");
+ const bound = renderer.currentProgram;
+ renderer._warmAdvancedBlend();
+ expect(renderer._advancedBlendEffect).toBeDefined();
+ expect(renderer._advancedBlendTarget).toBeDefined();
+ expect(renderer.currentProgram).toBe(bound);
+ });
+ });
+
+ describe("deleting a GL object", () => {
+ it("skips the synchronous is* probe until a context has been lost", () => {
+ // `is*` queries block until the driver answers, and teardown runs
+ // once per object, so a scene dropping a batch of retained meshes
+ // paid a round trip for every buffer in it
+ const gl = {
+ isContextLost: () => {
+ return false;
+ },
+ };
+ let probed = 0;
+ const probe = () => {
+ probed++;
+ return true;
+ };
+
+ expect(deletable(gl, probe, {})).toBe(true);
+ expect(probed).toBe(0);
+
+ // once a context has dropped something, a handle really may be
+ // dead and deleting it would raise INVALID_OPERATION
+ markContextLost(gl);
+ expect(deletable(gl, probe, {})).toBe(true);
+ expect(probed).toBe(1);
+ });
+
+ it("refuses while the context is lost, without probing", () => {
+ const gl = {
+ isContextLost: () => {
+ return true;
+ },
+ };
+ let probed = 0;
+ expect(
+ deletable(
+ gl,
+ () => {
+ probed++;
+ return true;
+ },
+ {},
+ ),
+ ).toBe(false);
+ expect(probed).toBe(0);
+ });
+
+ it("honours the probe's answer for a handle that died with a context", () => {
+ const gl = {
+ isContextLost: () => {
+ return false;
+ },
+ };
+ markContextLost(gl);
+ // the restored context is live, but this handle belonged to the
+ // one that died — exactly what the probe exists to catch
+ expect(
+ deletable(
+ gl,
+ () => {
+ return false;
+ },
+ {},
+ ),
+ ).toBe(false);
+ });
+ });
+});
diff --git a/packages/melonjs/tests/prewarm-hooks.spec.js b/packages/melonjs/tests/prewarm-hooks.spec.js
new file mode 100644
index 0000000000..729b7a17aa
--- /dev/null
+++ b/packages/melonjs/tests/prewarm-hooks.spec.js
@@ -0,0 +1,264 @@
+import { describe, expect, it, vi } from "vitest";
+import { Container, Renderable } from "../src/index.js";
+import Renderer from "../src/video/renderer.js";
+import RenderTargetPool from "../src/video/rendertarget/render_target_pool.js";
+
+/**
+ * When the warm-up runs, and for what.
+ *
+ * It is triggered from two places a game already goes through: attaching
+ * an effect, and adding a renderable to the world. Both must stay cheap and
+ * silent — a warm-up that throws or runs synchronously inside the caller's
+ * code would turn a stutter fix into a new way to break a frame — and both
+ * must honour the `prewarm` setting that the loading-screen warm-up does.
+ */
+
+/** a bare base renderer whose warm entry points record their calls */
+function makeRenderer(prewarm = true) {
+ const renderer = Object.create(Renderer.prototype);
+ renderer.settings = { prewarm };
+ renderer.shaderLanguage = "glsl";
+ renderer.calls = { effects: [], draws: [], blends: 0 };
+ renderer._warmPostEffects = (r) => {
+ renderer.calls.effects.push(r);
+ };
+ renderer._warmDraw = (r) => {
+ renderer.calls.draws.push(r);
+ };
+ renderer._warmAdvancedBlend = () => {
+ renderer.calls.blends++;
+ };
+ return renderer;
+}
+
+/** let a queued microtask run */
+const tick = () => {
+ return Promise.resolve();
+};
+
+describe("warm-up scheduling", () => {
+ it("runs after the caller's code, not inside it", async () => {
+ const renderer = makeRenderer();
+ const host = { postEffects: [{}], blendMode: "normal" };
+ renderer._queueWarm(host);
+ // the caller may still be setting the blend mode, size, parent...
+ expect(renderer.calls.draws).toHaveLength(0);
+ await tick();
+ expect(renderer.calls.effects).toEqual([host]);
+ expect(renderer.calls.draws).toEqual([host]);
+ });
+
+ it("warms a renderable once however often it was queued in one task", async () => {
+ const renderer = makeRenderer();
+ const host = { postEffects: [{}, {}], blendMode: "normal" };
+ renderer._queueWarm(host);
+ renderer._queueWarm(host);
+ await tick();
+ expect(renderer.calls.effects).toHaveLength(1);
+ });
+
+ it("does nothing on a backend without shaders", async () => {
+ const renderer = makeRenderer();
+ renderer.shaderLanguage = null;
+ renderer._queueWarm({ postEffects: [{}], blendMode: "overlay" });
+ await tick();
+ expect(renderer.calls.draws).toHaveLength(0);
+ expect(renderer.calls.blends).toBe(0);
+ });
+
+ it("does nothing with the prewarm setting off", async () => {
+ const renderer = makeRenderer(false);
+ renderer._queueWarm({ postEffects: [{}], blendMode: "normal" });
+ await tick();
+ expect(renderer.calls.effects).toHaveLength(0);
+ expect(renderer.calls.draws).toHaveLength(0);
+ });
+
+ it("keeps going past a warm-up that throws, and throws nothing", async () => {
+ const renderer = makeRenderer();
+ const bad = { postEffects: [{}], blendMode: "normal" };
+ const good = { postEffects: [{}], blendMode: "normal" };
+ renderer._warmPostEffects = (r) => {
+ if (r === bad) {
+ throw new Error("boom");
+ }
+ renderer.calls.effects.push(r);
+ };
+ renderer._queueWarm(bad);
+ renderer._queueWarm(good);
+ await expect(tick()).resolves.toBeUndefined();
+ expect(renderer.calls.effects).toEqual([good]);
+ });
+
+ it("skips the effect half for a renderable without effects", async () => {
+ const renderer = makeRenderer();
+ renderer._queueWarm({ postEffects: [], blendMode: "normal" });
+ await tick();
+ expect(renderer.calls.effects).toHaveLength(0);
+ expect(renderer.calls.draws).toHaveLength(1);
+ });
+
+ it("warms a container's whole subtree", async () => {
+ // adding a container brings its descendants in without an addChild each
+ const renderer = makeRenderer();
+ const leaf = { postEffects: [], blendMode: "normal" };
+ const inner = { postEffects: [], blendMode: "normal", children: [leaf] };
+ const outer = { postEffects: [], blendMode: "normal", children: [inner] };
+ renderer._queueWarm(outer);
+ await tick();
+ expect(renderer.calls.draws).toEqual([outer, inner, leaf]);
+ });
+
+ it("builds the advanced blend machinery for a renderable that needs it", async () => {
+ const renderer = makeRenderer();
+ renderer._queueWarm({ postEffects: [], blendMode: "overlay" });
+ renderer._queueWarm({ postEffects: [], blendMode: "multiply" });
+ await tick();
+ // `multiply` is fixed-function blend state, `overlay` is the shader
+ expect(renderer.calls.blends).toBe(1);
+ });
+});
+
+describe("warm-up triggers", () => {
+ /** an effect carrying the renderer it was built for, as ShaderEffect does */
+ const effectFor = (renderer) => {
+ return { _renderer: renderer, enabled: true };
+ };
+
+ it("addPostEffect queues the renderable", () => {
+ const renderer = { _queueWarm: vi.fn() };
+ const renderable = new Renderable(0, 0, 10, 10);
+ renderable.addPostEffect(effectFor(renderer));
+ expect(renderer._queueWarm).toHaveBeenCalledWith(renderable);
+ });
+
+ it("assigning `shader` queues the renderable", () => {
+ const renderer = { _queueWarm: vi.fn() };
+ const renderable = new Renderable(0, 0, 10, 10);
+ renderable.shader = effectFor(renderer);
+ expect(renderer._queueWarm).toHaveBeenCalledWith(renderable);
+ });
+
+ it("tolerates an effect without a renderer, or one that cannot warm", () => {
+ const renderable = new Renderable(0, 0, 10, 10);
+ expect(() => {
+ renderable.addPostEffect({ enabled: true });
+ renderable.addPostEffect({ _renderer: {}, enabled: true });
+ renderable.shader = null;
+ }).not.toThrow();
+ });
+
+ it("adding to the world queues the child, through the app's renderer", () => {
+ const renderer = { _queueWarm: vi.fn() };
+ const world = new Container(0, 0, 100, 100);
+ world.root = true;
+ world.app = { renderer };
+ const child = new Renderable(0, 0, 10, 10);
+ world.addChild(child);
+ expect(renderer._queueWarm).toHaveBeenCalledWith(child);
+ const second = new Renderable(0, 0, 10, 10);
+ world.addChildAt(second, 0);
+ expect(renderer._queueWarm).toHaveBeenCalledWith(second);
+ });
+
+ it("adding to a container outside the world queues nothing", () => {
+ // it is not going to be drawn; its subtree is warmed when it is added
+ const renderer = { _queueWarm: vi.fn() };
+ const loose = new Container(0, 0, 100, 100);
+ loose.app = { renderer };
+ loose.addChild(new Renderable(0, 0, 10, 10));
+ expect(renderer._queueWarm).not.toHaveBeenCalled();
+ });
+});
+
+describe("render targets for a chain, made ahead", () => {
+ const makePool = () => {
+ const made = [];
+ const pool = new RenderTargetPool((w, h, isCapture, index) => {
+ const target = {
+ index,
+ w,
+ h,
+ resized: 0,
+ resize(width, height) {
+ this.w = width;
+ this.h = height;
+ this.resized++;
+ },
+ destroy() {},
+ };
+ made.push(target);
+ return target;
+ });
+ return { pool, made };
+ };
+
+ it("reserve() makes the camera pair for a two-effect chain", () => {
+ const { pool, made } = makePool();
+ pool.reserve(true, 2, 320, 240);
+ expect(
+ made.map((t) => {
+ return t.index;
+ }),
+ ).toEqual([0, 1]);
+ // and touches nothing a pass relies on
+ expect(pool.activeBase).toBe(-1);
+ });
+
+ it("reserve() makes only the capture target for a single effect", () => {
+ const { pool, made } = makePool();
+ pool.reserve(false, 1, 320, 240);
+ expect(
+ made.map((t) => {
+ return t.index;
+ }),
+ ).toEqual([2]);
+ });
+
+ it("reserve() leaves an existing target alone", () => {
+ // the pass that uses it resizes it when it begins
+ const { pool, made } = makePool();
+ pool.begin(true, 2, 100, 100);
+ pool.end();
+ pool.reserve(true, 2, 320, 240);
+ expect(made).toHaveLength(2);
+ expect(made[0].resized).toBe(0);
+ });
+
+ it("a chain is provisioned for its attached effects, not just the enabled ones", () => {
+ // a disabled second effect is one the game means to switch on, often
+ // in the middle of play; its ping-pong target is made with the chain
+ const renderer = Object.create(Renderer.prototype);
+ const off = { enabled: false };
+ const on = { enabled: true };
+ const camera = { postEffects: [on, off], _postEffectManaged: true };
+ const enabled = renderer._effectChainFor(camera);
+ expect(enabled).toHaveLength(1);
+ expect(renderer._postEffectCapacity(camera, enabled)).toBe(2);
+ const { pool, made } = makePool();
+ pool.begin(true, renderer._postEffectCapacity(camera, enabled), 320, 240);
+ expect(made).toHaveLength(2);
+ });
+});
+
+describe("the tone curve", () => {
+ it("is warmed when it is built, and only then", () => {
+ // the curve is added by the renderer, not through addPostEffect, so
+ // the attach hook never sees it
+ const renderer = Object.create(Renderer.prototype);
+ renderer.shaderLanguage = "wgsl";
+ renderer.settings = { toneMapping: "aces" };
+ renderer._warmToneMap = vi.fn();
+ renderer._resolveToneMapping();
+ expect(renderer._warmToneMap).toHaveBeenCalledTimes(1);
+ // an exposure change is a uniform write on the same curve
+ renderer.settings.toneMappingExposure = 2;
+ renderer._resolveToneMapping();
+ expect(renderer._warmToneMap).toHaveBeenCalledTimes(1);
+ // a new curve is a new shader
+ renderer.settings.toneMapping = "reinhard";
+ renderer._resolveToneMapping();
+ expect(renderer._warmToneMap).toHaveBeenCalledTimes(2);
+ renderer._toneMapEffect.destroy?.();
+ });
+});
diff --git a/packages/melonjs/tests/prewarm.spec.js b/packages/melonjs/tests/prewarm.spec.js
index 09492e6af6..dce93868c6 100644
--- a/packages/melonjs/tests/prewarm.spec.js
+++ b/packages/melonjs/tests/prewarm.spec.js
@@ -118,22 +118,42 @@ describe("renderer.prewarm()", () => {
);
});
- it("forgets it warmed once the context is restored", async (ctx) => {
+ it("warms again once the context is restored", async (ctx) => {
requireWebGL(ctx, renderer);
// the programs died with the old context. Leaving the memo set
// would have `prewarm()` report the work done for the rest of the
// session, silently putting every variant back on the first-draw
- // path — no error, just the feature quietly off
+ // path — no error, just the feature quietly off. And clearing it
+ // is not enough on its own: the loader only warms on a preload,
+ // which a running game has long finished, so the restore itself
+ // has to start the warm-up again
const first = renderer.prewarm();
await first;
+ const batcher = meshBatcher();
// emit the real event rather than calling the handler: what needs
// pinning is that the renderer is actually SUBSCRIBED, not that the
// handler body works when invoked by hand
event.emit(event.ONCONTEXT_RESTORED, renderer);
- expect(renderer._prewarmed).toBeUndefined();
- const second = renderer.prewarm();
- expect(second).not.toBe(first);
- await second;
+ const rewarm = renderer._prewarmed;
+ expect(rewarm).toBeInstanceOf(Promise);
+ expect(rewarm).not.toBe(first);
+ // and asking again joins that warm-up rather than starting a third
+ expect(renderer.prewarm()).toBe(rewarm);
+ await rewarm;
+ expect(batcher.shaderVariants.has("mesh|fog")).toBe(true);
+ });
+
+ it("leaves a restored context cold when prewarm is off", async (ctx) => {
+ requireWebGL(ctx, renderer);
+ await renderer.prewarm();
+ const setting = renderer.settings.prewarm;
+ renderer.settings.prewarm = false;
+ try {
+ event.emit(event.ONCONTEXT_RESTORED, renderer);
+ expect(renderer._prewarmed).toBeUndefined();
+ } finally {
+ renderer.settings.prewarm = setting;
+ }
});
it("warms the keys the DRAW path actually asks for", async (ctx) => {
diff --git a/packages/melonjs/tests/video-sprite.spec.js b/packages/melonjs/tests/video-sprite.spec.js
index 7b554d947f..bb70bee880 100644
--- a/packages/melonjs/tests/video-sprite.spec.js
+++ b/packages/melonjs/tests/video-sprite.spec.js
@@ -1,4 +1,12 @@
-import { beforeAll, describe, expect, it } from "vitest";
+import {
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
import { Application, boot, event, Sprite, video } from "../src/index.js";
/**
@@ -226,4 +234,273 @@ describe("Video Sprite (rVFC frame sync)", () => {
sprite.destroy();
});
+
+ /**
+ * Browsers refuse to start a video before the user has interacted with
+ * the page. `update()` used to call `play()` again on every frame and
+ * leave each refusal unhandled: one uncaught console error per frame
+ * until the first click. The refusal is now handled once, and the next
+ * gesture starts playback from inside its own handler, where the browser
+ * allows it.
+ */
+ describe("playback refused until a user gesture", () => {
+ const GESTURES = ["touchstart", "touchend", "click", "keydown"];
+ let unhandled;
+ const onUnhandled = (e) => {
+ unhandled.push(e.reason);
+ };
+ // the gesture listeners currently left on the document
+ let tracked;
+ const listening = () => {
+ return tracked.length;
+ };
+
+ beforeEach(() => {
+ unhandled = [];
+ window.addEventListener("unhandledrejection", onUnhandled);
+ tracked = [];
+ const add = document.addEventListener;
+ const remove = document.removeEventListener;
+ vi.spyOn(document, "addEventListener").mockImplementation(
+ (type, listener, options) => {
+ if (GESTURES.includes(type)) {
+ tracked.push([type, listener]);
+ }
+ add.call(document, type, listener, options);
+ },
+ );
+ vi.spyOn(document, "removeEventListener").mockImplementation(
+ (type, listener, options) => {
+ const i = tracked.findIndex(([t, l]) => {
+ return t === type && l === listener;
+ });
+ if (i !== -1) {
+ tracked.splice(i, 1);
+ }
+ remove.call(document, type, listener, options);
+ },
+ );
+ });
+
+ afterEach(() => {
+ window.removeEventListener("unhandledrejection", onUnhandled);
+ vi.restoreAllMocks();
+ });
+
+ // a rejection's handler runs as a microtask, `unhandledrejection` as a task
+ const settle = () => {
+ return new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+ };
+
+ const refuse = (el, state, name = "NotAllowedError") => {
+ el.play = () => {
+ state.playCalls++;
+ return Promise.reject(new DOMException("refused", name));
+ };
+ };
+
+ const allow = (el, state) => {
+ el.play = () => {
+ state.paused = false;
+ state.playCalls++;
+ return Promise.resolve();
+ };
+ };
+
+ const gesture = (type) => {
+ document.dispatchEvent(new Event(type, { bubbles: true }));
+ };
+
+ it("a refused play() is tried once, not on every frame", async () => {
+ const { el, state } = makeVideoStub();
+ refuse(el, state);
+ const sprite = makeVideoSprite(el);
+ sprite.play();
+ for (let i = 0; i < 10; i++) {
+ sprite.update(16);
+ await settle();
+ }
+ expect(state.playCalls).toBe(1);
+ expect(listening()).toBe(GESTURES.length);
+ sprite.destroy();
+ });
+
+ it("the refusal is handled, so it never reaches the console", async () => {
+ const { el, state } = makeVideoStub();
+ refuse(el, state);
+ const sprite = makeVideoSprite(el);
+ sprite.play();
+ sprite.update(16);
+ await settle();
+ expect(unhandled).toHaveLength(0);
+ sprite.destroy();
+ });
+
+ it.for(GESTURES)(
+ "a %s starts playback from inside the gesture",
+ async (type) => {
+ const { el, state } = makeVideoStub();
+ refuse(el, state);
+ const sprite = makeVideoSprite(el);
+ sprite.play();
+ sprite.update(16);
+ await settle();
+
+ allow(el, state);
+ gesture(type);
+ // synchronously, inside the handler: no update() in between
+ expect(state.playCalls).toBe(2);
+ expect(el.paused).toBe(false);
+ sprite.destroy();
+ },
+ );
+
+ it("a gesture refused again keeps listening, and playing stops listening", async () => {
+ const { el, state } = makeVideoStub();
+ refuse(el, state);
+ const sprite = makeVideoSprite(el);
+ sprite.play();
+ sprite.update(16);
+ await settle();
+
+ gesture("click");
+ expect(state.playCalls).toBe(2);
+ await settle();
+ gesture("click");
+ expect(state.playCalls).toBe(3);
+ await settle();
+
+ allow(el, state);
+ gesture("click");
+ expect(state.playCalls).toBe(4);
+ await settle();
+ // playing now: nothing is left listening, and neither a gesture
+ // nor a frame calls play() again
+ expect(listening()).toBe(0);
+ gesture("click");
+ sprite.update(16);
+ expect(state.playCalls).toBe(4);
+ expect(unhandled).toHaveLength(0);
+ sprite.destroy();
+ });
+
+ it("a paused game does not start the video on a gesture", async () => {
+ const { el, state } = makeVideoStub();
+ refuse(el, state);
+ const sprite = makeVideoSprite(el);
+ sprite.play();
+ sprite.update(16);
+ await settle();
+
+ event.emit(event.STATE_PAUSE);
+ expect(listening()).toBe(0);
+ allow(el, state);
+ gesture("click");
+ expect(state.playCalls).toBe(1);
+
+ // once the game runs again, update() asks as it always has
+ sprite.update(16);
+ expect(state.playCalls).toBe(2);
+ expect(el.paused).toBe(false);
+ sprite.destroy();
+ });
+
+ it("a refusal landing after the game paused does not start listening", async () => {
+ const { el, state } = makeVideoStub();
+ refuse(el, state);
+ const sprite = makeVideoSprite(el);
+ sprite.play();
+ sprite.update(16);
+ // paused while the request is still pending
+ event.emit(event.STATE_PAUSE);
+ await settle();
+
+ expect(listening()).toBe(0);
+ allow(el, state);
+ gesture("click");
+ expect(state.playCalls).toBe(1);
+ sprite.destroy();
+ });
+
+ it("a sprite paused while waiting ignores the gesture", async () => {
+ const { el, state } = makeVideoStub();
+ refuse(el, state);
+ const sprite = makeVideoSprite(el);
+ sprite.play();
+ sprite.update(16);
+ await settle();
+
+ sprite.pause();
+ allow(el, state);
+ gesture("click");
+ expect(state.playCalls).toBe(1);
+
+ // resuming later plays on the next frame, as before
+ sprite.play();
+ sprite.update(16);
+ expect(state.playCalls).toBe(2);
+ sprite.destroy();
+ });
+
+ it("destroy() stops listening for the gesture", async () => {
+ const { el, state } = makeVideoStub();
+ refuse(el, state);
+ const sprite = makeVideoSprite(el);
+ sprite.play();
+ sprite.update(16);
+ await settle();
+
+ sprite.destroy();
+ expect(listening()).toBe(0);
+ allow(el, state);
+ gesture("click");
+ expect(state.playCalls).toBe(1);
+ });
+
+ it("a sprite destroyed while play() is pending never starts listening", async () => {
+ const { el, state } = makeVideoStub();
+ refuse(el, state);
+ const sprite = makeVideoSprite(el);
+ sprite.play();
+ sprite.update(16);
+ // the refusal lands after the sprite is gone
+ sprite.destroy();
+ await settle();
+
+ const thrown = [];
+ const onError = (e) => {
+ thrown.push(e.error);
+ e.preventDefault();
+ };
+ window.addEventListener("error", onError);
+ try {
+ allow(el, state);
+ gesture("click");
+ } finally {
+ window.removeEventListener("error", onError);
+ }
+ expect(thrown).toHaveLength(0);
+ expect(state.playCalls).toBe(1);
+ });
+
+ it("an interrupted play() is retried on the next frame, still handled", async () => {
+ const { el, state } = makeVideoStub();
+ // e.g. pause() landing while play() was pending
+ refuse(el, state, "AbortError");
+ const sprite = makeVideoSprite(el);
+ sprite.play();
+ sprite.update(16);
+ await settle();
+ expect(state.playCalls).toBe(1);
+
+ allow(el, state);
+ sprite.update(16);
+ expect(state.playCalls).toBe(2);
+ expect(el.paused).toBe(false);
+ expect(unhandled).toHaveLength(0);
+ sprite.destroy();
+ });
+ });
});
diff --git a/packages/melonjs/tests/webgl_compressed.spec.js b/packages/melonjs/tests/webgl_compressed.spec.js
new file mode 100644
index 0000000000..89a8b91508
--- /dev/null
+++ b/packages/melonjs/tests/webgl_compressed.spec.js
@@ -0,0 +1,303 @@
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import {
+ getWebGLRenderer,
+ releaseWebGLRenderer,
+ requireWebGL,
+} from "./helpers/webgl-context.js";
+
+const COMPRESSED_RGB_ETC1_WEBGL = 0x8d64;
+const COMPRESSED_RGB8_ETC2 = 0x9274;
+
+/**
+ * ETC1 on the WebGL backend. Every compressed format was uploaded into
+ * immutable storage with `compressedTexSubImage2D`, and ETC1 is the one
+ * format that call refuses (INVALID_OPERATION, "The texture image is ETC1
+ * and the corresponding subregion update extensions are not enabled"), so
+ * an ETC1 texture stayed empty and drew black.
+ *
+ * ETC2 decodes ETC1 data unchanged (it was designed to), so with ETC2
+ * available the same bytes upload as ETC2, which takes the immutable path
+ * every other format uses. With ETC1 alone, the texture is filled level by
+ * level with `compressedTexImage2D`, the only upload ETC1 allows.
+ */
+describe("WebGL compressed textures: ETC1", () => {
+ let renderer;
+
+ beforeAll(async () => {
+ renderer = await getWebGLRenderer(64, 64);
+ });
+
+ afterAll(() => {
+ releaseWebGLRenderer();
+ });
+
+ // One 4×4 ETC1 block decoding to solid (255, 2, 2): differential mode,
+ // base red 31 with no delta, codeword table 0, and every pixel index 0,
+ // which adds +2 to each channel.
+ const RED_BLOCK = [0xf8, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00];
+
+ // a 4×4 base with its 2×2 and 1×1 levels, one block each
+ const etc1Image = (levels = 1) => {
+ const mipmaps = [];
+ for (let i = 0, px = 4; i < levels; i++, px >>= 1) {
+ mipmaps.push({ data: new Uint8Array(RED_BLOCK), width: px, height: px });
+ }
+ return {
+ compressed: true,
+ format: COMPRESSED_RGB_ETC1_WEBGL,
+ width: 4,
+ height: 4,
+ mipmaps,
+ };
+ };
+
+ const requireEtc1 = (ctx) => {
+ requireWebGL(ctx, renderer);
+ if (!renderer.getSupportedCompressedTextureFormats().etc1) {
+ ctx.skip("this browser exposes no ETC1 support");
+ }
+ };
+
+ const upload = (image) => {
+ const gl = renderer.gl;
+ return renderer.batchers
+ .get("quad")
+ .createTexture2D(
+ 0,
+ image,
+ gl.NEAREST,
+ "no-repeat",
+ image.width,
+ image.height,
+ false,
+ false,
+ );
+ };
+
+ // Sample the texture's centre into a 1×1 RGBA8 target and read it back.
+ // Compressed textures cannot be framebuffer attachments, so a draw is
+ // the only way to see what the upload actually stored.
+ const readTexel = (texture) => {
+ const gl = renderer.gl;
+ const compile = (type, source) => {
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ return shader;
+ };
+ const vs = compile(
+ gl.VERTEX_SHADER,
+ `#version 300 es
+ void main() {
+ vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
+ }`,
+ );
+ const fs = compile(
+ gl.FRAGMENT_SHADER,
+ `#version 300 es
+ precision mediump float;
+ uniform sampler2D tex;
+ out vec4 color;
+ void main() { color = texture(tex, vec2(0.5)); }`,
+ );
+ const program = gl.createProgram();
+ gl.attachShader(program, vs);
+ gl.attachShader(program, fs);
+ gl.linkProgram(program);
+
+ const target = gl.createTexture();
+ gl.activeTexture(gl.TEXTURE0);
+ const previousTexture = gl.getParameter(gl.TEXTURE_BINDING_2D);
+ gl.bindTexture(gl.TEXTURE_2D, target);
+ gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1);
+ const fbo = gl.createFramebuffer();
+ const previousFbo = gl.getParameter(gl.FRAMEBUFFER_BINDING);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ target,
+ 0,
+ );
+
+ // a bound sampler object would override the texture's own filtering
+ const previousSampler = gl.getParameter(gl.SAMPLER_BINDING);
+ gl.bindSampler(0, null);
+ const previousProgram = gl.getParameter(gl.CURRENT_PROGRAM);
+ const previousVao = gl.getParameter(gl.VERTEX_ARRAY_BINDING);
+ const previousViewport = gl.getParameter(gl.VIEWPORT);
+ const capabilities = [
+ gl.BLEND,
+ gl.SCISSOR_TEST,
+ gl.DEPTH_TEST,
+ gl.STENCIL_TEST,
+ ].filter((cap) => {
+ return gl.isEnabled(cap);
+ });
+ for (const cap of capabilities) {
+ gl.disable(cap);
+ }
+ const vao = gl.createVertexArray();
+ gl.bindVertexArray(vao);
+ gl.viewport(0, 0, 1, 1);
+ gl.useProgram(program);
+ gl.uniform1i(gl.getUniformLocation(program, "tex"), 0);
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
+ const texel = new Uint8Array(4);
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, texel);
+
+ // hand the shared renderer back the state it had
+ for (const cap of capabilities) {
+ gl.enable(cap);
+ }
+ gl.viewport(...previousViewport);
+ gl.useProgram(previousProgram);
+ gl.bindVertexArray(previousVao);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, previousFbo);
+ gl.bindTexture(gl.TEXTURE_2D, previousTexture);
+ gl.bindSampler(0, previousSampler);
+ gl.deleteVertexArray(vao);
+ gl.deleteFramebuffer(fbo);
+ gl.deleteTexture(target);
+ gl.deleteProgram(program);
+ gl.deleteShader(vs);
+ gl.deleteShader(fs);
+ return [...texel];
+ };
+
+ const expectRed = (texel) => {
+ expect(texel[0]).toBeGreaterThanOrEqual(250);
+ expect(texel[1]).toBeLessThanOrEqual(6);
+ expect(texel[2]).toBeLessThanOrEqual(6);
+ expect(texel[3]).toBe(255);
+ };
+
+ it("uploads without a GL error and draws the texels it was given", (ctx) => {
+ requireEtc1(ctx);
+ const gl = renderer.gl;
+ gl.getError(); // start from a clean error flag
+ const texture = upload(etc1Image());
+ expect(gl.getError()).toBe(gl.NO_ERROR);
+ expectRed(readTexel(texture));
+ gl.deleteTexture(texture);
+ });
+
+ it("with ETC2 available, uploads the same bytes as ETC2 into immutable storage", (ctx) => {
+ requireEtc1(ctx);
+ if (!renderer.getSupportedCompressedTextureFormats().etc2) {
+ ctx.skip("this browser exposes no ETC2 support");
+ }
+ const gl = renderer.gl;
+ gl.getError();
+ const storage = vi.spyOn(gl, "texStorage2D");
+ const sub = vi.spyOn(gl, "compressedTexSubImage2D");
+ try {
+ const texture = upload(etc1Image(3));
+ expect(gl.getError()).toBe(gl.NO_ERROR);
+ expect(storage).toHaveBeenCalledWith(
+ gl.TEXTURE_2D,
+ 3,
+ COMPRESSED_RGB8_ETC2,
+ 4,
+ 4,
+ );
+ expect(sub).toHaveBeenCalledTimes(3);
+ for (const call of sub.mock.calls) {
+ expect(call[6]).toBe(COMPRESSED_RGB8_ETC2);
+ }
+ expect(
+ gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_IMMUTABLE_FORMAT),
+ ).toBe(true);
+ expectRed(readTexel(texture));
+ gl.deleteTexture(texture);
+ } finally {
+ storage.mockRestore();
+ sub.mockRestore();
+ }
+ });
+
+ it("with ETC1 alone, fills each level with compressedTexImage2D", (ctx) => {
+ requireEtc1(ctx);
+ const gl = renderer.gl;
+ const real = renderer.getSupportedCompressedTextureFormats();
+ const formats = vi
+ .spyOn(renderer, "getSupportedCompressedTextureFormats")
+ .mockReturnValue({ ...real, etc2: null });
+ const image2D = vi.spyOn(gl, "compressedTexImage2D");
+ const storage = vi.spyOn(gl, "texStorage2D");
+ try {
+ gl.getError();
+ const texture = upload(etc1Image(3));
+ expect(gl.getError()).toBe(gl.NO_ERROR);
+ expect(storage).not.toHaveBeenCalled();
+ expect(image2D).toHaveBeenCalledTimes(3);
+ for (const [level, call] of image2D.mock.calls.entries()) {
+ expect(call[1]).toBe(level);
+ expect(call[2]).toBe(COMPRESSED_RGB_ETC1_WEBGL);
+ }
+ expect(
+ gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_IMMUTABLE_FORMAT),
+ ).toBe(false);
+ expectRed(readTexel(texture));
+ gl.deleteTexture(texture);
+ } finally {
+ formats.mockRestore();
+ image2D.mockRestore();
+ storage.mockRestore();
+ }
+ });
+
+ // Meshes sample with trilinear filtering, which needs a complete mip
+ // chain. An asset may ship fewer levels than the full chain: immutable
+ // storage is complete with exactly the levels it was given, and the
+ // mutable ETC1 texture only once capped to them.
+ it.for(["with ETC2 available", "with ETC1 alone"])(
+ "a partial mip chain still samples under trilinear filtering, %s",
+ (mode, ctx) => {
+ requireEtc1(ctx);
+ const gl = renderer.gl;
+ const real = renderer.getSupportedCompressedTextureFormats();
+ if (mode === "with ETC2 available" && !real.etc2) {
+ ctx.skip("this browser exposes no ETC2 support");
+ }
+ const formats = vi
+ .spyOn(renderer, "getSupportedCompressedTextureFormats")
+ .mockReturnValue(
+ mode === "with ETC1 alone" ? { ...real, etc2: null } : real,
+ );
+ try {
+ // an 8×8 base (four blocks) and its 4×4 level: two of the four levels
+ const base = new Uint8Array(32);
+ for (let i = 0; i < 4; i++) {
+ base.set(RED_BLOCK, i * 8);
+ }
+ const image = {
+ compressed: true,
+ format: COMPRESSED_RGB_ETC1_WEBGL,
+ width: 8,
+ height: 8,
+ mipmaps: [
+ { data: base, width: 8, height: 8 },
+ { data: new Uint8Array(RED_BLOCK), width: 4, height: 4 },
+ ],
+ };
+ gl.getError();
+ const texture = upload(image);
+ gl.texParameteri(
+ gl.TEXTURE_2D,
+ gl.TEXTURE_MIN_FILTER,
+ gl.LINEAR_MIPMAP_LINEAR,
+ );
+ expect(gl.getError()).toBe(gl.NO_ERROR);
+ // an incomplete texture samples as opaque black
+ expectRed(readTexel(texture));
+ gl.deleteTexture(texture);
+ } finally {
+ formats.mockRestore();
+ }
+ },
+ );
+});
diff --git a/packages/melonjs/tests/webgpu_compressed.spec.js b/packages/melonjs/tests/webgpu_compressed.spec.js
index 5e4defaf1f..a1d834d189 100644
--- a/packages/melonjs/tests/webgpu_compressed.spec.js
+++ b/packages/melonjs/tests/webgpu_compressed.spec.js
@@ -61,6 +61,57 @@ describe("WebGPU compressed textures", () => {
expect(writes[2].dst.mipLevel).toBe(2);
});
+ // WebGPU rejects a compressed copy that does not cover whole blocks
+ // ("copySize.width (2) is not a multiple of compressed texture format
+ // block width (4)"), and a rejected write leaves that mip level empty. A
+ // level smaller than one block is stored padded to a full block, so the
+ // copy covers the padded size, which is also how its data is laid out.
+ it("sizes every copy to whole blocks, so mips smaller than a block upload", () => {
+ const copySizes = (format, chain) => {
+ const metrics = COMPRESSED_FORMATS.get(format);
+ const sizes = [];
+ const device = {
+ queue: {
+ writeTexture(dst, data, layout, size) {
+ sizes.push(size);
+ },
+ },
+ };
+ const mipmaps = chain.map((px) => {
+ const blocks =
+ Math.ceil(px / metrics.blockW) * Math.ceil(px / metrics.blockH);
+ return {
+ data: new Uint8Array(blocks * metrics.bytes),
+ width: px,
+ height: px,
+ };
+ });
+ uploadCompressedTexture(
+ device,
+ { label: "t" },
+ { format, mipmaps },
+ metrics,
+ );
+ return sizes;
+ };
+
+ // DXT1 → bc1, 4×4 blocks: the 2×2 and 1×1 tail covers one block each
+ expect(copySizes(0x83f0, [8, 4, 2, 1])).toEqual([
+ [8, 8],
+ [4, 4],
+ [4, 4],
+ [4, 4],
+ ]);
+ // ASTC 5×5: whole blocks of five, not of four
+ expect(copySizes(0x93b2, [20, 10, 5, 2, 1])).toEqual([
+ [20, 20],
+ [10, 10],
+ [5, 5],
+ [5, 5],
+ [5, 5],
+ ]);
+ });
+
it("format families mirror the device features in the GL shape", () => {
const stub = {
device: { features: new Set(["texture-compression-bc"]) },
diff --git a/packages/melonjs/tests/webgpu_mipmaps.spec.js b/packages/melonjs/tests/webgpu_mipmaps.spec.js
index c6eca71d69..23cb13a96a 100644
--- a/packages/melonjs/tests/webgpu_mipmaps.spec.js
+++ b/packages/melonjs/tests/webgpu_mipmaps.spec.js
@@ -292,4 +292,34 @@ describe("WebGPUTextureStore mipmaps", () => {
expect(createdTextures[0].mipLevelCount).toBe(1);
expect(mipgen.submits).toBe(0);
});
+
+ it("warmMipmaps builds the blitter and runs it once, on a throwaway", () => {
+ // creating the pipeline is not enough everywhere: some implementations
+ // compile on first USE, so the warm-up uses it
+ store.warmMipmaps();
+ expect(store.mipPipeline).toBeDefined();
+ expect(mipgen.submits).toBe(1);
+ expect(mipgen.draws).toBe(1);
+ const scratch = createdTextures.find((t) => {
+ return t.label === "melonJS mip warm-up";
+ });
+ expect(scratch.mipLevelCount).toBe(2);
+ expect(scratch.destroyed).toBe(true);
+ });
+
+ it("warmMipmaps is a no-op once the blitter exists", () => {
+ store.getBinding(makeAtlas(makeSource(64, 32)), { mipmaps: true });
+ const pipeline = store.mipPipeline;
+ const submits = mipgen.submits;
+ store.warmMipmaps();
+ expect(store.mipPipeline).toBe(pipeline);
+ expect(mipgen.submits).toBe(submits);
+ });
+
+ it("a warmed blitter is the one the first real chain uses", () => {
+ store.warmMipmaps();
+ const pipeline = store.mipPipeline;
+ store.getBinding(makeAtlas(makeSource(64, 32)), { mipmaps: true });
+ expect(store.mipPipeline).toBe(pipeline);
+ });
});
diff --git a/packages/melonjs/tests/webgpu_pipeline_warm.spec.js b/packages/melonjs/tests/webgpu_pipeline_warm.spec.js
new file mode 100644
index 0000000000..c5f3dcb2da
--- /dev/null
+++ b/packages/melonjs/tests/webgpu_pipeline_warm.spec.js
@@ -0,0 +1,528 @@
+import "./helpers/webgpu-globals.js";
+import { describe, expect, it } from "vitest";
+import WebGPUPipelineCache from "../src/video/webgpu/pipeline/cache.js";
+
+/**
+ * A mock GPUDevice for the warm-up path: synchronous creation is recorded
+ * like the other pipeline specs, asynchronous creation hands back a promise
+ * the test settles by hand, and command encoding records the passes a
+ * warm-up submits.
+ * @param {object} [options] - `{async: false}` drops createRenderPipelineAsync
+ * @returns {object} the device
+ */
+function createMockDevice(options = {}) {
+ const device = {
+ syncPipelines: [],
+ asyncRequests: [],
+ submits: [],
+ textures: [],
+ createBindGroupLayout(descriptor) {
+ return { label: descriptor.label };
+ },
+ createBindGroup(descriptor) {
+ return { label: descriptor.label };
+ },
+ createShaderModule(descriptor) {
+ return { label: descriptor.label };
+ },
+ createPipelineLayout() {
+ return {};
+ },
+ createRenderPipeline(descriptor) {
+ const pipeline = { descriptor, via: "sync" };
+ device.syncPipelines.push(pipeline);
+ return pipeline;
+ },
+ createTexture(descriptor) {
+ const texture = {
+ descriptor,
+ destroyed: false,
+ createView() {
+ return { texture };
+ },
+ destroy() {
+ texture.destroyed = true;
+ },
+ };
+ device.textures.push(texture);
+ return texture;
+ },
+ createCommandEncoder() {
+ const passes = [];
+ return {
+ beginRenderPass(descriptor) {
+ const pass = { descriptor, pipelines: [], ended: false };
+ passes.push(pass);
+ return {
+ setPipeline(pipeline) {
+ pass.pipelines.push(pipeline);
+ },
+ end() {
+ pass.ended = true;
+ },
+ };
+ },
+ finish() {
+ return { passes };
+ },
+ };
+ },
+ queue: {
+ submit(buffers) {
+ device.submits.push(...buffers);
+ },
+ },
+ };
+ if (options.async !== false) {
+ device.createRenderPipelineAsync = (descriptor) => {
+ return new Promise((resolve, reject) => {
+ device.asyncRequests.push({
+ descriptor,
+ resolve: () => {
+ resolve({ descriptor, via: "async" });
+ },
+ reject,
+ });
+ });
+ };
+ }
+ return device;
+}
+
+function makeCache(options) {
+ const device = createMockDevice(options);
+ const cache = new WebGPUPipelineCache(device, "bgra8unorm");
+ // the quad family's vertex layout, as the quad batcher registers it
+ cache.registerVertexLayout("quad", 28, [
+ { format: "float32x3", offset: 0 },
+ { format: "float32x2", offset: 12 },
+ { format: "unorm8x4", offset: 20 },
+ { format: "float32", offset: 24 },
+ ]);
+ return { device, cache };
+}
+
+/** let every queued promise callback run */
+const settle = async () => {
+ for (let i = 0; i < 10; i++) {
+ await Promise.resolve();
+ }
+};
+
+describe("WebGPUPipelineCache warm-up", () => {
+ describe("one key for the draw and the warm-up", () => {
+ it("a warmed pipeline is the one the draw gets, under that target", async () => {
+ const { device, cache } = makeCache();
+ const warming = cache.warm(
+ "quad",
+ "triangle-list",
+ "additive",
+ true,
+ "none",
+ undefined,
+ "rgba16float",
+ 4,
+ );
+ await settle();
+ device.asyncRequests[0].resolve();
+ await warming;
+
+ // the draw: the renderer has retargeted to a half-float, 4x pass
+ cache.format = "rgba16float";
+ cache.sampleCount = 4;
+ const pipeline = cache.get("quad", "triangle-list", "additive", true);
+ expect(pipeline.via).toBe("async");
+ expect(device.syncPipelines).toHaveLength(0);
+ });
+
+ it("builds for the target it was given, not the one bound last", async () => {
+ // a warm-up runs outside any pass, where the cache's own format is
+ // whatever the last frame left. Keying off that would file the
+ // pipeline under the wrong name, and the draw would compile its own
+ const { device, cache } = makeCache();
+ cache.format = "bgra8unorm";
+ cache.sampleCount = 1;
+ void cache.warm(
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ "rgba16float",
+ 4,
+ );
+ await settle();
+ const { descriptor } = device.asyncRequests[0];
+ expect(descriptor.fragment.targets[0].format).toBe("rgba16float");
+ expect(descriptor.multisample.count).toBe(4);
+ expect(descriptor.label).toContain("rgba16float|4");
+ });
+
+ it("keyFor is the key get() files a pipeline under", () => {
+ const { cache } = makeCache();
+ cache.format = "rgba8unorm";
+ cache.sampleCount = 4;
+ const mesh = { cullMode: "back", frontFace: "cw", depthWrite: false };
+ const key = cache.keyFor(
+ "quad",
+ "triangle-list",
+ "additive",
+ true,
+ "none",
+ mesh,
+ );
+ const pipeline = cache.get(
+ "quad",
+ "triangle-list",
+ "additive",
+ true,
+ "none",
+ mesh,
+ );
+ expect(cache.pipelines.get(key)).toBe(pipeline);
+ // and the explicit target defaults to the current one
+ expect(
+ cache.keyFor(
+ "quad",
+ "triangle-list",
+ "additive",
+ true,
+ "none",
+ mesh,
+ "rgba8unorm",
+ 4,
+ ),
+ ).toBe(key);
+ });
+
+ it("does not split a blend-less pipeline on the premultiplied flag", () => {
+ // with blending off the flag selects nothing in the descriptor, and
+ // it is runtime state (whatever drew last leaves it), so keying on
+ // it built the same pipeline twice and left a warm-up guessing
+ const { device, cache } = makeCache();
+ const a = cache.get("quad", "triangle-list", "none", true);
+ const b = cache.get("quad", "triangle-list", "none", false);
+ expect(b).toBe(a);
+ expect(device.syncPipelines).toHaveLength(1);
+ // where it does select something, it still splits
+ cache.get("quad", "triangle-list", "normal", true);
+ cache.get("quad", "triangle-list", "normal", false);
+ expect(device.syncPipelines).toHaveLength(3);
+ });
+ });
+
+ describe("the mid-frame compile counter", () => {
+ it("counts a pipeline built inside a frame, with its key", () => {
+ const { cache } = makeCache();
+ cache.inFrame = true;
+ cache.get("quad", "triangle-list", "normal", true);
+ expect(cache.inFrameMisses).toBe(1);
+ expect(cache.inFrameMissKeys).toEqual([
+ cache.keyFor("quad", "triangle-list", "normal", true),
+ ]);
+ // a second lookup of the same pipeline is not a compile
+ cache.get("quad", "triangle-list", "normal", true);
+ expect(cache.inFrameMisses).toBe(1);
+ });
+
+ it("does not count one built between frames", () => {
+ const { cache } = makeCache();
+ cache.inFrame = false;
+ cache.get("quad", "triangle-list", "normal", true);
+ expect(cache.inFrameMisses).toBe(0);
+ });
+
+ it("does not count a draw that finds its pipeline warmed", async () => {
+ const { device, cache } = makeCache();
+ const warming = cache.warm(
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ "bgra8unorm",
+ 1,
+ );
+ await settle();
+ device.asyncRequests[0].resolve();
+ await warming;
+ cache.inFrame = true;
+ cache.get("quad", "triangle-list", "normal", true);
+ expect(cache.inFrameMisses).toBe(0);
+ });
+
+ it("keeps only the newest keys", () => {
+ const { cache } = makeCache();
+ cache.inFrame = true;
+ for (let i = 0; i < 70; i++) {
+ cache.get(`quad${i}`, "triangle-list", "normal", true);
+ }
+ expect(cache.inFrameMisses).toBe(70);
+ expect(cache.inFrameMissKeys).toHaveLength(64);
+ expect(cache.inFrameMissKeys.at(-1)).toContain("quad69|");
+ });
+ });
+
+ describe("compiling off the frame", () => {
+ it("compiles once for two requests of the same pipeline", async () => {
+ const { device, cache } = makeCache();
+ const args = [
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ "bgra8unorm",
+ 1,
+ ];
+ const first = cache.warm(...args);
+ const second = cache.warm(...args);
+ await settle();
+ expect(second).toBe(first);
+ expect(device.asyncRequests).toHaveLength(1);
+ device.asyncRequests[0].resolve();
+ await first;
+ // and a third, once it is cached, compiles nothing
+ await cache.warm(...args);
+ expect(device.asyncRequests).toHaveLength(1);
+ });
+
+ it("never has more than 16 compiles in flight", async () => {
+ const { device, cache } = makeCache();
+ const all = [];
+ for (let i = 0; i < 20; i++) {
+ all.push(
+ cache.warm(
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ `format${i}`,
+ 1,
+ ),
+ );
+ }
+ await settle();
+ expect(device.asyncRequests).toHaveLength(16);
+ // one finishing lets exactly one more start
+ device.asyncRequests[0].resolve();
+ await settle();
+ expect(device.asyncRequests).toHaveLength(17);
+ for (const request of device.asyncRequests.slice(1)) {
+ request.resolve();
+ }
+ await settle();
+ for (const request of device.asyncRequests.slice(17)) {
+ request.resolve();
+ }
+ await Promise.all(all);
+ expect(cache.pipelines.size).toBe(20);
+ });
+
+ it("settles without a pipeline when the compile fails", async () => {
+ const { device, cache } = makeCache();
+ const warming = cache.warm(
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ "bgra8unorm",
+ 1,
+ );
+ await settle();
+ device.asyncRequests[0].reject(new Error("validation"));
+ await expect(warming).resolves.toBeUndefined();
+ expect(cache.pending.size).toBe(0);
+ // the draw builds it itself and reports, as it did before
+ cache.get("quad", "triangle-list", "normal", true);
+ expect(device.syncPipelines).toHaveLength(1);
+ });
+
+ it("keeps the copy a draw built while the warm-up was compiling", async () => {
+ // the draws already recorded hold that one, so swapping the cached
+ // object for the warm-up's would give two pipelines one name
+ const { device, cache } = makeCache();
+ const warming = cache.warm(
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ "bgra8unorm",
+ 1,
+ );
+ const drawn = cache.get("quad", "triangle-list", "normal", true);
+ await settle();
+ device.asyncRequests[0].resolve();
+ await warming;
+ expect(cache.get("quad", "triangle-list", "normal", true)).toBe(drawn);
+ });
+
+ it("falls back to synchronous creation where async is missing", async () => {
+ const { device, cache } = makeCache({ async: false });
+ await cache.warm(
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ "bgra8unorm",
+ 1,
+ );
+ expect(device.syncPipelines).toHaveLength(1);
+ expect(cache.get("quad", "triangle-list", "normal", true)).toBe(
+ device.syncPipelines[0],
+ );
+ });
+
+ it("drops what was compiling for a cleared cache", async () => {
+ const { device, cache } = makeCache();
+ const inFlight = cache.warm(
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ "bgra8unorm",
+ 1,
+ );
+ await settle();
+ cache.clear();
+ device.asyncRequests[0].resolve();
+ await inFlight;
+ expect(cache.pipelines.size).toBe(0);
+ });
+
+ it("settles the queued warm-ups of a cleared cache", async () => {
+ // anything awaiting them must not wait forever
+ const { cache } = makeCache();
+ const queued = [];
+ for (let i = 0; i < 18; i++) {
+ queued.push(
+ cache.warm(
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ `format${i}`,
+ 1,
+ ),
+ );
+ }
+ await settle();
+ cache.clear();
+ await expect(Promise.all(queued.slice(16))).resolves.toBeDefined();
+ });
+ });
+
+ describe("binding a warmed pipeline once", () => {
+ it("sets every warmed pipeline in a 1x1 pass of its own target", async () => {
+ // some implementations accept async creation and still compile at
+ // the first setPipeline; this makes that first use happen now
+ const { device, cache } = makeCache();
+ const a = cache.warm(
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ "rgba16float",
+ 4,
+ );
+ const b = cache.warm(
+ "quad",
+ "triangle-list",
+ "additive",
+ true,
+ "none",
+ undefined,
+ "rgba16float",
+ 4,
+ );
+ await settle();
+ device.asyncRequests[0].resolve();
+ device.asyncRequests[1].resolve();
+ await Promise.all([a, b]);
+ await settle();
+
+ // one submit, one pass for the target shape, both pipelines set
+ expect(device.submits).toHaveLength(1);
+ const [pass] = device.submits[0].passes;
+ expect(pass.ended).toBe(true);
+ expect(pass.pipelines).toEqual([
+ cache.pipelines.get(
+ cache.keyFor(
+ "quad",
+ "triangle-list",
+ "normal",
+ true,
+ "none",
+ undefined,
+ "rgba16float",
+ 4,
+ ),
+ ),
+ cache.pipelines.get(
+ cache.keyFor(
+ "quad",
+ "triangle-list",
+ "additive",
+ true,
+ "none",
+ undefined,
+ "rgba16float",
+ 4,
+ ),
+ ),
+ ]);
+ // against attachments a pipeline of that shape validates with
+ const color = pass.descriptor.colorAttachments[0].view.texture;
+ const depth = pass.descriptor.depthStencilAttachment.view.texture;
+ expect(color.descriptor).toMatchObject({
+ format: "rgba16float",
+ sampleCount: 4,
+ size: [1, 1],
+ });
+ expect(depth.descriptor).toMatchObject({
+ format: "depth24plus-stencil8",
+ sampleCount: 4,
+ });
+ });
+
+ it("reuses one scratch target per shape", async () => {
+ const { device, cache } = makeCache();
+ for (const blend of ["normal", "additive"]) {
+ const warming = cache.warm(
+ "quad",
+ "triangle-list",
+ blend,
+ true,
+ "none",
+ undefined,
+ "bgra8unorm",
+ 1,
+ );
+ await settle();
+ device.asyncRequests.at(-1).resolve();
+ await warming;
+ await settle();
+ }
+ expect(device.submits).toHaveLength(2);
+ // a colour and a depth texture, made once and reused
+ expect(device.textures).toHaveLength(2);
+ });
+ });
+});
diff --git a/packages/melonjs/tests/webgpu_prewarm.spec.js b/packages/melonjs/tests/webgpu_prewarm.spec.js
index d7561e24c4..853cd28987 100644
--- a/packages/melonjs/tests/webgpu_prewarm.spec.js
+++ b/packages/melonjs/tests/webgpu_prewarm.spec.js
@@ -29,8 +29,22 @@ describe("WebGPURenderer.prewarm()", () => {
* @param {number} epoch - the pipeline cache's device epoch
* @returns {object} the stub
*/
- const rendererStub = (epoch = 1) => {
+ const rendererStub = (epoch = 1, filter = "nearest") => {
+ const mipWarms = [];
return {
+ mipWarms,
+ getDefaultTextureFilter: () => {
+ return filter;
+ },
+ // no device behind this stub: the pipeline warm-ups stand down
+ _canWarm: () => {
+ return false;
+ },
+ textureStore: {
+ warmMipmaps() {
+ mipWarms.push(true);
+ },
+ },
device: { limits: { minUniformBufferOffsetAlignment: 256 } },
pipelineCache: {
epoch,
@@ -141,4 +155,20 @@ describe("WebGPURenderer.prewarm()", () => {
await expect(rendererStub().prewarm()).resolves.toBeUndefined();
expect(registered).toEqual([]);
});
+
+ it("builds the mip-chain blitter when textures are filtered", async () => {
+ // the first mipmapped texture uploaded would otherwise build it, in
+ // the middle of the frame that first draws a mesh
+ const renderer = rendererStub(1, "linear");
+ await renderer.prewarm();
+ expect(renderer.mipWarms).toHaveLength(1);
+ });
+
+ it("leaves the mip blitter alone under nearest filtering", async () => {
+ // `textureFilter: "nearest"`, or `"auto"` with `antiAlias: false`,
+ // never builds a mip chain, so there is nothing to warm
+ const renderer = rendererStub(1, "nearest");
+ await renderer.prewarm();
+ expect(renderer.mipWarms).toHaveLength(0);
+ });
});
diff --git a/packages/melonjs/tests/webgpu_warm_keys.spec.js b/packages/melonjs/tests/webgpu_warm_keys.spec.js
new file mode 100644
index 0000000000..befa78b2d3
--- /dev/null
+++ b/packages/melonjs/tests/webgpu_warm_keys.spec.js
@@ -0,0 +1,380 @@
+import "./helpers/webgpu-globals.js";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { Color, ShaderEffect, WebGPURenderer } from "../src/index.js";
+import ToneMappingEffect from "../src/video/effects/tonemap.js";
+import WebGPUMeshBatcher from "../src/video/webgpu/batchers/mesh_batcher.js";
+import WebGPUQuadBatcher from "../src/video/webgpu/batchers/quad_batcher.js";
+import WebGPUPipelineCache from "../src/video/webgpu/pipeline/cache.js";
+import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js";
+
+/**
+ * The warm-up builds pipelines ahead of the draws, from keys it works out
+ * itself. If one of those keys differs from the draw's by a character the
+ * warm-up compiles something nothing will ask for, the draw compiles its own
+ * on first use, and the stall is back with nothing failing.
+ *
+ * So every case here warms through the REAL renderer methods and then draws
+ * through the REAL draw path — the quad batcher's flush and blit, the
+ * renderer's own `drawMesh` and the mesh batcher behind it — against a real
+ * pipeline cache on a mock device, and asserts the draw built nothing.
+ */
+
+const BODY = `
+struct Fx { uStrength : f32, };
+@group(3) @binding(0) var fx : Fx;
+fn apply(color : vec4f, uv : vec2f) -> vec4f {
+ return vec4f(color.rgb * fx.uStrength, color.a);
+}
+`;
+
+const MODEL = (() => {
+ const val = new Float32Array(16);
+ val[0] = val[5] = val[10] = val[15] = 1;
+ return { val };
+})();
+
+/** the renderer methods under test, lifted off the real prototype */
+const WARM_METHODS = [
+ "_warmRenderable",
+ "_warmPostEffects",
+ "_warmDraw",
+ "_warmHostedEffect",
+ "_warmMeshDraw",
+ "_warmAdvancedBlend",
+ "_warmToneMap",
+ "_sceneTargets",
+ "_canWarm",
+ "_meshBatcherFor",
+ "_effectFamily",
+ "_postEffectCapacity",
+ "_hostsMeshEffect",
+ "drawMesh",
+];
+
+/**
+ * The mock renderer, given a real pipeline cache and the renderer state the
+ * warm-up and `drawMesh` read.
+ * @param {object} [options] - `{hdr}` puts the camera's targets in half-float
+ * @returns {object} the renderer
+ */
+function makeRenderer(options = {}) {
+ const renderer = createMockWebGPURenderer();
+ const device = renderer.device;
+ let built = 0;
+ Object.assign(device, {
+ createShaderModule(descriptor) {
+ return { label: descriptor.label };
+ },
+ createPipelineLayout(descriptor) {
+ return descriptor;
+ },
+ createRenderPipeline(descriptor) {
+ built++;
+ return { descriptor };
+ },
+ });
+ renderer.pipelinesBuilt = () => {
+ return built;
+ };
+ renderer.pipelineCache = new WebGPUPipelineCache(device, "bgra8unorm");
+ for (const name of WARM_METHODS) {
+ renderer[name] = WebGPURenderer.prototype[name];
+ }
+ const quad = new WebGPUQuadBatcher(renderer);
+ const mesh = new WebGPUMeshBatcher(renderer);
+ Object.assign(renderer, {
+ canvasFormat: "bgra8unorm",
+ canvasSampleCount: 1,
+ preferredFormat: "bgra8unorm",
+ _colorFormat: options.hdr === true ? "rgba16float" : "bgra8unorm",
+ isContextValid: true,
+ supportsRetainedMesh: true,
+ supportsInstancing: true,
+ settings: { prewarm: true },
+ _fog3d: null,
+ batchers: new Map([
+ ["quad", quad],
+ ["mesh", mesh],
+ ]),
+ currentTint: new Color(255, 255, 255, 1),
+ _transparentFlushing: false,
+ _replayBlend: null,
+ meshDepthActive: true,
+ queued: [],
+ queueTransparent(...args) {
+ this.queued.push(args);
+ },
+ setBatcher(name) {
+ return this.batchers.get(name);
+ },
+ getCanvas() {
+ return { width: 320, height: 240 };
+ },
+ _postEffectPool() {
+ return { reserve() {} };
+ },
+ });
+ return { renderer, quad, mesh };
+}
+
+/** point the cache at a target, the way `setRenderTarget` does per pass */
+function bindTarget(renderer, format, sampleCount) {
+ renderer.pipelineCache.format = format;
+ renderer.pipelineCache.sampleCount = sampleCount;
+}
+
+/** let the warm-up queue run: it compiles a microtask after it is asked */
+async function settle() {
+ for (let i = 0; i < 10; i++) {
+ await Promise.resolve();
+ }
+}
+
+function makeEffect() {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const effect = new ShaderEffect({ shaderLanguage: "wgsl" }, { wgsl: BODY });
+ warn.mockRestore();
+ return effect;
+}
+
+const ATLAS = {
+ getTexture() {
+ return { width: 64, height: 32 };
+ },
+};
+
+function makeMesh(overrides = {}) {
+ return {
+ originalVertices: new Float32Array([
+ -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0,
+ ]),
+ uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
+ indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
+ _indicesOriginal: new Uint16Array([0, 1, 2, 0, 2, 3]),
+ _geometryVersion: 0,
+ vertexCount: 4,
+ texture: { id: "atlas" },
+ alphaCutoff: 0,
+ lit: false,
+ cullBackFaces: true,
+ rightHanded: false,
+ depthTest: true,
+ transparent: undefined,
+ blendMode: "normal",
+ postEffects: [],
+ ...overrides,
+ };
+}
+
+describe("warm-up keys match the draw path (WebGPU)", () => {
+ let renderer;
+ let quad;
+
+ beforeEach(() => {
+ ({ renderer, quad } = makeRenderer({ hdr: true }));
+ });
+
+ it("an effect alone on a sprite: the sprite's own draw", async () => {
+ const effect = makeEffect();
+ const sprite = { postEffects: [effect], blendMode: "additive" };
+ renderer._warmPostEffects(sprite);
+ await settle();
+ const warmed = renderer.pipelinesBuilt();
+
+ // drawn onto the canvas, and into the camera's half-float capture
+ // when the camera runs a chain of its own
+ for (const [format, samples] of [
+ ["bgra8unorm", 1],
+ ["rgba16float", 1],
+ ]) {
+ bindTarget(renderer, format, samples);
+ renderer.currentBlendMode = "additive";
+ renderer.customShader = effect;
+ quad.addQuad(ATLAS, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff);
+ quad.flush();
+ }
+ expect(renderer.pipelinesBuilt()).toBe(warmed);
+ });
+
+ it("a sprite chain: mid-chain into its ping-pong, last back onto the scene", async () => {
+ const first = makeEffect();
+ const second = makeEffect();
+ const sprite = { postEffects: [first, second], blendMode: "normal" };
+ renderer._warmPostEffects(sprite);
+ await settle();
+ const warmed = renderer.pipelinesBuilt();
+ const source = {
+ getMaterialBindGroup() {
+ return {};
+ },
+ };
+ // the order `endPostEffect` binds them in (pinned by the flow spec):
+ // effect 1 into the 8-bit ping-pong, blending off ...
+ bindTarget(renderer, "bgra8unorm", 1);
+ quad.blitTexture(source, 0, 0, 320, 240, first, false);
+ // ... and the last one back with the sprite's blend, onto the canvas
+ // or into the camera's capture
+ renderer.currentBlendMode = "normal";
+ for (const format of ["bgra8unorm", "rgba16float"]) {
+ bindTarget(renderer, format, 1);
+ quad.blitTexture(source, 0, 0, 320, 240, second, true);
+ }
+ expect(renderer.pipelinesBuilt()).toBe(warmed);
+ });
+
+ it("a camera chain: either effect mid-chain or last", async () => {
+ const bloom = makeEffect();
+ const flash = makeEffect();
+ const camera = {
+ postEffects: [bloom, flash],
+ _postEffectManaged: true,
+ blendMode: "normal",
+ };
+ renderer._warmPostEffects(camera);
+ await settle();
+ const warmed = renderer.pipelinesBuilt();
+ const source = {
+ getMaterialBindGroup() {
+ return {};
+ },
+ };
+ for (const effect of [bloom, flash]) {
+ // mid-chain: into the camera's half-float ping-pong
+ bindTarget(renderer, "rgba16float", 1);
+ quad.blitTexture(source, 0, 0, 320, 240, effect, false);
+ // last: onto the canvas
+ bindTarget(renderer, "bgra8unorm", 1);
+ quad.blitTexture(source, 0, 0, 320, 240, effect, false);
+ }
+ expect(renderer.pipelinesBuilt()).toBe(warmed);
+ });
+
+ it("an effect hosted by a transparent mesh: the transparent pass", async () => {
+ const effect = makeEffect();
+ const host = makeMesh({
+ postEffects: [effect],
+ transparent: true,
+ blendMode: "additive",
+ cullBackFaces: false,
+ });
+ renderer._warmPostEffects(host);
+ await settle();
+ const warmed = renderer.pipelinesBuilt();
+ expect(warmed).toBeGreaterThan(0);
+
+ for (const format of ["bgra8unorm", "rgba16float"]) {
+ bindTarget(renderer, format, 1);
+ // the replay `flushTransparentPass` runs: blend and shader restored
+ // from the queued entry
+ renderer._transparentFlushing = true;
+ renderer._replayBlend = "additive";
+ renderer.customShader = effect;
+ renderer.drawMesh(host, MODEL);
+ }
+ expect(renderer.pipelinesBuilt()).toBe(warmed);
+ });
+
+ it("an effect hosted by an opaque mesh: opaque now, blended once faded", async () => {
+ const effect = makeEffect();
+ // `transparent` left unset: opaque at full alpha, blended when faded
+ const host = makeMesh({ postEffects: [effect], cullBackFaces: true });
+ renderer._warmPostEffects(host);
+ await settle();
+ const warmed = renderer.pipelinesBuilt();
+
+ bindTarget(renderer, "rgba16float", 1);
+ renderer.customShader = effect;
+ renderer.drawMesh(host, MODEL);
+ expect(renderer.queued).toHaveLength(0);
+ renderer._transparentFlushing = true;
+ renderer._replayBlend = "normal";
+ renderer.drawMesh(host, MODEL);
+ expect(renderer.pipelinesBuilt()).toBe(warmed);
+ });
+
+ it("a mesh's own draw, without an effect", async () => {
+ const host = makeMesh({ cullBackFaces: true, depthTest: false });
+ renderer._warmDraw(host);
+ await settle();
+ const warmed = renderer.pipelinesBuilt();
+ expect(warmed).toBeGreaterThan(0);
+ bindTarget(renderer, "bgra8unorm", 1);
+ renderer.customShader = undefined;
+ renderer.drawMesh(host, MODEL);
+ expect(renderer.pipelinesBuilt()).toBe(warmed);
+ });
+
+ it("a sprite's own draw, with its blend mode", async () => {
+ const sprite = { blendMode: "multiply" };
+ renderer._warmDraw(sprite);
+ await settle();
+ const warmed = renderer.pipelinesBuilt();
+ for (const format of ["bgra8unorm", "rgba16float"]) {
+ bindTarget(renderer, format, 1);
+ renderer.customShader = undefined;
+ renderer.currentBlendMode = "multiply";
+ quad.addQuad(ATLAS, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff);
+ quad.flush();
+ }
+ expect(renderer.pipelinesBuilt()).toBe(warmed);
+ });
+
+ it("warms nothing for a container's own draw, or a camera's", async () => {
+ renderer._warmDraw({ children: [], blendMode: "normal" });
+ renderer._warmDraw({ _postEffectManaged: true, blendMode: "normal" });
+ await settle();
+ expect(renderer.pipelinesBuilt()).toBe(0);
+ });
+
+ it("follows a mesh's fog state into the key", async () => {
+ renderer._fog3d = {
+ color: [0, 0, 0],
+ mode: 1,
+ near: 0,
+ invRange: 1,
+ density: 0,
+ heightAxis: [0, 0, 0],
+ heightBase: 1,
+ };
+ const host = makeMesh();
+ renderer._warmDraw(host);
+ await settle();
+ const warmed = renderer.pipelinesBuilt();
+ bindTarget(renderer, "bgra8unorm", 1);
+ renderer.drawMesh(host, MODEL);
+ expect(renderer.pipelinesBuilt()).toBe(warmed);
+ });
+
+ it("the tone curve: last in the camera chain, onto the canvas", async () => {
+ renderer.shaderLanguage = "wgsl";
+ renderer._toneMapEffect = new ToneMappingEffect(renderer, { mode: "aces" });
+ renderer._warmToneMap();
+ await settle();
+ const warmed = renderer.pipelinesBuilt();
+ expect(warmed).toBe(1);
+ bindTarget(renderer, "bgra8unorm", 1);
+ quad.blitTexture(
+ {
+ getMaterialBindGroup() {
+ return {};
+ },
+ },
+ 0,
+ 0,
+ 320,
+ 240,
+ renderer._toneMapEffect,
+ false,
+ );
+ expect(renderer.pipelinesBuilt()).toBe(warmed);
+ });
+
+ it("only warms the canvas when the camera's targets share its format", async () => {
+ ({ renderer } = makeRenderer({ hdr: false }));
+ const effect = makeEffect();
+ renderer._warmPostEffects({ postEffects: [effect], blendMode: "normal" });
+ await settle();
+ // one target shape, so one pipeline
+ expect(renderer.pipelinesBuilt()).toBe(1);
+ });
+});