Anton Gerdelan. 18 May 2015.
Web applications are attractive because they are an excellent way to share your work. All the user needs to do is open a web link. WebGL is a browser-implemented interface that gives web applications access to powerful hardware-accelerated rendering. No plug-ins or installation are required. WebGL is based on the mobile version of OpenGL; "OpenGL ES", which does not require the very latest graphics hardware, it's very portable, and will run on older desktops and most mobile devices as well. And, even better, you don't need to make a special build for each operating system. You will probably find WebGL to be several times faster to create programmes with than the desktop and mobile equivalents.
I am going to explain how I made the rotating character at the top of this page, and introduce basic start-up, use of JavaScript, loading textures, shaders, meshes and file loading, and matrix manipulation. To avoid redundant tutorial content I'm going to assume that you've done at least the basics of modern OpenGL programming already - this means you have an idea about shaders and vertex buffers. I will assume that the reader is familiar with basic HTML, but not necessarily with JavaScript or newer web interfaces. We will not be using any frameworks or high-level interfaces, as there are ample resources for these already.
You will find yourself switching between several languages when writing WebGL software.
Language | Rôle |
---|---|
HTML5 | Write a web page with canvas rectangle to render in. |
JavaScript | Call GL functions, load assets using AJAX, handle user input, write main logic. | GLSL | Write shaders to define the style of rendering. |
We will just write some basic HTML, using only one or two of the newer features from 5. JavaScript has a file loading interface commonly referred to as "AJAX" (asynchronous JavaScript and XML), as it was designed to serialise XML files into JavaScript object, but we will use it as a generic file loader that returns strings containing file contents. The shader language, with one or two very minor differences, is the same as that for OpenGL ES, with WebGL 1.0 based on the OpenGL ES 2.0 specification, and WebGL 2.0 based on ES 3.0.
We can start by making a very simple HTML web-page.
The basic concept with WebGL software is that we write a normal web-page, and
use the new HTML5 canvas
tag to define an area of the page (a blank
rectangle) that our rendering will draw to. You can use any of HTML's forms,
text areas, and other elements to interact with your visualisation - we can
say that we get a user interface library built-in.
I started with something like this:
If you open this in a browser you won't see anything - just a space where the default 300x150 pixel canvas sits. We will attach a GL context to it shortly. To do that we add some JavaScript, which will talk to our web document.
width
and height
attributes to specify
the actual size of the canvas on the page.
We leverage the DOM
(document object model) to access web page elements from our JavaScript code.
This is as simple as giving each element's HTML tag an id="my_thingy"
attribute. The browser also has a BOM (browser object model) which
provides built-in functions to handle user interaction via mouse,
gamepad, or keyboard.
JavaScript replaces C as the main interface language to the graphics library. If you've never used JavaScript before then you should know that it has nothing to do with the Java language apart from the name - it was a marketing scheme from a time when people still thought that Java was a good idea! JavaScript is a client-side script language, which means that a client's web-browser downloads your entire source code, then runs it within their browser on their own CPU. This means that you can do much more powerful interactive debugging, and it doesn't need recompilation, but it's a bit slower than C.
We can add JavaScript inside script
tags anywhere in the HTML
file, put I prefer to put them all at the end, after my HTML content. Scripts
support various languages through the type=
attribute, but JavaScript
is the default, and JavaScript blocks will execute automatically.
You can add this script block to your page:
I introduce a few new concepts here. The first instruction is the JavaScript
equivalent of printf()
. If you go to your browser's developer menu
you can open the JavaScript console, and you should see the message. It's good
to have this open whenever you refresh or reload your page, as it will give
you quite good error information that you may not otherwise be aware of as the
page may go on to look like it loaded correctly.
I use the DOM to fetch my canvas element as a JavaScript object. Note that
JavaScript does not use strong typing - everything is a var
object,
which I can tell you causes far more problems than it solves. In any case,
my first action is to modify the canvas' width and height attributes from
JavaScript, which should change its size on the page.
Next, I ask the canvas to set itself up with a new WebGL context, and keep
track of this as an object called gl
. This object will be our
interface to all of the WebGL functions.
My final instructions use the gl
object to call GL functions.
They are almost identical to OpenGL function names and constants,
except that gl
and GL_
are removed, and we access each
through our new object. You can set the aplha channel here to 0.0 if
you want to background to be transparent and the page background to show
through. If you refresh the web page you should see the canvas bigger, and
coloured. You can find the complete list of OpenGL functions on the WebGL
Quick Reference card at
https://www.khronos.org/webgl/.
You'll notice, reading the reference card, that WebGL does not support newer OpenGL's Vertex Array Object (VAO). It's actually quite tedious rendering in OpenGL without VAOs, as you have to set up vertex attribute pointers every time that you draw. There's a VAO extension for WebGL though. You can see it in the extension registry. We can query that in our script block, which will return a new object, which is then our interface to the VAO extension's subset of functions:
If it isn't supported by the user's browser/system then we can use the error logging mechanism of the browser console to report that. This also includes a line number link in the console's output.
It's actually much easier to load a texture into WebGL than regular OpenGL as we don't need an image loading library - HTML already loads images. We can use an image that appears on the web page as texture, or load one quietly in JavaScript. Note that this is asynchronous, so the texture will actually be created some time later in your code after you provide the image URL.
That's it! Note that I did something really unusual, and set an
is_loaded
attribute inside the texture. We know that OpenGL
textures aren't even objects - they certainly don't have attributes! In
JavaScript we can dynamically add new attributes to extant objects.
Because the modern web uses an asynchronous download model we can never be
sure when a resource will actually be downloaded. On a bad connection the
onload
function may not even execute for a minute after your main loop
starts. In the mean-time we will at least have a valid, but empty, texture with
a flag to check against. In a C programme that was
sophisticated enough to load files asynchronously, we would have a little
set-up like this:
We are not allowed to have a thread-hogging wait loop in JavaScript, so the best we can do is add an if-statement check in our main loop, so that it won't try to draw before all the required assets are loaded. For example:
You can see why it's useful to inject some state attributes, and where the "popping-in" effect of resources we see in WebGL demos comes from. If you want to avoid this odd effect you could have a function that checks if all of the assets are loaded before rendering, and instead render perhaps some "loading.." text, but from my experience with a larger commercial project which had a lot of resource data to download, you can get a very bad user experience if they have to wait for a long time on a mobile device or on a poor connection - you'll probably find users prefer "popping-in" to a long wait.
I usually like to have my shaders in external files, but that's actually a little bit inconvenient in JavaScript, which will also want to load them asynchronously. For multi-part shaders it's actually an annoyance using this mechanism. You could concatenate both shaders into one file. You can also store the shaders in JavaScript strings. Most developers instead find it more convenient to put shaders in their own script blocks so that you don't have to worry about string formatting or asynchronous downloads. I put my vertex shader in its own script block above my other scripts:
I set the type
attribute to something appropriate-looking so that the
browser doesn't think that it's JavaScript that should be exectuted. I set the
id
attribute so that I can fetch it later. The GLSL for WebGL 1.0 uses
the acoderibute
and varying
keywords of older OpenGL 2.1 and
OpenGL ES rather than newer in
and out
. Similarly, I have a
fragment shader in a script block:
We also have varying
instead of in
here, and the built-in
gl_FragColor
instead of an output variable. WebGL fragment shaders
are required to have that exact precision statement at the top.
To fetch the contents of these blocks as JavaScript strings, I do this after loading the mesh:
var el = document.getElementById ("heckler.vert"); var vs_str = el.innerHTML; el = document.getElementById ("heckler.frag"); var fs_str = el.innerHTML
I just use the DOM again to fetch the strings. Instead of a script block, you
could just as well put your shaders in a visible textarea
, and live
edit them. Web elements have a range of .on....()
functions
that you can define. When a user clicks on something, or text is changed a
callback function will fire - your function could recompile the shaders.
Following this, I have regular-looking code compiling the shaders and getting some variables to hold the locations of uniforms. Note that I also explicitly bind the locations of my attributes to 0, 1, and 2, for points, texture coordinates, and normals, respectively.
var vs = gl.createShader (gl.VERTEX_SHADER); var fs = gl.createShader (gl.FRAGMENT_SHADER); gl.shaderSource (vs, vs_str); gl.shaderSource (fs, fs_str); gl.compileShader (vs); if (!gl.getShaderParameter (vs, gl.COMPILE_STATUS)) { console.error ("ERROR compiling vert shader. log: " + gl.getShaderInfoLog (vs)); } gl.compileShader (fs); if (!gl.getShaderParameter (fs, gl.COMPILE_STATUS)) { console.error ("ERROR compiling frag shader. log: " + gl.getShaderInfoLog (fs)); } var sp = gl.createProgram (); gl.attachShader (sp, vs); gl.attachShader (sp, fs); gl.bindAttribLocation (sp, 0, "vp"); gl.bindAttribLocation (sp, 1, "vt"); gl.bindAttribLocation (sp, 2, "vn"); gl.linkProgram (sp); if (!gl.getProgramParameter (sp, gl.LINK_STATUS)) { console.error ("ERROR linking program. log: " + gl.getProgramInfoLog (sp)); } gl.validateProgram (sp); if (!gl.getProgramParameter(sp, gl.VALIDATE_STATUS)) { console.error ("ERROR validating program. log: " + gl.getProgramInfoLog (sp)); } var heckler_PV_loc = gl.getUniformLocation (sp, "PV"); var heckler_M_loc = gl.getUniformLocation (sp, "M");
"PV" is my combined projection and view matrix, and "M" is my model matrix.
Although similar to OpenGL in C, you can see things like string functions are
a little bit easier to deal with. If you refresh the browser and look in the
console you should get an error message (including shader and linker logs) if
that didn't work. If you want to be sure that the shader code loaded, you can
use alert (vs_str);
to throw up an alert box with the vertex shader
string.
I have a mesh file that I want to render - a Wavefront .obj file I made for a recent game jam. What I want to do is read the .obj file into a string, then use JavaScript string parsing functions (which are quite good) to break that up into lists of points, texture coordinates, and normals. We can use AJAX for that, which works like this:
var xmlhttp = new XMLHttpRequest(); xmlhttp.open ("GET", "OUR_URL_STRING_HERE", true); xmlhttp.onload = function (e) { var str = xmlhttp.responseText; var lines = str.split ('\n'); for (var i = 0; i < lines.length; i++) { //...parsing code goes here... } } xmlhttp.send ();
First, we get an interface to a new XMLHTTPRequest (AJAX' real name). We tell
it that it will use the HTTP "GET" request to retrieve a file from a URL, which
we will give it as a string. Finally, it prefers to work asynchronously, which
can be quite tricky to set up in JavaScript, but is worth doing to get the best
loading time. If you prefer to skip this extra fuss you can set the third
parameter of the open()
function to false
, but the browser
will complain to you in the console.
We can handle our asynchronous download with AJAX in the same way as we handled
the texture - adding an is_loaded
attribute. I'll also check that the
texture was loaded, because it would look terrible if the VAO loaded first,
and rendered with some other texture:
var my_vao = start_loading_obj ("meshes/my_mesh.obj"); ... // _inside the main drawing loop_ if (my_vao.is_loaded && texture.is_loaded) { vao_ext.bindVertexArrayOES (my_vao); ... // draw stuff that requires the VAO }
I won't paste the 50-lines or so for .obj parsing here, but you can view it at obj_parser.js. There are many ways that you can structure your JavaScript objects and functions. Another annoying limitation to JavaScript is that you cannot really pass back more than one variable from a function as you can in C. You might consider creating a mesh container object that holds a VAO and a point count, or simply adding the vertex point count as another attribute in your VAO like I do here - it all depends on how much abstraction you like to have in your working code.
function start_loading_obj (url) { // first create an empty VAO var vao = vao_ext.createVertexArrayOES (); // inject an is_loaded boolean vao.is_loaded = false; // inject point count into VAO (yeah...) vao.pc = 0; var xmlhttp = new XMLHttpRequest(); xmlhttp.open ("GET", url, true); xmlhttp.onload = function (e) { var str = xmlhttp.responseText; var lines = str.split ('\n'); for (var i = 0; i < lines.length; i++) { //...parsing code goes here... } // store loaded state and point count in VAO vao.pc = sorted_vp.length / 3; vao.is_loaded = true; } // start loading xmlhttp.send (); // return the empty VAO return vao; }
Note that, similar to the texture creation, the first step is to create a
valid, but empty, VAO. This will be returned to the function caller whilst
the download starts. That means that even if the download has not finished, our
main programme still has a valid handle to the eventual VAO, and can check its
state. The send
function starts the HTTP
handshaking. When the file has actually downloaded to the client the
onload
callback function will start - some-time after the original
function call returned. It's very easy to make a mistake here when testing. On
a local network the download won't have a delay. From another continent, with a
large mesh, the delay could take some time - when testing asynchronous
download code, check over the longest and worst connection possible. You
will definitely make mistakes with the little check flags and callbacks, and
have code that assumes something has downloaded without sufficiently checking.
In your parsing code you can use the str.split ('\n');
command to
return an array of strings; one for each line in the file, and parse that in a
loop with for (var i = 0; i < lines.length; i++) {
. Note that JavaScript uses dynamic arrays
(basically
C++ vectors), and they always know their own length.
To include an external JavaScript file, we just add another script
block, and specify the src="path_to_file.js"
attribute.
I have a relative path to my file here. Note that you must have a closing
script tag - you cannot have a single, self-closing
<script />
tag, as you can with other types of HTML element. This new
script block should really appear before our other block, so that the browser
parses it before it is used. If you don't do this it will still work, but the
browser might warn you that it has been forced to load the script
less efficiently.
If you're loading your web page from your desktop AJAX may complain and refuse to
load files as it violates security policy. You can put your content on a local
web-server - there are many light-weight servers available. You can start
Chrome with the --disable-web-security
command-line flag to ignore
this precaution, or you can just use Firefox, which should ignore this problem
entirely and load your files.
You will need a set of vector and matrix maths functions for JavaScript. A very popular library is Brandon Jones' gl-matrix. I also ported my maths library to JavaScript. You can of course also write your own. It's easier to just leave matrices and vectors in JavaScript's native array format, rather than creating custom data structures.
It's worth having a look at the source code of a
JavaScript maths library to see how functions, parameters, and arrays work in
JavaScript. Functions do not have declarations - just a definition. They are
all prefixed with the function
keyword instead of a data-type, and can
return or not return a value. Parameters are just given name, and do not
require a var
prefix. Currying and more advanced functional
programming is possible. Arrays are given as comma-separated lists of values
inside square brackets. An empty array is created with either
var my_array = []
or var my_array = new Array ()
.
I just include my maths library with another script block. I create my view and projection matrices with familiar-looking functions:
var cam_dirty = true; var V = look_at ([0.0, 0.4, 1.0], [0.0, 0.4, 0.0], normalise_vec3 ([0.0, 1.0, 0.0])); var aspect = canvas.clientWidth / canvas.clientHeight; var P = perspective (67.0, aspect, 0.1, 100.0); var PV = mult_mat4_mat4 (P, V);
Very important JavaScript note: my canvas dimensions are integers, but division here does floating point division, not integer division. This could be your first encounter with the horrible bits of JavaScript. If you want a particular data type you usually have to enforce it with particular truncation or parsing functions - floating point or string seem to be a kind of default assumed data type in JavaScript in most cases.
To have a loop in JavaScript the best strategy is to create function that you
put the code to be looped inside, and notify the browser that you want to call
this function again when it finishes. Unfortunately, the actual function to
request a repeat call does not seem to have been standardised over all the
browsers. I took a snippet from Google's webgl-utils.js
file to
encapsulate that in a cross-browser function:
window.requestAnimFrame = (function() { return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame || function(callback, element) { return window.setTimeout (callback, 1000 / 60); }; })();
If you put that somewhere in your script blocks we can call it. We will also write a function to repeat that will do our drawing. This can go after your start-up code if you like:
var previous_millis; function main_loop () { // update timers var current_millis = performance.now (); var elapsed_millis = current_millis - previous_millis; previous_millis = current_millis; var elapsed_s = elapsed_millis / 1000.0; // draw gl.clear (gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.activeTexture (gl.TEXTURE0); gl.bindTexture (gl.TEXTURE_2D, texture); gl.useProgram (sp); if (cam_dirty) { gl.uniformMatrix4fv (heckler_PV_loc, gl.FALSE, new Float32Array (PV)); cam_dirty = false; } var R = rotate_y_deg (identity_mat4 (), current_millis * 0.075); gl.uniformMatrix4fv (heckler_M_loc, gl.FALSE, new Float32Array (R)); vao_ext.bindVertexArrayOES (heckler_vao); gl.drawArrays (gl.TRIANGLES, 0, heckler_vao.pc); // "automatically re-call this function please" window.requestAnimFrame (main_loop, canvas); }
I like to have some timers in my loop so I can do animations, and measure
frame rate and so on. You can use the browser's performance.now()
function to do this, which gives you milliseconds since the page loaded, with
your system's maximum precision up to nanoseconds - much more reliable than
JavaScript's data and time functions. After this I start my drawing code. I
update my matrix uniform for the projection and view if it hasn't been updated
yet. There are some peculiarities with the uniform update function for
matrices. The transposition argument must be set to false in WebGL - it won't
do a transposition for you. It's a good idea to force the matrix' array to take
a 32-bit float data type, and you can do that by creating a new
Float32Array
object. Right at the end of the function I call the
request-to-call-again function. Buffer swapping and the
actual drawing of the final image to the canvas are handled automatically.
Right at the end of the script block we can actually call this
function, which will start the looping process:
previous_millis = performance.now(); main_loop ();
Those are the basics of WebGL, which should be enough to get started if you've done a bit of OpenGL before.
The browser has a plethora of functions and features that you can access. You can look at user interaction with the mouse, keyboard, touch-screens for mobile devices, and even the new W3C gamepad/joystick interface. You can look at setting up more sophisticated asynchronous file streaming.
Having a set of GUI tools from HTML can not be understated. You can use all of the web's additions to charts, sliders, buttons, and most importantly you have text rendering. You can overlay these on top of the canvas if you like via CSS. You can also float the canvas over other web content, and make the background transparent.
Browsers have built-in debugging, source, stepping, and watch-list tools. You can do some very comprehensive debugging inside the browser, and even enter new JavaScript code in as the application is running. It's a good idea to try out these tools.
Be sure to watch the development of the new WebGL 2.0 standard, and see if your browsers already have an early version of this running.
My favourite WebGL book so far is Diego Cantor and Brandon Jones' "WebGL Beginner's Guide", at PACKT. Mozilla Developer Network has an excellent tutorial series on getting started with WebGL.