Saturday 21 October 2017

An Introduction to WebGL

This month we had Carl, a regular member and graphics professional, give us an introduction to WebGL.


Carl's event page is here: [link], slides directly [here].

The vide recording of the meet up is here: [link].


What is WebGL?

WebGL brings two worlds together - the web and GPU accelerated graphics.

The web is is one of the most open and successful technology platforms on the planet. The number of people using the web every days is in the billions. And it all works (almost perfectly) with any device we're using - smartphones, tablets, laptops, tiny sensors, big cloud servers - and with any software - browsers like Firefox, Chrome and Safari, as well as a huge ecosystem of web software libraries.

A key reason it all just works its that the technical standards by which the web works are open, and largely driven by the community. They're are not secret proprietary and driven by a small number of powerful corporations. The open source and open standards movements have become very important in today's digital world.

On the other hand, the history of GPU accelerated graphics isn't such a fairy tale. Early computers found driving a graphics display a very intensive task. Moving this load away from the main computer's processor to specialised graphics processors was an obvious step. For several years, these specialist graphics remained proprietary, with little interoperability. Then standards emerged allowing programmers to code once, and expect their programs to run on different computers with different graphics hardware acceleration. A non-profit industry group called the Khronos Group looks after a very popular API called OpenGL, the leading API for hardware accelerated 3D graphics. Many vendors of GPU hardware have implemented support for OpenGL for many years.


WebGL can be thought of as a smaller version of OpenGL designed to be used as a web technology, through Javascript, and viewable on any modern web browser - on a smartphone or a laptop.


Big Picture

It helps to understand the several technology bits that work together.


You can see that the web browser contains a javascript engine. This is the same engine that runs normal javascript associated with a web page. The WebGL API is a javascript API, and so should not be too alien for web developers to pick up.

That API essentially feeds data and programming language instructions to the GLSL compiler. Let's explain this a bit more. The fast hardware that is the GPU doesn't talk javascript. It does however understand a language called the GL Shader Language (GLSL). GLSL is similar in many ways to C/C++ and is compiled before the GPU can run it. The WebGL API is simply passing the text of our GLSL programs to the OpenGL drivers to compile and run.

You can explore the reference for the WebGL javascript API here.


Lingo: Fragments, Vertices and Shaders

There's lots of unfamiliar language in the world of coding GPUs, and that can be a barrier to newcomers. Carl introduced the most important concepts.

If you remember that a GPU is designed to accelerate, potentially complex and detailed, 3-dimensional graphics, then it makes sense that the processing must be more constrained than the kind of things we can do on a normal general purpose CPU. Not everything we can do with a CPU can easily be done with a GPU, but what a GPU can do, it can do very fast, and to lots in parallel.

Viewed positively, these constraints can be seen as a pipeline of how information about a scene is processed into images on a display. Here's a simplified WebGL pipeline.


Let's talk that pipeline through:

  1. At the start we have data, numbers, which describe simple shapes. Complex objects, like trees, faces, buildings, are made of these simple shapes, or primitives as they're called. A triangle is a very simple primitive, described by three corners. That's all that's required to define a triangle. Other primitives include points and lines.
  2. A program that runs on the GPU transforms this data into the corners of a shape. A fancy word for corners is vertices, and just one is a vertex. That small program can run very fast on the GPU, and in fact, the GPU can transform lots of data in parallel (lots at the same time). That program is called a vertex shader. Confusing name, but there you are. The vertex shader can do things to the corners, like move it around in space, in effect moving the shape.
  3. The next step is to render that shape, that triangle for example, to a display made of pixels. That process is is called rasterisation. This is when the face of the triangle is coloured in. It could be a red triangle, or be a colour gradient, a texture or something else. Again a small, program on the GPU dos this very fast, and is highly parallel. That small program is called a fragment shader. Again, not the clearest of names, but there you go.


That pipeline makes sense, and everything we do must conform to that pipeline, if we want fast acceleration of graphics by the GPU. In short,
The vertex shader works on the shape corners,  
the fragment shader works on the pixels.


WebGL Simple Example

Carl explained and illustrated a simple WebGL program with vertex and fragment shaders, with data passed through javascript.

What I'll do here is try to use that knowledge from Carl's talk to create my own first WebGL code. It's a good way to see the basics of how we structure our code, and also see the basics working, learning by doing.

I'm following the 2D coloured triangle example at WebGL Academy, a site recommended by Carl.

The first thing we need is a web page element to draw on. A HTML canvas element makes sense.

<canvas id='my_canvas'></canvas>

That creates a canvas with identifier 'my_canvas'.  Now we work entirely in javascript.

The main thing we need to do is create a canvas context. Just like many technology frameworks, a context is a way of creating a bubble for your scene, separate and safe from other bubbles.

var html_canvas = document.getElementById('my_canvas');
var GL = html_canvas.getContext('webgl');

The html_canvas variable is just the HTML canvas we created, obtained by the identifier we gave it, my_canvas. The variable GL is the "webgl" context of the canvas, an object that supports WebGL. For IE and Edge we need the older "web-experimental".

Now we need to set up the shaders, the small GPU programs.

First let's set up the vertex shader, the code that operates on all the corners of our objects. Our code is very simple:

attribute vec2 position;
void main(void) {
    gl_Position = vec4(position, 0.0, 1.0);
}

Let's explain it. Remember this isn't javascript, this is the GLSL language which is similar to C.
  • The first line creates a variable called position. It is of type vec2 which is simply a data structure for 2 numbers, so 2D coordinates. There is also something else, attribute, which is a type qualifier that tells the GSLSL compiler that this variable is pulled into the GPU from a data buffer. That's how we'll pass vertex coordinate data to the shader. The type qualifier for single values is uniform, here we have an array of values which needs attribute.
  • The next line declares a new function main(), which is the main entry point into the GPU code. The name main() is used in many languages to identify the first entry point into executing code.
  • The content of that main() function currently has only one instruction. It sets the gl_Position variable to that position variable, but expands it from 2 numbers to 4, by adding a 0.0 and a 1.0. The 0.0 is the coordinate along the third dimension, so a measure of depth. The 1.0 is, simplistically, a scaling factor. The gl_Position is a special variable, which is used by the fragment shader later. So this is an opportunity to transform (translate, rotate, other) the positions of the vertices, but we haven't here, we've kept them as they are.

Now let's look at the fragment shader, which takes the output of the vertex shader.

precision mediump float;
void main(void) {
    gl_FragColor = vec4(0.,0.,0., 1.);
}

This is simple code again:

  • The first line sets the precision to be used for floating point numbers in the fragment shader. Medium precision mediump is faster than high precision and good enough for textures and colours.
  • A main() function is declared as an entry into the executed code.
  • This main() does only one thing, it sets the gl_FragColor special variable to a four number vector vec4. These 4 numbers describe a colour using RGB and an alpha channel (translucency), so (0, 0, 0, 1) is black. 

The fragment shader is called for every pixel (fragment) inside the triangle described by the vertices that emerge from the vertex shader, which itself gets them from the data we provide through javascript.

How do we compile this GLSL code? The steps are simple but kinda boring boilerplate code. The following shows the exact same approach needed for both shaders.

var shader_vertex = GL.createShader(GL.VERTEX_SHADER);
GL.shaderSource(shader_vertex, shader_vertex_source);
GL.compileShader(shader_vertex);

var shader_fragment = GL.createShader(GL.FRAGMENT_SHADER);
GL.shaderSource(shader_fragment, shader_fragment_source);
GL.compileShader(shader_fragment);

First a shader is created from the context, of the type required (vertex, fragment). Then the source code is associated with it, then it is compiled, with the result remaining in the shader. That's a lot of boring code, but that's all that's happening.

We then need to create a webGL program and attach these compiled shaders. Again, boilerplate code.

var shader_program = GL();
GL.attachShader(shader_program, shader_vertex);
GL.attachShader(shader_program, shader_fragment);

Almost there. We now link the variables in javascript to those in the shaders, so we can pass data through the connection. You can see the javascript js_position associated with the GLSL position variable.

GL.linkProgram(shader_program);
var js_position = GL.getAttribLocation(shader_program, "position");
GL.enableVertexAttribArray(js_position);
GL.useProgram(shader_program);

We're done with shaders now. Let's look at creating the data that describes the triangle so we can pass it through to the webGL pipeline.

var triangle_vertex_data_js = [-1, -1, 1, -1, 0, 1];
var triangle_vertex_data_gl = GL.createBuffer();
GL.bindBuffer(GL.ARRAY_BUFFER, triangle_vertex_data_gl);
GL.bufferData(GL.ARRAY_BUFFER, new Float32Array(triangle_vertex_data_js), GL.STATIC_DRAW);

That looks complicated, but again it's boilerplate code. What's happening is that a javascript array is created with a list of the corner coordinates. The first corner is at (-1, -1). Next a GL buffer is created that we bind to the javascript array. Then the data is copied over after being cast as Float32 numbers.

We now have to tell WebGL which of these points, and in which order, make a face. In this easy example, just the first, second and final third corners make a triangle face. The code mirrors the previous one, create the javascript array of data, create a GL buffer, bind it, and fill it with data.

var triangle_faces_js = [0, 1, 2];
var triangle_faces_gl = GL.createBuffer();
GL.bindBuffer(GL.ELEMENT_ARRAY_BUFFER, triangle_faces_gl);
GL.bufferData(GL.ELEMENT_ARRAY_BUFFER, new Uint16Array(triangle_faces_js), GL.STATIC_DRAW);

Now all that's left is to set up the scene and draw it.

GL.clearColor(0.0, 0.0, 0.0, 0.0);
var do_drawing = function() {
    GL.viewport(0.0, 0.0, html_canvas.width, html_canvas.height);
    GL.clear(GL.COLOR_BUFFER_BIT);

    GL.bindBuffer(GL.ARRAY_BUFFER, triangle_vertex_data_gl);
    GL.vertexAttribPointer(js_position, 2, GL.FLOAT, false, 4*2, 0);
    GL.bindBuffer(GL.ELEMENT_ARRAY_BUFFER, triangle_faces_gl);
    GL.drawElements(GL.TRIANGLES, 3, GL.UNSIGNED_SHORT, 0);

    GL.flush();
    window.requestAnimationFrame(do_drawing);
};
do_drawing();

Let's explain the key points in the code:

  • The first colour sets the colour used when a buffer is cleared. We set it to colourless and transparent. 
  • A new function is created, called do_drawing(), which does the actual drawing. It is called many times, to enable animation, if that is desired The window.requestAnimtionFrame() is how modern browsers allow custom code to be called whenever the browser is ready to draw a new animation frame. We're not actually doing animation here because every frame is the same drawing.
  • Inside the do_drawing() function, we set a viewport to the size of the html canvas and clear it, then bind the triangle vertex data to that buffer, the same for the faces.
  • The GL.flush() causes all queued commands to be executed, in case they are waiting cached somewhere in the network or GPU driver - which can happen. It's a bit like writing data to disk, it doesn't always get to disk, until forced to by a flush or sync. This command queuing is good for performance.
The full code for this WebGL example is always on GitHub at: https://github.com/algorithmicart/webgl/blob/master/index.html

The results of all this is a simple black triangle on a pink canvas, scaled to the size of the available canvas. Here's a screenshot showing the browser window decoration too:


Finally, a WebGL rendered object, from data that was sent through the GPU accelerated pipeline!

