Search code examples
javascriptwebpackhtml5-canvaswebglbundler

WebGL canvas crashes on mousemove, only when using webpack to bundle resources


I am trying to create a simple drawing application using webgl. When the user mouses over the canvas, a pixel should be drawn in that place. I am using webpack to bundle resources like HTML, JS, as well as .frag, .vert, and .glsl files. In a webpack environment, the canvas dies without error or warning on mouse over.

The working goal:

A simple, HTML and <script> version of the code is here. And it works just fine:

canvas {
  border: 1px solid blue;
}
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>WebGL Playground</title>
    </head>
    <body>
        <canvas id="canvas" height="300" width="300"></canvas>
        <div>Draw here</div>
    </body>

    <script id="vert1" type="x-vertex-shader">
        // Some extra code here to convert pixels to clip space within the shader,
        // as opposed to within the javascript code

        attribute vec2 a_position;
        uniform vec2 u_resolution;

        void main() {
            // convert the position from pixels to 0.0 to 1.0
            vec2 zeroToOne = a_position / u_resolution;

            // convert from 0->1 to 0->2
            vec2 zeroToTwo = zeroToOne * 2.0;

            // convert from 0->2 to -1->+1 (clip space)
            vec2 clipSpace = zeroToTwo - 1.0;

            gl_Position = vec4(clipSpace, 0.0, 1.0);
        }
    </script>

    <script id="frag1" type="x-fragment-shader">
        precision mediump float;

        uniform vec4 u_color;

        void main() {
                gl_FragColor = vec4(1,0,1,1);
        }
    </script>

    <script type="text/javascript">
        const canvas = document.getElementById('canvas');
        const gl = canvas.getContext('webgl', { preserveDrawingBuffer: true });

        // simple util to compile and attach shaders
        const program = setup(gl, vert1.textContent, frag1.textContent);

        gl.useProgram(program);

        // disable position attribute
        const positionAttributeLocation = gl.getAttribLocation(
            program,
            'a_position'
        );
        gl.disableVertexAttribArray(positionAttributeLocation);

        // set the resolution
        const resolutionUniformLocation = gl.getUniformLocation(
            program,
            'u_resolution'
        );
        gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);

        canvas.addEventListener('mousemove', (e) => {
            const br = canvas.getBoundingClientRect();
            const x = e.clientX - br.left;
            const y = br.height - (e.clientY - br.top);
            gl.vertexAttrib2f(positionAttributeLocation, x, y);
            gl.drawArrays(gl.POINTS, 0, 1);
        });

        function setup(ctx, vert, frag) {
            const vs = ctx.createShader(ctx.VERTEX_SHADER);
            ctx.shaderSource(vs, vert);
            ctx.compileShader(vs);

            const fs = ctx.createShader(ctx.FRAGMENT_SHADER);
            ctx.shaderSource(fs, frag);
            ctx.compileShader(fs);

            const program = ctx.createProgram();
            ctx.attachShader(program, vs);
            ctx.attachShader(program, fs);
            ctx.linkProgram(program);
            console.log(ctx.getProgramInfoLog(program));
            return program;
        }
    </script>
</html>

The webpack setup

I'd like to begin incorporating this same webgl drawing concept into my more complex, webpack-based codebase. So I have a simple html file:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>WebGL Playground</title>
    </head>
    <body>
        <canvas id="canvas" height="600" width="600"></canvas>
        <div>Demo here</div>
    </body>
</html>

And a relatively short typescript file with the same webgl logic as above:


// See question appendix for details on these imported functions:
import { getGlContext, setup } from '../utils';

import vert1 from './vert.vert';
import frag1 from './frag.frag';

