Skip to content
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

Dynamic atlas: Reduce unnecessary objects generated and draw from ImageBitmap #1692

Merged
merged 18 commits into from Sep 23, 2018
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/renderer/BaseRenderLayer.ts
Expand Up @@ -247,7 +247,13 @@ export abstract class BaseRenderLayer implements IRenderLayer {
fg += drawInBrightColor ? 8 : 0;
const atlasDidDraw = this._charAtlas && this._charAtlas.draw(
this._ctx,
{chars, code, bg, fg, bold: bold && terminal.options.enableBold, dim, italic},
chars,
code,
bg,
fg,
bold,
dim,
italic,
x * this._scaledCellWidth + this._scaledCharLeft,
y * this._scaledCellHeight + this._scaledCharTop
);
Expand Down
19 changes: 15 additions & 4 deletions src/renderer/atlas/BaseCharAtlas.ts
Expand Up @@ -3,8 +3,6 @@
* @license MIT
*/

import { IGlyphIdentifier } from './Types';

export default abstract class BaseCharAtlas {
private _didWarmUp: boolean = false;

Expand Down Expand Up @@ -39,14 +37,27 @@ export default abstract class BaseCharAtlas {
* do nothing and return false in that case.
*
* @param ctx Where to draw the character onto.
* @param glyph Information about what to draw
* @param chars The character(s) to draw. This is typically a single character bug can be made up
* of multiple when character joiners are used.
* @param code The character code.
* @param bg The background color.
* @param fg The foreground color.
* @param bold Whether the text is bold.
* @param dim Whether the text is dim.
* @param italic Whether the text is italic.
* @param x The position on the context to start drawing at
* @param y The position on the context to start drawing at
* @returns The success state. True if we drew the character.
*/
public abstract draw(
ctx: CanvasRenderingContext2D,
glyph: IGlyphIdentifier,
chars: string,
code: number,
bg: number,
fg: number,
bold: boolean,
dim: boolean,
italic: boolean,
x: number,
y: number
): boolean;
Expand Down
191 changes: 157 additions & 34 deletions src/renderer/atlas/DynamicCharAtlas.ts
Expand Up @@ -3,13 +3,14 @@
* @license MIT
*/

import { DIM_OPACITY, IGlyphIdentifier, INVERTED_DEFAULT_COLOR } from './Types';
import { DIM_OPACITY, INVERTED_DEFAULT_COLOR } from './Types';
import { ICharAtlasConfig } from '../../shared/atlas/Types';
import { IColor } from '../../shared/Types';
import BaseCharAtlas from './BaseCharAtlas';
import { DEFAULT_ANSI_COLORS } from '../ColorManager';
import { clearColor } from '../../shared/atlas/CharAtlasGenerator';
import LRUMap from './LRUMap';
import { isFirefox, isSafari } from '../../shared/utils/Browser';

// In practice we're probably never going to exhaust a texture this large. For debugging purposes,
// however, it can be useful to set this to a really tiny value, to verify that LRU eviction works.
Expand All @@ -29,14 +30,39 @@ const TRANSPARENT_COLOR = {
// cache.
const FRAME_CACHE_DRAW_LIMIT = 100;

/**
* The number of milliseconds to wait before generating the ImageBitmap, this is to debounce/batch
* the operation as window.createImageBitmap is asynchronous.
*/
const GLYPH_BITMAP_COMMIT_DELAY = 100;

/**
* The initial size of the queue used to track glyphs waiting on bitmap generation.
*/
const GLYPHS_WAITING_ON_BITMAP_QUEUE_INITIAL_SIZE = 100;

/**
* When the limit of the bitmap queue is reached, the queue increases by this factor.
*/
const GLYPHS_WAITING_ON_BITMAP_QUEUE_INCREMENT_FACTOR = 2;

interface IGlyphCacheValue {
index: number;
isEmpty: boolean;
inBitmap: boolean;
}

function getGlyphCacheKey(glyph: IGlyphIdentifier): string {
const styleFlags = (glyph.bold ? 0 : 4) + (glyph.dim ? 0 : 2) + (glyph.italic ? 0 : 1);
return `${glyph.bg}_${glyph.fg}_${styleFlags}${glyph.chars}`;
function getGlyphCacheKey(code: number, fg: number, bg: number, bold: boolean, dim: boolean, italic: boolean): number {
// Note that this only returns a valid key when code < 256
// Layout:
// 0b00000000000000000000000000000001: italic (1)
// 0b00000000000000000000000000000010: dim (1)
// 0b00000000000000000000000000000100: bold (1)
// 0b00000000000000000000111111111000: fg (9)
// 0b00000000000111111111000000000000: bg (9)
// 0b00011111111000000000000000000000: code (8)
// 0b11100000000000000000000000000000: unused (3)
return code << 21 | bg << 12 | fg << 3 | (bold ? 0 : 4) + (dim ? 0 : 2) + (italic ? 0 : 1);
Tyriar marked this conversation as resolved.
Show resolved Hide resolved
}

export default class DynamicCharAtlas extends BaseCharAtlas {
Expand All @@ -57,6 +83,18 @@ export default class DynamicCharAtlas extends BaseCharAtlas {

private _drawToCacheCount: number = 0;

// An array of glyph keys that are waiting on the bitmap to be generated.
private _glyphsWaitingOnBitmapQueue: Uint32Array = new Uint32Array(GLYPHS_WAITING_ON_BITMAP_QUEUE_INITIAL_SIZE);

// The number of glyphs keys waiting on the bitmap to be generated.
private _glyphsWaitingOnBitmapCount: number = 0;
Tyriar marked this conversation as resolved.
Show resolved Hide resolved

// The timeout that is used to batch bitmap generation so it's not requested for every new glyph.
private _bitmapCommitTimeout: number | null = null;

// The bitmap to draw from, this is much faster on other browsers than others.
private _bitmap: ImageBitmap | null = null;

constructor(document: Document, private _config: ICharAtlasConfig) {
super();
this._cacheCanvas = document.createElement('canvas');
Expand Down Expand Up @@ -88,47 +126,59 @@ export default class DynamicCharAtlas extends BaseCharAtlas {

public draw(
ctx: CanvasRenderingContext2D,
glyph: IGlyphIdentifier,
chars: string,
code: number,
bg: number,
fg: number,
bold: boolean,
dim: boolean,
italic: boolean,
x: number,
y: number
): boolean {
const glyphKey = getGlyphCacheKey(glyph);
// Space is always an empty cell, special case this as it's so common
if (code === 32) {
return true;
}

const glyphKey = getGlyphCacheKey(code, fg, bg, bold, dim, italic);
const cacheValue = this._cacheMap.get(glyphKey);
if (cacheValue !== null && cacheValue !== undefined) {
this._drawFromCache(ctx, cacheValue, x, y);
return true;
} else if (this._canCache(glyph) && this._drawToCacheCount < FRAME_CACHE_DRAW_LIMIT) {
} else if (this._canCache(code) && this._drawToCacheCount < FRAME_CACHE_DRAW_LIMIT) {
let index;
if (this._cacheMap.size < this._cacheMap.capacity) {
index = this._cacheMap.size;
} else {
// we're out of space, so our call to set will delete this item
index = this._cacheMap.peek().index;
}
const cacheValue = this._drawToCache(glyph, index);
const cacheValue = this._drawToCache(chars, code, bg, fg, bold, dim, italic, index);
this._cacheMap.set(glyphKey, cacheValue);
this._drawFromCache(ctx, cacheValue, x, y);
return true;
}
return false;
}

private _canCache(glyph: IGlyphIdentifier): boolean {
private _canCache(code: number): boolean {
// Only cache ascii and extended characters for now, to be safe. In the future, we could do
// something more complicated to determine the expected width of a character.
//
// If we switch the renderer over to webgl at some point, we may be able to use blending modes
// to draw overlapping glyphs from the atlas:
// https://github.com/servo/webrender/issues/464#issuecomment-255632875
// https://webglfundamentals.org/webgl/lessons/webgl-text-texture.html
return glyph.code < 256;
return code < 256;
}

private _toCoordinateX(index: number): number {
return (index % this._width) * this._config.scaledCharWidth;
}

private _toCoordinates(index: number): [number, number] {
return [
(index % this._width) * this._config.scaledCharWidth,
Math.floor(index / this._width) * this._config.scaledCharHeight
];
private _toCoordinateY(index: number): number {
return Math.floor(index / this._width) * this._config.scaledCharHeight;
}

private _drawFromCache(
Expand All @@ -141,9 +191,10 @@ export default class DynamicCharAtlas extends BaseCharAtlas {
if (cacheValue.isEmpty) {
return;
}
const [cacheX, cacheY] = this._toCoordinates(cacheValue.index);
const cacheX = this._toCoordinateX(cacheValue.index);
const cacheY = this._toCoordinateY(cacheValue.index);
ctx.drawImage(
this._cacheCanvas,
cacheValue.inBitmap ? this._bitmap : this._cacheCanvas,
cacheX,
cacheY,
this._config.scaledCharWidth,
Expand All @@ -162,39 +213,48 @@ export default class DynamicCharAtlas extends BaseCharAtlas {
return DEFAULT_ANSI_COLORS[idx];
}

private _getBackgroundColor(glyph: IGlyphIdentifier): IColor {
private _getBackgroundColor(bg: number): IColor {
if (this._config.allowTransparency) {
// The background color might have some transparency, so we need to render it as fully
// transparent in the atlas. Otherwise we'd end up drawing the transparent background twice
// around the anti-aliased edges of the glyph, and it would look too dark.
return TRANSPARENT_COLOR;
} else if (glyph.bg === INVERTED_DEFAULT_COLOR) {
} else if (bg === INVERTED_DEFAULT_COLOR) {
return this._config.colors.foreground;
} else if (glyph.bg < 256) {
return this._getColorFromAnsiIndex(glyph.bg);
} else if (bg < 256) {
return this._getColorFromAnsiIndex(bg);
}
return this._config.colors.background;
}

private _getForegroundColor(glyph: IGlyphIdentifier): IColor {
if (glyph.fg === INVERTED_DEFAULT_COLOR) {
private _getForegroundColor(fg: number): IColor {
if (fg === INVERTED_DEFAULT_COLOR) {
return this._config.colors.background;
} else if (glyph.fg < 256) {
} else if (fg < 256) {
// 256 color support
return this._getColorFromAnsiIndex(glyph.fg);
return this._getColorFromAnsiIndex(fg);
}
return this._config.colors.foreground;
}

// TODO: We do this (or something similar) in multiple places. We should split this off
// into a shared function.
private _drawToCache(glyph: IGlyphIdentifier, index: number): IGlyphCacheValue {
private _drawToCache(
chars: string,
code: number,
bg: number,
fg: number,
bold: boolean,
dim: boolean,
italic: boolean,
index: number
): IGlyphCacheValue {
this._drawToCacheCount++;

this._tmpCtx.save();

// draw the background
const backgroundColor = this._getBackgroundColor(glyph);
const backgroundColor = this._getBackgroundColor(bg);
// Use a 'copy' composite operation to clear any existing glyph out of _tmpCtxWithAlpha, regardless of
// transparency in backgroundColor
this._tmpCtx.globalCompositeOperation = 'copy';
Expand All @@ -203,20 +263,20 @@ export default class DynamicCharAtlas extends BaseCharAtlas {
this._tmpCtx.globalCompositeOperation = 'source-over';

// draw the foreground/glyph
const fontWeight = glyph.bold ? this._config.fontWeightBold : this._config.fontWeight;
const fontStyle = glyph.italic ? 'italic' : '';
const fontWeight = bold ? this._config.fontWeightBold : this._config.fontWeight;
const fontStyle = italic ? 'italic' : '';
this._tmpCtx.font =
`${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`;
this._tmpCtx.textBaseline = 'top';

this._tmpCtx.fillStyle = this._getForegroundColor(glyph).css;
this._tmpCtx.fillStyle = this._getForegroundColor(fg).css;

// Apply alpha to dim the character
if (glyph.dim) {
if (dim) {
this._tmpCtx.globalAlpha = DIM_OPACITY;
}
// Draw the character
this._tmpCtx.fillText(glyph.chars, 0, 0);
this._tmpCtx.fillText(chars, 0, 0);
this._tmpCtx.restore();

// clear the background from the character to avoid issues with drawing over the previous
Expand All @@ -230,13 +290,76 @@ export default class DynamicCharAtlas extends BaseCharAtlas {
}

// copy the data from imageData to _cacheCanvas
const [x, y] = this._toCoordinates(index);
const x = this._toCoordinateX(index);
const y = this._toCoordinateY(index);
// putImageData doesn't do any blending, so it will overwrite any existing cache entry for us
this._cacheCtx.putImageData(imageData, x, y);

// Add the glyph and queue it to the bitmap (if the browser supports it)
this._addGlyphToBitmap(code, fg, bg, bold, dim, italic);

return {
index,
isEmpty
isEmpty,
inBitmap: false
};
}

private _addGlyphToBitmap(
code: number,
bg: number,
fg: number,
bold: boolean,
dim: boolean,
italic: boolean
): void {
// Support is patchy for createImageBitmap at the moment, pass a canvas back
// if support is lacking as drawImage works there too. Firefox is also
// included here as ImageBitmap appears both buggy and has horrible
// performance (tested on v55).
if (!('createImageBitmap' in context) || isFirefox || isSafari) {
return;
}

// Add the glyph to the queue, increasing the size of it if needed
if (this._glyphsWaitingOnBitmapCount >= this._glyphsWaitingOnBitmapQueue.length) {
this._expandGlyphWaitingOnBitmapQueue();
}
this._glyphsWaitingOnBitmapQueue[this._glyphsWaitingOnBitmapCount++] = getGlyphCacheKey(code, fg, bg, bold, dim, italic);

// Check if bitmap generation timeout already exists
if (this._bitmapCommitTimeout !== null) {
return;
}

this._bitmapCommitTimeout = window.setTimeout(() => this._generateBitmap(), GLYPH_BITMAP_COMMIT_DELAY);
Tyriar marked this conversation as resolved.
Show resolved Hide resolved
}

private _expandGlyphWaitingOnBitmapQueue(): void {
const newQueue = new Uint32Array(this._glyphsWaitingOnBitmapQueue.length * GLYPHS_WAITING_ON_BITMAP_QUEUE_INCREMENT_FACTOR);
newQueue.set(this._glyphsWaitingOnBitmapQueue, 0);
this._glyphsWaitingOnBitmapQueue = newQueue;
}

private _generateBitmap(): void {
const countAtGeneration = this._glyphsWaitingOnBitmapCount;
window.createImageBitmap(this._cacheCanvas).then(bitmap => {
// Set bitmap
this._bitmap = bitmap;

// Mark all new glyphs as in bitmap
for (let i = 0; i < countAtGeneration; i++) {
const key = this._glyphsWaitingOnBitmapQueue[i];
this._cacheMap.get(key).inBitmap = true;
Tyriar marked this conversation as resolved.
Show resolved Hide resolved
this._glyphsWaitingOnBitmapQueue[i] = 0;
}

// Fix up any glyphs that were added since image bitmap was created
if (countAtGeneration > this._glyphsWaitingOnBitmapCount) {
this._glyphsWaitingOnBitmapQueue.set(this._glyphsWaitingOnBitmapQueue.subarray(countAtGeneration, this._glyphsWaitingOnBitmapCount - countAtGeneration), 0);
}
Tyriar marked this conversation as resolved.
Show resolved Hide resolved
this._glyphsWaitingOnBitmapCount -= countAtGeneration;
});
this._bitmapCommitTimeout = null;
}
}