Source: screens/WebGLScreen.js

let BITS_PER_BYTE = 8;
let RENDER_DEPTH = 100;

let TYPED_VIEW = Symbol();
let GL_FORMAT = Symbol();
let GL_TYPE = Symbol();

let gWebGlSupportedInputFormats = [ {

    depth: 16,

    rMask: 0b1111100000000000,
    gMask: 0b0000011111100000,
    bMask: 0b0000000000011111,
    aMask: 0b0000000000000000,

    [TYPED_VIEW]: Uint16Array,
    [GL_FORMAT]: `RGB`,
    [GL_TYPE]: `UNSIGNED_SHORT_5_6_5`

} ];

let gVertexShaderScript = `

    precision mediump float;

    uniform mat4 uMatrix;

    attribute vec3 aVertexPosition;
    attribute vec2 aVertexTextureUv;

    varying vec2 vTextureCoordinates;

    void main( void ) {

        vTextureCoordinates = vec2( aVertexTextureUv.s, 1.0 - aVertexTextureUv.t );

        gl_Position = uMatrix * vec4( aVertexPosition, 1.0 );

    }

`;

let gFragmentShaderScript = `

    precision mediump float;

    uniform sampler2D uScreenTexture;

    varying vec2 vTextureCoordinates;

    void main( void ) {

        gl_FragColor = texture2D( uScreenTexture, vTextureCoordinates );

    }

`;

function getMatchingInputFormat({ depth, rMask, gMask, bMask, aMask }) {

    for (let supported of gWebGlSupportedInputFormats)
        if (depth === supported.depth && rMask === supported.rMask && gMask === supported.gMask && bMask === supported.bMask && aMask === supported.aMask)
            return supported;

    return null;

}

function makeCanvasGlBuilder(canvas, options) {

    return () => {
        return canvas.getContext(`webgl`, options) || canvas.getContext(`experimental-webgl`, options);
    };

}

export class WebGLScreen {

    /**
     * A WebGLScreen is a screen device that uses a WebGL canvas as rendering target. Note that you can also use a headless-gl instance as rendering context, in which case you can simply pass null as canvas parameter.
     *
     * @constructor
     * @implements {Screen}
     *
     * @param {object} [options] - The screen options.
     * @param {CanvasElement} [options.canvas] - The target canvas.
     * @param {object} [options.glOptions] - The extra option used to setup the WebGL context.
     * @param {function} [options.glBuilder] - A factory that will build the WebGL context.
     */

    constructor({ canvas = document.createElement(`canvas`), glOptions = null, glBuilder = makeCanvasGlBuilder(canvas, glOptions) } = { }) {

        /**
         * The target canvas on which will be rendered the input data.
         *
         * @member
         * @readonly
         * @type {CanvasElement}
         */

        this.canvas = canvas;

        /**
         * The WebGL context used to render the input data.
         *
         * @member
         * @readonly
         * @type {WebGLRenderingContext}
         */

        this.gl = null;

        this.inputWidth = 0;
        this.inputHeight = 0;
        this.inputPitch = 0;

        this.inputFormat = null;
        this.inputData = null;

        this.outputWidth = 0;
        this.outputHeight = 0;

        this.pitchedInputData = null;

        this.shaderProgram = null;

        this.uMatrixLocation = null;
        this.uScreenTextureLocation = null;
        this.uInputResolutionLocation = null;
        this.uOutputResolutionLocation = null;

        this.aVertexPositionLocation = null;
        this.aVertexTextureUvLocation = null;

        this.textureIndex = 0;
        this.setupGl(glBuilder);

        let boundingBox = this.canvas && this.canvas.getBoundingClientRect();
        let { width, height } = boundingBox || { width: 100, height: 100 };

        this.setInputSize(width, height);
        this.setOutputSize(width, height);

    }

    setInputSize(width, height, pitch = width) {

        if (width === null && height === null)
            throw new Error(`Input width, height, and pitch cannot be null`);

        if (width === this.inputWidth && height === this.inputHeight && pitch === this.inputPitch)
            return;

        this.inputWidth = width;
        this.inputHeight = height;
        this.inputPitch = pitch;

        this.setupAlignmentBuffer();

        this.updateViewport();
        this.draw();

    }

    setOutputSize(width = null, height = null) {

        if (width === this.outputWidth && height === this.outputHeight)
            return;

        this.outputWidth = width;
        this.outputHeight = height;

        this.updateViewport();
        this.draw();

    }

