From fdb154ce9529450b8e7822909bc5241a9f07df60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Mon, 5 Oct 2026 12:59:28 +0200 Subject: [PATCH] Fix NavMesh memory leaks (#9175) --- Extensions/NavMeshPathfinding/JsExtension.js | 8 +++-- .../NavMeshCharacterRuntimeBehavior.ts | 36 +++++++++++++------ .../NavMeshDebugPixiRenderer.ts | 11 ++++++ .../NavMeshObstacleRuntimeBehavior.ts | 35 ++++++++++++++++++ .../navmeshcharacterruntimebehavior.spec.js | 18 ++++++++++ GDJS/Runtime/debugger-client/hot-reloader.ts | 3 ++ newIDE/app/src/Utils/Analytics/EventSender.js | 5 +++ 7 files changed, 102 insertions(+), 14 deletions(-) diff --git a/Extensions/NavMeshPathfinding/JsExtension.js b/Extensions/NavMeshPathfinding/JsExtension.js index 82f2c68b30..890ea9c590 100644 --- a/Extensions/NavMeshPathfinding/JsExtension.js +++ b/Extensions/NavMeshPathfinding/JsExtension.js @@ -389,7 +389,7 @@ module.exports = { .addIncludeFile( 'Extensions/NavMeshPathfinding/NavMeshObstacleRuntimeBehavior.js' ) - .addIncludeFile( + .addRequiredFile( 'Extensions/NavMeshPathfinding/recast-navigation.wasm.js' ) .addRequiredFile( @@ -858,7 +858,7 @@ module.exports = { .addIncludeFile( 'Extensions/NavMeshPathfinding/NavMeshObstacleRuntimeBehavior.js' ) - .addIncludeFile( + .addRequiredFile( 'Extensions/NavMeshPathfinding/recast-navigation.wasm.js' ) .addRequiredFile( @@ -886,7 +886,9 @@ module.exports = { .addIncludeFile( 'Extensions/NavMeshPathfinding/NavMeshObstacleRuntimeBehavior.js' ) - .addIncludeFile('Extensions/NavMeshPathfinding/recast-navigation.wasm.js') + // `recast-navigation.wasm.js` is an ES module loaded with `import()` by + // NavMeshCharacterRuntimeBehavior.js: it's a required file of the + // behaviors and must not be included as a classic script. .addIncludeFile( 'Extensions/NavMeshPathfinding/recast-navigation-generators.js' ) diff --git a/Extensions/NavMeshPathfinding/NavMeshCharacterRuntimeBehavior.ts b/Extensions/NavMeshPathfinding/NavMeshCharacterRuntimeBehavior.ts index c7960f4f9e..b069acbd3e 100644 --- a/Extensions/NavMeshPathfinding/NavMeshCharacterRuntimeBehavior.ts +++ b/Extensions/NavMeshPathfinding/NavMeshCharacterRuntimeBehavior.ts @@ -1,17 +1,31 @@ namespace gdjs { const loadRecast = async () => { try { - const module = await import('./recast-navigation.wasm.js'); - const initializeRecast = module.default; - if (!initializeRecast) { - throw new Error('No default export found in Recast.'); - } - - const Recast = await initializeRecast(); - - await RecastNav.init(); - //@ts-ignore - window.Recast = Recast; + // Give the module instantiated from `recast-navigation.wasm.wasm` to + // RecastNav. Without an implementation, `RecastNav.init` instantiates + // the "compat" build embedded (in base64) in + // `recast-navigation-generators.js`, so each game would create 2 + // WebAssembly instances (each with its own memory) instead of 1. + // Previews of the in-game editor and gameplay tests run in the editor + // process: this halves the WebAssembly memory reserved by each preview. + // `RecastNav.init` does nothing (and doesn't instantiate anything) if + // Recast is already initialized, for instance if this script is + // reloaded by the hot-reloader. + // The vendored `init` expects a function returning the module, but the + // typings of `@recast-navigation/core` declare the module itself. + const instantiateRecastModule = async () => { + const module = await import('./recast-navigation.wasm.js'); + const initializeRecast = module.default; + if (!initializeRecast) { + throw new Error('No default export found in Recast.'); + } + return initializeRecast(); + }; + await RecastNav.init( + instantiateRecastModule as unknown as Parameters< + typeof RecastNav.init + >[0] + ); } catch (err) { console.error('Unable to load Recast navigation mesh library.', err); throw err; diff --git a/Extensions/NavMeshPathfinding/NavMeshDebugPixiRenderer.ts b/Extensions/NavMeshPathfinding/NavMeshDebugPixiRenderer.ts index d64f77078b..8273fd6b5b 100644 --- a/Extensions/NavMeshPathfinding/NavMeshDebugPixiRenderer.ts +++ b/Extensions/NavMeshPathfinding/NavMeshDebugPixiRenderer.ts @@ -116,6 +116,17 @@ namespace gdjs { this.meshes.length = 0; } + /** + * Free the debug drawer from the WebAssembly memory. + */ + dispose() { + this.removeFor3D(); + if (this.debugDrawerUtils) { + this.debugDrawerUtils.dispose(); + this.debugDrawerUtils = null; + } + } + renderFor3D() { this.removeFor3D(); if (!this.enabled || !this.obstaclesManager.is3D) { diff --git a/Extensions/NavMeshPathfinding/NavMeshObstacleRuntimeBehavior.ts b/Extensions/NavMeshPathfinding/NavMeshObstacleRuntimeBehavior.ts index 99b4166f74..14a6db28cd 100644 --- a/Extensions/NavMeshPathfinding/NavMeshObstacleRuntimeBehavior.ts +++ b/Extensions/NavMeshPathfinding/NavMeshObstacleRuntimeBehavior.ts @@ -582,8 +582,43 @@ namespace gdjs { character._agent = null; } } + + /** + * Free the navigation mesh and the crowd from the WebAssembly memory. + * The manager must not be used after this. + */ + dispose(): void { + for (const character of this.characters) { + character._agent = null; + } + this.characters.clear(); + this.obstacles.clear(); + if (this.crowd) { + this.crowd.destroy(); + this.crowd = null; + } + if (this.navMesh) { + this.navMesh.destroy(); + this.navMesh = null; + } + if (this.debuggerRenderer) { + this.debuggerRenderer.dispose(); + this.debuggerRenderer = null; + } + } } + gdjs.registerRuntimeSceneUnloadedCallback(function (runtimeScene) { + // Without this, the navigation mesh and the crowd of each unloaded scene + // would stay allocated in the WebAssembly memory. + const navMeshObstaclesManager = runtimeScene.navMeshObstaclesManager; + if (navMeshObstaclesManager) { + navMeshObstaclesManager.dispose(); + // @ts-ignore The manager is set back to undefined like before its creation. + runtimeScene.navMeshObstaclesManager = undefined; + } + }); + /** @category Behaviors > NavMesh pathfinding */ export namespace NavMeshObstaclesManager { /** diff --git a/Extensions/NavMeshPathfinding/tests/navmeshcharacterruntimebehavior.spec.js b/Extensions/NavMeshPathfinding/tests/navmeshcharacterruntimebehavior.spec.js index ce17ecfae3..426b69cde5 100644 --- a/Extensions/NavMeshPathfinding/tests/navmeshcharacterruntimebehavior.spec.js +++ b/Extensions/NavMeshPathfinding/tests/navmeshcharacterruntimebehavior.spec.js @@ -315,6 +315,24 @@ describe('gdjs.NavMeshCharacterRuntimeBehavior', function () { expect(getPathLength(character)).to.be.within(180, 220); }); + it('frees the navigation mesh and the crowd when the scene is unloaded', function () { + character.setPosition(400, 300); + runtimeScene.renderAndStep(1000 / 60); + getCharacterBehavior(character).moveTo(600, 300, 0); + runtimeScene.renderAndStep(1000 / 60); + + const navMeshObstaclesManager = runtimeScene.navMeshObstaclesManager; + expect(navMeshObstaclesManager.navMesh).not.to.be(null); + expect(navMeshObstaclesManager.crowd).not.to.be(null); + + runtimeScene.unloadScene(); + + expect(navMeshObstaclesManager.navMesh).to.be(null); + expect(navMeshObstaclesManager.crowd).to.be(null); + expect(navMeshObstaclesManager.characters.size).to.be(0); + expect(runtimeScene.navMeshObstaclesManager).to.be(undefined); + }); + describe('(network synchronization)', function () { it('can sync an idle character over an idle character without error', function () { const otherCharacter = addCharacter(runtimeScene); diff --git a/GDJS/Runtime/debugger-client/hot-reloader.ts b/GDJS/Runtime/debugger-client/hot-reloader.ts index 37c4d91739..847e8bd5b1 100644 --- a/GDJS/Runtime/debugger-client/hot-reloader.ts +++ b/GDJS/Runtime/debugger-client/hot-reloader.ts @@ -116,6 +116,9 @@ namespace gdjs { if ( // Don't reload Box2d as it would confuse and crash the asm.js library. endsWith(srcFilename, 'box2d.js') || + // Don't reload the Recast navigation library as it would lose + // its initialized WebAssembly module. + endsWith(srcFilename, 'recast-navigation-generators.js') || // Don't reload sha256.js library. endsWith(srcFilename, 'sha256.js') || // Don't reload shopify-buy library. diff --git a/newIDE/app/src/Utils/Analytics/EventSender.js b/newIDE/app/src/Utils/Analytics/EventSender.js index dc7ff2a9bb..48f24debb3 100644 --- a/newIDE/app/src/Utils/Analytics/EventSender.js +++ b/newIDE/app/src/Utils/Analytics/EventSender.js @@ -198,6 +198,11 @@ export const installAnalyticsEvents = () => { posthogLoaded = true; }, autocapture: false, // we disable autocapture because we want to control which events we send. + session_recording: { + // Iframes (in-game editor, gameplay tests) are not recorded: the recording would + // keep a reference to every document loaded in them (including game WebAssembly memories). + blockSelector: 'iframe', + }, }); };