-
-
Notifications
You must be signed in to change notification settings - Fork 663
Comparing changes
Open a pull request
base repository: melonjs/melonJS
base: 19.x
head repository: melonjs/melonJS
compare: master
- 17 commits
- 421 files changed
- 2 contributors
Commits on Jul 29, 2026
-
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>
Configuration menu - View commit details
-
Copy full SHA for a6284cc - Browse repository at this point
Copy the full SHA a6284ccView commit details
Commits on Jul 31, 2026
-
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.
Configuration menu - View commit details
-
Copy full SHA for 8de1d03 - Browse repository at this point
Copy the full SHA 8de1d03View commit details -
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.
Configuration menu - View commit details
-
Copy full SHA for f853e8d - Browse repository at this point
Copy the full SHA f853e8dView commit details
Commits on Aug 1, 2026
-
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
Configuration menu - View commit details
-
Copy full SHA for 06d3f4f - Browse repository at this point
Copy the full SHA 06d3f4fView commit details -
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
Configuration menu - View commit details
-
Copy full SHA for 07965de - Browse repository at this point
Copy the full SHA 07965deView commit details
Commits on Aug 2, 2026
-
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>
Configuration menu - View commit details
-
Copy full SHA for c71d5d0 - Browse repository at this point
Copy the full SHA c71d5d0View commit details -
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>
Configuration menu - View commit details
-
Copy full SHA for eac8555 - Browse repository at this point
Copy the full SHA eac8555View commit details
Commits on Aug 3, 2026
-
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>
Configuration menu - View commit details
-
Copy full SHA for ff67146 - Browse repository at this point
Copy the full SHA ff67146View commit details
Commits on Aug 5, 2026
-
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>
Configuration menu - View commit details
-
Copy full SHA for 648cfba - Browse repository at this point
Copy the full SHA 648cfbaView commit details
Commits on Aug 6, 2026
-
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>
Configuration menu - View commit details
-
Copy full SHA for 1e4ae9a - Browse repository at this point
Copy the full SHA 1e4ae9aView commit details -
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>
Configuration menu - View commit details
-
Copy full SHA for ee0e934 - Browse repository at this point
Copy the full SHA ee0e934View commit details
Commits on Aug 7, 2026
-
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>
Configuration menu - View commit details
-
Copy full SHA for ed2f8d3 - Browse repository at this point
Copy the full SHA ed2f8d3View commit details -
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>
Configuration menu - View commit details
-
Copy full SHA for 9fa4451 - Browse repository at this point
Copy the full SHA 9fa4451View commit details -
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>
Configuration menu - View commit details
-
Copy full SHA for dadcdb6 - Browse repository at this point
Copy the full SHA dadcdb6View commit details
Commits on Aug 8, 2026
-
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>
Configuration menu - View commit details
-
Copy full SHA for 242966a - Browse repository at this point
Copy the full SHA 242966aView commit details
Commits on Aug 9, 2026
-
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>
Configuration menu - View commit details
-
Copy full SHA for 18868a7 - Browse repository at this point
Copy the full SHA 18868a7View commit details -
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>
Configuration menu - View commit details
-
Copy full SHA for abeea71 - Browse repository at this point
Copy the full SHA abeea71View commit details
This comparison is taking too long to generate.
Unfortunately it looks like we can’t render this comparison for you right now. It might be too big, or there might be something weird with your repository.
You can try running this command locally to see the comparison on your machine:
git diff 19.x...master