Setosa blog

home

A brief introduction to WebGL

There's a lot of things about WebGL that excite me. It seems there's so much potential for creating the types of visualizations that were, until recently, only possible in monolithic C++ or Java applications. It feels something akin to the early days of the web.

A lot of people also seem focused on 3D, as if that's the only advantage webGL offers. But WebGL wont just bring about more 3D pie charts. It will make an entire class of extremely detailed and interactive visualizations a reality on the web. OpenGL Shading Language (or GLSL) is what makes all this possible. It provides a way to run code in parallel on the GPU. This is extremely handy for things like force directed layouts. In the same way you might use the canvas element to draw nodes instead of SVG, the GPU can be used to draw thousands or millions of nodes. These types of visualizations were previously only possible using desktop applications like Geph.

Most WebGL examples ignore 2D outright but I find its actually a lot easier to approach webGL from a 2D perspective so we'll be doing the exact opposite; this tutorial wont introduce any 3D features of WebGL. This walk-through also assumes you're somewhat familiar with D3.

The first thing we need to do is create a canvas object and the gl context object. This should feel pretty similar to using the 2d canvas context. Every bit of communicate we do with the GPU with be through the gl object.

var canvas = d3.select('body').append('canvas')
    // make the canvas take up the entire browser screen.
    .attr({width: window.innerWidth, height: window.innerHeight});
  var gl;
  canvas.node().getContext('experimental-webgl');

Next, we need to create a vertex and fragment shader. These things should really been called "point" and "pixel" modifiers. They give us the ability to change how the shape points and pixels get draw onto the canvas. One or more shaders makes up a "program" (another poorly chosen noun...) Shaders are what make 3D video games seem realistic by providing things like shadows or surface textures. What makes shaders interesting is that each shader runs at the same time as all the other shaders run making them extremely fast. They do this by running in parallel on the GPU (instead of the CPU.) Here's the code that wires up our shaders.

// Compile the vertex shader.
  var vertexShaderCode = d3.select('#vertex-shader').text();
  var vertexShader = createShader(gl.VERTEX_SHADER, vertexShaderCode);
  // And the fragment shader.
  var fragmentShaderCode = d3.select('#fragment-shader').text();
  var fragmentShader = createShader(gl.FRAGMENT_SHADER, fragmentShaderCode);

  // Create and compile a new shader.
  function createShader(type, shaderCodeAsText) {
    var shader = gl.createShader(type);
    gl.shaderSource(shader, shaderCodeAsText);
    gl.compileShader(shader);
    return shader;
  }

  // Create a shader collection "program" to hold our shaders.
  var program = gl.createProgram();
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  gl.useProgram(program);

Now lets look at the shaders. Notice how the shaders above are read in from two different node elements. This is just a trick so we don't have to type out the code inside of JavaScript. Here the code for the vertex shader.

<script id="vertex-shader" type="x-shader/x-vertex">
  attribute vec2 position;
  void main() {
    gl_Position = vec4(position, 0, 1);
  }
  </script>

Our vertex shader is pretty simple. It doesn't modify our geometry points at all. The position attribute is defined later but is the location of the current vertex. Assigning to gl_Position tells the GPU where the current vertex point should be drawn. Normally, the vertex shader is normally responsible for translating and adjusting the vertices and projecting them onto our 2d canvas. Because our points are aleady in 2d, we can ignore this step.

Notice that the code in shaders is not JavaScript. GLSL is its own language, similar to C. Here's the fragment (or pixel) shader.

<script id="fragment-shader" type="x-shader/x-fragment">
  precision mediump float;
  uniform vec2 canvasSize;
  void main() {
    // Set this pixel's color to a gradient from black to green across the canvas.
    // Color values in shaders go from [0 -> 1].
    gl_FragColor = vec4(0, gl_FragCoord.x / canvasSize[0] * 1.0, 0, 1);
  }
  </script>

This fragment shader colors the current pixel along a gradient from black to green depending on how far across the screen it is. It uses an external "uniform" variable for the canvas size. A uniform variable is a variable every running fragment shader can use that wont change for the current frame. We'll see how this gets configured later.

Now we need to create two triangles. These triangles will be used by the vertex shader and need to fill the entire screen. The default coordinate system range in WebGL x -> [-1, 1], y -> [-1, 1], where y points up.

// Create the points for two triangles.
  var buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([
      // The first triangle.
      -1, -1,    1, -1,    -1, 1,
      // The second triangle.
      -1,  1,    1, -1,    1,  1
    ]),
    gl.STATIC_DRAW
  );

Notice how we don't actually pass the buffer as an argument to gl.bufferData when we set the data for the vertex position buffer? This is because WebGL's API is stateful. This means we have to first say, "hey WebGL, use this buffer for any subsequence instructions."

Now we need to expose the position and canvasSize variables we used above in the pixel and fragment shaders.

// Make the triangle points available to to the vertex shaders.
  var position = gl.getAttribLocation(program, "position");
  // This works because the triangle point buffer is still the current buffer.
  gl.enableVertexAttribArray(position);
  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);

  // Make the canvas size available to the fragment shader.
  var canvasSize = gl.getUniformLocation(program, "canvasSize");
  // The canvasSize variable is the same for all pixels.
  gl.uniform2f(canvasSize, canvas.node().width, canvas.node().height);

And now the very last step, draw the triangles, initiating the cascade of vertex shader, then fragment shader.

// Draw the buffer triangles
gl.drawArrays(gl.TRIANGLES, 0, 6);

This introduction was largely inspired by Playing with shaders by Alexander Oldemeier

Also checkout Mike Bostock's Milky Way block a large example.

comments powered by Disqus