Getting Started in WebGL, Part 4: WebGL Viewport and Clipping

In the previous parts of this series, we learned much about shaders, the canvas element, WebGL contexts, and how the browser alpha-composites our color buffer over the rest of the page elements.

Getting Started in WebGL, Part 4: WebGL Viewport and Clipping

In this article, we continue writing our WebGL boilerplate code. We are still preparing our canvas for WebGL drawing, this time taking viewports and primitives clipping into account.

This article is part of the “Getting Started in WebGL” series. If you haven’t read the previous parts, I recommend reading them first:

  1. Introduction to Shaders
  2. The Canvas Element for Our First Shader
  3. WebGL Context and Clear

Recap

  • In the first article of this series, we wrote a simple shader that draws a colorful gradient and fades it in and out slightly.
  • In the second article of this series, we started working towards using this shader in a webpage. Taking small steps, we explained the necessary background of the canvas element.
  • In the third article, we acquired our WebGL context and used it to clear the color buffer. We also explained how the canvas blends with the other elements of the page.

In this article, we continue from where we left, this time learning about WebGL viewports and how they affect primitives clipping.

Next in this series—if Allah wills—we’ll compile our shader program, learn about WebGL buffers, draw primitives, and actually run the shader program we wrote in the first article. Almost there!

Canvas Size

This is our code so far:

Note that I’ve restored the CSS background color to black and the clear-color to opaque red.

Thanks to our CSS, we have a canvas that stretches to fill our webpage, but the underlying 1×1 drawing buffer is hardly useful. We need to set a proper size for our drawing buffer. If the buffer is smaller than the canvas, then we are not making full use of the device’s resolution and are subject to scaling artifacts (as discussed in a previous article). If the buffer is larger than the canvas, well, the quality actually benefits a lot! It’s because of the super-sampling anti-aliasing the browser applies to downscale the buffer before it’s handed over to the compositor.

However, the performance takes a good hit. If anti-aliasing is desired, it’s better achieved through MSAA (multi-sampling anti-aliasing) and texture filtering. For now, we should aim at a drawing buffer of the same size of our canvas to make full use of the device’s resolution and avoid scaling altogether.

To do this, we’ll borrow the adjustCanvasBitmapSize from part 2 (with some modifications):

function adjustDrawingBufferSize() {

    var canvas = glContext.canvas;
    var pixelRatio = window.devicePixelRatio ? window.devicePixelRatio : 1.0;
    
    // Checking width and height individually to avoid two resize operations if only 
    // one was needed. Since this function was called, then at least on of them was
    // changed,
    if (canvas.width  != Math.floor(canvas.clientWidth  * pixelRatio)) canvas.width  = pixelRatio * canvas.clientWidth ;
    if (canvas.height != Math.floor(canvas.clientHeight * pixelRatio)) canvas.height = pixelRatio * canvas.clientHeight;
    
    // Set the new viewport dimensions,
    glContext.viewport(0, 0, glContext.drawingBufferWidth, glContext.drawingBufferHeight);
}

Changes:

  • We used clientWidth and clientHeight instead of offsetWidth and offsetHeight. The latter ones include the canvas borders, so they may not be exactly what we are looking for. clientWidth and clientHeight are more suited for this purpose. My bad!
  • adjustDrawingBufferSize is now scheduled to run only if changes took place. Therefore, we needn’t explicitly check and abort if nothing changed.
  • We no longer need to call drawScene every time the size changes. We’ll make sure it’s called on a regular basis somewhere else.
  • A glContext.viewport appeared! It gets its own section, so let it pass for now!

We’ll also borrow the resize events throttling function, onWindowResize (with some modifications too):

function onCanvasResize() {

    // Compute the dimensions in physical pixels,
    var canvas = glContext.canvas;
    var pixelRatio = window.devicePixelRatio ? window.devicePixelRatio : 1.0;

    var physicalWidth  = Math.floor(canvas.clientWidth  * pixelRatio);
    var physicalHeight = Math.floor(canvas.clientHeight * pixelRatio);

    // Abort if nothing changed,
    if ((onCanvasResize.targetWidth  == physicalWidth ) && 
        (onCanvasResize.targetHeight == physicalHeight)) {
        return;
    }

    // Set the new required dimensions,
    onCanvasResize.targetWidth  = physicalWidth ;
    onCanvasResize.targetHeight = physicalHeight;

    // Wait until the resizing events flood settles,
    if (onCanvasResize.timeoutId) window.clearTimeout(onCanvasResize.timeoutId);
    onCanvasResize.timeoutId = window.setTimeout(adjustDrawingBufferSize, 600);
}

Changes:

  • It’s now onCanvasResize instead of onWindowResize. It’s ok in our example to assume that the canvas size changes only when the window size is changed, but in the real world, our canvas can be a part of a page where other elements exist, elements that are resizable and affect our canvas size.
  • Instead of listening to the events related to changes in canvas size, we’ll just check for changes every time we are about to redraw the canvas contents. In other words, onCanvasResize gets called whether changes occurred or not, so aborting when nothing has changed is necessary.

