diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a55f61cb..4b5988426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index baeb7f281..78b797acf 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -285,7 +285,7 @@ export interface EngineOptions { * * 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`. @@ -295,6 +295,16 @@ export interface EngineOptions { */ 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 */ @@ -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( @@ -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 = ` +

There was an issue rendering, please refresh the page.

+
+

WebGL Graphics Context Lost

+ + + +

There are a few reasons this might happen:

+
    +
  • Two or more pages are placing a high demand on the GPU
  • +
  • Another page or operation has stalled the GPU and the browser has decided to reset the GPU
  • +
  • The computer has multiple GPUs and the user has switched between them
  • +
  • Graphics driver has crashed or restarted
  • +
  • Graphics driver was updated
  • +
+
+ `; + 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() { diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts index 6f5c97d80..6bf390217 100644 --- a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts +++ b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts @@ -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 { @@ -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 { @@ -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, @@ -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; @@ -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) { @@ -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(); diff --git a/src/engine/Screen.ts b/src/engine/Screen.ts index c6a8d8aec..113196cce 100644 --- a/src/engine/Screen.ts +++ b/src/engine/Screen.ts @@ -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 { diff --git a/src/spec/ScreenSpec.ts b/src/spec/ScreenSpec.ts index e0cb9405e..dcf075018 100644 --- a/src/spec/ScreenSpec.ts +++ b/src/spec/ScreenSpec.ts @@ -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).' + ]); + }); });