WebGL 01: "Hello, triangle!"

In this tutorial, you will learn how to draw the most basic shape in graphics programming: a triangle.

  1. The core stages of the graphics pipeline
  2. Uploading geometry to the GPU
  3. Programming basic vertex shaders in GLSL
  4. Programming basic fragment shaders in GLSL
  5. Executing draw commands
The full source code for this tutorial is on GitHub
Indigo Code iconA live demo is available here on indigocode.dev

The graphics pipeline

Real-time rendering APIs use a triangle rasterization pipeline to efficiently render geometry, and provide artistic freedom for a huge variety of effects.

For both 2D and 3D effects, it turns out a lot of normally very hard things become very simple if you only use triangles. If you put enough triangles together, you can make all sorts of shapes, even approximating smooth surfaces with enough of them.

Modern graphics applications almost exclusively use triangles. It's much more obvious in old blocky video games, but even the more smooth geometry of modern games is just a lot of triangles (with some exceptions).

Loading
Loading
Wireframe shader adapted from mattdesl/webgl-wireframes

For this tutorial, we'll focus on a single triangle. It'll be centered on the game surface, and take up half of the vertical and horizontal space. This triangle right here:

WebGL Tutorial Triangle

WebGL is focused on extremely efficiently passing information about triangles along five key stages:

  1. Vertex Shading: Place an input point (vertex) in clip space (defined below)
    • "Hello, Triangle" will run this 3 times - one for each vertex on one triangle
  2. Primitive Assembly: Organize points (vertices) into triangles
    • This is run once - to organize our 3 input vertices into a triangle
  3. Rasterization: Identify which pixels are inside an input triangle
    • Find which of the 360,000 (600x600) frame pixels belong to our triangle
    • Output the (approximately) 45,000 included pixels
  4. Fragment Shading: Decide the color for a single pixel
    • Invoked (about) 45,000 times - once per pixel in our triangle!
    • Output the color indigo - 29.4% red, 0% green, 51% blue
  5. Output Merging: Update the output frame image, usually by replacing the existing color with the fragment shader output
    • Replace existing pixels with our newly shaded pixel fragment (default behavior)

The YouTube version of this tutorial (linked at the top of this page) shows a nice animated walkthrough of each of these stages.

GPU Programs and GPU Memory

Modern computers have a general-purpose Central Processing Unit (CPU) built for general programming, and also a Graphics Processing Unit (GPU) built specifically for handling graphics. GPUs sacrifice the ability to do individual tasks quickly in exchange for doing huge amounts of tasks at the same time.

An example with realistic(-ish) numbers:

Say a CPU can calculate a pixel in 50 nanoseconds, but it takes the GPU 300 nanoseconds (six times longer). The CPU can process 8 pixels simultaneously, and the GPU can process 1024.

A 1080p image has 2,073,600 pixels, which takes the CPU 259,200 batches, or the GPU 225 batches.

Even though each individual pixel is 6x slower on the GPU, the GPU is still able to finish in 0.07 seconds, while it takes the CPU nearly 13 seconds to do the same work!

You can almost think of the CPU and GPU as two separate computers, each with their own way of running code - and, importantly, each with their own distinct memory.

Code run on GPUs needs to be written in a GPU-friendly programming language, and data used by that code needs to be put in VRAM buffers. To make things more interesting, GPUs typically don't just have one single VRAM pool, but instead many VRAM areas, each one optimized for different types of memory access.

So, before we can draw a triangle, we need to do a few things to get the GPU ready:

Clip Space

One last thing before getting into code: that "clip space" I mentioned earlier.

Games can support a bunch of different output resolutions - 1080p, 1440p, 4K, 8K, 800x600 if you're running it on an actual potato, whatever. At each of these output resolutions, the relative location of each triangle will stay the same.

For example: You might draw a player character starting at the (X, Y) coordinates (108, 192) on a 1080p monitor, or at positions (216, 384) on a 4K monitor. It makes more sense to describe that position as (10%, 10%) on both.

Because the actual output resolution doesn't matter until the rasterizer stage anyways, vertex shaders operate in clip space, which has three dimensions:

