Create 3D Models with WebGL

Banner for a MediaJam post

Milecia

There are a lot of different technologies that are in the browser that enable a lot of interesting functionality. One of these things is WebGL. You can make all kinds of advanced animations and models, some of which can be used in other applications. We're going to go through a tutorial on how to make a 3D model in WebGL.

If you're using the Chrome browser, like me, you'll need to enable WebGL. You can do that by going to chrome://flags and enabling the WebGL options. With those enabled, let's start with a little background on what WebGL is.

Brief background on WebGL

WebGL is a JavaScript API that lets us render 2D and 3D graphics in the browser without any extra plugins. It utilizes the hardware on the user's computer. It works with OpenGL to let us render these high-performance graphics in an HTML canvas element. So you can create complex models and animations with JavaScript and make them interactive for users.

Learning the syntax to build decent models in WebGL can take time since you're dealing with vertices of objects, shaders to handle color effects, and possibly animations. While all of this is handled through JavaScript, it can still be different from what you're used to with regular development.

Let's go ahead and start making the model with just an HTML file and a JavaScript file. (When's the last time you did that?)

Set up the canvas

We'll start by creating a new HTML file called index.html. This will have a few JavaScript imports and a canvas to support our 3D model. Add the following code to your new file.

1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <title>WebGL 3D Model</title>
6 <script
7 src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"
8 integrity="sha512-zhHQR0/H5SEBL3Wn6yYSaTTZej12z0hVZKOv3TwCUXT1z5qeqGcXJLLrbERYRScEDDpYIJhPC1fk31gqR783iQ=="
9 crossorigin="anonymous"
10 defer
11 ></script>
12 <script src="model.js" defer></script>
13 </head>
14
15 <body>
16 <canvas id="glcanvas" width="640" height="480"></canvas>
17 </body>
18</html>

We're importing the gl-matrix library to support the model rendering and animation in WebGL and we're importing a custom model.js file to load the model we're about to build. Finally, we define the <canvas> element that the model will be rendered in. That's all we need for the HTML! Now let's start working on that model.js file.

Make the WebGL context

In the same folder as index.html add a new JavaScript file called model.js. This is where we'll do all of the fancy WebGL model building. There are libraries like three.js and Babylon.js that can handle this for us, but you still have to know what's happening under the hood to use it effectively.

To kick things off, let's start by defining the WebGL context. This is the only way we can render objects in that <canvas> element with WebGL. In the model.js file, add the following code.

1main();
2
3function main() {
4 const canvas = document.querySelector("#model-container");
5
6 const wgl =
7 canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
8
9 // If we don't have a GL context, return error message
10 if (!wgl) {
11 alert("Try to enable WebGL in Chrome.");
12 return;
13 }
14}

That's all for setting up the context.

Add a 2D object

Make the shader

You'll run into two shader functions when working with WebGL: the fragment shader and the vertex shader. The fragment shader is called after the object's vertices have been handled by the vertex shader. It's called once for each pixel on the object. The vertex shader transforms the input vertex into the coordinate system used by WebGL. This is what we use to define lighting and textures on the model.

In model.js, add the following code beneath the alert we made earlier to define the vertex shader.

1// model.js
2...
3const vsSource = `
4 attribute vec4 aVertexPosition;
5 attribute vec4 aVertexColor;
6
7 uniform mat4 uModelViewMatrix;
8 uniform mat4 uProjectionMatrix;
9
10 varying lowp vec4 vColor;
11
12 void main(void) {
13 gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
14 vColor = aVertexColor;
15 }
16`;
17...

This is all written using the OpenGL Shading Language (GLSL). What's happening here is all of the calculations to render the shader to the vertices of the object we're going to draw. Now let's add the fragment shader below the vertex shader.

1// model.js
2...
3const fsSource = `
4 varying lowp vec4 vColor;
5
6 void main(void) {
7 gl_FragColor = vColor;
8 }
9`;
10...

This is responsible for applying the color to the sides of the object and is also written in GLSL. Next, we need to initialize the shader functions so they can be used together. Add this code after the fragment shader.

1// model.js
2...
3const shaderProgram = initShaderProgram(wgl, vsSource, fsSource);
4
5const programInfo = {
6 program: shaderProgram,
7 attribLocations: {
8 vertexPosition: wgl.getAttribLocation(shaderProgram, "aVertexPosition"),
9 vertexColor: wgl.getAttribLocation(shaderProgram, "aVertexColor"),
10 },
11 uniformLocations: {
12 projectionMatrix: wgl.getUniformLocation(
13 shaderProgram,
14 "uProjectionMatrix"
15 ),
16 modelViewMatrix: wgl.getUniformLocation(
17 shaderProgram,
18 "uModelViewMatrix"
19 ),
20 },
21};
22...

