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 <script7 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 defer11 ></script>12 <script src="model.js" defer></script>13 </head>1415 <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();23function main() {4 const canvas = document.querySelector("#model-container");56 const wgl =7 canvas.getContext("webgl") || canvas.getContext("experimental-webgl");89 // If we don't have a GL context, return error message10 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.js2...3const vsSource = `4 attribute vec4 aVertexPosition;5 attribute vec4 aVertexColor;67 uniform mat4 uModelViewMatrix;8 uniform mat4 uProjectionMatrix;910 varying lowp vec4 vColor;1112 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.js2...3const fsSource = `4 varying lowp vec4 vColor;56 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.js2...3const shaderProgram = initShaderProgram(wgl, vsSource, fsSource);45const 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.js2...3function initShaderProgram(wgl, vsSource, fsSource) {4 const vertexShader = loadShader(wgl, wgl.VERTEX_SHADER, vsSource);5 const fragmentShader = loadShader(wgl, wgl.FRAGMENT_SHADER, fsSource);67 const shaderProgram = wgl.createProgram();89 wgl.attachShader(shaderProgram, vertexShader);10 wgl.attachShader(shaderProgram, fragmentShader);11 wgl.linkProgram(shaderProgram);1213 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 }2021 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.js2...3function loadShader(wgl, type, source) {4 const shader = wgl.createShader(type);56 wgl.shaderSource(shader, source);78 wgl.compileShader(shader);910 if (!wgl.getShaderParameter(shader, wgl.COMPILE_STATUS)) {11 alert(12 "An error occurred compiling the shaders: " + wgl.getShaderInfoLog(shader)13 );1415 wgl.deleteShader(shader);1617 return null;18 }1920 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.js2...3function initBuffers(wgl) {4 const positionBuffer = wgl.createBuffer();56 wgl.bindBuffer(wgl.ARRAY_BUFFER, positionBuffer);78 const positions = [9 // Front face10 -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0,1112 // Back face13 -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0,1415 // Top face16 -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0,1718 // Bottom face19 -1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -1.0, -1.0, 1.0,2021 // Right face22 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0,2324 // Left face25 -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 ];2728 wgl.bufferData(29 wgl.ARRAY_BUFFER,30 new Float32Array(positions),31 wgl.STATIC_DRAW32 );3334 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 ];4243 let colors = [];4445 faceColors.map((faceColor) => {46 colors = colors.concat(faceColor, faceColor, faceColor, faceColor);47 });4849 const colorBuffer = wgl.createBuffer();5051 wgl.bindBuffer(wgl.ARRAY_BUFFER, colorBuffer);52 wgl.bufferData(wgl.ARRAY_BUFFER, new Float32Array(colors), wgl.STATIC_DRAW);5354 const indexBuffer = wgl.createBuffer();5556 wgl.bindBuffer(wgl.ELEMENT_ARRAY_BUFFER, indexBuffer);5758 const indices = [59 0, 1, 2,60 0, 2, 3, // front61 4, 5, 6,62 4, 6, 7, // back63 8, 9, 10,64 8, 10, 11, // top65 12, 13, 14,66 12, 14, 15, // bottom67 16, 17, 18,68 16, 18, 19, // right69 20, 21, 22,70 20, 22, 23, // left71 ];7273 wgl.bufferData(74 wgl.ELEMENT_ARRAY_BUFFER,75 new Uint16Array(indices),76 wgl.STATIC_DRAW77 );7879 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.js2...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);89 wgl.clear(wgl.COLOR_BUFFER_BIT | wgl.DEPTH_BUFFER_BIT);1011 const fieldOfView = (45 * Math.PI) / 180; // in radians12 const aspect = wgl.canvas.clientWidth / wgl.canvas.clientHeight;13 const zNear = 0.1;14 const zFar = 100.0;15 const projectionMatrix = mat4.create();1617 mat4.perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar);1819 const modelViewMatrix = mat4.create();2021 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]);2425 {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 offset39 );40 wgl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);41 }4243 {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 offset57 );58 wgl.enableVertexAttribArray(programInfo.attribLocations.vertexColor);59 }6061 wgl.bindBuffer(wgl.ELEMENT_ARRAY_BUFFER, buffers.indices);6263 wgl.useProgram(programInfo.program);6465 wgl.uniformMatrix4fv(66 programInfo.uniformLocations.projectionMatrix,67 false,68 projectionMatrix69 );7071 wgl.uniformMatrix4fv(72 programInfo.uniformLocations.modelViewMatrix,73 false,74 modelViewMatrix75 );7677 {78 const vertexCount = 36;79 const type = wgl.UNSIGNED_SHORT;80 const offset = 0;81 wgl.drawElements(wgl.TRIANGLES, vertexCount, type, offset);82 }8384 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.js2...3const programInfo = {4 ...5 // existing code is still there6};78const buffers = initBuffers(wgl);910let then = 0;1112function render(now) {13 now *= 0.001; // convert to seconds14 const deltaTime = now - then;15 then = now;1617 drawScene(wgl, programInfo, buffers, deltaTime);1819 requestAnimationFrame(render);20}2122requestAnimationFrame(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.