Fix NavMesh memory leaks (#9175)

This commit is contained in:
Aurélien Vivet
2026-10-05 12:59:28 +02:00
committed by GitHub
parent 965b93662d
commit fdb154ce95
7 changed files with 102 additions and 14 deletions
+5 -3
View File
@@ -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'
)
@@ -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;
@@ -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) {
@@ -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 {
/**
@@ -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);
@@ -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.
@@ -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',
},
});
};