diff --git a/addons/xterm-addon-webgl/src/GlyphRenderer.ts b/addons/xterm-addon-webgl/src/GlyphRenderer.ts index d78d9ce947..ce71f7b6d0 100644 --- a/addons/xterm-addon-webgl/src/GlyphRenderer.ts +++ b/addons/xterm-addon-webgl/src/GlyphRenderer.ts @@ -10,6 +10,7 @@ import { Terminal } from 'xterm'; import { IRasterizedGlyph, IRenderDimensions, ITextureAtlas } from 'browser/renderer/shared/Types'; import { Disposable, toDisposable } from 'common/Lifecycle'; import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; +import { TextureAtlas } from 'browser/renderer/shared/TextureAtlas'; interface IVertices { attributes: Float32Array; @@ -107,8 +108,6 @@ export class GlyphRenderer extends Disposable { ] }; - private static _maxAtlasPages: number | undefined; - constructor( private readonly _terminal: Terminal, private readonly _gl: IWebGL2RenderingContext, @@ -118,11 +117,14 @@ export class GlyphRenderer extends Disposable { const gl = this._gl; - if (GlyphRenderer._maxAtlasPages === undefined) { - GlyphRenderer._maxAtlasPages = throwIfFalsy(gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null); + if (TextureAtlas.maxAtlasPages === undefined) { + // Typically 8 or 16 + TextureAtlas.maxAtlasPages = throwIfFalsy(gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null); + // Almost all clients will support >= 4096 + TextureAtlas.maxTextureSize = throwIfFalsy(gl.getParameter(gl.MAX_TEXTURE_SIZE) as number | null); } - this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(GlyphRenderer._maxAtlasPages))); + this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages))); this.register(toDisposable(() => gl.deleteProgram(this._program))); // Uniform locations @@ -177,8 +179,8 @@ export class GlyphRenderer extends Disposable { // Setup static uniforms gl.useProgram(this._program); - const textureUnits = new Int32Array(GlyphRenderer._maxAtlasPages); - for (let i = 0; i < GlyphRenderer._maxAtlasPages; i++) { + const textureUnits = new Int32Array(TextureAtlas.maxAtlasPages); + for (let i = 0; i < TextureAtlas.maxAtlasPages; i++) { textureUnits[i] = i; } gl.uniform1iv(this._textureLocation, textureUnits); @@ -187,7 +189,7 @@ export class GlyphRenderer extends Disposable { // Setup 1x1 red pixel textures for all potential atlas pages, if one of these invalid textures // is ever drawn it will show characters as red rectangles. this._atlasTextures = []; - for (let i = 0; i < GlyphRenderer._maxAtlasPages; i++) { + for (let i = 0; i < TextureAtlas.maxAtlasPages; i++) { const texture = throwIfFalsy(gl.createTexture()); this.register(toDisposable(() => gl.deleteTexture(texture))); gl.activeTexture(gl.TEXTURE0 + i); diff --git a/addons/xterm-addon-webgl/src/WebglAddon.ts b/addons/xterm-addon-webgl/src/WebglAddon.ts index 5262da8e58..9ae6df5a1b 100644 --- a/addons/xterm-addon-webgl/src/WebglAddon.ts +++ b/addons/xterm-addon-webgl/src/WebglAddon.ts @@ -21,6 +21,8 @@ export class WebglAddon extends Disposable implements ITerminalAddon { public readonly onChangeTextureAtlas = this._onChangeTextureAtlas.event; private readonly _onAddTextureAtlasCanvas = this.register(new EventEmitter()); public readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event; + private readonly _onRemoveTextureAtlasCanvas = this.register(new EventEmitter()); + public readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event; private readonly _onContextLoss = this.register(new EventEmitter()); public readonly onContextLoss = this._onContextLoss.event; @@ -67,6 +69,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon { this.register(forwardEvent(this._renderer.onContextLoss, this._onContextLoss)); this.register(forwardEvent(this._renderer.onChangeTextureAtlas, this._onChangeTextureAtlas)); this.register(forwardEvent(this._renderer.onAddTextureAtlasCanvas, this._onAddTextureAtlasCanvas)); + this.register(forwardEvent(this._renderer.onRemoveTextureAtlasCanvas, this._onRemoveTextureAtlasCanvas)); renderService.setRenderer(this._renderer); this.register(toDisposable(() => { diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.ts b/addons/xterm-addon-webgl/src/WebglRenderer.ts index 8f039033bc..97e8dd7dc8 100644 --- a/addons/xterm-addon-webgl/src/WebglRenderer.ts +++ b/addons/xterm-addon-webgl/src/WebglRenderer.ts @@ -16,7 +16,7 @@ import { AttributeData } from 'common/buffer/AttributeData'; import { CellData } from 'common/buffer/CellData'; import { Content, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants'; import { EventEmitter, forwardEvent } from 'common/EventEmitter'; -import { Disposable, toDisposable } from 'common/Lifecycle'; +import { Disposable, getDisposeArrayDisposable, toDisposable } from 'common/Lifecycle'; import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; import { CharData, IBufferLine, ICellData } from 'common/Types'; import { IDisposable, Terminal } from 'xterm'; @@ -53,6 +53,8 @@ export class WebglRenderer extends Disposable implements IRenderer { public readonly onChangeTextureAtlas = this._onChangeTextureAtlas.event; private readonly _onAddTextureAtlasCanvas = this.register(new EventEmitter()); public readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event; + private readonly _onRemoveTextureAtlasCanvas = this.register(new EventEmitter()); + public readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event; private readonly _onRequestRedraw = this.register(new EventEmitter()); public readonly onRequestRedraw = this._onRequestRedraw.event; private readonly _onContextLoss = this.register(new EventEmitter()); @@ -268,7 +270,10 @@ export class WebglRenderer extends Disposable implements IRenderer { this._charAtlasDisposable?.dispose(); this._onChangeTextureAtlas.fire(atlas.pages[0].canvas); - this._charAtlasDisposable = forwardEvent(atlas.onAddTextureAtlasCanvas, this._onAddTextureAtlasCanvas); + this._charAtlasDisposable = getDisposeArrayDisposable([ + forwardEvent(atlas.onAddTextureAtlasCanvas, this._onAddTextureAtlasCanvas), + forwardEvent(atlas.onRemoveTextureAtlasCanvas, this._onRemoveTextureAtlasCanvas) + ]); } this._charAtlas = atlas; this._charAtlas.warmUp(); @@ -327,7 +332,6 @@ export class WebglRenderer extends Disposable implements IRenderer { // Tell renderer the frame is beginning if (this._glyphRenderer.beginFrame()) { this._clearModel(true); - this._model.selection.clear(); } // Update model to reflect what's drawn diff --git a/demo/client.ts b/demo/client.ts index 033ea41b48..3a438c000b 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -220,6 +220,7 @@ if (document.location.pathname === '/test') { document.getElementById('custom-glyph').addEventListener('click', writeCustomGlyphHandler); document.getElementById('load-test').addEventListener('click', loadTest); document.getElementById('print-cjk').addEventListener('click', addCjk); + document.getElementById('print-cjk-sgr').addEventListener('click', addCjkRandomSgr); document.getElementById('powerline-symbol-test').addEventListener('click', powerlineSymbolTest); document.getElementById('underline-test').addEventListener('click', underlineTest); document.getElementById('ansi-colors').addEventListener('click', ansiColorsTest); @@ -278,6 +279,7 @@ function createTerminal(): void { setTextureAtlas(addons.webgl.instance.textureAtlas); addons.webgl.instance.onChangeTextureAtlas(e => setTextureAtlas(e)); addons.webgl.instance.onAddTextureAtlasCanvas(e => appendTextureAtlas(e)); + addons.webgl.instance.onRemoveTextureAtlasCanvas(e => removeTextureAtlas(e)); } }, 0); @@ -661,6 +663,9 @@ function appendTextureAtlas(e: HTMLCanvasElement): void { styleAtlasPage(e); document.querySelector('#texture-atlas').appendChild(e); } +function removeTextureAtlas(e: HTMLCanvasElement): void { + e.remove(); +} function styleAtlasPage(e: HTMLCanvasElement): void { e.style.width = `${e.width / window.devicePixelRatio}px`; e.style.height = `${e.height / window.devicePixelRatio}px`; @@ -989,6 +994,25 @@ function addCjk(): void { } } +/** + * Prints the 20977 characters from the CJK Unified Ideographs unicode block with randomized styles. + */ +function addCjkRandomSgr(): void { + term.write('\n\n\r'); + for (let i = 0x4E00; i < 0x9FCC; i++) { + term.write(`\x1b[${getRandomSgr()}m${String.fromCharCode(i)}\x1b[0m`); + } +} +const randomSgrAttributes = [ + '1', '2', '3', '4', '5', '6', '7', '9', + '21', '22', '23', '24', '25', '26', '27', '28', '29', + '30', '31', '32', '33', '34', '35', '36', '37', '38', '39', + '40', '41', '42', '43', '44', '45', '46', '47', '48', '49' +]; +function getRandomSgr(): string { + return randomSgrAttributes[Math.floor(Math.random() * randomSgrAttributes.length)]; +} + function addDecoration(): void { term.options['overviewRulerWidth'] = 15; const marker = term.registerMarker(1); diff --git a/demo/index.html b/demo/index.html index 26dcc53d2d..f62d535ce6 100644 --- a/demo/index.html +++ b/demo/index.html @@ -75,6 +75,7 @@

Test

Performance
+
Styles
diff --git a/src/browser/renderer/shared/TextureAtlas.ts b/src/browser/renderer/shared/TextureAtlas.ts index 22c33c5006..654f368033 100644 --- a/src/browser/renderer/shared/TextureAtlas.ts +++ b/src/browser/renderer/shared/TextureAtlas.ts @@ -13,7 +13,7 @@ import { excludeFromContrastRatioDemands, isPowerlineGlyph, isRestrictedPowerlin import { IUnicodeService } from 'common/services/Services'; import { FourKeyMap } from 'common/MultiKeyMap'; import { IdleTaskQueue } from 'common/TaskQueue'; -import { IBoundingBox, ICharAtlasConfig, IRasterizedGlyph, ITextureAtlas } from 'browser/renderer/shared/Types'; +import { IBoundingBox, ICharAtlasConfig, IRasterizedGlyph, IRequestRedrawEvent, ITextureAtlas } from 'browser/renderer/shared/Types'; import { EventEmitter } from 'common/EventEmitter'; /** @@ -35,7 +35,13 @@ const enum Constants { * The amount of pixel padding to allow in each row. Setting this to zero would make the atlas * page pack as tightly as possible, but more pages would end up being created as a result. */ - ROW_PIXEL_THRESHOLD = 2 + ROW_PIXEL_THRESHOLD = 2, + /** + * The maximum texture size regardless of what the actual hardware maximum turns out to be. This + * is enforced to ensure uploading the texture still finishes in a reasonable amount of time. A + * 4096 squared image takes up 16MB of GPU memory. + */ + FORCED_MAX_TEXTURE_SIZE = 4096 } interface ICharAtlasActiveRow { @@ -69,8 +75,13 @@ export class TextureAtlas implements ITextureAtlas { private _textureSize: number = 512; + public static maxAtlasPages: number | undefined; + public static maxTextureSize: number | undefined; + private readonly _onAddTextureAtlasCanvas = new EventEmitter(); public readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event; + private readonly _onRemoveTextureAtlasCanvas = new EventEmitter(); + public readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event; constructor( private readonly _document: Document, @@ -116,9 +127,9 @@ export class TextureAtlas implements ITextureAtlas { } } + private _requestClearModel = false; public beginFrame(): boolean { - // TODO: Something should happen to prevent reaching capacity - return false; + return this._requestClearModel; } public clearTexture(): void { @@ -134,10 +145,57 @@ export class TextureAtlas implements ITextureAtlas { } private _createNewPage(): AtlasPage { - if (this._pages.length === 4 || this._pages.length === 7) { - this._increaseTextureSize(); + // Try merge the set of the 4 most used pages of the largest size. This is is deferred to a + // microtask to ensure it does not interrupt textures that will be rendered in the current + // animation frame which would result in blank rendered areas. This is actually not that + // expensive relative to drawing the glyphs, so there is no need to wait for an idle callback. + if (TextureAtlas.maxAtlasPages && this._pages.length >= Math.max(4, TextureAtlas.maxAtlasPages / 2)) { + queueMicrotask(() => { + // Find the set of the largest 4 images, below the maximum size, with the highest + // percentages used + const pagesBySize = this._pages.filter(e => { + return e.canvas.width * 2 <= (TextureAtlas.maxTextureSize || Constants.FORCED_MAX_TEXTURE_SIZE); + }).sort((a, b) => { + if (b.canvas.width !== a.canvas.width) { + return b.canvas.width - a.canvas.width; + } + return b.percentageUsed - a.percentageUsed; + }); + let sameSizeI = -1; + let size = 0; + for (let i = 0; i < pagesBySize.length; i++) { + if (pagesBySize[i].canvas.width !== size) { + sameSizeI = i; + size = pagesBySize[i].canvas.width; + } else if (i - sameSizeI === 3) { + break; + } + } + + // Gather details of the merge + const mergingPages = pagesBySize.slice(sameSizeI, sameSizeI + 4); + const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a > b ? 1 : -1); + const mergedPageIndex = sortedMergingPagesIndexes[0]; + + // Merge into the new page + const mergedPage = this._mergePages(mergingPages, mergedPageIndex); + mergedPage.hasCanvasChanged = true; + + // Replace the first _merging_ page with the _merged_ page + this._pages[mergedPageIndex] = mergedPage; + + // Delete the other 3 pages, shifting glyph texture pages as needed + for (let i = sortedMergingPagesIndexes.length - 1; i >= 1; i--) { + this._deletePage(sortedMergingPagesIndexes[i]); + } + + // Request the model to be cleared to refresh all texture pages. + this._requestClearModel = true; + this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); + }); } - // TODO: Ensure pages aren't created beyond the maximum supported + + // All new atlas pages are created small as they are highly dynamic const newPage = new AtlasPage(this._document, this._textureSize); this._pages.push(newPage); this._activePages.push(newPage); @@ -145,14 +203,42 @@ export class TextureAtlas implements ITextureAtlas { return newPage; } - /** - * Doubles the texture size of new atlas pages if allowed. - */ - private _increaseTextureSize(): void { - // 4096 is the minimum texture size in WebGL, but we still want the texture to be reasonably fast - // to upload. We could loosen this limit if it ever becomes a problem. - if (this._textureSize < 2048) { - this._textureSize *= 2; + private _mergePages(mergingPages: AtlasPage[], mergedPageIndex: number): AtlasPage { + const mergedSize = mergingPages[0].canvas.width * 2; + const mergedPage = new AtlasPage(this._document, mergedSize, mergingPages); + for (const [i, p] of mergingPages.entries()) { + const xOffset = i * p.canvas.width % mergedSize; + const yOffset = Math.floor(i / 2) * p.canvas.height; + mergedPage.ctx.drawImage(p.canvas, xOffset, yOffset); + for (const g of p.glyphs) { + g.texturePage = mergedPageIndex; + g.sizeClipSpace.x = g.size.x / mergedSize; + g.sizeClipSpace.y = g.size.y / mergedSize; + g.texturePosition.x += xOffset; + g.texturePosition.y += yOffset; + g.texturePositionClipSpace.x = g.texturePosition.x / mergedSize; + g.texturePositionClipSpace.y = g.texturePosition.y / mergedSize; + } + + this._onRemoveTextureAtlasCanvas.fire(p.canvas); + + // Remove the merging page from active pages if it was there + const index = this._activePages.indexOf(p); + if (index !== -1) { + this._activePages.splice(index, 1); + } + } + return mergedPage; + } + + private _deletePage(pageIndex: number): void { + this._pages.splice(pageIndex, 1); + for (let j = pageIndex; j < this._pages.length; j++) { + const adjustingPage = this._pages[j]; + for (const g of adjustingPage.glyphs) { + g.texturePage--; + } + adjustingPage.hasCanvasChanged = true; } } @@ -615,6 +701,15 @@ export class TextureAtlas implements ITextureAtlas { let activePage: AtlasPage; let activeRow: ICharAtlasActiveRow; while (true) { + // If there are no active pages (the last smallest 4 were merged), create a new one + if (this._activePages.length === 0) { + const newPage = this._createNewPage(); + activePage = newPage; + activeRow = newPage.currentRow; + activeRow.height = rasterizedGlyph.size.y; + break; + } + // Get the best current row from all active pages activePage = this._activePages[this._activePages.length - 1]; activeRow = activePage.currentRow; @@ -730,6 +825,7 @@ export class TextureAtlas implements ITextureAtlas { rasterizedGlyph.size.x, rasterizedGlyph.size.y ); + activePage.addGlyph(rasterizedGlyph); activePage.hasCanvasChanged = true; return rasterizedGlyph; @@ -829,6 +925,16 @@ class AtlasPage { public readonly canvas: HTMLCanvasElement; public readonly ctx: CanvasRenderingContext2D; + private _usedPixels: number = 0; + public get percentageUsed(): number { return this._usedPixels / (this.canvas.width * this.canvas.height); } + + private readonly _glyphs: IRasterizedGlyph[] = []; + public get glyphs(): ReadonlyArray { return this._glyphs; } + public addGlyph(glyph: IRasterizedGlyph): void { + this._glyphs.push(glyph); + this._usedPixels += glyph.size.x * glyph.size.y; + } + /** * Whether the canvas of the atlas page has changed, this is only set to true by the atlas, the * user of the boolean is required to reset its value to false. @@ -854,8 +960,15 @@ class AtlasPage { constructor( document: Document, - size: number + size: number, + sourcePages?: AtlasPage[] ) { + if (sourcePages) { + for (const p of sourcePages) { + this._glyphs.push(...p.glyphs); + this._usedPixels += p._usedPixels; + } + } this.canvas = createCanvas(document, size, size); // The canvas needs alpha because we use clearColor to convert the background color to alpha. // It might also contain some characters with transparent backgrounds if allowTransparency is diff --git a/src/browser/renderer/shared/Types.d.ts b/src/browser/renderer/shared/Types.d.ts index 0948aec7d4..78a6b6e5ed 100644 --- a/src/browser/renderer/shared/Types.d.ts +++ b/src/browser/renderer/shared/Types.d.ts @@ -90,6 +90,7 @@ export interface ITextureAtlas extends IDisposable { readonly pages: { canvas: HTMLCanvasElement, hasCanvasChanged: boolean }[]; onAddTextureAtlasCanvas: IEvent; + onRemoveTextureAtlasCanvas: IEvent; /** * Warm up the texture atlas, adding common glyphs to avoid slowing early frame.