WebGL point sprites, a tutorial
Tags: webgl, javascript
Published: 2019-02-06 06:38:17

Over the past few days, I've been experimenting with WebGL, which is OpenGL from your browser. Personally, I want to build something which lets me display a lot of sprites very quickly—so I've turned the basics into a tutorial! 👨‍🏫

_First, let me say that that for most people, you want to learn a wrapper like [Three.JS](https://threejs.org/) or [PixiJS](http://pixijs.com). Building your own renderer is fun, but not for finishing projects!_ 😂

If that hasn't scared you off, then read on. 👇

# The Technique

If you think of OpenGL, you might say—well, everything's drawn with triangles. That cube is triangles, that house is triangles, that square is triangles. But actually, there's a slightly simpler approach we can use. 😕

OpenGL allows us to draw points, which can be 'billboarded' towards the screen. These are points rendered as a square facing the camera 🎥 based on a fixed "point size", like you see below.

![Triangle vs points](https://thepracticaldev.s3.amazonaws.com/i/jmzvzpoxee5mhfxbpqbp.png)

So with the approach in mind, let's get started! 🌈

# Tutorial

## Step 0: Get a rendering context

Zeroth step! Create a canvas and get its context:

```js
// create Canvas element, or you could grab it from DOM
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);

// optional: set width/height, default is 300/150
canvas.width = 640;
canvas.height = 480;

// retrieve WebGLRenderingContext
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
```

This is of type `WebGLRenderingContext`, which you can [look up on MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext). We need to fall back to `experimental-webgl` for IE and Edge.

## Step 1: Create a shader program

### Aside

Everything in OpenGL is drawn by a shader program, which is made up of a vertex and fragment shader. Shaders are small C-like programs that are compiled and run on your graphics card. 

The vertex shader lets us tell OpenGL what to draw, and where to draw it (the output is "points" in 3D space). The fragment shader is run once per-pixel that is actually put on the screen, and lets you specify the color.

Shaders are notoriously hard to debug. There's a few tools, but I honestly suggest just making small changes so you can see when you break them. 💥

### Vertex shader

Let's create a variable containing the source code to a vertex shader that places things in our 'screen', where we're rendering. 💻

```js
const vertexShaderSource = `
attribute vec2 spritePosition;  // position of sprite
uniform vec2 screenSize;        // width/height of screen

void main() {
  vec4 screenTransform = 
      vec4(2.0 / screenSize.x, -2.0 / screenSize.y, -1.0, 1.0);
  gl_Position =
      vec4(spritePosition * screenTransform.xy + screenTransform.zw, 0.0, 1.0);
  gl_PointSize = 64.0;
}
`;
```

What is this doing? 🤔

1. We are describing `spritePosition`, which is an __attribute__—that means, it's unique for every time we run this program. It's the location to draw each sprite.

2. There's also `screenSize`, which is a __uniform__—it's unique to this whole program. 

3. To set `gl_Position`, we create a `screenTransform` value. This is because in OpenGL, the screen has a default 'size' of 2.0 wide and 2.0 high. This basically says, if we give a position of (200,100), then this is actually at a fractional position along the screen. We write this to `gl_Position`, which takes four values (don't ask), but the first three are X, Y, and Z: since we're drawing sprites, leave Z at zero.

4. Finally, we're setting `gl_PointSize` to 64. This is the drawing size of our point, which I covered at the start of this post. 🔳

⚠️ Whenever you see `gl_`, this is an internal part of WebGL. These are usually outputs to the vertex shader, and inputs to the fragment shader.

### Fragment shader

The fragment shader will later be where we apply a texture, because it's run for every drawn pixel. For now, let's just make it draw a solid color so we know it's working. 🔴

```js
const fragmentShaderSource = `
void main() {
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;
```

⚠️ In OpenGL, we specify colors as vectors of four floats. This matches what you know from CSS/HTML: one value for red, green, blue, and alpha.

## Step 2: Compile the shader program

Now that we have source, there's a few steps to compile it. Both types of shaders compile the same way, so add a helper that compiles them: 🗜️

```js
function loadShader(gl, type, source) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);

  const status = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
  if (!status) {
    throw new TypeError(`couldn't compile shader:\n${gl.getShaderInfoLog(shader)}`);
  }
  return shader;
}
```

Now, use it to instantiate both vertex and fragment shaders:

```js
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
```

And finally, build the whole program: ⚒️

```js
const shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);

const status = gl.getProgramParameter(shaderProgram, gl.LINK_STATUS);
if (!status) {
  throw new TypeError(`couldn't link shader program:\n${gl.getProgramInfoLog(shaderProgram)}`);
}
```

You should reload the page now to make sure you don't have any errors, but you won't yet see any output. 🙅

## Step 3: Upload variables

We now have a program ready to run inside the `shaderProgram` variable. However, we need to tell it what to draw. 🤔💭

First, let's do the easy part—upload the screen dimensions from before. We need to look up the location OpenGL has assigned to our variable, and write the width and height there:

```js
gl.useProgram(shaderProgram);
gl.uniform2f(gl.getUniformLocation(shaderProgram, 'screenSize'), canvas.width, canvas.height);
```

The hard part though, is telling OpenGL to draw lots of sprites. For now, we'll just draw a single one. We create a _local_ `Float32Array` (a [typed array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray)), and upload it to OpenGL:

```js
const array = new Float32Array(1000);  // allow for 500 sprites
array[0] = 128;  // x-value
array[1] = 128;  // y-value

const glBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, glBuffer);
gl.bufferData(gl.ARRAY_BUFFER, array, gl.DYNAMIC_DRAW);  // upload data
```

⚠️ If you change the buffer, you'll need to upload it again.

We've created and uploaded a buffer, but not told our program where to find it. This is like writing our `screenSize` above, but since it's a whole array, it's a bit trickier:

```js
const loc = gl.getAttribLocation(shaderProgram, 'spritePosition');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc,
    2,  // because it was a vec2
    gl.FLOAT,  // vec2 contains floats
    false,  // ignored
    0,   // each value is next to each other
    0);  // starts at start of array
