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
<!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:
<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:
<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.
<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:
<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:
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:
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:
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.
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.
<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.
#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:
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.