The Game Window


Web First Gamedev - Part 2

Opening a window is the first thing I do when I start writing a game. Once I have that I can bolt on graphics, update loops, input, etc, but nothing comes before having a window to anchor everything to. Figuring out how that works on the web platform is the goal of this article. Answering what is the web equivalent of SDL_CreateWindow or winit::WindowBuilder.

And going beyond the very basics of this, I do want to dig in to a few extra details.

  • Window resolution
  • Title and Icon
  • Fullscreen or windowed modes
  • Resizeable windows
  • Cursor visibility
  • Locking the cursor to the window
  • Relationship between window and renderer

Many of the demos use the developer console to log relevant info, so I’d recommend having the console open while inspecting the demos.

Opening the Window

There is no window, in the sense that it is used in context of native applications. We’re embedding the “window” concept in to a web page, so the responsibilities of the native window are going to be spread across a few components.

In particular, the canvas tag will be used to specify the portion of the page which the game will render to. It provides a renderable region which supports a variety of graphics APIs:

  • Simple 2d rendering
  • WebGL/WebGL2
  • WebGPU (in the future)

I’d like to use WebGPU since it’s a very pleasant modern graphics API, but it’s not widely available in browsers yet. So I’ll be using WebGL2 as the graphics API for these web gamedev investigations, which is similar to OpenGL ES 3.0. I’ll be assuming a passing familiarity with OpenGL programming in the explaination of concepts.

Basic Canvas Usage

Demo

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title>Basic Canvas</title>
    <script language="Javascript">
        window.addEventListener('DOMContentLoaded', (event) => {
            let canvas = document.getElementById('main-canvas');

            let gl = canvas.getContext('webgl2');
            gl.clearColor(1.0, 0.0, 1.0, 1.0);
            gl.clear(gl.COLOR_BUFFER_BIT);
            gl.flush();
        });
    </script>
</head>

<body>
    <canvas id="main-canvas"></canvas>
</body>

</html>

The starting point is a very basic web page which includes a canvas element, and enough js to grab the WeblGL2 context, and fill the canvas with a known color. I don’t want to dig in to the WebGL side of things yet, but this lets us make some clearer demos, and explore the relationship between the canvas’s rendering resolution and the size it’s displayed as on screen.

The window.addEventListener('DOMContentLoaded', (event) => { ... }); portion delays the execution of our rendering code until all the elements on the page have been loaded - I can rely on the <canvas> element existing at this point.

Window Title

The closest analog to the window’s title is the web page’s title. This is set in the above example using the <title> element:

<head>
    <title>Basic Canvas</title>
</head>

Window Icon

Similarly, the closest parallel to the window icon is the web page’s favicon. By default browsers will request favicon.ico from the host that’s serving the web page. In the case of this blog post which is under https://stroan.net/blog/something, the browser will try to grab https://stroan.net/favicon.ico. This can be overridden using a link element:

<head>
    <link rel="icon" href="/alt-favicon.png">
</head>

Window Dimensions

The basic canvas example above presents a magenta rectangle on the page. The canvas should have dimensions of 300px x 150px. This is the default specified in the HTML5 spec.

To control the size of the canvas I use the width and height attributes of the canvas element:

Demo

    <canvas id="main-canvas" width="800px" height="600px"></canvas>

The magenta canvas is now the specified size on screen, great.

Render Target Dimensions

The drawingBufferWidth and drawingBufferHeight properties on the WebGL2 context report the size of the target being rendered to. These should be the same value as the canvas’s width and height attributes. In the previous example we log these values like so:

console.log('Rendering dimensions', gl.drawingBufferWidth, gl.drawingBufferHeight);

This logs "Rendering dimensions 800 600", the render target is 1:1 with the dimensions of the canvas.

If we want to have the render target have a different size, we can control the size of the canvas on the page separately from the size of render target. This is done via the element’s style:

Demo

<canvas id="main-canvas" width="400px" height="300px"
                         style="width:800px; height:600px;"></canvas>

The canvas appears to be the same size as the previous example - 800px x 600px. But the logged dimensions of the render target are now half that of previously, and match the canvas width and height attributes.

Scaling / Filtering

So we’re able to control the size of canvas presentation dimensions and the render target dimensions independently. To upscale from one to the other some kind of filtering must be applied. This is controlled by the image-rendering CSS attribute, the values of which are available in the CSS spec.

The default value seems to be unspecified but on FF and Chrome a smoothing algorithm has been applied. By setting the value of image-rendering to pixelated upscaling is done via nearest-neighbour.

Demo

<canvas id="main-canvas" width="200px" height="150px"
        style="width:800px; height:600px; image-rendering:pixelated;"></canvas>

