Skip to content

Renderer: two caches that never hit, one per backend - #1652

Merged
obiot merged 1 commit into
masterfrom
fix/per-frame-cache-misses
Sep 7, 2026
Merged

Renderer: two caches that never hit, one per backend#1652
obiot merged 1 commit into
masterfrom
fix/per-frame-cache-misses

Conversation

@obiot

@obiot obiot commented Sep 7, 2026

Copy link
Copy Markdown
Member

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#bindTo asked the driver for the 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/unlit, fog/no-fog). The index is a fixed property of a linked program, so it is now memoized in a WeakMap<WebGLProgram, Map<string, number>>.

forest example, 9 s before after
getUniformBlockIndex calls 1358 (4 distinct answers) 4
time in those calls 133 ms 0.4 ms
worst single call 17 ms 0.2 ms

WebGPU — 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.

forest example, 9 s before after
createTexture 2066 3
createBindGroup 3114 21

Rendering 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:

backend mean before mean after
WebGL 0.703 / 0.610 / 0.673 ms 0.323 / 0.307 / 0.321 ms ~50% less
WebGPU 0.384 / 0.366 / 0.417 ms 0.342 / 0.313 / 0.312 ms ~17% less

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

uniformBlockBinding still runs on every bindTo; 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, Camera3d or 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 the UniformBlock itself regardless. The engine never re-links a program in place: compileProgram is the single linkProgram site and it creates the program it links. extractUniforms already 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:

  • getUniformBlockIndex asked once per program across 50 calls, and the miss (INVALID_INDEX) cached too
  • a separate answer per block name on one program
  • a second program re-queried — the context-restore shape
  • compileProgram yields a distinct program per call (fails 4 tests if it ever re-links in place)
  • a 1×1 source uploaded once, and copied once — the mock now counts copyExternalImageToTexture, without which the outer guard could be reverted alone and every test still passed
  • a flat record upgraded to a chain across a frame boundary, then settling
  • a non-power-of-two source settling after one upload
  • a compressed source with a partial authored chain, held across frames

Full suite: 276 files, 6742 passed, exit 0.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N

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
Copilot AI lite review requested due to automatic review settings September 7, 2026 08:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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 in UniformBlock#bindTo using a WeakMap<WebGLProgram, Map<string, number>>, caching both hits and INVALID_INDEX.
  • WebGPU: replace mipLevelCount === 1-style “has mips” checks with a computed full-chain level count via fullMipLevelCount(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.

@obiot
obiot merged commit 5ab23f5 into master Sep 7, 2026
7 checks passed
@obiot
obiot deleted the fix/per-frame-cache-misses branch September 7, 2026 23:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants