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 read the 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!