The demo here has a canvas which is being upscaled 4x, has buttons to toggle the value of the image-rendering property on the canvas, and renders a rectangle in the middle of the canvas.

The edges of the rectangle demonstrate the effect of the filtering. When image-rendering is auto the borders of the rectangle will look blurry, and when its pixelated the edges will look crisp. This may be of particular use when working on simple pixel art games.

Fullscreen

Unlike when a native fullscreen window is created, a canvas isn’t created fullscreen. It always starts as a regular element in a webpage, and we have to request for the canvas to become fullscreen through use of the Element.requestFullscreen method.

If we try to call this automatically on page load the request will fail. Requests must be triggered from a direction user interaction, such as a mouse click. We connect a button to a new fullscreen function:

Demo

<button onclick="fullscreen()">Fullscreen</button>
function fullscreen() {
    canvas.requestFullscreen().then(() => {
        console.log('Rendering dimensions', gl.drawingBufferWidth, gl.drawingBufferHeight);
        console.log('Onscreen dimensions', canvas.clientWidth, canvas.clientHeight);
    });
}

In this demo I have a 800px x 600px canvas to begin with. After the requestFullscreen request succeeds it logs the dimensions of the render target and the dimensions of the canvas on screen. When the fullscreen button is pressed the canvas becomes fullscreen, with black bars around it as needed to preserve the aspect ratio of the canvas.

The logged rendering dimensions remain unchanged. This means the canvas is being upscaled, which means the details from the filtering section above come in to play. The logged on screen dimensions however should be the dimensions of the display. So these dimensions don’t represent the area of the screen displaying the canvas contents, it also includes the black bars.

To take advantage of the new resolution available I need to update the width and height attribute of the canvas after it goes fullscreen:

Demo

canvas.requestFullscreen().then(() => {
    canvas.width = canvas.clientWidth;
    canvas.height = canvas.clientHeight;

    console.log('Rendering dimensions', gl.drawingBufferWidth, gl.drawingBufferHeight);
    console.log('Onscreen dimensions', canvas.clientWidth, canvas.clientHeight);

    gl.clearColor(1.0, 0.0, 1.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);
});

This demo resizes the canvas after going fullscreen, which means there should be no more black bars around the canvas. Of note, we now need to re-render the contents of the canvas - changing the render dimensions of the canvas clear the contents of the canvas. In the context of a game this probably isn’t a particularly significant detail since the render loop will be running constantly.

Fullscreen can be exited by hitting escape or via the document.exitFullscreen(). On leaving fullscreen the canvas still has its new larger size. So we need to scale the canvas back down in response to exiting fullscreen. The fullscreenchange event lets us respond to both entering and exiting fullscreen. Reworking the fullscreen function we get:

Demo

document.addEventListener('fullscreenchange', (event) => {
    if (document.fullscreenElement == null) {
        canvas.width = 800;
        canvas.height = 600;
    } else {
        canvas.width = canvas.clientWidth;
        canvas.height = canvas.clientHeight;
    }

    console.log('Rendering dimensions', gl.drawingBufferWidth, gl.drawingBufferHeight);
    console.log('Onscreen dimensions', canvas.clientWidth, canvas.clientHeight);

    gl.clearColor(1.0, 0.0, 1.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);
})

function fullscreen() {
    canvas.requestFullscreen();
}

document.fullscreenElement will be null in the handler when exiting fullscreen, so we use that restore the original resolution.

Fullpage

As a second class of fullscreen-ness, which I’m calling fullpage, is having the canvas completely fill the web page. There are a couple ways of doing this.

We could use javascript to control the canvas’s native resolution based on the changing dimensions of the document containing the canvas. This would be done via window.addEventListener('resize', ...);.

Or we can use css to control the size of the canvas, and use javascript to update the resolution of the canvas when the size changes. I’ve gone for this latter solution since it neatly supports both the fullscreen and fullpage use cases, along with any other mechanism by which the canvas size is changed.

The following css results in the canvas fully occupying the page:

Demo

html {
    height: 100%;
}

body {
    margin: 0px;
    height: 100%;
    overflow: hidden;
}

canvas {
    height: 100%;
    width: 100%;
}

ResizeObserver is used to update the canvas resolution when it changes size. This will fire on page load when the element is initially sized for the window, it’ll happen if the window containing the page is resized, and it’ll fire entering and exiting fullscreen mode.

canvasResizeObserver = new ResizeObserver(() => {
    canvas.width = canvas.clientWidth;
    canvas.height = canvas.clientHeight;

    console.log('Rendering dimensions', gl.drawingBufferWidth, gl.drawingBufferHeight);
    console.log('Onscreen dimensions', canvas.clientWidth, canvas.clientHeight);

    gl.clearColor(1.0, 0.0, 1.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);
})
canvasResizeObserver.observe(canvas);

