Skip to content

Commit

Permalink
feat: WebGLTexture garbage collection (#2974)
Browse files Browse the repository at this point in the history
This PR adds WebGLTexture garbage collection, it looks for textures that haven't been drawn in a minute and removes them from the GPU. If they get drawn in the future they will be reloaded

---------

Co-authored-by: Yorick van Klinken <[email protected]>
  • Loading branch information
eonarheim and Autsider666 authored Jul 19, 2024
1 parent 8689e72 commit 385c061
Show file tree
Hide file tree
Showing 9 changed files with 381 additions and 9 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ...;
Expand Down
14 changes: 14 additions & 0 deletions sandbox/tests/memory-leaker/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Memory Leaker</title>
</head>
<body>
<p>This creates a new bitmap every 200 ms and uploads it to the gpu</p>
<p>You should see WebGL Texture cleanup messages in the console after the configured interval (1 minute)</p>
<script src="../../lib/excalibur.js"></script>
<script src="index.js"></script>
</body>
</html>
53 changes: 53 additions & 0 deletions sandbox/tests/memory-leaker/index.ts
Original file line number Diff line number Diff line change
@@ -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;
52 changes: 49 additions & 3 deletions src/engine/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -144,6 +145,19 @@ export interface EngineOptions<TKnownScenes extends string = any> {
*/
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
*
Expand Down Expand Up @@ -372,6 +386,10 @@ export class Engine<TKnownScenes extends string = any> implements CanInitialize,
*/
scope = <TReturn>(cb: () => TReturn) => Engine.Context.scope(this, cb);

private _garbageCollector: GarbageCollector;

public readonly garbageCollectorConfig: GarbageCollectionOptions | null;

/**
* Current Excalibur version string
*
Expand Down Expand Up @@ -695,6 +713,7 @@ export class Engine<TKnownScenes extends string = any> implements CanInitialize,
snapToPixel: false,
antialiasing: true,
pixelArt: false,
garbageCollection: true,
powerPreference: 'high-performance',
pointerScope: PointerScope.Canvas,
suppressConsoleBootMessage: null,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -830,6 +865,9 @@ O|===|* >________________>\n\
displayMode = DisplayMode.FitScreen;
}

this.grabWindowFocus = options.grabWindowFocus;
this.pointerScope = options.pointerScope;

this._originalDisplayMode = displayMode;

let pixelArtSampler: boolean;
Expand Down Expand Up @@ -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
});
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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');
}
}
Expand Down
126 changes: 126 additions & 0 deletions src/engine/GarbageCollector.ts
Original file line number Diff line number Diff line change
@@ -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<any, [type: string, time: number]>();
private _collectors = new Map<string, [(resource: any) => 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);
}
}
5 changes: 4 additions & 1 deletion src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -229,6 +231,7 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
snapToPixel,
backgroundColor,
useDrawSorting,
garbageCollector,
handleContextLost,
handleContextRestored
} = options;
Expand Down Expand Up @@ -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;
Expand Down
Loading

0 comments on commit 385c061

Please sign in to comment.