3.0 Canvas Resizing

At this point, all of our samples have been rendered on a fixed-size canvas. However, in real applications, we often want the canvas to fully occupy the entire page or its container. Resizing a canvas to achieve this can be trickier than expected. In this tutorial, we'll explore how to accomplish this.

Launch Playground - 3_00_canvas_resizing

A canvas has two key dimensions: the framebuffer size and the display size. The framebuffer size, which we'll refer to as the rendering size, determines the size of the rendering attachment. The display size is used to present the canvas on the webpage. Our rendering applies to the framebuffer, and once rendered, we treat it as an image of the rendering size. This image is then displayed on the webpage using the display size. If the rendering size and display size do not match, the rendered result will be stretched to fit the display size.

The rendering size can be defined by specifying the width and height attributes in the HTML tag:

<canvas width="640" height="480"></canvas>

Or it can be configured in javascript:

canvas.width = 640;
canvas.height = 480;

The display size can be controlled by CSS:

<canvas style="width: 400px; height: 300px;"> </canvas>

You can also specify the canvas size to be 100% of its parent container to maximize its size. However, this adjustment affects only the display size, not the rendering size. It is up to developers to synchronize these two sizes. To update the rendering size, we first need to capture the canvas resizing event. This can be achieved using the ResizeObserver.

let timeId = null;
const resizeObserver = new ResizeObserver((entries) => {
    if (timeId) {
        clearTimeout(timeId);
    }
    timeId = setTimeout(() => {
        requestAnimationFrame(render);
    }, 100);
});
requestAnimationFrame(render);
resizeObserver.observe(canvas);

The ResizeObserver allows you to specify a callback function that is triggered whenever the canvas is resized. This callback should initiate the rendering of a new frame. In this new frame, you should adjust the rendering size and update the framebuffer and depth buffer accordingly.

A straightforward approach might involve calling requestAnimationFrame directly. However, this could lead to excessive calls if resizing events occur frequently. For instance, resizing the window triggers a series of events, and immediately rerendering the scene might be unnecessary since the final window size may not be settled until resizing stops. Frequent rendering during resizing could also make the process feel sluggish.

In the above code snippet, we throttle the rerendering by using a timer. The ResizeObserver callback applies a 100ms delay to requestAnimationFrame. If another resizing event occurs before the timeout expires, the timeout is reset. This approach helps batch the execution of rerendering and improves performance during resizing.

const devicePixelRatio = window.devicePixelRatio || 1;
let currentCanvasWidth = canvas.clientWidth * devicePixelRatio;
let currentCanvasHeight = canvas.clientHeight * devicePixelRatio;
let projectionMatrixUniformBufferUpdate = null;
if (depthTexture === null || currentCanvasWidth != canvas.width || currentCanvasHeight != canvas.height) {
    canvas.width = currentCanvasWidth;
    canvas.height = currentCanvasHeight;

    const depthTextureDesc = {
        size: [canvas.width, canvas.height, 1],
        dimension: '2d',
        format: 'depth24plus-stencil8',
        usage: GPUTextureUsage.RENDER_ATTACHMENT
    };

    if (depthTexture !== null) {
        depthTexture.destroy();
    }

    depthTexture = device.createTexture(depthTextureDesc);
    let depthTextureView = depthTexture.createView();

    depthAttachment = {
        view: depthTextureView,
        depthClearValue: 1,
        depthLoadOp: 'clear',
        depthStoreOp: 'store',
        stencilClearValue: 0,
        stencilLoadOp: 'clear',
        stencilStoreOp: 'store'
    };

    let projectionMatrix = glMatrix.mat4.perspective(glMatrix.mat4.create(),
        1.4, canvas.width / canvas.height, 0.1, 1000.0);

    projectionMatrixUniformBufferUpdate = createGPUBuffer(device, projectionMatrix, GPUBufferUsage.COPY_SRC);

}

In the rendering function, we first obtain the pixel density using window.devicePixelRatio. On high-DPI screens, such as those on a MacBook, this value can be 2, indicating that we can fit 4 times more pixels into the same physical area compared to standard pixels. To achieve finer rendering, we can create a framebuffer that is 4 times the size of the canvas. On most conventional monitors, this value is simply 1.0, and in this case, we set the framebuffer size to match the canvas size.

When the canvas size changes, such as when the old canvas.width does not match the new size, several resources need to be updated.

First, update the depth texture. While the canvas automatically maintains the color rendering target, the depth buffer is managed manually. Therefore, when the canvas size changes, you need to release the old depth map and create a new one with the updated dimensions.

Second, update the projection matrix. The projection matrix depends on the canvas's aspect ratio, so it needs to be adjusted when the canvas size changes. Since a uniform buffer cannot be both MAP_WRITE and MAP_READ simultaneously, you need to use a staging buffer for this process. Load the updated matrix into the staging buffer first and then copy it to the uniform buffer.

Leave a Comment on Github