Clicking on the canvas will enter fullscreen mode, with the onclick event calling the same function used previously:

<canvas id="main-canvas" onclick="fullscreen()"></canvas>

As before we have to rerender the contents of the canvas whenever we change its resolution. The logs will show the dimensions and resolution of the canvas changing as the window size changes and when entering and exiting fullscreen.

Resizeable Canvas

The above fullpage example allows the canvas to be resized by resizing the browser window containing the web page. It may be desireable to resize the canvas without resizing the browser window.

The corner-drag free resizing behavior of native windows isn’t something provided to HTML elements by default. To implement it roughly, I’m going to build on the approach taken by the fullpage example. The canvas will be placed in a div which we’ll be resizing. The canvas’s css will result in it automatically resizing to fill the div, and the ResizeObserver will update the resolution of the canvas. I came across this approach on this html dom page.

Demo

The HTML change is small, the canvas moves inside a container div, and it joined by a div which will act as the resize handle.

<div id="container">
    <canvas id="main-canvas"></canvas>
    <div id="resize-handle"></div>
</div>

The CSS doesn’t change for the canvas itself. The new container however gets given a size, and the resize handle is configured to be a 10px x 10px black square in the bottom right of the container.

#container {
    position: relative;
    width: 400px;
    height: 300px;
}

#resize-handle {
    position: absolute;
    bottom: 0px;
    right: 0px;
    width: 10px;
    height: 10px;
    background-color: black;
    cursor: se-resize;
}

#main-canvas {
    height: 100%;
    width: 100%;
}

Finally we add a mousedown handler to the resize event. When fired it registers a handler for mousemove and mouseup. The mousemove handler uses the mouse movement to resize the containing div. The mouseup handler just unregisters the previously registered handlers. This code only changes the display size of the canvas - ResizeObserver is still handling updating the canvas resolution and rerendering the contents.

let resizeHandle = document.getElementById('resize-handle');
resizeHandle.addEventListener('mousedown', (event) => {
    console.log('Starting Drag');

    const onMove = function (moveEvent) {
        const container = document.getElementById('container');
        const initialStyle = window.getComputedStyle(container);

        const currentW = parseInt(initialStyle.width, 10);
        const currentH = parseInt(initialStyle.height, 10);

        console.log('Drag', currentW + moveEvent.movementX, currentH + moveEvent.movementY);

        container.style.width = `${currentW + moveEvent.movementX}px`;
        container.style.height = `${currentH + moveEvent.movementY}px`;
    }

    const onMouseUp = function () {
        console.log('Finishing Drag');
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup', onMouseUp);
    }

    document.addEventListener('mousemove', onMove);
    document.addEventListener('mouseup', onMouseUp);
});

This is the first dealing with mouse input events. Depending on how these events get fed through to game logic, which is a future investigation, the details of this solution may have to be adjusted.

Embedded window

A common pattern of web games or other graphical demos is for them to be embedded in other webpages. This allows for the application to be developed independently of where it will be exposed on the web. The above fullpage technique allows us to place the demo in to an iframe, and have the demo size itself appropriately to the size of the frame its in. The following demo embeds the fullpage demo in an iframe, with specified dimensions.

Demo

<body>
    <h1>Parent Page</h1>
    <iframe src="fullpage.html" style="width:400px; height:400px; border:2px solid;"></iframe>
</body>

This approach also works if the canvas is of a fixed size. All that is required is to remove the ResizeObserver from the above example, and to set the iframe’s dimensions match that of the canvas.

Cursor Visibility

By default the cursor is visible while over the canvas. This can be controlled using the CSS property cursor, the options for which are available here. To hide the cursor we just need to set the value of that property to none; as shown below.

Demo

#main-canvas {
    cursor: none;
}

Cursor Locking

Finally, we may want to restrict the cursor to the canvas. This would be the behavior used for FPS games, or games where you move your cursor to the edge of the window to scroll around the map. Similarly to fullscreen, this is something we have to request after the page has loaded, and must come from a direct user interaction. It’s used by invoking requestPointerLock on the canvas:

Demo

    function lockcursor() {
        canvas.requestPointerLock();
    }

We can respond to chagne in the pointer lock state, by attaching a handler to the pointerlockchange event on the document:

document.addEventListener('pointerlockchange', (event) => {
    if (document.pointerLockElement == null) {
        console.log("Cursor Unlocked");
    } else {
        console.log("Cursor Locked");
    }
})

Much like with the requestFullscreen API and mode, we can exit this state by pressing escape, or by invoking document.exitPointerLock(). Of note, when the cursor becomes invisible while locked to the canvas.