This takes the shader functions we wrote and defines the object that will tell WebGL what to do with our model. You'll notice we have a function called initShaderProgram that we need to define. Outside of the main function, add the following code below it.

1// model.js
2...
3function initShaderProgram(wgl, vsSource, fsSource) {
4 const vertexShader = loadShader(wgl, wgl.VERTEX_SHADER, vsSource);
5 const fragmentShader = loadShader(wgl, wgl.FRAGMENT_SHADER, fsSource);
6
7 const shaderProgram = wgl.createProgram();
8
9 wgl.attachShader(shaderProgram, vertexShader);
10 wgl.attachShader(shaderProgram, fragmentShader);
11 wgl.linkProgram(shaderProgram);
12
13 if (!wgl.getProgramParameter(shaderProgram, wgl.LINK_STATUS)) {
14 alert(
15 "Unable to initialize the shader program: " +
16 wgl.getProgramInfoLog(shaderProgram)
17 );
18 return null;
19 }
20
21 return shaderProgram;
22}

This function converts the individual shader functions into a shader program that WebGL will use to apply colors to the 3D object. But you'll probably see there's another helper function we need called loadShader. Add this below the initShaderProgram function we just defined.

1// model.js
2...
3function loadShader(wgl, type, source) {
4 const shader = wgl.createShader(type);
5
6 wgl.shaderSource(shader, source);
7
8 wgl.compileShader(shader);
9
10 if (!wgl.getShaderParameter(shader, wgl.COMPILE_STATUS)) {
11 alert(
12 "An error occurred compiling the shaders: " + wgl.getShaderInfoLog(shader)
13 );
14
15 wgl.deleteShader(shader);
16
17 return null;
18 }
19
20 return shader;
21}

This takes the vertex and fragment shader functions and compiles them to something WebGL can interpret. There's just one more function we need to make sure the shader is applied to the model with the colors we want.

We need to define the model's vertices and the colors we want to apply.

Create the 3D model

We need to create an array of the vertex colors and store it in a WebGL buffer. That's how WebGL will actually render the 3D model on the page. So below the loadShader function, add this.

1// model.js
2...
3function initBuffers(wgl) {
4 const positionBuffer = wgl.createBuffer();
5
6 wgl.bindBuffer(wgl.ARRAY_BUFFER, positionBuffer);
7
8 const positions = [
9 // Front face
10 -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0,
11
12 // Back face
13 -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0,
14
15 // Top face
16 -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0,
17
18 // Bottom face
19 -1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -1.0, -1.0, 1.0,
20
21 // Right face
22 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0,
23
24 // Left face
25 -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0,
26 ];
27
28 wgl.bufferData(
29 wgl.ARRAY_BUFFER,
30 new Float32Array(positions),
31 wgl.STATIC_DRAW
32 );
33
34 const faceColors = [
35 [0.0, 0.6, 0.78, 1.0],
36 [0.25, 0.0, 0.0, 0.5],
37 [0.0, 1.0, 0.33, 1.0],
38 [0.0, 0.42, 0.74, 0.8],
39 [1.0, 1.0, 0.0, 0.62],
40 [0.32, 0.0, 0.55, 0.39],
41 ];
42
43 let colors = [];
44
45 faceColors.map((faceColor) => {
46 colors = colors.concat(faceColor, faceColor, faceColor, faceColor);
47 });
48
49 const colorBuffer = wgl.createBuffer();
50
51 wgl.bindBuffer(wgl.ARRAY_BUFFER, colorBuffer);
52 wgl.bufferData(wgl.ARRAY_BUFFER, new Float32Array(colors), wgl.STATIC_DRAW);
53
54 const indexBuffer = wgl.createBuffer();
55
56 wgl.bindBuffer(wgl.ELEMENT_ARRAY_BUFFER, indexBuffer);
57
58 const indices = [
59 0, 1, 2,
60 0, 2, 3, // front
61 4, 5, 6,
62 4, 6, 7, // back
63 8, 9, 10,
64 8, 10, 11, // top
65 12, 13, 14,
66 12, 14, 15, // bottom
67 16, 17, 18,
68 16, 18, 19, // right
69 20, 21, 22,
70 20, 22, 23, // left
71 ];
72
73 wgl.bufferData(
74 wgl.ELEMENT_ARRAY_BUFFER,
75 new Uint16Array(indices),
76 wgl.STATIC_DRAW
77 );
78
79 return {
80 position: positionBuffer,
81 color: colorBuffer,
82 indices: indexBuffer,
83 };
84}

This looks like a lot is going on, but it's not as bad as it seems. Most of this function is made of the matrices that define the vertices for the model, which is a cube, and the colors we want on its faces. This adds those model matrices to a WebGL buffer that will be used to show the object in the <canvas>. If you aren't familiar with matrix math, you should check out a few resources on it.

