WebGL Triangle

Drawing with WebGL

If you are completely new to WebGL, you may find it helpful to review WebGL Concepts.

This tutorial shows how to setup a WebGL project with minimal code. We'll draw a red triangle:

View the demo or jump ahead and edit the code.

The next tutorial, WebGL Square, builds on this one.

The HTML Page

First, create a web page. It needs a <canvas> element to display the output of our WebGL program. We'll arbitrarily set the size to 500×500. We also need the code for the vertex shader, the fragment shader, and some JavaScript to set everything up. We'll set an id on the elements that JavaScript needs to find:

<!doctype html>
<html>
  <body>
    <canvas id="canvas" width="500" height="500"></canvas>

    <script id="vertex" type="x-shader/x-vertex">
      // GLSL code...
    </script>

    <script id="fragment" type="x-shader/x-fragment">
      // GLSL code...
    </script>

    <script>
      // JavaScript WebGL setup code...
    </script>
  </body>
</html>

All the code is in <script> tags. The shader GLSL code uses custom types like "x-shader/x-vertex" to prevents the browser from executing the code as JavaScript. These custom scripts types are recognized by some IDE plugins to provide syntax highlighting inside HTML documents.

JavaScript WebGL setup code

Now that we have a page, it's time to start coding. Let's begin with the JavaScript WebGL setup code in the final <script> tag of the page. We need to work with the canvas and shader code, so let's get them from the document:

const canvas = document.getElementById("canvas");
const vertexCode = document.getElementById("vertex").textContent;
const fragmentCode = document.getElementById("fragment").textContent;

We ask the canvas for a WebGL2 context, which provides the WebGL API. We throw an error when WebGL2 is not available so we can look for problems in the browser console:

const gl = canvas.getContext("webgl2");
if (!gl) throw "WebGL2 not supported";

Using the gl context object, first we setup the vertex shader. We create a shader object, set its source code, compile the code, and throw any compilation errors:

const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexCode.trim());
gl.compileShader(vertexShader);
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
  throw gl.getShaderInfoLog(vertexShader);
}

And then the same thing for the fragment shader:

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentCode.trim());
gl.compileShader(fragmentShader);
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
  throw gl.getShaderInfoLog(fragmentShader);
}

Note the trim() calls, which we will see avoids some trouble later.

We don't have any shader code in our <script> tags yet, so these will fail to compile for now. Let's finish the JavaScript setup code first. We create a full WebGL program by linking the two shaders together, like this:

const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  throw gl.getProgramInfoLog(program);
}
gl.useProgram(program);

The program is ready to receive instructions to draw something. We're drawing a single triangle, so we need three vertices.

The coordinate system of the vertex shader is a cube going from -1 to 1 in all three dimensions: left to right, bottom to top, and near to far. The point (-1,-1,-1) is the near bottom left corner of the cube and (1,1,1) is the far upper right corner. Anything outside this cube is clipped and hidden, so this coordinate system is called clip space.

We're going to work in 2D for now, so we'll set z=0 on every vertex. This vertex list will draw a triangle with corners in the left bottom (-1,-1), right bottom (1,-1), and right upper (1,1) corner of our coordinate system:

const vertices = [
  [-1, -1, 0], // [x, y, z]
  [1, -1, 0],
  [1, 1, 0],
];

const vertices is a JavaScript Array. We need to convert it to a WebGL array-like data structure called a buffer that we can pass to the GPU. Shader code is strictly typed, and JavaScript Arrays are not, so before we do anything else we need to convert it to one of JavaScript's typed arrays, specifically a Float32Array.

const vertexData = new Float32Array(vertices.flat());

We call vertices.flat() because a buffer is a simple list of numbers with no concept that they represent groups of (x,y,z) coordinates. We could have avoided this by not defining const vertices with nested arrays, but nested arrays make it clear it's a list of 3D coordinates and simplify the code below where we call vertices.length.

