loading...
Cover image for WebGL point sprites, a tutorial

WebGL point sprites, a tutorial

samthor profile image Sam Thorogood ・7 min read

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 or PixiJS. 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

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:

// 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. 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. 💻

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. 🔴

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: 🗜️

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:

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

And finally, build the whole program: ⚒️

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:

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), and upload it to OpenGL:

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:

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):

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: 🎉

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:

<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: 🎯

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:

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! 👏

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:

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:

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.

🙋‍♂️

Posted on by:

samthor profile

Sam Thorogood

@samthor

Developer Relations for Web at Google.

Discussion

markdown guide
 

Very good, thank you.
A while a go, I have integrate these WebGL primitives from a php WordPress server.

blog.cyring.free.fr/?page_id=4496&...

blog.cyring.free.fr/?page_id=4576&...

The goal was to source coordinates and textures from the html page.

Let me know if you are interested by the source code

 

Hello,
This was a great great tutorial. Thank you :)
I would like to understand why you create a buffer of 500 floats ?
Where is this buffer used in the program ?
And... please write some other WebGL tutorials.
(Sorry for my english.)

 

So the buffer is new Float32Array(1000);, it actually takes 1000 floats.

But since it's used to provide a vec2, an x,y vector, then it supports 500 sprites.

The "500" is just a random number. The demo will fail if you try to add more than 500 images. If you wanted to support any number of sprites, you'd have to create a new, larger array when the original ran out of space.

Also, thanks, I'm glad you enjoyed the article!

 

Understood. Thank you this explanation.

 

I'm definitely in for more OpenGL/WebGL!