Very Basic WebGL with modern JavaScript
So you’ve heard of WebGL, and think it’s neat, and want to make stuff with it. But if you haven’t done any development with 3D things before, or with OpenGL before, it can be pretty daunting, even if you know JS.
This post is intended to be a modern (as of 2017) walkthrough of incredibly basic WebGL, so that you can take on the rest yourself. It should give you enough of a vocabulary to be able to ask basic questions and search the web for what you’re missing.
Unlike most of the tutorials I’ve found, I want there to be no dependencies. The whole thing is less than 100 lines of actual code, and fits in one script, that needs a canvas with id=”canvas” and nothing else. Which is how it should be: all we’re going to do is draw a couple triangles.
I am going to use some fairly modern JS features: I use let
instead of var
everywhere, and I don’t include polyfills for methods or functions that might not exist in non-modern browsers. You’re welcome to find these elsewhere if you need them, but for the vast majority of the audience trying to get off the ground, they just get in the way of understanding.
What we’ll make:
Yes, that’s right: a couple triangles in 100 lines. Sounds crazy. That’s mostly because there’s a lot of minutiae that you have to take care of if you want to be able to draw millions of triangles very fast. Unfortunately, there’s no good reason to make the “easy” case very easy to code, because it’s not something that anyone other than beginners actually uses! So we’re forced to confront the full brunt of WebGL pretty much right off the bat.
(The complete code is at the bottom of this post, in case you want it. In the meantime, I’ll be explaining things line-by-line or so)
I’ve based this very loosely off of several examples from http://learningwebgl.com/blog/?page_id=1217. Once you’ve read this tutorial, I think it’s a great place to go next. However, I feel that it really throws a lot at you without doing a great job explaining everything, and its reliance on some fairly formidable dependencies (matrix and vertex libraries) can be intimidating and confusing, because they’re not really necessary for very simple demos.
Let’s start easy. We need a canvas:
let canvas = document.getElementById("canvas");let gl = canvas.getContext("webgl");
A canvas is an HTML5 element that’s used for drawing. We’re obtaining a WebGLRenderingContext that lets us do what we want. It holds all of the WebGL-related methods and constants.
If your browser doesn’t support WebGL, this can fail. To be polite, you should check for this:
if (!gl) throw "your browser doesn't support WebGL";
First, some general remarks about 3D rendering. These are largely common knowledge, but it doesn’t hurt to just nail down some basic ideas:
- scenes are composed (generally) of many triangles, to approximate the shapes of objects
- modern rendering is usually done on the GPU, which is specialized at handling graphics and rendering
- the corners of triangles will be referred to as “vertices” in the context of rendering
From here, the first WebGL concept that we have to introduce are shaders. A shader is a program that runs on the GPU. With WebGL, they’re written in a language called GLSL, that looks pretty similar to C. We’ll use a modern (i.e. ES2016) feature called template literals to define them (it’s basically a multiline string).
First, we need the vertex shader. It figures out where the vertices of the triangles are located:
let vertexShaderSource = ` precision mediump float;
attribute vec3 vertexPosition;
void main(void) {
gl_Position = vec4(vertexPosition, 1.0);
}`;
The first thing we do is set the precision of the floats we’re using. This isn’t very important, and you can mostly ignore this (in fact, it will run fine if you exclude it).
The second thing we do is declare a vec3
attribute called vertexPosition
. Attributes are values that are passed per-vertex to the GPU. In this case, we’re passing the location of the vertex as a position in 3D space.
main
is the name of the function that WebGL will use to process each triangle’s vertices.
An attribute is a per-vertex value that is passed from the CPU (JavaScript). We declare it as a vec3
to represent the vertex’s location.
Although we’re using the attribute to say where the vertex is, WeBGL doesn’t know that. To tell it where the vertex should be,we assign the predefined global gl_Position
. You might think that a position would be a vec3
, but in order to represent perspective correctly, it turns out to be more convenient for it to be a vec4
. We’ll get to the details behind this later. For now, just understand that vec4(vertexPosition, 1.0)
makes a vec4
whose fourth component is 1.0
and whose x
, y
, and z
, are from vertexPosition
.
Now that we know where the triangle is located, we need to decide what color it should be. This is the job of the fragment shader. You can usually think of “fragment” as “pixel”, but there are some details that make the reality more complicated. The main job of the fragment shader is to decide the color of each pixel that’s rendered in each triangle.
let fragmentShaderSource = ` precision mediump float;
void main(void) {
gl_FragColor = vec4(1.0, 0.3, 0.1, 1.0);
}`;
It’s pretty similar to the vertex shader. Instead of setting gl_Position
, we set gl_FragColor
. The components of the vector are red, green, blue, and alpha, respectively. Alpha describes the opacity of the color; we set it to 1.0 so that it will be fully opaque.
Now we have to build our shaders into a program. This isn’t too bad:
let vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSource);
gl.compileShader(vertexShader);if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
console.log("err:", gl.getShaderInfoLog(vertexShader);
throw "error";
}
We create a shader object andspecify that it’s a vertex (createShader
), attach the source to it (shaderSource
), and then compile the shader (compileShader
). If something goes wrong (like a syntax error in the shader, or the use of a feature that your browser or hardware doesn’t support) we’ll print out an error message.
Now, we do the same thing for the fragment shader.
let fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader);
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
console.log("err:", gl.getShaderInfoLog(fragmentShader));
throw "error";
}
Next, we combine the two shaders into a “Program”. A program basically just bundles shaders or resources together.
let shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
console.log("err:", gl.getProgramInfoLog(shaderProgram));
throw "error";
}
We attach the shaders, then link the program. If there’s a problem, we report it, like the above.
WebGL makes it easy to switch out programs. You can do this whenever you need to draw a different kind of object in the scene (such as having special materials or effects). This means we have to tell WebGL to use our program:
gl.useProgram(shaderProgram);
If you look above at the vertex shader, we had an attribute called vertexPosition. We need to ask WebGL how to refer to that location, since calling it by name (“vertexPosition”) could be quite slow in comparison to the work that we want it to do. Thus, WebGL will give us an integer location that it lives for fast access. While we’re at it, we’ll also ask WebGL to make the attribute active.
let vertexPositionAttribute = gl.getAttribLocation(shaderProgram, "vertexPosition");
gl.enableVertexAttribArray(vertexPositionAttribute);
Great, we’re getting somewhere! Now let’s create the vertices for our triangles, and send them to the GPU in this attribute.
The Z component is depth; we’ll set it to 0 for all of the vertices.
let triangleVertexArray = [
0, 0, 0, // triangle 1
1, 0, 0,
1, 1, 0, -0.2, -0.5, 0, // triangle 2
-1.0, -0.7, 0,
0, 0, 0,
];
Shuffling data between the CPU and the GPU is one of the slowest parts of rendering. Therefore, we want to do it carefully, and as quickly as possible. WebGL provides a concept known as buffers to help us make this as efficient as possible.
let triangleVertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, triangleVertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(triangleVertexArray), gl.STATIC_DRAW);
First we create the buffer, then we bind it. Binding the buffer makes it the “active” one so-to-speak; the bufferData
operation on the next line is taking data and putting it into the currently-bound-buffer.
In this context, the enum constant ARRAY_BUFFER
indicates that this is per-vertex data. The enum STATIC_DRAW
indicates that we’ll be reading this data frequently, but rarely writing to it. If you’re going to be modifying an attribute list a lot, use DYNAMIC_DRAW
instead. There can be a big difference in speed!
Lots of tutorials seem to prefer a black background. Let’s use a light blue one instead:
gl.clearColor(0.2, 0.5, 1.0, 1.0);
Note that this just tells WebGL what the color should be after we clear the canvas. It doesn’t actually clear anything.
Let’s set the size of the view. We’re going to do it the quick-and-dirty-way, which is to assign the width
and height
of the canvas. Be careful here, because these will be controlled by the CSS styled width and height if those are specified!
canvas.width = 600;
canvas.height = 600;gl.viewPort(0, 0, 600, 600);
The viewport tells WebGL where the main view should be. It’s often just the whole canvas.
Now, get ready to draw our triangles!
function loop() {
window.requestAnimationFrame(loop); // render as fast as possible
gl.clear(gl.COLOR_BUFFER_BIT);
gl.bindBuffer(triangleVertexBuffer);
gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0); gl.drawArrays(gl.TRIANGLES, 0, 6);
}
loop();
First, we clear color. Then, we rebind the buffer. Technically, we can skip this step: we already bound it above, and nothing changed in between. However, this could be fairly fragile; if we later introduce a new bind and forgot that we were relying on this being the right buffer, we could get some pretty confusing bugs. So unless you’re really squeezing performance out of everything, it’s probably best to just keep these things together.
We call vertexAttribPointer
to tell WebGL that what we stored in the buffer should be used for the specified attribute. The 3
indicates that 3 of the floats (x
, y
, and z
) belong to each attribute (it’s a vec3
). gl.FLOAT
is the type of the elements. The false
has no meaning since we’re using gl.FLOAT
. The first 0
is the number of elements between each attribute, called the stride (in case the buffer holds other attribute data too) and the second 0
is the index of the first attribute in the buffer (again, in case it holds other data), called the offset.
Lastly, we call drawArrays
. The TRIANGLES
option means that the attributes should be interpreted as [A1, B1, C1, A2, B2, C2, A3, B3, C3, ...]
where Ai
, Bi
, and Ci
are the vertices of the i
th triangle. The 0
is the index of the first (in case the buffers hold other data) and the second is the number of vertices to include.
So there we go! We have two red triangles on a blue background. I definitely don’t want to stop here, because this is really just the beginning. Here’s the full code, for each copy-pasting:
let canvas = document.getElementById("canvas");let gl = canvas.getContext("webgl");if (!gl) {
throw "unable to get webGL context";
}let vertexShaderSource = `precision mediump float;
attribute vec3 vertexPosition;
void main(void) {
gl_Position = vec4(vertexPosition, 1.0);
}`;let fragmentShaderSource = `precision mediump float;
void main(void) {
gl_FragColor = vec4(1.0, 0.2, 0.0, 1.0);
}`;let vertexShader = gl.createShader(gl.VERTEX_SHADER);gl.shaderSource(vertexShader, vertexShaderSource);gl.compileShader(vertexShader);if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
console.log("err:", gl.getShaderInfoLog(vertexShader));
throw "error loading vertex shader";
}let fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader);
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
console.log(gl.getShaderInfoLog(fragmentShader));
throw "error loading fragment shader";
}let shaderProgram = gl.createProgram();gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);gl.linkProgram(shaderProgram);if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
console.log(gl.getProgramInfoLog(shaderProgram));
throw "error linking program";
}gl.useProgram(shaderProgram);let vertexPositionAttribute = gl.getAttribLocation(shaderProgram, "vertexPosition");gl.enableVertexAttribArray(vertexPositionAttribute);let triangleVertexArray = [
0, 0, 0,
1, 0, 0,
1, 1, 0,
-0.2, -0.5, 0,
-1.0, -0.7, 0,
0, 0, 0,
];let triangleVertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, triangleVertexBuffer);gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(triangleVertexArray), gl.STATIC_DRAW);gl.clearColor(0.2, 0.5, 1.0, 1.0);canvas.width = 600;
canvas.height = 600;
gl.viewport(0, 0, 600, 600);function loop() {
window.requestAnimationFrame(loop);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);gl.bindBuffer(gl.ARRAY_BUFFER, triangleVertexBuffer);
gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
loop();