From 200eb810984a76f9897475c71f7b80ed413257bc Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Wed, 29 Jul 2026 07:13:20 +0800 Subject: [PATCH 1/2] feat(webgl)!: WebGL 2 only renderer + Vertex Array Objects (#1509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of the WebGPU groundwork: drop the WebGL 1 path and give every batcher an immutable vertex state, so the layout description stops being re-issued per draw and starts looking like a pipeline descriptor. BREAKING: the WebGL renderer now requires WebGL 2. - `video.AUTO` falls back to the Canvas renderer on WebGL-1-only devices; `video.WEBGL` throws there - `preferWebGL1` setting and the `#webgl1` URL flag removed - `device.isWebGLSupported()` probes for WebGL 2 — it now agrees with what renderer construction actually requests (it probed WebGL 1 before, so the gate and the context could disagree) - `renderer.type` is always "WebGL2"; `renderer.WebGLVersion` deprecated - corrections on ex-WebGL-1 configs: NPOT `repeat` genuinely tiles, darken/lighten use true MIN/MAX, `createPattern()` accepts NPOT - user shaders need NO changes: GLSL ES 1.00 compiles on WebGL 2 contexts Vertex Array Objects: - new `WebGLVertexState` (buffer/vertexstate.js, sibling of WebGLIndexBuffer) owns the VAO lifecycle; its binding save/restore is private, so no caller can half-apply the protocol - built from a {attributes, stride, buffer, indexBuffer} descriptor — a GPUVertexBufferLayout + arrayStride, so #1492 becomes a class swap - batcher switches cost one bindVertexArray; steady-state frames issue zero attribute-specification calls (19 at startup, 0 per frame after) - the attribute-leak machinery is gone: state is swapped, not disabled - custom shaders on a built-in batcher must declare that batcher's attributes first, in layout order — warns once per shader on mismatch - TMX GPU tilemap eligibility now uses `renderer.supportsShaderTileLayers` (a backend capability flag) instead of a WebGL-version check Tests: 6 new specs (vertex-state units, VAO state/call-counts/recreation/ contract/adversarial, teardown), the attribute-leak spec retired and its invariant re-expressed, 4888 passing. Closes #1509 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JvxaXQRgQn5AoR8DNkv6Wg --- packages/melonjs/CHANGELOG.md | 14 + packages/melonjs/package.json | 2 +- .../melonjs/src/application/application.ts | 20 +- .../application/defaultApplicationSettings.ts | 1 - packages/melonjs/src/application/settings.ts | 39 +- packages/melonjs/src/lang/deprecated.js | 2 +- packages/melonjs/src/level/tiled/TMXLayer.js | 14 +- packages/melonjs/src/system/device.ts | 9 +- packages/melonjs/src/video/renderer.js | 11 + .../video/rendertarget/canvasrendertarget.js | 37 +- .../video/rendertarget/webglrendertarget.js | 17 +- .../melonjs/src/video/utils/autodetect.js | 44 +- .../src/video/webgl/batchers/batcher.js | 236 +++++--- .../video/webgl/batchers/lit_quad_batcher.js | 2 +- .../video/webgl/batchers/material_batcher.js | 98 ++-- .../src/video/webgl/batchers/quad_batcher.js | 57 +- .../melonjs/src/video/webgl/buffer/index.js | 2 +- .../src/video/webgl/buffer/vertexstate.js | 168 ++++++ packages/melonjs/src/video/webgl/glshader.js | 10 +- .../src/video/webgl/lighting/constants.ts | 2 +- .../melonjs/src/video/webgl/shadereffect.js | 22 +- .../video/webgl/shaders/multitexture-lit.js | 2 +- .../melonjs/src/video/webgl/webgl_renderer.js | 72 +-- .../tests/batcher_attribute_leak.spec.js | 523 ------------------ packages/melonjs/tests/lights.spec.js | 10 +- packages/melonjs/tests/sprite3d_webgl.spec.js | 2 +- .../melonjs/tests/texture-resource.spec.js | 2 +- packages/melonjs/tests/texture.spec.js | 13 + .../melonjs/tests/tmxlayer-shader.spec.js | 2 +- .../melonjs/tests/webgl_batcher_state.spec.js | 23 + .../melonjs/tests/webgl_mesh_anchor.spec.js | 2 +- .../melonjs/tests/webgl_mesh_depth.spec.js | 2 +- .../tests/webgl_pipeline_adversarial.spec.js | 40 +- .../tests/webgl_required_check.spec.js | 33 +- .../tests/webgl_vao_adversarial.spec.js | 411 ++++++++++++++ .../tests/webgl_vao_call_counts.spec.js | 111 ++++ .../melonjs/tests/webgl_vao_contract.spec.js | 149 +++++ .../tests/webgl_vao_recreation.spec.js | 175 ++++++ .../melonjs/tests/webgl_vao_state.spec.js | 132 +++++ .../melonjs/tests/webgl_vao_teardown.spec.js | 89 +++ .../melonjs/tests/webgl_vertexstate.spec.js | 317 +++++++++++ .../melonjs/tests/webglrendertarget.spec.js | 23 +- 42 files changed, 2055 insertions(+), 885 deletions(-) create mode 100644 packages/melonjs/src/video/webgl/buffer/vertexstate.js delete mode 100644 packages/melonjs/tests/batcher_attribute_leak.spec.js create mode 100644 packages/melonjs/tests/webgl_vao_adversarial.spec.js create mode 100644 packages/melonjs/tests/webgl_vao_call_counts.spec.js create mode 100644 packages/melonjs/tests/webgl_vao_contract.spec.js create mode 100644 packages/melonjs/tests/webgl_vao_recreation.spec.js create mode 100644 packages/melonjs/tests/webgl_vao_state.spec.js create mode 100644 packages/melonjs/tests/webgl_vao_teardown.spec.js create mode 100644 packages/melonjs/tests/webgl_vertexstate.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 8b9c06d1dd..c7ee32944f 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [20.0.0] (melonJS 2) - _unreleased_ + +### Changed (breaking) +- **The WebGL renderer is now WebGL 2 only** ([#1509](https://github.com/melonjs/melonJS/issues/1509)) — the WebGL 1 fallback path is removed. `renderer: video.AUTO` falls back to the Canvas renderer on WebGL-1-only devices; `renderer: video.WEBGL` throws there. **User shaders need no changes** — `ShaderEffect` bodies and raw `GLShader` sources (GLSL ES 1.00) compile unchanged on WebGL 2 contexts. +- `preferWebGL1` setting and the `#webgl1` URI flag are removed (`#webgl` / `#webgl2` are synonyms) +- `device.isWebGLSupported()` now probes for a WebGL **2** context — it finally agrees with what renderer construction actually requests (the gate probed WebGL 1 before, so the two could disagree) +- `renderer.type` is always `"WebGL2"` for the WebGL renderer; `renderer.WebGLVersion` is deprecated (always `2`) +- behavior corrections on ex-WebGL-1 configs: `repeat` wrap now genuinely tiles non-power-of-two textures (was clamp + warning), `"darken"` / `"lighten"` blend modes use true MIN/MAX equations (were silently downgraded to `"normal"`), and `createPattern()` accepts non-power-of-two sources (threw before) +- TMX GPU tilemap eligibility is now advertised through `renderer.supportsShaderTileLayers` (a backend capability flag) instead of a WebGL-version check +- **each `Batcher` now owns an immutable Vertex Array Object** built at init ([#1509](https://github.com/melonjs/melonJS/issues/1509)): vertex attribute layout is frozen once built, batcher switches cost a single `bindVertexArray` (steady-state frames issue zero attribute-specification calls, measured), and `Batcher.unbind()` no longer disables attribute arrays. Custom batchers inheriting `Batcher.init()`/`bind()` need no changes. Custom shaders hosted by a built-in batcher must declare that batcher's attributes first, in layout order (ShaderEffect-generated vertex shaders already comply) — a console warning fires on mismatch. `GLShader.setVertexAttributes` is no longer called by the engine (still public) + +### Performance +- **Vertex Array Objects for every batcher** ([#1509](https://github.com/melonjs/melonJS/issues/1509)) — vertex attribute layout is specified once at init rather than on every batcher switch and every mesh flush, so steady-state frames issue **zero** attribute-specification calls. How much GL traffic this saves depends on how often a scene alternates batchers: a scene that stays on one batcher saves about 2 calls per frame, one mixing sprites, meshes and primitives about 40 — in both cases well under a millisecond. The structural benefit is the larger one: attribute-state leaks between batchers become impossible by construction. + ## [19.9.1] (melonJS 2) - _2026-07-28_ ### Fixed diff --git a/packages/melonjs/package.json b/packages/melonjs/package.json index 7d45f96e02..95f8c366f7 100644 --- a/packages/melonjs/package.json +++ b/packages/melonjs/package.json @@ -1,6 +1,6 @@ { "name": "melonjs", - "version": "19.9.1", + "version": "20.0.0", "description": "melonJS Game Engine", "homepage": "http://www.melonjs.org/", "type": "module", diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts index d97f8a16ac..8d8bd8bd34 100644 --- a/packages/melonjs/src/application/application.ts +++ b/packages/melonjs/src/application/application.ts @@ -316,16 +316,11 @@ export default class Application { } as ResolvedApplicationSettings; // override renderer settings if &webgl or &canvas is defined in the URL + // (the WebGL renderer is WebGL 2 only since 20.0 — the legacy + // `#webgl1` flag is gone, `#webgl`/`#webgl2` are synonyms) const uriFragment = getUriFragment(); - if ( - uriFragment.webgl === true || - uriFragment.webgl1 === true || - uriFragment.webgl2 === true - ) { + if (uriFragment.webgl === true || uriFragment.webgl2 === true) { settings.renderer = WEBGL; - if (uriFragment.webgl1 === true) { - settings.preferWebGL1 = true; - } } else if (uriFragment.canvas === true) { settings.renderer = CANVAS; } @@ -381,10 +376,11 @@ export default class Application { if (!device.isWebGLSupported(this.settings)) { throw new Error( "Application: `renderer: video.WEBGL` was requested " + - "but the WebGL context could not be created in this " + - "environment (driver-blocklisted GPU, " + - "`failIfMajorPerformanceCaveat: true` on a software " + - "renderer, etc.). Use `video.AUTO` if Canvas " + + "but a WebGL 2 context could not be created in this " + + "environment (WebGL-1-only device, driver-blocklisted " + + "GPU, `failIfMajorPerformanceCaveat: true` on a " + + "software renderer, etc.). The WebGL renderer is " + + "WebGL 2 only since 20.0. Use `video.AUTO` if Canvas " + "fallback is acceptable, or check " + "`device.isWebGLSupported()` before construction.", ); diff --git a/packages/melonjs/src/application/defaultApplicationSettings.ts b/packages/melonjs/src/application/defaultApplicationSettings.ts index 11fa68e07d..7990ce201b 100644 --- a/packages/melonjs/src/application/defaultApplicationSettings.ts +++ b/packages/melonjs/src/application/defaultApplicationSettings.ts @@ -6,7 +6,6 @@ export const defaultApplicationSettings = { renderer: AUTO, scale: 1.0, scaleMethod: ScaleMethods.Manual, - preferWebGL1: false, powerPreference: "default", transparent: false, antiAlias: false, diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts index 6a17a13db7..a905b8cc79 100644 --- a/packages/melonjs/src/application/settings.ts +++ b/packages/melonjs/src/application/settings.ts @@ -32,7 +32,7 @@ type PhysicsType = | PhysicsAdapter | { adapter: PhysicsAdapter }; -type PowerPreference = "default" | "low-power"; +type PowerPreference = "default" | "low-power" | "high-performance"; export type ApplicationSettings = { /** @@ -40,12 +40,13 @@ export type ApplicationSettings = { * * - {@link CANVAS} — HTML5 Canvas backend. No shader / mesh / * Camera3d support. - * - {@link WEBGL} — **requires WebGL**. Throws at `new Application(...)` - * time if WebGL is unavailable (driver-blocklisted GPU, perf-caveat - * failure, etc.). Use this when your scene needs Camera3d, Mesh, - * ShaderEffect, Light2d or GPU tilemap — you'd rather fail fast - * than render a stuck blank canvas. - * - {@link AUTO} — try WebGL, silently fall back to Canvas if + * - {@link WEBGL} — **requires WebGL 2** (the WebGL renderer is + * WebGL 2 only since 20.0). Throws at `new Application(...)` + * time if a WebGL 2 context is unavailable (WebGL-1-only device, + * driver-blocklisted GPU, perf-caveat failure, etc.). Use this when + * your scene needs Camera3d, Mesh, ShaderEffect, Light2d or GPU + * tilemap — you'd rather fail fast than render a stuck blank canvas. + * - {@link AUTO} — try WebGL 2, silently fall back to Canvas if * unavailable. Application construction always succeeds. The * WebGL-only subsystems (Camera3d, Mesh, ShaderEffect, Light2d, * GPU tilemap) silently stop working under the Canvas fallback — @@ -74,14 +75,20 @@ export type ApplicationSettings = { scaleTarget: HTMLElement; /** - * if true the renderer will only use WebGL 1 - * @default false - */ - preferWebGL1: boolean; - - /** - * a hint to the user agent indicating what configuration of GPU is suitable for the WebGL context. To be noted that Safari and Chrome (since version 80) both default to "low-power" to save battery life and improve the user experience on these dual-GPU machines. - * @default default + * A hint to the user agent about which GPU to use on multi-GPU systems + * (discrete vs integrated). Browsers generally favour the low-power GPU + * unless asked otherwise, to preserve battery life. + * + * - `"default"` — no hint; let the user agent decide. + * - `"low-power"` — prefer the integrated GPU. + * - `"high-performance"` — prefer the discrete GPU. Note that browsers + * only honour this for pages that handle context loss, since switching + * GPU can drop the context; melonJS registers those handlers itself, + * so the request is respected. + * + * The same hint (and the same values, minus `"default"`) is used by + * WebGPU's adapter request, so this setting is backend-neutral. + * @default "default" */ powerPreference: PowerPreference; @@ -166,7 +173,7 @@ export type ApplicationSettings = { * When `true` (default), eligible layers render via a single quad per * tileset + a fragment shader doing per-fragment GID lookup, bypassing * the per-tile draw loop entirely. Layers that don't qualify - * (Canvas/WebGL1, non-orthogonal, collection-of-image tilesets, + * (Canvas renderer, non-orthogonal, collection-of-image tilesets, * tilerendersize "grid", non-zero tileoffset, oversampled beyond the * shader's overflow window) fall back to the legacy path automatically. * Set to `false` to disable globally. diff --git a/packages/melonjs/src/lang/deprecated.js b/packages/melonjs/src/lang/deprecated.js index 89c7ac5d78..2eb6c49a12 100644 --- a/packages/melonjs/src/lang/deprecated.js +++ b/packages/melonjs/src/lang/deprecated.js @@ -21,7 +21,7 @@ export class CanvasTexture extends CanvasRenderTarget { * @param {number} width - the desired width of the canvas * @param {number} height - the desired height of the canvas * @param {object} attributes - The attributes to create both the canvas and context - * @param {boolean} [attributes.context="2d"] - the context type to be created ("2d", "webgl", "webgl2") + * @param {boolean} [attributes.context="2d"] - the context type to be created ("2d", "webgl" — creates a WebGL 2 context) * @param {boolean} [attributes.offscreenCanvas=false] - will create an offscreenCanvas if true instead of a standard canvas * @param {boolean} [attributes.willReadFrequently=false] - Indicates whether or not a lot of read-back operations are planned * @param {boolean} [attributes.antiAlias=false] - Whether to enable anti-aliasing, use false (default) for a pixelated effect. diff --git a/packages/melonjs/src/level/tiled/TMXLayer.js b/packages/melonjs/src/level/tiled/TMXLayer.js index 4b76031565..ce4d3f6fea 100644 --- a/packages/melonjs/src/level/tiled/TMXLayer.js +++ b/packages/melonjs/src/level/tiled/TMXLayer.js @@ -22,7 +22,7 @@ const FLIP_AD_BIT = 1 << 2; // the fallback so apps without any tilemap stay quiet (the prior heads-up // at Application init time fired regardless of whether a TMX layer was // ever loaded). -let _warnedNoWebGL2Once = false; +let _warnedNoGpuTileSupportOnce = false; /** * extract a 3-bit flip mask from a raw 32-bit GID (Tiled's flip bits live in @@ -409,11 +409,11 @@ export default class TMXLayer extends Renderable { // — emitted from the first TMX layer that hits it, so apps with // no tilemap loaded stay quiet. if (gpuAllowed) { - if (elig.reason === "no-webgl2-renderer") { - if (!_warnedNoWebGL2Once) { - _warnedNoWebGL2Once = true; + if (elig.reason === "no-gpu-renderer") { + if (!_warnedNoGpuTileSupportOnce) { + _warnedNoGpuTileSupportOnce = true; console.warn( - "melonJS: gpuTilemap is enabled but the active renderer is not WebGL 2 — falling back to the legacy tile renderer for every tile layer", + "melonJS: gpuTilemap is enabled but the active renderer has no GPU tile-layer support — falling back to the legacy tile renderer for every tile layer", ); } } else { @@ -440,8 +440,8 @@ export default class TMXLayer extends Renderable { if (!gpuAllowed) { return { ok: false, reason: "gpuTilemap disabled" }; } - if (!renderer || renderer.WebGLVersion !== 2) { - return { ok: false, reason: "no-webgl2-renderer" }; + if (!renderer || renderer.supportsShaderTileLayers !== true) { + return { ok: false, reason: "no-gpu-renderer" }; } if (this.orientation !== "orthogonal") { return { diff --git a/packages/melonjs/src/system/device.ts b/packages/melonjs/src/system/device.ts index 03211f4fbb..8937de084e 100644 --- a/packages/melonjs/src/system/device.ts +++ b/packages/melonjs/src/system/device.ts @@ -655,10 +655,13 @@ export function isWebGLSupported(options?: { failIfMajorPerformanceCaveat: options.failIfMajorPerformanceCaveat, }), }; + // WebGL 2 only since 20.0 (#1509) — this gate must agree with + // the context actually created by the WebGL renderer (which + // requests "webgl2"), so AUTO falls back to Canvas exactly when + // renderer construction would fail const _supported = !!( - globalThis.WebGLRenderingContext && - (canvas.getContext("webgl", ctxOptions) || - canvas.getContext("experimental-webgl", ctxOptions)) + globalThis.WebGL2RenderingContext && + canvas.getContext("webgl2", ctxOptions) ); WebGLSupport = _supported ? 1 : 0; } catch { diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index b988ac50e7..cec17259d5 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -110,6 +110,17 @@ export default class Renderer { */ this.type = "Generic"; + /** + * Whether this renderer backend can draw TMX tile layers through a + * GPU shader path (see the `gpuTilemap` application setting). + * `false` here on the base/Canvas renderer; GPU backends flip it. + * A capability flag rather than a version/class check so future + * backends advertise support without consumers growing type checks. + * @type {boolean} + * @default false + */ + this.supportsShaderTileLayers = false; + /** * The background color used to clear the main framebuffer. * Note: alpha value will be set based on the transparent property of the renderer settings. diff --git a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js index fa35eb28e4..e911775b5b 100644 --- a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js +++ b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js @@ -20,13 +20,9 @@ const defaultAttributes = { stencil: true, blendMode: "normal", failIfMajorPerformanceCaveat: true, - preferWebGL1: false, powerPreference: "default", }; -// WebGL version (if a gl context is created) -let WebGLVersion; - // a helper function to create the 2d/webgl context function createContext(canvas, attributes) { let context; @@ -50,24 +46,13 @@ function createContext(canvas, attributes) { failIfMajorPerformanceCaveat: attributes.failIfMajorPerformanceCaveat, }; - // attempt to create a WebGL2 context unless not requested - if (attributes.preferWebGL1 !== true) { - context = canvas.getContext("webgl2", attr); - if (context) { - WebGLVersion = 2; - } - } + // WebGL 2 only since 20.0 (#1509) — no WebGL 1 fallback; callers + // (autoDetectRenderer / Application) fall back to the Canvas + // renderer instead when this throws + context = canvas.getContext("webgl2", attr); - // fallback to WebGL1 if (!context) { - WebGLVersion = 1; - context = - canvas.getContext("webgl", attr) || - canvas.getContext("experimental-webgl", attr); - } - - if (!context) { - throw new Error("A WebGL context could not be created."); + throw new Error("A WebGL 2 context could not be created."); } } else { throw new Error("Invalid context type. Must be one of '2d' or 'webgl'"); @@ -89,8 +74,7 @@ class CanvasRenderTarget extends RenderTarget { * @param {number} width - the desired width of the canvas * @param {number} height - the desired height of the canvas * @param {object} attributes - The attributes to create both the canvas and context - * @param {string} [attributes.context="2d"] - the context type to be created ("2d", "webgl") - * @param {boolean} [attributes.preferWebGL1=false] - set to true for force using WebGL1 instead of WebGL2 (if supported) + * @param {string} [attributes.context="2d"] - the context type to be created ("2d", "webgl" — a "webgl" request creates a WebGL 2 context) * @param {boolean} [attributes.transparent=false] - specify if the canvas contains an alpha channel * @param {boolean} [attributes.offscreenCanvas=false] - will create an offscreenCanvas if true instead of a standard canvas * @param {boolean} [attributes.willReadFrequently=false] - Indicates whether or not a lot of read-back operations are planned @@ -120,7 +104,14 @@ class CanvasRenderTarget extends RenderTarget { // create the context this.context = createContext(this.canvas, this.attributes); - this.WebGLVersion = WebGLVersion; + if (this.attributes.context === "webgl") { + /** + * The WebGL version of the created context. + * @type {number} + * @deprecated since 20.0.0 — the WebGL renderer is WebGL 2 only, this is always `2` + */ + this.WebGLVersion = 2; + } // enable or disable antiAlias if specified this.setAntiAlias(this.attributes.antiAlias); diff --git a/packages/melonjs/src/video/rendertarget/webglrendertarget.js b/packages/melonjs/src/video/rendertarget/webglrendertarget.js index b6cc5e9db0..dd645be48c 100644 --- a/packages/melonjs/src/video/rendertarget/webglrendertarget.js +++ b/packages/melonjs/src/video/rendertarget/webglrendertarget.js @@ -1,15 +1,5 @@ import RenderTarget from "./rendertarget.ts"; -// `DEPTH_STENCIL` (0x84F9) and `DEPTH_STENCIL_ATTACHMENT` (0x821A) are core -// WebGL 1.0 constants per the spec — no extension required. They're exposed -// natively on WebGL2 contexts and on most WebGL1 implementations, but a few -// browsers/drivers leave one or both as `undefined` on the gl context. -// Passing `undefined` to `renderbufferStorage` / `framebufferRenderbuffer` -// silently produces an incomplete framebuffer (no error), so we fall back -// to the spec-defined numeric values when the context lookup is missing. -const DEPTH_STENCIL = 0x84f9; -const DEPTH_STENCIL_ATTACHMENT = 0x821a; - /** * Try to attach a depth+stencil (preferred) or depth-only renderbuffer to * the currently-bound framebuffer, validating completeness at each step. @@ -21,9 +11,10 @@ const DEPTH_STENCIL_ATTACHMENT = 0x821a; * @ignore */ function attachDepthStencil(gl, framebuffer, renderbuffer, width, height) { - const depthStencil = gl.DEPTH_STENCIL ?? DEPTH_STENCIL; - const depthStencilAttachment = - gl.DEPTH_STENCIL_ATTACHMENT ?? DEPTH_STENCIL_ATTACHMENT; + // core WebGL 2 constants — always present on the context (the WebGL 1 + // driver-gap numeric fallbacks were removed with WebGL 1 support) + const depthStencil = gl.DEPTH_STENCIL; + const depthStencilAttachment = gl.DEPTH_STENCIL_ATTACHMENT; // preferred path: packed depth + stencil so masking works gl.bindRenderbuffer(gl.RENDERBUFFER, renderbuffer); diff --git a/packages/melonjs/src/video/utils/autodetect.js b/packages/melonjs/src/video/utils/autodetect.js index 04bacaa95b..85ea7d1b83 100644 --- a/packages/melonjs/src/video/utils/autodetect.js +++ b/packages/melonjs/src/video/utils/autodetect.js @@ -2,17 +2,49 @@ import { isWebGLSupported } from "../../system/device"; import CanvasRenderer from "../canvas/canvas_renderer"; import WebGLRenderer from "../webgl/webgl_renderer"; +// Ordered backend candidates, walked front-to-back: the first one whose +// support probe passes and whose construction succeeds wins. Kept as a +// list (rather than an if-chain) so a future backend — e.g. WebGPU — +// prepends a single entry without reshaping the negotiation. +const BACKEND_CANDIDATES = [ + { + name: "webgl2", + isSupported: (options) => { + return isWebGLSupported(options); + }, + create: (options) => { + return new WebGLRenderer(options); + }, + }, + { + // Canvas is the terminal fallback and always supported + name: "canvas", + isSupported: () => { + return true; + }, + create: (options) => { + return new CanvasRenderer(options); + }, + }, +]; + /** * Auto-detect the best renderer to use * @ignore */ export function autoDetectRenderer(options) { - try { - if (isWebGLSupported(options)) { - return new WebGLRenderer(options); + let lastError; + for (const backend of BACKEND_CANDIDATES) { + try { + if (backend.isSupported(options)) { + return backend.create(options); + } + } catch (e) { + lastError = e; + console.log("Error creating " + backend.name + " renderer: " + e.message); } - } catch (e) { - console.log("Error creating WebGL renderer :" + e.message); } - return new CanvasRenderer(options); + // every candidate failed — surface the terminal one's cause rather than + // swallowing it behind a generic message + throw new Error("No usable renderer backend found", { cause: lastError }); } diff --git a/packages/melonjs/src/video/webgl/batchers/batcher.js b/packages/melonjs/src/video/webgl/batchers/batcher.js index 42cf1c4783..fa620e897b 100644 --- a/packages/melonjs/src/video/webgl/batchers/batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/batcher.js @@ -1,5 +1,6 @@ import VertexArrayBuffer from "../../buffer/vertex.js"; import WebGLIndexBuffer from "../buffer/index.js"; +import WebGLVertexState from "../buffer/vertexstate.js"; import GLShader from "../glshader.js"; /** @@ -12,7 +13,8 @@ import GLShader from "../glshader.js"; * Default maximum number of vertices per batch. * At 4096 vertices (1024 quads), the vertex buffer is ~80 KB (5 floats × 4 bytes × 4096), * which balances draw call reduction with safe buffer upload sizes on mobile tile-based GPUs. - * Within the Uint16 index limit (65,535) required for WebGL1 compatibility. + * Within the Uint16 index limit (65,535) — a deliberate capacity choice + * (smaller index uploads), not an API constraint. * @ignore */ const DEFAULT_MAX_VERTICES = 4096; @@ -76,6 +78,14 @@ export class Batcher { */ this.mode = this.gl.TRIANGLES; + // re-init (context restore runs init() on the existing instance): + // drop the previous life's vertex state, which also unfreezes the + // layout so the attribute definitions below can be rebuilt + if (this.vertexState) { + this.vertexState.destroy(); + this.vertexState = null; + } + /** * an array of vertex attribute properties * @see Batcher.addAttribute @@ -172,6 +182,81 @@ export class Batcher { // max indices: worst case is 3 indices per vertex (all triangles, no sharing) this.indexBuffer = new WebGLIndexBuffer(gl, maxVertices * 3, false); } + + // build the frozen vertex-state object (VAO) once all buffers exist; + // quad-family batchers create their static index buffer AFTER this + // (in their own init) and capture it via the wrap in + // `createIndexBuffer` + this.createVertexState(); + } + + /** + * The GL buffer this batcher uploads its vertex data into: its own + * buffer for indexed (mesh-family) batchers, otherwise the renderer's + * shared one. + * @type {WebGLBuffer} + * @ignore + */ + get uploadBuffer() { + return this.glVertexBuffer ?? this.renderer.vertexBuffer; + } + + /** + * (Re)build this batcher's {@link WebGLVertexState} — the frozen vertex + * buffer layout (`attributes` + `stride`) realized as a GL vertex array + * object bound to this batcher's upload buffer and, for indexed + * batchers, its index buffer. + * + * Every shader hosted by this batcher must declare a prefix of that + * layout, in layout order (see {@link validateShaderLocations}) — the + * locations are frozen here at the default shader's mapping. + * @ignore + */ + createVertexState() { + const descriptor = { + attributes: this.attributes, + stride: this.stride, + buffer: this.uploadBuffer, + indexBuffer: this.useIndexBuffer ? this.indexBuffer : undefined, + resolveLocation: (name) => { + return this.defaultShader.getAttribLocation(name); + }, + }; + if (this.vertexState) { + // keep the object identity across rebuilds; only the GL handle + // and the buffers it references change + this.vertexState.build(descriptor); + } else { + this.vertexState = new WebGLVertexState(this.gl, descriptor); + } + } + + /** + * Release every GL object this batcher owns (vertex state, own vertex + * and index buffers, default shader). Called by + * {@link WebGLRenderer#destroy}; subclasses that override must chain to + * `super.destroy()`. + * @ignore + */ + destroy() { + const gl = this.gl; + if (this.vertexState) { + this.vertexState.destroy(); + this.vertexState = null; + } + if (this.glVertexBuffer) { + gl.deleteBuffer(this.glVertexBuffer); + this.glVertexBuffer = null; + } + if (this.indexBuffer) { + this.indexBuffer.destroy(); + this.indexBuffer = null; + } + if (this.defaultShader) { + this.defaultShader.destroy(); + this.defaultShader = undefined; + } + this.currentShader = undefined; } /** @@ -197,6 +282,10 @@ export class Batcher { gl.deleteBuffer(this.glVertexBuffer); this.glVertexBuffer = gl.createBuffer(); this.indexBuffer.recreate(); + // the vertex state still references the DELETED buffers — a VAO + // keeps deleted attachments alive per the GL spec and draws from + // them (stale/dead data). Rebuild against the new buffers. + this.createVertexState(); } } @@ -204,13 +293,15 @@ export class Batcher { * called by the WebGL renderer when a batcher becomes the current one */ bind() { - if (this.useIndexBuffer) { - const gl = this.gl; - gl.bindBuffer(gl.ARRAY_BUFFER, this.glVertexBuffer); - if (this.indexBuffer) { - this.indexBuffer.bind(); - } - } + const gl = this.gl; + // one bind restores the whole frozen vertex state (attribute + // pointers + element buffer) — the WebGL analogue of setPipeline + + // setVertexBuffer + setIndexBuffer + this.vertexState.bind(); + // ARRAY_BUFFER binding is NOT VAO state; uploads in flush() need the + // batcher's upload target bound (this also preserves the invariant + // custom batchers with hand-rolled flush() relied on) + gl.bindBuffer(gl.ARRAY_BUFFER, this.uploadBuffer); if (this.renderer.currentProgram !== this.defaultShader.program) { this.useShader(this.defaultShader); @@ -218,23 +309,50 @@ export class Batcher { } /** - * called by the WebGL renderer when this batcher is being replaced by another. - * Disables this batcher's vertex attribute locations so they don't leak across - * (otherwise stale stride/offset state can cause INVALID_OPERATION on the next draw). + * called by the WebGL renderer when this batcher is being replaced by + * another. Attribute state no longer needs disabling — it lives in this + * batcher's vertex-state object and the incoming batcher's `bind()` + * replaces the binding wholesale. Kept as a lifecycle hook: subclasses + * override it to restore mode-specific GL state (see MeshBatcher's + * blend/depth restore). + */ + unbind() {} + + /** + * Validate (once per shader) that a hosted shader's attribute locations + * match this batcher's frozen vertex state. The engine's analogue of + * WebGPU's pipeline-creation validation: locations are bound in vertex + * source declaration order, so every shader hosted by this batcher must + * declare a prefix of the batcher's attribute layout, in layout order. + * A mismatched shader silently reads wrong vertex data — warn loudly. + * @ignore */ - unbind() { - if (this.currentShader === undefined) { + validateShaderLocations(shader) { + if (this.validatedShaders === undefined) { + this.validatedShaders = new WeakSet(); + } + if (shader === this.defaultShader || this.validatedShaders.has(shader)) { return; } - const gl = this.gl; - for (let i = 0; i < this.attributes.length; ++i) { - const location = this.currentShader.getAttribLocation( - this.attributes[i].name, - ); - if (location !== -1) { - gl.disableVertexAttribArray(location); + this.validatedShaders.add(shader); + const mismatches = []; + for (const attr of this.attributes) { + const expected = this.defaultShader.getAttribLocation(attr.name); + const actual = shader.getAttribLocation(attr.name); + // -1 = the shader doesn't declare this attribute — allowed (the + // enabled array is simply unconsumed); a DIFFERENT location is + // the contract violation + if (actual !== -1 && actual !== expected) { + mismatches.push(`"${attr.name}" at ${actual} (expected ${expected})`); } } + if (mismatches.length > 0) { + // one consolidated warning per (batcher, shader) pair + console.warn( + `melonJS: shader attribute location mismatch: ${mismatches.join(", ")} — ` + + "custom shaders must declare the batcher's attributes first, in layout order (vertex data will be read incorrectly)", + ); + } } /** @@ -248,19 +366,12 @@ export class Batcher { this.renderer.currentProgram !== shader.program ) { this.flush(); - // Disable the previous shader's enabled attribute locations - // before binding the new one. The two shaders may have linked - // the same attribute name to different locations, so the new - // shader's `setVertexAttributes` won't necessarily re-point - // every old location — leaving stale stride/offset state that - // can trigger `INVALID_OPERATION` on the next draw call. - if (this.currentShader && this.currentShader !== shader) { - this.unbind(); - } shader.bind(); shader.setUniform(this.projectionUniform, this.renderer.projectionMatrix); - shader.setVertexAttributes(this.gl, this.attributes, this.stride); + // attribute pointers live in the frozen vertex state — hosted + // shaders must conform to it (checked once per shader) + this.validateShaderLocations(shader); this.currentShader = shader; this.renderer.currentProgram = shader.program; @@ -281,6 +392,14 @@ export class Batcher { * @param {number} offset - offset in bytes of the first component in the vertex attribute array */ addAttribute(name, size, type, normalized, offset) { + if (this.vertexState) { + // the layout is baked into the vertex-state object at init — + // immutable afterwards, exactly like a vertex layout in a + // compiled render pipeline + throw new Error( + "Batcher.addAttribute: the vertex buffer layout is frozen once the vertex state is built (add attributes before/without calling init)", + ); + } this.attributes.push({ name, size, type, normalized, offset }); switch (type) { @@ -351,33 +470,22 @@ export class Batcher { vertexCount * vertexSize * Float32Array.BYTES_PER_ELEMENT; if (this.useIndexBuffer && this.indexBuffer.length > 0) { - // indexed drawing path — bind own buffers + // indexed drawing path — attribute pointers live in the + // vertex state; only the upload target needs (re)binding + // (belt-and-braces: bind() already did this, but a custom + // flush caller may not have gone through bind()) gl.bindBuffer(gl.ARRAY_BUFFER, this.glVertexBuffer); - // re-apply vertex attributes - this.currentShader.setVertexAttributes( - gl, - this.attributes, - this.stride, + // upload vertex data (WebGL 2 srcOffset/length overload — no + // subview copy needed) + gl.bufferData( + gl.ARRAY_BUFFER, + vertex.toUint8(), + gl.STREAM_DRAW, + 0, + byteLength, ); - // upload vertex data - if (this.renderer.WebGLVersion > 1) { - gl.bufferData( - gl.ARRAY_BUFFER, - vertex.toUint8(), - gl.STREAM_DRAW, - 0, - byteLength, - ); - } else { - gl.bufferData( - gl.ARRAY_BUFFER, - vertex.toUint8(0, byteLength), - gl.STREAM_DRAW, - ); - } - // upload and draw with index buffer this.indexBuffer.upload(); gl.drawElements( @@ -391,21 +499,13 @@ export class Batcher { this.indexBuffer.clear(); } else { // non-indexed drawing path (original behavior) - if (this.renderer.WebGLVersion > 1) { - gl.bufferData( - gl.ARRAY_BUFFER, - vertex.toUint8(), - gl.STREAM_DRAW, - 0, - byteLength, - ); - } else { - gl.bufferData( - gl.ARRAY_BUFFER, - vertex.toUint8(0, byteLength), - gl.STREAM_DRAW, - ); - } + gl.bufferData( + gl.ARRAY_BUFFER, + vertex.toUint8(), + gl.STREAM_DRAW, + 0, + byteLength, + ); gl.drawArrays(mode, 0, vertexCount); } diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index 55c36168bd..71a96ebf7d 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -31,7 +31,7 @@ export default class LitQuadBatcher extends QuadBatcher { */ init(renderer) { // halve the texture cap: each color slot is paired with a normal - // slot at offset `+ maxBatchTextures` so the WebGL1 8-unit minimum + // slot at offset `+ maxBatchTextures` so the historical 8-unit budget // still affords at least 4 lit sprites per batch. const halved = Math.min( Math.max(1, Math.floor(renderer.maxTextures / 2)), diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index 2cbfe6490b..f1d6bcc878 100644 --- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js @@ -1,4 +1,3 @@ -import { isPowerOfTwo } from "../../../math/math.ts"; import { GPU_TEXTURE_CACHE_RESET, off, on } from "../../../system/event.ts"; import { Batcher } from "./batcher.js"; @@ -105,6 +104,7 @@ export class MaterialBatcher extends Batcher { off(GPU_TEXTURE_CACHE_RESET, this._onCacheReset); this._onCacheReset = null; } + super.destroy(); } /** @@ -149,34 +149,12 @@ export class MaterialBatcher extends Batcher { flush = true, ) { const gl = this.gl; - const isPOT = isPowerOfTwo(w) && isPowerOfTwo(h); - const wantsRepeat = repeat !== "no-repeat"; - const canRepeat = isPOT || this.renderer.WebGLVersion > 1; + // WebGL 2: REPEAT wrap and mipmaps work on NPOT textures — no + // power-of-two gating (the WebGL 1 clamp-downgrade path is gone) const rs = - repeat.search(/^repeat(-x)?$/) === 0 && canRepeat - ? gl.REPEAT - : gl.CLAMP_TO_EDGE; + repeat.search(/^repeat(-x)?$/) === 0 ? gl.REPEAT : gl.CLAMP_TO_EDGE; const rt = - repeat.search(/^repeat(-y)?$/) === 0 && canRepeat - ? gl.REPEAT - : gl.CLAMP_TO_EDGE; - - // Warn (only when actually downgrading) — the caller asked for tiling - // but we have to clamp because WebGL 1 does not allow `REPEAT` on - // non-power-of-two textures. Their `repeat: "repeat*"` setting will - // have no visible effect. Either resize the source to POT or run on - // a WebGL 2 context. - if (wantsRepeat && !canRepeat) { - console.warn( - "melonJS: repeat wrap (" + - repeat + - ") requested on a non-power-of-two texture (" + - w + - "x" + - h + - ") under WebGL 1 — downgrading to clamp-to-edge", - ); - } + repeat.search(/^repeat(-y)?$/) === 0 ? gl.REPEAT : gl.CLAMP_TO_EDGE; let currentTexture = texture; if (!currentTexture) { @@ -224,38 +202,37 @@ export class MaterialBatcher extends Batcher { mipmaps[i].data, ); } - } else if (pixels === null || typeof pixels.byteLength !== "undefined") { - if (this.renderer.WebGLVersion > 1) { - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.RGBA, - w, - h, - 0, - gl.RGBA, - gl.UNSIGNED_BYTE, - pixels, - 0, - ); - } else { - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.RGBA, - w, - h, - 0, - gl.RGBA, - gl.UNSIGNED_BYTE, - pixels, - ); - } + } else if (pixels === null) { + // allocation without data — srcOffset overload requires a view + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + w, + h, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + } else if (typeof pixels.byteLength !== "undefined") { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + w, + h, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + pixels, + 0, + ); } else if ( typeof globalThis.OffscreenCanvas !== "undefined" && pixels instanceof globalThis.OffscreenCanvas ) { - // WebGL2 (and WebGL1 in modern browsers) accepts an + // WebGL2 accepts an // OffscreenCanvas directly as a TexImageSource. The // previous path went through `transferToImageBitmap()`, // which is DESTRUCTIVE — it moves the bitmap out of the @@ -284,16 +261,13 @@ export class MaterialBatcher extends Batcher { ); } + // WebGL 2 mipmaps NPOT textures fine — no POT gate if ( - isPOT && mipmap === true && - pixels !== null && - pixels.compressed !== true && - typeof pixels.upload !== "function" + (pixels === null || + (pixels.compressed !== true && typeof pixels.upload !== "function")) ) { gl.generateMipmap(gl.TEXTURE_2D); - } else if (pixels === null && isPOT && mipmap === true) { - gl.generateMipmap(gl.TEXTURE_2D); } return currentTexture; @@ -443,7 +417,7 @@ export class MaterialBatcher extends Batcher { // passed the DESTINATION quad size, not the texture size. That // broke the downstream POT check — a 480×1216 atlas drawn into // a 256×256 quad reported `isPOT=true` and tripped - // `gl.generateMipmap` on WebGL 1. Always derive the actual + // `gl.generateMipmap` POT checks historically. Always derive the actual // texture dimensions from the source, falling back to the // passed-in values only when the source has none. const source = texture.getTexture(); diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index 4b544df3e2..3a75cece71 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -100,19 +100,24 @@ export default class QuadBatcher extends MaterialBatcher { * @ignore */ createIndexBuffer() { - // free the previous GL buffer first (reset / context-restore path) — - // recreating without deleting orphaned one ~12 KB ELEMENT_ARRAY - // buffer per reset until GC collected the wrapper - if (this.indexBuffer) { - this.indexBuffer.destroy(); - } - const maxQuads = this.vertexData.maxVertex / 4; - this.indexBuffer = new IndexBuffer( - this.gl, - maxQuads * 6, - this.renderer.WebGLVersion > 1, - ); - this.indexBuffer.fillQuadPattern(maxQuads); + // The ELEMENT_ARRAY_BUFFER binding is vertex-state state, so the + // buffer must be created and captured with THIS batcher's vertex + // state current — `captureIndexBuffer` owns that protocol (and + // restores whatever was bound, so rebuilding a non-current batcher + // never disturbs the in-flight one). + this.vertexState.captureIndexBuffer(undefined, () => { + // free the previous GL buffer first (reset / context-restore + // path) — recreating without deleting orphaned one ~12 KB + // ELEMENT_ARRAY buffer per reset until GC collected the wrapper + if (this.indexBuffer) { + this.indexBuffer.destroy(); + } + const maxQuads = this.vertexData.maxVertex / 4; + // Uint32 indices — OES_element_index_uint is core in WebGL 2 + this.indexBuffer = new IndexBuffer(this.gl, maxQuads * 6, true); + this.indexBuffer.fillQuadPattern(maxQuads); + this.vertexState.descriptor.indexBuffer = this.indexBuffer; + }); } /** @@ -162,29 +167,21 @@ export default class QuadBatcher extends MaterialBatcher { const gl = this.gl; const vertexSize = vertex.vertexSize; - // ensure the index buffer is bound - this.indexBuffer.bind(); + // (index buffer binding is captured in the vertex state — no + // per-flush rebind) // Byte-view upload — keeps packed-color bytes intact across // drivers that canonicalize NaN-pattern Float32 values on // upload (see `VertexArrayBuffer.bufferU8`). const byteLength = vertexCount * vertexSize * Float32Array.BYTES_PER_ELEMENT; - if (this.renderer.WebGLVersion > 1) { - gl.bufferData( - gl.ARRAY_BUFFER, - vertex.toUint8(), - gl.STREAM_DRAW, - 0, - byteLength, - ); - } else { - gl.bufferData( - gl.ARRAY_BUFFER, - vertex.toUint8(0, byteLength), - gl.STREAM_DRAW, - ); - } + gl.bufferData( + gl.ARRAY_BUFFER, + vertex.toUint8(), + gl.STREAM_DRAW, + 0, + byteLength, + ); // 4 vertices per quad -> vertexCount/4 quads -> *6 indices per quad const indexCount = (vertexCount / 4) * 6; diff --git a/packages/melonjs/src/video/webgl/buffer/index.js b/packages/melonjs/src/video/webgl/buffer/index.js index abd9911d3d..5ab61c5100 100644 --- a/packages/melonjs/src/video/webgl/buffer/index.js +++ b/packages/melonjs/src/video/webgl/buffer/index.js @@ -9,7 +9,7 @@ export default class WebGLIndexBuffer extends IndexBuffer { /** * @param {WebGLRenderingContext|WebGL2RenderingContext} gl - the WebGL context * @param {number} maxIndices - maximum number of indices this buffer can hold - * @param {boolean} [useUint32=false] - use Uint32 indices (WebGL2) instead of Uint16 (WebGL1) + * @param {boolean} [useUint32=false] - use Uint32 indices instead of Uint16 (a capacity knob — Uint16 keeps index uploads half the size) */ constructor(gl, maxIndices, useUint32 = false) { super(maxIndices, useUint32); diff --git a/packages/melonjs/src/video/webgl/buffer/vertexstate.js b/packages/melonjs/src/video/webgl/buffer/vertexstate.js new file mode 100644 index 0000000000..f252ecfef9 --- /dev/null +++ b/packages/melonjs/src/video/webgl/buffer/vertexstate.js @@ -0,0 +1,168 @@ +/** + * A WebGL Vertex State — a Vertex Array Object owning a frozen vertex + * buffer layout: one `vertexAttribPointer` / `enableVertexAttribArray` per + * attribute against a given vertex buffer, plus the ELEMENT_ARRAY_BUFFER + * binding when the geometry is indexed. + * + * This is the engine's `GPUVertexState` analogue. The layout it is built + * from (the `attributes` + `stride` pair — a `GPUVertexBufferLayout` and + * its `arrayStride`) is immutable once built, exactly like a vertex layout + * baked into a render pipeline. A future WebGPU backend replaces this + * class wholesale without its callers changing. + * + * Every method that mutates GL binding state saves and restores the live + * bindings itself, so building or rebuilding one vertex state can never + * disturb whichever one is mid-frame. Callers never touch + * `bindVertexArray` directly. + * @ignore + */ +export default class WebGLVertexState { + /** + * @param {WebGL2RenderingContext} gl - the WebGL context + * @param {object} descriptor - the vertex layout to realize + * @param {object[]} descriptor.attributes - attribute definitions (`name`, `size`, `type`, `normalized`, `offset`) + * @param {number} descriptor.stride - size of a single vertex in bytes (`arrayStride`) + * @param {WebGLBuffer} descriptor.buffer - the vertex buffer the attribute pointers read from + * @param {Function} descriptor.resolveLocation - maps an attribute name to its shader location (`-1` when absent) + * @param {WebGLIndexBuffer} [descriptor.indexBuffer] - index buffer to capture, for indexed geometry + */ + constructor(gl, descriptor) { + this.gl = gl; + this.descriptor = descriptor; + /** + * the underlying GL vertex array object + * @type {WebGLVertexArrayObject} + */ + this.handle = null; + this.build(); + } + + /** + * Snapshot the live vertex-array / array-buffer bindings. Pure GL + * bookkeeping — a WebGPU backend builds immutable descriptors and has + * no global binding points to disturb. + * @ignore + */ + #captureBindings() { + const gl = this.gl; + return { + vertexArray: gl.getParameter(gl.VERTEX_ARRAY_BINDING), + arrayBuffer: gl.getParameter(gl.ARRAY_BUFFER_BINDING), + }; + } + + /** @ignore */ + #restoreBindings(saved) { + const gl = this.gl; + gl.bindVertexArray(saved.vertexArray); + gl.bindBuffer(gl.ARRAY_BUFFER, saved.arrayBuffer); + } + + /** + * (Re)build the vertex array object from this state's descriptor, + * replacing any previous one. Called on construction and from every + * buffer-recreation path — a vertex state referencing a deleted buffer + * keeps it alive per the GL spec and draws stale data. + * @param {object} [changes] - descriptor fields to replace first (e.g. recreated `buffer` / `indexBuffer`) + */ + build(changes) { + const gl = this.gl; + if (changes !== undefined) { + Object.assign(this.descriptor, changes); + } + const { attributes, stride, buffer, indexBuffer, resolveLocation } = + this.descriptor; + + const saved = this.#captureBindings(); + this.release(); + + this.handle = gl.createVertexArray(); + gl.bindVertexArray(this.handle); + + // ARRAY_BUFFER is NOT vertex-array state, but each pointer record + // captures whichever buffer is bound at the time it is issued + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + for (const attr of attributes) { + const location = resolveLocation(attr.name); + if (location === -1) { + // on a lost context every location is -1 (the shader never + // compiled); that is expected and rebuilt on restore + if (!gl.isContextLost()) { + console.warn( + `melonJS: vertex attribute "${attr.name}" not found in the shader — skipped in the vertex state`, + ); + } + continue; + } + gl.enableVertexAttribArray(location); + gl.vertexAttribPointer( + location, + attr.size, + attr.type, + attr.normalized, + stride, + attr.offset, + ); + } + + if (indexBuffer) { + indexBuffer.bind(); + } + + this.#restoreBindings(saved); + } + + /** + * Capture an ELEMENT_ARRAY_BUFFER binding into this vertex state. + * Used when an index buffer is (re)created after the state was built — + * the quad batchers' static pattern buffer — since that binding is + * vertex-array state and would otherwise land in whichever state + * happens to be bound. + * @param {WebGLIndexBuffer} indexBuffer - the index buffer to capture + * @param {Function} [fill] - optional work to run with this state bound (buffer creation/upload) + */ + captureIndexBuffer(indexBuffer, fill) { + const gl = this.gl; + const saved = this.#captureBindings(); + gl.bindVertexArray(this.handle); + if (typeof fill === "function") { + fill(); + } + this.descriptor.indexBuffer = indexBuffer ?? this.descriptor.indexBuffer; + this.descriptor.indexBuffer?.bind(); + this.#restoreBindings(saved); + } + + /** + * Make this vertex state current. One call restores the entire frozen + * layout — the WebGL analogue of `setPipeline` + `setVertexBuffer` + + * `setIndexBuffer`. + */ + bind() { + this.gl.bindVertexArray(this.handle); + } + + /** + * 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. + */ + release() { + if (this.handle !== null) { + if (this.gl.isVertexArray(this.handle)) { + this.gl.deleteVertexArray(this.handle); + } + this.handle = null; + } + } + + /** + * Release the vertex array object. The vertex state must not be used + * after calling destroy. + */ + destroy() { + this.release(); + this.descriptor = null; + } +} diff --git a/packages/melonjs/src/video/webgl/glshader.js b/packages/melonjs/src/video/webgl/glshader.js index 6d1c02c519..65012859fe 100644 --- a/packages/melonjs/src/video/webgl/glshader.js +++ b/packages/melonjs/src/video/webgl/glshader.js @@ -270,7 +270,15 @@ export default class GLShader { } /** - * activate the given vertex attribute for this shader + * activate the given vertex attribute for this shader. + * + * Note: since 20.0 the engine no longer calls this per frame — each + * {@link Batcher} captures its attribute layout once into an immutable + * vertex-state object (VAO) at init (see `Batcher.createVertexState`). + * Kept public for custom callers managing their own vertex setup. + * Custom shaders hosted by a built-in batcher must declare that + * batcher's attributes first, in layout order — attribute locations + * are bound in declaration order and the vertex state is frozen. * @param {WebGLRenderingContext} gl - the current WebGL rendering context * @param {object[]} attributes - an array of vertex attributes * @param {number} stride - the size of a single vertex in bytes diff --git a/packages/melonjs/src/video/webgl/lighting/constants.ts b/packages/melonjs/src/video/webgl/lighting/constants.ts index 04f7093658..8b9dae437f 100644 --- a/packages/melonjs/src/video/webgl/lighting/constants.ts +++ b/packages/melonjs/src/video/webgl/lighting/constants.ts @@ -8,6 +8,6 @@ /** * Maximum number of `Light2d` instances the lit fragment shader supports * concurrently per draw call. Lights past this index are ignored. Sized - * to keep the GLSL uniform arrays comfortably within WebGL1 limits. + * to keep the GLSL uniform arrays comfortably within conservative GL limits. */ export const MAX_LIGHTS = 8; diff --git a/packages/melonjs/src/video/webgl/shadereffect.js b/packages/melonjs/src/video/webgl/shadereffect.js index 4ee89c6a01..23ddfd2529 100644 --- a/packages/melonjs/src/video/webgl/shadereffect.js +++ b/packages/melonjs/src/video/webgl/shadereffect.js @@ -436,25 +436,13 @@ export default class ShaderEffect { /** * Apply the wrap mode a `: screen_texture()` annotation asked for - * onto the live capture handle. Captures are NPOT (canvas-sized), which - * WebGL 1 cannot repeat — warn once and keep the clamp there. The unit is - * force-activated first: the batcher's bind may have been skipped as - * redundant while the GL active unit points elsewhere. + * onto the live capture handle. The unit is force-activated first: the + * batcher's bind may have been skipped as redundant while the GL active + * unit points elsewhere. * @ignore */ _applyCaptureWrap(batcher, glTex, entry) { const gl = batcher.gl; - if (batcher.renderer.WebGLVersion === 1) { - if (this._captureWrapWarned !== true) { - this._captureWrapWarned = true; - console.warn( - "ShaderEffect: screen_texture(" + - entry.repeat + - ") needs WebGL 2 (captures are non-power-of-two) — using clamp-to-edge", - ); - } - return; - } gl.activeTexture(gl.TEXTURE0 + entry.unit); gl.bindTexture(gl.TEXTURE_2D, glTex); batcher.currentTextureUnit = entry.unit; @@ -482,7 +470,7 @@ export default class ShaderEffect { * drawable source. No-op in Canvas mode. * @param {string} name - the `sampler2D` uniform name declared in the fragment * @param {Texture2d|HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap} image - the texture: an engine texture asset, or a raw drawable source - * @param {"repeat"|"repeat-x"|"repeat-y"|"no-repeat"} [repeat="no-repeat"] - wrap mode; use `"repeat"` for a tiled/scrolled texture (power-of-two size under WebGL 1) + * @param {"repeat"|"repeat-x"|"repeat-y"|"no-repeat"} [repeat="no-repeat"] - wrap mode; use `"repeat"` for a tiled/scrolled texture * @returns {ShaderEffect} this effect for chaining * @example * // "water": distort the sprite by a static noise texture scrolled over time @@ -630,7 +618,7 @@ export default class ShaderEffect { entry.image.width, entry.image.height, false, // premultipliedAlpha — keep raw texel values - false, // mipmap — not needed, and NPOT-unsafe under WebGL 1 + false, // mipmap — not needed for effect inputs undefined, false, // flush — the following draw flushes with everything bound ); diff --git a/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js b/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js index befbf50d8e..fe3ef783e1 100644 --- a/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js +++ b/packages/melonjs/src/video/webgl/shaders/multitexture-lit.js @@ -5,7 +5,7 @@ export { MAX_LIGHTS }; /** * Build the GLSL `if/else` chain that picks among N samplers based on - * a varying float texture-id. WebGL1 forbids dynamic indexing of + * a varying float texture-id. GLSL ES 1.00 forbids dynamic indexing of * `sampler2D`, so the standard workaround is an if-ladder using the * usual `< i + 0.5` threshold pattern. * @ignore diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 4084e888a3..11710ab133 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -1,5 +1,4 @@ import { Color, colorPool } from "./../../math/color.ts"; -import { isPowerOfTwo } from "./../../math/math.ts"; import { Matrix3d } from "../../math/matrix3d.ts"; import { Bounds } from "../../physics/bounds.ts"; import { @@ -209,8 +208,8 @@ export default class WebGLRenderer extends Renderer { */ this.batchers = new Map(); - // bind the vertex buffer - this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); + // (no raw vertex-buffer bind here — each batcher's vertex state + // captures its buffers at init, and bind() binds the upload target) // Create the default batchers. The lit-aware quad batcher is only // created when no custom batcher override is supplied — its lit @@ -252,7 +251,10 @@ export default class WebGLRenderer extends Renderer { this.cache = new TextureCache(this, this.maxTextures); // set the renderer type - this.type = "WebGL" + this.WebGLVersion; + this.type = "WebGL2"; + + // this backend renders TMX tile layers on the GPU (see TMXLayer) + this.supportsShaderTileLayers = true; // to simulate context lost and restore in WebGL: // let ctx = me.video.renderer.context.getExtension('WEBGL_lose_context'); @@ -272,8 +274,9 @@ export default class WebGLRenderer extends Renderer { "webglcontextrestored", () => { // restore renderer-owned GL state before downstream subscribers run + // (no raw bind needed — reset() below re-inits every batcher, + // rebuilding their vertex states against this new buffer) this.vertexBuffer = this.gl.createBuffer(); - this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); // driver wipes all GL state on restore; re-apply ours this.gl.disable(this.gl.DEPTH_TEST); @@ -318,12 +321,13 @@ export default class WebGLRenderer extends Renderer { } /** - * The WebGL version used by this renderer (1 or 2) + * The WebGL version used by this renderer. * @type {number} - * @default 1 + * @default 2 + * @deprecated since 20.0.0 — the WebGL renderer is WebGL 2 only, this is always `2` */ get WebGLVersion() { - return this.renderTarget.WebGLVersion; + return 2; } /** @@ -409,6 +413,12 @@ export default class WebGLRenderer extends Renderer { }); this.batchers.clear(); } + // the shared vertex buffer is renderer-owned (batchers only + // reference it), so it is released here rather than per batcher + if (this.vertexBuffer) { + this.gl.deleteBuffer(this.vertexBuffer); + this.vertexBuffer = null; + } } reset() { @@ -431,13 +441,6 @@ export default class WebGLRenderer extends Renderer { // initial viewport size this.setViewport(); - // rebind the vertex buffer if required (e.g in case of context loss) - if ( - this.gl.getParameter(this.gl.ARRAY_BUFFER_BINDING) !== this.vertexBuffer - ) { - this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); - } - this.currentBatcher = undefined; this.currentProgram = undefined; this.customShader = undefined; @@ -609,8 +612,6 @@ export default class WebGLRenderer extends Renderer { this.currentBatcher.flush(); this.currentBatcher.unbind(); } - // rebind the renderer's shared vertex buffer (custom batchers may have bound their own) - this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); this.currentBatcher = batcher; // `bind()` is where each batcher sets up its own GL state // (vertex attributes, plus mode-specific state like depth / @@ -651,23 +652,6 @@ export default class WebGLRenderer extends Renderer { createPattern(image, repeat = "no-repeat") { this.setBatcher("quad"); - if ( - this.WebGLVersion === 1 && - (!isPowerOfTwo(image.width) || !isPowerOfTwo(image.height)) - ) { - const src = typeof image.src !== "undefined" ? image.src : image; - throw new Error( - "[WebGL Renderer] " + - src + - " is not a POT texture " + - "(" + - image.width + - "x" + - image.height + - ")", - ); - } - // No band-aid texture cleanup here anymore — the cache's // `(source, repeat)` unit keying (see `TextureCache.getUnit` / // `peekUnit` / `freeTextureUnit`, which inline the `(source, @@ -1853,25 +1837,13 @@ export default class WebGLRenderer extends Renderer { break; case "darken": - if (this.WebGLVersion > 1) { - gl.blendEquation(gl.MIN); - gl.blendFunc(gl.ONE, gl.ONE); - } else { - gl.blendEquation(gl.FUNC_ADD); - gl.blendFunc(srcAlpha, gl.ONE_MINUS_SRC_ALPHA); - this.currentBlendMode = "normal"; - } + gl.blendEquation(gl.MIN); + gl.blendFunc(gl.ONE, gl.ONE); break; case "lighten": - if (this.WebGLVersion > 1) { - gl.blendEquation(gl.MAX); - gl.blendFunc(gl.ONE, gl.ONE); - } else { - gl.blendEquation(gl.FUNC_ADD); - gl.blendFunc(srcAlpha, gl.ONE_MINUS_SRC_ALPHA); - this.currentBlendMode = "normal"; - } + gl.blendEquation(gl.MAX); + gl.blendFunc(gl.ONE, gl.ONE); break; default: diff --git a/packages/melonjs/tests/batcher_attribute_leak.spec.js b/packages/melonjs/tests/batcher_attribute_leak.spec.js deleted file mode 100644 index 551d162bef..0000000000 --- a/packages/melonjs/tests/batcher_attribute_leak.spec.js +++ /dev/null @@ -1,523 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { boot, Matrix3d, video, WebGLRenderer } from "../src/index.js"; - -/** - * Regression guard for a GL state-leak between batchers. - * - * Each Batcher owns its own vertex attribute layout. `LitQuadBatcher` has - * 5 attributes at stride 32 (including `aNormalTextureId` at offset 28); - * `PrimitiveBatcher` has 3 attributes at stride 24. (Strides include the - * vec3 `aVertex` z component added for Camera3d perspective in 19.7.) - * - * Vertex attribute enable/disable + stride/offset is *global* GL state, not - * per-program. If batcher A enables a location and batcher B never disables - * it, B's draw call still validates A's stale stride against B's smaller - * vertex buffer, surfacing as - * INVALID_OPERATION: glDrawArrays: Vertex buffer is not big enough - * - * The fix: Batcher.unbind() disables the locations it enabled; the renderer - * calls it whenever the active batcher changes. - */ -describe("Batcher attribute leak between switches (WebGL)", () => { - let renderer; - let gl; - let isWebGL; - - beforeAll(() => { - boot(); - video.init(800, 600, { - parent: "screen", - scale: "auto", - renderer: video.WEBGL, - failIfMajorPerformanceCaveat: false, - }); - renderer = video.renderer; - isWebGL = renderer instanceof WebGLRenderer; - if (isWebGL) { - gl = renderer.gl; - } - }); - - afterAll(() => { - // hand the world back to the default renderer for any later test files - video.init(800, 600, { - parent: "screen", - scale: "auto", - renderer: video.AUTO, - }); - }); - - // helper: collect the GL locations enabled for the given batcher's - // current shader (the locations that batcher.bind/useShader just turned on) - function batcherEnabledLocations(batcher) { - const set = new Set(); - const shader = batcher.currentShader || batcher.defaultShader; - for (const attr of batcher.attributes) { - const loc = shader.getAttribLocation(attr.name); - if (loc !== -1) { - set.add(loc); - } - } - return set; - } - - // Runtime skip helper — visible "skipped" reporter status instead - // of silent early-return. Mirrors `webgl_pipeline_adversarial.spec.js`. - const skipIfNoWebGL = (ctx) => { - if (!isWebGL) { - ctx.skip("WebGL renderer not available in this environment"); - return true; - } - return false; - }; - - it("switching from quad → primitive disables quad-only attribute locations", (ctx) => { - if (skipIfNoWebGL(ctx)) { - return; - } - - const quad = renderer.setBatcher("quad"); - // force quad through bind() so all its attributes are enabled - quad.bind(); - const quadLocs = batcherEnabledLocations(quad); - - const primitive = renderer.setBatcher("primitive"); - const primLocs = batcherEnabledLocations(primitive); - - // any location that was enabled by quad but is NOT used by primitive - // must be disabled now — otherwise its stale stride/offset leaks - const leaked = []; - for (const loc of quadLocs) { - if (primLocs.has(loc)) { - continue; - } - const enabled = gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED); - if (enabled) { - leaked.push(loc); - } - } - - expect(leaked).toEqual([]); - }); - - it("drawing after quad → primitive switch produces no GL error", (ctx) => { - // End-to-end reproducer of the original report: a frame that draws a - // sprite (quad path) followed by a primitive (line/rect) used to - // throw INVALID_OPERATION on the primitive draw because quad's - // aNormalTextureId stayed enabled at a larger stride than the - // primitive buffer's stride. - if (skipIfNoWebGL(ctx)) { - return; - } - - // drain any pending GL errors from earlier tests - while (gl.getError() !== gl.NO_ERROR) { - /* drain */ - } - - const tex = video.createCanvas(16, 16); - - renderer.save(); - renderer.setBatcher("quad"); - renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); - // flush the quad batch so its draw call actually runs - renderer.currentBatcher.flush(); - - // now draw a primitive — this is where the leak used to fire - renderer.strokeRect(10, 10, 50, 30); - renderer.currentBatcher.flush(); - renderer.restore(); - - expect(gl.getError()).toBe(gl.NO_ERROR); - }); - - it("Batcher.unbind disables exactly the locations bind/useShader enabled", (ctx) => { - // Symmetry test: bind enables N locations, unbind disables those same - // N locations and no others. - if (skipIfNoWebGL(ctx)) { - return; - } - - const quad = renderer.setBatcher("quad"); - const enabledByQuad = batcherEnabledLocations(quad); - - // snapshot pre-unbind state - const enabledBefore = new Set(); - for (const loc of enabledByQuad) { - if (gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED)) { - enabledBefore.add(loc); - } - } - expect(enabledBefore.size).toBe(enabledByQuad.size); - - quad.unbind(); - - for (const loc of enabledByQuad) { - expect(gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED)).toBe( - false, - ); - } - - // restore for any subsequent tests - renderer.setBatcher("quad"); - }); - - it("switching from litQuad → primitive disables litQuad-only attribute locations", (ctx) => { - // The dispatch path that originally triggered the platformer crash: - // a normal-mapped sprite drawn through `litQuad` (5 attributes) - // followed by a primitive draw (`primitive`, 3 attribs). The lit - // batcher's `aNormalTextureId` and `aTextureId` must be disabled - // before the primitive's vertex buffer is uploaded. - if (skipIfNoWebGL(ctx)) { - return; - } - - const lit = renderer.setBatcher("litQuad"); - lit.bind(); - const litLocs = batcherEnabledLocations(lit); - - const primitive = renderer.setBatcher("primitive"); - const primLocs = batcherEnabledLocations(primitive); - - const leaked = []; - for (const loc of litLocs) { - if (primLocs.has(loc)) { - continue; - } - if (gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED)) { - leaked.push(loc); - } - } - expect(leaked).toEqual([]); - }); -}); - -describe("WebGLRenderer.drawImage lit/unlit dispatch", () => { - let renderer; - let isWebGL; - - beforeAll(() => { - boot(); - video.init(800, 600, { - parent: "screen", - scale: "auto", - renderer: video.WEBGL, - failIfMajorPerformanceCaveat: false, - }); - renderer = video.renderer; - isWebGL = renderer instanceof WebGLRenderer; - }); - - afterAll(() => { - // reset state so later tests see a clean renderer - renderer.activeLightCount = 0; - renderer.currentNormalMap = null; - video.init(800, 600, { - parent: "screen", - scale: "auto", - renderer: video.AUTO, - }); - }); - - const skipIfNoWebGL = (ctx) => { - if (!isWebGL) { - ctx.skip("WebGL renderer not available in this environment"); - return true; - } - return false; - }; - - it("unlit sprite (no normalMap, no lights) dispatches to `quad`", (ctx) => { - if (skipIfNoWebGL(ctx)) { - return; - } - renderer.activeLightCount = 0; - renderer.currentNormalMap = null; - const tex = video.createCanvas(8, 8); - renderer.drawImage(tex, 0, 0, 8, 8, 0, 0, 8, 8); - expect(renderer.currentBatcher).toBe(renderer.batchers.get("quad")); - }); - - it("normal-mapped sprite WITH active lights dispatches to `litQuad`", (ctx) => { - if (skipIfNoWebGL(ctx)) { - return; - } - renderer.activeLightCount = 1; - const normal = video.createCanvas(8, 8); - renderer.currentNormalMap = normal; - const tex = video.createCanvas(8, 8); - renderer.drawImage(tex, 0, 0, 8, 8, 0, 0, 8, 8); - expect(renderer.currentBatcher).toBe(renderer.batchers.get("litQuad")); - }); - - it("normal-mapped sprite WITHOUT active lights still uses `quad` (nothing to light)", (ctx) => { - // A sprite with normalMap but no Light2d in the scene has no lit - // math to run — the unlit batcher renders it identically to a plain - // sprite, at full texture-unit capacity. - if (skipIfNoWebGL(ctx)) { - return; - } - renderer.activeLightCount = 0; - const normal = video.createCanvas(8, 8); - renderer.currentNormalMap = normal; - const tex = video.createCanvas(8, 8); - renderer.drawImage(tex, 0, 0, 8, 8, 0, 0, 8, 8); - expect(renderer.currentBatcher).toBe(renderer.batchers.get("quad")); - }); - - it("active lights but no normalMap on the sprite still uses `quad` (no normal to sample)", (ctx) => { - if (skipIfNoWebGL(ctx)) { - return; - } - renderer.activeLightCount = 3; - renderer.currentNormalMap = null; - const tex = video.createCanvas(8, 8); - renderer.drawImage(tex, 0, 0, 8, 8, 0, 0, 8, 8); - expect(renderer.currentBatcher).toBe(renderer.batchers.get("quad")); - }); - - it("`quad` keeps full texture-unit capacity (no halving)", (ctx) => { - // The whole reason for splitting batchers: unlit `quad` is no longer - // punished for the lit pipeline's paired-sampler layout. Capacity - // equals `min(maxTextures, 16)`, not `floor(maxTextures / 2)`. - if (skipIfNoWebGL(ctx)) { - return; - } - const quad = renderer.batchers.get("quad"); - const expected = Math.min(renderer.maxTextures, 16); - expect(quad.maxBatchTextures).toBe(expected); - }); - - it("`litQuad` halves texture-unit capacity for paired (color, normal) layout", (ctx) => { - if (skipIfNoWebGL(ctx)) { - return; - } - const lit = renderer.batchers.get("litQuad"); - const expected = Math.min( - Math.max(1, Math.floor(renderer.maxTextures / 2)), - 16, - ); - expect(lit.maxBatchTextures).toBe(expected); - }); - - it("`setLightUniforms` updates renderer.activeLightCount and forwards to litQuad", (ctx) => { - if (skipIfNoWebGL(ctx)) { - return; - } - const lit = renderer.batchers.get("litQuad"); - // fake two lights with the duck-typed shape `packLights` reads - const fakeLight = (cx, cy) => { - return { - getBounds: () => { - return { centerX: cx, centerY: cy, width: 30, height: 30 }; - }, - intensity: 1, - color: { r: 255, g: 255, b: 255 }, - lightHeight: 1.5, - }; - }; - renderer.setLightUniforms( - [fakeLight(10, 20), fakeLight(50, 60)], - { r: 50, g: 50, b: 50 }, - 0, - 0, - ); - expect(renderer.activeLightCount).toBe(2); - expect(lit._lightCount).toBe(2); - }); - - it("setLightUniforms tolerates undefined / empty inputs (no throw, count = 0)", (ctx) => { - // Camera2d.draw forwards `stage?._activeLights` — when there's no - // active stage, both lights and ambient are undefined. - if (skipIfNoWebGL(ctx)) { - return; - } - expect(() => { - renderer.setLightUniforms(undefined, undefined, 0, 0); - }).not.toThrow(); - expect(renderer.activeLightCount).toBe(0); - - expect(() => { - renderer.setLightUniforms(null, null, 0, 0); - }).not.toThrow(); - expect(renderer.activeLightCount).toBe(0); - - expect(() => { - renderer.setLightUniforms([], { r: 0, g: 0, b: 0 }, 0, 0); - }).not.toThrow(); - expect(renderer.activeLightCount).toBe(0); - }); - - it("setLightUniforms must not leak gl.useProgram out of the active batcher", (ctx) => { - // Real-world reproducer for the bug that broke the platformer: - // every camera draw calls `renderer.setLightUniforms(...)` even - // when the scene has no lights. That call writes to litQuad's - // shader, and `GLShader.setUniform` flips `gl.useProgram` to litQuad's - // program. If the renderer doesn't restore the active batcher's - // program before the next draw, quad's 4-attribute vertex data gets - // fed to litQuad's 5-attribute shader and renders as garbage. - if (skipIfNoWebGL(ctx)) { - return; - } - const gl = renderer.gl; - - // pin the active batcher to quad (the unlit fast path) - renderer.setBatcher("quad"); - const quad = renderer.batchers.get("quad"); - const expectedProgram = quad.defaultShader.program; - expect(gl.getParameter(gl.CURRENT_PROGRAM)).toBe(expectedProgram); - - // simulate Camera2d.draw uploading per-frame light state (no - // lights — the platformer case) - renderer.setLightUniforms([], { r: 0, g: 0, b: 0 }, 0, 0); - - // gl.useProgram must still point to quad's program — otherwise the - // next drawImage will render quad data through litQuad's shader - expect(gl.getParameter(gl.CURRENT_PROGRAM)).toBe(expectedProgram); - - // belt-and-suspenders: simulate a sprite draw, verify program stays - const tex = video.createCanvas(8, 8); - renderer.drawImage(tex, 0, 0, 8, 8, 0, 0, 8, 8); - expect(renderer.currentBatcher).toBe(quad); - expect(gl.getParameter(gl.CURRENT_PROGRAM)).toBe(expectedProgram); - }); -}); - -describe("LitQuadBatcher internal bookkeeping invariants", () => { - // These tests probe internal state that GL doesn't expose (and that - // observable invariants like `gl.getError()` won't surface). They guard - // against silent desync between MaterialBatcher's per-unit bookkeeping - // (`boundTextures`, `currentTextureUnit`) and the actual GL bind state. - // A desync doesn't break rendering today (color/normal slot ranges don't - // overlap) but would silently corrupt any future feature that reads the - // bookkeeping — e.g. an LRU evict, a debug overlay, or unit re-purposing. - let renderer; - let isWebGL; - - beforeAll(() => { - boot(); - video.init(800, 600, { - parent: "screen", - scale: "auto", - renderer: video.WEBGL, - failIfMajorPerformanceCaveat: false, - }); - renderer = video.renderer; - isWebGL = renderer instanceof WebGLRenderer; - }); - - afterAll(() => { - video.init(800, 600, { - parent: "screen", - scale: "auto", - renderer: video.AUTO, - }); - }); - - const skipIfNoWebGL = (ctx) => { - if (!isWebGL) { - ctx.skip("WebGL renderer not available in this environment"); - return true; - } - return false; - }; - - it("cached normal-map rebind updates boundTextures and currentTextureUnit", (ctx) => { - // Original Copilot-reported footgun: the cached branch in - // `bindNormalMap` was using raw `gl.activeTexture` + `gl.bindTexture`, - // which set GL state correctly but didn't update MaterialBatcher's - // `boundTextures[unit]` / `currentTextureUnit`. Switched to - // `bindTexture2D(cached, unit, false)` so a subsequent color-texture - // upload at the same unit can correctly skip the rebind (or, - // conversely, knows the slot is already taken). - if (skipIfNoWebGL(ctx)) { - return; - } - const lit = renderer.batchers.get("litQuad"); - const normal = video.createCanvas(8, 8); - const unit = lit.maxBatchTextures; // first paired-normal slot - - // first call: createTexture2D path. Populates boundTextures[unit]. - lit.bindNormalMap(normal, unit); - const tex = lit.boundTextures[unit]; - expect(tex).toBeDefined(); - expect(lit.normalMapTextures.get(normal).tex).toBe(tex); - - // simulate the bookkeeping going stale (e.g. another unit took over, - // the slot was reset, etc.). The cached rebind must restore it. - lit.boundTextures[unit] = undefined; - lit.currentTextureUnit = -1; - - // second call: cached path. Must restore boundTextures[unit] and - // currentTextureUnit, not just GL state. - lit.bindNormalMap(normal, unit); - expect(lit.boundTextures[unit]).toBe(tex); - expect(lit.currentTextureUnit).toBe(unit); - }); - - it("cached rebind to the SAME unit doesn't trigger redundant work", (ctx) => { - // Sanity check: if the bookkeeping already says this unit holds the - // texture, the cached path should be a true no-op (no flush, no - // activeTexture, no bindTexture). `bindTexture2D` skips the work - // when `texture === boundTextures[unit] && unit === currentTextureUnit`. - if (skipIfNoWebGL(ctx)) { - return; - } - const lit = renderer.batchers.get("litQuad"); - const normal = video.createCanvas(8, 8); - const unit = lit.maxBatchTextures; - - lit.bindNormalMap(normal, unit); - const before = { - tex: lit.boundTextures[unit], - currentUnit: lit.currentTextureUnit, - }; - // rebind without disturbing state: no observable change - lit.bindNormalMap(normal, unit); - expect(lit.boundTextures[unit]).toBe(before.tex); - expect(lit.currentTextureUnit).toBe(before.currentUnit); - }); - - it("animated normal map re-uploads when its version bumps across frames (addQuad gating)", (ctx) => { - // Regression for the frozen-ripples bug: addQuad used to (re)bind the - // normal map only when the *reference* changed. An animated NoiseTexture2d - // keeps a stable canvas reference across re-bakes (bumping `canvas.version` - // instead), so the reference-only guard skipped the re-upload forever and - // the surface froze at frame 0. addQuad must now also re-upload on a - // version bump. - if (skipIfNoWebGL(ctx)) { - return; - } - const lit = renderer.batchers.get("litQuad"); - renderer.setBatcher("litQuad"); - lit.viewMatrix = new Matrix3d(); // identity — skip the transform branch - - const colorTex = renderer.cache.get(video.createCanvas(8, 8), { - framewidth: 8, - frameheight: 8, - }); - const normal = video.createCanvas(8, 8); // a "dynamic" source - normal.version = 1; - - let uploads = 0; - const orig = lit.uploadNormalMap; - lit.uploadNormalMap = function spy(img, unit, version) { - if (img === normal) { - uploads++; - } - return orig.call(this, img, unit, version); - }; - try { - // frame 1: first upload (reference is new) - lit.addQuad(colorTex, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff, false, normal); - // frame 2: SAME reference, bumped version (simulates update(dt)'s re-bake) - normal.version = 2; - lit.addQuad(colorTex, 0, 0, 8, 8, 0, 0, 1, 1, 0xffffffff, false, normal); - } finally { - lit.uploadNormalMap = orig; - renderer.setBatcher("quad"); - } - // with the reference-only guard this was 1 (frozen); must be 2 - expect(uploads).toBe(2); - }); -}); diff --git a/packages/melonjs/tests/lights.spec.js b/packages/melonjs/tests/lights.spec.js index a0f01efc8d..a7d830e6a1 100644 --- a/packages/melonjs/tests/lights.spec.js +++ b/packages/melonjs/tests/lights.spec.js @@ -1828,7 +1828,7 @@ describe("RadialGradientEffect (standalone API, WebGL)", () => { it("constructor accepts color/intensity options without throwing", async () => { // Sanity: the test only makes sense if WebGL actually came up. - expect(video.renderer.WebGLVersion).toBeGreaterThan(0); + expect(video.renderer.WebGLVersion).toBe(2); const { default: RadialGradientEffect } = await import( "../src/video/webgl/effects/radialGradient.js" ); @@ -1844,7 +1844,7 @@ describe("RadialGradientEffect (standalone API, WebGL)", () => { }); it("constructor with no options uses sensible defaults (white, 1.0)", async () => { - expect(video.renderer.WebGLVersion).toBeGreaterThan(0); + expect(video.renderer.WebGLVersion).toBe(2); const { default: RadialGradientEffect } = await import( "../src/video/webgl/effects/radialGradient.js" ); @@ -1870,7 +1870,7 @@ describe("RadialGradientEffect (standalone API, WebGL)", () => { // enforces that ordering by spying on `flush` and capturing the // active shader at flush time. const renderer = video.renderer; - expect(renderer.WebGLVersion).toBeGreaterThan(0); + expect(renderer.WebGLVersion).toBe(2); // warm up the lazy resources so the test only measures the // steady-state path. @@ -1922,7 +1922,7 @@ describe("RadialGradientEffect (standalone API, WebGL)", () => { // back lights pile into one shared vertex buffer instead of each // taking its own draw call. This test enforces that contract. const renderer = video.renderer; - expect(renderer.WebGLVersion).toBeGreaterThan(0); + expect(renderer.WebGLVersion).toBe(2); // warm up const warmup = new Light2d(0, 0, 8); @@ -1981,7 +1981,7 @@ describe("RadialGradientEffect (standalone API, WebGL)", () => { // `setColor` / `setIntensity` calls on the shared shader (which // would be uniforms — unique per-program-state, killing batching). const renderer = video.renderer; - expect(renderer.WebGLVersion).toBeGreaterThan(0); + expect(renderer.WebGLVersion).toBe(2); const warmup = new Light2d(0, 0, 8); renderer.drawLight(warmup); diff --git a/packages/melonjs/tests/sprite3d_webgl.spec.js b/packages/melonjs/tests/sprite3d_webgl.spec.js index d73ac8e66d..49be0acb34 100644 --- a/packages/melonjs/tests/sprite3d_webgl.spec.js +++ b/packages/melonjs/tests/sprite3d_webgl.spec.js @@ -44,7 +44,7 @@ describe("Sprite3d — WebGL draw path", () => { } if ( video.renderer instanceof WebGLRenderer && - video.renderer.WebGLVersion === 2 + typeof video.renderer.gl !== "undefined" ) { renderer = video.renderer; } diff --git a/packages/melonjs/tests/texture-resource.spec.js b/packages/melonjs/tests/texture-resource.spec.js index e808ed11c0..a90fdf2042 100644 --- a/packages/melonjs/tests/texture-resource.spec.js +++ b/packages/melonjs/tests/texture-resource.spec.js @@ -74,7 +74,7 @@ describe("BufferTextureResource — WebGL2 integration", () => { }); if ( video.renderer instanceof WebGLRenderer && - video.renderer.WebGLVersion === 2 + typeof video.renderer.gl !== "undefined" ) { renderer = video.renderer; } diff --git a/packages/melonjs/tests/texture.spec.js b/packages/melonjs/tests/texture.spec.js index f48f3b292f..7e1a103743 100644 --- a/packages/melonjs/tests/texture.spec.js +++ b/packages/melonjs/tests/texture.spec.js @@ -357,6 +357,19 @@ describe("Texture", () => { expect(pattern.repeat).toEqual("repeat"); }); + it("accepts a non-power-of-two source (WebGL2-only contract, 20.0)", (ctx) => { + if (typeof video.renderer.gl === "undefined") { + ctx.skip("WebGL renderer not available in this environment"); + return; + } + // pre-20.0 this threw "not a POT texture" — NPOT REPEAT is core + // in WebGL 2, so odd-sized patterns are now first-class + const canvas = new CanvasTexture(30, 20); + const pattern = video.renderer.createPattern(canvas.canvas, "repeat"); + expect(pattern).toBeDefined(); + expect(pattern.repeat).toEqual("repeat"); + }); + it("allocates a separate texture unit per (image, repeat) pair (#1448)", (ctx) => { if (typeof video.renderer.gl === "undefined") { ctx.skip("WebGL-only — Canvas createPattern doesn't allocate GL units"); diff --git a/packages/melonjs/tests/tmxlayer-shader.spec.js b/packages/melonjs/tests/tmxlayer-shader.spec.js index fd7ecccf95..a60b08d91f 100644 --- a/packages/melonjs/tests/tmxlayer-shader.spec.js +++ b/packages/melonjs/tests/tmxlayer-shader.spec.js @@ -16,7 +16,7 @@ describe("TMXLayer shader path", () => { }); if ( video.renderer instanceof WebGLRenderer && - video.renderer.WebGLVersion === 2 + typeof video.renderer.gl !== "undefined" ) { renderer = video.renderer; } diff --git a/packages/melonjs/tests/webgl_batcher_state.spec.js b/packages/melonjs/tests/webgl_batcher_state.spec.js index be603958a5..0fcc852176 100644 --- a/packages/melonjs/tests/webgl_batcher_state.spec.js +++ b/packages/melonjs/tests/webgl_batcher_state.spec.js @@ -261,5 +261,28 @@ describe("batcher GL state", () => { expect(gl.isBuffer(oldMeshIdx)).toBe(false); expect(mesh.glVertexBuffer).not.toBe(oldMeshVbo); expect(gl.isBuffer(oldMeshVbo)).toBe(false); + + // VAO re-capture (#1509): the vertex states must reference the NEW + // buffers — a VAO keeps deleted attachments alive and draws stale + // data, so recreation without re-capture is the classic black-frame + // bug this pins + expect(gl.isVertexArray(quad.vertexState.handle)).toBe(true); + expect(gl.isVertexArray(mesh.vertexState.handle)).toBe(true); + const previous = gl.getParameter(gl.VERTEX_ARRAY_BINDING); + gl.bindVertexArray(quad.vertexState.handle); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe( + quad.indexBuffer.buffer, + ); + gl.bindVertexArray(mesh.vertexState.handle); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe( + mesh.indexBuffer.buffer, + ); + const meshLoc = mesh.defaultShader.getAttribLocation( + mesh.attributes[0].name, + ); + expect( + gl.getVertexAttrib(meshLoc, gl.VERTEX_ATTRIB_ARRAY_BUFFER_BINDING), + ).toBe(mesh.glVertexBuffer); + gl.bindVertexArray(previous); }); }); diff --git a/packages/melonjs/tests/webgl_mesh_anchor.spec.js b/packages/melonjs/tests/webgl_mesh_anchor.spec.js index b0b7a86e0a..377eb6b7fc 100644 --- a/packages/melonjs/tests/webgl_mesh_anchor.spec.js +++ b/packages/melonjs/tests/webgl_mesh_anchor.spec.js @@ -62,7 +62,7 @@ describe("Mesh anchor-point leak under Camera3d (glTF prop-sink bug)", () => { } if ( video.renderer instanceof WebGLRenderer && - video.renderer.WebGLVersion === 2 + typeof video.renderer.gl !== "undefined" ) { renderer = video.renderer; } diff --git a/packages/melonjs/tests/webgl_mesh_depth.spec.js b/packages/melonjs/tests/webgl_mesh_depth.spec.js index 5fa691790b..968cc98fcf 100644 --- a/packages/melonjs/tests/webgl_mesh_depth.spec.js +++ b/packages/melonjs/tests/webgl_mesh_depth.spec.js @@ -60,7 +60,7 @@ describe("Mesh depth handling (issue #1468)", () => { } if ( video.renderer instanceof WebGLRenderer && - video.renderer.WebGLVersion === 2 + typeof video.renderer.gl !== "undefined" ) { renderer = video.renderer; } diff --git a/packages/melonjs/tests/webgl_pipeline_adversarial.spec.js b/packages/melonjs/tests/webgl_pipeline_adversarial.spec.js index b9b8c15c0d..8725872357 100644 --- a/packages/melonjs/tests/webgl_pipeline_adversarial.spec.js +++ b/packages/melonjs/tests/webgl_pipeline_adversarial.spec.js @@ -931,34 +931,28 @@ describe("WebGL pipeline adversarial integration", () => { expect(true).toBe(true); // reached the end of all seeds without throwing }); - // ---- Per-batcher unbind symmetry ---- - - it("every registered batcher's unbind disables exactly its own attribute locations", (ctx) => { - // Generic invariant on the Batcher contract: unbind must only - // touch locations it owns, never leave one of its own enabled, - // never disable a location it doesn't own (e.g. one belonging to a - // different batcher's currently-active program). + // ---- Per-batcher vertex-state ownership (VAO era) ---- + + it("after every batcher switch the bound vertex state and element buffer are the incoming batcher's own", (ctx) => { + // The VAO-era invariant replacing the old unbind-symmetry test: + // attribute state is never disabled between batchers — it is + // SWAPPED wholesale. After setBatcher, the GL vertex-array binding + // must be the incoming batcher's vertexState, and the element + // binding captured in it must be its own index buffer (or null for + // non-indexed batchers). if (skipIfNoWebGL(ctx)) { return; } for (const [name, batcher] of renderer.batchers) { renderer.setBatcher(name); - batcher.bind(); - - const ownLocs = new Set(); - const shader = batcher.currentShader || batcher.defaultShader; - for (const attr of batcher.attributes) { - const loc = shader.getAttribLocation(attr.name); - if (loc !== -1) { - ownLocs.add(loc); - } - } - - batcher.unbind(); - for (const loc of ownLocs) { - expect(gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED)).toBe( - false, - ); + expect(gl.getParameter(gl.VERTEX_ARRAY_BINDING)).toBe( + batcher.vertexState.handle, + ); + const boundElement = gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING); + if (batcher.indexBuffer) { + expect(boundElement).toBe(batcher.indexBuffer.buffer); + } else { + expect(boundElement).toBe(null); } } }); diff --git a/packages/melonjs/tests/webgl_required_check.spec.js b/packages/melonjs/tests/webgl_required_check.spec.js index af02fd5074..ca396873a7 100644 --- a/packages/melonjs/tests/webgl_required_check.spec.js +++ b/packages/melonjs/tests/webgl_required_check.spec.js @@ -1,5 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { Application, boot, Camera3d } from "../src/index.js"; +import { Application, boot, Camera3d, device } from "../src/index.js"; import * as video from "../src/video/video.js"; /** @@ -58,11 +58,38 @@ describe("Application: WebGL requirements fail loudly (#1479)", () => { ctx.skip("WebGL is available in this environment"); return; } catch (err) { - expect(err.message).toMatch(/WebGL/); + expect(err.message).toMatch(/WebGL 2/); expect(err.message).toMatch(/video\.AUTO/); } }); + it("isWebGLSupported() and WEBGL construction agree (WebGL2-only contract)", () => { + // The 20.0 invariant: the support gate probes the same context + // ("webgl2") that construction requests, so the two can never + // disagree — pre-20.0 the gate probed WebGL 1 while construction + // preferred WebGL 2. + if (device.isWebGLSupported()) { + const app = new Application(64, 64, { + parent: "screen", + renderer: video.WEBGL, + consoleHeader: false, + }); + expect(app.renderer.WebGLVersion).toBe(2); + expect(app.renderer.type).toBe("WebGL2"); + expect(app.renderer.gl).toBeInstanceOf( + globalThis.WebGL2RenderingContext, + ); + } else { + expect(() => { + void new Application(64, 64, { + parent: "screen", + renderer: video.WEBGL, + consoleHeader: false, + }); + }).toThrow(/WebGL 2/); + } + }); + it("renderer: video.AUTO falls back to Canvas silently (preserved behaviour)", () => { // AUTO is the documented fallback path. The same conditions // that make `video.WEBGL` throw must NOT cause AUTO to throw. @@ -84,7 +111,7 @@ describe("Application: WebGL requirements fail loudly (#1479)", () => { // Other engine paths can emit unrelated warnings during Canvas // Application setup (e.g. `gpuTilemap is enabled but the active - // renderer is not WebGL 2`), so each test scans all warn calls + // renderer has no GPU tile-layer support`), so each test scans all warn calls // for our specific Camera3d-mismatch message rather than asserting // total call counts. const findCamera3dWarn = () => { diff --git a/packages/melonjs/tests/webgl_vao_adversarial.spec.js b/packages/melonjs/tests/webgl_vao_adversarial.spec.js new file mode 100644 index 0000000000..ae83e64d7c --- /dev/null +++ b/packages/melonjs/tests/webgl_vao_adversarial.spec.js @@ -0,0 +1,411 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { + Application, + boot, + event, + Light2d, + QuadBatcher, + ShaderEffect, + video, + WebGLRenderer, +} from "../src/index.js"; + +/** + * Adversarial VAO validation (#1509) — hunting the failure mode VAO bugs + * are worst at: wrong vertex data with NO_ERROR. Storms of random + * operations followed by full layout readback and pixel-truth checks. + */ +describe("WebGL VAO adversarial", () => { + let renderer; + let gl; + let isWebGL; + + beforeAll(() => { + boot(); + video.init(64, 64, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + antiAlias: false, + }); + renderer = video.renderer; + isWebGL = renderer instanceof WebGLRenderer; + if (isWebGL) { + gl = renderer.gl; + // bare-harness gotcha: no game loop runs here, so the camera + // never installs its projection — screen-space ortho or every + // quad lands outside clip space + renderer.projectionMatrix.ortho(0, 64, 64, 0, -1, 1); + } + }); + + const requireWebGL = (ctx) => { + if (!isWebGL) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + const tick = () => { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }; + + // deterministic LCG so the fuzz sequence is reproducible + function makeRandom(seed) { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0xffffffff; + }; + } + + function solidCanvas(r, g, b) { + const c = document.createElement("canvas"); + c.width = c.height = 16; + const ctx2d = c.getContext("2d"); + ctx2d.fillStyle = `rgb(${r},${g},${b})`; + ctx2d.fillRect(0, 0, 16, 16); + return c; + } + + // full vertex-state readback for one batcher (binds its VAO raw, then + // restores the previous binding) + function snapshotLayout(batcher) { + const previous = gl.getParameter(gl.VERTEX_ARRAY_BINDING); + gl.bindVertexArray(batcher.vertexState.handle); + const snap = batcher.attributes.map((attr) => { + const loc = batcher.defaultShader.getAttribLocation(attr.name); + if (loc === -1) { + return `${attr.name}:absent`; + } + return [ + attr.name, + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED), + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_STRIDE), + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_SIZE), + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_TYPE), + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_NORMALIZED), + gl.getVertexAttribOffset(loc, gl.VERTEX_ATTRIB_ARRAY_POINTER), + ].join("|"); + }); + gl.bindVertexArray(previous); + return snap.join(" ~ "); + } + + function snapshotAll() { + const all = {}; + for (const [name, batcher] of renderer.batchers) { + all[name] = snapshotLayout(batcher); + } + return all; + } + + // read one pixel from the default framebuffer (y flipped to screen space) + function pixelAt(x, y) { + const px = new Uint8Array(4); + gl.readPixels( + x, + gl.drawingBufferHeight - 1 - y, + 1, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + px, + ); + return px; + } + + // deterministic mini-scene exercised between storms: red quad on the + // left, green stroked square on the right (quad + primitive layouts — + // the mesh layout is pinned by the recreation/state specs) + function drawTruthSceneAndAssert() { + // projection uniforms are synced at batcher-SWITCH time; a bare + // harness (no game loop) may never switch, leaving the init-era + // identity in the program — toggle to push the ortho into both + renderer.setBatcher("primitive"); + renderer.setBatcher("quad"); + renderer.clearColor("#000000ff"); + const red = solidCanvas(255, 0, 0); + renderer.drawImage(red, 0, 0, 16, 16, 4, 24, 16, 16); + renderer.setColor("#00ff00"); + renderer.fillRect(40, 24, 16, 16); + renderer.setColor("#ffffff"); + renderer.flush(); + + const inRed = pixelAt(12, 32); + expect(inRed[0], "red quad").toBeGreaterThan(200); + expect(inRed[1], "red quad").toBeLessThan(60); + const inGreen = pixelAt(48, 32); + expect(inGreen[1], "green fill").toBeGreaterThan(200); + expect(inGreen[0], "green fill").toBeLessThan(60); + expect(gl.getError()).toBe(gl.NO_ERROR); + } + + it("fuzz: several hundred random ops leave every frozen layout byte-identical", (ctx) => { + requireWebGL(ctx); + const before = snapshotAll(); + const random = makeRandom(0xc0ffee); + const tex = solidCanvas(0, 0, 255); + const effect = new ShaderEffect( + renderer, + "vec4 apply(vec4 color, vec2 uv) { return color; }", + ); + + let firstError = "none"; + for (let i = 0; i < 300; i++) { + const op = Math.floor(random() * 6); + switch (op) { + case 0: + renderer.drawImage( + tex, + 0, + 0, + 16, + 16, + random() * 40, + random() * 40, + 16, + 16, + ); + break; + case 1: + renderer.strokeRect(random() * 40, random() * 40, 10, 8); + break; + case 2: + renderer.setBatcher(random() < 0.5 ? "quad" : "primitive"); + break; + case 3: { + renderer.customShader = effect; + renderer.drawImage( + tex, + 0, + 0, + 16, + 16, + random() * 40, + random() * 40, + 16, + 16, + ); + renderer.customShader = undefined; + break; + } + case 4: + renderer.flush(); + break; + case 5: + // engine-faithful reset flow: a batcher is reset while + // current (renderer.reset re-binds afterwards); a naked + // cross-batcher reset would setUniform on a non-current + // program — not an engine path + renderer.setBatcher("quad"); + renderer.batchers.get("quad").reset(); + break; + } + if (firstError === "none") { + const e = gl.getError(); + if (e !== gl.NO_ERROR) { + firstError = `op${op}@${i}:0x${e.toString(16)}`; + } + } + } + renderer.flush(); + effect.destroy(); + + expect(firstError).toBe("none"); + expect(snapshotAll()).toEqual(before); + drawTruthSceneAndAssert(); + }); + + it("enabled-but-unconsumed attribute: 3-of-4 shader roundtrip stays pixel-correct", (ctx) => { + requireWebGL(ctx); + // ShaderEffect vertex declares aVertex/aRegion/aColor — quad's 4th + // attribute (aTextureId, location 3) stays ENABLED but unconsumed: + // the legality assumption the frozen-VAO design rests on + const effect = new ShaderEffect( + renderer, + "vec4 apply(vec4 color, vec2 uv) { return color; }", + ); + const tex = solidCanvas(255, 0, 255); + renderer.save(); + renderer.customShader = effect; + renderer.drawImage(tex, 0, 0, 16, 16, 4, 4, 16, 16); + renderer.flush(); + renderer.customShader = undefined; + renderer.restore(); + expect(gl.getError()).toBe(gl.NO_ERROR); + + drawTruthSceneAndAssert(); + effect.destroy(); + }); + + it("drawLight interleaved with sprites keeps the pipeline clean", (ctx) => { + requireWebGL(ctx); + const tex = solidCanvas(200, 200, 0); + const light = new Light2d(20, 20, 12); + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.drawLight(light); + renderer.drawImage(tex, 0, 0, 16, 16, 30, 30, 16, 16); + renderer.flush(); + expect(gl.getError()).toBe(gl.NO_ERROR); + light.destroy(); + drawTruthSceneAndAssert(); + }); + + it("mid-frame context loss (between setBatcher and flush) recovers", async (ctx) => { + requireWebGL(ctx); + const ext = gl.getExtension("WEBGL_lose_context"); + if (ext === null) { + ctx.skip("WEBGL_lose_context extension not available"); + return; + } + const tex = solidCanvas(0, 255, 255); + // half-bound: batcher switched, vertices pushed, NOT flushed + renderer.setBatcher("quad"); + renderer.drawImage(tex, 0, 0, 16, 16, 8, 8, 16, 16); + + const restored = new Promise((resolve) => { + event.once(event.ONCONTEXT_RESTORED, resolve); + }); + ext.loseContext(); + await tick(); + ext.restoreContext(); + await restored; + await tick(); + + for (const [name, batcher] of renderer.batchers) { + expect(gl.isVertexArray(batcher.vertexState.handle), name).toBe(true); + } + drawTruthSceneAndAssert(); + }); + + it("multi-renderer isolation: two live WebGL contexts, no crosstalk", (ctx) => { + requireWebGL(ctx); + const appB = new Application(48, 48, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + consoleHeader: false, + }); + const rendererB = appB.renderer; + const glB = rendererB.gl; + rendererB.projectionMatrix.ortho(0, 48, 48, 0, -1, 1); + try { + expect(glB.getError(), "post-construction B").toBe(glB.NO_ERROR); + expect(gl.getError(), "post-construction A").toBe(gl.NO_ERROR); + // each context validates its OWN vertex states... + for (const [name, batcher] of rendererB.batchers) { + expect(glB.isVertexArray(batcher.vertexState.handle), `B.${name}`).toBe( + true, + ); + } + // ...and rejects the other context's (container objects are + // per-context) + expect( + glB.isVertexArray(renderer.batchers.get("quad").vertexState.handle), + ).toBe(false); + + // drawing on B must not disturb A's pixel truth + const texB = solidCanvas(255, 128, 0); + rendererB.drawImage(texB, 0, 0, 16, 16, 0, 0, 16, 16); + rendererB.flush(); + expect(glB.getError()).toBe(glB.NO_ERROR); + + drawTruthSceneAndAssert(); + } finally { + appB.destroy(); + } + }); + + it("custom batcher (no overrides) inherits a working vertex state", (ctx) => { + requireWebGL(ctx); + class InheritingBatcher extends QuadBatcher {} + const appC = new Application(48, 48, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + consoleHeader: false, + batcher: InheritingBatcher, + }); + const rendererC = appC.renderer; + const glC = rendererC.gl; + rendererC.projectionMatrix.ortho(0, 48, 48, 0, -1, 1); + rendererC.setBatcher("primitive"); + rendererC.setBatcher("quad"); + try { + const quad = rendererC.batchers.get("quad"); + expect(quad).toBeInstanceOf(InheritingBatcher); + expect(glC.isVertexArray(quad.vertexState.handle)).toBe(true); + + const tex = solidCanvas(255, 0, 0); + rendererC.clearColor("#000000ff"); + rendererC.drawImage(tex, 0, 0, 16, 16, 4, 4, 16, 16); + rendererC.flush(); + expect(glC.getError()).toBe(glC.NO_ERROR); + + const px = new Uint8Array(4); + glC.readPixels( + 8, + glC.drawingBufferHeight - 1 - 8, + 1, + 1, + glC.RGBA, + glC.UNSIGNED_BYTE, + px, + ); + expect(px[0], "custom batcher renders").toBeGreaterThan(200); + } finally { + appC.destroy(); + } + }); + + it("custom batcher with a hand-rolled flush (self-bound upload) still renders", (ctx) => { + requireWebGL(ctx); + // a legacy-style custom flush that re-binds its upload target itself + // — must keep working because bind() preserves the upload-buffer + // invariant rather than relying on renderer-side rebinds + class SelfBindingBatcher extends QuadBatcher { + flush(mode) { + this.gl.bindBuffer( + this.gl.ARRAY_BUFFER, + this.glVertexBuffer ?? this.renderer.vertexBuffer, + ); + super.flush(mode); + } + } + const appD = new Application(48, 48, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + consoleHeader: false, + batcher: SelfBindingBatcher, + }); + const rendererD = appD.renderer; + const glD = rendererD.gl; + rendererD.projectionMatrix.ortho(0, 48, 48, 0, -1, 1); + rendererD.setBatcher("primitive"); + rendererD.setBatcher("quad"); + try { + const tex = solidCanvas(0, 255, 0); + rendererD.clearColor("#000000ff"); + rendererD.drawImage(tex, 0, 0, 16, 16, 4, 4, 16, 16); + rendererD.flush(); + expect(glD.getError()).toBe(glD.NO_ERROR); + + const px = new Uint8Array(4); + glD.readPixels( + 8, + glD.drawingBufferHeight - 1 - 8, + 1, + 1, + glD.RGBA, + glD.UNSIGNED_BYTE, + px, + ); + expect(px[1], "self-binding batcher renders").toBeGreaterThan(200); + } finally { + appD.destroy(); + } + }); +}); diff --git a/packages/melonjs/tests/webgl_vao_call_counts.spec.js b/packages/melonjs/tests/webgl_vao_call_counts.spec.js new file mode 100644 index 0000000000..f048d04617 --- /dev/null +++ b/packages/melonjs/tests/webgl_vao_call_counts.spec.js @@ -0,0 +1,111 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { boot, video, WebGLRenderer } from "../src/index.js"; + +/** + * The #1509 acceptance criterion, as measured GL call counts: with VAOs, + * vertex-attribute specification happens exactly once per batcher at init + * (19 pointer + 19 enable calls across the five default batchers: + * quad 4 + litQuad 5 + primitive 3 + mesh 3 + litMesh 4), and steady-state + * frames issue ZERO attribute-pointer calls — batcher switches cost a + * single `bindVertexArray`. + * + * The GL context is spied by wrapping methods on the context object + * (instance properties shadow the prototype). + */ +describe("WebGL VAO call counts (#1509 acceptance)", () => { + let renderer; + let gl; + let isWebGL; + const counts = {}; + + function spy(name) { + const original = gl[name].bind(gl); + counts[name] = 0; + gl[name] = (...args) => { + counts[name]++; + return original(...args); + }; + } + + function resetCounts() { + for (const key of Object.keys(counts)) { + counts[key] = 0; + } + } + + beforeAll(() => { + boot(); + // spy BEFORE video.init so init-time calls are counted — the canvas + // context doesn't exist yet, so instead spy right after context + // creation by re-initializing the batchers via renderer.reset() + video.init(160, 120, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + }); + renderer = video.renderer; + isWebGL = renderer instanceof WebGLRenderer; + if (isWebGL) { + gl = renderer.gl; + for (const name of [ + "vertexAttribPointer", + "enableVertexAttribArray", + "disableVertexAttribArray", + "bindVertexArray", + ]) { + spy(name); + } + } + }); + + const requireWebGL = (ctx) => { + if (!isWebGL) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + it("rebuilding all vertex states costs exactly 19 pointer + 19 enable calls", (ctx) => { + requireWebGL(ctx); + resetCounts(); + for (const batcher of renderer.batchers.values()) { + batcher.createVertexState(); + } + expect(counts.vertexAttribPointer).toBe(19); + expect(counts.enableVertexAttribArray).toBe(19); + expect(counts.disableVertexAttribArray).toBe(0); + }); + + it("a steady-state mixed frame issues ZERO attribute-specification calls", (ctx) => { + requireWebGL(ctx); + const tex = document.createElement("canvas"); + tex.width = tex.height = 16; + // warm up (shader binds, texture upload) + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.strokeRect(0, 0, 10, 10); + renderer.flush(); + + resetCounts(); + // sprite → primitive → sprite → primitive with flushes — the + // classic frame shape that used to re-specify attributes per switch + for (let i = 0; i < 5; i++) { + renderer.drawImage(tex, 0, 0, 16, 16, i, i, 16, 16); + renderer.strokeRect(i, i, 10, 10); + } + renderer.flush(); + expect(counts.vertexAttribPointer).toBe(0); + expect(counts.enableVertexAttribArray).toBe(0); + expect(counts.disableVertexAttribArray).toBe(0); + }); + + it("100 alternating batcher switches cost ≤101 bindVertexArray and 0 pointer calls", (ctx) => { + requireWebGL(ctx); + resetCounts(); + for (let i = 0; i < 50; i++) { + renderer.setBatcher("quad"); + renderer.setBatcher("primitive"); + } + expect(counts.vertexAttribPointer).toBe(0); + expect(counts.enableVertexAttribArray).toBe(0); + expect(counts.bindVertexArray).toBeLessThanOrEqual(101); + }); +}); diff --git a/packages/melonjs/tests/webgl_vao_contract.spec.js b/packages/melonjs/tests/webgl_vao_contract.spec.js new file mode 100644 index 0000000000..c1394427b6 --- /dev/null +++ b/packages/melonjs/tests/webgl_vao_contract.spec.js @@ -0,0 +1,149 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { + boot, + GLShader, + ShaderEffect, + video, + WebGLRenderer, +} from "../src/index.js"; + +/** + * The attribute-location contract (#1509): locations are bound in vertex + * source declaration order, so every shader hosted by a batcher must + * declare a PREFIX of that batcher's attribute layout, in layout order — + * the engine's analogue of WebGPU's pipeline-creation validation. + * + * (a) violation → exactly one console warning per (batcher, shader) pair; + * (b) built-in conformance sweep: every shader population the engine + * ships must satisfy the contract of its host batcher — CI fails the + * moment a non-conforming built-in is added. + */ +describe("WebGL vertex-state location contract", () => { + let renderer; + let gl; + let isWebGL; + + beforeAll(() => { + boot(); + video.init(160, 120, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + }); + renderer = video.renderer; + isWebGL = renderer instanceof WebGLRenderer; + if (isWebGL) { + gl = renderer.gl; + } + }); + + const requireWebGL = (ctx) => { + if (!isWebGL) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + it("an out-of-order shader triggers exactly one warning per (batcher, shader) pair", (ctx) => { + requireWebGL(ctx); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + // aRegion declared BEFORE aVertex — links aRegion to location 0 + // where the quad vertex state expects aVertex + const badShader = new GLShader( + gl, + `attribute vec2 aRegion; + attribute vec3 aVertex; + attribute vec4 aColor; + uniform mat4 uProjectionMatrix; + varying vec2 vRegion; + void main(void) { + vRegion = aRegion; + gl_Position = uProjectionMatrix * vec4(aVertex, 1.0); + }`, + `varying vec2 vRegion; + void main(void) { gl_FragColor = vec4(vRegion, 0.0, 1.0); }`, + ); + const quad = renderer.setBatcher("quad") ?? renderer.batchers.get("quad"); + quad.useShader(badShader); + quad.useShader(quad.defaultShader); + // second use: the WeakSet cache must suppress a repeat warning + quad.useShader(badShader); + quad.useShader(quad.defaultShader); + + const contractWarns = warnSpy.mock.calls.filter((args) => { + return /layout order/.test(String(args[0])); + }); + expect(contractWarns.length).toBe(1); + badShader.destroy(); + } finally { + warnSpy.mockRestore(); + } + }); + + it("a conforming ShaderEffect triggers no contract warning", (ctx) => { + requireWebGL(ctx); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const effect = new ShaderEffect( + renderer, + "vec4 apply(vec4 color, vec2 uv) { return color; }", + ); + const quad = renderer.batchers.get("quad"); + renderer.setBatcher("quad"); + quad.useShader(effect._shader); + quad.useShader(quad.defaultShader); + const contractWarns = warnSpy.mock.calls.filter((args) => { + return /layout order/.test(String(args[0])); + }); + expect(contractWarns.length).toBe(0); + effect.destroy(); + } finally { + warnSpy.mockRestore(); + } + }); + + it("built-in conformance sweep: every default batcher shader matches its own layout", (ctx) => { + requireWebGL(ctx); + // trivially true (the default shader DEFINES the canonical mapping) + // but pins the invariant that getAttribLocation is stable + present + for (const [name, batcher] of renderer.batchers) { + for (const attr of batcher.attributes) { + expect( + batcher.defaultShader.getAttribLocation(attr.name), + `${name}.${attr.name}`, + ).not.toBe(-1); + } + } + }); + + it("built-in conformance sweep: cross-hosted engine shaders satisfy the quad contract", (ctx) => { + requireWebGL(ctx); + const quad = renderer.batchers.get("quad"); + const canonical = {}; + for (const attr of quad.attributes) { + canonical[attr.name] = quad.defaultShader.getAttribLocation(attr.name); + } + + const hosted = []; + // ShaderEffect-generated vertex (the user-effect population) + const effect = new ShaderEffect( + renderer, + "vec4 apply(vec4 color, vec2 uv) { return color; }", + ); + hosted.push(["ShaderEffect", effect._shader]); + // the light shader (drawLight swaps it onto the quad batcher) + if (renderer._lightShader) { + hosted.push(["lightShader", renderer._lightShader]); + } + + for (const [label, shader] of hosted) { + for (const [name, expected] of Object.entries(canonical)) { + const actual = shader.getAttribLocation(name); + if (actual !== -1) { + expect(actual, `${label}.${name}`).toBe(expected); + } + } + } + effect.destroy(); + }); +}); diff --git a/packages/melonjs/tests/webgl_vao_recreation.spec.js b/packages/melonjs/tests/webgl_vao_recreation.spec.js new file mode 100644 index 0000000000..eb3d720fbf --- /dev/null +++ b/packages/melonjs/tests/webgl_vao_recreation.spec.js @@ -0,0 +1,175 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { boot, event, video, WebGLRenderer } from "../src/index.js"; + +/** + * VAO recreation across the two buffer-churn flows (#1509): a VAO holding a + * DELETED buffer keeps it alive per the GL spec and draws stale/dead data — + * so every buffer-recreation path must rebuild (or re-capture into) the + * owning vertex state. Pinned flows: plain reset (GAME_RESET) and a real + * WEBGL_lose_context lose/restore cycle. + */ +describe("WebGL VAO recreation (buffer churn + context loss)", () => { + let renderer; + let gl; + let isWebGL; + + beforeAll(() => { + boot(); + video.init(160, 120, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + }); + renderer = video.renderer; + isWebGL = renderer instanceof WebGLRenderer; + if (isWebGL) { + gl = renderer.gl; + } + }); + + const requireWebGL = (ctx) => { + if (!isWebGL) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + const tick = () => { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }; + + function drawSomething() { + const tex = document.createElement("canvas"); + tex.width = tex.height = 16; + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.flush(); + } + + it("quad batcher: reset() re-captures the recreated static index buffer", (ctx) => { + requireWebGL(ctx); + const quad = renderer.batchers.get("quad"); + const oldBuffer = quad.indexBuffer.buffer; + + quad.reset(); + quad.createIndexBuffer(); + + // the vertex state must reference the NEW GL buffer + renderer.setBatcher("primitive"); // move away first + renderer.setBatcher("quad"); + const bound = gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING); + expect(bound).toBe(quad.indexBuffer.buffer); + expect(bound).not.toBe(oldBuffer); + + drawSomething(); + expect(gl.getError()).toBe(gl.NO_ERROR); + }); + + it("mesh batcher: reset() rebuilds the vertex state against the new buffers", (ctx) => { + requireWebGL(ctx); + const mesh = renderer.batchers.get("mesh"); + const oldVao = mesh.vertexState.handle; + const oldVertexBuffer = mesh.glVertexBuffer; + + mesh.reset(); + + // the WebGLVertexState object persists across a rebuild; the GL + // handle it wraps is replaced + expect(mesh.vertexState.handle).not.toBe(oldVao); + expect(gl.isVertexArray(mesh.vertexState.handle)).toBe(true); + expect(mesh.glVertexBuffer).not.toBe(oldVertexBuffer); + + renderer.setBatcher("mesh"); + expect(gl.getParameter(gl.VERTEX_ARRAY_BINDING)).toBe( + mesh.vertexState.handle, + ); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe( + mesh.indexBuffer.buffer, + ); + // attribute pointers reference the NEW vertex buffer + const loc = mesh.defaultShader.getAttribLocation(mesh.attributes[0].name); + expect(gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_BUFFER_BINDING)).toBe( + mesh.glVertexBuffer, + ); + expect(gl.getError()).toBe(gl.NO_ERROR); + }); + + it("rebuilding a NON-current batcher does not corrupt the current one's uploads", (ctx) => { + requireWebGL(ctx); + // regression: `createVertexState()` binds ARRAY_BUFFER (not VAO + // state) while building. Without restoring it, rebuilding any + // own-buffer batcher — `meshBatcher.reset()`, or `addBatcher()` + // mid-scene — left the CURRENT batcher uploading into the wrong + // buffer while drawing from the old one: vanished geometry, and + // NO_ERROR to hide it. + renderer.projectionMatrix.ortho(0, 160, 120, 0, -1, 1); + renderer.setBatcher("primitive"); + renderer.setBatcher("quad"); + + const red = document.createElement("canvas"); + red.width = red.height = 16; + const r2d = red.getContext("2d"); + r2d.fillStyle = "rgb(255,0,0)"; + r2d.fillRect(0, 0, 16, 16); + + renderer.clearColor("#000000ff"); + renderer.drawImage(red, 0, 0, 16, 16, 4, 60, 16, 16); + renderer.flush(); + + // rebuild another batcher's vertex state while quad is current + renderer.batchers.get("mesh").reset(); + + // draw at a DIFFERENT spot in a DIFFERENT colour, so stale contents + // of the previous upload cannot masquerade as success + const green = document.createElement("canvas"); + green.width = green.height = 16; + const g2d = green.getContext("2d"); + g2d.fillStyle = "rgb(0,255,0)"; + g2d.fillRect(0, 0, 16, 16); + + renderer.clearColor("#000000ff"); + renderer.drawImage(green, 0, 0, 16, 16, 100, 20, 16, 16); + renderer.flush(); + + const px = new Uint8Array(4); + gl.readPixels( + 108, + gl.drawingBufferHeight - 1 - 28, + 1, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + px, + ); + expect(px[1], "green quad after foreign rebuild").toBeGreaterThan(200); + expect(px[0], "green quad after foreign rebuild").toBeLessThan(60); + expect(gl.getError()).toBe(gl.NO_ERROR); + }); + + it("a real lose/restore cycle leaves every vertex state valid and drawable", async (ctx) => { + requireWebGL(ctx); + const ext = gl.getExtension("WEBGL_lose_context"); + if (ext === null) { + ctx.skip("WEBGL_lose_context extension not available"); + return; + } + drawSomething(); + + const restored = new Promise((resolve) => { + event.once(event.ONCONTEXT_RESTORED, resolve); + }); + ext.loseContext(); + await tick(); + ext.restoreContext(); + await restored; + await tick(); + + for (const [name, batcher] of renderer.batchers) { + expect(gl.isVertexArray(batcher.vertexState.handle), name).toBe(true); + } + drawSomething(); + renderer.strokeRect(2, 2, 20, 12); + renderer.flush(); + expect(gl.getError()).toBe(gl.NO_ERROR); + }); +}); diff --git a/packages/melonjs/tests/webgl_vao_state.spec.js b/packages/melonjs/tests/webgl_vao_state.spec.js new file mode 100644 index 0000000000..ff582e5d09 --- /dev/null +++ b/packages/melonjs/tests/webgl_vao_state.spec.js @@ -0,0 +1,132 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { boot, video, WebGLRenderer } from "../src/index.js"; + +/** + * VAO (vertex state) isolation — every batcher owns one immutable Vertex + * Array Object built at init, capturing its frozen vertex buffer layout + * (the engine's `GPUVertexState` analogue, #1509). These specs pin the + * frozen state via GL readback: strides, enabled flags, per-attribute + * buffer attachment (shared renderer buffer vs the batcher's own), and + * the element binding travelling with the VAO across switches. + */ +describe("WebGL vertex state (VAO) isolation", () => { + let renderer; + let gl; + let isWebGL; + + // (batcher name → expected stride in bytes) + const STRIDES = { + quad: 28, + litQuad: 32, + primitive: 24, + mesh: 36, + litMesh: 48, + }; + + beforeAll(() => { + boot(); + video.init(160, 120, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + }); + renderer = video.renderer; + isWebGL = renderer instanceof WebGLRenderer; + if (isWebGL) { + gl = renderer.gl; + } + }); + + const requireWebGL = (ctx) => { + if (!isWebGL) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + it("each batcher owns a distinct, valid vertex state", (ctx) => { + requireWebGL(ctx); + const seen = new Set(); + for (const [name, batcher] of renderer.batchers) { + expect(gl.isVertexArray(batcher.vertexState.handle), name).toBe(true); + expect(seen.has(batcher.vertexState.handle), name).toBe(false); + seen.add(batcher.vertexState.handle); + } + }); + + it("setBatcher binds the batcher's own vertex state", (ctx) => { + requireWebGL(ctx); + for (const name of Object.keys(STRIDES)) { + renderer.setBatcher(name); + expect(gl.getParameter(gl.VERTEX_ARRAY_BINDING)).toBe( + renderer.batchers.get(name).vertexState.handle, + ); + } + }); + + it("the frozen layout is readable back per batcher: stride, enabled, buffer attachment", (ctx) => { + requireWebGL(ctx); + for (const [name, expectedStride] of Object.entries(STRIDES)) { + const batcher = renderer.batchers.get(name); + renderer.setBatcher(name); + const expectedBuffer = batcher.glVertexBuffer ?? renderer.vertexBuffer; + for (const attr of batcher.attributes) { + const loc = batcher.defaultShader.getAttribLocation(attr.name); + if (loc === -1) { + continue; + } + const label = `${name}.${attr.name}`; + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED), + label, + ).toBe(true); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_STRIDE), + label, + ).toBe(expectedStride); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_BUFFER_BINDING), + label, + ).toBe(expectedBuffer); + } + } + }); + + it("the element-buffer binding travels with the vertex state across switches", (ctx) => { + requireWebGL(ctx); + // quad (static index pattern) → primitive (none) → mesh (dynamic) + renderer.setBatcher("quad"); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe( + renderer.batchers.get("quad").indexBuffer.buffer, + ); + renderer.setBatcher("primitive"); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe(null); + renderer.setBatcher("mesh"); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe( + renderer.batchers.get("mesh").indexBuffer.buffer, + ); + // and back — the quad VAO still remembers its own + renderer.setBatcher("quad"); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe( + renderer.batchers.get("quad").indexBuffer.buffer, + ); + }); + + it("a mixed-layout frame leaves no GL error", (ctx) => { + requireWebGL(ctx); + const tex = document.createElement("canvas"); + tex.width = tex.height = 16; + renderer.drawImage(tex, 0, 0, 16, 16, 0, 0, 16, 16); + renderer.strokeRect(4, 4, 30, 20); + renderer.drawImage(tex, 0, 0, 16, 16, 32, 32, 16, 16); + renderer.flush(); + expect(gl.getError()).toBe(gl.NO_ERROR); + }); + + it("addAttribute throws once the vertex state is frozen", (ctx) => { + requireWebGL(ctx); + const quad = renderer.batchers.get("quad"); + expect(() => { + quad.addAttribute("aLate", 1, gl.FLOAT, false, 28); + }).toThrow(/frozen/); + }); +}); diff --git a/packages/melonjs/tests/webgl_vao_teardown.spec.js b/packages/melonjs/tests/webgl_vao_teardown.spec.js new file mode 100644 index 0000000000..d34e81bfb1 --- /dev/null +++ b/packages/melonjs/tests/webgl_vao_teardown.spec.js @@ -0,0 +1,89 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { Application, boot, video, WebGLRenderer } from "../src/index.js"; + +/** + * GL resource teardown (#1509): every object a batcher creates — its + * vertex state, its own vertex/index buffers, its default shader — must be + * released when the renderer is destroyed, and the renderer's shared + * vertex buffer with it. Without this, an app that tears down and rebuilds + * Applications against a surviving context accumulates GL objects. + */ +describe("WebGL batcher teardown releases GL objects", () => { + let isWebGL; + + beforeAll(() => { + boot(); + video.init(64, 64, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + }); + isWebGL = video.renderer instanceof WebGLRenderer; + }); + + const requireWebGL = (ctx) => { + if (!isWebGL) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + it("Application.destroy() deletes every batcher's vertex state and buffers", (ctx) => { + requireWebGL(ctx); + const app = new Application(48, 48, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + consoleHeader: false, + }); + const renderer = app.renderer; + const gl = renderer.gl; + + // snapshot the GL objects the renderer owns before teardown + const owned = []; + for (const [name, batcher] of renderer.batchers) { + owned.push({ name, kind: "vao", handle: batcher.vertexState.handle }); + if (batcher.glVertexBuffer) { + owned.push({ name, kind: "vbo", handle: batcher.glVertexBuffer }); + } + if (batcher.indexBuffer) { + owned.push({ name, kind: "ibo", handle: batcher.indexBuffer.buffer }); + } + } + const sharedBuffer = renderer.vertexBuffer; + expect(owned.length).toBeGreaterThan(5); + for (const o of owned) { + const live = + o.kind === "vao" ? gl.isVertexArray(o.handle) : gl.isBuffer(o.handle); + expect(live, `${o.name}.${o.kind} live before destroy`).toBe(true); + } + + app.destroy(); + + for (const o of owned) { + const live = + o.kind === "vao" ? gl.isVertexArray(o.handle) : gl.isBuffer(o.handle); + expect(live, `${o.name}.${o.kind} released after destroy`).toBe(false); + } + expect(gl.isBuffer(sharedBuffer), "shared vertex buffer released").toBe( + false, + ); + expect(renderer.vertexBuffer).toBe(null); + }); + + it("destroy() is idempotent and does not queue GL errors", (ctx) => { + requireWebGL(ctx); + const app = new Application(48, 48, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + consoleHeader: false, + }); + const gl = app.renderer.gl; + while (gl.getError() !== gl.NO_ERROR) { + /* drain */ + } + app.destroy(); + app.renderer.destroy(); + expect(gl.getError()).toBe(gl.NO_ERROR); + }); +}); diff --git a/packages/melonjs/tests/webgl_vertexstate.spec.js b/packages/melonjs/tests/webgl_vertexstate.spec.js new file mode 100644 index 0000000000..ac4f0df92a --- /dev/null +++ b/packages/melonjs/tests/webgl_vertexstate.spec.js @@ -0,0 +1,317 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { boot, video, WebGLRenderer } from "../src/index.js"; +import WebGLIndexBuffer from "../src/video/webgl/buffer/index.js"; +import WebGLVertexState from "../src/video/webgl/buffer/vertexstate.js"; + +/** + * Unit tests for {@link WebGLVertexState} — the GL vertex array object + * wrapper (the engine's `GPUVertexState` analogue). Exercised directly + * against a real WebGL 2 context rather than through a Batcher, so each + * method's contract is pinned independently of its callers. + */ +describe("WebGLVertexState", () => { + let gl; + let isWebGL; + let shader; + + // a 3-attribute layout: vec3 position, vec2 uv, unsigned-byte colour + const STRIDE = 24; + const ATTRIBUTES = [ + { name: "aVertex", size: 3, type: 0, normalized: false, offset: 0 }, + { name: "aRegion", size: 2, type: 0, normalized: false, offset: 12 }, + { name: "aColor", size: 4, type: 0, normalized: true, offset: 20 }, + ]; + + beforeAll(() => { + boot(); + video.init(64, 64, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + }); + isWebGL = video.renderer instanceof WebGLRenderer; + if (isWebGL) { + gl = video.renderer.gl; + // fill in the GL enums now that a context exists + ATTRIBUTES[0].type = gl.FLOAT; + ATTRIBUTES[1].type = gl.FLOAT; + ATTRIBUTES[2].type = gl.UNSIGNED_BYTE; + shader = video.renderer.batchers.get("quad").defaultShader; + } + }); + + const requireWebGL = (ctx) => { + if (!isWebGL) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + // resolve to fixed locations so the spec doesn't depend on a shader + const fixedLocations = (name) => { + return { aVertex: 0, aRegion: 1, aColor: 2 }[name] ?? -1; + }; + + function makeState(overrides = {}) { + // the helper must be transparent to GL state itself, or the + // binding-restore assertions below would be testing the helper + const previous = gl.getParameter(gl.ARRAY_BUFFER_BINDING); + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(600), gl.STREAM_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, previous); + return new WebGLVertexState(gl, { + attributes: ATTRIBUTES, + stride: STRIDE, + buffer, + resolveLocation: fixedLocations, + ...overrides, + }); + } + + describe("construction", () => { + it("builds a valid vertex array applying the whole layout", (ctx) => { + requireWebGL(ctx); + const state = makeState(); + expect(gl.isVertexArray(state.handle)).toBe(true); + + state.bind(); + for (const attr of ATTRIBUTES) { + const loc = fixedLocations(attr.name); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED), + attr.name, + ).toBe(true); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_STRIDE), + attr.name, + ).toBe(STRIDE); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_SIZE), + attr.name, + ).toBe(attr.size); + expect( + gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_NORMALIZED), + attr.name, + ).toBe(attr.normalized); + expect( + gl.getVertexAttribOffset(loc, gl.VERTEX_ATTRIB_ARRAY_POINTER), + attr.name, + ).toBe(attr.offset); + } + gl.bindVertexArray(null); + state.destroy(); + }); + + it("does not leak the bindings it uses while building", (ctx) => { + requireWebGL(ctx); + // this is the invariant whose absence corrupted the current + // batcher's uploads: building must be transparent to GL state + const other = makeState(); + const otherBuffer = gl.createBuffer(); + other.bind(); + gl.bindBuffer(gl.ARRAY_BUFFER, otherBuffer); + + const built = makeState(); + + expect(gl.getParameter(gl.VERTEX_ARRAY_BINDING)).toBe(other.handle); + expect(gl.getParameter(gl.ARRAY_BUFFER_BINDING)).toBe(otherBuffer); + + gl.bindVertexArray(null); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + built.destroy(); + other.destroy(); + gl.deleteBuffer(otherBuffer); + }); + + it("skips (and warns about) an attribute the shader does not declare", (ctx) => { + requireWebGL(ctx); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const state = makeState({ + resolveLocation: (name) => { + return name === "aColor" ? -1 : fixedLocations(name); + }, + }); + state.bind(); + expect(gl.getVertexAttrib(2, gl.VERTEX_ATTRIB_ARRAY_ENABLED)).toBe( + false, + ); + gl.bindVertexArray(null); + const warned = warnSpy.mock.calls.filter((args) => { + return /aColor/.test(String(args[0])); + }); + expect(warned.length).toBe(1); + state.destroy(); + } finally { + warnSpy.mockRestore(); + } + }); + }); + + describe("bind()", () => { + it("makes this vertex array current", (ctx) => { + requireWebGL(ctx); + const a = makeState(); + const b = makeState(); + a.bind(); + expect(gl.getParameter(gl.VERTEX_ARRAY_BINDING)).toBe(a.handle); + b.bind(); + expect(gl.getParameter(gl.VERTEX_ARRAY_BINDING)).toBe(b.handle); + gl.bindVertexArray(null); + a.destroy(); + b.destroy(); + }); + }); + + describe("build()", () => { + it("replaces the GL handle while keeping the object identity", (ctx) => { + requireWebGL(ctx); + const state = makeState(); + const first = state.handle; + state.build(); + expect(state.handle).not.toBe(first); + expect(gl.isVertexArray(first)).toBe(false); + expect(gl.isVertexArray(state.handle)).toBe(true); + state.destroy(); + }); + + it("re-points the layout at a replaced vertex buffer", (ctx) => { + requireWebGL(ctx); + const state = makeState(); + const replacement = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, replacement); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(600), gl.STREAM_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + + state.build({ buffer: replacement }); + state.bind(); + expect(gl.getVertexAttrib(0, gl.VERTEX_ATTRIB_ARRAY_BUFFER_BINDING)).toBe( + replacement, + ); + gl.bindVertexArray(null); + state.destroy(); + gl.deleteBuffer(replacement); + }); + + it("captures an index buffer supplied in the descriptor", (ctx) => { + requireWebGL(ctx); + const indexBuffer = new WebGLIndexBuffer(gl, 60, true); + const state = makeState({ indexBuffer }); + state.bind(); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe( + indexBuffer.buffer, + ); + gl.bindVertexArray(null); + state.destroy(); + indexBuffer.destroy(); + }); + }); + + describe("captureIndexBuffer()", () => { + it("captures a later-created index buffer into this vertex array", (ctx) => { + requireWebGL(ctx); + const state = makeState(); + state.bind(); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe(null); + gl.bindVertexArray(null); + + let created; + state.captureIndexBuffer(undefined, () => { + created = new WebGLIndexBuffer(gl, 60, true); + created.fillQuadPattern(10); + state.descriptor.indexBuffer = created; + }); + + state.bind(); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe( + created.buffer, + ); + gl.bindVertexArray(null); + state.destroy(); + created.destroy(); + }); + + it("restores the previous bindings, so a foreign state is untouched", (ctx) => { + requireWebGL(ctx); + const current = makeState(); + const foreignBuffer = gl.createBuffer(); + current.bind(); + gl.bindBuffer(gl.ARRAY_BUFFER, foreignBuffer); + + const other = makeState(); + const idx = new WebGLIndexBuffer(gl, 60, true); + other.captureIndexBuffer(idx); + + expect(gl.getParameter(gl.VERTEX_ARRAY_BINDING)).toBe(current.handle); + expect(gl.getParameter(gl.ARRAY_BUFFER_BINDING)).toBe(foreignBuffer); + // and the capture landed in the OTHER state, not the current one + current.bind(); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe(null); + other.bind(); + expect(gl.getParameter(gl.ELEMENT_ARRAY_BUFFER_BINDING)).toBe(idx.buffer); + + gl.bindVertexArray(null); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + current.destroy(); + other.destroy(); + idx.destroy(); + gl.deleteBuffer(foreignBuffer); + }); + }); + + describe("release() / destroy()", () => { + it("release() deletes the handle and is idempotent", (ctx) => { + requireWebGL(ctx); + const state = makeState(); + const handle = state.handle; + state.release(); + expect(gl.isVertexArray(handle)).toBe(false); + expect(state.handle).toBe(null); + expect(() => { + state.release(); + }).not.toThrow(); + expect(gl.getError()).toBe(gl.NO_ERROR); + }); + + it("destroy() releases the handle and drops the descriptor", (ctx) => { + requireWebGL(ctx); + const state = makeState(); + const handle = state.handle; + state.destroy(); + expect(gl.isVertexArray(handle)).toBe(false); + expect(state.descriptor).toBe(null); + expect(gl.getError()).toBe(gl.NO_ERROR); + }); + + it("destroy() is safe on a handle from a lost context", (ctx) => { + requireWebGL(ctx); + // deleting an object belonging to a lost context queues + // INVALID_OPERATION — the isVertexArray probe must prevent it. + // Simulated by handing it a foreign (never-valid) handle. + const state = makeState(); + state.handle = gl.createVertexArray(); + gl.deleteVertexArray(state.handle); + while (gl.getError() !== gl.NO_ERROR) { + /* drain */ + } + state.destroy(); + expect(gl.getError()).toBe(gl.NO_ERROR); + }); + }); + + it("integrates with the real batcher shader locations", (ctx) => { + requireWebGL(ctx); + // sanity: the production resolveLocation shape (a shader lookup) + // works exactly like the fixed map used above + const state = makeState({ + resolveLocation: (name) => { + return shader.getAttribLocation(name); + }, + }); + state.bind(); + const loc = shader.getAttribLocation("aVertex"); + expect(gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED)).toBe(true); + gl.bindVertexArray(null); + state.destroy(); + }); +}); diff --git a/packages/melonjs/tests/webglrendertarget.spec.js b/packages/melonjs/tests/webglrendertarget.spec.js index 11afe0b60e..261c44bfdd 100644 --- a/packages/melonjs/tests/webglrendertarget.spec.js +++ b/packages/melonjs/tests/webglrendertarget.spec.js @@ -23,8 +23,9 @@ const CLAMP_TO_EDGE = 0x812f; const ACTIVE_TEXTURE = 0x84e0; const TEXTURE0 = 0x84c0; -// Build a minimal mock gl context. `extras` overrides any default field — -// the `webGL1` helper omits the WebGL1-undefined `DEPTH_STENCIL_ATTACHMENT`. +// Build a minimal mock gl context, WebGL2-shaped: the packed depth-stencil +// constants are always present on a real WebGL 2 context (the WebGL 1 +// missing-constant fallback was removed with WebGL 1 support in 20.0). function makeMockGL(extras = {}) { const calls = { renderbufferStorage: [], @@ -32,7 +33,9 @@ function makeMockGL(extras = {}) { texImage2D: [], }; const base = { - // constants — the bug's blast radius depends on which are present + // constants — always present on a WebGL 2 context + DEPTH_STENCIL, + DEPTH_STENCIL_ATTACHMENT, RENDERBUFFER, FRAMEBUFFER, FRAMEBUFFER_COMPLETE, @@ -90,15 +93,13 @@ function makeMockGL(extras = {}) { } describe("WebGLRenderTarget", () => { - describe("WebGL1 stencil constant fallbacks", () => { + describe("packed depth-stencil attachment", () => { let gl; beforeEach(() => { - // WebGL1 worst case: gl context exposes neither DEPTH_STENCIL nor - // DEPTH_STENCIL_ATTACHMENT (both `undefined`). gl = makeMockGL(); }); - it("falls back to numeric DEPTH_STENCIL (0x84F9) for renderbufferStorage", () => { + it("uses the context's DEPTH_STENCIL for renderbufferStorage", () => { const target = new WebGLRenderTarget(gl, 256, 128); expect(target).toBeDefined(); @@ -110,7 +111,7 @@ describe("WebGLRenderTarget", () => { expect(call[3]).toBe(128); }); - it("falls back to numeric DEPTH_STENCIL_ATTACHMENT (0x821A) for framebufferRenderbuffer", () => { + it("uses the context's DEPTH_STENCIL_ATTACHMENT for framebufferRenderbuffer", () => { const target = new WebGLRenderTarget(gl, 256, 128); expect(target).toBeDefined(); @@ -121,8 +122,8 @@ describe("WebGLRenderTarget", () => { expect(call[2]).toBe(RENDERBUFFER); }); - it("uses gl context constants when they are exposed (WebGL2 path)", () => { - // WebGL2-style: constants present on the gl object. + it("honors whatever constant values the context exposes", () => { + // paranoia: values must be read off the context, never hardcoded const webgl2 = makeMockGL({ DEPTH_STENCIL: 0x99aa, DEPTH_STENCIL_ATTACHMENT: 0x99bb, @@ -134,7 +135,7 @@ describe("WebGLRenderTarget", () => { expect(webgl2.__calls.framebufferRenderbuffer[0][1]).toBe(0x99bb); }); - it("resize() reuses the same fallback constant", () => { + it("resize() reuses the context constant", () => { const target = new WebGLRenderTarget(gl, 256, 128); gl.__calls.renderbufferStorage.length = 0; From ab74325d411186672d3b116bc3dae8737a7c91d7 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Wed, 29 Jul 2026 07:16:39 +0800 Subject: [PATCH 2/2] docs(webgl): correct WebGL-availability docs for the WebGL 2 requirement - `device.isWebGLSupported()` documented as probing WebGL 2, and as the same probe renderer construction uses, with the AUTO-falls-back / WEBGL-throws consequence spelled out (the old text claimed the renderer "will switch to CANVAS mode", true only on the AUTO path) - `failIfMajorPerformanceCaveat` notes that melonJS defaults it to `true` where the WebGL default is `false`, and what that means combined with the WebGL 2 requirement - changelog: record the resulting narrowing of which devices get the WebGL renderer Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JvxaXQRgQn5AoR8DNkv6Wg --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/application/settings.ts | 12 +++++++++--- packages/melonjs/src/system/device.ts | 11 ++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index c7ee32944f..65b7c70c53 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -9,6 +9,7 @@ - `renderer.type` is always `"WebGL2"` for the WebGL renderer; `renderer.WebGLVersion` is deprecated (always `2`) - behavior corrections on ex-WebGL-1 configs: `repeat` wrap now genuinely tiles non-power-of-two textures (was clamp + warning), `"darken"` / `"lighten"` blend modes use true MIN/MAX equations (were silently downgraded to `"normal"`), and `createPattern()` accepts non-power-of-two sources (threw before) - TMX GPU tilemap eligibility is now advertised through `renderer.supportsShaderTileLayers` (a backend capability flag) instead of a WebGL-version check +- the WebGL renderer is now selected only on devices providing a WebGL 2 context **and** passing the `failIfMajorPerformanceCaveat` check (which melonJS leaves enabled by default, unlike the WebGL default of `false`) — a software rasterizer or blocklisted driver therefore gets the Canvas renderer under `video.AUTO`, and throws under `video.WEBGL`. Set `failIfMajorPerformanceCaveat: false` to accept such a context - **each `Batcher` now owns an immutable Vertex Array Object** built at init ([#1509](https://github.com/melonjs/melonJS/issues/1509)): vertex attribute layout is frozen once built, batcher switches cost a single `bindVertexArray` (steady-state frames issue zero attribute-specification calls, measured), and `Batcher.unbind()` no longer disables attribute arrays. Custom batchers inheriting `Batcher.init()`/`bind()` need no changes. Custom shaders hosted by a built-in batcher must declare that batcher's attributes first, in layout order (ShaderEffect-generated vertex shaders already comply) — a console warning fires on mismatch. `GLShader.setVertexAttributes` is no longer called by the engine (still public) ### Performance diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts index a905b8cc79..b8e73c1c28 100644 --- a/packages/melonjs/src/application/settings.ts +++ b/packages/melonjs/src/application/settings.ts @@ -181,9 +181,15 @@ export type ApplicationSettings = { */ gpuTilemap: boolean; /** - * if true, the renderer will fail if the browser reports a major performance caveat - * (e.g. software WebGL). Set to false to allow WebGL on machines with - * blocklisted GPU drivers or software renderers. + * If true, treat WebGL as unavailable when the browser warns that a + * context would perform dramatically worse than a native application + * (a software rasterizer, a blocklisted driver). Note this is stricter + * than the WebGL default, which is `false`. + * + * Combined with the WebGL 2 requirement, the effect is: under + * {@link AUTO} such a machine gets the Canvas renderer, and under + * {@link WEBGL} construction throws. Set to `false` to accept a + * software or blocklisted WebGL context instead. * @default true */ failIfMajorPerformanceCaveat: boolean; diff --git a/packages/melonjs/src/system/device.ts b/packages/melonjs/src/system/device.ts index 8937de084e..f10cb5d512 100644 --- a/packages/melonjs/src/system/device.ts +++ b/packages/melonjs/src/system/device.ts @@ -637,10 +637,15 @@ export function getParentBounds(element: string | HTMLElement) { } /** - * returns true if the device supports WebGL + * Returns true if the device supports WebGL 2 — the version the WebGL + * renderer requires since 20.0. This probes the same context type that + * renderer construction requests, so the two can never disagree. + * + * When it returns false, `renderer: video.AUTO` falls back to the Canvas + * renderer and `renderer: video.WEBGL` throws at construction time. * @param [options] - context creation options - * @param [options.failIfMajorPerformanceCaveat=true] - If true, the renderer will switch to CANVAS mode if the performances of a WebGL context would be dramatically lower than that of a native application making equivalent OpenGL calls. - * @returns true if WebGL is supported + * @param [options.failIfMajorPerformanceCaveat=true] - if true, report WebGL as unsupported when the browser warns that a context would perform dramatically worse than a native application (e.g. a software rasterizer). Set false to accept WebGL on blocklisted drivers or software renderers. + * @returns true if WebGL 2 is supported * @category Application */ export function isWebGLSupported(options?: {