The X dimension goes from the left of the frame (-1) to the right of the frame (+1).

The Y dimension goes from the left of the frame (-1) to the right of the frame (+1).

The Z dimension defines draw order - anything with Z<-1 or Z>1 is out of frame and should not be drawn, and the pixel fragment with the highest Z value should be drawn to the final image.

Create a "Hello, Triangle" HTML page

For any web app (WebGL or not), an HTML page is the entry point.

The most important thing here is the <canvas> element.

<!DOCTYPE HTML>
<html>
  <head>
    <title>Hello, Triangle!</title>
    <style>
      html, body {
        background-color: #2a2a2a;
      }
      #demo-canvas {
        width: 200px;
        height: 200px;
        background-color: #da6052;
      }
    </style>
  </head>
  <body>
    <canvas id="demo-canvas" width="200" height="200"></canvas>
  </body>
</html>

What's going on here?

  1. The canvas is created with an id="demo-canvas" attribute.
    • This gives us a way to set CSS styles on the canvas
    • It will also help later when we need to access this canvas in JavaScript
  2. The on-screen size of the canvas is set with the width and height CSS styles.
  3. The width and height canvas attributes set the size of the render buffer
    • Setting them as the same as the CSS width and height is a good place to start.

I like setting background-color: #da6052; on WebGL canvases to make it really pop!

In a real WebGL app, you should be clearing the canvas (more on that later), so any time that bright color appears, it's a good sign that something is probably wrong with your app!

Make your errors LOUD to make them easy to find.

Set up WebGL

The canvas HTML tag gives you an area on the page to display a WebGL-generated image, but we need some JavaScript to start actually drawing things.

To get a reference to the HTML canvas element, the document.getElementById() function can be used:

const canvas = document.getElementById("demo-canvas");

All WebGL commands for that canvas go through a WebGL2RenderingContext, object, which can be obtained like so:

const gl = canvas.getContext("webgl2");

Great!

The variable name gl is a bit of a convention, but there's nothing special about the name. Cosmetically it's nice, since the OpenGL C API calls are all prefixed by "gl" and the WebGL equivalents aren't, but name it whatever you want.

WebGL operates by drawing triangles very, very efficiently. So, the most basic WebGL program is one that draws a simple triangle.

In a nutshell, every triangle is drawn with this process:

  1. Determine where each corner of the triangle goes with a vertex shader
  2. Decide which pixels in the output are inside the triangle through the rasterizer stage
  3. Decide what color each pixel should be with a fragment shader

The vertex and fragment shaders are custom programs that get run on the GPU, and are written in a special programming language (GLSL).

Shaders can only read data that is passed into GPU memory in particular structured ways with WebGL calls, so there's a bit of setup required to actually run this process:

  1. Upload triangle geometry information to the GPU
  2. Compile shader code and send result to the GPU for later execution
  3. Upload configuration variables required by vertex or fragment shaders to the GPU

Define triangle geometry and upload to GPU

Since we're only drawing one triangle, we might as well define it in clip space from the beginning.

const triangleVerticies = [
  // Top middle
  0.0, 0.5,
  // Bottom left
  -0.5, -0.5,
  // Bottom right
  0.5, -0.5,
];
  1. Halfway left-to-right (0.0) and 3/4 of the way bottom-to-top (0.5)
  2. 1/4 left-to-right (-0.5) and 1/4 of the way bottom-to-top (-0.5)
  3. 3/4 left-to-right (0.5) and 1/4 of the way bottom-to-top (-0.5)

This is just a JavaScript array of numbers, which is a problem.

For one, JavaScript arrays aren't necessarily contiguous - the actual binary data in memory for each element might not be right next to its neighbors.

JavaScript arrays are made up of the JavaScript Number type, which is a 64-bit floating point number. GPUs quite prefer 32-bit floats.

Thankfully, the solution to both problems exist in something that can be built from a standard JS array - the Float32Array type.

const triangleGeoCpuBuffer = new Float32Array(triangleVerticies);

Great! Now we have data in a GPU-friendly format, but still in main RAM and not GPU memory. To move code over to GPU VRAM, create a WebGLBuffer object and fill it with data using the bufferData command.

