Getting Started in WebGL, Part 2: The Canvas Element for Our First Shader

Datetime:2016-08-22 23:11:06          Topic: WebGL           Share

In the previous article, we wrote our first vertex and fragment shaders. Having written the GPU-side code, it's time to learn how to write the CPU-side one. In this tutorial and the next one, I'll show you how to incorporate shaders into your WebGL application. We'll start from scratch, using JavaScript only and no third-party libraries. In this part, we'll cover the canvas-specific code. In the next one, we'll cover the WebGL-specific one.

Note that these articles:

  • assume you are familiar with GLSL shaders. If not, please readthe first article.
  • are not intended to teach you HTML, CSS, or JavaScript. I'll try to explain the tricky concepts as we encounter them, but you'll have to look for more information about them on the web. The MDN  (Mozilla Developer Network) is an excellent place to do so.

Let's start already!

What Is WebGL?

WebGL 1.0 is a low-level 3D graphics API for the web, exposed through the HTML5 Canvas element. It's a shader-based API that is very similar to the OpenGL ES 2.0 API. WebGL 2.0 is the same, but is based on OpenGL ES 3.0 instead. WebGL 2.0 is not entirely backward compatible with WebGL 1.0, but most error-free WebGL 1.0 applications that don't use extensions should work on WebGL 2.0 without problems.

At the time of writing this article, WebGL 2.0 implementations are still experimental in the few browsers that do implement it. They are also not enabled by default. Therefore, the code we'll write in this series is targeted at WebGL 1.0.

Take a look at the following example (remember to switch tabs and take a few glances at the code as well):

This is the code we are going to write. Yeah, it actually takes a little more than a hundred lines of JavaScript to implement something this simple. But don't worry, we'll take our time explaining them so that they all make sense at the end. We'll cover the canvas-related code in this tutorial and continue to the WebGL-specific code in the next one.

The Canvas

First, we need to create a canvas where we'll show our rendered stuff.

This cute little square is our canvas! Switch to the HTML view and let's see how we made it.

<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">

This is to tell the browser that we don't want our page to be zoomable on mobile devices.

<canvas width="30" height="30"></canvas>

And this is our canvas element. If we didn't assign dimensions to our canvas, it would have defaulted to 300*150px (CSS pixels). Now switch to the CSS view to check how we styled it.

canvas { ... }

This is a CSS selector. This particular one means that the following rules are going to be applied to all the canvas elements in our document.

background: #0f0;

