Back to main page

WebGL for web apps - rebuilding kte-map-builder

Kill the Evil Devlogs

WebGL is so cool. It's daunting, but worth the learning curve. I avoided it for years because I thought it would only be useful for 3D creative programming. I now see 2D WebGL as the secret to unlocking native performance in web apps for complex UIs.

If you are new to WebGL and want to understand how it works, I strongly recommend WebGL Fundamentals.

This post is a short and sweet summary of how I setup kte-map-builder to use WebGL with Solid.js. This post and the relevant Git repo could be used as "real world" example of how to use WebGL with a modern FE framework.

Lets go.

Map Builder Diagram
KTE Map Builder State Management

WebGL is an API to render graphics with the GPU. It uses compiled shaders to run on every pixel in a canvas. It is very fast, but static compared to modern FE frameworks.

I observed two rules while building this. First, the canvas dimensions must be set before initializing the WebGL context. Second, re-rendering the canvas clears the WebGL context. These rules influenced a few attributes of state that could be present on any given field.

  • State that effects canvas dimensions (cell counts and size)
  • State that needed to be accessed on each requestAnimationFrame()
  • Temporary state that could be cleared at any point without inconviencing the user (me). This includes fields for uniform locations and drag coordinates
  • Persisted state that the user (me) uses to build a map in multiple sessions

I ended up with two state "sources of truth". First, I wrote a state module that initialized based on localstorage for persisted state. Second, I added some window.__kteFields to the window for temporary state.

I initialized the state in the <App /> and passed it to the main WebGL function initBuilder. Since state was being passed as reference and referenced every frame, all I had to do was use my state updater functions to mutate the state based on user actions.

If a piece of state required a re-init of the WebGL context, I just called initBuilderagain with the updated state.

const App = () => {
    const state = initState();
    const [foo, setFoo] = createSignal(state.foo);
    const [bar, setBar] = createSignal(state.bar);

    onMount(() => {
        initBuilder(state);
    })
    
    function handleFooChange(nextVal) {
        stateModule.setFoo(nextVal); // mutation
        setFoo(nextVal);

        // foo affects canvas size, re init webgl
        initBuilder(state);
    }

    function handleBarChange(nextVal) {
       stateModule.convertAndUpdateBar(nextVal); // mutation 
       setBar(state.bar);
    
       // bar only affects rendering, will be accessed through the
       // the reference to state. No re-init is required
    }

    return <jsx />
}

And thats it! I almost didn't write a blog post about this, but I am super stoked about the performance gains on my map builder and I wanted to share something about it.

Thanks for reading!