Renderer: two caches that never hit, one per backend - #1652
Conversation
Both were doing the same work on every frame, forever, for an answer that could not change. WebGL — `UniformBlock#bindTo` asked the driver for the light block's index on every call. That is a blocking round-trip, and `bindTo` is reached per draw: `_bindLightBlock` guards with a single-slot `program === this._lightBlockProgram` check, which thrashes whenever the program alternates (lit and unlit, fog and no fog). The index is a fixed property of a linked program, so it is now memoized in a WeakMap keyed on the program. On the instanced forest example that is 1358 queries for 4 distinct answers, now 4, and about 50% less engine CPU per frame. Keying on the program object is what makes it safe across a lost context: every program is recompiled on restore, so the new object misses the cache while the old one becomes collectable — and `WebGLRenderer.reset()` rebuilds the UniformBlock itself anyway. The engine never re-links a program in place: `compileProgram` is the single link site and it creates the program it links. `extractUniforms` already relies on the same fact for its uniform locations. WebGPU — a mesh texture whose largest dimension is 1 was re-created every frame. A full mip chain for a 1x1 image is one level, but the test for whether a resident texture already had a chain was `mipLevelCount === 1`, which for that size can never come true: rebuilt with one level, asked again next draw, never settling. Every mesh without an alpha map binds a 1x1 filler through this path. Comparing against the chain the size can actually hold takes 2066 texture creations down to 3, and the bind groups rebuilt alongside them from 3114 to 21 — about 17% less engine CPU per frame. Neither fix changes what is drawn. `uniformBlockBinding` still runs on every `bindTo`; only the query is skipped. The 1x1 texture is reused rather than recreated with identical contents. The four lighting-sensitive static examples render byte-identical before and after, on both backends. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
There was a problem hiding this comment.
🟢 Approval recommended
The fixes are narrowly scoped, align with backend invariants, and are backed by targeted regression tests that would fail if the previous “never-hit” cache behavior regressed.
Pull request overview
This PR fixes two renderer-side cache conditions (one in WebGL, one in WebGPU) that prevented caches from ever hitting, causing repeated driver/API work every frame for results that are invariant per program/texture size. The changes reduce per-frame CPU overhead by memoizing WebGL uniform-block indices per linked program and correctly detecting when a WebGPU texture record already has the complete mip chain for its dimensions (including the 1×1 case).
Changes:
- WebGL: memoize
getUniformBlockIndex(program, blockName)results inUniformBlock#bindTousing aWeakMap<WebGLProgram, Map<string, number>>, caching both hits andINVALID_INDEX. - WebGPU: replace
mipLevelCount === 1-style “has mips” checks with a computed full-chain level count viafullMipLevelCount(width, height), preventing perpetual rebuild/re-upload for 1×1 and other sizes. - Tests + changelog: add/extend regression tests for both backends’ “never re-created every frame” behavior and document the performance fixes.
File summaries
| File | Description |
|---|---|
| packages/melonjs/src/video/webgl/buffer/uniformblock.js | Adds per-program/per-block-name memoization of uniform block indices to avoid repeated driver queries. |
| packages/melonjs/src/video/webgpu/texture/store.js | Introduces fullMipLevelCount() and uses it to correctly decide when mip chains are complete, preventing endless rebuilds. |
| packages/melonjs/tests/webgl_uniformblock.spec.js | Adds tests that pin “one driver query per program” behavior and context-restore safety assumptions. |
| packages/melonjs/tests/webgpu_mipmaps.spec.js | Adds tests ensuring 1×1 and other sources settle (no per-frame recreate/re-upload) and counts uploads via copyExternalImageToTexture. |
| packages/melonjs/tests/webgpu_compressed.spec.js | Extends coverage to ensure authored (partial) compressed mip chains are not treated as incomplete and rebuilt across frames. |
| packages/melonjs/CHANGELOG.md | Documents the two performance fixes in the unreleased changelog. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Two independent bugs of the same shape, found by profiling a 3D scene: a cache that can never hit, so the same work repeats every frame, forever, for an answer that cannot change. One per backend.
WebGL — the light block index
UniformBlock#bindToasked the driver for the block's index on every call. That is a blocking round-trip, andbindTois reached per draw —_bindLightBlockguards with a single-slotprogram === this._lightBlockProgramcheck, which thrashes whenever the program alternates (lit/unlit, fog/no-fog). The index is a fixed property of a linked program, so it is now memoized in aWeakMap<WebGLProgram, Map<string, number>>.getUniformBlockIndexcallsWebGPU — the 1×1 filler texture
A mesh texture whose largest dimension is 1 was re-created every frame. A full mip chain for a 1×1 image is one level, but the test for whether a resident texture already had a chain was
mipLevelCount === 1— never satisfiable at that size, so it was rebuilt with one level, asked again next draw, and never settled. Every mesh without an alpha map binds a 1×1 filler through this path.createTexturecreateBindGroupRendering time
CPU inside the frame callback, since the scene is vsync-bound and a frame counter cannot see engine work getting cheaper. 12 s runs, first 150 frames discarded, 3 runs per arm:
The arms do not overlap across runs. Absolute values are sub-millisecond on a GPU-bound scene, so this is headroom rather than frames — of a 16.7 ms budget the engine was spending 0.66 ms and now spends 0.32 ms. One machine, one example.
Neither fix changes what is drawn
uniformBlockBindingstill runs on everybindTo; only the query is skipped. The 1×1 texture is reused instead of being recreated with identical contents.Verified across 16 examples × 2 backends (every one touching
Light2d,Light3d,Camera3dor meshes): all render, no console errors, and the four lighting-sensitive static scenes —sprite-illuminator,normal-map,gltf,forest— are byte-identical (same MD5) before and after. The routes that differ are animated, with same-build run-to-run variance of the same magnitude.Safety of the WeakMap
Keying on the program object is what makes it safe across a lost context: every program is recompiled on restore, so the new object misses the cache and the old one becomes collectable — and
WebGLRenderer.reset()rebuilds theUniformBlockitself regardless. The engine never re-links a program in place:compileProgramis the singlelinkProgramsite and it creates the program it links.extractUniformsalready depends on the same fact for its cached uniform locations, so this is an existing invariant rather than a new assumption; a test now pins it.Tests
Eight added or extended, each checked by reverting the code it covers:
getUniformBlockIndexasked once per program across 50 calls, and the miss (INVALID_INDEX) cached toocompileProgramyields a distinct program per call (fails 4 tests if it ever re-links in place)copyExternalImageToTexture, without which the outer guard could be reverted alone and every test still passedFull suite: 276 files, 6742 passed, exit 0.
🤖 Generated with Claude Code
https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N