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.
Go to the next tutorial, WebGL Square.