diff --git a/CHANGELOG.md b/CHANGELOG.md
index d829b89e1..1b6c28343 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -53,6 +53,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Added
+- Excalibur will now clean up WebGL textures that have not been drawn in a while, which improves stability for long game sessions
+ * If a graphic is drawn again it will be reloaded into the GPU seamlessly
- You can now query for colliders on the physics world
```typescript
const scene = ...;
diff --git a/sandbox/tests/memory-leaker/index.html b/sandbox/tests/memory-leaker/index.html
new file mode 100644
index 000000000..94c96e22d
--- /dev/null
+++ b/sandbox/tests/memory-leaker/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Memory Leaker
+
+
+ This creates a new bitmap every 200 ms and uploads it to the gpu
+ You should see WebGL Texture cleanup messages in the console after the configured interval (1 minute)
+
+
+
+
diff --git a/sandbox/tests/memory-leaker/index.ts b/sandbox/tests/memory-leaker/index.ts
new file mode 100644
index 000000000..0e37e3325
--- /dev/null
+++ b/sandbox/tests/memory-leaker/index.ts
@@ -0,0 +1,53 @@
+ex.Logger.getInstance().defaultLevel = ex.LogLevel.Debug;
+var game = new ex.Engine({
+ width: 1400,
+ height: 1400,
+ pixelRatio: 4,
+ garbageCollection: true // should not crash chrome/ff after 10 minutes
+});
+
+var actor = new ex.Actor({
+ anchor: ex.vec(0.5, 0.5),
+ pos: game.screen.center,
+ color: ex.Color.Red,
+ radius: 5
+});
+
+actor.actions.repeatForever((ctx) => {
+ ctx.moveBy(ex.vec(100, 100), 100);
+ ctx.moveBy(ex.vec(-100, -100), 100);
+});
+var ran = new ex.Random(1337);
+// var rectangles: ex.Rectangle[] = [];
+// for (let i = 0; i < 4; i++) {
+// const rect = new ex.Rectangle({
+// width: 2000,
+// height: 2000,
+// color: new ex.Color(ran.integer(0, 255), ran.integer(0, 255), ran.integer(0, 255))
+// });
+// rectangles.push(rect);
+// }
+
+actor.onInitialize = () => {
+ ex.coroutine(function* () {
+ let index = 0;
+ // actor.graphics.use(rectangles[index]);
+ while (true) {
+ yield 500;
+ // yield 2_000;
+ // actor.graphics.use(rectangles[index++ % rectangles.length]);
+ actor.graphics.use(
+ new ex.Rectangle({
+ width: 2000,
+ height: 2000,
+ color: new ex.Color(ran.integer(0, 255), ran.integer(0, 255), ran.integer(0, 255))
+ })
+ );
+ }
+ });
+};
+
+game.start();
+game.add(actor);
+
+actor.angularVelocity = 2;
diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts
index a6f2b7eb2..a31820f7e 100644
--- a/src/engine/Engine.ts
+++ b/src/engine/Engine.ts
@@ -53,6 +53,7 @@ import { InputHost } from './Input/InputHost';
import { DefaultPhysicsConfig, DeprecatedStaticToConfig, PhysicsConfig } from './Collision/PhysicsConfig';
import { DeepRequired } from './Util/Required';
import { Context, createContext, useContext } from './Context';
+import { DefaultGarbageCollectionOptions, GarbageCollectionOptions, GarbageCollector } from './GarbageCollector';
import { mergeDeep } from './Util/Util';
export type EngineEvents = {
@@ -144,6 +145,19 @@ export interface EngineOptions {
*/
antialiasing?: boolean | AntialiasOptions;
+ /**
+ * Optionally specify excalibur garbage collection, by default false
+ *
+ * * `true` - garbage collection defaults are enabled (default)
+ *
+ * * `false` - garbage collection is completely disabled (not recommended)
+ *
+ * * [[GarbageCollectionOptions]] Optionally deeply configure garbage collection settings, **WARNING** thar be dragons here.
+ * It is recommended you stick to `true` or `false` unless you understand what you're doing, it is possible to get into a downward
+ * spiral if collection timings are set too low where you are stuck in repeated collection.
+ */
+ garbageCollection?: boolean | GarbageCollectionOptions;
+
/**
* Quick convenience property to configure Excalibur to use special settings for "pretty" anti-aliased pixel art
*
@@ -372,6 +386,10 @@ export class Engine implements CanInitialize,
*/
scope = (cb: () => TReturn) => Engine.Context.scope(this, cb);
+ private _garbageCollector: GarbageCollector;
+
+ public readonly garbageCollectorConfig: GarbageCollectionOptions | null;
+
/**
* Current Excalibur version string
*
@@ -695,6 +713,7 @@ export class Engine implements CanInitialize,
snapToPixel: false,
antialiasing: true,
pixelArt: false,
+ garbageCollection: true,
powerPreference: 'high-performance',
pointerScope: PointerScope.Canvas,
suppressConsoleBootMessage: null,
@@ -799,6 +818,22 @@ O|===|* >________________>\n\
}
this._logger.debug('Building engine...');
+ if (options.garbageCollection === true) {
+ this.garbageCollectorConfig = {
+ ...DefaultGarbageCollectionOptions
+ };
+ } else if (options.garbageCollection === false) {
+ this._logger.warn(
+ 'WebGL Garbage Collection Disabled!!! If you leak any images over time your game will crash when GPU memory is exhausted'
+ );
+ this.garbageCollectorConfig = null;
+ } else {
+ this.garbageCollectorConfig = {
+ ...DefaultGarbageCollectionOptions,
+ ...options.garbageCollection
+ };
+ }
+ this._garbageCollector = new GarbageCollector({ getTimestamp: Date.now });
this.canvasElementId = options.canvasElementId;
@@ -830,6 +865,9 @@ O|===|* >________________>\n\
displayMode = DisplayMode.FitScreen;
}
+ this.grabWindowFocus = options.grabWindowFocus;
+ this.pointerScope = options.pointerScope;
+
this._originalDisplayMode = displayMode;
let pixelArtSampler: boolean;
@@ -885,6 +923,12 @@ O|===|* >________________>\n\
backgroundColor: options.backgroundColor,
snapToPixel: options.snapToPixel,
useDrawSorting: options.useDrawSorting,
+ garbageCollector: this.garbageCollectorConfig
+ ? {
+ garbageCollector: this._garbageCollector,
+ collectionInterval: this.garbageCollectorConfig.textureCollectInterval
+ }
+ : null,
handleContextLost: options.handleContextLost ?? this._handleWebGLContextLost,
handleContextRestored: options.handleContextRestored
});
@@ -930,9 +974,6 @@ O|===|* >________________>\n\
this.backgroundColor = options.backgroundColor.clone();
}
- this.grabWindowFocus = options.grabWindowFocus;
- this.pointerScope = options.pointerScope;
-
this.maxFps = options.maxFps ?? this.maxFps;
this.fixedUpdateFps = options.fixedUpdateFps ?? this.fixedUpdateFps;
@@ -1122,6 +1163,7 @@ O|===|* >________________>\n\
if (!this._disposed) {
this._disposed = true;
this.stop();
+ this._garbageCollector.forceCollectAll();
this.input.toggleEnabled(false);
this.canvas.parentNode.removeChild(this.canvas);
this.canvas = null;
@@ -1656,6 +1698,9 @@ O|===|* >________________>\n\
this._logger.debug('Starting game clock...');
this.browser.resume();
this.clock.start();
+ if (this.garbageCollectorConfig) {
+ this._garbageCollector.start();
+ }
this._logger.debug('Game clock started');
await this.load(loader ?? new Loader());
@@ -1730,6 +1775,7 @@ O|===|* >________________>\n\
this.emit('stop', new GameStopEvent(this));
this.browser.pause();
this.clock.stop();
+ this._garbageCollector.stop();
this._logger.debug('Game stopped');
}
}
diff --git a/src/engine/GarbageCollector.ts b/src/engine/GarbageCollector.ts
new file mode 100644
index 000000000..d3e8ab454
--- /dev/null
+++ b/src/engine/GarbageCollector.ts
@@ -0,0 +1,126 @@
+export interface GarbageCollectionOptions {
+ /**
+ * Textures that aren't drawn after a certain number of milliseconds are unloaded from the GPU
+ * Default 60_000 ms
+ */
+ textureCollectInterval?: number; // default 60_000 ms
+ // TODO future work to integrate the font and text configuration, refactor existing collection mechanism
+ // /**
+ // * Font pre-renders that aren't drawn after a certain number of milliseconds are unloaded from the GPU
+ // * Default 60_000 ms
+ // */
+ // fontCollectInterval: number; // default 60_000 ms
+ // /**
+ // * Text measurements that aren't used after a certain number of milliseconds are unloaded from the GPU
+ // * Default 60_000 ms
+ // */
+ // textMeasurementCollectInterval: number; // default 60_000 ms
+}
+
+export const DefaultGarbageCollectionOptions: GarbageCollectionOptions = {
+ textureCollectInterval: 60_000
+ // TODO future work to integrate the font and text configuration, refactor existing collection mechanism
+ // fontCollectInterval: 60_000,
+ // textMeasurementCollectInterval: 60_000,
+};
+
+export interface GarbageCollectorOptions {
+ /**
+ * Returns a timestamp in milliseconds representing now
+ */
+ getTimestamp: () => number;
+}
+
+export class GarbageCollector {
+ private _collectHandle: number;
+ private _running = false;
+ private _collectionMap = new Map();
+ private _collectors = new Map boolean, interval: number]>();
+
+ constructor(public options: GarbageCollectorOptions) {}
+
+ /**
+ *
+ * @param type Resource type
+ * @param timeoutInterval If resource type exceeds interval in milliseconds collect() is called
+ * @param collect Collection implementation, returns true if collected
+ */
+ registerCollector(type: string, timeoutInterval: number, collect: (resource: any) => boolean) {
+ this._collectors.set(type, [collect, timeoutInterval]);
+ }
+
+ /**
+ * Add a resource to be tracked for collection
+ * @param type
+ * @param resource
+ */
+ addCollectableResource(type: string, resource: any) {
+ this._collectionMap.set(resource, [type, this.options.getTimestamp()]);
+ }
+
+ /**
+ * Update the resource last used timestamp preventing collection
+ * @param resource
+ */
+ touch(resource: any) {
+ const collectionData = this._collectionMap.get(resource);
+ if (collectionData) {
+ this._collectionMap.set(resource, [collectionData[0], this.options.getTimestamp()]);
+ }
+ }
+
+ /**
+ * Runs the collection loop to cleanup any stale resources given the registered collect handlers
+ */
+ public collectStaleResources = (deadline?: IdleDeadline) => {
+ if (!this._running) {
+ return;
+ }
+ for (const [type, [collector, timeoutInterval]] of this._collectors.entries()) {
+ const now = this.options.getTimestamp();
+ for (const [resource, [resourceType, time]] of this._collectionMap.entries()) {
+ if (type !== resourceType || time + timeoutInterval >= now) {
+ continue;
+ }
+
+ const collected = collector(resource);
+ if (collected) {
+ this._collectionMap.delete(resource);
+ }
+ }
+ }
+
+ this._collectHandle = requestIdleCallback(this.collectStaleResources);
+ };
+
+ /**
+ * Force collect all resources, useful for shutting down a game
+ * or if you know that you will not use anything you've allocated before now
+ */
+ public forceCollectAll() {
+ for (const [_, [collector]] of this._collectors.entries()) {
+ for (const [resource] of this._collectionMap.entries()) {
+ const collected = collector(resource);
+ if (collected) {
+ this._collectionMap.delete(resource);
+ }
+ }
+ }
+ }
+
+ /**
+ * Starts the garbage collection loop
+ */
+ start() {
+ this._running = true;
+ this.collectStaleResources();
+ }
+
+ /**
+ * Stops the garbage collection loop
+ */
+ stop() {
+ this._running = false;
+ cancelIdleCallback(this._collectHandle);
+ }
+}
diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts
index 63ed7f089..660dd60af 100644
--- a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts
+++ b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts
@@ -34,6 +34,7 @@ import { AffineMatrix } from '../../Math/affine-matrix';
import { Material, MaterialOptions } from './material';
import { MaterialRenderer } from './material-renderer/material-renderer';
import { Shader, ShaderOptions } from './shader';
+import { GarbageCollector } from '../../GarbageCollector';
export const pixelSnapEpsilon = 0.0001;
@@ -88,6 +89,7 @@ export interface WebGLGraphicsContextInfo {
export interface ExcaliburGraphicsContextWebGLOptions extends ExcaliburGraphicsContextOptions {
context?: WebGL2RenderingContext;
+ garbageCollector?: { garbageCollector: GarbageCollector; collectionInterval: number };
handleContextLost?: (e: Event) => void;
handleContextRestored?: (e: Event) => void;
}
@@ -229,6 +231,7 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
snapToPixel,
backgroundColor,
useDrawSorting,
+ garbageCollector,
handleContextLost,
handleContextRestored
} = options;
@@ -261,7 +264,7 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
this._isContextLost = false;
});
- this.textureLoader = new TextureLoader(this.__gl);
+ this.textureLoader = new TextureLoader(this.__gl, garbageCollector);
this.snapToPixel = snapToPixel ?? this.snapToPixel;
this.smoothing = antialiasing ?? this.smoothing;
this.transparency = enableTransparency ?? this.transparency;
diff --git a/src/engine/Graphics/Context/texture-loader.ts b/src/engine/Graphics/Context/texture-loader.ts
index f70d32b44..422776eb2 100644
--- a/src/engine/Graphics/Context/texture-loader.ts
+++ b/src/engine/Graphics/Context/texture-loader.ts
@@ -1,3 +1,4 @@
+import { GarbageCollector } from '../../GarbageCollector';
import { Logger } from '../../Util/Log';
import { ImageFiltering } from '../Filtering';
import { ImageSourceOptions, ImageWrapConfiguration } from '../ImageSource';
@@ -10,9 +11,19 @@ import { HTMLImageSource } from './ExcaliburGraphicsContext';
export class TextureLoader {
private static _LOGGER = Logger.getInstance();
- constructor(gl: WebGL2RenderingContext) {
+ constructor(
+ gl: WebGL2RenderingContext,
+ private _garbageCollector?: {
+ garbageCollector: GarbageCollector;
+ collectionInterval: number;
+ }
+ ) {
this._gl = gl;
TextureLoader._MAX_TEXTURE_SIZE = gl.getParameter(gl.MAX_TEXTURE_SIZE);
+ if (_garbageCollector) {
+ TextureLoader._LOGGER.debug('WebGL Texture collection interval:', this._garbageCollector.collectionInterval);
+ this._garbageCollector.garbageCollector?.registerCollector('texture', this._garbageCollector.collectionInterval, this._collect);
+ }
}
public dispose() {
@@ -78,6 +89,7 @@ export class TextureLoader {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
}
+ this._garbageCollector?.garbageCollector.touch(image);
return tex;
}
@@ -141,7 +153,8 @@ export class TextureLoader {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
- this._textureMap.set(image, tex as any);
+ this._textureMap.set(image, tex);
+ this._garbageCollector?.garbageCollector.addCollectableResource('texture', image);
return tex;
}
@@ -152,10 +165,12 @@ export class TextureLoader {
return;
}
- let tex: WebGLTexture | null = null;
if (this.has(image)) {
- tex = this.get(image);
- gl.deleteTexture(tex);
+ const texture = this.get(image);
+ if (texture) {
+ this._textureMap.delete(image);
+ gl.deleteTexture(texture);
+ }
}
}
@@ -185,4 +200,17 @@ export class TextureLoader {
}
return true;
}
+
+ /**
+ * Looks for textures that haven't been drawn in a while
+ */
+ private _collect = (image: HTMLImageSource) => {
+ if (this._gl) {
+ const name = image.dataset.originalSrc ?? image.constructor.name;
+ TextureLoader._LOGGER.debug(`WebGL Texture for ${name} collected`);
+ this.delete(image);
+ return true;
+ }
+ return false;
+ };
}
diff --git a/src/engine/index.ts b/src/engine/index.ts
index 81f2f8c89..147292bf2 100644
--- a/src/engine/index.ts
+++ b/src/engine/index.ts
@@ -11,6 +11,7 @@ polyfill();
export * from './Flags';
export * from './Id';
export * from './Engine';
+export * from './GarbageCollector';
export * from './Screen';
export * from './Actor';
export * from './Math/Index';
diff --git a/src/spec/GarbageCollectorSpec.ts b/src/spec/GarbageCollectorSpec.ts
new file mode 100644
index 000000000..b20acb210
--- /dev/null
+++ b/src/spec/GarbageCollectorSpec.ts
@@ -0,0 +1,99 @@
+import * as ex from '@excalibur';
+
+describe('A garbage collector', () => {
+ let requestIdleCallback: jasmine.Spy<(typeof window)['requestIdleCallback']>;
+ let cancelIdleCallback: jasmine.Spy<(typeof window)['cancelIdleCallback']>;
+
+ it('exists', () => {
+ expect(ex.GarbageCollector).toBeDefined();
+ });
+
+ beforeEach(() => {
+ requestIdleCallback = spyOn(window, 'requestIdleCallback');
+ cancelIdleCallback = spyOn(window, 'cancelIdleCallback');
+ });
+
+ it('can be started', () => {
+ const currentTime = 0;
+ const getTimestamp = () => currentTime;
+ const sut = new ex.GarbageCollector({ getTimestamp });
+ sut.start();
+ expect(requestIdleCallback).toHaveBeenCalled();
+ });
+
+ it('can be stopped', () => {
+ const currentTime = 0;
+ const getTimestamp = () => currentTime;
+ const sut = new ex.GarbageCollector({ getTimestamp });
+ sut.start();
+ sut.stop();
+ expect(requestIdleCallback).toHaveBeenCalled();
+ expect(cancelIdleCallback).toHaveBeenCalled();
+ });
+
+ it('can register a resource for collection that will be collected', () => {
+ let currentTime = 0;
+ const getTimestamp = () => currentTime;
+ const sut = new ex.GarbageCollector({ getTimestamp });
+
+ const collect = jasmine.createSpy('collect').and.returnValue(true);
+
+ sut.start();
+ sut.registerCollector('test', 100, collect);
+ const resource = { my: 'cool resource' };
+ sut.addCollectableResource('test', resource);
+ sut.collectStaleResources();
+
+ expect(collect).not.toHaveBeenCalled();
+
+ currentTime = 101;
+ sut.collectStaleResources();
+ expect(collect).toHaveBeenCalled();
+ });
+
+ it('can register a resource for collection and touch will prevent collection', () => {
+ let currentTime = 0;
+ const getTimestamp = () => currentTime;
+ const sut = new ex.GarbageCollector({ getTimestamp });
+
+ const collect = jasmine.createSpy('collect').and.returnValue(true);
+
+ sut.start();
+ sut.registerCollector('test', 100, collect);
+ const resource = { my: 'cool resource' };
+ sut.addCollectableResource('test', resource);
+ sut.collectStaleResources();
+
+ expect(collect).not.toHaveBeenCalled();
+
+ currentTime = 101;
+ sut.touch(resource);
+ sut.collectStaleResources();
+ expect(collect).not.toHaveBeenCalled();
+
+ currentTime = 202;
+ sut.collectStaleResources();
+ expect(collect).toHaveBeenCalled();
+ });
+
+ it('can force collect all resources', () => {
+ const currentTime = 0;
+ const getTimestamp = () => currentTime;
+ const sut = new ex.GarbageCollector({ getTimestamp });
+
+ const collect = jasmine.createSpy('collect').and.returnValue(true);
+
+ sut.start();
+ sut.registerCollector('test', 100, collect);
+ const resource = { my: 'cool resource' };
+ sut.addCollectableResource('test', resource);
+ sut.registerCollector('test2', 100, collect);
+ const resource2 = { my: 'cool resource2' };
+ sut.addCollectableResource('test2', resource2);
+
+ sut.forceCollectAll();
+
+ expect(collect.calls.argsFor(0)).toEqual([resource]);
+ expect(collect.calls.argsFor(1)).toEqual([resource2]);
+ });
+});