    setShaderProgram(shaderProgram) {

        if (shaderProgram === this.shaderProgram)
            return;

        if (this.shaderProgram !== null)
            this.gl.deleteProgram(this.shaderProgram);

        this.shaderProgram = shaderProgram;
        this.gl.useProgram(shaderProgram);

        this.uMatrixLocation = this.gl.getUniformLocation(shaderProgram, `uMatrix`);

        this.uScreenTextureLocation = this.gl.getUniformLocation(shaderProgram, `uScreenTexture`);
        this.gl.uniform1i(this.uScreenTextureLocation, 0);

        this.uInputResolutionLocation = this.gl.getUniformLocation(shaderProgram, `uInputResolution`);
        this.uOutputResolutionLocation = this.gl.getUniformLocation(shaderProgram, `uOutputResolution`);
        this.uViewportResolutionLocation = this.gl.getUniformLocation(shaderProgram, `uViewportResolution`);

        this.aVertexPositionLocation = this.gl.getAttribLocation(shaderProgram, `aVertexPosition`);
        this.gl.enableVertexAttribArray(this.aVertexPositionLocation);

        this.aVertexTextureUvLocation = this.gl.getAttribLocation(shaderProgram, `aVertexTextureUv`);
        this.gl.enableVertexAttribArray(this.aVertexTextureUvLocation);

        this.gl.bindBuffer(this.vertexPositionBuffer.bufferTarget, this.vertexPositionBuffer);
        this.gl.vertexAttribPointer(this.aVertexPositionLocation, this.vertexPositionBuffer.itemSize, this.gl.FLOAT, false, 0, 0);

        this.gl.bindBuffer(this.vertexTextureUvBuffer.bufferTarget, this.vertexTextureUvBuffer);
        this.gl.vertexAttribPointer(this.aVertexTextureUvLocation, this.vertexTextureUvBuffer.itemSize, this.gl.FLOAT, false, 0, 0);

    }

    validateInputFormat(format) {

        return getMatchingInputFormat(format) !== null;

    }

    setInputFormat(partialFormat) {

        let fullFormat = getMatchingInputFormat(partialFormat);

        if (!fullFormat)
            throw new Error(`Invalid input format`);

        this.inputFormat = fullFormat;

        this.setupAlignmentBuffer();

    }

    setInputData(data) {

        if (!data)
            return;

        this.inputData = data;

    }

    flushScreen() {

        this.draw();

    }

    createTexture() {

        let texture = this.gl.createTexture();

        return texture;

    }

    createBuffer(target, count, content) {

        let buffer = this.gl.createBuffer();
        buffer.bufferTarget = target;
        buffer.itemCount = count;
        buffer.itemSize = content.length / count;

        this.gl.bindBuffer(buffer.bufferTarget, buffer);
        this.gl.bufferData(buffer.bufferTarget, content, this.gl.STATIC_DRAW);

        return buffer;

    }

    createOrthoMatrix(left, right, bottom, top, near, far) {

        let lr = 1 / (left - right);
        let bt = 1 / (bottom - top);
        let nf = 1 / (near - far);

        /* eslint-disable no-magic-numbers */

        return [ -2 * lr, 0, 0, 0, 0, -2 * bt, 0, 0, 0, 0, 2 * nf, 0, (left + right) * lr, (bottom + top) * bt, (near + far) * nf, 1 ];

        /* eslint-enable no-magic-numbers */

    }

    setupBuffers() {

        /* eslint-disable no-magic-numbers */

        this.vertexPositionBuffer = this.createBuffer(this.gl.ARRAY_BUFFER, 4, new Float32Array([ -1, -1, 0, /**/ 1, -1, 0, /**/ 1, 1, 0, /**/ -1, 1, 0 ]));
        this.vertexTextureUvBuffer = this.createBuffer(this.gl.ARRAY_BUFFER, 4, new Float32Array([ 0, 0, /**/ 1, 0, /**/ 1, 1, /**/ 0, 1 ]));
        this.vertexIndexBuffer = this.createBuffer(this.gl.ELEMENT_ARRAY_BUFFER, 4, new Uint16Array([ 0, 1, 3, 2 ]));

        /* eslint-enable no-magic-numbers */

    }

