Brandon Rice

Software development with a focus on web technologies.

A Gentle Intro to WebGL

| Comments

WebGL is a JavaScript API for rendering hardware accelerated graphics in an HTML canvas element. In other words, it’s a key that unlocks the door between desktop application graphics and the web. This post discusses the WebGL rendering pipeline and shows an example of drawing basic shapes using a fairly minimal 50 lines of code.

At a high level, the WebGL rendering process breaks down into three phases:

  1. Stuff happens inside JavaScript.
  2. More stuff happens inside the GPU.
  3. The GPU draws the results of all the stuff in an HTML canvas element.

Setting up this drawing process comes with a lot of initial ceremony. It might seem overwhelming without prior OpenGL programming experience, but this is a one-time cost. An early investment in a few different concepts becomes the foundation for creating a custom rendering pipeline tailored to the individual needs of a system.

A basic program that exercises every piece of the above diagram can be assembled from the bottom up. But first, some initial setup.

index.htmllink
1
2
3
4
5
6
7
8
9
10
<html>
  <head>
    <script src="scripts.js"></script>
  </head>
  <body onload="main()">
    <canvas id="canvas" width="400" height="400">
      Your browser does not support canvas.
    </canvas>
  </body>
</html>
scripts.jslink
1
2
3
4
function main() {
  var canvas = document.getElementById("canvas");
  var gl     = canvas.getContext("webgl");
}

The gl variable contains a reference to a WebGL rendering context. This context is the main interface for the WebGL API.

Shaders

Shaders are pre-compiled drawing programs that run inside the GPU. They are written in a C-like language called GLSL and provide rendering instructions to the GPU. Two types of shaders are used in this pipeline example: Vertex and fragment.

Vertex Shader

Vertex shaders describe how to draw the vertices making up one or more polygons. For the purposes of this example, that means a list of two-dimensional coordinates. However, the vertex shader does not know the actual positions of these coordinates. It knows only that they exist, and that they will be available by way of some attribute provided when the program runs.

index.htmllink
1
2
3
4
5
6
7
<script id="vertex-shader" type="x-shader/x-vertex">
  attribute vec2 a_position;

  void main() {
      gl_Position = vec4(a_position, 0.0, 1.0);
  }
</script>

At runtime, an attribute named a_position of the type vec2 (a 2-dimensional vector) contains positional data about a vertex. Convert that vector into a vec4 (4-dimensional vector) and assign it to the special WebGL global variable gl_Position. This program runs once for every pair of vertex coordinates.

Fragment Shader

Fragment shaders describe the space between vertices. While the vertex shader was called once for each vertex, the fragment shader program is called once for each pixel in the space between those vertices. In this example, the fragment shader program describes the color of each pixel.

index.htmllink
1
2
3
4
5
<script id="fragment-shader" type="x-shader/x-fragment">
  void main() {
      gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
  }
</script>

At runtime, each time the fragment shader program executes (for each pixel), assign a new 4-dimensional vector describing a color (in RGBA form) to the special WebGL global variable gl_FragColor. In this case, the color is always white.

Shader Setup

Hooking up shaders makes up a large chunk of the WebGL setup ceremony. The source code for the shaders must be compiled and linked together in an instance of a WebGL program.

scripts.jslink
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var program       = gl.createProgram();
var vShader       = gl.createShader(gl.VERTEX_SHADER);
var fShader       = gl.createShader(gl.FRAGMENT_SHADER);
var vShaderSource = document.getElementById("vertex-shader").text;
var fShaderSource = document.getElementById("fragment-shader").text;

gl.shaderSource(vShader, vShaderSource);
gl.compileShader(vShader);

gl.shaderSource(fShader, fShaderSource);
gl.compileShader(fShader);

gl.attachShader(program, vShader);
gl.attachShader(program, fShader);

gl.linkProgram(program);
gl.useProgram(program);

Once compiled, the process is not repeated unless the shader source code changes.

Attributes

Attributes serve as containers for the data that travels from JavaScript into the shader programs.

scripts.jslink
1
2
var positionLocation = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionLocation);

Expose the a_position attribute from the vertex shader and provide a reference to it in JavaScript. Think of it as a pointer to the place in memory where the attribute data resides.

Buffers

If attributes are the containers for data, then buffers are the pipes that connect JavaScript to those containers.

scripts.jslink
1
2
3
4
5
6
7
8
9
var buffer   = gl.createBuffer();
var vertices = [-0.5,  0.5,
                -0.5, -0.5,
                 0.5,  0.5,
                 0.5,  0.5,
                -0.5, -0.5,
                 0.5, -0.5];
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);

Create a buffer and an array containing positional data. Then, activate the buffer by “binding” it. Finally, declare that the data for the activated buffer is the array of positional data in the form of 32-bit floats.

Drawing

The setup is finally complete. It’s time to draw.

scripts.jslink
1
2
3
4
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);

First, declare a clear color of black (in RGBA form). Next, inform WebGL to clear the color buffer using the declared clear color. Then, declare a pointer to the WebGL attribute a_position containing the vertex data. Finally, draw the buffered vertices as a pair of triangles.

The results may be slightly underwhelming for the amount of effort, but this lays the groundwork for more advanced applications.

Conclusion

In a more advanced WebGL application, the drawing section might be called repeatedly in a loop as the buffered data changes. Drawing 60 times each second results in a target frame rate of 60fps. Everything else is initial setup that may expand in size (i.e. additional buffers, more complex shaders), but otherwise looks very similar to this example. The complete example is available on Github and as a JSBin.

For more in-depth learning about WebGL, I recommend these resources:

  • The WebGL Programming Guide is an introductory book that assumes no previous knowledge of WebGL, HTML, JavavScript, or OpenGL.
  • Learning Three.js is a book covering a popular library that wraps WebGL in a higher-level abstraction. Three.js is great for getting things done, but it hides many implementation details.
  • Learning WebGL is an older tutorial site based on an even older OpenGL tutorial. However, it has a wide variety of lessons covering many different aspects of WebGL.
  • WebGL Fundamentals is a new(er) tutorial site covering more advanced WebGL concepts. Some preexisting familiarity with WebGL will be helpful here. In particular, the math sections are well done.

If you enjoyed this post, please consider subscribing.

Comments