Source: shellfish-ui/html/canvas.js

shellfish-ui/html/canvas.js

/*******************************************************************************
This file is part of the Shellfish UI toolkit.
Copyright (c) 2021 - 2025 Martin Grimme <martin.grimme@gmail.com>

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*******************************************************************************/

"use strict";

shRequire(["shellfish/low", __dirname + "/item.js", "shellfish/core/matrix"], function (low, item, mat)
{
    const simpleVertexShader = `#version 300 es
        in vec2 position;
        out vec2 uv;

        void main()
        {
            uv = position;
            gl_Position = vec4(position, 0.0, 1.0);
        }
    `;

    function loadImage(source)
    {
        return new Promise((resolve, reject) =>
        {
            const img = new Image();
            img.onload = () =>
            {
                resolve(img);
            };
            img.onerror = (err) =>
            {
                reject(err);
            };
            img.src = shRequire.resource(source);
        });
    }

    function createTexture(gl, image)
    {
        const texture = gl.createTexture();
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
        if (image.width !== undefined && image.height !== undefined && image.data !== undefined)
        {
            // this is an ImageData-like structure ({ width, height, data })
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, image.data);
        }
        else
        {
            // this is an Image object
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
        }
        gl.generateMipmap(gl.TEXTURE_2D)
        //gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        //gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        //gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
        //gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
        gl.bindTexture(gl.TEXTURE_2D, null);

        return texture;
    }


    let glTimerExtUnsupported = false;

    const d = new WeakMap();

    /**
     * Class representing a HTML5 canvas. It provides a `context2d` property
     * for using the HTML5 Canvas API, and a `fragmentShader` property for GPU-driven
     * rendering using a GLSL fragment shader.
     * 
     * @extends html.Item
     * @memberof html
     * 
     * @property {object} context2d - [readonly] The 2D canvas context.
     * @property {number} originalHeight - (default: `100`) The original unscaled height.
     * @property {number} originalWidth - (default: `100`) The original unscaled width.
     * @property {string} fragmentShader - (default: `""`) An optional GLSL fragment shader for rendering directly into the canvas.
     * @property {string} errorMessage - (default: `""`) A shader-related error message.
     */
    class Canvas extends item.Item
    {
        constructor()
        {
            super();
            d.set(this, {
                item: low.createElementTree(
                    low.tag("canvas")
                    .attr("width", "100")
                    .attr("height", "100")
                    //.style("pointer-events", "none")
                    .html()
                ),
                gl: null,
                programId: 0,
                shader: "",
                textures: [],
                errorMessage: ""
            });

            this.notifyable("originalWidth");
            this.notifyable("originalHeight");
            this.notifyable("fragmentShader");
            this.notifyable("errorMessage");
        }

        updateContentSize()
        {
            this.renderGL();
        }

        /**
         * Sets a uniform value to pass to the fragment shader.
         * 
         * When passing vectors and matrices, they must be of the form created
         * by Shellfish's {@link core.matrix matrix} tools.
         * 
         * @param {string} type - The type of uniform. Supported types are: `float|int|mat3|mat4|vec2|vec3|vec4`
         * @param {string} name - The name of the uniform. It must be defined by that name in the shader.
         * @param {any} value - The value to set.
         * @see {@link core.matrix matrix}
         */
        setUniform(type, name, value)
        {
            const priv = d.get(this);
            const gl = priv.gl;
            if (! gl)
            {
                return;
            }

            const uniformLocation = gl.getUniformLocation(priv.programId, name);
            if (! uniformLocation)
            {
                console.error("Cannot find uniform in shader: " + name);
                return;
            }

            if (type === "float")
            {
                gl.uniform1f(uniformLocation, value);
            }
            else if (type === "int")
            {
                gl.uniform1i(uniformLocation, value);
            }
            else if (type === "mat3")
            {
                // transpose for OpenGL
                gl.uniformMatrix3fv(uniformLocation, false, new Float32Array(mat.flat(mat.t(value))));
            }    
            else if (type === "mat4")
            {
                // transpose for OpenGL
                gl.uniformMatrix4fv(uniformLocation, false, new Float32Array(mat.flat(mat.t(value))));
            }
            else if (type === "vec2")
            {
                gl.uniform2fv(uniformLocation, new Float32Array(mat.flat(value)));
            }
            else if (type === "vec3")
            {
                gl.uniform3fv(uniformLocation, new Float32Array(mat.flat(value)));
            }
            else if (type === "vec4")
            {
                gl.uniform4fv(uniformLocation, new Float32Array(mat.flat(value)));
            }
        }

        /**
         * Sets a 2D sampler (texture) value to pass to the fragment shader.
         * 
         * The `parameters` dictionary may contain these string entries:
         * 
         * * `wrap`: `[repeat|clamp|mirror, repeat|clamp|mirror]`
         * * `filter`: `[linear|nearest, linear|nearest]`
         * * `mipmap`: `[true|false]` (Generate mipmap textures)
         * * `data`: `[true|false]` (Use this for storing data textures)
         * 
         * @param {string} name - The name of the uniform. It must be defined by that name in the shader.
         * @param {number} width - The width of the texture. This value is ignored if `value` is an image type.
         * @param {number} height - The height of the texture. This value is ignored if `value` is an image type.
         * @param {any} value - The texture value to set. Either an image type, or a `Float32Array` or a `Int32Array`.
         * @param {object} parameters - A dictionary of parameters.
         */
        setSampler(name, width, height, value, parameters)
        {
            const priv = d.get(this);
            const gl = priv.gl;
            if (! gl)
            {
                return;
            }

            const uniformLocation = gl.getUniformLocation(priv.programId, name);
            if (! uniformLocation)
            {
                console.error("Cannot find sampler uniform in shader: " + name);
                return;
            }

            let texIndex = priv.textures.findIndex(tex => tex.uniform === name);
            if (texIndex === -1)
            {
                // this is a new texture
                texIndex = priv.textures.length;
                const texHandle = gl.createTexture();
                gl.uniform1i(uniformLocation, texIndex);

                priv.textures.push({
                    uniform: name,
                    texture: texHandle
                });
            }

            // use that texture
            gl.activeTexture(gl.TEXTURE0 + texIndex);
            gl.bindTexture(gl.TEXTURE_2D, priv.textures[texIndex].texture);

            let generateMipMap = false;

            if (parameters)
            {
                const valueMap = {
                    "linear": gl.LINEAR,
                    "nearest": gl.NEAREST,
                    "repeat": gl.REPEAT,
                    "clamp": gl.CLAMP_TO_EDGE,
                    "mirror": gl.MIRRORED_REPEAT,
                    "nearestMipmapNearest": gl.NEAREST_MIPMAP_NEAREST,
                    "nearestMipmapLinear": gl.NEAREST_MIPMAP_LINEAR,
                    "linearMipmapNearest": gl.LINEAR_MIPMAP_NEAREST,
                    "linearMipmaplinear": gl.LINEAR_MIPMAP_LINEAR
                };

                for (let key in parameters)
                {
                    const param = parameters[key];
                    if (key === "wrap")
                    {
                        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, valueMap[param[0]] || gl.REPEAT);
                        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, valueMap[param[1]] || gl.REPEAT);
                    }
                    else if (key == "filter")
                    {
                        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, valueMap[param[0]] || gl.LINEAR);
                        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, valueMap[param[1]] || gl.LINEAR);
                    }
                    else if (key == "mipmap")
                    {
                        generateMipMap = param;
                    }
                    else if (key == "data" && param)
                    {
                        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);
                        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
                        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
                        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_BASE_LEVEL, 0);
                        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAX_LEVEL, 0);
                    }
                }
            }

            let internalFormat = gl.RGBA;
            let dataFormat = gl.RGBA;
            let dataType = gl.UNSIGNED_BYTE;
            let data = value;

            // analyze data
            if (data.data)
            {
                data = value.data;
            }
            else if (data instanceof Float32Array)
            {
                gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
                internalFormat = gl.RGBA32F;
                dataFormat = gl.RGBA;
                dataType = gl.FLOAT;
            }
            else if (data instanceof Int32Array)
            {
                gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
                internalFormat = gl.RGBA32I;
                dataFormat = gl.RGBA_INTEGER;
                dataType = gl.INT;
            }
            else if (data instanceof Uint32Array)
            {
                gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
                internalFormat = gl.RGBA32UI;
                dataFormat = gl.RGBA_INTEGER;
                dataType = gl.UNSIGNED_INT;
            }
            else
            {
                gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, dataFormat, dataType, value);
                return;
            }

            gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, width, height, 0, dataFormat, dataType, data);

            if (generateMipMap)
            {
                gl.generateMipmap(gl.TEXTURE_2D);
            }
        }

        /**
         * Updates an area of a sampler (texture). This allows updating a part
         * of a texture without having to re-upload the full texture to the GPU.
         * 
         * @param {string} name - The name of the uniform. It must be defined by that name in the shader.
         * @param {number} width - The width of the new area.
         * @param {number} height - The height of the new area.
         * @param {any} value - The texture value to set. Either an image type, or a `Float32Array` or a `Int32Array`.
         */
        updateSampler(name, x, y, width, height, value)
        {
            const priv = d.get(this);
            const gl = priv.gl;
            if (! gl)
            {
                return;
            }

            const uniformLocation = gl.getUniformLocation(priv.programId, name);
            if (! uniformLocation)
            {
                console.error("Cannot find sampler uniform in shader: " + name);
                return;
            }

            let texIndex = priv.textures.findIndex(tex => tex.uniform === name);
            if (texIndex === -1)
            {
                console.error("Cannot update non-existing texture: " + name);
                return;
            }

            // use that texture
            gl.activeTexture(gl.TEXTURE0 + texIndex);
            gl.bindTexture(gl.TEXTURE_2D, priv.textures[texIndex].texture);

            let dataFormat = gl.RGBA;
            let dataType = gl.UNSIGNED_BYTE;
            let data = value;

            // analyze data
            if (data.data)
            {
                data = value.data;
            }
            else if (data instanceof Float32Array)
            {
                dataFormat = gl.RGBA;
                dataType = gl.FLOAT;
            }
            else if (data instanceof Int32Array)
            {
                dataFormat = gl.RGBA_INTEGER;
                dataType = gl.INT;
            }
            else if (data instanceof Uint32Array)
            {
                dataFormat = gl.RGBA_INTEGER;
                dataType = gl.UNSIGNED_INT;
            }

            gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, width, height, dataFormat, dataType, data);
        }

        initShader()
        {
            const priv = d.get(this);
            const gl = this.get().getContext("webgl2");

            const vShaderId = gl.createShader(gl.VERTEX_SHADER);
            const fShaderId = gl.createShader(gl.FRAGMENT_SHADER);
            gl.shaderSource(vShaderId, simpleVertexShader);
            
            gl.compileShader(vShaderId);
            if (! gl.getShaderParameter(vShaderId, gl.COMPILE_STATUS))
            {
                const info = gl.getShaderInfoLog(vShaderId);
                console.error(info);
                priv.errorMessage = info;
                this.errorMessageChanged();
                //throw "Failed to compile vertex shader: " + info;
                return;;
            }
                
            gl.shaderSource(fShaderId, priv.fragmentShader);
            gl.compileShader(fShaderId);
            if (! gl.getShaderParameter(fShaderId, gl.COMPILE_STATUS))
            {
                const info = gl.getShaderInfoLog(fShaderId);
                console.error(info);
                priv.errorMessage = info;
                this.errorMessageChanged();
                //throw "Failed to compile fragment shader: " + info;
                return;
            }

            const programId = gl.createProgram();
            gl.attachShader(programId, vShaderId);
            gl.attachShader(programId, fShaderId);
            gl.linkProgram(programId);
            if (! gl.getProgramParameter(programId, gl.LINK_STATUS))
            {
                const info = gl.getProgramInfoLog(programId);
                console.error(info);
                priv.errorMessage = info;
                this.errorMessageChanged();
                //throw "Failed to link GLSL program: " + info;
                return;
            }

            gl.useProgram(programId);
            console.log(gl.getShaderInfoLog(fShaderId));

            const positionAttrib = gl.getAttribLocation(programId, "position");

            const vertices = [
                -1.0, -1.0,
                1.0, -1.0,
                -1.0, 1.0,
                1.0, -1.0,
                1.0, 1.0,
                -1.0, 1.0
            ];

            const vbo = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
            
            gl.vertexAttribPointer(positionAttrib, 2, gl.FLOAT, false, 0, 0);
            gl.enableVertexAttribArray(positionAttrib);

            priv.gl = gl;
            priv.programId = programId;
        }
        
        /**
         * Renders using the configured fragment shader and returns the rendering time in nanoseconds
         * precison, if measuring was requested and the platform supports it. Not all browsers
         * may support the timer extension for measuring on all platforms, though.
         * 
         * @param {bool} measure - Whether to measure the rendering time.
         * @return {number} The rendering time in nanoseconds, if measuring was requested. This value is `-1` if not supported by platform, or measuring was not requested.
         */
        async renderGL(measure)
        {
            const priv = d.get(this);
            if (! priv.gl)
            {
                return 0;
            }

            const gl = priv.gl;

            let timerExt = null;
            let glVersion = 0;
            if (measure && ! glTimerExtUnsupported)
            {
                timerExt = gl.getExtension("EXT_disjoint_timer_query_webgl2");
                if (timerExt)
                {
                    glVersion = 2;
                }
                else
                {
                    timerExt = gl.getExtension("EXT_disjoint_timer_query");
                    if (timerExt)
                    {
                        glVersion = 1;
                    }
                    else
                    {
                        glTimerExtUnsupported = true;
                    }
                }
            }

            let timerQuery = null;
            if (timerExt)
            {
                if (glVersion === 2)
                {
                    timerQuery = gl.createQuery();
                    gl.beginQuery(timerExt.TIME_ELAPSED_EXT, timerQuery);
                }
                else
                {
                    timerQuery = gl.createQueryEXT();
                    timerExt.beginQueryEXT(timerExt.TIME_ELAPSED_EXT, timerQuery);
                }
            }

            gl.viewport(0, 0, this.originalWidth, this.originalHeight);
            gl.drawArrays(gl.TRIANGLES, 0, 6);

            if (timerExt)
            {
                if (glVersion === 2)
                {
                    gl.endQuery(timerExt.TIME_ELAPSED_EXT);
                }
                else
                {
                    timerExt.endQueryEXT(timerExt.TIME_ELAPSED_EXT);
                }

                const pollForResult = async () =>
                {
                    for (let tries = 0; tries < 50; ++tries)
                    {
                        if (glVersion === 2)
                        {
                            if (gl.getQueryParameter(timerQuery, gl.QUERY_RESULT_AVAILABLE) &&
                                ! gl.getParameter(timerExt.GPU_DISJOINT_EXT))
                            {
                                return gl.getQueryParameter(timerQuery, gl.QUERY_RESULT);
                            }
                        }
                        else
                        {
                            if (timerExt.getQueryObjectEXT(timerQuery, gl.QUERY_RESULT_AVAILABLE_EXT) &&
                                ! gl.getParameter(timerExt.GPU_DISJOINT_EXT))
                            {
                                return timerExt.getQueryObjectEXT(timerQuery, gl.QUERY_RESULT_EXT);
                            }
                        }
                        await this.wait(10);
                    }
                    return -1;
                };
    
                return await pollForResult();
            }
            else
            {
                return -1;
            }
        }

        get context2d() { return this.get().getContext("2d"); }

        get originalWidth() { return this.get().width; }
        set originalWidth(w)
        {
            if (w !== this.get().width)
            {
                this.get().width = w;
            }
            this.originalWidthChanged();
        }

        get originalHeight() { return this.get().height; }
        set originalHeight(h)
        {
            if (h !== this.get().height)
            {
                this.get().height = h;
            }
            this.originalHeightChanged();
        }

        get fragmentShader() { return d.get(this).fragmentShader; }
        set fragmentShader(s)
        {
            if (s !== d.get(this).fragmentShader)
            {
                d.get(this).fragmentShader = s;
                if (s !== "")
                {
                    this.initShader();
                }
                this.fragmentShaderChanged();
            }
        }

        get errorMessage() { return d.get(this).errorMessage; }

        get()
        {
            return d.get(this).item;
        }
    }
    exports.Canvas = Canvas;

});