This repository has been archived by the owner on Apr 1, 2020. It is now read-only.
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add experimental WebGL Renderer (#2120)
* First version of the WebGL renderer with grayscale text antialias * Add CachedColorNormalizer and first attempt at background rendering * Get background renderer to work :) * Refactor WebGL code * Incorporate line padding into glyph positioning * Add configuration property for switching between canvas and WebGL renderer * Improve warning and tweak atlas padding
- Loading branch information
Showing
17 changed files
with
1,074 additions
and
45 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import * as normalizeColor from "color-normalize" | ||
import { IColorNormalizer } from "./IColorNormalizer" | ||
|
||
export class CachedColorNormalizer implements IColorNormalizer { | ||
private _cache = new Map<string, Float32Array>() | ||
|
||
public normalizeColor(cssColor: string): Float32Array { | ||
const cachedRgba = this._cache.get(cssColor) | ||
|
||
if (cachedRgba) { | ||
return cachedRgba | ||
} else { | ||
const rgba = normalizeColor.call(null, cssColor, "float32") | ||
this._cache.set(cssColor, rgba) | ||
return rgba | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export interface IColorNormalizer { | ||
normalizeColor(cssColor: string): Float32Array | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
const defaultTextureSizeInPixels = 512 | ||
const glyphPaddingInPixels = 0 | ||
|
||
export interface IWebGLAtlasOptions { | ||
fontFamily: string | ||
fontSize: string | ||
lineHeightInPixels: number | ||
linePaddingInPixels: number | ||
devicePixelRatio: number | ||
offsetGlyphVariantCount: number | ||
} | ||
|
||
export interface WebGLGlyph { | ||
width: number | ||
height: number | ||
textureWidth: number | ||
textureHeight: number | ||
textureU: number | ||
textureV: number | ||
variantOffset: number | ||
subpixelWidth: number | ||
} | ||
|
||
export class WebGLAtlas { | ||
private _glyphContext: CanvasRenderingContext2D | ||
private _glyphs = new Map<string, Map<number, WebGLGlyph>>() | ||
private _nextX = 0 | ||
private _nextY = 0 | ||
private _textureChangedSinceLastUpload = false | ||
private _texture: WebGLTexture | ||
private _textureSize: number | ||
private _uvScale: number | ||
|
||
constructor(private _gl: WebGL2RenderingContext, private _options: IWebGLAtlasOptions) { | ||
this._textureSize = defaultTextureSizeInPixels * _options.devicePixelRatio | ||
this._uvScale = 1 / this._textureSize | ||
|
||
const glyphCanvas = document.createElement("canvas") | ||
glyphCanvas.width = this._textureSize | ||
glyphCanvas.height = this._textureSize | ||
this._glyphContext = glyphCanvas.getContext("2d", { alpha: false }) | ||
this._glyphContext.font = `${this._options.fontSize} ${this._options.fontFamily}` | ||
this._glyphContext.fillStyle = "white" | ||
this._glyphContext.textBaseline = "top" | ||
this._glyphContext.scale(_options.devicePixelRatio, _options.devicePixelRatio) | ||
this._glyphContext.imageSmoothingEnabled = false | ||
|
||
this._texture = _gl.createTexture() | ||
_gl.bindTexture(_gl.TEXTURE_2D, this._texture) | ||
_gl.texParameteri(_gl.TEXTURE_2D, _gl.TEXTURE_MIN_FILTER, _gl.LINEAR) | ||
_gl.texParameteri(_gl.TEXTURE_2D, _gl.TEXTURE_WRAP_S, _gl.CLAMP_TO_EDGE) | ||
_gl.texParameteri(_gl.TEXTURE_2D, _gl.TEXTURE_WRAP_T, _gl.CLAMP_TO_EDGE) | ||
this._textureChangedSinceLastUpload = true | ||
this.uploadTexture() | ||
} | ||
|
||
public getGlyph(text: string, variantIndex: number) { | ||
let glyphVariants = this._glyphs.get(text) | ||
if (!glyphVariants) { | ||
glyphVariants = new Map() | ||
this._glyphs.set(text, glyphVariants) | ||
} | ||
|
||
let glyph = glyphVariants.get(variantIndex) | ||
if (!glyph) { | ||
glyph = this._rasterizeGlyph(text, variantIndex) | ||
glyphVariants.set(variantIndex, glyph) | ||
} | ||
|
||
return glyph | ||
} | ||
|
||
public uploadTexture() { | ||
if (this._textureChangedSinceLastUpload) { | ||
this._gl.texImage2D( | ||
this._gl.TEXTURE_2D, | ||
0, | ||
this._gl.RGBA, | ||
this._textureSize, | ||
this._textureSize, | ||
0, | ||
this._gl.RGBA, | ||
this._gl.UNSIGNED_BYTE, | ||
this._glyphContext.canvas, | ||
) | ||
this._textureChangedSinceLastUpload = false | ||
} | ||
} | ||
|
||
private _rasterizeGlyph(text: string, variantIndex: number) { | ||
this._textureChangedSinceLastUpload = true | ||
|
||
const { | ||
devicePixelRatio, | ||
lineHeightInPixels, | ||
linePaddingInPixels, | ||
offsetGlyphVariantCount, | ||
} = this._options | ||
const variantOffset = variantIndex / offsetGlyphVariantCount | ||
|
||
const height = lineHeightInPixels | ||
const { width: subpixelWidth } = this._glyphContext.measureText(text) | ||
const width = Math.ceil(variantOffset) + Math.ceil(subpixelWidth) | ||
|
||
if ((this._nextX + width) * devicePixelRatio > this._textureSize) { | ||
this._nextX = 0 | ||
this._nextY = Math.ceil(this._nextY + height + glyphPaddingInPixels) | ||
} | ||
|
||
if ((this._nextY + height) * devicePixelRatio > this._textureSize) { | ||
// TODO implement a fallback instead of just throwing | ||
throw new Error("Texture is too small") | ||
} | ||
|
||
const x = this._nextX | ||
const y = this._nextY | ||
this._glyphContext.fillText(text, x + variantOffset, y + linePaddingInPixels / 2) | ||
this._nextX += width | ||
|
||
return { | ||
textureU: x * devicePixelRatio * this._uvScale, | ||
textureV: y * devicePixelRatio * this._uvScale, | ||
textureWidth: width * devicePixelRatio * this._uvScale, | ||
textureHeight: height * devicePixelRatio * this._uvScale, | ||
width: width * devicePixelRatio, | ||
height: height * devicePixelRatio, | ||
subpixelWidth: subpixelWidth * devicePixelRatio, | ||
variantOffset, | ||
} as WebGLGlyph | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
import { INeovimRenderer } from ".." | ||
import { IScreen } from "../../neovim" | ||
import { CachedColorNormalizer } from "./CachedColorNormalizer" | ||
import { IColorNormalizer } from "./IColorNormalizer" | ||
import { IWebGLAtlasOptions } from "./WebGLAtlas" | ||
import { WebGLSolidRenderer } from "./WebGLSolidRenderer" | ||
import { WebGlTextRenderer } from "./WebGLTextRenderer" | ||
|
||
export class WebGLRenderer implements INeovimRenderer { | ||
private _editorElement: HTMLElement | ||
private _colorNormalizer: IColorNormalizer | ||
private _previousAtlasOptions: IWebGLAtlasOptions | ||
|
||
private _gl: WebGL2RenderingContext | ||
private _solidRenderer: WebGLSolidRenderer | ||
private _textRenderer: WebGlTextRenderer | ||
|
||
public start(editorElement: HTMLElement): void { | ||
this._editorElement = editorElement | ||
this._colorNormalizer = new CachedColorNormalizer() | ||
|
||
const canvasElement = document.createElement("canvas") | ||
canvasElement.style.width = `100%` | ||
canvasElement.style.height = `100%` | ||
|
||
this._editorElement.innerHTML = "" | ||
this._editorElement.appendChild(canvasElement) | ||
|
||
this._gl = canvasElement.getContext("webgl2") as WebGL2RenderingContext | ||
} | ||
|
||
public redrawAll(screenInfo: IScreen): void { | ||
this._updateCanvasDimensions() | ||
this._createNewRendererIfRequired(screenInfo) | ||
this._clear(screenInfo.backgroundColor) | ||
this._draw(screenInfo) | ||
} | ||
|
||
public draw(screenInfo: IScreen): void { | ||
this.redrawAll(screenInfo) | ||
} | ||
|
||
public onAction(action: any): void { | ||
// do nothing | ||
} | ||
|
||
private _updateCanvasDimensions() { | ||
const devicePixelRatio = window.devicePixelRatio | ||
this._gl.canvas.width = this._editorElement.offsetWidth * devicePixelRatio | ||
this._gl.canvas.height = this._editorElement.offsetHeight * devicePixelRatio | ||
} | ||
|
||
private _createNewRendererIfRequired({ | ||
width: columnCount, | ||
height: rowCount, | ||
fontWidthInPixels, | ||
fontHeightInPixels, | ||
linePaddingInPixels, | ||
fontFamily, | ||
fontSize, | ||
}: IScreen) { | ||
const devicePixelRatio = window.devicePixelRatio | ||
const offsetGlyphVariantCount = Math.max(4 / devicePixelRatio, 1) | ||
const atlasOptions = { | ||
fontFamily, | ||
fontSize, | ||
lineHeightInPixels: fontHeightInPixels, | ||
linePaddingInPixels, | ||
devicePixelRatio, | ||
offsetGlyphVariantCount, | ||
} as IWebGLAtlasOptions | ||
|
||
if ( | ||
!this._solidRenderer || | ||
!this._textRenderer || | ||
!this._previousAtlasOptions || | ||
!isShallowEqual(this._previousAtlasOptions, atlasOptions) | ||
) { | ||
this._solidRenderer = new WebGLSolidRenderer( | ||
this._gl, | ||
this._colorNormalizer, | ||
atlasOptions.devicePixelRatio, | ||
) | ||
this._textRenderer = new WebGlTextRenderer( | ||
this._gl, | ||
this._colorNormalizer, | ||
atlasOptions, | ||
) | ||
this._previousAtlasOptions = atlasOptions | ||
} | ||
} | ||
|
||
private _clear(backgroundColor: string) { | ||
const backgroundColorToUse = backgroundColor || "black" | ||
const normalizedBackgroundColor = this._colorNormalizer.normalizeColor(backgroundColorToUse) | ||
this._gl.clearColor( | ||
normalizedBackgroundColor[0], | ||
normalizedBackgroundColor[1], | ||
normalizedBackgroundColor[2], | ||
normalizedBackgroundColor[3], | ||
) | ||
this._gl.clear(this._gl.COLOR_BUFFER_BIT) | ||
} | ||
|
||
private _draw({ | ||
width: columnCount, | ||
height: rowCount, | ||
fontWidthInPixels, | ||
fontHeightInPixels, | ||
getCell, | ||
foregroundColor, | ||
backgroundColor, | ||
}: IScreen) { | ||
const canvasWidth = this._gl.canvas.width | ||
const canvasHeight = this._gl.canvas.height | ||
const viewportScaleX = 2 / canvasWidth | ||
const viewportScaleY = -2 / canvasHeight | ||
this._gl.viewport(0, 0, canvasWidth, canvasHeight) | ||
|
||
this._solidRenderer.draw( | ||
columnCount, | ||
rowCount, | ||
getCell, | ||
fontWidthInPixels, | ||
fontHeightInPixels, | ||
backgroundColor, | ||
viewportScaleX, | ||
viewportScaleY, | ||
) | ||
this._textRenderer.draw( | ||
columnCount, | ||
rowCount, | ||
getCell, | ||
fontWidthInPixels, | ||
fontHeightInPixels, | ||
foregroundColor, | ||
viewportScaleX, | ||
viewportScaleY, | ||
) | ||
} | ||
} | ||
|
||
function isShallowEqual<T>(objectA: T, objectB: T) { | ||
for (const key in objectA) { | ||
if (!(key in objectB) || objectA[key] !== objectB[key]) { | ||
return false | ||
} | ||
} | ||
|
||
for (const key in objectB) { | ||
if (!(key in objectA) || objectA[key] !== objectB[key]) { | ||
return false | ||
} | ||
} | ||
|
||
return true | ||
} |
Oops, something went wrong.