Skip to content

Commit

Permalink
fix: Handle webgl context lost + Recover from hidpi scale too large (#…
Browse files Browse the repository at this point in the history
…2958)

This PR addresses some issues with webgl contexts

- Fixed issue when WebGL context lost occurs where there was no friendly output to the user
- Fixed issue where HiDPI scaling could accidentally scale past the 4k mobile limit, if the context would scale too large it will now attempt to recover by backing off.
- Added way to add custom WebGL context lost/recovered handlers for your game
  ```typescript
  const game = new ex.Engine({
    handleContextLost: (e) => {...},
    handleContextRestored: (e) => {...}
  })
  ```

Default context lost screen 

![image](https://github.com/excaliburjs/Excalibur/assets/612071/92b20772-7ce9-4099-86aa-d174d561044e)
  • Loading branch information
eonarheim authored Mar 8, 2024
1 parent aad9ad2 commit 31548c9
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 9 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,18 @@ This project adheres to [Semantic Versioning](http://semver.org/).

- Added `GraphicsComponent.bounds` which will report the world bounds of the graphic if applicable!
- Added `ex.Vector.EQUALS_EPSILON` to configure the `ex.Vector.equals(v)` threshold
- Added way to add custom WebGL context lost/recovered handlers for your game
```typescript
const game = new ex.Engine({
handleContextLost: (e) => {...},
handleContextRestored: (e) => {...}
})
```

### Fixed

- Fixed issue when WebGL context lost occurs where there was no friendly output to the user
- Fixed issue where HiDPI scaling could accidentally scale past the 4k mobile limit, if the context would scale too large it will now attempt to recover by backing off.
- Fixed issue where logo was sometimes not loaded during `ex.Loader`
- Fixed issue where unbounded containers would grow infinitely when using the following display modes:
* `DisplayMode.FillContainer`
Expand Down
59 changes: 57 additions & 2 deletions src/engine/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export interface EngineOptions<TKnownScenes extends string = any> {
*
* By default is unset and updates will use the current instantaneous framerate with 1 update and 1 draw each clock step.
*/
fixedUpdateFps?: number
fixedUpdateFps?: number;

/**
* Default `true`, optionally configure excalibur to use optimal draw call sorting, to opt out set this to `false`.
Expand All @@ -295,6 +295,16 @@ export interface EngineOptions<TKnownScenes extends string = any> {
*/
useDrawSorting?: boolean;

/**
* Optionally provide a custom handler for the webgl context lost event
*/
handleContextLost?: (e: Event) => void;

/**
* Optionally provide a custom handler for the webgl context restored event
*/
handleContextRestored?: (e: Event) => void;

/**
* Optionally configure how excalibur handles poor performance on a player's browser
*/
Expand Down Expand Up @@ -860,7 +870,9 @@ O|===|* >________________>\n\
powerPreference: options.powerPreference,
backgroundColor: options.backgroundColor,
snapToPixel: options.snapToPixel,
useDrawSorting: options.useDrawSorting
useDrawSorting: options.useDrawSorting,
handleContextLost: options.handleContextLost ?? this._handleWebGLContextLost,
handleContextRestored: options.handleContextRestored
});
} catch (e) {
this._logger.warn(
Expand Down Expand Up @@ -941,6 +953,49 @@ O|===|* >________________>\n\
(window as any).___EXCALIBUR_DEVTOOL = this;
}

private _handleWebGLContextLost = (e: Event) => {
e.preventDefault();
this.clock.stop();
this._logger.fatalOnce('WebGL Graphics Lost', e);
const container = document.createElement('div');
container.id = 'ex-webgl-graphics-context-lost';
container.style.position = 'absolute';
container.style.zIndex = '99';
container.style.left = '50%';
container.style.top = '50%';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.transform = 'translate(-50%, -50%)';
container.style.backgroundColor = 'white';
container.style.padding = '10px';
container.style.borderStyle = 'solid 1px';

const div = document.createElement('div');
div.innerHTML = `
<h1>There was an issue rendering, please refresh the page.</h1>
<div>
<p>WebGL Graphics Context Lost</p>
<button id="ex-webgl-graphics-reload">Refresh Page</button>
<p>There are a few reasons this might happen:</p>
<ul>
<li>Two or more pages are placing a high demand on the GPU</li>
<li>Another page or operation has stalled the GPU and the browser has decided to reset the GPU</li>
<li>The computer has multiple GPUs and the user has switched between them</li>
<li>Graphics driver has crashed or restarted</li>
<li>Graphics driver was updated</li>
</ul>
</div>
`;
container.appendChild(div);
if (this.canvas?.parentElement) {
this.canvas.parentElement.appendChild(container);
const button = div.querySelector('#ex-webgl-graphics-reload');
button?.addEventListener('click', () => location.reload());
}
};

private _performanceThresholdTriggered = false;
private _fpsSamples: number[] = [];
private _monitorPerformanceThresholdAndTriggerFallback() {
Expand Down
35 changes: 33 additions & 2 deletions src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ export interface WebGLGraphicsContextInfo {
}

export interface ExcaliburGraphicsContextWebGLOptions extends ExcaliburGraphicsContextOptions {
context?: WebGL2RenderingContext
context?: WebGL2RenderingContext,
handleContextLost?: (e: Event) => void,
handleContextRestored?: (e: Event) => void
}

export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
Expand Down Expand Up @@ -209,6 +211,7 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
public readonly multiSampleAntialiasing: boolean = true;
public readonly samples?: number;
public readonly transparency: boolean = true;
private _isContextLost = false;

constructor(options: ExcaliburGraphicsContextWebGLOptions) {
const {
Expand All @@ -222,7 +225,9 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
powerPreference,
snapToPixel,
backgroundColor,
useDrawSorting
useDrawSorting,
handleContextLost,
handleContextRestored
} = options;
this.__gl = context ?? canvasElement.getContext('webgl2', {
antialias: antialiasing ?? this.smoothing,
Expand All @@ -234,6 +239,23 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
if (!this.__gl) {
throw Error('Failed to retrieve webgl context from browser');
}

if (handleContextLost) {
this.__gl.canvas.addEventListener('webglcontextlost', handleContextLost, false);
}

if (handleContextRestored) {
this.__gl.canvas.addEventListener('webglcontextrestored', handleContextRestored, false);
}

this.__gl.canvas.addEventListener('webglcontextlost', () => {
this._isContextLost = true;
});

this.__gl.canvas.addEventListener('webglcontextrestored', () => {
this._isContextLost = false;
});

this.textureLoader = new TextureLoader(this.__gl);
this.snapToPixel = snapToPixel ?? this.snapToPixel;
this.smoothing = antialiasing ?? this.smoothing;
Expand Down Expand Up @@ -369,6 +391,10 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
`Attempting to draw outside the the drawing lifecycle (preDraw/postDraw) is not supported and is a source of bugs/errors.\n` +
`If you want to do custom drawing, use Actor.graphics, or any onPreDraw or onPostDraw handler.`);
}
if (this._isContextLost) {
this._logger.errorOnce(`Unable to draw ${rendererName}, the webgl context is lost`);
return;
}

const renderer = this._renderers.get(rendererName);
if (renderer) {
Expand Down Expand Up @@ -600,6 +626,11 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
* Flushes all batched rendering to the screen
*/
flush() {
if (this._isContextLost) {
this._logger.errorOnce(`Unable to flush the webgl context is lost`);
return;
}

// render target captures all draws and redirects to the render target
let currentTarget = this.multiSampleAntialiasing ? this._msaaTarget : this._renderTarget;
currentTarget.use();
Expand Down
30 changes: 25 additions & 5 deletions src/engine/Screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,24 +499,44 @@ export class Screen {
}
}

public applyResolutionAndViewport() {
this._canvas.width = this.scaledWidth;
this._canvas.height = this.scaledHeight;


public applyResolutionAndViewport() {
if (this.graphicsContext instanceof ExcaliburGraphicsContextWebGL) {
const supported = this.graphicsContext.checkIfResolutionSupported({
const scaledResolutionSupported = this.graphicsContext.checkIfResolutionSupported({
width: this.scaledWidth,
height: this.scaledHeight
});
if (!supported) {
if (!scaledResolutionSupported) {
this._logger.warnOnce(
`The currently configured resolution (${this.resolution.width}x${this.resolution.height}) and pixel ratio (${this.pixelRatio})` +
' are too large for the platform WebGL implementation, this may work but cause WebGL rendering to behave oddly.' +
' Try reducing the resolution or disabling Hi DPI scaling to avoid this' +
' (read more here https://excaliburjs.com/docs/screens#understanding-viewport--resolution).');

// Attempt to recover if the user hasn't configured a specific ratio for up scaling
if (!this.pixelRatioOverride) {
let currentPixelRatio = Math.max(1, this.pixelRatio - .5);
let newResolutionSupported = false;
while (currentPixelRatio > 1 && !newResolutionSupported) {
currentPixelRatio = Math.max(1, currentPixelRatio - .5);
const width = this._resolution.width * currentPixelRatio;
const height = this._resolution.height * currentPixelRatio;
newResolutionSupported = this.graphicsContext.checkIfResolutionSupported({ width, height });
}
this.pixelRatioOverride = currentPixelRatio;
this._logger.warnOnce(
'Scaled resolution too big attempted recovery!' +
` Pixel ratio was automatically reduced to (${this.pixelRatio}) to avoid 4k texture limit.` +
' Setting `ex.Engine({pixelRatio: ...}) will override any automatic recalculation, do so at your own risk.` ' +
' (read more here https://excaliburjs.com/docs/screens#understanding-viewport--resolution).');
}
}
}

this._canvas.width = this.scaledWidth;
this._canvas.height = this.scaledHeight;

if (this._canvasImageRendering === 'auto') {
this._canvas.style.imageRendering = 'auto';
} else {
Expand Down
41 changes: 41 additions & 0 deletions src/spec/ScreenSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,4 +902,45 @@ describe('A Screen', () => {
' (read more here https://excaliburjs.com/docs/screens#understanding-viewport--resolution).'
);
});

it('will warn if the resolution is too large and attempt to recover', () => {
const logger = ex.Logger.getInstance();
const warnOnce = spyOn(logger, 'warnOnce');

const canvasElement = document.createElement('canvas');
canvasElement.width = 100;
canvasElement.height = 100;

const context = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvasElement,
enableTransparency: false,
snapToPixel: true,
backgroundColor: ex.Color.White
});

const sut = new ex.Screen({
canvas,
context,
browser,
viewport: { width: 800, height: 600 }
});

spyOn(context, 'checkIfResolutionSupported').and.callThrough();
sut.resolution = { width: 2000, height: 2000 };
(sut as any)._devicePixelRatio = 3;
sut.applyResolutionAndViewport();
expect(context.checkIfResolutionSupported).toHaveBeenCalled();
expect(warnOnce.calls.argsFor(0)).toEqual([
`The currently configured resolution (${sut.resolution.width}x${sut.resolution.height}) and pixel ratio (3)` +
' are too large for the platform WebGL implementation, this may work but cause WebGL rendering to behave oddly.' +
' Try reducing the resolution or disabling Hi DPI scaling to avoid this' +
' (read more here https://excaliburjs.com/docs/screens#understanding-viewport--resolution).'
]);
expect(warnOnce.calls.argsFor(1)).toEqual([
'Scaled resolution too big attempted recovery!' +
` Pixel ratio was automatically reduced to (2) to avoid 4k texture limit.` +
' Setting `ex.Engine({pixelRatio: ...}) will override any automatic recalculation, do so at your own risk.` ' +
' (read more here https://excaliburjs.com/docs/screens#understanding-viewport--resolution).'
]);
});
});

0 comments on commit 31548c9

Please sign in to comment.