Skip to content
This repository has been archived by the owner on Apr 1, 2020. It is now read-only.

Add experimental WebGL Renderer #2120

Merged
merged 9 commits into from Apr 25, 2018
9 changes: 7 additions & 2 deletions browser/src/Editor/NeovimEditor/NeovimEditor.tsx
Expand Up @@ -33,7 +33,7 @@ import {
NeovimScreen,
NeovimWindowManager,
} from "./../../neovim"
import { CanvasRenderer, INeovimRenderer } from "./../../Renderer"
import { INeovimRenderer } from "./../../Renderer"

import { PluginManager } from "./../../Plugins/PluginManager"

Expand Down Expand Up @@ -94,6 +94,8 @@ import WildMenu from "./../../UI/components/WildMenu"

import { WelcomeBufferLayer } from "./WelcomeBufferLayer"

import { CanvasRenderer } from "../../Renderer/CanvasRenderer"
import { WebGLRenderer } from "../../Renderer/WebGL/WebGLRenderer"
import { getInstance as getNotificationsInstance } from "./../../Services/Notifications"

export class NeovimEditor extends Editor implements IEditor {
Expand Down Expand Up @@ -292,7 +294,10 @@ export class NeovimEditor extends Editor implements IEditor {
initVimNotification.show()
}

this._renderer = new CanvasRenderer()
this._renderer =
this._configuration.getValue("editor.renderer") === "webgl"
? new WebGLRenderer()
: new CanvasRenderer()

this._rename = new Rename(
this,
Expand Down
18 changes: 18 additions & 0 deletions browser/src/Renderer/WebGL/CachedColorNormalizer.ts
@@ -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
}
}
}
3 changes: 3 additions & 0 deletions browser/src/Renderer/WebGL/IColorNormalizer.ts
@@ -0,0 +1,3 @@
export interface IColorNormalizer {
normalizeColor(cssColor: string): Float32Array
}
131 changes: 131 additions & 0 deletions browser/src/Renderer/WebGL/WebGLAtlas.ts
@@ -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
}
}
157 changes: 157 additions & 0 deletions browser/src/Renderer/WebGL/WebGLRenderer.ts
@@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for refactoring this! Definitely helped make it easier to understand 👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy that it helped! I think I'll try to make it even more concise but that depends on how much time I will find for doing that. :)

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
}