Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: melonjs/melonJS
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 19.x
Choose a base ref
...
head repository: melonjs/melonJS
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref
Checking mergeability… Don’t worry, you can still create the pull request.
  • 17 commits
  • 421 files changed
  • 2 contributors

Commits on Jul 29, 2026

  1. WebGL 2 only renderer + Vertex Array Objects (#1509) — 20.0.0 (#1553)

    * feat(webgl)!: WebGL 2 only renderer + Vertex Array Objects (#1509)
    
    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 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01JvxaXQRgQn5AoR8DNkv6Wg
    
    * 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 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01JvxaXQRgQn5AoR8DNkv6Wg
    
    ---------
    
    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
    obiot and claude authored Jul 29, 2026
    Configuration menu
    Copy the full SHA
    a6284cc View commit details
    Browse the repository at this point in the history

Commits on Jul 31, 2026

  1. Retained-mode mesh rendering (#1507) + shared WebGL test context — 20…

    ….0.0 (#1557)
    
    Converts mesh rendering from immediate mode to retained mode, closing #1507.
    
    Geometry now lives on the GPU in model space and placement is carried by
    uniforms (uModelMatrix / uViewMatrix / uTint), so moving, rotating, scaling or
    re-tinting a mesh touches no buffer. Buffers are rewritten only when the mesh
    signals a geometry change via the new `Mesh.needsUpdate`. There is no "static"
    flag — placement stopped being part of the geometry, so nothing needs to opt in,
    and animated glTF models are retained too. The Camera2d path stays immediate,
    since its CPU perspective divide cannot fold into a mat4.
    
    Two wins fall out as deletions: indices upload as authored with the reflection
    bridge corrected by frontFace(CW) instead of a reversed index copy, and
    getBounds3d() bounds model-space geometry through the model matrix, so it is
    correct before the first draw. A mesh past 65 535 vertices is one drawElements
    call instead of N chunks.
    
    Measured on an Apple M4 Max (ANGLE Metal), draw-phase CPU per frame: 3.6ms ->
    0.20ms at 158k vertices, 13.8ms -> 0.29ms at 1.16M. Linear before, flat after —
    at 1.16M, submitting the scene went from 83% of a 60fps budget to under 2%.
    
    Also included:
    
    - `Application.updateAverageDelta` renamed to `lastUpdateDelta`, with a
      deprecated alias. It has never been an average since 2015.
    - debug-plugin 16.1.1: both frame-time readouts were wrong. `draw` printed two
      decimals on a clock clamped to 100µs; `update` differenced two values that
      are not a matched pair and went negative. The panel also now reads the
      application it was registered against rather than the global singleton, and
      derives its minimum-engine gate from peerDependencies.
    - Test suite: a shared WebGL context fixture (33 spec files created their own
      context, now 7, each with a documented reason), the melonjs vitest config
      anchored to its own package so it stops globbing the whole monorepo, and a
      CI-scoped tripwire that fails the run when WebGL is unavailable rather than
      letting hundreds of specs skip into a green build.
    
    An adversarial pre-merge review caught five defects fixed here — lit meshes
    could render NaN, toPolygon() returned a hull collapsed to the origin, GPU
    geometry leaked on keepalive/pooled removal, drawElements ignored batcher.mode,
    and frontFace was never restored — plus nine tests that passed vacuously.
    obiot authored Jul 31, 2026
    Configuration menu
    Copy the full SHA
    8de1d03 View commit details
    Browse the repository at this point in the history
  2. feat(video): backend-neutral vertex formats and draw topologies (#1551)…

    … (#1558)
    
    Adopts a backend-neutral vocabulary for vertex layouts and draw topologies,
    closing #1551 and laying groundwork for the WebGPU backend (#1184).
    
    An attribute can be declared with a single format token and a draw with a
    topology name:
    
        { name: "aColor", format: "unorm8x4", offset: 20 }
        batcher.mode = "triangle-list"
    
    The GLenum form is accepted indefinitely and behaves exactly as before.
    `addAttribute` takes three shapes — a descriptor object, (name, format,
    offset), and the existing (name, size, glType, normalized, offset) — and
    records carry both spellings, so every existing reader of size/type/normalized
    is untouched and vertexstate.js needed no change. The spine plugin still
    declares its layout in GLenum and was left alone as the live back-compat test.
    
    The format vocabulary is canonical because translation is only lossless
    downward: "unorm8x4" yields 4 + UNSIGNED_BYTE + normalized, while
    UNSIGNED_BYTE alone cannot say how many components or whether it is
    normalized. A format-declared layout also needs no live rendering context.
    
    `Batcher.mode` becomes an accessor pair over one private canonical topology.
    A plain field cannot intercept `batcher.mode = "triangle-list"`, and WebIDL
    coerces that string to 0 — which is GL_POINTS — so the batch would render as
    points with no error. Reading `mode` still returns the GLenum, so internal
    comparisons are untouched; `topology` is the new portable spelling.
    
    Two behaviour changes on the legacy path, both fixes, both in the changelog:
    an omitted offset now packs after the previous attribute instead of silently
    defaulting to byte 0, and a stride that is not a multiple of 4 now throws
    instead of building a fractional vertex size that discarded every write.
    
    214 new tests, 100% line/branch/function coverage on all four new modules,
    verified visually across ten examples.
    obiot authored Jul 31, 2026
    Configuration menu
    Copy the full SHA
    f853e8d View commit details
    Browse the repository at this point in the history

Commits on Aug 1, 2026

  1. feat(video): light data in a std140 uniform buffer, 32-light cap (#1552

    …) (#1559)
    
    Light data for both lit paths moves out of GLSL uniform arrays and into a
    std140 uniform buffer, raising MAX_LIGHTS from 8 to 32 for Light2d (lit
    sprites) and Light3d (lit meshes). The old cap was a compatibility limit:
    uniform arrays are charged against MAX_FRAGMENT_UNIFORM_VECTORS, a small
    driver-reported budget shared with every other uniform a shader declares.
    A uniform block is charged against MAX_UNIFORM_BLOCK_SIZE instead (>=16 KB
    everywhere); 32 lights occupy 1056 bytes. A static light rig still costs
    zero GL calls per frame.
    
    This raises capacity, not shading cost: the fragment loop still runs once
    per pixel per live light.
    
    The four lit shaders move to GLSL ES 3.00, since uniform blocks do not
    exist in ES 1.00. User shaders are unaffected — ShaderEffect bodies and raw
    GLShader sources stay ES 1.00.
    
    Also fixes a regression from #1468: a scene containing only meshes stopped
    clearing its depth buffer after the first frame and its geometry
    disappeared. The depth clear and the lit-mesh light upload both ran from
    MeshBatcher.bind(), which is a per-transition hook and not a per-frame one
    — setBatcher returns early when the requested batcher is already current.
    Both now refresh on the draw path via MeshBatcher.updatePassState().
    
    Closes #1552.
    
    Co-Authored-By: Claude <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01JvxaXQRgQn5AoR8DNkv6Wg
    obiot and claude authored Aug 1, 2026
    Configuration menu
    Copy the full SHA
    06d3f4f View commit details
    Browse the repository at this point in the history
  2. refactor(video): allocate uniform buffer binding points from the rend…

    …erer (#1560)
    
    Binding points are a context-wide namespace: two uniform blocks sharing one
    silently overwrite each other, and the loser reads the winner's bytes as its
    own data. They were two literals in the lighting module, so a third block
    would have had to grep for a free integer and a collision would not have been
    caught.
    
    WebGLRenderer.reserveUniformBindingPoint() now hands them out, mirroring how
    texture units are claimed through TextureCache.reserveUnit(), and throws when
    MAX_UNIFORM_BUFFER_BINDINGS is exhausted rather than binding out of range.
    Both lit batchers claim once and hold: init() re-runs on every context
    restore, and re-claiming each time would walk through the budget.
    
    Internal only, no behaviour change — the points handed out are 0 and 1, the
    same values the constants held. Point 0 stays occupied deliberately: an active
    uniform block with no buffer bound is INVALID_OPERATION at draw time rather
    than a read of zeroes.
    
    Follow-up to #1552. Two other follow-ups from that review were investigated
    and rejected on measurement rather than implemented; numbers are in the PR.
    
    Co-Authored-By: Claude <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01JvxaXQRgQn5AoR8DNkv6Wg
    obiot and claude authored Aug 1, 2026
    Configuration menu
    Copy the full SHA
    07965de View commit details
    Browse the repository at this point in the history

Commits on Aug 2, 2026

  1. Two-phase async Application startup (await app.init()) + experimental…

    … WebGPU renderer bootstrap — 20.0.0 (#1561)
    
    * feat(application): two-phase async startup + experimental WebGPU bootstrap (#1184) — 20.0.0
    
    Breaking: starting a game is now construct + mandatory `await app.init()`.
    The constructor owns everything renderer-independent (settings resolution,
    game world, DOM event bridging); init() builds the renderer and everything
    downstream, and is asynchronous because a WebGPU device cannot be acquired
    synchronously. The exported `game` names the most recently initialized
    Application — set at the END of init(), undefined before the first one.
    `video.init()`, `video.renderer`, `video.createCanvas()`,
    `video.getParent()` and the `legacy` setting are removed. destroy() is
    terminal; a repeated init() is a warned no-op.
    
    The experimental WebGPU backend is the first consumer of the async path:
    GPUCanvasContext at construction, adapter/device negotiation + canvas
    configuration in renderer.init(), and a real per-frame clear pass; every
    other drawing method stays a base-class no-op. Opt-in only — AUTO never
    selects it, and init() rejects when WebGPU is unavailable. Renderers
    gained an argument-less async init() lifecycle hook awaited by
    Application.init().
    
    Tests and examples migrated to `const app = new Application(...);
    await app.init()`; spec files no longer read the `game` global (the
    singleton spec excepted). Lifecycle hardening from adversarial review:
    destroy() during a pending init() aborts instead of resurrecting the app,
    each backend stamps its own context type so a WEBGPU rejection can be
    retried as Canvas, and the MutationObserver / GAME_RESET subscriptions
    are released on destroy. New Hello WebGPU example; all 43 examples
    verified via Playwright; full suite 5223 passing.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * docs(changelog): keep the two-phase startup entry to the user-facing API
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * test(adapters,debug-plugin): migrate package suites off the removed video.init()
    
    The CI test job also runs the matter-adapter, planck-adapter and
    debug-plugin suites, which still bootstrapped through `video.init()` —
    removed by the two-phase Application startup. Migrated all seven specs to
    `const app = new Application(...); await app.init()`, and updated the
    debug-plugin app-binding test to the new `game` semantics (the global
    names the most recently *initialized* Application, no longer the most
    recently constructed one). matter 177 / planck 169 / debug-plugin 16
    passing locally.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    ---------
    
    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
    obiot and claude authored Aug 2, 2026
    Configuration menu
    Copy the full SHA
    c71d5d0 View commit details
    Browse the repository at this point in the history
  2. WebGPU 2D pipeline: sprites, text, primitives, blend modes, clipping …

    …and stencil masks (#1184) — 20.0.0 (#1562)
    
    * docs(changelog): call out the await app.init() requirement for existing new Application users
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(video): WebGPU 2D pipeline — sprites, text, primitives, blend modes, clipping, stencil masks (#1184)
    
    The experimental WebGPU backend grows from bootstrap to the full
    non-post-effect 2D contract, mirroring the WebGL renderer philosophy
    exactly (batcher lifecycle, flush-before-state-change, RenderState-owned
    transforms):
    
    - One command encoder + render pass per frame (clear() opens, flush()
      submits), with a depth24plus-stencil8 attachment carried from day one
      so masks (now) and meshes (later) never reshape the pipeline set.
    - WGSL quad + primitive pipelines sharing the frozen GL vertex layouts —
      the backend-neutral vertex formats of #1551 are consumed declaratively
      into GPUVertexBufferLayout (#1492), including the GL-convention clip-z
      remap and the packed-ARGB `.bgr * .a` premultiply contract.
    - Per-frame buffer arena (each internal flush gets its own region) and a
      dynamic-offset uniform ring for frame globals (projection + line
      width — the #1555 bind-group-0 shape), so mid-frame projection swaps
      (floating containers) keep every recorded draw on its own slot.
    - Pipeline cache keyed by shader/topology/blend/pma/stencil-mode: all
      six blend modes (min/max darken/lighten included), stencil write/test
      variants for setMask/clearMask (level-0 entry breaks the pass with
      stencilLoadOp clear), scissor clipping with clamped
      transform-derived AABBs.
    - Texture store: copyExternalImageToTexture uploads with the GL
      premultiply convention, sampler cache with per-axis repeat, video
      version-stamp reupload, filter changes re-pair bind groups without
      re-uploading, TextureCache unit bookkeeping reused untouched.
    - Device-loss recovery renegotiates and rebuilds in dependency order;
      CanvasRenderTarget gained WebGPU invalidate/destroy branches so
      dynamic Text re-bakes reach the resident texture.
    
    Hello WebGPU example reworked into a parity scene (blend modes, masked
    sprite, clipped container, primitive shapes); verified pixel-level on an
    Apple Metal adapter — note headless SwiftShader negotiates a device but
    cannot present, so WebGPU visual verification requires a headed browser.
    Suite 5240 passing across packages; device-dependent specs skip visibly
    where WebGPU is absent.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(webgpu): validate texture-store records against their source (recycled units served stale pixels)
    
    A TextureCache unit number is not a stable identity: units freed by a
    stage switch (the loading screen's assets) get recycled for new sources
    with no per-unit release event, and the WebGPU texture store kept
    serving the old resident texture for the reused unit — the platformer's
    sky background pattern rendered the loading screen's 256x256 leftovers.
    Every lookup now validates the record's SOURCE: a recycled unit
    re-uploads in place (same-size path keeps the GPUTexture and its bind
    groups; a size change recreates), which also covers ghost frames from
    stale loading-screen pixels.
    
    Also: getUriFragment now splits on "?" as well as "&", so engine flags
    coexist with SPA hash routers — `/#/platformer?webgpu` runs the stock
    platformer example on the WebGPU backend, verified end-to-end on an
    Apple Metal adapter (parallax + screen-blend clouds + tiles + minimap).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(webgpu): harden the frame lifecycle per phase-1 review
    
    - primitive lineWidth rides the frame-globals slot, which clear()
      rewrites every frame — compare against the value the current slot was
      written with instead of a batcher-local cache that goes stale across
      frames (thick strokes thinned to 1px from the second frame on)
    - replaced GPUTextures retire at frame end instead of being destroyed
      mid-frame: draws already recorded against them would make the whole
      queue.submit() fail validation and drop the frame
    - a record already sampled this frame gets a fresh texture on re-upload:
      queue writes execute before every recorded draw, so an in-place
      re-upload applied retroactively (shared gradient/Text canvases baking
      different content mid-frame)
    - setProjection pushes a frame-globals slot even with no open pass
      (slots are plain buffer writes; the projection was stale after an
      explicit mid-frame flush)
    - _ensurePass pushes a slot when none exists yet (out-of-bracket draws
      before the renderer's first clear() crashed on a null binding)
    - reset() during the device renegotiation window no longer re-inits
      batchers against an undefined device
    - clear() emits RENDER_TARGET_CHANGED (GL parity, mesh-path contract)
    - _applyScissor clamps to the attachment (out-of-bounds scissor is a
      validation error under WebGPU where GL clamps)
    - removed the unreachable-and-wrong triangle-fan branch from the
      chunked primitive path; documented the clearColor-honors-mask
      divergence from GL
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * refactor(video): shared Batcher base class, webgpu folder subdivision, no underscore prefixes
    
    Review comments on the phase-1 PR:
    
    - `Batcher` (src/video/gpu/) is now the backend-neutral base class
      defining the shared lifecycle contract (init/bind/unbind/flush/reset/
      destroy); the WebGL base batcher is renamed `WebGLBatcher` and
      `WebGPUBatcher` derives from the same base, so `addBatcher()` accepts
      a custom batcher from either backend and validates it up front.
      WebGPU batcher classes are exported. SpineBatcher moves to
      `WebGLBatcher` (spine-plugin 4.0.0, peer melonjs >=20)
    - the webgpu folder now mirrors the webgl subdivision: buffer/ (arena,
      uniform ring), pipeline/ (cache, bind-group constants), texture/
      (store), batchers/, shaders/
    - underscore-prefixed internals renamed throughout the webgpu backend
      (commandEncoder, renderPass, pushFrameGlobals, ...) per the 20.0
      naming convention — private means not exported, and names follow the
      WebGPU vocabulary minus the GPU prefix
    
    The neutral base deliberately has no constructor: the backend bases call
    this.init() from their own constructor AFTER super(), because a derived
    class's private fields (#topology/#mode) are not installed until the
    base constructor returns.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * test(webgpu): unit coverage for every WebGPU class + review follow-ups
    
    Adversarial review of the refactor found no behavior drift; follow-ups:
    
    - addBatcher gates on the BACKEND base class (WebGLBatcher /
      WebGPUBatcher), not the neutral Batcher — a batcher from the wrong
      backend now fails up front with the message the gate promises,
      instead of mid-activation with an unrelated TypeError
    - a first WebGPUBatcher init without settings throws "attributes
      definition missing" (WebGL-base parity) instead of a confusing
      TypeError inside the pipeline cache
    - settings.ts types the WebGL-only batcher/compositor settings against
      WebGLBatcher; last three underscore members renamed (indexBuffer,
      maskDepthWarned, warnGradientShape)
    
    New mock-device unit suites (CI-safe, no GPU needed) covering every
    WebGPU class:
    
    - texture store: record lifecycle, source-identity revalidation on
      recycled units, same-frame fresh-texture rule, retire-vs-destroy,
      per-axis samplers, bind-group invalidation, cache-reset event
    - primitive batcher: lineWidth/frame-slot interplay (the review-fixed
      frame-2 thin-stroke bug now has a regression test), topology-switch
      flush, line-loop closure, fan re-expansion, thick-line expansion,
      chunk-boundary math
    - quad batcher: frozen 28-byte layout (corners/UVs/packed tint/depth),
      corner transform, material-adoption flush, indexed draw counts
    - buffer arena: alignment, region isolation, page rollover, oversized
      throw, reset/destroy
    - uniform ring: slot math, page-cached bind groups, exact frame-globals
      byte layout, 65th-slot rollover
    - pipeline cache: key dedupe, full blend table, stencil variants,
      vertex-layout consumption, strip index format, clear-never-blends
    - addBatcher gates + frozen bind-group constants
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    ---------
    
    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
    obiot and claude authored Aug 2, 2026
    Configuration menu
    Copy the full SHA
    eac8555 View commit details
    Browse the repository at this point in the history

Commits on Aug 3, 2026

  1. Dual-language ShaderEffect (GLSL + WGSL) and post effects on the WebG…

    …PU renderer — 20.0.0 (#1563)
    
    * refactor(video): hoist ShaderEffect + effects to a backend-neutral home
    
    Checkpoint 1 of the dual-language effect arc: ShaderEffect and the 19
    built-in effect classes move from src/video/webgl/ to src/video/effects/,
    and the pure GLSL source assembly (builtin parsing + vertex/fragment
    boilerplate) is extracted verbatim into glsl_realization.js so the class
    can dispatch per backend in the next step.
    
    Zero behavior change, proven two ways: every existing shader/effect spec
    passes unmodified, and a new generated-GLSL golden spec pins the
    assembled sources byte-identical across the move (snapshots generated
    against the pre-refactor assembly). Only mechanical path updates outside
    the moved files (index.ts, loader parser, drawLight import, camera2d,
    one dynamic-import path in lights.spec).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(video): dual-language ShaderEffect bodies + WGSL contract core
    
    Checkpoint 2 of the dual-language effect arc. ShaderEffect's body
    argument now accepts `{glsl, wgsl}` alongside the historical GLSL string
    (which keeps meaning GLSL everywhere); the constructor picks the body
    matching `renderer.shaderLanguage` and generalizes the Canvas inert-stub
    contract to any missing language: warn once, `enabled = false`, every
    method no-ops.
    
    The WGSL side (WebGPU has no uniform reflection, and preloaded assets
    are pure text, so everything derives from the source):
    - wgsl/parse.js — declaration-only parser: `fn apply`, one uniform
      struct at @group(3) @binding(0) whose members ARE the setUniform
      names, texture/sampler pairs at explicit consecutive bindings,
      builtins activated on reference; every malformed shape refuses with
      a reason (warn + inert, never a guessed offset)
    - wgsl/layout.js — uniform-address-space offset calculator (vec3
      align-16, 16-multiple array strides, struct tail rounding)
    - wgsl/scaffold.js — deterministic module assembly around the verbatim
      body: frozen quad vertex layout, clip-z remap, .bgr premultiply,
      y-down screen_uv, builtin bindings assigned above user bindings
    - wgsl_realization.js — CPU uniform mirror + values map (clone replay,
      device-loss-proof); GPU objects deferred to the renderer's effect path
    - pipeline cache: registerShader (module-text dedup → shared pipelines
      for clones), signature-cached effect layouts, shared empty group for
      the reserved lights slot, and a device epoch for lazy invalidation
    
    VignetteEffect carries the first WGSL twin as the reference body (its
    GLSL string is byte-identical — the golden spec proves it).
    
    61 new unit tests: layout offsets hand-computed, every parser refusal,
    scaffold snapshots + frozen-convention assertions, the full dispatch
    matrix on mock renderers, mirror byte placement, clone/destroy, and the
    pipeline-cache registration surfaces.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(video): WebGPU render targets, pass-target parametrization + frame capture
    
    Checkpoint 3 of the dual-language effect arc — the offscreen foundations
    the post-effect chain builds on:
    
    - WebGPURenderTarget: color texture in the canvas format (renderable +
      sampleable + copyable), generation-keyed lazy material bind group for
      blits, deferred clear via colorLoadOp, async readPixels() (sync
      getImageData throws with guidance). The depth-stencil attachment is
      SHARED (renderer-owned, sized per pass) — targets in the 2D flow are
      canvas-sized, and sharing the stencil means a surrounding mask keeps
      clipping offscreen content
    - pass-target parametrization: setRenderTarget(target, {clear}) is the
      retarget primitive (flush + pass break; next pass opens on the
      target's view with an optional clearing load); beginPass/viewport/
      scissor clamp against the active target's size; clear()/abandonFrame
      always return to the canvas
    - retireTexture() centralizes mid-frame texture disposal (recorded
      draws must outlive their resources until submit) — the texture store,
      targets, depth recreation and capture all route through it
    - captureFrame(): encoder-ordered copyTextureToTexture of the active
      destination into the shared WebGPUFrameTexture (the screen_texture
      builtin's backing); canvas configured with COPY_SRC usage; capture is
      copy-only so the next pass samples it hazard-free
    
    Unit specs: target lifecycle incl. mid-frame retire semantics,
    generation-keyed bind groups, pool composition with the WebGPU factory,
    capture reallocation. Full suite green; WebGPU platformer re-verified
    headed after the hot-path changes.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(video): WebGPU pooled post-effect path — effects composite on WebGPU
    
    Checkpoint 4 of the dual-language effect arc: beginPostEffect /
    endPostEffect / blitEffect on the WebGPU renderer, mirroring the WebGL
    control flow on the recording model — pooled offscreen capture (camera
    clears with background color + fresh stencil, sprites clear
    transparent), screen_texture capture points (camera-before / sprite-
    after retarget), camera-viewport scissor bounding, ping-pong chains,
    final blit with keepBlend semantics, per-depth projection stack restored
    through fresh frame-globals slots.
    
    The effect draw itself (effect_binding.js): device state built lazily
    per pipeline-cache epoch (module registered once per body text — clones
    share pipelines), group-3 layout from the parsed shape, and uniform
    values SNAPSHOT-PER-BIND into a dedicated dynamic-offset arena — the
    uniform twin of the vertex arena's queue-write-before-draws rationale,
    so a shared effect bound twice per frame with different values stays
    correct. Declared-but-unset textures and never-captured screen_texture
    bind a 1×1 stub so bind groups stay valid; an effect without a WGSL
    realization composites as a plain blit (content never lost).
    
    quad batcher blitTexture records the screen-space quad with UNFLIPPED
    UVs (WebGPU texture row 0 is the top — the GL flip exists because GL
    FBOs are bottom-up; apply()'s uv orientation matches across backends).
    
    Headed gate: the platformer minimap renders VIGNETTED under WebGPU —
    closing the phase-1 known degradation. 11 new mock-renderer tests pin
    the snapshot offsets, epoch rebuilds, stub/capture keying and the blit's
    pipeline + bind-group recording. Full suite 5351 green.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(video): WebGPU single-effect fast path (customShader)
    
    Checkpoint 5 of the dual-language effect arc. A single enabled effect on
    a non-managed renderable draws the sprite's own quad through the
    effect's pipeline — live compositing against the backdrop with blending
    KEPT, the semantic that distinguishes the fast path from the pooled
    blit (apply()'s uv is the sprite's atlas region; discard/edge effects
    composite differently through a target).
    
    WebGPUQuadBatcher adopts renderer.customShader like a material: pending
    vertices drain under THEIR pipeline before the state changes, and each
    effect sprite is its own draw with its own uniform snapshot, per-sprite
    noise_uv frame rect (min-normalized UVs, GL parity) and — for
    screen_texture effects — a fresh backdrop capture before the draw.
    
    Mock-renderer tests pin the adoption drain, per-quad draws with
    per-draw snapshots, capture-before-draw ordering, the ME rect values,
    and the return to plain batching when customShader clears.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(video): WGSL twin bodies for all built-in effects
    
    Checkpoint 6 of the dual-language effect arc: every built-in effect now
    carries a WGSL body beside its GLSL one (same logic, same uniform
    names — one setUniform serves both backends). Desaturate/Invert/Sepia
    inherit ColorMatrix's twin; RadialGradientEffect stays GLSL-only
    (drawLight is WebGL-internal; lights on WebGPU are a later phase).
    
    Porting notes encoded in the bodies:
    - new `vColor` builtin (reference-gated like the others): the
      interpolated tint under its GLSL varying name, for bodies that
      re-sample the source texture (blur, chromatic, pixelate, wave,
      glow, outline, drop shadow)
    - conditional/post-return sampling uses textureSampleLevel(…, 0.0)
      (WGSL uniform-control-flow rule; sprites are single-level textures
      so output is identical)
    - `discard` ports as-is (dissolve, scanline); swizzle-assignment and
      ternaries become vec4f reconstruction and select()
    
    The GLSL literals were wrapped, not touched — the generated-GLSL golden
    stays byte-identical. Headed gate: the shader-effects showcase renders
    side-by-side matching between WebGL and WebGPU across all 15 per-sprite
    effects + the viewport vignette, zero console/validation errors on a
    real adapter. A device-gated spec compiles every twin's scaffolded
    module via getCompilationInfo (skips visibly without WebGPU).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(loader): dual-language {glsl, wgsl} shader assets + effect docs
    
    Checkpoint 7 of the dual-language effect arc:
    
    - shader assets accept the dual shape — src: {glsl: url, wgsl: url} or
      inline via data, either language omittable — fetched with the
      established pair Promise.all pattern and compiled at load time into a
      shared ShaderEffect carrying both bodies. A body for a language the
      active renderer doesn't speak never fails the load: the preload
      succeeds with the inert stub (the Canvas-fallback contract), so mixed
      manifests stay portable across backends
    - the ShaderEffect class JSDoc documents the full dual-body contract
      and WGSL authoring convention (apply signature, the group-3 uniform
      struct whose member names are the setUniform names, texture/sampler
      pairs, builtins, the textureSampleLevel porting note)
    - CHANGELOG: effects-on-WebGPU entry; the WebGPU renderer entry's
      coverage claims updated (full 2D contract; post effects no longer in
      the not-yet list)
    
    New loader specs (existing describes untouched): dual inline + dual URL
    fetch on the WebGL renderer, and the wgsl-only-on-GLSL inert-stub
    mirror case with safe unload.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(examples): platformer viewport vignette gates on shader capability, not backend type
    
    The main-viewport VignetteEffect was guarded by `renderer instanceof
    WebGLRenderer` — the backend-type check the 20.0.0 capability flags
    replace — so under the WebGPU renderer the full-screen vignette was
    silently never attached (the visible WebGL/WebGPU difference: dark
    corners on one, none on the other). Gate on
    `renderer.shaderLanguage !== null` instead: Canvas still skips, both
    GPU backends attach, and the platformer renders identically under
    WebGL and WebGPU (minimap close-ups verified pixel-equivalent).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(video): linear-time block-comment stripping in the WGSL parser
    
    CodeQL flagged the lazy [\s\S]*? block-comment pattern as polynomially
    backtracking on pathological inputs (js/polynomial-redos). Replaced
    with the classic linear-time form; identical matching behavior,
    comment-handling specs unchanged.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(video): scan WGSL comments without a regex
    
    CodeQL's polynomial-redos check also flags the classic linear-time
    block-comment regex form. Comment stripping is now a plain single-pass
    character scanner — provably linear, identical output, parser specs
    unchanged.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(examples): platformer attaches its vignette with no renderer check at all
    
    Follow-up to the capability-flag guard: no guard is needed in the first
    place. ShaderEffect self-disables on a renderer without a programmable
    pipeline (warn once, enabled = false, scene renders without it), so the
    example simply attaches the effect unconditionally — the code a user
    should write. Verified headed on WebGL, WebGPU (vignetted) and Canvas
    (clean, un-vignetted).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(video): apply adversarial-review findings on the effect pipeline
    
    Second review pass over the dual-language effect arc — eight verified
    findings fixed, each with a regression test:
    
    - CRITICAL: WebGPUFrameTexture.generation was never advanced, so a
      canvas resize left every screen_texture bind group pointing at the
      destroyed capture — permanent whole-frame validation failure. Each
      capture instance now draws from a monotonic counter (and the test
      mock no longer models behavior the real class lacked)
    - pass restarts re-apply the stencil reference: content masked ACROSS a
      post-effect retarget or capture tested against level 0 (i.e. drew
      only OUTSIDE its mask) — beginPass now re-records maskVisibleRef
    - the clamp screen-sampler contributed no effect-layout signature
      token, so clamp-only and clamp+repeat shapes shared one cached layout
      (bind groups mismatched whichever registered second)
    - WGSL compilation failures now disable the effect asynchronously via
      getCompilationInfo (warn + enabled=false) instead of invalidating
      every subsequent submit — a parse-clean body with a bad expression
      black-screened forever
    - struct members split on top-level commas only: array<vec4f, N> —
      advertised and layout-supported — could never parse
    - mat3x3f values place per vec4-strided column (9-float column-major
      input); previously columns 2-3 read scrambled, diverging from GLSL
    - WGSL block comments nest — the scanner now tracks depth
    - effect destroy retires its resident setTexture textures; booleans
      normalize to 0/1; readPixels clamps its window and frees the staging
      buffer on rejection; clear() unwinds effectPassDepth (exception
      hygiene); capture generation only keys screen_texture consumers
    
    New webgpu_post_effect_flow.spec.js pins the pooled control flow's
    ordering laws through the REAL begin/endPostEffect bodies over recorded
    primitives: camera capture-before-retarget vs sprite capture-after,
    ping-pong clears, viewport clip bracket, keepBlend semantics, nested
    projection-slot restore, fast-path/filtering short-circuits.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(video): GPU tile rendering on the WebGPU backend (orthogonal TMX)
    
    The last missing shader family of the renderer's 2D contract
    (user-requested for this phase): orthogonal TMX tile layers now draw
    through the WGSL port of the shader tilemap path — one quad per tileset,
    the fragment shader sampling a per-layer GID index texture and the
    tileset atlas, oversized-tile candidate walk, flip mask decode, and
    animated tiles included (#1445 parity). supportsShaderTileLayers flips
    true; eligible layers resolve renderMode "shader" exactly as on WebGL.
    
    Backend mechanics: the GID index and animation lookups are
    renderer-owned rgba8unorm textures read byte-exact with textureLoad (no
    sampler) and re-uploaded only on dataVersion / frame advances; per-draw
    uniforms snapshot into the effect uniform arena with dynamic offsets
    (one region per tileset pass); the atlas rides the texture-store
    material path; pipelines flow through the registered shader family and
    the frozen quad vertex layout. The atlas samples use
    textureSampleLevel (non-uniform control flow; single-level textures).
    
    Lifecycle: lazily built per device epoch, resources retired on
    GAME_RESET / device loss / destroy.
    
    Verified headed: the platformer's three tile layers report renderMode
    "shader" under WebGPU and render pixel-identical to WebGL (animated
    water included). Six mock-renderer tests pin version-gated uploads,
    anim dirty semantics, per-tileset snapshots/draws, and reset retirement.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(tests): drop the unused batcher-name param in the tmx spec helper
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(examples): migrate stale bootstrap guards to createExampleComponent
    
    waterOverworld, tiledMapLoader and aseprite still hand-rolled their mount
    effect around `if (!game?.isInitialized)` — a guard written before the
    20.0 constructor/init() split. With `app.init()` now async, both React
    StrictMode dev effect runs pass the guard before init resolves, two
    Applications race, the second throws ("plugin debugPanel already
    registered") and its never-drawn canvas stacks on top of the live one —
    a black screen on plain WebGL. Route all three through the shared
    createExampleComponent helper (which already serializes StrictMode
    remounts and cross-example navigation), keeping each example's overlay
    UI. Also drop the obsolete preferWebGL1 option from the platformer
    bootstrap (WebGL2-only since 20.0).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(examples): WebGPU parity fixes across the 2D example sweep
    
    Findings from running every 2D example under both backends:
    - clipping: replace the deprecated setLineWidth() call (a 17.3.0 shim
      patched onto the Canvas/WebGL renderers only) with the lineWidth
      property — the WebGPU renderer never carried the legacy shim, so the
      example threw per-frame and rendered black.
    - platformer-matter: attach the viewport vignette unconditionally like
      the SAT platformer — the effect self-disables on renderers without a
      programmable pipeline, so the instanceof guard only suppressed it on
      WebGPU.
    - drop the obsolete preferWebGL1 option everywhere (WebGL2-only since
      20.0).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(examples): WGSL twin for the Water Overworld refraction shader
    
    The pond effect was GLSL-only, so the WebGPU renderer disabled it and
    drew flat water. The dual {glsl, wgsl} body brings the full
    screen_texture / screen_uv / noise_uv refraction to WebGPU, matching
    the WebGL rendering.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(video): shape-masked gradient fills on the WebGPU backend
    
    Port the GL #gradientMask machinery as two new pipeline stencil
    variants: "tag" stamps the shape's pixels with the dynamic stencil
    reference on a cleared stencil (always/replace — overdraw-immune,
    color writes off), and "mark" writes the reference only where the
    stencil's low 7 bits already match, so the high-bit marker can tag and
    untag visible pixels inside an active mask without disturbing mask
    levels. fillArc/fillEllipse/fillPolygon/fillRoundRect now clip the
    Canvas-baked gradient rect to the shape instead of falling back to a
    solid fill.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(video): toFrameTexture on the WebGPU backend
    
    Generalize the frame-capture machinery into the public toFrameTexture
    contract: shared renderer-owned slot by default, `target: null` for a
    caller-owned capture, a prior capture as target to refresh it in place
    (WebGPUFrameTexture gains an identity-preserving realloc that retires
    the old texture and advances the bind-group generation), and region
    capture with the same clamp rules as the GL backend — the public
    bottom-left origin converted to the copy's top-left one. captureFrame
    now delegates to it. Two documented divergences: alpha is preserved,
    and row 0 of the capture is the top of the frame — GLSL bodies flip
    with `1.0 - uv.y`, WGSL twins must not.
    
    The aquarium and heat-haze examples gain WGSL twins for their capture
    shaders through the dual {glsl, wgsl} asset shape, closing their
    black-screen gap under WebGPU.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(video): 2D lights and normal-map lighting on the WebGPU backend
    
    Port the complete Light2d pipeline. The pure-CPU lighting math
    (packLights + the published std140 block) is shared with the GL
    backend; everything GPU-side is new:
    
    - WebGPULitQuadBatcher: normal-mapped sprites shaded by the std140
      Light2dBlock at the reserved group 2. Single-texture-per-segment
      model — group 1 is a combined color+normal material, so the GL
      sampler ladder and per-vertex normal-texture id are gone and the
      frozen 28-byte quad layout is unchanged. Each setLightUniforms call
      snapshots the packed block into the effect uniform arena with a
      dynamic offset (queue writes execute before every recorded draw, so
      a shared region per camera would be retroactively clobbered).
      Normal maps are resident textures keyed by source, re-uploaded on
      the duck-typed version stamp, never premultiplied.
    - quad-lit.wgsl: the multitexture-lit port — same quadratic
      attenuation, Y-flipped normal decode and ambient floor.
    - drawLight rides the single-effect fast path with a now dual-language
      RadialGradientEffect (WGSL twin added), color+intensity packed into
      the per-vertex tint; the ambient-overlay cutouts already worked via
      the stencil mask machinery.
    - drawImage gates onto the lit batcher exactly like the GL backend
      (lit scene + normal map present), so unlit sprites pay nothing.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * refactor(video): TextureAtlas no longer reaches for the global game
    
    The renderer now carries its owning application (renderer.parentApplication,
    stamped by Application.init) so engine code holding a renderer reference
    never needs the global instance. TextureAtlas registration goes through
    the VIDEO_INIT-captured renderer's own cache — the same pattern the
    loader parsers already use.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * feat(video): compressed texture support on the WebGPU backend
    
    The loader's dds/ktx/ktx2/pvr/pkm parsers are backend-neutral and emit
    WebGL format constants; the WebGPU side now consumes them: the renderer
    requests the texture-compression-bc/etc2/astc device features the
    adapter offers and reports format families in the same shape as the GL
    backend (so the shared capability gate and the loader pre-filter work
    unchanged, ETC1 synthesized from ETC2, PVRTC honestly null — it has no
    WebGPU equivalent), and the texture store uploads compressed sources
    through a dedicated createTexture + block-aligned per-mip writeTexture
    path (compressed formats cannot be render attachments). sRGB variants
    map for KTX1 files, which carry raw GL internal formats.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * docs: WebGPU 2D feature completion in the 20.0.0 changelog entry
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(examples): whac-a-mole HUD z-order — addChild assigns the z
    
    The HUD relied on depth = Infinity set in its constructor, which
    world.addChild overwrites with an auto-assigned z (23) below the grass
    strips (up to 30) — add it with an explicit z above them. Note: the
    score text is still not visible pending an engine-side bug with
    BitmapText nested in floating Containers (draws are recorded with
    correct coordinates but never reach the screen, on both backends).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(video): recycled units never adopt an image source into a compressed texture
    
    Review finding: a texture-cache unit resident with a compressed-format
    texture, recycled to a same-size non-compressed source, fell into the
    same-size adopt branch — copyExternalImageToTexture into a
    non-renderable format fails validation while the already-adopted source
    match kept serving the stale compressed pixels forever. Compressed
    records are now stamped and always recreate on that transition.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(video): harden the WebGPU 2D port — review minors
    
    - drawImage: an active customShader now wins over the lit gate — the lit
      pipeline has no effect stage, so the sprite draws effected-but-unlit
      instead of silently losing the effect (deliberate, commented divergence
      from the GL combo)
    - lit batcher: light bindings are stamped with the frame they were
      snapshotted in; the lit gate requires a current-frame snapshot
      (hasCurrentLightBinding) so a previous-frame binding never samples
      reused arena bytes
    - lit batcher: a normal-map version bump on a map already sampled this
      frame lands in a fresh texture instead of an in-place re-upload
      (queue writes execute before every recorded draw — same rule as the
      texture store's color records)
    - setAntiAlias/setTextureFilter also clear the lit tier's combined
      color+normal bind groups (each embeds a sampler resolved from the
      default filter)
    - compressed textures: material bind groups sample a mip-0-only view for
      GL parity (the GL backend never samples the chain); the full chain
      stays uploaded
    - getSupportedCompressedTextureFormats: the all-null table produced while
      the device renegotiates is no longer memoized, so the replacement
      device's families are picked up
    - water overworld example: the WGSL twin's reflection is now the y-down
      mirror of the GLSL y-up affine, so the waterline sits at the same
      screen height on both backends
    
    Also swept in from the working tree: Renderable.preDraw treats a
    non-finite anchor offset as 0 (an Infinity-sized container child would
    inherit z = NaN and be clipped by the GPU backends) with a floating-
    container regression spec, and the platformer example runs on the WebGPU
    renderer.
    
    New coverage: drawImage lit-gate dispatch, lit reset()/clearMaterialCache
    lifecycle, same-frame normal-map version bumps, inverted gradientMask,
    destroyed-capture refresh, unsupported compressed format, and the
    renegotiation memo guard.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * fix(examples): platformer back on video.AUTO (WEBGPU was a local test)
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    ---------
    
    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
    obiot and claude authored Aug 3, 2026
    Configuration menu
    Copy the full SHA
    ff67146 View commit details
    Browse the repository at this point in the history

Commits on Aug 5, 2026

  1. WebGPU 3D tier + full backend parity, spine on WebGPU — 20.0.0 (#1184,

    …#1536) (#1564)
    
    * WebGPU 3D tier and full backend parity — 20.0.0 (#1184, #1536)
    
    The WebGPU renderer completes the engine contract and becomes the AUTO
    default (WebGPU → WebGL 2 → Canvas, negotiated inside app.init()):
    
    - 3D tier: drawMesh through unlit/lit WGSL pipelines — retained
      model-space geometry under Camera3d (upload once, placement/tint/
      cutoff/emissive per-draw uniforms) and the CPU-projected 2D path;
      per-mesh cull/winding as pipeline state; depth as pass load/store ops
      (one clear per target per frame, pure-2D passes byte-identical)
    - point + spot Light3d on both backends (#1536): 12-float std140 light
      entries (posRange/dirCone/colorInner), quadratic-over-range falloff,
      runtime-mutable fields; glTF loader instantiates all three punctual
      types (scene-scaled), carries authored light names, and gains a
      lightIntensityScale load option
    - custom mesh shaders on both backends: GLShader is now dual-language —
      object sources form {vertex, fragment, wgsl} with isWebGL/isWebGPU
      flags; mesh.shader hosts it (warn-and-degrade on mismatch); shader
      assets grow the matching complete-program shape
    - antiAlias => 4x MSAA on WebGPU canvas passes; mesh textures gain
      generated mip chains + trilinear + 4x anisotropy on both backends
      (authored compressed chains included; "nearest" opts out); 8-slot
      multi-texture quad batching
    - renderer parity batch: setBlendMode("none") on WebGL; enableScissor/
      setBlendEnabled/clearRenderTarget, settings.batcher, settings.blendMode,
      GPUVendor, failIfMajorPerformanceCaveat on WebGPU; frameless-video
      guard on WebGL; instance-scoped compressed-format memo
    - shine adopts the uUVYDir seam (vertical sweeps ran backwards on the GL
      pooled path); GLTFScene flags meshes lit for lamp-only scenes
    - shared neutral hoists: gpu/meshvertex.ts, gpu/quadcorners.ts,
      gpu/primitives.ts (~250 duplicated lines deleted)
    - d.ts hygiene: @internal + a strip-internal build pass keep renderer
      internals out of the published typings
    - examples swept to video.AUTO; 3D examples guard on supportsDepthBuffer
    - documentation refresh across JSDoc and README (retained-mesh story,
      capability flags, dual-language shaders, glTF light options)
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * Spine plugin: WebGPU spine batcher with full two-color tinting (4.0.0)
    
    On the WebGPU backend, skeletons now render through the new
    WebGPUSpineBatcher instead of the canvas-renderer fallback: spine-core's
    backend-neutral SkeletonRendererCore geometry pass (world vertices,
    clipping, draw-order batching, packed light+dark colors) feeds a WGSL
    port of the official two-colored-textured shader, one indexed draw per
    texture/blend batch — the same rendering model as the WebGL path.
    
    - dark-tint (two-color) rendering now works on WebGPU; mesh-heavy
      skeletons go from one drawImage per triangle to batched indexed draws
    - atlas pages stay image-backed and upload through the engine texture
      store honoring each page's pma flag
    - backend dispatch keys on renderer.type ("WebGL2"/"WebGPU"/canvas
      fallback) instead of the deprecated WebGLVersion sniff
    - SpineBatcher renamed WebGLSpineBatcher for symmetry (internal class)
    - bundled spine runtimes bumped ^4.3.7 -> ^4.3.13 (SkeletonRendererCore
      clipping fixes); debug rendering remains WebGL-only for now
    
    All 18 example skeletons verified headed on WebGPU, WebGL and Canvas.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    ---------
    
    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
    obiot and claude authored Aug 5, 2026
    Configuration menu
    Copy the full SHA
    648cfba View commit details
    Browse the repository at this point in the history

Commits on Aug 6, 2026

  1. Gradient + Text textures: allocation-stable sizing without power-of-t…

    …wo rounding (#1554) (#1566)
    
    * Gradient and Text textures: allocation-stable sizing without power-of-two rounding (#1554)
    
    The POT rounding on baked Gradient/Text textures was size hysteresis, not
    a WebGL 1 leftover — consecutive re-bakes must land on identical texture
    dimensions so updates stay on the cheap same-size upload path. Replace it
    with two schemes that keep the stability and shrink the waste:
    
    - Gradients bake into a FIXED 256x256 shared target (1:1 up to 256,
      transform-scaled beyond; the destination quad's stretch inverts the
      scale exactly). The shared canvas is allocated once and never resized,
      every re-bake is a same-size update, and gradient memory is capped at
      256 KB. toCanvas now returns {canvas, width, height} — the drawImage
      source rect — and the bake-reuse identity is the draw rect itself
      (dimension comparison would alias sizes on a fixed canvas).
    - Text canvases round to 32-pixel buckets (grow-only preserved) instead
      of the next power of two: a ticking counter re-bakes into identical
      dimensions and the same canvas element, while worst-case waste drops
      from up to 2x per axis to at most 31 px per axis.
    
    21 adversarial tests: pixel-level ramp correctness 1:1 and downscaled
    (row monotonicity as a banding detector), non-uniform radial scaling,
    offset rects, canvas identity stability, reuse vs dirty-repaint, the
    size-aliasing trap, degenerate/fractional rects, edge-padding opacity;
    text bucket-exactness property sweep, ticking-counter identity, boundary
    growth, never-shrinks, multiline height, huge-font waste bound. The
    gradients example diffs to zero changed pixels on all three backends.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * tests: borrow the shared renderer in the #1554 specs (context-budget fix)
    
    The two new spec files each booted their own Application into the shared
    vitest page — the exact anti-pattern the webgl-context helper documents:
    the session's GL context budget overflows and some UNRELATED late-running
    spec's beforeAll times out (renderTargetPool + texturecache-batcher-reset
    on CI, twice, deterministically). Both specs now borrow the session's
    single shared renderer with requireWebGL skip guards, adding zero
    contexts and zero live game loops to the shared page.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    ---------
    
    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
    obiot and claude authored Aug 6, 2026
    Configuration menu
    Copy the full SHA
    1e4ae9a View commit details
    Browse the repository at this point in the history
  2. Immutable texture storage + MSAA that survives post effects (#1556) (#…

    …1567)
    
    Two paired GPU-backend changes from the #1556 tracker, plus a 20.0
    documentation accuracy pass.
    
    texStorage2D + sized formats (WebGL)
    ------------------------------------
    Every texture the WebGL renderer allocates now uses immutable
    `texStorage2D` storage (sized RGBA8, mip-level count pinned at
    allocation) instead of per-level `texImage2D`, with content updates
    going through `texSubImage2D` into the existing allocation. This is the
    same immutable-allocation model WebGPU mandates, so both backends now
    share one texture lifecycle.
    
    The structural win is on same-size content updates. A ticking `Text`, a
    `Gradient` re-bake or a video frame previously created a fresh texture
    object on every change: `CanvasRenderTarget.invalidate` dropped the
    batcher's binding record, so the next upload took the allocate path.
    Invalidation now marks the unit content-dirty instead, and the upload
    lands as a pure `texSubImage2D` into storage the driver already owns —
    combined with the #1554 size buckets, the steady state of dynamic text
    and gradients allocates nothing at all.
    
    MSAA post-effect capture targets (both backends)
    ------------------------------------------------
    Adding any post effect used to silently switch `antiAlias` off: the
    scene rasterized into a single-sampled offscreen capture target, and the
    antialiased default framebuffer only ever received already-aliased
    pixels. Capture targets are now multisampled themselves — up to 4×
    color + depth-stencil renderbuffers resolved via `blitFramebuffer` on
    WebGL, a per-target multisampled texture resolved by the render pass on
    WebGPU. Ping-pong intermediates deliberately stay 1×: effect blits are
    screen-aligned quads with no geometric edges to antialias.
    
    The WebGPU multisampled half is per-target rather than shared, because
    nested effect brackets interleave passes and a mid-frame load-back would
    otherwise read another target's stored samples. `toFrameTexture()`
    resolves an active multisampled target before copying (reading from a
    multisampled framebuffer is a GL error) and rebinds it afterwards.
    
    Verified with a headed edge probe on both backends: with an effect
    active, MSAA produces the identical intermediate-coverage signature as
    the no-effect control (3 distinct levels — the 4× quantization), while
    `antiAlias: false` measures a hard step.
    
    Tests
    -----
    - texstorage.spec.js: immutable format/level assertions, texture-identity
      stability under forced re-uploads, shape-change replacement, blank
      allocation, ticking-Text integration, raw pixel views
    - msaa_post_effect.spec.js: real-driver framebuffer completeness, clear →
      resolve → readback round-trips, resolve re-arming, post-resize
      renderability, and the pool's capture/ping-pong contract
    - webglrendertarget.spec.js: MSAA lifecycle on the mock context, incl.
      resolve-once semantics, readPixels ordering and a samples:0 pin
    - webgpu_msaa.spec.js: capture-target pass shape and per-target
      multisampled texture lifecycle
    
    Documentation
    -------------
    - CHANGELOG entries for both features, with the memory/bandwidth cost of
      multisampled capture targets quantified
    - `antiAlias` setting doc: composes with post effects, and what it costs
    - README: `await app.init()` in the Hello World example (with a note on
      why it is required since 20.0), `postEffects` -> `addPostEffect()`, the
      pre-retained-mesh performance claim replaced, and the 20.0 GPU features
      that were missing from the feature list
    - the Hello WebGPU example is no longer labelled experimental
    
    
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
    obiot and claude authored Aug 6, 2026
    Configuration menu
    Copy the full SHA
    ee0e934 View commit details
    Browse the repository at this point in the history

Commits on Aug 7, 2026

  1. Mesh instancing: InstancedMesh + EXT_mesh_gpu_instancing (#1508) (#1571)

    Draw one geometry many times in a single call, on both GPU backends. Cost
    scales with the number of instances rather than with instances × vertices:
    the new Instanced Forest example renders 100 000 trees from one 52-vertex
    geometry in one `drawElementsInstanced`, at 60 fps on WebGL and WebGPU.
    
    The enabling refactor
    ---------------------
    Both backends assumed a single vertex buffer. `WebGLVertexState` and the
    WebGPU pipeline cache now take a LIST of buffer layouts, each with a step
    mode — the `GPUVertexBufferLayout[]` shape. Attributes in an "instance"
    group advance once per instance (`vertexAttribDivisor` / `stepMode`).
    Single-buffer callers are untouched: divisors are issued only for instance
    groups, and WebGPU omits `stepMode` for vertex groups, so every existing
    descriptor and pipeline key stays byte-identical.
    
    The record
    ----------
    A row-major 3×4 affine transform (48 B) — the bottom row of an affine
    matrix is always (0,0,0,1), so a full mat4 would waste 16 bytes and an
    attribute slot per instance. Two opt-in slots follow: a colour multiplied
    into the mesh tint, and an opaque vec4 the built-in shading reads as
    emissive and a custom shader may read as anything. Slot locations are
    PINNED rather than sequential, so omitting one does not renumber the rest —
    which is what lets one derived WGSL module serve every variant.
    
    InstancedMesh
    -------------
    Extends Mesh, so every existing setting works unchanged. Placement is
    uniform-driven as it is for a retained mesh: moving the whole group
    re-uploads nothing, moving one instance re-uploads one record, and
    `visibleInstanceCount` shortens the draw without touching the buffer.
    
    Backends
    --------
    WebGL compiles `#ifdef` variants per declared slot combination, lazily.
    WGSL has no preprocessor and a module carries both stages in one file, so
    the WebGPU variant is DERIVED from the ordinary module — head and fragment
    stage verbatim, vertex stage replaced — which keeps the lighting loop and
    the std140 light block in exactly one place. The derivation throws if the
    source drifts from what it substitutes.
    
    glTF
    ----
    `EXT_mesh_gpu_instancing` loads with no user code: a node carrying
    per-instance TRANSLATION / ROTATION / SCALE accessors becomes an
    InstancedMesh, on the static and animated paths alike. ROTATION is accepted
    as float or normalized byte/short; malformed input (mismatched counts,
    wrong accessor types, sparse accessors, unsigned quaternions) is rejected
    or clamped rather than producing NaN records.
    
    Reviewed
    --------
    Three independent reviews of this branch found bugs the tests could not:
    the group was frustum-culled by the PROTOTYPE's box (Camera3d culls on
    getBounds(), never getBounds3d(), so a wide scatter vanished wholesale);
    `draw()` ignored the viewport and took the GPU path under a 2D camera; a
    custom shader could leave the transform rows unbound, collapsing the mesh
    to a point; one dirty span was drained by whichever batcher drew first,
    freezing instances for the other; recycled slots inherited the dead
    instance's emissive; and the Canvas fallback placed instances in raw model
    units. All fixed, each with a regression test that fails against the code
    as reviewed.
    
    
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
    obiot and claude authored Aug 7, 2026
    Configuration menu
    Copy the full SHA
    ed2f8d3 View commit details
    Browse the repository at this point in the history
  2. OBJ vertex normals (#1572) and per-material diffuse textures (#1573) (#…

    …1577)
    
    * OBJ vertex normals (#1572) + per-material textures (#1573)
    
    Two gaps in the OBJ/MTL path, both of which made an OBJ model render
    worse than the same model imported from glTF.
    
    #1572 — the parser read `vn` and threw it away, so `lit: true` on an
    OBJ shaded against a fallback. Authored normals (`v//vn`, `v/vt/vn`)
    now reach the mesh, with a vertex shared between different normals
    split so hard edges stay hard. A file supplying none gets them
    generated from face geometry — area-weighted, accumulated, normalized
    — computed after the winding correction so they follow the final
    triangle orientation. Normals are stored raw; the axis bridge is
    applied at draw through the model matrix, as it is for glTF.
    
    #1573 — a multi-material model bound whichever material's `map_Kd`
    came first for the whole mesh, so a crate with wood sides and a steel
    lid rendered entirely in wood. Each material's `Kd` already composed
    correctly (baked per-vertex at construction), which made the asymmetry
    the confusing part. `Mesh` now resolves each material's own texture
    and reduces the result to the shortest list of index ranges that need
    switching (`mesh.textureGroups`); both GPU backends draw one indexed
    range per entry over the same buffers — `drawElements` at a byte
    offset on WebGL, `drawIndexed` with a `firstIndex` on WebGPU —
    covering the retained, accumulated and instanced paths.
    
    The collapses matter as much as the split: adjacent materials sharing
    a map merge, a material without a `map_Kd` keeps the mesh texture, and
    a model needing no split issues exactly the one draw call it always
    did. An explicit `texture:` pins one binding and opts out. A `map_Kd`
    naming an image that never loaded warns and falls back rather than
    throwing — only the first material's map used to be resolved at all,
    so a partially-preloaded model that rendered must keep rendering.
    
    Also fixed while demonstrating #1572: a lit mesh whose vertex normal
    is zero-length rendered black (a normalize of the zero vector is NaN).
    Both lit fragment shaders now guard the length and degrade to unlit
    shading, which is what a mesh with no usable normals should look like.
    That is the Camera2d case — `normals` is only populated on the
    Camera3d path — tracked separately as #1576.
    
    Tests: 23 new (15 mesh_texture_groups, 8 webgpu_texture_groups) over a
    four-group OBJ fixture exercising merge, switch and fallback in one
    model, plus 8 for OBJ normals; both no-split regression pins are
    explicit. New `materialTextures` example, verified identical on WebGL
    and WebGPU; AfterBurner is now lit, which is what surfaced the black
    degradation.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * Fix review findings on #1572/#1573
    
    Three parallel reviews of eca097d found seven real defects. All are
    fixed here with regression tests; the two HIGH ones were reachable on
    ordinary assets.
    
    OBJ normals (#1572):
    
    - Index buffers stayed Uint16 unconditionally. Splitting a position per
      distinct normal can treble a flat-shaded model's unified vertex count,
      so a model that fit before can now exceed 65 535 — and every index past
      that wrapped mod 65 536 in silence, stitching the tail of the model to
      its head. The buffer widens to Uint32 past 65 536 vertices.
    
    - Generated normals were area-weighted, which is biased by how a polygon
      was triangulated: a fan hands its pivot corners two triangles' worth of
      one face and the others one, so a cube built from quads accumulated
      (1, 0.5, 0.5) at a corner instead of (1, 1, 1) — 19.5 degrees off, and
      asymmetric on a symmetric model. Now angle-weighted, which is
      triangulation-independent.
    
    - Normal values were read at face-parse time, so an out-of-range `vn`
      read past `sourceNormals` and wrote NaN (which the shader guard does
      not catch), and a `vn` declared after the face using it never resolved.
      Resolution now happens once the whole file is read.
    
    - Generation was gated on "the file declared any vn" rather than on
      whether a given vertex got one, leaving zero-filled normals for
      partially-normalled files, for `vn` blocks no face references, and for
      empty normal fields. The rule is now per-vertex.
    
    - Generation accumulated per unified vertex, so the per-material dedup
      scope creased the model along every `usemtl` boundary — contradicting
      the documented "smooth" claim. It accumulates per source position.
    
    - `Mesh` wrote the OBJ's normals back onto the caller's `settings`
      object: a frozen literal threw, and one object reused for two models
      gave the second mesh the first's normals.
    
    - The WebGL lit shader's zero-normal early return dropped the
      per-instance emissive term the lit path adds, diverging from WGSL.
    
    Per-material textures (#1573):
    
    - The WebGL pre-pass resolved every range's texture unit before drawing.
      Exhausting the unit budget makes the texture cache recycle from unit 0,
      which invalidates units already handed out, so the earlier ranges
      sampled whatever landed last. Bound per range instead, immediately
      before its own draw — safe because `flush()` returns at zero vertices
      without touching the vertex array, and correct because a later range
      recycling an earlier one's unit no longer matters.
    
    - `textureFilter` was written onto `mesh.texture` alone and the batcher's
      mipmap gate read `mesh.texture` rather than the range's own, so a
      pixel-art model rendered one material crisp and the rest through the
      renderer default, with an incoherent mag/min pair on the others.
    
    - A zero-index group (two `usemtl` in a row) could name the mesh-level
      texture, painting the model in a map no geometry uses and leaving
      `mesh.texture` outside the plan entirely.
    
    - WebGPU resolved the mesh-level material even on the split path,
      reserving a unit for a binding immediately overwritten.
    
    CHANGELOG: the "falls back rather than throwing" claim now says which
    resolution it covers, and the black-lit entry no longer reads as closing
    #1576, which stays open.
    
    Tests: +14 (10 OBJ normals, 4 per-material). The unit-exhaustion test
    asserts a cache reset actually fired, so it cannot pass vacuously.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * Pin the wide-index byte offset on a split draw
    
    `indexType === UNSIGNED_INT ? 4 : 2` was dead code when written — the OBJ
    parser only ever emitted Uint16. Widening past 65 536 vertices in the
    previous commit makes it live, so a large multi-material model now takes
    that branch. A hard-coded stride of 2 would draw the wrong triangles with
    no GL error.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
    obiot and claude authored Aug 7, 2026
    Configuration menu
    Copy the full SHA
    9fa4451 View commit details
    Browse the repository at this point in the history
  3. MTL material fidelity: specular, alpha maps, Pr/Pm — #1575 items 1, 2…

    … (scalars) and 3 (#1578)
    
    * MTL specular and per-texel opacity (#1575 items 1 and 3)
    
    The MTL parser recognised around twenty properties and consumed five, so
    an authored highlight was read and thrown away and an alpha map was
    rejected outright. Both destinations already existed.
    
    Specular (Ks + Ns) gives the lit mesh path a Blinn-Phong term where it
    was half-Lambert diffuse plus an ambient floor, so every material read
    as chalk. Two decisions worth recording:
    
    - Gated on the EXPONENT, not the colour. `Ns` of 0 is the format's "no
      highlight" and exporters routinely write a bright `Ks` beside it, so
      reading the colour alone would put a full-strength highlight on every
      matte material in the wild.
    - Masked by the UNWRAPPED Lambert term. Half-Lambert deliberately lifts
      the shadowed side, and reusing it would light a highlight on a surface
      facing away from the light.
    
    The eye position the half-vector needs is derived from the view matrix
    as -RT*t rather than plumbed from the camera, so it stays correct for
    any caller that sets a view directly.
    
    `map_d` drives alphaCutoff per texel instead of per material — the shape
    of a leaf rather than one threshold across a whole surface. It
    multiplies alpha BEFORE the cutout; the other order cuts nothing out.
    Both backends sample unconditionally and weight by a flag rather than
    branching: one backend branching and the other not is exactly how the
    emissive early-return diverged in #1572, and weighting also keeps the
    WGSL sample in uniform control flow.
    
    WebGPU needed the second texture. Group 1 for the mesh family grows from
    two bindings to four and MeshUniforms from 176 to 208 bytes. A mesh with
    no map binds its own diffuse texture as filler, so there is no extra
    unit and no extra upload, and the bind-group cache is keyed on the alpha
    record OBJECT — one diffuse shared by two meshes with different masks
    must not hand the second mesh the first's cut-outs. Widening the layout
    does not break the documented custom-WGSL mesh contract: a module may
    declare a subset of its layout's bindings.
    
    The glTF loader now maps metallic/roughness onto the same terms, which
    is where the factors every asset already carries can finally land. It is
    an approximation onto a stylized shading model, not a PBR
    implementation: roughness -> exponent through the usual GGX bridge, with
    the glTF DEFAULT roughness of 1 landing on exactly 0 so a scene that
    declares nothing is untouched; metallic -> tint between the dielectric
    F0 and the base colour. Note this changes no shipped example — every
    glTF asset in the repo is authored fully rough — so it is unblocking
    imported assets, not improving existing ones.
    
    Also folded in, unrelated to the above:
    
    - packages/examples/LICENSE.md had the multiMaterialMesh attribution cut
      mid-sentence by the Water Overworld paragraph. Pre-existing.
    - The #1573 unit-exhaustion spec cleared the SHARED session renderer's
      unit assignments without announcing it, leaving every batcher's
      boundTextures claiming units the cache no longer considered assigned.
    
    The example is reworked into a Camera3d scene showing all of it: the
    crate for #1573, a chrome ball for the highlight (smooth because its
    normals are generated, #1572), a perforated panel for the cutout.
    Textures regenerated at 256x256 with antiAlias on — at 64x64 they were
    magnified into blocks.
    
    Tests: +31, adversarial where the trap is silent — a bright Ks with
    Ns 0, an Ns with no Ks, the sample-before-cutout ordering pinned across
    all four shader sources, the specular/emissive uniform offsets, one
    diffuse with two masks, and the glTF defaults. That last one caught a
    real bug: a malformed roughnessFactor made the exponent NaN, failed the
    `> 0` test and fell into the mirror-smooth branch, turning a broken file
    into chrome.
    
    The four new uniforms were also being set unconditionally per draw while
    every other uniform in that method is change-guarded, which timed out a
    fuzz spec; all four are guarded now.
    
    WGSL verified against a real device (both tiers plus all four derived
    instanced variants, zero messages) since the in-tree validation spec
    skips without one.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * MTL Pr/Pm through the shared metallic-roughness mapping (#1575 item 2, scalars)
    
    The scalar half of item 2. I had deferred it as a PBR design question,
    then wrote exactly that mapping for the glTF loader an hour later —
    which made the MTL side nearly free and the deferral hard to justify.
    
    The mapping moves out of `gltf.js` into `loader/parsers/pbr.ts` and both
    loaders call it. That is the point of the change as much as the feature
    is: MTL's `Pr`/`Pm` and glTF's `pbrMetallicRoughness` describe one
    material concept, and approximating it in two places is how the two
    drift apart.
    
    - `Pr` / `Pm` are parsed, defaulting to `null` rather than a number:
      0 is meaningful for both (mirror-smooth, non-metal), so "declared
      nothing" must stay distinguishable from "declared a mirror".
    - An explicit `Ks`/`Ns` WINS over the derived terms. Blender writes both
      blocks, so this precedence decides most real files; the explicit
      specular states what the artist wanted, the extension only implies it.
    - A fully-rough material derives nothing, which is what leaves existing
      scenes untouched.
    
    Still an approximation onto a stylized half-Lambert model, not a PBR
    shading model, and `map_Pr` / `map_Pm` are not consumed — those need the
    second-texture plumbing alongside #1574.
    
    Separately: `webgl_vao_adversarial`'s fuzz test gets an explicit 60s
    timeout. It runs in well under a second on its own but times out at 15s
    in a full run — the shared browser session slows as specs accumulate and
    a software rasterizer under load stretches several hundred GL ops by
    more than an order of magnitude. Same rationale as the config's 90s
    hookTimeout. It flaked before this branch too; cutting the iteration
    count to fit would have traded real fuzz coverage for a round number.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
    obiot and claude authored Aug 7, 2026
    Configuration menu
    Copy the full SHA
    dadcdb6 View commit details
    Browse the repository at this point in the history

Commits on Aug 8, 2026

  1. Ground shadows for 3D objects (#1515) (#1579)

    * Ground shadows for 3D objects (#1515)
    
    `castGroundShadow` gives a Mesh, a Sprite3d billboard or a whole
    InstancedMesh scatter a soft blob shadow on the ground — the thing 2.5D
    scenes had no way to get, and without which characters and props read as
    floating however carefully they are placed. Deliberately not simulated:
    for paper-thin billboards a shadow map costs far more than this engine
    wants to spend and looks worse, because a flat silhouette has to be
    special-cased to cast anything sensible. What the player needs is
    contact, and a blob says exactly that.
    
    On by default (the new `castGroundShadow` application setting), and
    controllable at three levels, most specific first: per object, per glTF
    scene via `level.load(name, { castGroundShadow, shadowGroundY })`, then
    application-wide. The two blanket forms skip meshes with no vertical
    extent — a flat plane lying on the floor IS the floor, and shadowing it
    with itself smears the whole ground. 2D games are untouched whatever the
    setting says: the shadow rides the retained Camera3d path only.
    
    The blob is an ellipse built from the caster's own model-space footprint
    carried through its transform, so it matches the object's aspect ratio
    and turns with it; a thin upright panel gets a thin shadow lying along
    the panel rather than a disc reading as perpendicular to it. Read from
    `currentTransform`, not the model matrix, because a billboarded Sprite3d
    builds that from a camera-facing basis and the blob would spin with the
    camera.
    
    Shadows are held back until every opaque mesh in the pass is down, then
    drawn in one go. A blob writes no depth (so two overlapping at one ground
    height blend instead of fighting), which leaves it nothing to defend
    itself with, and a ground plane routinely sorts after the props standing
    on it. Depth testing stays on, so a shadow is still correctly hidden
    behind geometry genuinely in front of it. The queue is drained when the
    renderer leaves mesh mode — but not on a lit/unlit switch, and not while
    a mask is being stencilled in or inside a post-effect bracket, where the
    device state is not the scene's — and at the end of the camera's own
    draw, inside its FBO bracket.
    
    The instanced tier costs ONE extra draw for an entire scatter regardless
    of instance count: the blobs are read from the same instance buffer the
    meshes draw from, through a standalone shader that reads only the
    transform rows, so per-instance colour and emissive cannot leak into
    them. Its quad carries the prototype's extents in its own vertices, so
    the same asset draws the same shadow instanced or standalone.
    
    An object that does not opt in is untouched — no extra draw, no extra
    state, no changed pipeline key — and the shared falloff texture and quads
    are allocated lazily, so an application with no shadows builds neither.
    
    Also here, found while building it:
    
    - `registerShader` deduped on module source alone, silently handing any
      later caller the first registration's vertex layout. One module can
      legitimately serve several layouts; now keyed on both, with the
      compiled module still shared.
    - WebGPU pipelines gain a conditional `depthWrite` axis, appended to the
      key only when false so existing pipelines are byte-identical.
    
    Both GPU backends. The Canvas renderer has no depth buffer and draws
    none. 50 tests covering the backward-compatibility contract, opt-in
    precedence, the deferred queue, resource lifetime and pixels — the last
    because every draw-count, GL-state and matrix assertion passed while the
    feature rendered nothing at all.
    
    Docs: CHANGELOG, both READMEs, and the Working-in-3D, glTF, supported-
    assets and 2.5D wiki pages (the last two carried caveats this removes).
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * Narrow world children with a type guard, and test the glTF tri-state
    
    `world.children` is typed as the base `Renderable`, so `castGroundShadow`
    and `getBounds3d()` — which live on `Mesh` — are not visible on it. The
    diorama example silenced that with `as any` instead of proving it, which
    switched off checking for every member access in the loop that assigns
    `shadowGroundY`: exactly where the compiler is worth having.
    
    `filter((c): c is Mesh => c instanceof Mesh)` narrows properly. It also
    removes a latent bug: the old predicate was `castGroundShadow !==
    undefined`, which only selected anything because this scene passes
    `castGroundShadow: true` to `level.load`. The flag is deliberately
    TRI-STATE — `undefined` means "follow the application setting" — so on a
    scene that did not pass the option, that filter would have matched
    nothing and the whole ground-resolution loop would have silently done
    nothing.
    
    That contract had no test, so add four against the animated glTF path:
    an omitted option must leave the flag `undefined` (or an application-wide
    default can never reach a glTF scene), `true` and `false` must both
    forward, `shadowGroundY` must reach the part meshes, and a scene-wide
    opt-in must still skip a part with no vertical extent. Verified the first
    fails if the loader flattens `undefined` to `false`.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * Don't make every WebGL spec pay for ground shadows
    
    Ground shadows (#1515) ship ON, so every 3D mesh draw now issues a second
    one. That is free on a discrete GPU and is not on CI's SwiftShader
    software renderer: `renderTargetPool` and `texturecache-batcher-reset`
    both ran past their timeout there — 190s for a suite whose tests all
    passed — while the local suite stayed green throughout.
    
    The suites that are not testing shadows should not be paying for them, so
    the shared WebGL test renderer pins the setting off. `ground_shadow.spec`
    opts itself in per test and asserts the shipped default separately
    against `defaultApplicationSettings`, so coverage of the real default is
    unaffected.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
    obiot and claude authored Aug 8, 2026
    Configuration menu
    Copy the full SHA
    242966a View commit details
    Browse the repository at this point in the history

Commits on Aug 9, 2026

  1. Octree broadphase: exact octant classification, depth-blind retrieve() (

    #1580)
    
    Two defects found while validating the Camera3d + Octree + SAT stack for
    the 2.5D platformer example (#1476), plus an adversarial/differential
    sweep that found the second one.
    
    1. retrieve() pruned on depth and silently dropped real collisions.
    
       It descended only into the octant the query item classified into. But
       every consumer of retrieve() decides overlap in the XY plane — the SAT
       detector, pointer picking, the 2D raycast, adapter.queryAABB. Two
       bodies at different z that overlap in XY genuinely collide under 2D
       SAT and were never offered to each other as candidates; whether a pair
       got tested came down to which side of an octant boundary each fell on.
       Measured on a randomized 300-body scene: 12 of 20 genuinely
       overlapping pairs never surfaced.
    
       getIndex is split into getIndex + _quadrantXY, and retrieve() now uses
       the latter: classify on x/y only, walk both depth halves of that
       quadrant. x/y pruning still applies at every level and in both halves.
       Going through getIndex and walking `index ^ 4` also restores
       correctness but costs ~4x the nodes visited, because the sibling
       rejects the item on its depth out-of-bounds guard and falls back to an
       unpruned 8-way walk.
    
    2. Items sitting exactly ON a midpoint were misfiled to the parent.
    
       -1 means "straddles a midpoint, keep at this level". A point-z item
       cannot straddle the depth midpoint, and an item whose far edge merely
       touches a vertical one lies wholly inside the near child. The root box
       is origin-centred, so its midpoints are (0, 0, 0) — the default pos of
       every renderable and the shared gameplay z the 2.5D recipe prescribes.
       Measured: 200 bodies on a z=0 plane all stayed at the root and
       retrieve() returned 200 of 200; the same 200 spread across z left 10.
    
       Classification is now exact on all three axes; the midpoint belongs to
       the far/right/bottom child. Genuine straddlers and out-of-bounds items
       still stay at the parent, both under regression test.
    
    octree.spec.js:247 asserted the old depth-midpoint behaviour as correct,
    which is why this survived; it is flipped with the reasoning recorded.
    
    Adds tests/octree_adversarial.spec.js: midpoint ties on all three axes,
    gameplay-plane partitioning, structural invariants under random churn,
    and randomized differential testing of queryAABB / querySphere /
    retrieve() against a brute-force scan. queryAABB and querySphere passed
    unchanged — the 3D queries were already correct and still prune on depth.
    
    Invisible to 2D games, which use a QuadTree and never build an Octree.
    
    Full suite 234 files / 5851 pass, eslint 0 errors (no new warnings),
    biome clean, tsc clean.
    
    
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
    obiot and claude authored Aug 9, 2026
    Configuration menu
    Copy the full SHA
    18868a7 View commit details
    Browse the repository at this point in the history
  2. Fix resource leaks in Application teardown (WebGL context + renderer …

    …event listeners) (#1582)
    
    * Application.destroy() releases the WebGL context
    
    Teardown deleted every GL object the renderer owned and removed the canvas
    from the DOM, but never handed back the context itself. A canvas keeps its
    context until the canvas is garbage-collected — non-deterministic and
    routinely delayed — so each destroyed application left a live context
    behind.
    
    Browsers cap how many they keep (~16 on Chromium) and force-lose the oldest
    past that. A long-lived page that builds and tears down several
    applications therefore accumulates dead-but-unfreed contexts until an
    unrelated later getContext() stalls or returns one already lost. That hits
    any SPA that unmounts a game view — the examples gallery does exactly this
    on every navigation — and it is also what makes unrelated specs time out in
    CI, where the shared browser session spans every spec file.
    
    WebGLRenderer.destroy() now releases the context through
    WEBGL_lose_context. The hint had been sitting commented out in this same
    file since forever (webgl_renderer.js:295-296).
    
    destroy() stays idempotent — GL calls on a lost context are no-ops by spec
    — and it is already terminal (Application.init() refuses to run again
    afterwards), so losing the context forecloses nothing. Drivers without the
    extension are unaffected.
    
    webgl_vao_teardown.spec.js records the new contract: the old test asserted
    getError() === NO_ERROR after destroy, which a deliberately-lost context
    cannot satisfy; it now asserts isContextLost() and that a second teardown
    does not throw. Verified to fail with the fix reverted.
    
    A "create/destroy N applications past the context cap" test was written and
    deliberately REMOVED — it passed identically with and without the fix, so
    it discriminated nothing. The reasoning is left as a comment so the dead
    end is not re-derived.
    
    Also drops the unnecessary Application from octree_adversarial.spec.js: it
    only ever needed a `world` for the isFloating branch and nothing there
    floats, so it now stands up no canvas at all (import 280ms -> 12ms).
    
    Full suite 234 files / 5851 pass, eslint 0 errors, biome clean, tsc clean.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * tests: stop leaking WebGL contexts from spec-local Applications
    
    Follow-up to the destroy() context-release fix. An audit parsing the
    `renderer:` argument at each `new Application(` call site (not grepping
    filenames — camera3d_integration has 19 Applications but deliberately uses
    CANVAS) found 24 GL-capable Applications created in specs that never call
    destroy(). Against a Chromium cap of ~16 live contexts, that is what pushed
    unrelated suites' beforeAll hooks past their 90s timeout on CI.
    
    Two kinds of offender, two fixes:
    
    - Suites that never needed GL at all — bezier, linedash, timer — pinned to
      video.CANVAS. An unspecified renderer resolves to AUTO, so these were
      silently holding a WebGL context for the whole session.
    
    - "Reset-only" Applications: a fresh app built inside afterAll purely to
      restore global defaults (Camera2d, a clean world) for later spec files.
      Eight of these across depth, glcore-audit, webgl_save_restore, mesh,
      camera3d_integration, lighting3d, gltf_model and canvas-cliprect-transform
      took a context under AUTO and were never destroyed. They do not render, so
      they are now CANVAS. gltf_model was already using CANVAS for its real app
      and AUTO for the throwaway.
    
    Also adds afterAll teardown to bezier, linedash and timer: a Canvas
    Application still leaves a canvas, listeners and timers live in the shared
    browser session, so it should be destroyed whichever backend it uses.
    
    Full suite 234 files / 5851 pass, eslint 0 errors, biome clean.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * Renderers unregister their global event listeners on destroy()
    
    WebGLRenderer subscribed to GAME_RESET, ONCONTEXT_RESTORED and
    CANVAS_ONRESIZE; CanvasRenderer to GAME_RESET. All four were inline
    anonymous arrows, which cannot be passed to off() — so nothing could ever
    unregister them. CanvasRenderer had no destroy() at all, inheriting the
    base class no-op.
    
    Two consequences, both silent. A destroyed renderer kept reacting to those
    events. And each handler closes over the renderer, so the subscription
    pinned the renderer, its batchers and its GPU objects against garbage
    collection — which is why releasing the GL context in the previous commit
    was not sufficient on its own: the JS graph stayed reachable from the
    event bus.
    
    The handlers are now per-instance fields and destroy() calls off() on each,
    matching what WebGPURenderer already did (it stores this.onGameReset /
    this.onCanvasResize and unregisters both).
    
    Adds tests/application_lifecycle.spec.js. It asserts the structural
    property that made the bug possible — handlers must be retrievable
    per-instance references, and CanvasRenderer must define its own destroy().
    Two stronger tests were attempted and abandoned, and the spec records why
    so the dead ends are not re-walked:
    
      - "destroy, then emit(GAME_RESET), assert no reaction" — emit reaches
        every listener in the shared browser session, including ones left by
        other spec files, and throws partway through. A try/catch around it
        would pass without reaching the handler under test.
      - "spy on event.off" — vitest browser mode cannot spy on ESM exports.
    
    The structural assertion is necessary but not sufficient: it would not
    catch a destroy() that simply forgot to call off(). A listener-count
    assertion would be strictly better and needs a test-visible way to inspect
    the bus. Said so in the spec rather than implying more coverage than there
    is.
    
    Note World (GAME_RESET) and Container (CANVAS_ONRESIZE) have the same
    unpaired-subscription shape and are NOT fixed here — Container matters most
    since every container in the scene graph takes one.
    
    Full suite 235 files / 5854 pass, eslint 0 errors, biome clean, tsc clean.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    * World and root Container unregister their event listeners too
    
    Same class as the renderer fix in the previous commit, in the scene graph.
    
    - Container (root only — the subscription is guarded by `this.root ===
      true`, so it is one per world, not one per node) subscribed to
      CANVAS_ONRESIZE with an inline arrow. Unremovable, and the closure kept
      the container and its entire child tree reachable from the event bus.
    - World subscribed to GAME_RESET (handler + context) and LEVEL_LOADED
      (inline arrow), and had no destroy() of its own — so a destroyed world
      kept resetting itself on GAME_RESET and clearing a broadphase nobody
      read on LEVEL_LOADED, and could never be collected.
    
    Both now hold their handlers as fields, and destroy() calls off() before
    delegating to the container teardown. Container clears the field so a
    second destroy is a no-op rather than a double off().
    
    Extends tests/application_lifecycle.spec.js with the two cases. Same
    caveat as the renderer tests, already documented in that file: these
    assert the structural property (handlers are retrievable, and cleared on
    teardown), which is necessary but does not prove `off()` was called.
    
    Full suite 235 files / 5856 pass, eslint 0 errors, biome clean, tsc clean.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
    obiot and claude authored Aug 9, 2026
    Configuration menu
    Copy the full SHA
    abeea71 View commit details
    Browse the repository at this point in the history
Loading