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]); + }); +});