Let's add some colour, not because the black triangle is boring, but to illustrate how GLSL on the GPU can do some of the work.

The first thing we need to do is declare new variables for colour in the vertex and fragment shaders. The changes to the vertex shader are:

attribute vec2 position;
attribute vec3 colour;
varying vec3 vColour;
void main(void) {
    gl_Position = vec4(position, 0.0, 1.0);
    vColour = colour;
}

In the vertex shader we set an attribute colour to allow data to be passed from javascript. We also set a varying vColour, which means a variable allowed to change inside GLSL, and has no link to anything outsidre GLSL such as javascript data. For each triangle corner, the vertex shader sets the internal vColour to the colour which will be passed from javascript as data.

The fragment shader changes are:

precision mediump float;
varying vec3 vColour;
void main(void) {
    gl_FragColor = vec4(vColour, 1.);
}

This again declared vColour as an internal mutable variable. The fragment shader simply sets the colour of the pixel to vColour, not black as it did before.

All we need to do now, is actually create the javascript data and pass it through. Here are the changes.

var triangle_vertex_data_js = [
   -1, -1,
   0, 0, 1,
   1, -1,
   1, 1, 0,
   0, 1,
   1, 0, 0];

The triangle data now contains rgb colour values, not just the coordinates of the corners.

var do_drawing = function() {
    GL.viewport(0.0, 0.0, html_canvas.width, html_canvas.height);
    GL.clear(GL.COLOR_BUFFER_BIT);

    GL.bindBuffer(GL.ARRAY_BUFFER, triangle_vertex_data_gl);
    GL.vertexAttribPointer(js_position, 2, GL.FLOAT, false,4*(2+3),0) ;
    GL.vertexAttribPointer(js_colour, 3, GL.FLOAT, false,4*(2+3),2*4) ;
    GL.bindBuffer(GL.ELEMENT_ARRAY_BUFFER, triangle_faces_gl);
    GL.drawElements(GL.TRIANGLES, 3, GL.UNSIGNED_SHORT, 0);

    GL.flush();
    window.requestAnimationFrame(do_drawing);
};

The do_drawing() function now has to have two changes, because that javascript data structure has changed. The numbers show the steps into the array the js_position and js_colour data is to be found. More details here.

The results are rather nice:


We only specified the colours of the corners, so why is the inside of the triangle coloured at all? More to the point, why is it shaded using smooth colour transitions. The reason is that WebGL by default interpolates colour between vertices if it can.


Easier JavaScript Frameworks

The code and complexity of the example just to draw a simple coloured triangle is huge. That's a problem for many reasons - the barriers to entry are high, the code is error prone, even seasoned coders will just prefer not to use WebGL.

Carl explained that today, there exist several abstraction layers over WebGL to reduce the code and complexity for the most common rendering tasks. He lists three.js and babylon.js as examples. Both of their websites link to interesting examples.

The babylon.js playground and tutorial looks really well thought out:



Editors and Tools

We've seen above that writing GLSL shader code as a javascript string and then juggling that to compile, link and run the code is very clunky. Carl recommended online editors which make developing shaders much easier by handling all that machinery behind the scenes, leaving you to the creative task of creating shaders.

He used the editor at The Book of Shaders as a good example:


Despite excellent compatibility across many different browsers and devices, there can be some small differences. The well used Can I Use website is also great for comparing WebGL capabilities.

Carl also listed web tools which show the capabilies of your browser, with a lot of detail, for example showing how many vertices can be created, or the highest level of floating point precision.


Skull Model

Carl demonstrated that you could import 3d objects created elsewhere, and use javascript libraries to convert those models into vertex data for WebGL. He also demonstrated techniques for animating a skull model by using the vertex and fragment shaders to do things like transform the skull into a sphere, or to apply a time-varying texture.



More Resources

The following are hand selected resources and tutorials which I think are good for beginners:

No comments:

Post a Comment