Now, let’s call onCanvasResize from drawScene:

function drawScene() {

    // Handle canvas size changes,
    onCanvasResize();
              
    // Clear the color buffer,
    glContext.clear(glContext.COLOR_BUFFER_BIT);
}

I mentioned that we’ll be calling drawScene regularly. This means that we are rendering continuously, not only when changes occur (aka when dirty). Drawing continuously consumes more power than drawing only when dirty, but it saves us the trouble of having to track when the contents have to be updated.

But it’s worth considering if you are planning to make an application that runs for extended periods of time, like wallpapers and launchers (but you wouldn’t do these in WebGL to begin with, would you?). Therefore, for this tutorial, we’ll be rendering continuously. The easiest way to do it is by scheduling re-running drawScene from within itself:

function drawScene() {

    ... stuff ...

    // Request drawing again next frame,
    window.requestAnimationFrame(drawScene);
}

No, we didn’t use setInterval or setTimeout for this. requestAnimationFrame tells the browser that you wish to perform an animation and requests calling drawScene before the next repaint. It’s the most suitable for animations among the three, because:

  • The timings of setInterval and setTimeout are often not honored precisely—they are best-effort based. With requestAnimationFrame, the timing will generally match the display refresh rate.
  • If the scheduled code contains changes in page contents layout, setInterval and setTimeout could cause layout-thrashing (but that’s not our case). requestAnimationFrame takes care of that and doesn’t trigger unnecessary reflow and repaint cycles.
  • Using requestAnimationFrame allows the browser to decide how often to call our animation/drawing function. This means it can throttle it down if the page/iframe becomes hidden or inactive, which means more battery life for mobile devices. This also happens with setInterval and setTimeout in several browsers (FirefoxChrome)—just pretend you don’t know!

Back to our page. Now, our resizing mechanism is complete:

  • drawScene is being called regularly, and it calls onCanvasResize every time.
  • onCanvasResize checks the canvas size, and if changes took place, schedules an adjustDrawingBufferSize call, or postpones it if it was already scheduled.
  • adjustDrawingBufferSize actually changes the drawing buffer size, and sets the new viewport dimensions while at it.

Putting everything together:

I’ve added an alert that pops up every time the drawing buffer is resized. You may want to open the above sample in a new tab and resize the window or change the device orientation to test it. Notice that it only resizes when you’ve stopped resizing for 0.6 seconds (as if you’ll measure that!).

One last remark before we end this buffer resizing thing. There are limits to how large a drawing buffer can be. These depend on the hardware and browser in use. If you happen to be:

  • using a smartphone, or
  • a ridiculously high-resolution screen, or
  • have multiple monitors/work-spaces/virtual desktops set, or
  • are using a smartphone, or
  • are viewing your page from within a very large iframe (which is the easiest way to test this), or
  • are using a smartphone

there’s a chance that the canvas gets resized to more than the possible limits. In such case, the canvas width and height will show no objections, but that actual buffer size will be clamped to the maximum possible. You can get the actual buffer size using the read-only members glContext.drawingBufferWidth and glContext.drawingBufferHeight, which I used to construct the alert.

Other than that, everything should work fine… except that on some browsers, parts of what you draw (or all of it) may actually never end up on the screen! In this case, adding these two lines to adjustDrawingBufferSize after resizing might be worthwhile:

if (canvas.width  != glContext.drawingBufferWidth ) canvas.width  = glContext.drawingBufferWidth ;
if (canvas.height != glContext.drawingBufferHeight) canvas.height = glContext.drawingBufferHeight;

Now we are back to where stuff makes sense. But note that clamping to drawingBufferWidth and drawingBufferHeight may not be the best action. You might want to consider maintaining a certain aspect ratio.

Now let’s do some drawing!

Viewport and Scissoring

// Set the new viewport dimensions,
glContext.viewport(0, 0, glContext.drawingBufferWidth, glContext.drawingBufferHeight);

Remember in the first article of this series when I mentioned that inside the shader, WebGL uses the coordinates (-1, -1) to represent the lower left corner of your viewport, and (1, 1) to represent the upper right corner? That’s it. viewport tells WebGL which rectangle in our drawing buffer should be mapped to (-1, -1) and (1, 1). It’s just a transformation, nothing more. It doesn’t affect buffers or anything.

I also said that anything outside the viewport dimensions is skipped and is not drawn altogether. That’s almost entirely true, but has a twist to it. The trick lies in the words “drawn” and “outside”. What really counts as drawing or as outside?

// Restrict drawing to the left half of the canvas,
glContext.viewport(0, 0, glContext.drawingBufferWidth / 2, glContext.drawingBufferHeight);

This line limits our viewport rectangle to the left half of the canvas. I’ve added it to the drawScene function. We usually don’t need to call viewport except when the canvas size changes, and we actually did it there. You can delete the one in the resize function, but I’ll just leave it be. In practice, try to minimize your WebGL calls as much as you can. Let’s see what this line does:

Oh, clear(glContext.COLOR_BUFFER_BIT) totally ignored our viewport settings! That’s what it does, duh! viewport has no effect on clear calls at all. What the viewport dimensions affect is the clipping of primitives. Remember in the first article, I said that we can only draw points, lines and triangles in WebGL. These will be clipped against the viewport dimensions the way you think they are … except points.

Points

A point is drawn entirely if its center lies within the viewport dimensions, and will be omitted entirely if its center lies outside them. If a point is fat enough, its center can still be inside the viewport while a part of it extends outside. This extending part should be drawn. That’s how it should be, but that’s not necessarily the case in practice:

You should see something that resembles this if your browser, device and drivers stick to the standard (in this regard):

Getting Started in WebGL, Part 4: WebGL Viewport and Clipping

The points’ size depends on your device’s actual resolution, so don’t mind the difference in size. Just pay attention to how much of the points appear. In the above sample, I’ve set the viewport area to the middle section of the canvas (the area with the gradient), but since the points’ centers are still inside the viewport, they should be entirely drawn (the green things). If this is the case in your browser, then great! But not all users are that lucky. Some users will see the outside parts trimmed, something like this:

Getting Started in WebGL, Part 4: WebGL Viewport and Clipping

Most of the time, it really makes no difference. If the viewport is going to cover the entire canvas, then we don’t care whether the outsides will be trimmed or not. But it would matter if these points were moving smoothly heading outside the canvas, and then they suddenly disappeared because their centers went outside:

(Press Result to restart the animation.)

Again, this behavior is not necessarily what you see. According to history, Nvidia devices won’t clip the points when their centers go outside, but will trim the parts that go outside. On my machine (using an AMD device), Chrome, Firefox and Edge behave the same way when run on Windows. However, on the same machine, Chrome and Firefox will clip the points and won’t trim them when run on Linux. On my Android phone, Chrome and Firefox will both clip and trim the points!

Scissoring

It seems that drawing points is bothersome. Why even care? Because points needn’t be circular. They are axis-aligned rectangular regions. It’s the fragment shader that decides how to draw them. They can be textured, in which case they are known as point-sprites. These can be used to make plenty of stuff, like tile-maps and particle effects, in which they are really handy since you only need to pass one vertex per sprite (the center), instead of four in the case of a triangle-strip. Reducing the amount of data transferred from the CPU to the GPU can really pay off in complex scenes. In WebGL 2, we can use geometry instancing (which has its own catches), but we are not there yet.

So, how do we deal with points clipping? To get the outer parts trimmed, we use scissoring:

function initializeState() {

    ...
    
    // Enable scissoring,
    glContext.enable(glContext.SCISSOR_TEST);
}

Scissoring is now enabled, so here’s how to set the scissored region:

function adjustDrawingBufferSize() {

    ...
    
    // Set the new scissor box,
    glContext.scissor(xInPixels, yInPixels, widthInPixels, heightInPixels);
}

While primitives’ positions are relative to the viewport dimensions, the scissor box dimensions are not. They specify a raw rectangle in the drawing buffer, not minding how much it overlaps the viewport (or not). In the following sample, I’ve set the viewport and scissor box to the middle section of the canvas:

(Press Result to restart the animation.)

Note that the scissor test is a per-sample operation that discards the fragments that fall outside the test box. It has nothing to do with what’s being drawn; it just discards the fragments that go outside. Even clear respects the scissor test! That’s why the blue color (the clear color) is bound to the scissor box. All that remains is to prevent the points from disappearing when their centers go outside. To do this, I’ll make sure the viewport is larger than the scissor box, with a margin that allows the points to still be drawn until they are completely outside the scissor box:

(Press Result to restart the animation.)

Yay! This should work nicely everywhere. But in the above code, we only used a part of the canvas to do the drawing. What if we wanted to occupy the whole canvas? It really makes no difference. The viewport can be larger than the drawing buffer without problems (just ignore Firefox’s ranting about it in the console output):

function adjustDrawingBufferSize() {

    ...

    // Set the new viewport dimensions,
    var pointSize = 150;
    glContext.viewport(
            -0.5 * pointSize, -0.5 * pointSize, 
            glContext.drawingBufferWidth + pointSize, glContext.drawingBufferHeight + pointSize);

    // Set the new scissor box,
    glContext.scissor(0, 0, glContext.drawingBufferWidth, glContext.drawingBufferHeight);
}

See:

Be mindful of the viewport size, though. Even if the viewport is but a transformation that costs you no resources, you don’t want to rely on per-sample clipping alone. Consider changing the viewport only when needed, and restore it for the rest of the drawing. And remember that the viewport affects the position of the primitives on the screen, so account for this as well.

That’s it for now! Next time, let’s put the whole size, viewport and clipping things behind us. On to drawing some triangles! Thanks for reading so far, and I hope it was helpful.

References