document.addEventListener('DOMContentLoaded', () => {
    const canvas = document.getElementById('canvas');
    const gl = getGlContext('canvas', { preserveDrawingBuffer: true });

    const { program } = setup(gl, vert1, frag1);

    gl.useProgram(program);

    // disable position attribute
    const positionAttributeLocation = gl.getAttribLocation(program, 'a_position');
    // gl.disableVertexAttribArray(positionAttributeLocation);

    // set the resolution
    const resolutionUniformLocation = gl.getUniformLocation(
        program,
        'u_resolution'
    );
    gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);

    canvas.addEventListener('mousemove', (e) => {
        const x = e.x - canvas.offsetLeft + 1;
        const y = e.y - canvas.offsetTop + 1;
        gl.vertexAttrib2f(positionAttributeLocation, x, y);
        gl.drawArrays(gl.POINTS, 0, 1);
    });
});

Importing shaders from their own files with webpack:

One of the great things about doing GLSL with webpack, is that I can install a GLSL extension in my editor, write GLSL with syntax highlighting and linting in a .frag, .vert or .glsl file, and import it as a string with webpack.

// vert.vert, a simple vertex shader

// An attribute will receive data from a buffer
attribute vec2 a_position;
uniform vec2 u_resolution;

void main() {
  // convert the position from pixels to 0.0 to 1.0
  vec2 zeroToOne = a_position / u_resolution;

  // convert from 0->1 to 0->2
  vec2 zeroToTwo = zeroToOne * 2.0;

  // convert from 0->2 to -1->+1 (clip space)
  vec2 clipSpace = zeroToTwo - 1.0;

  gl_Position = vec4(clipSpace, 0.0, 1.0);
}
// frag.frag, a dead simple fragment shader

precision mediump float;

uniform vec4 u_color;

void main() {
    gl_FragColor = vec4(1,0,1,1);
}

Webpack is then used to glue these all together, using a raw loader and glslify loader. The webpack config:

const webpack = require('webpack');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

const isProduction = process.env.NODE_ENV == 'production';

const stylesHandler = isProduction
    ? MiniCssExtractPlugin.loader
    : 'style-loader';

const config = {
    entry: './src/index.ts',
    output: {
        path: path.resolve(__dirname, 'dist'),
    },
    stats: 'errors-only',
    devServer: {
        open: true,
        host: 'localhost',
        port: 3009,
        hot: true,
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: 'src/index.html',
        }),
        new webpack.ProvidePlugin({
            process: 'process/browser',
        }),
    ],
    module: {
        rules: [
            {
                test: /\.(ts|tsx)$/i,
                loader: 'ts-loader',
                exclude: ['/node_modules/'],
            },
            {
                test: /\.css$/i,
                use: [stylesHandler, 'css-loader'],
            },
            {
                test: /\.s[ac]ss$/i,
                use: [stylesHandler, 'css-loader', 'sass-loader'],
            },
            {
                test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i,
                type: 'asset',
            },
            {
                test: /\.(glsl|vs|fs|vert|frag)$/,
                exclude: /node_modules/,
                use: ['raw-loader', 'glslify-loader'],
            },
        ],
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.jsx', '.js', '...'],
    },
};

module.exports = () => {
    if (isProduction) {
        config.mode = 'production';

        config.plugins.push(new MiniCssExtractPlugin());
    } else {
        config.mode = 'development';
    }
    return config;
};

The whole thing is run through a simple webpack dev server, i.e. webpack serve. For completeness, here is the package.json:

{
    "main": "index.ts",
    "scripts": {
        "watch": "webpack --watch",
        "start": "webpack serve"
    },
    "devDependencies": {
        "@webpack-cli/generators": "^3.0.1",
        "css-loader": "^6.7.2",
        "html-webpack-plugin": "^5.5.0",
        "mini-css-extract-plugin": "^2.7.0",
        "prettier": "^2.7.1",
        "sass": "^1.56.1",
        "sass-loader": "^13.2.0",
        "style-loader": "^3.3.1",
        "ts-loader": "^9.3.1",
        "typescript": "^4.9.3",
        "webpack": "^5.76.3",
        "webpack-cli": "^5.0.1",
        "webpack-dev-server": "^4.11.1"
    },
    "dependencies": {
        "glslify-loader": "^2.0.0",
        "path": "^0.12.7",
        "process": "^0.11.10",
        "raw-loader": "^4.0.2"
    }
}