WebGL buffers have specific bind points that each serve a specific job - the vertex buffer bind point is gl.ARRAY_BUFFER.

const triangleGeoBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, triangleGeoBuffer);
gl.bufferData(gl.ARRAY_BUFFER, triangleGeoCpuBuffer, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);

Sooooo.... gl.ARRAY_BUFFER? gl.STATIC_DRAW?

Old-ish graphics APIs like OpenGL were designed to hide the complexity of GPU memory pools from developers, and instead only asked developers to provide hints about how the data in question would be used.

The gl.ARRAY_BUFFER binding point is used for vertex attributes - position, color, texture coordinates, etc. Instead of interacting directly with a buffer using a WebGL call, you interact with whatever buffer is bound to the specified binding point.

Binding points are very easy to mess up! I like to set the gl.ARRAY_BUFFER slot back to null immediately after I'm done sending it data to avoid accidentally sending data there down the road. Trust me - bind point bugs are much easier to find down the road if you get in this habit!

Author and compile a simple vertex shader

The graphics pipeline requires two custom functions to be written by the application developer and uploaded to the GPU - the vertex shader is the first one.

The primary job of the vertex shader is to take all the input vertex attributes and use them to generate a final clip space position output.

Our case is easy, since we already defined our triangle in XY clip space.

In more complex scenes, the vertex shader is where you can add effects like animation, geometry distortion, 3D perspective, camera movements, etc.

This is the code for our vertex shader:

#version 300 es
precision mediump float;

in vec2 vertexPosition;

void main() {
  gl_Position = vec4(vertexPosition, 0.0, 1.0);
}

Shader code is written in GLSL, the OpenGL Shading Language.

Important things to note here:

gl_Position takes four values - the first two are the clip-space X and Y coordinates.

The third, Z, is for depth information. This helps the GPU decide which pixel fragment should be shown if multiple triangles draw to the same pixel.

The fourth, W, is special and used for 3D effects - I'll cover this in more detail in a later tutorial.

The TL;DR of W is that X, Y, and Z are all divided by W before being used in the rasterizer. Any number divided by 1 is the same number, so setting 1 here applies no perspective division effect.

Shader code can be written in-line in JavaScript using multi-line strings before being compiled for the specific user's GPU and checked for errors, like so:

const vertexShaderSourceCode = `#version 300 es
precision mediump float;

in vec2 vertexPosition;

void main() {
  gl_Position = vec4(vertexPosition, 0.0, 1.0);
}`;

const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSourceCode);
gl.compileShader(vertexShader);
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
  const errorMessage = gl.getShaderInfoLog(vertexShader);
  console.error(`Failed to compile vertex shader: ${errorMessage}`);
  return;
}

Always check for compile errors when developing WebGL apps! It's incredibly easy to make mistakes here. Error checking is a bit weird (well, C-style). The gist of it is to check for compile success with getShaderParameter (checking specifically the gl.COMPILE_STATUS parameter), and report errors if it reports failure. There will be a pretty nice error message that you can get with the gl.getShaderInfoLog method.

You can also keep shaders in their own files, and load them asynchronously with JavaScript, or at build time using Webpack.

Author and compile a simple vertex shader

The second custom GPU function needed to draw anything is a fragment shader. The fragment shader takes some pixel that the rasterizer has identified as part of a triangle, and declares what color that fragment should be.

The vertex shader has a special gl_Position output, but fragment shaders are capable of writing to multiple outputs for some advanced effects. Older version of GLSL have a special gl_Color variable, but WebGL uses a user-defined output that matches the output format (in this basic case, RGBA).

#version 300 es
precision mediump float;

out vec4 outputColor;

void main() {
  outputColor = vec4(0.294, 0.0, 0.51, 1.0);
}

Nice and easy - define an output variable color (the name here is unimportant, just the type), and set it to the RGBA color for indigo.

Compiling and sending this shader works the same as it did for the vertex shader, with minor adjustments:

const fragmentShaderSourceCode = `#version 300 es
precision mediump float;

out vec4 outputColor;

void main() {
  outputColor = vec4(0.294, 0.0, 0.51, 1.0);
}`;

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSourceCode);
gl.compileShader(fragmentShader);
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
  const errorMessage = gl.getShaderInfoLog(fragmentShader);
  console.error(`Failed to compile fragment shader: ${errorMessage}`);
  return;
}

This tutorial is WET (Write Everything Twice) for shader creation code because I try to avoid too much abstraction in tutorials. Your code should generally be DRY (Don't Repeat Yourself) - write a buildShader(shaderType, shaderCode) method for any real project to avoid nasty copy/paste problems everywhere.

Combining Shaders into a WebGLProgram

WebGL never uses just a vertex or a fragment shader, and it needs to make sure that the user-defined outputs of a vertex shader are compatible with the inputs for a fragment shader. We aren't using either, but this will come up later!

The object for a compatible vertex+fragment shader pair is confusingly called a program. To set up a program, specify which vertex and fragment shaders should be used, and link the program to check for errors:

const helloTriangleProgram = gl.createProgram();
gl.attachShader(helloTriangleProgram, vertexShader);
gl.attachShader(helloTriangleProgram, fragmentShader);
gl.linkProgram(helloTriangleProgram);
if (!gl.getProgramParameter(helloTriangleProgram, gl.LINK_STATUS)) {
  const errorMessage = gl.getProgramInfoLog(helloTriangleProgram);
  console.error(`Failed to link GPU program: ${errorMessage}`);
  return;
}

Notice the familiar error checking code, this time using Program instead of Shader and checking for gl.LINK_STATUS instead of gl.COMPILE_STATUS. Same idea, different build step.

Once you have your WebGLProgram, the next step is to get the handles for the vertex shader input attributes, so that you can properly wire up your vertex shader data. Unlike other WebGL handle types, an attribute handle is an integer that refers to a location.

You can figure out the location of each attribute you need in one of a few ways:

  1. Know offhand that the first listed input has attribute index 0, and each following input has the next number (1, 2, 3, ...)
  2. Add a GLSL location=n annotation to each attribute.
  3. Ask WebGL what the attribute location is by the name of the input variable.

I usually prefer the third option. If you edit your GLSL code and add, remove, or re-order input attributes, you don't have to worry about keeping things pretty or updating a bunch of hard-coded values in your vertex buffer binding code.

Also, if the graphics driver's GLSL compiler optimizes an unused input away, the attribute appears as invalid, which gives a nice hint that maybe something is wrong in your shader code (e.g. an expected input is unused because you forgot to include it in the math somewhere).

const vertexPositionAttributeLocation = gl.getAttribLocation(
  helloTriangleProgram,
  "vertexPosition"
);
if (vertexPositionAttributeLocation < 0) {
  console.error(`Failed to get attribute location for vertexPosition`);
  return;
}

Once this is finished, the setup code is complete - congratulations! The worst part is over! We can move on to rendering now.

Planning out the render loop

Assembling all the code up to this point, the result... looks exactly the same as before. We did a whole bunch of setup, but we haven't actually drawn (or rendered) anything yet.

Check the error console in the demo below - if there are any errors, the rest of this demo won't work on your browser!

<!DOCTYPE html>
<html>
  <head>
    <title>Hello, Triangle!</title>
    <style>
      html,
      body {
        background-color: #2a2a2a;
      }
      #demo-canvas {
        width: 200px;
        height: 200px;
        background-color: #da6052;
      }
    </style>
  </head>
  <body>
    <canvas id="demo-canvas" width="200" height="200"></canvas>
    <script src="hello-triangle.js"></script>
  </body>
</html>


Quick note - I put everything in a runDemo function so that I could use return; statements if something went wrong. That's a style choice, you could also throw an error or just let it fail if something didn't initialize right.

At this point, all the data that needs to be on the GPU has been assembled and uploaded.

Ultimately, everything boils down to a draw(...) command, which will dispatch instructions to the GPU and draw stuff based on the state currently bound to the WebGL pipeline.

For this tutorial, it doesn't really matter in which order you do the pipeline preparation steps, and I encourage you to play around with it in the demo window below!

