-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
WebGL context loss handling #5
Changes from 4 commits
42ad0ff
a8cbf98
15def95
8d3a076
d4907cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,9 +20,15 @@ import { CHAR_DATA_ATTR_INDEX, CHAR_DATA_CODE_INDEX, CHAR_DATA_CHAR_INDEX, NULL_ | |
import { IWebGL2RenderingContext } from './Types'; | ||
import { INVERTED_DEFAULT_COLOR, DEFAULT_COLOR } from '../atlas/Types'; | ||
import { RenderModel, COMBINED_CHAR_BIT_MASK } from './RenderModel'; | ||
import { addDisposableDomListener } from '../../ui/Lifecycle'; | ||
|
||
export const INDICIES_PER_CELL = 4; | ||
|
||
interface IPendingRecover { | ||
renderer: WebglRenderer; | ||
justLost: boolean | ||
} | ||
|
||
export class WebglRenderer extends EventEmitter implements IRenderer { | ||
private _renderDebouncer: RenderDebouncer; | ||
private _renderLayers: IRenderLayer[]; | ||
|
@@ -39,10 +45,101 @@ export class WebglRenderer extends EventEmitter implements IRenderer { | |
|
||
private _isPaused: boolean = false; | ||
private _needsFullRefresh: boolean = false; | ||
private _salvaged: boolean = false; | ||
|
||
public dimensions: IRenderDimensions; | ||
public colorManager: ColorManager; | ||
|
||
// Destroys paused renderes if the resources are needed for unpaused ones. | ||
// This is unique to WebglRenderer because 'webgl2' contexts can become | ||
// invalidated at any moment, typically by creating other canvases with | ||
// 'webgl2' context, but also arbitrarily by the OS. | ||
private static _contextHelper = new class { | ||
private _recoverTimeout: any = null; | ||
|
||
// Terminals ordered by least-recently-used first | ||
private _lru: ITerminal[] = []; | ||
|
||
// Renderers that lost context and need to be recreated | ||
private _pendingRecover: IPendingRecover[] = []; | ||
|
||
// Called when a renderer is created or unpaused to signify that the user | ||
// just used this terminal. Terminals are kept in least-recently-used order, | ||
// so that if resources are needed, we can sacrifice the least important | ||
// ones | ||
public onRendererUnpaused(renderer: WebglRenderer) { | ||
const index = this._lru.indexOf(renderer._terminal); | ||
if (index >= 0) { | ||
this._lru.splice(index, 1); | ||
} | ||
this._lru.push(renderer._terminal); | ||
} | ||
|
||
public onRendererCreated(renderer: WebglRenderer) { | ||
this.onRendererUnpaused(renderer); | ||
} | ||
|
||
|
||
public onRendererDestroyed(renderer: WebglRenderer) { | ||
const index = this._lru.indexOf(renderer._terminal); | ||
this._lru.splice(index, 1); | ||
} | ||
|
||
public recoverContext(renderer: WebglRenderer, justLost: boolean) { | ||
this._pendingRecover.push({ renderer, justLost }); | ||
if (!this._recoverTimeout) { | ||
this._recoverTimeout = setTimeout(() => { | ||
this._recoverTimeout = null; | ||
|
||
// webglcontextlost can fire before resize observer, which means that some | ||
// of the renderers that are pending recovery, might not actually need it | ||
// if they are now paused | ||
const toRecover = this._pendingRecover.filter(({ renderer }) => !renderer._isPaused); | ||
this._pendingRecover = []; | ||
if (toRecover.length > 0) { | ||
this._recoverPendingContexts(toRecover) | ||
} | ||
}, 0); | ||
} | ||
} | ||
|
||
private _recoverPendingContexts(toRecover: IPendingRecover[]) { | ||
// try to kill some paused renderers to make room for this one | ||
let killCount = 0; | ||
for (let term of this._lru) { | ||
const renderer = <WebglRenderer>term.renderer; | ||
if (renderer._isPaused && !renderer._gl.isContextLost()) { | ||
// Prevent this renderer from being immediately recreated when | ||
// webglcontextlost fires | ||
renderer.loseContext(); | ||
renderer._salvaged = true; | ||
|
||
// Only kill enough renderers to recover all the pending contexts | ||
killCount++; | ||
if (killCount >= toRecover.length) { | ||
break; | ||
} | ||
} | ||
} | ||
|
||
// If any of these contexts were *just* lost, it means that we're currently | ||
// at a resource limit, but if we failed to release any resources | ||
// (i.e. everything is unpaused), there's no point in trying to recreate a | ||
// renderer because we would be killing an unpaused one. In this case, | ||
// just give up. | ||
const requireKill = toRecover.some(({ justLost }) => justLost); | ||
if (requireKill && killCount === 0) { | ||
console.error('Too many simultaneous webgl contexts'); | ||
return; | ||
} | ||
|
||
// Recreate these renderer | ||
toRecover.forEach(({ renderer }) => { | ||
renderer._terminal.recreateRenderer() | ||
}); | ||
} | ||
} | ||
|
||
constructor( | ||
private _terminal: ITerminal, | ||
theme: ITheme | ||
|
@@ -87,6 +184,7 @@ export class WebglRenderer extends EventEmitter implements IRenderer { | |
if (!this._gl) { | ||
throw new Error('WebGL2 not supported'); | ||
} | ||
this.register(addDisposableDomListener(this._canvas, 'webglcontextlost', () => { this._onContextLost() })); | ||
this._terminal.screenElement.appendChild(this._canvas); | ||
|
||
this._rectangleRenderer = new RectangleRenderer(this._terminal, this.colorManager, this._gl, this.dimensions); | ||
|
@@ -95,22 +193,59 @@ export class WebglRenderer extends EventEmitter implements IRenderer { | |
// Detect whether IntersectionObserver is detected and enable renderer pause | ||
// and resume based on terminal visibility if so | ||
if ('IntersectionObserver' in window) { | ||
const observer = new IntersectionObserver(e => this.onIntersectionChange(e[0]), { threshold: 0 }); | ||
const observer = new IntersectionObserver(e => this._onIntersectionChange(e[0]), { threshold: 0 }); | ||
observer.observe(this._terminal.element); | ||
this.register({ dispose: () => observer.disconnect() }); | ||
} | ||
|
||
WebglRenderer._contextHelper.onRendererCreated(this); | ||
} | ||
|
||
public dispose(): void { | ||
this._renderLayers.forEach(l => l.dispose()); | ||
this._terminal.screenElement.removeChild(this._canvas); | ||
super.dispose(); | ||
this._canvas.width = 0; | ||
this._canvas.height = 0; | ||
|
||
if (!this._gl.isContextLost()) { | ||
console.log('Force context loss'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should happen automatically after dispose on the next GC? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, however, we force it so that another context can be created before the next GC. If we don't force it here, Chrome might not notice there's an available context and could kill an active one. What I observed is:
So, it seems like one of these is happening:
I'll grab a snapshot to make sure (3) is not happening There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can confirm all webgl contexts are eventually GC'd. But we still need this to prevent Chrome from killing the oldest context (as opposed to the LRU one) |
||
this.loseContext(); | ||
} | ||
WebglRenderer._contextHelper.onRendererDestroyed(this); | ||
} | ||
|
||
public loseContext(): void { | ||
this._gl.getExtension('WEBGL_lose_context').loseContext() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not supported in Chrome? https://developer.mozilla.org/en-US/docs/Web/API/WEBGL_lose_context/loseContext There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nah, that seems outdated. It's definitely supported on Chrome and Chromium: https://codesandbox.io/embed/xpjjjmn1oq?view=preview |
||
} | ||
|
||
public onIntersectionChange(entry: IntersectionObserverEntry): void { | ||
private _onContextLost(): void { | ||
if (this._salvaged) { | ||
this._salvaged = false; | ||
return; | ||
} | ||
|
||
WebglRenderer._contextHelper.recoverContext(this, true); | ||
} | ||
|
||
private _onIntersectionChange(entry: IntersectionObserverEntry): void { | ||
const wasPaused = this._isPaused; | ||
this._isPaused = entry.intersectionRatio === 0; | ||
if (!this._isPaused && this._needsFullRefresh) { | ||
this._terminal.refresh(0, this._terminal.rows - 1); | ||
|
||
if (!this._isPaused) { | ||
if (wasPaused) { | ||
WebglRenderer._contextHelper.onRendererUnpaused(this); | ||
} | ||
if (this._gl.isContextLost()) { | ||
WebglRenderer._contextHelper.recoverContext(this, false); | ||
|
||
// Return here because this renderer will be re-created to acquire a | ||
// valid context, so there's no point in refreshing this one | ||
return; | ||
} | ||
if (this._needsFullRefresh) { | ||
this._terminal.refresh(0, this._terminal.rows - 1); | ||
} | ||
} | ||
} | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you pull this into WebglContextHelper.ts?