This setup is working well for me in other scenarios, i.e. drawing simple shapes in webgl, working with textures, etc. However, in attempting to capture mouse event information and send it to the GPU via the gl.vertexAttrib2f call, the canvas silently crashes. No warnings or errors.

Codesandbox demonstrating the issue

The above codesandbox is built with parcel, instead of webpack, but the issue is the same. Mouse into the canvas, and it crashes.

What is it about the bundled approach that is causing this mouse event to crash the webgl canvas?

Appendix:

// utils.ts

// Required by glsl parser
export const glsl = (x: TemplateStringsArray) => x.join('');

/**
 * Util function to get the webgl rendering context
 * @param canvasId The HTML id of the canvas element being used
 * @param options The options to pass to the `.getContext` call
 * @returns The WebGLRenderingContext
 */
export function getGlContext(
    canvasId: string = 'canvas',
    options?: WebGLContextAttributes
) {
    const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
    const gl = canvas.getContext('webgl', options) as WebGLRenderingContext;

    return gl;
}

const canvas = document.getElementById('canvas') as HTMLCanvasElement;
export const gl = canvas.getContext('webgl') as WebGLRenderingContext;

/**
 * Util function to set up canvas and webgl context
 * @param vertexShaderText
 * @param fragmentShaderText
 */
export const setup = (
    gl: WebGLRenderingContext,
    vertexShaderText: string,
    fragmentShaderText: string
) => {
    gl.clearColor(0.75, 0.85, 0.8, 1);
    gl.clear(gl.COLOR_BUFFER_BIT);

    /*
     * Create shaders
     */
    const vertexShader = gl.createShader(gl.VERTEX_SHADER) as WebGLShader;
    const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER) as WebGLShader;

    gl.shaderSource(vertexShader, vertexShaderText);
    gl.shaderSource(fragmentShader, fragmentShaderText);

    gl.compileShader(vertexShader);
    /**
     * Check for GLSL compile errors
     */
    if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
        console.error(
            'ERROR compiling vertex shader!',
            gl.getShaderInfoLog(vertexShader)
        );
    }

    gl.compileShader(fragmentShader);
    if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
        console.error(
            'ERROR compiling fragment shader!',
            gl.getShaderInfoLog(fragmentShader)
        );
    }

    const program = gl.createProgram() as WebGLProgram;
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program);

    /**
     * Check for linker errors
     */
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        console.error('ERROR linking program!', gl.getProgramInfoLog(program));
    }

    gl.validateProgram(program);
    if (!gl.getProgramParameter(program, gl.VALIDATE_STATUS)) {
        console.error('ERROR validating program', gl.getProgramInfoLog(program));
    }

    return { gl, program };
};

Solution

  • The webgl context doesn't crash, that'd give an error message, nor does the canvas die as it's still present in the DOM as can be seen using the inspector, if you look closely you'll see that it does render one pixel, but only one, suggesting that the canvas is being cleared in-between draw calls.

    When looking at the actual context attributes via getContextAttributes() you'll see that preserveDrawingBuffer is false the first time around. After an hour of digging through the chromium source code and cross checking the WebGL Specification it's clear that preserveDrawingBuffer must be, and in fact is obeyed by the implementation, so how is this possible?

    Every canvas can only have one context:

    Later calls to this method on the same canvas element, with the same contextType argument, will always return the same drawing context instance as was returned the first time the method was invoked. It is not possible to get a different drawing context object on a given canvas element.

    MDN web docs

    Injecting a simple script in the head of the html to intercept calls to getContext and log the passed arguments, reveals that getContext is called twice, once without attributes and then subsequently with them. A breakpoint later we find that in your utils.ts you not only have utility functions but also these two lines:

    const canvas = document.getElementById("canvas") as HTMLCanvasElement;
    export const gl = canvas.getContext("webgl") as WebGLRenderingContext;