Skip to content

Commit

Permalink
Merge pull request #4252 from Tyriar/4243_merge
Browse files Browse the repository at this point in the history
Continually merge texture atlas pages
  • Loading branch information
Tyriar committed Nov 3, 2022
2 parents c66dc82 + 97a0df2 commit 862e83d
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 27 deletions.
18 changes: 10 additions & 8 deletions addons/xterm-addon-webgl/src/GlyphRenderer.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -107,8 +108,6 @@ export class GlyphRenderer extends Disposable {
]
};

private static _maxAtlasPages: number | undefined;

constructor(
private readonly _terminal: Terminal,
private readonly _gl: IWebGL2RenderingContext,
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions addons/xterm-addon-webgl/src/WebglAddon.ts
Expand Up @@ -21,6 +21,8 @@ export class WebglAddon extends Disposable implements ITerminalAddon {
public readonly onChangeTextureAtlas = this._onChangeTextureAtlas.event;
private readonly _onAddTextureAtlasCanvas = this.register(new EventEmitter<HTMLCanvasElement>());
public readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event;
private readonly _onRemoveTextureAtlasCanvas = this.register(new EventEmitter<HTMLCanvasElement>());
public readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event;
private readonly _onContextLoss = this.register(new EventEmitter<void>());
public readonly onContextLoss = this._onContextLoss.event;

Expand Down Expand Up @@ -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(() => {
Expand Down
10 changes: 7 additions & 3 deletions addons/xterm-addon-webgl/src/WebglRenderer.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -53,6 +53,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
public readonly onChangeTextureAtlas = this._onChangeTextureAtlas.event;
private readonly _onAddTextureAtlasCanvas = this.register(new EventEmitter<HTMLCanvasElement>());
public readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event;
private readonly _onRemoveTextureAtlasCanvas = this.register(new EventEmitter<HTMLCanvasElement>());
public readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event;
private readonly _onRequestRedraw = this.register(new EventEmitter<IRequestRedrawEvent>());
public readonly onRequestRedraw = this._onRequestRedraw.event;
private readonly _onContextLoss = this.register(new EventEmitter<void>());
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions demo/client.ts
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions demo/index.html
Expand Up @@ -75,6 +75,7 @@ <h3>Test</h3>
<dt>Performance</dt>
<dd><button id="load-test" title="Write several MB of data to simulate a lot of data coming from the process">Load test</button></dd>
<dd><button id="print-cjk" title="Prints the 20977 characters from the CJK Unified Ideographs unicode block">CJK Unified Ideographs</button></dd>
<dd><button id="print-cjk-sgr" title="Prints the 20977 characters from the CJK Unified Ideographs unicode block with randomized SGR attributes">CJK Unified Ideographs (random SGR)</button></dd>

<dt>Styles</dt>
<dd><button id="custom-glyph" title="Write custom box drawing and block element characters to the terminal">Test custom glyphs</button></dd>
Expand Down
145 changes: 129 additions & 16 deletions src/browser/renderer/shared/TextureAtlas.ts
Expand Up @@ -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';

/**
Expand All @@ -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 {
Expand Down Expand Up @@ -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<HTMLCanvasElement>();
public readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event;
private readonly _onRemoveTextureAtlasCanvas = new EventEmitter<HTMLCanvasElement>();
public readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event;

constructor(
private readonly _document: Document,
Expand Down Expand Up @@ -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 {
Expand All @@ -134,25 +145,100 @@ 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);
this._onAddTextureAtlasCanvas.fire(newPage.canvas);
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;
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -730,6 +825,7 @@ export class TextureAtlas implements ITextureAtlas {
rasterizedGlyph.size.x,
rasterizedGlyph.size.y
);
activePage.addGlyph(rasterizedGlyph);
activePage.hasCanvasChanged = true;

return rasterizedGlyph;
Expand Down Expand Up @@ -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<IRasterizedGlyph> { 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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/browser/renderer/shared/Types.d.ts
Expand Up @@ -90,6 +90,7 @@ export interface ITextureAtlas extends IDisposable {
readonly pages: { canvas: HTMLCanvasElement, hasCanvasChanged: boolean }[];

onAddTextureAtlasCanvas: IEvent<HTMLCanvasElement>;
onRemoveTextureAtlasCanvas: IEvent<HTMLCanvasElement>;

/**
* Warm up the texture atlas, adding common glyphs to avoid slowing early frame.
Expand Down

0 comments on commit 862e83d

Please sign in to comment.