Now we can feed our typed array into a GPU buffer in two steps. WebGL provides an important global variable called gl.ARRAY_BUFFER, which is a reference to the where we need to send the vertices. First we create a new buffer associated with that global variable and then we fill the buffer with our vertex data:

gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.STATIC_DRAW);

The gl.STATIC_DRAW parameter tells WebGL the list of vertices won't change, which can help optimize the program.

In order to use this vertex buffer in our shader code, we need to give it a name. We'll name it "vertexPosition" by doing this:

const vertexPosition = gl.getAttribLocation(program, "vertexPosition");

The type of shader input that receives vertices is called an attribute, hence the function name. Our vertexPosition input can then be attached to the vertex buffer with these two commands:

gl.enableVertexAttribArray(vertexPosition);
gl.vertexAttribPointer(vertexPosition, 3, gl.FLOAT, false, 0, 0);

WebGL requires us to explicitly enable the input first. Then, although it is completely unclear it's doing this, vertexAttribPointer() connects the gl.ARRAY_BUFFER vertex buffer to the shader input named "vertexPosition". The second parameter 3 is important. It tells WebGL we are using 3D vertices. Technically we can pass in 1D, 2D, 3D, or 4D vertices, but conceptually we're always working with 3D coordinates in the vertex shader. The remaining parameters are for advanced uses cases and will almost always be gl.FLOAT, false, 0, 0 in these tutorials. See MDN for more info.

Finally, we are ready to draw the triangle:

gl.drawArrays(gl.TRIANGLES, 0, vertices.length);

gl.TRIANGLES tells WebGL we want to render triangles from groups of three vertices. This is where we could tell it to render lines or points instead.

We can't see anything yet because we haven't written any shader code for the GPU to run. Let's do that now.

The vertex shader

The vertex shader's job is to transform the input vertices. It processes a single vertex at a time by defining a main() function that computes the transformed vertex position of a given input vertex. The GPU runs many copies of the shader algorithm in parallel for all the vertices.

For this tutorial, we already setup the vertices the way we wanted in JavaScript. So our vertex shader doesn't really need to do anything. It's the "no-op" vertex shader. Add this code to your <script id="vertex" type="x-shader/x-vertex"> tag:

#version 300 es

in vec4 vertexPosition;

void main() {
  gl_Position = vertexPosition;
}

#version 300 es tells the GPU we're using OpenGL ES Shading Language version 3.0 (the latest supported by browsers at the time of writing). If we don't start with that line of code, the browser will default to WebGL1 / ES 2 (even though we got a "webgl2" context from the canvas), which is not what we want. Be careful: This line of code must be the very first line of the shader program. No empty lines allowed! This is why we did vertexShader.textContent.trim() in the JavaScript setup code.

in vec4 vertexPosition is the current vertex from the input list that is being processed. in means input and vec4 is a 4D vector (a 4 element list) of floating point numbers, which represents the 3D coordinate of a vertex. But wait: Why are we using a 4D vector to represent a 3D coordinate? These components of this vector are called (x,y,z,w), where (x,y,z) is the 3D coordinate. The w value is used for 3D perspective calculations and divides the value of x, y, and z during rasterization so, for example, (1, 2, 3, 10) would become the coordinate (1/10, 2/10, 3/10). This is for advanced use cases and we can ignore it for now. When our list of 3D vertices from JavaScript is converted to this vec4 input, w defaults to 1 and has no effect on the (x,y,z) position.

Since we have three vertices, main() will be called 3 times in parallel and the vertexPosition for each call will have the 3D coordinates [-1, -1, 0], [1, -1, 0] , and [1, 1, 0] as defined in the setup code. As explained in the previous paragraph, the actual values of vertexPosition are vec4(-1, -1, 0, 1), vec4(1, -1, 0, 1), vec4(1, 1, 0, 1), which is effectively the same thing.

To output a vertex, main() needs to assign the output to the pre-defined gl_Position variable, rather than return a value. We assign this to the input because we aren't changing the vertex positions. This tells WebGL to render the triangle that we defined in JavaScript.