I'm going to do the preparation steps in an order that generally makes sense for more complex WebGL scenes, which looks like this:

  1. Set up the HTML canvas for the next frame
    • Make sure the render surface size is correct
    • Blank the render surface
  2. Set up the viewport for the next frame
  3. For each shader... (in our case, only one):
    1. Set the shader for the current visual effect
    2. For each object using that effect... (in our case, only one)
      1. Bind vertex attributes to that object's vertex buffers.
      2. Execute a draw call to draw all the triangles in that object.

Prepare canvas for rendering

An important but unintuitive part of WebGL is that you're never drawing directly to the canvas element. You're drawing to an image that will eventually be drawn in the same place the canvas exists in the browser.

It's a bit hard to explain, so let me show you what I mean - go ahead and play with the sliders in the demo below, and see how changing the different width/height properties change the output image. Try moving width and height to a much lower number than the CSS width and CSS height!

<canvas style="width: 200px; height: 200;" width="200" height=200"></canvas>

The canvas width and height properties specify how big the image WebGL generates is, while the CSS width and height styles affect how large the canvas appears on the page. The WebGL generated image will stretch and shrink as appropriate to fit into the space set aside by the browser on the page.

I've also included devicePixelRatio, which you can think of as the zoom level on a screen. Zoom is used on high pixel density displays or by visually impaired users to make application UI easier to read.

Accounting for this by multiplying canvas width and height properties is a quick and dirty way to make sure your output takes full advantage of your user's display capabilities.

WebGLFundamentals has a great read on the topic. For real-world applications, it's a bit more complicated than "multiply by this thing first".

For this demo, we'll just do the easy thing - we'll set the canvas width and height attributes to whatever the HTML rendered size of the thing is on-screen. That way you can play around with CSS and not have to worry about updating the size of your render buffer.

There's another sizing property that needs consideration though - the gl.viewport(...) area. WebGL needs to be told what sub-region of the full output buffer it should be drawing to. For our purposes, we just want to draw to the whole thing, but below I've added viewport sliders to play around with:

<canvas style="width: 400px; height: 400;" width="400" height=400"></canvas>
gl.viewport(0, 0, 200, 200);

Let's put it together into code:

canvas.width = canvas.clientWidth;   // * window.devicePixelRatio if you want
canvas.height = canvas.clientHeight; // * window.devicePixelRatio if you want
gl.viewport(0, 0, canvas.width, canvas.height);

Clear the canvas

To draw something to a canvas, the first step is to empty the surface. By default, the render surface to a WebGL context is just a transparent image, which is why the salmon color set to the background-color of the Canvas element is showing up. We want our triangle to be drawn on a dark gray background instead:

