Skip to content

Fix the browser-side memory leaks of the live map - #938

Open
sven-n wants to merge 1 commit into
masterfrom
claude/issue-918-5j8194
Open

Fix the browser-side memory leaks of the live map#938
sven-n wants to merge 1 commit into
masterfrom
claude/issue-918-5j8194

Conversation

@sven-n

@sven-n sven-n commented Sep 4, 2026

Copy link
Copy Markdown
Member

Fixes #918.

Watching a populated map in the admin panel made the browser tab grow steadily, because the three.js resources of the map objects were never released.

The three leaks

1. Resources of removed map objects were never disposed

World.removeObject only detached the mesh from the scene graph. Since three.js r88 keys its WebGLProperties store by uuid and only removes an entry when the material/texture raises its dispose event, every material of an object which ever entered scope stayed in that dictionary — together with its compiled program, and with the GL texture handle for every label which was hovered.

  • Attackable, Attacks and World got a dispose() which releases the materials, textures and geometries they own and stops their tweens. The shared geometries (Player.defaultGeometry, NonPlayerCharacter.defaultGeometry) stay untouched.
  • World.removeObject disposes the removed object, World.dispose traverses its objects and additionally disposes the terrain mesh, its material and the terrain texture. Objects arriving after the disposal (the addOrUpdate* methods await the alpha map texture) are ignored.
  • The canvas, texture and material of a NameLabel are created lazily on the first show(). Before, every object allocated a 512×64 canvas (~128 KB backing store) although labels are only shown on hover — that alone was the bulk of the steady-state footprint. dispose() releases the canvas backing store (width = height = 0).
  • MapApp.dispose now disposes the object picker (which removes its click/mousemove handlers), cancels the pending animation frame, releases the WebGL context and removes the renderer canvas from its container. The stats element is removed by the map launcher, which is the place that created it.

2. Per-event console.debug retained every object that ever appeared

The console keeps live references to what is logged to it, so a message per add/update/remove pinned every object which ever appeared on the map. These messages now go through logDebug (new Debug.ts) and are only written when liveMapDebugLogging is set to true on the window object.

3. Orphaned MapApp when the page is left before System.import resolves

CreateMap registers the pending map before importing the module. If DisposeMap runs before the import resolves, the creation is cancelled — otherwise a MapApp was constructed which nobody could dispose, and its requestAnimationFrame loop kept a WebGL context and the whole scene alive for the lifetime of the tab.

Smaller defects in the same code

  • Walk animation only played the last step: chain() replaces the chained-tween list instead of appending, so each step tween is now chained to its predecessor. The empty head tween is gone as well — it delayed the walk by its default duration of one second.
  • rotateTo never stored the direction: Object.assign({}, this.data, rotation) with a number copies nothing; it now assigns { direction: rotation }.
  • terrainShader was a module-level singleton: its uniforms were shared by every map, so each map load overwrote (and leaked) the texture of the previous one, and a resize of one map changed the pixel size of all of them. It's now created per World by createTerrainShader().
  • MapApp.animate ignored the rAF timestamp: it re-passed the captured (always undefined) time instead of the timestamp of the new frame. tween.js uses performance.now in the browser, which is the same time base as the rAF timestamp.
  • Queue grew its index space forever and degraded into a dictionary-mode array: the storage is now compacted once half of its entries are dequeued (amortized constant per entry).
  • The renderer canvas was appended twice to the map container.
  • Removed a stray double assignment in Attackable.moveTo.

Verification

The changed bundle was exercised in Node with stubbed three/document/rAF, driving the real compiled MUnique.OpenMU.Web.Map.js and the real tween.js:

  • 101 objects allocate 101 materials and zero canvases/textures; hovering one object creates exactly one texture.
  • After removing all objects and World.dispose(), the only live three.js resources left are the intentionally shared statics (the two box geometries and the placeholder sprite material) — everything else is disposed, and no double disposal occurs.
  • The terrain texture is disposed on dispose(), and a texture which finishes loading after the disposal is disposed immediately instead of being retained.
  • MapApp.dispose() removes the canvas from the container, removes the resize and picker listeners, cancels the animation frame and releases the WebGL context; a second dispose() is a no-op.
  • The walk animation now moves through every step (positions change over all 3 × walkDelay ms) and the object's direction ends at the last step's direction; disposing mid-walk stops the running step tween.
  • The queue stays bounded: after 200,000 dequeue/enqueue cycles on a 10,000-entry queue its backing array holds 10,020 slots.

The TypeScript output (wwwroot/js/MUnique.OpenMU.Web.Map.js and its map) is regenerated. It was built with the locally available tsc 6.0.2, which additionally emits a "use strict"; at the top of the bundle — the file is otherwise identical in structure to what the previous toolchain produced. The remaining tsc diagnostics are pre-existing strict-null findings of the newer compiler; this change removes two of them and adds none.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WDGCcNgQgRxvxGaTjyQNHM


Generated by Claude Code

Watching a populated map made the browser tab grow steadily, because the
three.js resources of the map objects were never released.

- Attackable, Attacks and World got a dispose() which releases the
  materials, textures and geometries they own and stops their tweens.
  World.dispose() traverses its objects and World.removeObject() disposes
  the removed one, instead of just detaching it from the scene graph.
  Without that, the renderer keeps an entry (including the compiled
  program and the GL texture handle) for each of them forever.
- The canvas, texture and material of a NameLabel are created lazily when
  the label is shown for the first time. Previously every object allocated
  a 512x64 canvas, although the label is only shown on hover.
- MapApp.dispose() now also disposes the object picker, releases the WebGL
  context and removes the canvas from its container. The stats element is
  removed by the map launcher which created it.
- The per-object debug messages are gated behind a debug flag: the console
  keeps live references to everything which is logged to it.
- If the page is left before the MapApp module is loaded, the creation of
  the map is cancelled. Otherwise it started a rendering loop which never
  stopped and kept a WebGL context alive for the lifetime of the tab.

It also fixes some smaller defects in the same code:

- The walk animation played all steps, instead of just the last one:
  chain() replaces the chained tweens instead of appending to them, so
  each step tween is now chained to its predecessor.
- rotateTo stores the direction in the object data again.
- The terrain shader parameters are created per world. As a shared
  singleton, each map load overwrote the texture of the previously loaded
  map and the resize of one map changed the pixel size of all of them.
- MapApp.animate passes the timestamp of the new frame to TWEEN.
- The queue of the attack particles compacts its storage, so that its
  backing array doesn't grow indefinitely.
- The renderer canvas is only appended once to the map container.

Fixes #918

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDGCcNgQgRxvxGaTjyQNHM
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.

Live map (admin panel): browser-side memory leaks while watching a populated map

2 participants