The fragment shader

Now we've told WebGL where to render a triangle. The final step is to tell it what colors the pixels are. That's the fragment shader's job. We'll set the whole triangle to a single color with this code inside the <script id="fragment" type="x-shader/x-fragment"> tag:

#version 300 es
precision highp float;

out vec4 fragColor;

void main() {
  fragColor = vec4(1, 0, 0, 1);
}

Like in the vertex shader, we have to start with #version 300 es. This is always the case when using GLSL ES 3.0.

In the fragment shader we also have to specify our floating point precision. There's a few options here but I always opt for the maximum highp precision to avoid unnecessary issues with low precision (we'll be drawing fractals later and need high precision for that). Keep in mind fragment shaders can potentially be optimized by using lower precision.

Next we need to declare our output vec4 fragColor, similar to how we declared our input vertices in the vertex shader. This output is the color of each pixel. It's a 4D vector representing RGBA (red, green, blue, alpha). Note: We did not tell WebGL the name "fragColor" output in our JavaScript setup code like we had to for the "vertexPosition" input. When you have a single output, WebGL assumes it's the pixel color.

All that's left is to actually set the color we want the pixel to be. This goes inside another main() method. vec4(1, 0, 0, 1) means 100% red, 0% green, 0% blue, and 100% alpha channel. By assigning that value to our output, we draw every triangle (in our case a single triangle) a red color.

Results

Whew! We're done. Putting all of that together, we should see:

Here's the full HTML page with code:

  
    <!doctype html>
    <html>
      <body>
        <canvas id="canvas" width="500" height="500"></canvas>

        <script id="vertex" type="x-shader/x-vertex">
          #version 300 es

          in vec4 vertexPosition;

          void main() {
            gl_Position = vertexPosition;
          }
        </script>

        <script id="fragment" type="x-shader/x-fragment">
          #version 300 es
          precision highp float;

          out vec4 fragColor;

          void main() {
            fragColor = vec4(1, 0, 0, 1);
          }
        </script>

        <script>
          const canvas = document.getElementById("canvas");
          const vertexCode = document.getElementById("vertex").textContent;
          const fragmentCode = document.getElementById("fragment").textContent;

          const gl = canvas.getContext("webgl2");
          if (!gl) throw "WebGL2 not supported";

          const vertexShader = gl.createShader(gl.VERTEX_SHADER);
          gl.shaderSource(vertexShader, vertexCode.trim());
          gl.compileShader(vertexShader);
          if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
            throw gl.getShaderInfoLog(vertexShader);
          }

          const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
          gl.shaderSource(fragmentShader, fragmentCode.trim());
          gl.compileShader(fragmentShader);
          if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
            throw gl.getShaderInfoLog(fragmentShader);
          }

          const program = gl.createProgram();
          gl.attachShader(program, vertexShader);
          gl.attachShader(program, fragmentShader);
          gl.linkProgram(program);
          if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
            throw gl.getProgramInfoLog(program);
          }
          gl.useProgram(program);

          const vertices = [
            [-1, -1, 0],
            [1, -1, 0],
            [1, 1, 0],
          ];
          const vertexData = new Float32Array(vertices.flat());
          gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
          gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.STATIC_DRAW);

          const vertexPosition = gl.getAttribLocation(program, "vertexPosition");
          gl.enableVertexAttribArray(vertexPosition);
          gl.vertexAttribPointer(vertexPosition, 3, gl.FLOAT, false, 0, 0);

          gl.drawArrays(gl.TRIANGLES, 0, vertices.length);
        </script>
      </body>
    </html>
  

And there we have it: A zero-dependency WebGL program that can serve as the basis for further exploration.

View the demo

Try it on CodePen

Go to the next tutorial, WebGL Square.

Table of Contents:
  1. WebGL Concepts
  2. WebGL Triangle
  3. WebGL Square
  4. WebGL Gradient
  5. WebGL Animation