gl.clearColor(0.08, 0.08, 0.08, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

The clear command can clear one of a few WebGL buffers - by passing in gl.COLOR_BUFFER_BIT, we clarify that we want to clear the color buffer, which is the image that will eventually be displayed on-screen.

Configure the graphics pipeline

Remember from before that we have two WebGL resources that we've created for drawing a triangle - the WebGL Program with instructions for placing and shading a triangle, and a vertex buffer that holds the geometry information of our triangle.

Setting the program is easy:

gl.useProgram(helloTriangleProgram);

Attaching the vertex buffer is a bit more complicated!

WebGL buffers are just raw binary data sitting somewhere in GPU memory. We need to tell WebGL how to populate the vertexPosition attribute in a vertex shader.

To do that, we bind the vertex buffer we want to use to the ARRAY_BUFFER buffer slot, and then we use a method called gl.vertexAttribPointer to tell WebGL how to read data out of that buffer.

gl.enableVertexAttribArray(vertexPositionAttributeLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, triangleGeoBuffer);
gl.vertexAttribPointer(
  /* index: vertex attrib location */
  vertexPositionAttributeLocation,
  /* size: number of components in the attribute */
  2,
  /* type: type of data in the GPU buffer for this attribute */
  gl.FLOAT,
  /* normalized: if type=float and is writing to a vec(n) float input, should WebGL normalize the ints first? */
  false,
  /* stride: bytes between starting byte of attribute for a vertex and the same attrib for the next vertex */
  2 * Float32Array.BYTES_PER_ELEMENT,
  /* offset: bytes between the start of the buffer and the first byte of the attribute */
  0
);

Your guess is as good as mine why we need to call gl.enableVertexAttribArray. I'm sure there's some driver nonsense going on behind the scenes that needs that information. But for whatever reason, any vertex attribute that we use needs to be enabled before it can be used.

Remember that vertexPositionAttributeLocation is just a number referring to an input binding location for shaders! If you have multiple shaders, you won't need to re-enable each attribute slot, just the ones that are not shared between shaders.

The vertexAttribPointer call is easily the most complicated individual WebGL call we'll be using here, so I strongly suggest reading the vertexAttribArray Mozilla documentation page.

The parameters are:

  1. Index: Which vertex attribute is being bound by the call
  2. Size: The number of components (not bytes) in the input attribute. in vec2 has 2, so... 2.
  3. Type: The type of data in the bound GPU buffer for this attribute (see note below). It was defined with Float32Array, so we use gl.FLOAT.
  4. Normalized: This parameter is ignored when reading floats from GPU buffers. If we had used ints instead, it specifies how ints are converted to floats.
  5. Stride: How many bytes of data to move forward in the buffer to find the same attribute on the next vertex.
  6. Offset: How many bytes of data to move forward from the start of the buffer to find this attribute for the first vertex.

On the type and normalized parameters: you can read integer GPU buffer data into float vertex shader inputs. This can be a useful trick for saving space - if you have thousands of vertices in some geometry and don't need a lot of detail, you can store quantized 16-bit integers instead of full-size 32-bit floats and save a ton of space!

What normalized does in this case is decide how those ints are converted to floats. When it's set to false, the nearest float is used - e.g., 17 (8-bit uint) is read as 17.0 (float). When set to true, the value is first divided by the maximum integer value to get a float in between 0.0-1.0 (for unsigned ints) or -1.0 to 1.0 (for signed ints). Example: 127 (8-bit uint) is read as 0.5 (float).

Or, if you're storing data that exists only between 0 and 1 but don't need very much precision (e.g., a percentage to apply some fragment shading effect down the line), you can store 8-bit integers between 0-255 and normalize them into a float percentage by setting normalized to true.

Stride and offset get a bit more interesting when we start specifying multiple attributes in the same buffer, which we'll get into in the next tutorial. For now, just thinking of stride as how big our attribute is (2 x float32, each float32 is 4 bytes, so 8 bytes) and offset as how much non-position data is at the front of our buffer (...none, so 0) is fine.

Drawing the triangle (finally!)

Alright, are you ready? Everything comes to a head here, all the complicated setup we've been doing has been leading up to this!

Go grab a coffee if you need it, I'll still be here.

Back?

Ready?

Okay! To dispatch a draw call, use the gl.drawArrays command. The three parameters are the type of geometry to use, which vertex to start with, and how many vertices to draw.

gl.drawArrays(gl.TRIANGLES, 0, 3);

We want to draw triangles, we want to start with the first vertex (index = 0), and we want to a draw a single triangle made up of 3 vertices.

That's it!

Closing notes

Congratulations!

WebGL is an intensely complicated API, but the overwhelming majority of the complexity is in setting up the GPU resources needed to draw a frame. This is by design! Think about it - if you're trying to render a whole bunch of interesting shapes and effects, you want as little time as possible to be spent thinking about drawing.

Go ahead and play around with the sandbox of the completed demo, and pat yourself on the back for finishing! There's a lot more to learn, but understanding the basics of the WebGL context, shaders, buffers, and dispatching draw commands puts you in a great spot to learn more about the ins and outs of rendering more interesting scenes.

A few things to try:

<!DOCTYPE html>
<html>
  <head>
    <title>Hello, Triangle!</title>
    <style>
      html,
      body {
        background-color: #2a2a2a;
      }
      #demo-canvas {
        width: 200px;
        height: 200px;
        background-color: #da6052;
      }
    </style>
  </head>
  <body>
    <canvas id="demo-canvas" width="200" height="200"></canvas>
    <script src="hello-triangle.js"></script>
  </body>
</html>

Cheers!