    setupShaders() {

        this.fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, gFragmentShaderScript);
        this.vertexShader = this.createShader(this.gl.VERTEX_SHADER, gVertexShaderScript);
        this.linkShaders(this.fragmentShader, this.vertexShader);

    }

    setupTextures() {

        this.gl.activeTexture(this.gl.TEXTURE0);

        this.textures = [ this.createTexture(), this.createTexture() ];

        this.textures.forEach(texture => {

            this.gl.bindTexture(this.gl.TEXTURE_2D, texture);

            this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
            this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
            this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
            this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);

        });

    }

    setupGl(glBuilder) {

        this.gl = glBuilder();
        this.gl.clearColor(0, 0, 0, 0);

        this.setupBuffers();
        this.setupShaders();
        this.setupTextures();

    }

    createShader(type, script) {

        let shader = this.gl.createShader(type);

        this.gl.shaderSource(shader, script);
        this.gl.compileShader(shader);

        if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS))
            throw new Error(`Shader compilation failed: ${this.gl.getShaderInfoLog(shader)}`);

        return shader;

    }

    linkShaders(vertexShader, fragmentShader) {

        let shaderProgram = this.gl.createProgram();

        this.gl.attachShader(shaderProgram, vertexShader);
        this.gl.attachShader(shaderProgram, fragmentShader);

        this.gl.linkProgram(shaderProgram);

        if (!this.gl.getProgramParameter(shaderProgram, this.gl.LINK_STATUS))
            throw new Error(`Shader linking failed: ${this.gl.getError()}`);

        this.setShaderProgram(shaderProgram);

    }

    setupAlignmentBuffer() {

        if (!this.inputFormat)
            return;

        if (this.inputPitch === this.inputWidth * this.inputFormat.depth / BITS_PER_BYTE) {
            this.alignedData = null;
        } else {
            this.alignedData = new this.inputFormat[TYPED_VIEW](this.inputWidth * this.inputHeight);
        }

    }

    getAlignedData() {

        if (!this.alignedData)
            return this.inputData;

        let height = this.inputHeight;
        let byteLength = this.inputFormat.depth / BITS_PER_BYTE;

        let sourceRowSize = this.inputPitch / byteLength;
        let destinationRowSize = this.inputWidth;

        let source = this.inputData;
        let destination = this.alignedData;

        let sourceIndex = 0;
        let destinationIndex = 0;

        for (let y = 0; y < height; ++y) {

            for (let t = 0; t < destinationRowSize; ++t)
                destination[destinationIndex + t] = source[sourceIndex + t];

            sourceIndex += sourceRowSize;
            destinationIndex += destinationRowSize;

        }

        return this.alignedData;

    }

    updateViewport() {

        let inputWidth = Math.max(1, this.inputWidth);
        let inputHeight = Math.max(1, this.inputHeight);

        let outputWidth = Math.max(1, this.outputWidth);
        let outputHeight = Math.max(1, this.outputHeight);

        if (outputWidth === null && outputHeight === null) {
            outputWidth = inputWidth;
            outputHeight = inputHeight;
        }

        if (outputWidth === null)
            outputWidth = inputWidth * (outputHeight / inputHeight);

        if (outputHeight === null)
            outputHeight = inputHeight * (outputWidth / inputWidth);

        let widthRatio = outputWidth / inputWidth;
        let heightRatio = outputHeight / inputHeight;

        let ratio = Math.min(widthRatio, heightRatio);

        let viewportWidth = widthRatio / ratio;
        let viewportHeight = heightRatio / ratio;

        if (this.canvas) {
            this.canvas.width = outputWidth;
            this.canvas.height = outputHeight;
        }

        if (this.gl.resize)
            this.gl.resize(outputWidth, outputHeight);

        let matrix = this.createOrthoMatrix(-viewportWidth, viewportWidth, -viewportHeight, viewportHeight, -RENDER_DEPTH, RENDER_DEPTH);
        this.gl.uniformMatrix4fv(this.uMatrixLocation, false, matrix);

        this.gl.uniform2f(this.uInputResolutionLocation, inputWidth, inputHeight);
        this.gl.uniform2f(this.uOutputResolutionLocation, outputWidth, outputHeight);
        this.gl.uniform2f(this.uViewportResolutionLocation, viewportWidth * inputWidth, viewportHeight * inputHeight);

        this.gl.viewport(0, 0, outputWidth, outputHeight);

    }

    draw() {

        this.gl.clear(this.gl.COLOR_BUFFER_BIT);

        if (!this.inputData || this.inputWidth === 0 || this.inputHeight === 0)
            return;

        let format = this.gl[this.inputFormat[GL_FORMAT]];
        let type = this.gl[this.inputFormat[GL_TYPE]];
        let data = this.getAlignedData();

        let textureIndex = this.textureIndex++ % 2;
        this.gl.bindTexture(this.gl.TEXTURE_2D, this.textures[textureIndex]);
        this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGB, this.inputWidth, this.inputHeight, 0, format, type, data);

        this.gl.bindBuffer(this.vertexIndexBuffer.bufferTarget, this.vertexIndexBuffer);
        this.gl.drawElements(this.gl.TRIANGLE_STRIP, this.vertexIndexBuffer.itemCount, this.gl.UNSIGNED_SHORT, 0);

    }

}