Finally, the rule to be applied to the canvas elements. The background is set to bright green ( #0f0 ).

Note: in the above editor, the CSS text is attached to the document automatically. When making your own files, you'll have to link to the CSS file in your HTML file like this:

<link rel="stylesheet" href="[filename].css">

Preferably, put it in the head tag.

Now that the canvas is ready, it's time to draw some stuff! Unfortunately, while the canvas up there looks nice and all, we still have a long way to go before we can draw anything using WebGL. So scrap WebGL! For this tutorial, we'll do a simple 2D drawing to explain some concepts before switching to WebGL. Let our drawing be a diagonal line.

Rendering Context

The HTML is the same as the last example, except for this line:

<canvas id="canvas" width="30" height="30"></canvas>

in which we've given an id to the canvas element so we can easily retrieve it in JavaScript. The CSS is exactly the same and a new JavaScript tab was added to perform the drawing.

Switch to the JS tab,

window.addEventListener('load', function() { ... });

In the above example, the JavaScript code we've written is to be attached to the document head, meaning that it runs before the page finishes loading. But if so, we won't be able to draw to the canvas, which has yet to be created. That's why we defer running our code till after the page loads. To do this, we use window.addEventListener , specifying load as the event we want to listen to and our code as a function that runs when the event is triggered.

Moving on:

var canvas = document.getElementById("canvas");

Remember the id we assigned to the canvas earlier in the HTML? Here is where it becomes useful. In the above line we retrieve the canvas element from the document using its id as a reference. From now on, things get more interesting,

context = canvas.getContext('2d');

In order to be able to do any drawing on the canvas, we first have to acquire a drawing context. A context in this sense is a helper object that exposes the required drawing API and ties it to the canvas element. This means that any subsequent usage of the API using this context will be performed on the canvas object in question.

In this particular case, we requested a 2d drawing context ( CanvasRenderingContext2D ) which allows us to use arbitrary 2D drawing functions. We could have requested a webgl , a webgl2 or a bitmaprenderer contexts instead, each of which would have exposed a different set of functions.

A canvas always has its context mode set to none initially. Then, by calling getContext , its mode changes permanently. No matter how many times you call getContext on a canvas, it won't change its mode after it has been initially set. Calling getContext again for the same API will return the same context object returned upon first usage. Calling getContext for a different API will return null .

Unfortunately, things can go wrong. In some particular cases, getContext may be unable to create a context and would fire an exception instead. While this is pretty rare nowadays, it's possible with 2d contexts. So instead of crashing if this happens, we encapsulated our code into a try-catch block:

try {
    context = canvas.getContext('2d');
} catch (exception) {
    alert("Umm... sorry, no 2d contexts for you! " + exception.message);
    return ;
}

This way, if an exception is thrown, we can catch it and display an error message, and then proceed gracefully to hit our heads against the wall. Or maybe display a static image of a diagonal line. While we could do that, it defies the goal of this tutorial!

Assuming we've successfully acquired a context, all there is left to do is draw the line:

context.beginPath();

The 2d context remembers the last path you constructed. Drawing a path doesn't automatically discard it from the context's memory. beginPath tells the context to forget any previous paths and start fresh. So yeah, in this case, we could have omitted this line altogether and it would have worked flawlessly, since there were no previous paths to begin with.

context.moveTo(0, 0);

A path may consist of multiple sub-paths. moveTo starts a new sub-path at the required coordinates.

context.lineTo(30, 30);

Creates a line segment from the last point on the sub-path to (30, 30) . This means a diagonal line from the upper-left corner of the canvas (0, 0) to its bottom-right corner (30, 30).

context.stroke();

Creating a path is one thing; drawing it is another. stroke tells the context to draw all the sub-paths in its memory.

beginPath , moveTo , lineTo , and stroke are available only because we requested a 2d context. If, for example, we requested a webgl context, these functions wouldn't have been available.

Note: in the above editor, the JavaScript code is attached to the document automatically. When making your own files, you'll have to link to the JavaScript file in your HTML file like this:

<script src="[filename].js" type="text/javascript" charset="utf-8"></script>

You should put it in the head tag.

This concludes our line drawing tutorial! But somehow, I'm not satisfied with this tiny canvas. We can do bigger than this!

Canvas Sizing

We shall add a few rules to our CSS to make the canvas fill the entire page. The new CSS code is going to look like this:

html, body { height: 100%; }
body { margin: 0; }

canvas { 
    display: block;
    width: 100%; height: 100%; 
    background: #888; 
}

Let's take it apart:

html, body { height: 100%; }

The html and body elements are treated like block elements; they consume the entire available width. However, they expand vertically just enough to wrap their contents. In other words, their heights depend on their children's heights. Setting one of their children's heights to a percentage of their height will cause a dependency loop. So, unless we explicitly assign values to their heights, we wouldn't be able to set the children heights relative to them.

Since we want the canvas to fill the entire page (set its height to 100% of its parent), we set their heights to 100% (of the page height).

body { margin: 0; }

Browsers have basic style sheets that give a default style to any document they render. It's called the user-agent stylesheets . The styles in these sheets depend on the browser in question. Sometimes they can even be adjusted by the user.

The body element usually has a default margin in the user-agent stylesheets. We want the canvas to fill the entire page, so we set its margins to 0 .

canvas { 
    display: block;

Unlike block elements, inline elements are elements that can be treated like text on a regular line. They can have elements before or after them on the same line, and they have an empty space below them whose size depends on the font and font size in use. We don't want any empty space below our canvas, so we just set its display mode to block .

width: 100%; height: 100%;

As planned, we set the canvas dimensions to 100% of the page width and height.

background: #888;

We already explained that before, didn't we?!

Behold the result of our changes...

...

...

No, we didn't do anything wrong! This is totally normal behavior. Remember the dimensions we gave to the canvas in the HTML tag?

<canvas id="canvas" width="30" height="30"></canvas>

Now we've gone and given the canvas other dimensions in the CSS:

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

Turns out that the dimensions we set in the HTML tag control the intrinsic dimensions of the canvas. The canvas is more or less a bitmap container. The bitmap dimensions are independent on how the canvas is going to be displayed in its final position and dimensions in the page. What defines these are the extrinsic dimensions , those we set in the CSS.

As we can see, our tiny 30*30 bitmap has been stretched to fill the entire canvas. This is controlled by the CSS object-fit property, which defaults to fill . There are other modes that, for example, clip instead of scale, but since fill won't get into our way (actually it can be useful), we'll just leave it be. If you are planning to support Internet Explorer or Edge, then you can't do anything about it anyway. At the time of writing this article, they don't support object-fit at all.

However, be aware that how the browser scales the content is still a matter of debate. The CSS property image-rendering was proposed to handle this, but it's still experimental (if supported at all), and it doesn't dictate certain scaling algorithms. Not just that, the browser can choose to neglect it entirely since it's just a hint. What this means is that, for the time being, different browsers will use different scaling algorithms to scale your bitmap. Some of these have really terrible artifacts, so don't scale too much.

Whether we are drawing using a 2d context or other types of contexts (like webgl ), the canvas behaves almost the same. If we want our small bitmap to fill the entire canvas and we don't like stretching, then we should watch for the canvas size changes and adjust the bitmap dimensions accordingly. Let's do that now,

Looking at the changes we made, we've added these two lines to the JavaScript:

canvas.width  = canvas.offsetWidth ;
canvas.height = canvas.offsetHeight;

Yeah, when using 2d contexts, setting the internal bitmap dimensions to the canvas dimensions is that easy! The canvas width and height are monitored, and when any of them is written to (even if it's the same value):

  • The current bitmap is destroyed.
  • A new one with the new dimensions is created.
  • The new bitmap is initialized with the default value (transparent black).
  • Any associated context is cleared back to its initial state and is reinitialized with the newly specified coordinate space dimensions.

Notice that, to set both the width and height , the above steps are carried out twice ! Once when changing width and the other when changing height . No, there is no other way to do it, not that I know of.

We've also extended our short line to become the new diagonal,

context.lineTo(canvas.width, canvas.height);

instead of:

context.lineTo(30, 30);

Since we no longer use the original 30*30 dimensions, they are no longer needed in the HTML:

<canvas id="canvas"></canvas>

We could have left them initialized to very small values (like 1*1) to save the overhead of creating a bitmap using the relatively large default dimensions (300*150), initializing it, deleting it, and then creating a new one with the correct size we set in JavaScript.

...

on second thought, let's just do that!

<canvas id="canvas" width="1" height="1"></canvas>

Nobody should ever notice the difference, but I can't bear the guilt!

CSS Pixel vs. Physical Pixel

I would have loved to say that's it, but it's not! offsetWidth and offsetHeight are specified in CSS pixels. 

Here's the catch. CSS pixels are not physical pixels. They are density-independent pixels. Depending on your device's physical pixels density (and your browser), one CSS pixel may correspond to one or more physical pixels.

Putting it blatantly, if you have a Full-HD 5-inch smartphone, then offsetWidth * offsetHeight would be 640*360 instead of 1920*1080. Sure, it fills the screen, but since the internal dimensions are set to 640*360, the result is a stretched bitmap that doesn't make full use of the device's high resolution. To fix this, we take into account the devicePixelRatio :

var pixelRatio = window.devicePixelRatio ? window.devicePixelRatio : 1.0;

canvas.width  = pixelRatio * canvas.offsetWidth ;
canvas.height = pixelRatio * canvas.offsetHeight;

devicePixelRatio is the ratio of the CSS pixel to the physical pixel. In other words, how many physical pixels a single CSS pixel represents.

var pixelRatio = window.devicePixelRatio ? window.devicePixelRatio : 1.0;

window.devicePixelRatio is well supported in most modern browsers, but just in case it is undefined, we fall back to the default value of 1.0 .

canvas.width  = pixelRatio * canvas.offsetWidth ;
canvas.height = pixelRatio * canvas.offsetHeight;

By multiplying the CSS dimensions with the pixel ratio, we are back to the physical dimensions. Now our internal bitmap is exactly the same size as the canvas and no stretching will occur.

If your devicePixelRatio is 1 then there won't be any difference. However, for any other value, the difference is significant.

Responding to Size Changes

That's not all there is to handling canvas sizing. Since we've specified our CSS dimensions relative to the page size, changes in the page size do affect us. If we are running on a desktop browser, the user may resize the window manually. If we are running on a mobile device, we are subject to orientation changes. Not mentioning that we may be running inside an iframe that changes its size arbitrarily. To keep our internal bitmap sized correctly at all times, we have to watch for changes in the page (window) size,

We've moved our bitmap resizing code:

// Get the device pixel ratio,
var pixelRatio = window.devicePixelRatio ? window.devicePixelRatio : 1.0;

// Adjust the canvas size,
canvas.width  = pixelRatio * canvas.offsetWidth ;
canvas.height = pixelRatio * canvas.offsetHeight;

To a separate function, adjustCanvasBitmapSize :

function adjustCanvasBitmapSize() {

    // Get the device pixel ratio,
    var pixelRatio = window.devicePixelRatio ? window.devicePixelRatio : 1.0;

    if ((canvas.width  / pixelRatio) != canvas.offsetWidth ) canvas.width  = pixelRatio * canvas.offsetWidth ;
    if ((canvas.height / pixelRatio) != canvas.offsetHeight) canvas.height = pixelRatio * canvas.offsetHeight;
}

with a little modification. Since we know how expensive assigning values to width or height is, it would be irresponsible to do so needlessly. Now we only set width and height when they actually change.

Since our function accesses our canvas, we'll declare it where it can see it. Initially, it was declared in this line:

var canvas = document.getElementById("canvas");

This makes it local to our anonymous function . We could have just removed the var part and it would have become global (or more specifically, a property of the global object , which can be accessed through window ):

canvas = document.getElementById("canvas");

However, I strongly advise against implicit declaration . If you always declare your variables, you'll avoid lots of confusion. So instead, I'm going to declare it outside all functions:

var canvas;
var context;

This also makes it a property of the global object (with a little difference that doesn't really bother us). There are other ways of making a global variable—check them out in this StackOverflow thread

Oh, and I have sneaked context up there as well! This will prove useful later.

Now, let's hook our function to the window resize event:

window.addEventListener('resize', adjustCanvasBitmapSize);

From now on, whenever the window size is changed, adjustCanvasBitmapSize is called. But since the window size event is not thrown upon initial loading, our bitmap will still be 1*1. Therefore, we have to call adjustCanvasBitmapSize once by ourselves.

adjustCanvasBitmapSize();

This pretty much takes care of it... except that when you resize the window, the line disappears! Try it in this demo .

Luckily, this is to be expected. Remember the steps carried on when the bitmap is resized? One of them was to initialize it to transparent black. This is what happened here. The bitmap was overwritten with transparent black, and now the canvas green background shines through. This happens because we only draw our line once at the beginning. When the resize event takes place, the contents are cleared and not redrawn. Fixing this should be easy. Let's move drawing our line to a separate function:

function drawScene() {

    // Draw our line,
    context.beginPath();
    context.moveTo(0, 0);
    context.lineTo(canvas.width, canvas.height);
    context.stroke();
}

and call this function from within adjustCanvasBitmapSize :

// Redraw everything again,
drawScene();

However, this way our scene will be redrawn whenever adjustCanvasBitmapSize is called, even if no change in dimensions took place. To handle this, we'll add a simple check:

// Abort if nothing changed,
if (((canvas.width  / pixelRatio) == canvas.offsetWidth ) &&
   ((canvas.height / pixelRatio) == canvas.offsetHeight)) {
    return ;
}

Check out the final result:

Try resizing it here .

Throttling Resize Events

So far we are doing great! Yet, resizing and redrawing everything can easily become very expensive when your canvas is fairly large and/or when the scene is complicated. Moreover, resizing the window with the mouse can trigger resizing events at a high rate. That's why we'll throttle it. Instead of:

window.addEventListener('resize', adjustCanvasBitmapSize);

we'll use:

window.addEventListener('resize', function onWindowResize(event) {   

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

First,

window.addEventListener('resize', function onWindowResize(event) { ... });

instead of directly calling adjustCanvasBitmapSize when the resize event is fired, we used a function expression to define the desired behavior. Unlike the function we used earlier for the load event, this function is a named function . Giving a name to the function allows to easily refer to it from within the function itself.

if (onWindowResize.timeoutId) window.clearTimeout(onWindowResize.timeoutId);

Just like other objects, properties can be added to function objects . Initially, timeoutId is undefined , thus, this statement is not executed. Be careful though when using undefined and null in logical expressions , because they can be tricky. Read more about them in the  ECMAScript Language Specification .

Later, timeoutId will hold the timeoutID of an adjustCanvasBitmapSize timeout:

onWindowResize.timeoutId = window.setTimeout(adjustCanvasBitmapSize, 600);

This delays calling adjustCanvasBitmapSize for 600 milliseconds after the event is fired. But it doesn't prevent the event from firing. If it isn't fired again within these 600 milliseconds, then adjustCanvasBitmapSize is executed and the bitmap is resized. Otherwise, clearTimeout cancels the scheduled adjustCanvasBitmapSize and setTimeout schedules another one 600 milliseconds in the future. The result is, as long as the user is still resizing the window, adjustCanvasBitmapSize is not called. When the user stops or pauses for a while, it is called. Go ahead, try it:

Err... I mean, here .

Why 600 milliseconds? I think it's not too fast and not too slow, but more than anything else, it works well with entering/leaving fullscreen animations, which is out of the scope of this tutorial.

This concludes our tutorial for today! We've covered all the canvas-specific code we need to set up our canvas. Next time—if Allah wills—we'll cover the WebGL-specific code and actually run the shader. Till then, thanks for reading!

References





About List