From 5ce39d3c816404834581b811b789098211b262f8 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 30 Aug 2017 19:12:26 -0700 Subject: [PATCH] GPU ascii rendering --- src/InputHandler.ts | 15 ++-- src/Interfaces.ts | 2 - src/Renderer.ts | 70 --------------- src/RendererCanvas.ts | 199 ++++++++++++++++++++++++++++++++--------- src/Terminal.ts | 21 +---- src/renderer/Canvas.ts | 14 +++ 6 files changed, 179 insertions(+), 142 deletions(-) create mode 100644 src/renderer/Canvas.ts diff --git a/src/InputHandler.ts b/src/InputHandler.ts index abfdfa0e..e9346c1a 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -36,13 +36,14 @@ export class InputHandler implements IInputHandler { // dont overflow left if (this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1]) { if (!this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1][CHAR_DATA_WIDTH_INDEX]) { - // found empty cell after fullwidth, need to go 2 cells back - if (this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 2]) + if (this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 2]) { this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 2][CHAR_DATA_CHAR_INDEX] += char; - + this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 2][3] = char.charCodeAt(0); + } } else { this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1][CHAR_DATA_CHAR_INDEX] += char; + this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1][3] = char.charCodeAt(0); } this._terminal.updateRange(this._terminal.buffer.y); } @@ -81,21 +82,21 @@ export class InputHandler implements IInputHandler { if (removed[CHAR_DATA_WIDTH_INDEX] === 0 && this._terminal.buffer.lines.get(row)[this._terminal.cols - 2] && this._terminal.buffer.lines.get(row)[this._terminal.cols - 2][CHAR_DATA_WIDTH_INDEX] === 2) { - this._terminal.buffer.lines.get(row)[this._terminal.cols - 2] = [this._terminal.curAttr, ' ', 1]; + this._terminal.buffer.lines.get(row)[this._terminal.cols - 2] = [this._terminal.curAttr, ' ', 1, ' '.charCodeAt(0)]; } // insert empty cell at cursor - this._terminal.buffer.lines.get(row).splice(this._terminal.buffer.x, 0, [this._terminal.curAttr, ' ', 1]); + this._terminal.buffer.lines.get(row).splice(this._terminal.buffer.x, 0, [this._terminal.curAttr, ' ', 1, ' '.charCodeAt(0)]); } } - this._terminal.buffer.lines.get(row)[this._terminal.buffer.x] = [this._terminal.curAttr, char, ch_width]; + this._terminal.buffer.lines.get(row)[this._terminal.buffer.x] = [this._terminal.curAttr, char, ch_width, char.charCodeAt(0)]; this._terminal.buffer.x++; this._terminal.updateRange(this._terminal.buffer.y); // fullwidth char - set next cell width to zero and advance cursor if (ch_width === 2) { - this._terminal.buffer.lines.get(row)[this._terminal.buffer.x] = [this._terminal.curAttr, '', 0]; + this._terminal.buffer.lines.get(row)[this._terminal.buffer.x] = [this._terminal.curAttr, '', 0, undefined]; this._terminal.buffer.x++; } } diff --git a/src/Interfaces.ts b/src/Interfaces.ts index b643de45..fb66f4d7 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -21,8 +21,6 @@ export interface ITerminal extends IEventEmitter { element: HTMLElement; rowContainer: HTMLElement; selectionContainer: HTMLElement; - canvasElement: HTMLCanvasElement; - canvasContext: CanvasRenderingContext2D; selectionManager: ISelectionManager; charMeasure: ICharMeasure; textarea: HTMLTextAreaElement; diff --git a/src/Renderer.ts b/src/Renderer.ts index 8c155096..f4a275bb 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -98,7 +98,6 @@ export class Renderer { this._refreshRowsQueue = []; this._refreshAnimationFrame = null; this._refresh(start, end); - this._canvasRender(start, end); } /** @@ -346,75 +345,6 @@ export class Renderer { '#eeeeec' ]; - private _canvasRender(start: number, end: number): void { - - const charWidth = Math.ceil(this._terminal.charMeasure.width) * window.devicePixelRatio; - const charHeight = Math.ceil(this._terminal.charMeasure.height) * window.devicePixelRatio; - const ctx = this._terminal.canvasContext; - - ctx.fillStyle = '#000000'; - // console.log('fill', start, end); - // console.log('fill', start * charHeight, (end - start + 1) * charHeight); - // ctx.fillRect(0, start * charHeight, charWidth * this._terminal.cols, (end - start + 1) * charHeight); - ctx.fillStyle = 'rgb(255, 255, 255)'; - ctx.textBaseline = 'top'; - - // Indicates whether to reset the font next cell - let resetFont = true; - - for (let y = start; y <= end; y++) { - if (resetFont) { - ctx.font = `${16 * window.devicePixelRatio}px courier`; - resetFont = false; - } - let row = y + this._terminal.buffer.ydisp; - let line = this._terminal.buffer.lines.get(row); - for (let x = 0; x < this._terminal.cols; x++) { - let data: any = line[x][0]; - const ch = line[x][CHAR_DATA_CHAR_INDEX]; - - // if (ch === ' ') { - // continue; - // } - - let bg = data & 0x1ff; - let fg = (data >> 9) & 0x1ff; - let flags = data >> 18; - - // if (bg < 16) { - // } - - if (flags & FLAGS.BOLD) { - ctx.font = `bold ${ctx.font}`; - resetFont = true; - // Convert the FG color to the bold variant - if (fg < 8) { - fg += 8; - } - } - - if (fg < 16) { - ctx.fillStyle = this._colors[fg]; - } - - // Simulate cache - let imageData; - let key = ch + fg; - if (key in this._imageDataCache) { - imageData = this._imageDataCache[key]; - } else { - ctx.fillText(ch, x * charWidth, y * charHeight); - imageData = ctx.getImageData(x * charWidth, y * charHeight, charWidth, charHeight); - this._imageDataCache[key] = imageData; - } - ctx.putImageData(imageData, x * charWidth, y * charHeight); - - // Always write text - // ctx.fillText(ch, x * charWidth, y * charHeight); - } - } - } - /** * Refreshes the selection in the DOM. * @param start The selection start. diff --git a/src/RendererCanvas.ts b/src/RendererCanvas.ts index 0665b880..9d4156c4 100644 --- a/src/RendererCanvas.ts +++ b/src/RendererCanvas.ts @@ -5,6 +5,7 @@ import { ITerminal } from './Interfaces'; import { DomElementObjectPool } from './utils/DomElementObjectPool'; import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from './Buffer'; +import { createBackgroundFillData } from './renderer/Canvas'; /** * The maximum number of refresh frames to skip when the write buffer is non- @@ -32,6 +33,38 @@ export class Renderer { private _refreshFramesSkipped = 0; private _refreshAnimationFrame = null; + private _canvasElement: HTMLCanvasElement; + private _canvasContext: CanvasRenderingContext2D; + private _offscreenCanvas: HTMLCanvasElement; + private _offscreenContext: CanvasRenderingContext2D; + + private _charImageDataAtlas: ImageData; + private _charImageDataAtlasBitmap;//: ImageBitmap; + + // TODO: This would be better as a large texture atlas rather than a cache of ImageData objects + private _imageDataCache = {}; + private _colors = [ + // dark: + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + // bright: + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec' + ]; + private _imageData: ImageData; + constructor(private _terminal: ITerminal) { // Figure out whether boldness affects // the character width of monospace fonts. @@ -39,11 +72,65 @@ export class Renderer { brokenBold = checkBoldBroken(this._terminal.element); } + + this._offscreenCanvas = document.createElement('canvas'); + this._offscreenContext = this._offscreenCanvas.getContext('2d'); + + this._canvasElement = document.createElement('canvas'); + this._canvasContext = this._canvasElement.getContext('2d'); + // Scale the context for HDPI screens + this._canvasContext.scale(window.devicePixelRatio, window.devicePixelRatio); + this._terminal.element.appendChild(this._canvasElement); + // TODO: Pull more DOM interactions into Renderer.constructor, element for // example should be owned by Renderer (and also exposed by Terminal due to // to established public API). } + public onResize(cols: number, rows: number): void { + // TODO: Could be triggered immediately after onCharSizeChanged + } + + public onCharSizeChanged(charWidth: number, charHeight: number): void { + const width = Math.ceil(charWidth) * this._terminal.cols; + const height = Math.ceil(charHeight) * this._terminal.rows; + this._canvasElement.width = width * window.devicePixelRatio; + this._canvasElement.height = height * window.devicePixelRatio; + this._canvasElement.style.width = `${width}px`; + this._canvasElement.style.height = `${height}px`; + + this._offscreenCanvas.width = 255 * charWidth * window.devicePixelRatio; + this._offscreenCanvas.height = (/*default*/1 + /*0-15*/16) * charHeight * window.devicePixelRatio; + this._refreshCharImageDataAtlas(); + } + + private _refreshCharImageDataAtlas(): void { + const scaledCharWidth = Math.ceil(this._terminal.charMeasure.width) * window.devicePixelRatio; + const scaledCharHeight = Math.ceil(this._terminal.charMeasure.height) * window.devicePixelRatio; + + this._offscreenContext.save(); + this._offscreenContext.fillStyle = '#ffffff'; + this._offscreenContext.font = `${16 * window.devicePixelRatio}px courier`; + this._offscreenContext.textBaseline = 'top'; + // Default color + for (let i = 0; i < 256; i++) { + this._offscreenContext.fillText(String.fromCharCode(i), i * scaledCharWidth, 0); + } + // Colors 0-15 + for (let colorIndex = 0; colorIndex < 16; colorIndex++) { + for (let i = 0; i < 256; i++) { + this._offscreenContext.fillStyle = this._colors[colorIndex]; + this._offscreenContext.fillText(String.fromCharCode(i), i * scaledCharWidth, (colorIndex + 1) * scaledCharHeight); + } + } + this._offscreenContext.restore(); + + this._charImageDataAtlas = this._offscreenContext.getImageData(0, 0, this._offscreenCanvas.width, this._offscreenCanvas.height); + (window).createImageBitmap(this._charImageDataAtlas).then(bitmap => { + this._charImageDataAtlasBitmap = bitmap; + }); + } + /** * Queues a refresh between two rows (inclusive), to be done on next animation * frame. @@ -98,29 +185,6 @@ export class Renderer { this._terminal.emit('refresh', {start, end}); } - // TODO: This would be better as a large texture atlas rather than a cache of ImageData objects - private _imageDataCache = {}; - private _colors = [ - // dark: - '#2e3436', - '#cc0000', - '#4e9a06', - '#c4a000', - '#3465a4', - '#75507b', - '#06989a', - '#d3d7cf', - // bright: - '#555753', - '#ef2929', - '#8ae234', - '#fce94f', - '#729fcf', - '#ad7fa8', - '#34e2e2', - '#eeeeec' - ]; - /** * Refreshes (re-renders) terminal content within two rows (inclusive) * @@ -143,11 +207,22 @@ export class Renderer { * @param {number} end The row to end at (between fromRow and terminal's height terminal - 1) */ private _refresh(start: number, end: number): void { - const charWidth = Math.ceil(this._terminal.charMeasure.width) * window.devicePixelRatio; - const charHeight = Math.ceil(this._terminal.charMeasure.height) * window.devicePixelRatio; - const ctx = this._terminal.canvasContext; + const scaledCharWidth = Math.ceil(this._terminal.charMeasure.width) * window.devicePixelRatio; + const scaledCharHeight = Math.ceil(this._terminal.charMeasure.height) * window.devicePixelRatio; + const ctx = this._canvasContext; + + // TODO: Needs to react to terminal resize + // Initialize image data + if (!this._imageData) { + this._imageData = ctx.createImageData(scaledCharWidth * this._terminal.cols * window.devicePixelRatio, scaledCharHeight * this._terminal.rows * window.devicePixelRatio); + this._imageData.data.set(createBackgroundFillData(this._imageData.width, this._imageData.height, 255, 0, 0, 255)); + } + + // Don't bother rendering until the atlas bitmap is ready + if (!this._charImageDataAtlasBitmap) { + return; + } - ctx.fillStyle = '#000000'; // console.log('fill', start, end); // console.log('fill', start * charHeight, (end - start + 1) * charHeight); // ctx.fillRect(0, start * charHeight, charWidth * this._terminal.cols, (end - start + 1) * charHeight); @@ -155,6 +230,9 @@ export class Renderer { ctx.textBaseline = 'top'; ctx.font = `${16 * window.devicePixelRatio}px courier`; + // Clear out the old data + ctx.clearRect(0, start * scaledCharHeight, scaledCharWidth * this._terminal.cols, (end - start + 1) * scaledCharHeight); + for (let y = start; y <= end; y++) { let row = y + this._terminal.buffer.ydisp; let line = this._terminal.buffer.lines.get(row); @@ -162,7 +240,8 @@ export class Renderer { ctx.save(); let data: number = line[x][0]; - const ch = line[x][CHAR_DATA_CHAR_INDEX]; + // const ch = line[x][CHAR_DATA_CHAR_INDEX]; + const code: number = line[x][3]; // if (ch === ' ') { // continue; @@ -183,32 +262,64 @@ export class Renderer { } } + // if (fg < 16) { + // ctx.fillStyle = this._colors[fg]; + // } else if (fg < 256) { + // // TODO: Support colors 16-255 + // } + + let colorIndex = 0; if (fg < 16) { - ctx.fillStyle = this._colors[fg]; - } else if (fg < 256) { - // TODO: Support colors 16-255 + colorIndex = fg + 1; } // Simulate cache - let imageData; - let key = ch + data; - if (key in this._imageDataCache) { - imageData = this._imageDataCache[key]; - } else { - ctx.fillText(ch, x * charWidth, y * charHeight); - if (flags & FLAGS.UNDERLINE) { - ctx.fillRect(x * charWidth, (y + 1) * charHeight - window.devicePixelRatio, charWidth, window.devicePixelRatio); - } - imageData = ctx.getImageData(x * charWidth, y * charHeight, charWidth, charHeight); - this._imageDataCache[key] = imageData; - } - ctx.putImageData(imageData, x * charWidth, y * charHeight); + // let imageData; + // let key = ch + data; + // if (key in this._imageDataCache) { + // imageData = this._imageDataCache[key]; + // } else { + // ctx.fillText(ch, x * scaledCharWidth, y * scaledCharHeight); + // if (flags & FLAGS.UNDERLINE) { + // ctx.fillRect(x * scaledCharWidth, (y + 1) * scaledCharHeight - window.devicePixelRatio, scaledCharWidth, window.devicePixelRatio); + // } + // imageData = ctx.getImageData(x * scaledCharWidth, y * scaledCharHeight, scaledCharWidth, scaledCharHeight); + // this._imageDataCache[key] = imageData; + // } + // ctx.putImageData(imageData, x * scaledCharWidth, y * scaledCharHeight); + + // TODO: Try to get atlas working + // This seems too slow :( + //ctx.putImageData(this._charImageDataAtlas, x * scaledCharWidth - ch.charCodeAt(0) * scaledCharWidth, y * scaledCharHeight, ch.charCodeAt(0) * scaledCharWidth, 0, scaledCharWidth, scaledCharHeight); + + // TODO: Draw background + // Maybe background should be on a separate layer? + // ctx.save(); + // if (bg < 16) { + // ctx.fillStyle = this._colors[fg]; + // } else { + // ctx.fillStyle = '#000000'; + // } + // ctx.fillRect(x * scaledCharWidth, y * scaledCharHeight, scaledCharWidth, scaledCharHeight); + // ctx.restore(); + + // ctx.drawImage(this._offscreenCanvas, code * scaledCharWidth, 0, scaledCharWidth, scaledCharHeight, x * scaledCharWidth, y * scaledCharHeight, scaledCharWidth, scaledCharHeight); + + // ImageBitmap's draw about twice as fast as from a canvas + ctx.drawImage(this._charImageDataAtlasBitmap, code * scaledCharWidth, colorIndex * scaledCharHeight, scaledCharWidth, scaledCharHeight, x * scaledCharWidth, y * scaledCharHeight, scaledCharWidth, scaledCharHeight); // Always write text // ctx.fillText(ch, x * charWidth, y * charHeight); ctx.restore(); } + + + // TODO: Try to get atlas working + // ctx.putImageData(this._charImageDataAtlas, y * scaledCharWidth, y * scaledCharHeight, 65 * scaledCharWidth, 0, scaledCharWidth, scaledCharHeight); } + + // This draws the atlas (for debugging purposes) + //ctx.putImageData(this._charImageDataAtlas, 0, 0); } /** diff --git a/src/Terminal.ts b/src/Terminal.ts index 2ddba9a2..42909298 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -176,8 +176,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT private body: HTMLBodyElement; private viewportScrollArea: HTMLElement; private viewportElement: HTMLElement; - public canvasElement: HTMLCanvasElement; - public canvasContext: CanvasRenderingContext2D; public selectionContainer: HTMLElement; private helperContainer: HTMLElement; private compositionView: HTMLElement; @@ -706,14 +704,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.selectionContainer.classList.add('xterm-selection'); this.element.appendChild(this.selectionContainer); - - this.canvasElement = document.createElement('canvas'); - this.canvasContext = this.canvasElement.getContext('2d'); - // Scale the context for HDPI screens - this.canvasContext.scale(window.devicePixelRatio, window.devicePixelRatio); - this.element.appendChild(this.canvasElement); - - // Create the container that will hold the lines of the terminal and then // produce the lines the lines. this.rowContainer = document.createElement('div'); @@ -757,17 +747,10 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT }); this.charMeasure.measure(); - this.charMeasure.on('charsizechanged', () => { - const width = Math.ceil(this.charMeasure.width) * this.cols; - const height = Math.ceil(this.charMeasure.height) * this.rows; - this.canvasElement.width = width * window.devicePixelRatio; - this.canvasElement.height = height * window.devicePixelRatio; - this.canvasElement.style.width = `${width}px`; - this.canvasElement.style.height = `${height}px`; - }); - this.viewport = new Viewport(this, this.viewportElement, this.viewportScrollArea, this.charMeasure); this.renderer = new Renderer(this); + this.on('resize', () => this.renderer.onResize(this.cols, this.rows)); + this.charMeasure.on('charsizechanged', () => this.renderer.onCharSizeChanged(this.charMeasure.width, this.charMeasure.height)); this.selectionManager = new SelectionManager(this, this.buffer, this.rowContainer, this.charMeasure); this.selectionManager.on('refresh', data => { this.renderer.refreshSelection(data.start, data.end); diff --git a/src/renderer/Canvas.ts b/src/renderer/Canvas.ts new file mode 100644 index 00000000..97e7f9ea --- /dev/null +++ b/src/renderer/Canvas.ts @@ -0,0 +1,14 @@ +export function createBackgroundFillData(width: number, height: number, r: number, g: number, b: number, a: number): Uint8ClampedArray { + const data = new Uint8ClampedArray(width * height * 4); + let offset = 0; + for (let i = 0; i < height; i++) { + for (let j = 0; j < width; j++) { + data[offset] = r; + data[offset + 1] = g; + data[offset + 2] = b; + data[offset + 3] = a; + offset += 4; + } + } + return data; +}