From 24feaabd9ac771526b66301d43366d71c27dc210 Mon Sep 17 00:00:00 2001 From: Benjamin Woodruff Date: Sat, 10 Mar 2018 21:33:09 -0800 Subject: [PATCH] Add a basic dynamic character atlas implementation This adds a very minimal implementation of DynamicCharAtlas using a LRU cache. I've got some optimizations that I'll add on top of this, but this proves the concept. --- demo/index.html | 1 + src/renderer/atlas/CharAtlasCache.ts | 2 + src/renderer/atlas/CharAtlasUtils.ts | 5 +- src/renderer/atlas/DynamicCharAtlas.ts | 195 +++++++++++++++++++++++++ src/renderer/atlas/StaticCharAtlas.ts | 4 +- src/shared/atlas/CharAtlasGenerator.ts | 15 +- src/shared/atlas/Types.ts | 2 +- tsconfig.json | 9 +- typings/xterm.d.ts | 6 +- 9 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 src/renderer/atlas/DynamicCharAtlas.ts diff --git a/demo/index.html b/demo/index.html index e18614a4..e4db4118 100644 --- a/demo/index.html +++ b/demo/index.html @@ -58,6 +58,7 @@ experimentalCharAtlas diff --git a/src/renderer/atlas/CharAtlasCache.ts b/src/renderer/atlas/CharAtlasCache.ts index 14037acf..c44f718a 100644 --- a/src/renderer/atlas/CharAtlasCache.ts +++ b/src/renderer/atlas/CharAtlasCache.ts @@ -8,12 +8,14 @@ import { IColorSet } from '../Types'; import { ICharAtlasConfig } from '../../shared/atlas/Types'; import { generateConfig, configEquals } from './CharAtlasUtils'; import BaseCharAtlas from './BaseCharAtlas'; +import DynamicCharAtlas from './DynamicCharAtlas'; import NoneCharAtlas from './NoneCharAtlas'; import StaticCharAtlas from './StaticCharAtlas'; const charAtlasImplementations = { 'none': NoneCharAtlas, 'static': StaticCharAtlas, + 'dynamic': DynamicCharAtlas, }; interface ICharAtlasCacheEntry { diff --git a/src/renderer/atlas/CharAtlasUtils.ts b/src/renderer/atlas/CharAtlasUtils.ts index c0c54783..ded04085 100644 --- a/src/renderer/atlas/CharAtlasUtils.ts +++ b/src/renderer/atlas/CharAtlasUtils.ts @@ -8,13 +8,16 @@ import { IColorSet } from '../Types'; import { ICharAtlasConfig } from '../../shared/atlas/Types'; export function generateConfig(scaledCharWidth: number, scaledCharHeight: number, terminal: ITerminal, colors: IColorSet): ICharAtlasConfig { + // null out some fields that don't matter const clonedColors = { foreground: colors.foreground, background: colors.background, cursor: null, cursorAccent: null, selection: null, - ansi: colors.ansi.slice(0, 16) + // For the static char atlas, we only use the first 16 colors, but we need all 256 for the + // dynamic character atlas. + ansi: colors.ansi, }; return { type: terminal.options.experimentalCharAtlas, diff --git a/src/renderer/atlas/DynamicCharAtlas.ts b/src/renderer/atlas/DynamicCharAtlas.ts new file mode 100644 index 00000000..8992fa8a --- /dev/null +++ b/src/renderer/atlas/DynamicCharAtlas.ts @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { DIM_OPACITY, IGlyphIdentifier, INVERTED_DEFAULT_COLOR } from './Types'; +import { ICharAtlasConfig } from '../../shared/atlas/Types'; +import BaseCharAtlas from './BaseCharAtlas'; +import { clearColor } from '../../shared/atlas/CharAtlasGenerator'; + +// 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. +const TEXTURE_WIDTH = 1024; +const TEXTURE_HEIGHT = 1024; + +type GlyphCacheKey = string; + +/** + * Removes and returns the oldest element in a map. + */ +function mapShift(map: Map): [K, V] { + // Map guarantees insertion-order iteration. + const entry = map.entries().next().value; + if (entry === undefined) { + return undefined; + } + map.delete(entry[0]); + return entry; +} + +function getGlyphCacheKey(glyph: IGlyphIdentifier): GlyphCacheKey { + return `${glyph.bg}_${glyph.fg}_${glyph.bold ? 0 : 1}${glyph.dim ? 0 : 1}${glyph.char}`; +} + +export default class DynamicCharAtlas extends BaseCharAtlas { + // An ordered map that we're using to keep track of where each glyph is in the atlas texture. + // It's ordered so that we can determine when to remove the old entries. + private _cacheMap: Map = new Map(); + + // The texture that the atlas is drawn to + private _cacheCanvas: HTMLCanvasElement; + private _cacheCtx: CanvasRenderingContext2D; + + // A temporary canvas that glyphs are drawn to before being transfered over to the atlas. + private _tmpCanvas: HTMLCanvasElement; + private _tmpCtx: CanvasRenderingContext2D; + + // The number of characters stored in the atlas by width/height + private _capacity: number; + private _width: number; + private _height: number; + + constructor(document: Document, private _config: ICharAtlasConfig) { + super(); + this._cacheCanvas = document.createElement('canvas'); + this._cacheCanvas.width = TEXTURE_WIDTH; + this._cacheCanvas.height = TEXTURE_HEIGHT; + // The canvas needs alpha because we use clearColor to convert the background color to alpha. + this._cacheCtx = this._cacheCanvas.getContext('2d', {alpha: true}); + + this._tmpCanvas = document.createElement('canvas'); + this._tmpCanvas.width = this._config.scaledCharWidth; + this._tmpCanvas.height = this._config.scaledCharHeight; + this._tmpCtx = this._tmpCanvas.getContext('2d', {alpha: true}); + + this._width = Math.floor(TEXTURE_WIDTH / this._config.scaledCharWidth); + this._height = Math.floor(TEXTURE_HEIGHT / this._config.scaledCharHeight); + this._capacity = this._width * this._height; + + // This is useful for debugging + // document.body.appendChild(this._cacheCanvas); + } + + public draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number, + ): boolean { + const glyphKey = getGlyphCacheKey(glyph); + const index = this._cacheMap.get(glyphKey); + if (index != null) { + // move to end of insertion order, so this can behave like an LRU cache + this._cacheMap.delete(glyphKey); + this._cacheMap.set(glyphKey, index); + this._drawFromCache(ctx, index, x, y); + return true; + } else if (this._canCache(glyph)) { + let index; + if (this._cacheMap.size < this._capacity) { + index = this._cacheMap.size; + } else { + index = mapShift(this._cacheMap)[1]; + } + this._drawToCache(glyph, index); + this._cacheMap.set(glyphKey, index); + this._drawFromCache(ctx, index, x, y); + return true; + } else { + return false; + } + } + + private _canCache(glyph: IGlyphIdentifier): 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.char.charCodeAt(0) < 256; + } + + private _toCoordinates(index: number): [number, number] { + return [ + (index % this._width) * this._config.scaledCharWidth, + Math.floor(index / this._width) * this._config.scaledCharHeight + ]; + } + + private _drawFromCache( + ctx: CanvasRenderingContext2D, + index: number, + x: number, + y: number + ): void { + const [cacheX, cacheY] = this._toCoordinates(index); + ctx.drawImage( + this._cacheCanvas, + cacheX, + cacheY, + this._config.scaledCharWidth, + this._config.scaledCharHeight, + x, + y, + this._config.scaledCharWidth, + this._config.scaledCharHeight, + ); + } + + // 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): void { + this._tmpCtx.save(); + // no need to clear _tmpCtx, since we're going to draw a fully opaque background + + // draw the background + let backgroundColor = this._config.colors.background; + if (glyph.bg === INVERTED_DEFAULT_COLOR) { + backgroundColor = this._config.colors.foreground; + } else if (glyph.bg < 256) { + backgroundColor = this._config.colors.ansi[glyph.bg]; + } + this._tmpCtx.fillStyle = backgroundColor.css; + this._tmpCtx.fillRect(0, 0, this._config.scaledCharWidth, this._config.scaledCharHeight); + + // draw the foreground/glyph + this._tmpCtx.font = + `${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`; + if (glyph.bold) { + this._tmpCtx.font = `bold ${this._tmpCtx.font}`; + } + this._tmpCtx.textBaseline = 'top'; + + if (glyph.fg === INVERTED_DEFAULT_COLOR) { + this._tmpCtx.fillStyle = this._config.colors.background.css; + } else if (glyph.fg < 256) { + // 256 color support + this._tmpCtx.fillStyle = this._config.colors.ansi[glyph.fg].css; + } else { + this._tmpCtx.fillStyle = this._config.colors.foreground.css; + } + + // Apply alpha to dim the character + if (glyph.dim) { + this._tmpCtx.globalAlpha = DIM_OPACITY; + } + // Draw the character + this._tmpCtx.fillText(glyph.char, 0, 0); + this._tmpCtx.restore(); + + // clear the background from the character to avoid issues with drawing over the previous + // character if it extends past it's bounds + const imageData = this._tmpCtx.getImageData( + 0, 0, this._config.scaledCharWidth, this._config.scaledCharHeight, + ); + clearColor(imageData, backgroundColor); + + // copy the data from _tmpCanvas to _cacheCanvas + const [x, y] = this._toCoordinates(index); + // putImageData doesn't do any blending, so it will overwrite any existing cache entry for us + this._cacheCtx.putImageData(imageData, x, y); + } +} diff --git a/src/renderer/atlas/StaticCharAtlas.ts b/src/renderer/atlas/StaticCharAtlas.ts index de808716..746369fc 100644 --- a/src/renderer/atlas/StaticCharAtlas.ts +++ b/src/renderer/atlas/StaticCharAtlas.ts @@ -6,7 +6,7 @@ import { DIM_OPACITY, IGlyphIdentifier } from './Types'; import { ICharAtlasConfig } from '../../shared/atlas/Types'; import { CHAR_ATLAS_CELL_SPACING } from '../../shared/atlas/Types'; -import { generateCharAtlas } from '../../shared/atlas/CharAtlasGenerator'; +import { generateStaticCharAtlasTexture } from '../../shared/atlas/CharAtlasGenerator'; import BaseCharAtlas from './BaseCharAtlas'; export default class StaticCharAtlas extends BaseCharAtlas { @@ -24,7 +24,7 @@ export default class StaticCharAtlas extends BaseCharAtlas { } public async _doWarmUp(): Promise { - const result = generateCharAtlas(window, this._canvasFactory, this._config); + const result = generateStaticCharAtlasTexture(window, this._canvasFactory, this._config); if (result instanceof Promise) { this._texture = await result; } else { diff --git a/src/shared/atlas/CharAtlasGenerator.ts b/src/shared/atlas/CharAtlasGenerator.ts index fc83c7ce..874020c3 100644 --- a/src/shared/atlas/CharAtlasGenerator.ts +++ b/src/shared/atlas/CharAtlasGenerator.ts @@ -5,6 +5,7 @@ import { FontWeight } from 'xterm'; import { CHAR_ATLAS_CELL_SPACING, ICharAtlasConfig } from './Types'; +import { IColor } from '../Types'; import { isFirefox } from '../utils/Browser'; declare const Promise: any; @@ -20,9 +21,9 @@ export interface IOffscreenCanvas { * Generates a char atlas. * @param context The window or worker context. * @param canvasFactory A function to generate a canvas with a width or height. - * @param request The config for the new char atlas. + * @param config The config for the new char atlas. */ -export function generateCharAtlas(context: Window, canvasFactory: (width: number, height: number) => HTMLCanvasElement | IOffscreenCanvas, config: ICharAtlasConfig): HTMLCanvasElement | Promise { +export function generateStaticCharAtlasTexture(context: Window, canvasFactory: (width: number, height: number) => HTMLCanvasElement | IOffscreenCanvas, config: ICharAtlasConfig): HTMLCanvasElement | Promise { const cellWidth = config.scaledCharWidth + CHAR_ATLAS_CELL_SPACING; const cellHeight = config.scaledCharHeight + CHAR_ATLAS_CELL_SPACING; const canvas = canvasFactory( @@ -100,10 +101,7 @@ export function generateCharAtlas(context: Window, canvasFactory: (width: number const charAtlasImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); // Remove the background color from the image so characters may overlap - const r = config.colors.background.rgba >>> 24; - const g = config.colors.background.rgba >>> 16 & 0xFF; - const b = config.colors.background.rgba >>> 8 & 0xFF; - clearColor(charAtlasImageData, r, g, b); + clearColor(charAtlasImageData, config.colors.background); return context.createImageBitmap(charAtlasImageData); } @@ -111,7 +109,10 @@ export function generateCharAtlas(context: Window, canvasFactory: (width: number /** * Makes a partiicular rgb color in an ImageData completely transparent. */ -function clearColor(imageData: ImageData, r: number, g: number, b: number): void { +export function clearColor(imageData: ImageData, color: IColor): void { + const r = color.rgba >>> 24; + const g = color.rgba >>> 16 & 0xFF; + const b = color.rgba >>> 8 & 0xFF; for (let offset = 0; offset < imageData.data.length; offset += 4) { if (imageData.data[offset] === r && imageData.data[offset + 1] === g && diff --git a/src/shared/atlas/Types.ts b/src/shared/atlas/Types.ts index 2bec4194..25eaa716 100644 --- a/src/shared/atlas/Types.ts +++ b/src/shared/atlas/Types.ts @@ -9,7 +9,7 @@ import { IColorSet } from '../Types'; export const CHAR_ATLAS_CELL_SPACING = 1; export interface ICharAtlasConfig { - type: 'none' | 'static'; + type: 'none' | 'static' | 'dynamic'; devicePixelRatio: number; fontSize: number; fontFamily: string; diff --git a/tsconfig.json b/tsconfig.json index 6d329658..7525c3de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,14 @@ "compilerOptions": { "module": "commonjs", "target": "es5", - "lib": ["DOM", "ES5", "ScriptHost", "ES2015.Promise"], + "lib": [ + "DOM", + "ES5", + "ScriptHost", + "ES2015.Promise", + "ES2015.Collection", + "ES2015.Iterable" + ], "rootDir": "src", "outDir": "lib", "sourceMap": true, diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index a208b7ed..f7898d6e 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -69,11 +69,15 @@ declare module 'xterm' { * - 'none': Don't use an atlas. * - 'static': Generate an atlas when the terminal starts or is reconfigured. This atlas will * only contain ASCII characters in 16 colors. + * - 'dynamic': Generate an atlas using a LRU cache as characters are requested. Limited to + * ASCII characters (for now), but supports 256 colors. For characters covered by the static + * cache, it's slightly slower in comparison, since there's more overhead involved in + * managing the cache. * * Currently defaults to 'static'. This option may be removed in the future. If it is, passed * parameters will be ignored. */ - experimentalCharAtlas?: 'none' | 'static'; + experimentalCharAtlas?: 'none' | 'static' | 'dynamic'; /** * The font size used to render text.