There's one more function we need to render what's in our WebGL buffer to the screen and that will be the logic that draws the scene in the canvas element. Below the initBuffers function, add this code.

1// model.js
2...
3function drawScene(wgl, programInfo, buffers, deltaTime) {
4 wgl.clearColor(0.2, 0.35, 0.15, 1.0);
5 wgl.clearDepth(1.0);
6 wgl.enable(wgl.DEPTH_TEST);
7 wgl.depthFunc(wgl.LEQUAL);
8
9 wgl.clear(wgl.COLOR_BUFFER_BIT | wgl.DEPTH_BUFFER_BIT);
10
11 const fieldOfView = (45 * Math.PI) / 180; // in radians
12 const aspect = wgl.canvas.clientWidth / wgl.canvas.clientHeight;
13 const zNear = 0.1;
14 const zFar = 100.0;
15 const projectionMatrix = mat4.create();
16
17 mat4.perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar);
18
19 const modelViewMatrix = mat4.create();
20
21 mat4.translate(modelViewMatrix, modelViewMatrix, [-3.7, -1.0, -16.0]);
22 mat4.rotate(modelViewMatrix, modelViewMatrix, cubeRotation, [0, 1, 0]);
23 mat4.rotate(modelViewMatrix, modelViewMatrix, cubeRotation * 0.7, [1, 1, 1]);
24
25 {
26 const numComponents = 3;
27 const type = wgl.FLOAT;
28 const normalize = false;
29 const stride = 0;
30 const offset = 0;
31 wgl.bindBuffer(wgl.ARRAY_BUFFER, buffers.position);
32 wgl.vertexAttribPointer(
33 programInfo.attribLocations.vertexPosition,
34 numComponents,
35 type,
36 normalize,
37 stride,
38 offset
39 );
40 wgl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);
41 }
42
43 {
44 const numComponents = 4;
45 const type = wgl.FLOAT;
46 const normalize = false;
47 const stride = 0;
48 const offset = 0;
49 wgl.bindBuffer(wgl.ARRAY_BUFFER, buffers.color);
50 wgl.vertexAttribPointer(
51 programInfo.attribLocations.vertexColor,
52 numComponents,
53 type,
54 normalize,
55 stride,
56 offset
57 );
58 wgl.enableVertexAttribArray(programInfo.attribLocations.vertexColor);
59 }
60
61 wgl.bindBuffer(wgl.ELEMENT_ARRAY_BUFFER, buffers.indices);
62
63 wgl.useProgram(programInfo.program);
64
65 wgl.uniformMatrix4fv(
66 programInfo.uniformLocations.projectionMatrix,
67 false,
68 projectionMatrix
69 );
70
71 wgl.uniformMatrix4fv(
72 programInfo.uniformLocations.modelViewMatrix,
73 false,
74 modelViewMatrix
75 );
76
77 {
78 const vertexCount = 36;
79 const type = wgl.UNSIGNED_SHORT;
80 const offset = 0;
81 wgl.drawElements(wgl.TRIANGLES, vertexCount, type, offset);
82 }
83
84 cubeRotation += deltaTime;
85}

This hefty function is what determines what is shown to users. It starts by clearing out the canvas and getting it ready for WebGL. Then we do some math operations to determine where the object is located in space and how big the space should appear. Then we put the model vertices into the view, apply the shader, and draw the elements on the canvas.

The very last thing we need to do so all of these helper functions are put to use is add a bit more code to our main function that gets called when the page is loaded. Inside the main function, just below the programInfo object, add these lines.

1// model.js
2...
3const programInfo = {
4 ...
5 // existing code is still there
6};
7
8const buffers = initBuffers(wgl);
9
10let then = 0;
11
12function render(now) {
13 now *= 0.001; // convert to seconds
14 const deltaTime = now - then;
15 then = now;
16
17 drawScene(wgl, programInfo, buffers, deltaTime);
18
19 requestAnimationFrame(render);
20}
21
22requestAnimationFrame(render);
23...

Finally, this is where we initialize the WebGL buffer and create a render function that slowly rotates the cube in space and displays it on the canvas. This is a simple model, but once you see it in action, it's surprisingly smooth.

Finished code

You can check out the complete code for this project in the webgl-3d-model folder of this repo. You can also check it out in this Code Sandbox.

Conclusion

Now that you've created a basic model, you can start playing around with fancier models. Maybe try to make a WebGL version of your favorite video game character. It's fun because it's always wonky when you get started, but once you really get the vertices and shaders as you want, it can look pretty good.

Milecia

Software Team Lead

Milecia is a senior software engineer, international tech speaker, and mad scientist that works with hardware and software. She will try to make anything with JavaScript first.