Fix the browser-side memory leaks of the live map - #938
Open
sven-n wants to merge 1 commit into
Open
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.removeObjectonly detached the mesh from the scene graph. Since three.js r88 keys itsWebGLPropertiesstore byuuidand only removes an entry when the material/texture raises itsdisposeevent, 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,AttacksandWorldgot adispose()which releases the materials, textures and geometries they own and stops their tweens. The shared geometries (Player.defaultGeometry,NonPlayerCharacter.defaultGeometry) stay untouched.World.removeObjectdisposes the removed object,World.disposetraverses its objects and additionally disposes the terrain mesh, its material and the terrain texture. Objects arriving after the disposal (theaddOrUpdate*methods await the alpha map texture) are ignored.NameLabelare created lazily on the firstshow(). 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.disposenow disposes the object picker (which removes itsclick/mousemovehandlers), 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.debugretained every object that ever appearedThe 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(newDebug.ts) and are only written whenliveMapDebugLoggingis set totrueon the window object.3. Orphaned
MapAppwhen the page is left beforeSystem.importresolvesCreateMapregisters the pending map before importing the module. IfDisposeMapruns before the import resolves, the creation is cancelled — otherwise aMapAppwas constructed which nobody could dispose, and itsrequestAnimationFrameloop kept a WebGL context and the whole scene alive for the lifetime of the tab.Smaller defects in the same code
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.rotateTonever stored the direction:Object.assign({}, this.data, rotation)with anumbercopies nothing; it now assigns{ direction: rotation }.terrainShaderwas 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 perWorldbycreateTerrainShader().MapApp.animateignored the rAF timestamp: it re-passed the captured (alwaysundefined)timeinstead of the timestamp of the new frame. tween.js usesperformance.nowin the browser, which is the same time base as the rAF timestamp.Queuegrew 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).Attackable.moveTo.Verification
The changed bundle was exercised in Node with stubbed
three/document/rAF, driving the real compiledMUnique.OpenMU.Web.Map.jsand the real tween.js: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.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 seconddispose()is a no-op.walkDelayms) and the object's direction ends at the last step's direction; disposing mid-walk stops the running step tween.The TypeScript output (
wwwroot/js/MUnique.OpenMU.Web.Map.jsand its map) is regenerated. It was built with the locally availabletsc6.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 remainingtscdiagnostics 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