```

Great! We're nearly there.

## Step 4: Draw!

This is the most exciting step! Now we can run and draw something. 🏃💨

Let's add a method that draws (since you might later want to call it every frame):

```js
function draw() {
  gl.clear(gl.COLOR_BUFFER_BIT);   // clear screen
  gl.useProgram(shaderProgram);    // activate our program
  gl.drawArrays(gl.POINTS, 0, 1);  // run our program by drawing points (one for now)
}
draw();
```

You should see something like this: 🎉

{% codepen https://codepen.io/samthor/pen/VgMzbr %}

This is great, but it's just a red square. Let's add some texture. 👩‍🎨

## Step 5: See a texture

First, let's add an `<img>` tag to the HTML of your page. This is a lazy way to bring in an image that will be used by OpenGL—and it's even lazier because the image is just encoded in base64:

```html
<img src="DATA:image/png;base64,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" id="icon" hidden />
```

Now, we need to make some changes to the JavaScript. First, let's replace our fragment shader from before. Remember—this is the code that's run for every pixel, so we can tell it to draw the right color for each position: 🎯

```js
const fragmentShaderSource = `
uniform sampler2D spriteTexture;  // texture we are drawing

void main() {
  gl_FragColor = texture2D(spriteTexture, gl_PointCoord);
}
`;
```

The `gl_PointCoord` is a variable provided for us which specifies the position within the 64-pixel square we're rendering to. We use it to index into the texture to determine the color. 📇

And lastly, before the `draw()` call, add this code to actually upload the image to OpenGL:

```js
const icon = document.getElementById('icon');  // get the <img> tag

const glTexture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);  // this is the 0th texture
gl.bindTexture(gl.TEXTURE_2D, glTexture);

// actually upload bytes
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, icon);

// generates a version for different resolutions, needed to draw
gl.generateMipmap(gl.TEXTURE_2D);
```

Now refresh and check out your amazing image! 👏

{% codepen https://codepen.io/samthor/pen/Noaaep %}

We're actually cheating a bit here 😊. Because this is the first texture we've created, we don't have to set the `spriteTexture` uniform to anything—it's just got a sensible default of zero, which matches the 0️⃣th texture uploaded before. If you wanted to write the value, it would look like:

```js
gl.uniform2f(gl.getUniformLocation(shaderProgram, 'spriteTexture'), 0);
```

## Step 6: Extras

The demo above has two extra bits:

1. If you click on the canvas, we'll add an extra sprite and redraw the scene. Take a look to find the code! 🔍

2. We've set a blend mode, so that transparent parts of the texture don't render as solid white ⬜. We just set this before the `draw()` call:

```js
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
```

You might notice that creating sprites on top of others causes clashing around the edges. This unfortunately not a simple problem to solve with OpenGL, and it's out of scope of this tutorial, for sure. 😢

# Done

We're done! Or you skimmed to the end. Either is good 👌

This was an incredibly specific tutorial about drawing sprites with OpenGL. If you're like me, you're now incredibly excited 😆 about writing helpers—like tools to move your sprites around, to animate things, to render multiple textures. 🏗️

Regardless, I hope you've enjoyed this read, and if you'd like to know more—_let me know_! There's a lot more to do with sprites—rotate, move, animation—just based on this simple approach.

🙋‍♂️