3D Graphics: A WebGL Tutorial

The world of 3D graphics can be very intimidating to get into. Whether you just want to create an interactive 3D logo, or design a fully fledged game, if you don’t know the principles of 3D rendering, you’re stuck using a library that abstracts out a lot of things.

Using a library can be just the right tool, and JavaScript has an amazing open source one in the form of three.js. There are some disadvantages to using pre-made solutions, though:

  • They can have many features that you don’t plan to use. The size of the minified base three.js features is around 500kB, and any extra features (loading actual model files is one of them) make the payload even larger. Transferring that much data just to show a spinning logo on your website would be a waste.
  • An extra layer of abstraction can make otherwise easy modifications hard to do. Your creative way of shading an object on the screen can either be straightforward to implement or require tens of hours of work to incorporate into the library’s abstractions.
  • While the library is optimized very well in most scenarios, a lot of bells and whistles can be cut out for your use case. The renderer can cause certain procedures to run millions of times on the graphics card. Every instruction removed from such a procedure means that a weaker graphics card can handle your content without problems.

Even if you decide to use a high-level graphics library, having basic knowledge of the things under the hood allows you to use it more effectively. Libraries can also have advanced features, like

ShaderMaterial

in

three.js

. Knowing the principles of graphics rendering allows you to use such features.

Illustration of a 3D Toptal logo on a WebGL canvas

Our goal is to give a short introduction to all the key concepts behind rendering 3D graphics and using WebGL to implement them. You will see the most common thing that is done, which is showing and moving 3D objects in an empty space.

The final code is available for you to fork and play around with.

Representing 3D Models

The first thing you would need to understand is how 3D models are represented. A model is made of a mesh of triangles. Each triangle is represented by three vertices, for each of the corners of the triangle. There are three most common properties attached to vertices.

Vertex Position

Position is the most intuitive property of a vertex. It is the position in 3D space, represented by a 3D vector of coordinates. If you know the exact coordinates of three points in space, you would have all the information you need to draw a simple triangle between them. To make models look actually good when rendered, there are a couple more things that need to be provided to the renderer.

Vertex Normal

Spheres with the same wireframe, that have flat and smooth shading applied

Consider the two models above. They consist of the same vertex positions, yet look totally different when rendered. How is that possible?

Besides telling the renderer where we want a vertex to be located, we can also give it a hint on how the surface is slanted in that exact position. The hint is in the form of the normal of the surface at that specific point on the model, represented with a 3D vector. The following image should give you a more descriptive look at how that is handled.

Comparison between normals for flat and smooth shading

The left and right surface correspond to the left and right ball in the previous image, respectively. The red arrows represent normals that are specified for a vertex, while the blue arrows represent the renderer’s calculations of how the normal should look for all the points between the vertices. The image shows a demonstration for 2D space, but the same principle applies in 3D.

The normal is a hint for how lights will illuminate the surface. The closer a light ray’s direction is to the normal, the brighter the point is. Having gradual changes in the normal direction causes light gradients, while having abrupt changes with no changes in-between causes surfaces with constant illumination across them, and sudden changes in illumination between them.

Texture Coordinates

The last significant property are texture coordinates, commonly referred to as UV mapping. You have a model, and a texture that you want to apply to it. The texture has various areas on it, representing images that we want to apply to different parts of the model. There has to be a way to mark which triangle should be represented with which part of the texture. That’s where texture mapping comes in.

For each vertex, we mark two coordinates, U and V. These coordinates represent a position on the texture, with U representing the horizontal axis, and V the vertical axis. The values aren’t in pixels, but a percentage position within the image. The bottom-left corner of the image is represented with two zeros, while the top-right is represented with two ones.

A triangle is just painted by taking the UV coordinates of each vertex in the triangle, and applying the image that is captured between those coordinates on the texture.

Demonstration of UV mapping, with one patch highlighted, and seams visible on the model

You can see a demonstration of UV mapping on the image above. The spherical model was taken, and cut into parts that are small enough to be flattened onto a 2D surface. The seams where the cuts were made are marked with thicker lines. One of the patches has been highlighted, so you can nicely see how things match. You can also see how a seam through the middle of the smile places parts of the mouth into two different patches.

The wireframes aren’t part of the texture, but just overlayed over the image so you can see how things map together.

Loading an OBJ Model

Believe it or not, this is all you need to know to create your own simple model loader. The OBJ file format is simple enough to implement a parser in a few lines of code.

The file lists vertex positions in a

v <float> <float> <float>

format, with an optional fourth float, which we will ignore, to keep things simple. Vertex normals are represented similarly with

vn <float> <float> <float>

. Finally, texture coordinates are represented with

vt <float> <float>

, with an optional third float which we shall ignore. In all three cases, the floats represent the respective coordinates. These three properties are accumulated in three arrays.

Faces are represented with groups of vertices. Each vertex is represented with the index of each of the properties, whereby indices start at 1. There are various ways this is represented, but we will stick to the

f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3

format, requiring all three properties to be provided, and limiting the number of vertices per face to three. All of these limitations are being done to keep the loader as simple as possible, since all other options require some extra trivial processing before they are in a format that WebGL likes.

We’ve put in a lot of requirements for our file loader. That may sound limiting, but 3D modeling applications tend to give you the ability to set those limitations when exporting a model as an OBJ file.

The following code parses a string representing an OBJ file, and creates a model in the form of an array of faces.


<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Geometry</span> <span class="hljs-params">(faces)</span> {</span>
  <span class="hljs-keyword">this</span>.faces = faces || []
}

<span class="hljs-comment">// Parses an OBJ file, passed as a string</span>
Geometry.parseOBJ = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(src)</span> {</span>
  <span class="hljs-keyword">var</span> POSITION = <span class="hljs-regexp">/^vs+([d.+-eE]+)s+([d.+-eE]+)s+([d.+-eE]+)/</span>
  <span class="hljs-keyword">var</span> NORMAL = <span class="hljs-regexp">/^vns+([d.+-eE]+)s+([d.+-eE]+)s+([d.+-eE]+)/</span>
  <span class="hljs-keyword">var</span> UV = <span class="hljs-regexp">/^vts+([d.+-eE]+)s+([d.+-eE]+)/</span>
  <span class="hljs-keyword">var</span> FACE = <span class="hljs-regexp">/^fs+(-?d+)/(-?d+)/(-?d+)s+(-?d+)/(-?d+)/(-?d+)s+(-?d+)/(-?d+)/(-?d+)(?:s+(-?d+)/(-?d+)/(-?d+))?/</span>

  lines = src.split(<span class="hljs-string">'n'</span>)
  <span class="hljs-keyword">var</span> positions = []
  <span class="hljs-keyword">var</span> uvs = []
  <span class="hljs-keyword">var</span> normals = []
  <span class="hljs-keyword">var</span> faces = []
  lines.forEach(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(line)</span> {</span>
    <span class="hljs-comment">// Match each line of the file against various RegEx-es</span>
    <span class="hljs-keyword">var</span> result
    <span class="hljs-keyword">if</span> ((result = POSITION.exec(line)) != <span class="hljs-literal">null</span>) {
      <span class="hljs-comment">// Add new vertex position</span>
      positions.push(<span class="hljs-keyword">new</span> Vector3(<span class="hljs-built_in">parseFloat</span>(result[<span class="hljs-number">1</span>]), <span class="hljs-built_in">parseFloat</span>(result[<span class="hljs-number">2</span>]), <span class="hljs-built_in">parseFloat</span>(result[<span class="hljs-number">3</span>])))
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> ((result = NORMAL.exec(line)) != <span class="hljs-literal">null</span>) {
      <span class="hljs-comment">// Add new vertex normal</span>
      normals.push(<span class="hljs-keyword">new</span> Vector3(<span class="hljs-built_in">parseFloat</span>(result[<span class="hljs-number">1</span>]), <span class="hljs-built_in">parseFloat</span>(result[<span class="hljs-number">2</span>]), <span class="hljs-built_in">parseFloat</span>(result[<span class="hljs-number">3</span>])))
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> ((result = UV.exec(line)) != <span class="hljs-literal">null</span>) {
      <span class="hljs-comment">// Add new texture mapping point</span>
      uvs.push(<span class="hljs-keyword">new</span> Vector2(<span class="hljs-built_in">parseFloat</span>(result[<span class="hljs-number">1</span>]), <span class="hljs-number">1</span> - <span class="hljs-built_in">parseFloat</span>(result[<span class="hljs-number">2</span>])))
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> ((result = FACE.exec(line)) != <span class="hljs-literal">null</span>) {
      <span class="hljs-comment">// Add new face</span>
      <span class="hljs-keyword">var</span> vertices = []
      <span class="hljs-comment">// Create three vertices from the passed one-indexed indices</span>
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">var</span> i = <span class="hljs-number">1</span>; i &lt; <span class="hljs-number">10</span>; i += <span class="hljs-number">3</span>) {
        <span class="hljs-keyword">var</span> part = result.slice(i, i + <span class="hljs-number">3</span>)
        <span class="hljs-keyword">var</span> position = positions[<span class="hljs-built_in">parseInt</span>(part[<span class="hljs-number">0</span>]) - <span class="hljs-number">1</span>]
        <span class="hljs-keyword">var</span> uv = uvs[<span class="hljs-built_in">parseInt</span>(part[<span class="hljs-number">1</span>]) - <span class="hljs-number">1</span>]
        <span class="hljs-keyword">var</span> normal = normals[<span class="hljs-built_in">parseInt</span>(part[<span class="hljs-number">2</span>]) - <span class="hljs-number">1</span>]
        vertices.push(<span class="hljs-keyword">new</span> Vertex(position, normal, uv))
      }
      faces.push(<span class="hljs-keyword">new</span> Face(vertices))
    }
  })

  <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> Geometry(faces)
}

<span class="hljs-comment">// Loads an OBJ file from the given URL, and returns it as a promise</span>
Geometry.loadOBJ = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(url)</span> {</span>
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> Promise(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(resolve)</span> {</span>
    <span class="hljs-keyword">var</span> xhr = <span class="hljs-keyword">new</span> XMLHttpRequest()
    xhr.onreadystatechange = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
      <span class="hljs-keyword">if</span> (xhr.readyState == XMLHttpRequest.DONE) {
        resolve(Geometry.parseOBJ(xhr.responseText))
      }
    }
    xhr.open(<span class="hljs-string">'GET'</span>, url, <span class="hljs-literal">true</span>)
    xhr.send(<span class="hljs-literal">null</span>)
  })
}

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Face</span> <span class="hljs-params">(vertices)</span> {</span>
  <span class="hljs-keyword">this</span>.vertices = vertices || []
}

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Vertex</span> <span class="hljs-params">(position, normal, uv)</span> {</span>
  <span class="hljs-keyword">this</span>.position = position || <span class="hljs-keyword">new</span> Vector3()
  <span class="hljs-keyword">this</span>.normal = normal || <span class="hljs-keyword">new</span> Vector3()
  <span class="hljs-keyword">this</span>.uv = uv || <span class="hljs-keyword">new</span> Vector2()
}

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Vector3</span> <span class="hljs-params">(x, y, z)</span> {</span>
  <span class="hljs-keyword">this</span>.x = <span class="hljs-built_in">Number</span>(x) || <span class="hljs-number">0</span>
  <span class="hljs-keyword">this</span>.y = <span class="hljs-built_in">Number</span>(y) || <span class="hljs-number">0</span>
  <span class="hljs-keyword">this</span>.z = <span class="hljs-built_in">Number</span>(z) || <span class="hljs-number">0</span>
}

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Vector2</span> <span class="hljs-params">(x, y)</span> {</span>
  <span class="hljs-keyword">this</span>.x = <span class="hljs-built_in">Number</span>(x) || <span class="hljs-number">0</span>
  <span class="hljs-keyword">this</span>.y = <span class="hljs-built_in">Number</span>(y) || <span class="hljs-number">0</span>
}

The

Geometry

structure holds the exact data needed to send a model to the graphics card to process. Before you do that though, you’d probably want to have the ability to move the model around on the screen.

Performing Spatial Transformations

All the points in the model we loaded are relative to its coordinate system. If we want to translate, rotate, and scale the model, all we need to do is perform that operation on its coordinate system. Coordinate system A, relative to coordinate system B, is defined by the position of its center as a vector

p_ab

, and the vector for each of its axes,

x_ab

,

y_ab

, and

z_ab

, representing the direction of that axis. So if a point moves by 10 on the

x

axis of coordinate system A, then—in the coordinate system B—it will move in the direction of

x_ab

, multiplied by 10.

All of this information is stored in the following matrix form:


x_ab.x  y_ab.x  y_ab.x  p_ab.x
x_ab.y  y_ab.y  y_ab.y  p_ab.y
x_ab.z  y_ab.z  y_ab.z  p_ab.z
     0       0       0       1

If we want to transform the 3D vector

q

, we just have to multiply the transformation matrix with the vector:


q.x
q.y
q.z
1

This causes the point to move by

q.x

along the new

x

axis, by

q.y

along the new

y

axis, and by

q.z

along the new

z

axis. Finally it causes the point to move additionally by the

p

vector, which is the reason why we use a one as the final element of the multiplication.

The big advantage of using these matrices is the fact that if we have multiple transformations to perform on the vertex, we can merge them into one transformation by multiplying their matrices, prior to transforming the vertex itself.

There are various transformations that can be performed, and we’ll take a look at the key ones.

No Transformation

If no transformations happen, then the

p

vector is a zero vector, the

x

vector is

[1, 0, 0]

,

y

is

[0, 1, 0]

, and

z

is

[0, 0, 1]

. From now on we’ll refer to these values as the default values for these vectors. Applying these values gives us an identity matrix:


1 0 0 0
0 1 0 0
0 0 1 0
0 0 0 1

This is a good starting point for chaining transformations.

Translation

Frame transformation for translation

When we perform translation, then all the vectors except for the

p

vector have their default values. This results in the following matrix:


1 0 0 p.x
0 1 0 p.y
0 0 1 p.z
0 0 0   1

Scaling

Frame transformation for scaling

Scaling a model means reducing the amount that each coordinate contributes to the position of a point. There is no uniform offset caused by scaling, so the

p

vector keeps its default value. The default axis vectors should be multiplied by their respective scaling factors, which results in the following matrix:


s_x   0   0 0
  0 s_y   0 0
  0   0 s_z 0
  0   0   0 1

Here

s_x

,

s_y

, and

s_z

represent the scaling applied to each axis.

Rotation

Frame transformation for rotation around the Z axis

The image above shows what happens when we rotate the coordinate frame around the Z axis.

Rotation results in no uniform offset, so the

p

vector keeps its default value. Now things get a bit trickier. Rotations cause movement along a certain axis in the original coordinate system to move in a different direction. So if we rotate a coordinate system by 45 degrees around the Z axis, moving along the

x

axis of the original coordinate system causes movement in a diagonal direction between the

x

and

y

axis in the new coordinate system.

To keep things simple, we’ll just show you how the transformation matrices look for rotations around the main axes.


Around X:
        1         0         0 0
        0  cos(phi)  sin(phi) 0
        0 -sin(phi)  cos(phi) 0
        0         0         0 1

Around Y:
 cos(phi)         0  sin(phi) 0
        0         1         0 0
-sin(phi)         0  cos(phi) 0
        0         0         0 1

Around Z:
 cos(phi) -sin(phi)         0 0
 sin(phi)  cos(phi)         0 0
        0         0         1 0
        0         0         0 1

Implementation

All of this can be implemented as a class that stores 16 numbers, storing matrices in a column-major order.


<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Transformation</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-comment">// Create an identity transformation</span>
  <span class="hljs-keyword">this</span>.fields = [<span class="hljs-number">1</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">1</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">1</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">1</span>]
}

<span class="hljs-comment">// Multiply matrices, to chain transformations</span>
Transformation.prototype.mult = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(t)</span> {</span>
  <span class="hljs-keyword">var</span> output = <span class="hljs-keyword">new</span> Transformation()
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">var</span> row = <span class="hljs-number">0</span>; row &lt; <span class="hljs-number">4</span>; ++row) {
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">var</span> col = <span class="hljs-number">0</span>; col &lt; <span class="hljs-number">4</span>; ++col) {
      <span class="hljs-keyword">var</span> sum = <span class="hljs-number">0</span>
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">var</span> k = <span class="hljs-number">0</span>; k &lt; <span class="hljs-number">4</span>; ++k) {
        sum += <span class="hljs-keyword">this</span>.fields[k * <span class="hljs-number">4</span> + row] * t.fields[col * <span class="hljs-number">4</span> + k]
      }
      output.fields[col * <span class="hljs-number">4</span> + row] = sum
    }
  }
  <span class="hljs-keyword">return</span> output
}

<span class="hljs-comment">// Multiply by translation matrix</span>
Transformation.prototype.translate = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(x, y, z)</span> {</span>
  <span class="hljs-keyword">var</span> mat = <span class="hljs-keyword">new</span> Transformation()
  mat.fields[<span class="hljs-number">12</span>] = <span class="hljs-built_in">Number</span>(x) || <span class="hljs-number">0</span>
  mat.fields[<span class="hljs-number">13</span>] = <span class="hljs-built_in">Number</span>(y) || <span class="hljs-number">0</span>
  mat.fields[<span class="hljs-number">14</span>] = <span class="hljs-built_in">Number</span>(z) || <span class="hljs-number">0</span>
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.mult(mat)
}

<span class="hljs-comment">// Multiply by scaling matrix</span>
Transformation.prototype.scale = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(x, y, z)</span> {</span>
  <span class="hljs-keyword">var</span> mat = <span class="hljs-keyword">new</span> Transformation()
  mat.fields[<span class="hljs-number">0</span>] = <span class="hljs-built_in">Number</span>(x) || <span class="hljs-number">0</span>
  mat.fields[<span class="hljs-number">5</span>] = <span class="hljs-built_in">Number</span>(y) || <span class="hljs-number">0</span>
  mat.fields[<span class="hljs-number">10</span>] = <span class="hljs-built_in">Number</span>(z) || <span class="hljs-number">0</span>
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.mult(mat)
}

<span class="hljs-comment">// Multiply by rotation matrix around X axis</span>
Transformation.prototype.rotateX = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(angle)</span> {</span>
  angle = <span class="hljs-built_in">Number</span>(angle) || <span class="hljs-number">0</span>
  <span class="hljs-keyword">var</span> c = <span class="hljs-built_in">Math</span>.cos(angle)
  <span class="hljs-keyword">var</span> s = <span class="hljs-built_in">Math</span>.sin(angle)
  <span class="hljs-keyword">var</span> mat = <span class="hljs-keyword">new</span> Transformation()
  mat.fields[<span class="hljs-number">5</span>] = c
  mat.fields[<span class="hljs-number">10</span>] = c
  mat.fields[<span class="hljs-number">9</span>] = -s
  mat.fields[<span class="hljs-number">6</span>] = s
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.mult(mat)
}

<span class="hljs-comment">// Multiply by rotation matrix around Y axis</span>
Transformation.prototype.rotateY = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(angle)</span> {</span>
  angle = <span class="hljs-built_in">Number</span>(angle) || <span class="hljs-number">0</span>
  <span class="hljs-keyword">var</span> c = <span class="hljs-built_in">Math</span>.cos(angle)
  <span class="hljs-keyword">var</span> s = <span class="hljs-built_in">Math</span>.sin(angle)
  <span class="hljs-keyword">var</span> mat = <span class="hljs-keyword">new</span> Transformation()
  mat.fields[<span class="hljs-number">0</span>] = c
  mat.fields[<span class="hljs-number">10</span>] = c
  mat.fields[<span class="hljs-number">2</span>] = -s
  mat.fields[<span class="hljs-number">8</span>] = s
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.mult(mat)
}

<span class="hljs-comment">// Multiply by rotation matrix around Z axis</span>
Transformation.prototype.rotateZ = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(angle)</span> {</span>
  angle = <span class="hljs-built_in">Number</span>(angle) || <span class="hljs-number">0</span>
  <span class="hljs-keyword">var</span> c = <span class="hljs-built_in">Math</span>.cos(angle)
  <span class="hljs-keyword">var</span> s = <span class="hljs-built_in">Math</span>.sin(angle)
  <span class="hljs-keyword">var</span> mat = <span class="hljs-keyword">new</span> Transformation()
  mat.fields[<span class="hljs-number">0</span>] = c
  mat.fields[<span class="hljs-number">5</span>] = c
  mat.fields[<span class="hljs-number">4</span>] = -s
  mat.fields[<span class="hljs-number">1</span>] = s
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.mult(mat)
}

Looking through a Camera

Here comes the key part of presenting objects on the screen: the camera. There are two key components to a camera; namely, its position, and how it projects observed objects onto the screen.

Camera position is handled with one simple trick. There is no visual difference between moving the camera a meter forward, and moving the whole world a meter backward. So naturally, we do the latter, by applying the inverse of the matrix as a transformation.

The second key component is the way observed objects are projected onto the lens. In WebGL, everything visible on the screen is located in a box. The box spans between -1 and 1 on each axis. Everything visible is within that box. We can use the same approach of transformation matrices to create a projection matrix.

Orthographic Projection

Rectangular space getting transformed into the proper framebuffer dimensions using orthographic projection

The simplest projection is orthographic projection. You take a box in space, denoting the width, height and depth, with the assumption that its center is at the zero position. Then the projection resizes the box to fit it into the previously described box within which WebGL observes objects. Since we want to resize each dimension to two, we scale each axis by

2/size

, whereby

size

is the dimension of the respective axis. A small caveat is the fact that we’re multiplying the Z axis with a negative. This is done because we want to flip the direction of that dimension. The final matrix has this form:


2/width        0        0 0
      0 2/height        0 0
      0        0 -2/depth 0
      0        0        0 1

Perspective Projection

Frustum getting transformed into the proper framebuffer dimensions using perspective projection

We won’t go through the details of how this projection is designed, but just use the final formula, which is pretty much standard by now. We can simplify it by placing the projection in the zero position on the x and y axis, making the right/left and top/bottom limits equal to

width/2

and

height/2

respectively. The parameters

n

and

f

represent the

near

and

far

clipping planes, which are the smallest and largest distance a point can be to be captured by the camera. They are represented by the parallel sides of the frustum in the above image.

A perspective projection is usually represented with a field of view (we’ll use the vertical one), aspect ratio, and the near and far plane distances. That information can be used to calculate

width

and

height

, and then the matrix can be created from the following template:


2*n/width          0           0           0
        0 2*n/height           0           0
        0          0 (f+n)/(n-f) 2*f*n/(n-f)
        0          0          -1           0

To calculate the width and height, the following formulas can be used:


height = <span class="hljs-number">2</span> * near * <span class="hljs-built_in">Math</span>.tan(fov * <span class="hljs-built_in">Math</span>.PI / <span class="hljs-number">360</span>)
width = aspectRatio * height

The FOV (field of view) represents the vertical angle that the camera captures with its lens. The aspect ratio represents the ratio between image width and height, and is based on the dimensions of the screen we’re rendering to.

Implementation

Now we can represent a camera as a class that stores the camera position and projection matrix. We also need to know how to calculate inverse transformations. Solving general matrix inversions can be problematic, but there is a simplified approach for our special case.


<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Camera</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">this</span>.position = <span class="hljs-keyword">new</span> Transformation()
  <span class="hljs-keyword">this</span>.projection = <span class="hljs-keyword">new</span> Transformation()
}

Camera.prototype.setOrthographic = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(width, height, depth)</span> {</span>
  <span class="hljs-keyword">this</span>.projection = <span class="hljs-keyword">new</span> Transformation()
  <span class="hljs-keyword">this</span>.projection.fields[<span class="hljs-number">0</span>] = <span class="hljs-number">2</span> / width
  <span class="hljs-keyword">this</span>.projection.fields[<span class="hljs-number">5</span>] = <span class="hljs-number">2</span> / height
  <span class="hljs-keyword">this</span>.projection.fields[<span class="hljs-number">10</span>] = -<span class="hljs-number">2</span> / depth
}

Camera.prototype.setPerspective = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(verticalFov, aspectRatio, near, far)</span> {</span>
  <span class="hljs-keyword">var</span> height_div_2n = <span class="hljs-built_in">Math</span>.tan(verticalFov * <span class="hljs-built_in">Math</span>.PI / <span class="hljs-number">360</span>)
  <span class="hljs-keyword">var</span> width_div_2n = aspectRatio * height_div_2n
  <span class="hljs-keyword">this</span>.projection = <span class="hljs-keyword">new</span> Transformation()
  <span class="hljs-keyword">this</span>.projection.fields[<span class="hljs-number">0</span>] = <span class="hljs-number">1</span> / height_div_2n
  <span class="hljs-keyword">this</span>.projection.fields[<span class="hljs-number">5</span>] = <span class="hljs-number">1</span> / width_div_2n
  <span class="hljs-keyword">this</span>.projection.fields[<span class="hljs-number">10</span>] = (far + near) / (near - far)
  <span class="hljs-keyword">this</span>.projection.fields[<span class="hljs-number">10</span>] = -<span class="hljs-number">1</span>
  <span class="hljs-keyword">this</span>.projection.fields[<span class="hljs-number">14</span>] = <span class="hljs-number">2</span> * far * near / (near - far)
  <span class="hljs-keyword">this</span>.projection.fields[<span class="hljs-number">15</span>] = <span class="hljs-number">0</span>
}

Camera.prototype.getInversePosition = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">var</span> orig = <span class="hljs-keyword">this</span>.position.fields
  <span class="hljs-keyword">var</span> dest = <span class="hljs-keyword">new</span> Transformation()
  <span class="hljs-keyword">var</span> x = orig[<span class="hljs-number">12</span>]
  <span class="hljs-keyword">var</span> y = orig[<span class="hljs-number">13</span>]
  <span class="hljs-keyword">var</span> z = orig[<span class="hljs-number">14</span>]
  <span class="hljs-comment">// Transpose the rotation matrix</span>
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">var</span> i = <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">3</span>; ++i) {
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">var</span> j = <span class="hljs-number">0</span>; j &lt; <span class="hljs-number">3</span>; ++j) {
      dest.fields[i * <span class="hljs-number">4</span> + j] = orig[i + j * <span class="hljs-number">4</span>]
    }
  }

  <span class="hljs-comment">// Translation by -p will apply R^T, which is equal to R^-1</span>
  <span class="hljs-keyword">return</span> dest.translate(-x, -y, -z)
}

This is the final piece we need before we can start drawing things on the screen.

Drawing an Object with the WebGL Graphics Pipeline

The simplest surface you can draw is a triangle. In fact, the majority of things that you draw in 3D space consist of a great number of triangles.

A basic look at what steps of the graphics pipeline do

The first thing that you need to understand is how the screen is represented in WebGL. It is a 3D space, spanning between -1 and 1 on the x, y, and z axis. By default this z axis is not used, but you are interested in 3D graphics, so you’ll want to enable it right away.

Having that in mind, what follows are three steps required to draw a triangle onto this surface.

You can define three vertices, which would represent the triangle you want to draw. You serialize that data and send it over to the GPU (graphics processing unit). With a whole model available, you can do that for all the triangles in the model. The vertex positions you give are in the local coordinate space of the model you’ve loaded. Put simply, the positions you provide are the exact ones from the file, and not the one you get after performing matrix transformations.

Now that you’ve given the vertices to the GPU, you tell the GPU what logic to use when placing the vertices onto the screen. This step will be used to apply our matrix transformations. The GPU is very good at multiplying a lot of 4×4 matrices, so we’ll put that ability to good use.

In the last step, the GPU will rasterize that triangle. Rasterization is the process of taking vector graphics and determining which pixels of the screen need to be painted for that vector graphics object to be displayed. In our case, the GPU is trying to determine which pixels are located within each triangle. For each pixel, the GPU will ask you what color you want it to be painted.

These are the four elements needed to draw anything you want, and they are the simplest example of a graphics pipeline. What follows is a look at each of them, and a simple implementation.

The Default Framebuffer

The most important element for a WebGL application is the WebGL context. You can access it with

gl = canvas.getContext('webgl')

, or use

'experimental-webgl'

as a fallback, in case the currently used browser doesn’t support all WebGL features yet. The

canvas

we referred to is the DOM element of the canvas we want to draw on. The context contains many things, among which is the default framebuffer.

You could loosely describe a framebuffer as any buffer (object) that you can draw on. By default, the default framebuffer stores the color for each pixel of the canvas that the WebGL context is bound to. As described in the previous section, when we draw on the framebuffer, each pixel is located between -1 and 1 on the x and y axis. Something we also mentioned is the fact that, by default, WebGL doesn’t use the z axis. That functionality can be enabled by running

gl.enable(gl.DEPTH_TEST)

. Great, but what is a depth test?

Enabling the depth test allows a pixel to store both color and depth. The depth is the z coordinate of that pixel. After you draw to a pixel at a certain depth z, to update the color of that pixel, you need to draw at a z position that is closer to the camera. Otherwise, the draw attempt will be ignored. This allows for the illusion of 3D, since drawing objects that are behind other objects will cause those objects to be occluded by objects in front of them.

Any draws you perform stay on the screen until you tell them to get cleared. To do so, you have to call

gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

. This clears both the color and depth buffer. To pick the color that the cleared pixels are set to, use

gl.clearColor(red, green, blue, alpha)

.

Let’s create a renderer that uses a canvas and clears it upon request:


<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Renderer</span> <span class="hljs-params">(canvas)</span> {</span>
  <span class="hljs-keyword">var</span> gl = canvas.getContext(<span class="hljs-string">'webgl'</span>) || canvas.getContext(<span class="hljs-string">'experimental-webgl'</span>)
  gl.enable(gl.DEPTH_TEST)
  <span class="hljs-keyword">this</span>.gl = gl
}

Renderer.prototype.setClearColor = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(red, green, blue)</span> {</span>
  gl.clearColor(red / <span class="hljs-number">255</span>, green / <span class="hljs-number">255</span>, blue / <span class="hljs-number">255</span>, <span class="hljs-number">1</span>)
}

Renderer.prototype.getContext = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.gl
}

Renderer.prototype.render = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">this</span>.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
}

<span class="hljs-keyword">var</span> renderer = <span class="hljs-keyword">new</span> Renderer(document.getElementById(<span class="hljs-string">'webgl-canvas'</span>))
renderer.setClearColor(<span class="hljs-number">100</span>, <span class="hljs-number">149</span>, <span class="hljs-number">237</span>)

loop()

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">loop</span> <span class="hljs-params">()</span> {</span>
  renderer.render()
  requestAnimationFrame(loop)
}

Attaching this script to the following HTML will give you a bright blue rectangle on the screen


<span class="hljs-doctype">&lt;!DOCTYPE html&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-title">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-title">head</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-title">head</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-title">body</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-title">canvas</span> <span class="hljs-attribute">id</span>=<span class="hljs-value">"webgl-canvas"</span> <span class="hljs-attribute">width</span>=<span class="hljs-value">"800"</span> <span class="hljs-attribute">height</span>=<span class="hljs-value">"500"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-title">canvas</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-title">script</span> <span class="hljs-attribute">src</span>=<span class="hljs-value">"script.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-title">script</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-title">body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-title">html</span>&gt;</span>

The

requestAnimationFrame

call causes the loop to be called again as soon as the previous frame is done rendering and all event handling is finished.

Vertex Buffer Objects

The first thing you need to do is define the vertices that you want to draw. You can do that by describing them via vectors in 3D space. After that, you want to move that data into the GPU RAM, by creating a new Vertex Buffer Object (VBO).

A Buffer Object in general is an object that stores an array of memory chunks on the GPU. It being a VBO just denotes what the GPU can use the memory for. Most of the time, Buffer Objects you create will be VBOs.

You can fill the VBO by taking all

N

vertices that we have and creating an array of floats with

3N

elements for the vertex position and vertex normal VBOs, and

2N

for the texture coordinates VBO. Each group of three floats, or two floats for UV coordinates, represents individual coordinates of a vertex. Then we pass these arrays to the GPU, and our vertices are ready for the rest of the pipeline.

Since the data is now on the GPU RAM, you can delete it from the general purpose RAM. That is, unless you want to later on modify it, and upload it again. Each modification needs to be followed by an upload, since modifications in our JS arrays don’t apply to VBOs in the actual GPU RAM.

Below is a code example that provides all of the described functionality. An important note to make is the fact that variables stored on the GPU are not garbage collected. That means that we have to manually delete them once we don’t want to use them any more. We will just give you an example for how that is done here, and will not focus on that concept further on. Deleting variables from the GPU is necessary only if you plan to stop using certain geometry throughout the program.

We also added serialization to our

Geometry

class and elements within it.


Geometry.prototype.vertexCount = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.faces.length * <span class="hljs-number">3</span>
}

Geometry.prototype.positions = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">var</span> answer = []
  <span class="hljs-keyword">this</span>.faces.forEach(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(face)</span> {</span>
    face.vertices.forEach(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(vertex)</span> {</span>
      <span class="hljs-keyword">var</span> v = vertex.position
      answer.push(v.x, v.y, v.z)
    })
  })
  <span class="hljs-keyword">return</span> answer
}

Geometry.prototype.normals = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">var</span> answer = []
  <span class="hljs-keyword">this</span>.faces.forEach(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(face)</span> {</span>
    face.vertices.forEach(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(vertex)</span> {</span>
      <span class="hljs-keyword">var</span> v = vertex.normal
      answer.push(v.x, v.y, v.z)
    })
  })
  <span class="hljs-keyword">return</span> answer
}

Geometry.prototype.uvs = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">var</span> answer = []
  <span class="hljs-keyword">this</span>.faces.forEach(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(face)</span> {</span>
    face.vertices.forEach(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(vertex)</span> {</span>
      <span class="hljs-keyword">var</span> v = vertex.uv
      answer.push(v.x, v.y)
    })
  })
  <span class="hljs-keyword">return</span> answer
}

<span class="hljs-comment">////////////////////////////////</span>

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">VBO</span> <span class="hljs-params">(gl, data, count)</span> {</span>
  <span class="hljs-comment">// Creates buffer object in GPU RAM where we can store anything</span>
  <span class="hljs-keyword">var</span> bufferObject = gl.createBuffer()
  <span class="hljs-comment">// Tell which buffer object we want to operate on as a VBO</span>
  gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject)
  <span class="hljs-comment">// Write the data, and set the flag to optimize</span>
  <span class="hljs-comment">// for rare changes to the data we're writing</span>
  gl.bufferData(gl.ARRAY_BUFFER, <span class="hljs-keyword">new</span> <span class="hljs-built_in">Float32Array</span>(data), gl.STATIC_DRAW)
  <span class="hljs-keyword">this</span>.gl = gl
  <span class="hljs-keyword">this</span>.size = data.length / count
  <span class="hljs-keyword">this</span>.count = count
  <span class="hljs-keyword">this</span>.data = bufferObject
}

VBO.prototype.destroy = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-comment">// Free memory that is occupied by our buffer object</span>
  <span class="hljs-keyword">this</span>.gl.deleteBuffer(<span class="hljs-keyword">this</span>.data)
}

The

VBO

data type generates the VBO in the passed WebGL context, based on the array passed as a second parameter.

You can see three calls to the

gl

context. The

createBuffer()

call creates the buffer. The

bindBuffer()

call tells the WebGL state machine to use this specific memory as the current VBO (

ARRAY_BUFFER

) for all future operations, until told otherwise. After that, we set the value of the current VBO to the provided data, with

bufferData()

.

We also provide a destroy method that deletes our buffer object from the GPU RAM, by using

deleteBuffer()

.

You can use three VBOs and a transformation to describe all the properties of a mesh, together with its position.


<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Mesh</span> <span class="hljs-params">(gl, geometry)</span> {</span>
  <span class="hljs-keyword">var</span> vertexCount = geometry.vertexCount()
  <span class="hljs-keyword">this</span>.positions = <span class="hljs-keyword">new</span> VBO(gl, geometry.positions(), vertexCount)
  <span class="hljs-keyword">this</span>.normals = <span class="hljs-keyword">new</span> VBO(gl, geometry.normals(), vertexCount)
  <span class="hljs-keyword">this</span>.uvs = <span class="hljs-keyword">new</span> VBO(gl, geometry.uvs(), vertexCount)
  <span class="hljs-keyword">this</span>.vertexCount = vertexCount
  <span class="hljs-keyword">this</span>.position = <span class="hljs-keyword">new</span> Transformation()
  <span class="hljs-keyword">this</span>.gl = gl
}

Mesh.prototype.destroy = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">this</span>.positions.destroy()
  <span class="hljs-keyword">this</span>.normals.destroy()
  <span class="hljs-keyword">this</span>.uvs.destroy()
}

As an example, here is how we can load a model, store its properties in the mesh, and then destroy it:


Geometry.loadOBJ(<span class="hljs-string">'/assets/model.obj'</span>).then(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(geometry)</span> {</span>
  <span class="hljs-keyword">var</span> mesh = <span class="hljs-keyword">new</span> Mesh(gl, geometry)
  console.log(mesh)
  mesh.destroy()
})

Shaders

What follows is the previously described two-step process of moving points into desired positions and painting all individual pixels. To do this, we write a program that is run on the graphics card many times. This program typically consists of at least two parts. The first part is a Vertex Shader, which is run for each vertex, and outputs where we should place the vertex on the screen, among other things. The second part is the Fragment Shader, which is run for each pixel that a triangle covers on the screen, and outputs the color that pixel should be painted to.

Vertex Shaders

Let’s say you want to have a model that moves around left and right on the screen. In a naive approach, you could update the position of each vertex and resend it to the GPU. That process is expensive and slow. Alternatively, you would give a program for the GPU to run for each vertex, and do all those operations in parallel with a processor that is built for doing exactly that job. That is the role of a vertex shader.

A vertex shader is the part of the rendering pipeline that processes individual vertices. A call to the vertex shader receives a single vertex and outputs a single vertex after all possible transformations to the vertex are applied.

Shaders are written in GLSL. There are a lot of unique elements to this language, but most of the syntax is very C-like, so it should be understandable to most people.

There are three types of variables that go in and out of a vertex shader, and all of them serve a specific use:

  • attribute

    — These are inputs that hold specific properties of a vertex. Previously, we described the position of a vertex as an attribute, in the form of a three-element vector. You can look at attributes as values that describe one vertex.

  • uniform

    — These are inputs that are the same for every vertex within the same rendering call. Let’s say that we want to be able to move our model around, by defining a transformation matrix. You can use a

    uniform

    variable to describe that. You can point to resources on the GPU as well, like textures. You can look at uniforms as values that describe a model, or a part of a model.

  • varying

    — These are outputs that we pass to the fragment shader. Since there are potentially thousands of pixels for a triangle of vertices, each pixel will receive an interpolated value for this variable, depending on the position. So if one vertex sends 500 as an output, and another one 100, a pixel that is in the middle between them will receive 300 as an input for that variable. You can look at varyings as values that describe surfaces between vertices.

So, let’s say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let’s say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” you might ask.


<span class="hljs-keyword">attribute</span> <span class="hljs-keyword">vec3</span> position;
<span class="hljs-keyword">attribute</span> <span class="hljs-keyword">vec3</span> normal;
<span class="hljs-keyword">attribute</span> <span class="hljs-keyword">vec2</span> uv;
<span class="hljs-keyword">uniform</span> <span class="hljs-keyword">mat4</span> model;
<span class="hljs-keyword">uniform</span> <span class="hljs-keyword">mat4</span> view;
<span class="hljs-keyword">uniform</span> <span class="hljs-keyword">mat4</span> projection;
<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec3</span> vNormal;
<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec2</span> vUv;

<span class="hljs-keyword">void</span> main() {
    vUv = uv;
    vNormal = (model * <span class="hljs-keyword">vec4</span>(normal, <span class="hljs-number">0.</span>)).xyz;
    <span class="hljs-built_in">gl_Position</span> = projection * view * model * <span class="hljs-keyword">vec4</span>(position, <span class="hljs-number">1.</span>);
}

Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the

main

function. All values that we would want to return are assigned, either to

varying

variables, or to special variables. Here we assign to

gl_Position

, which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a

vec4

out of the position vector. You can construct a

vec4

by using four

float

s, two

vec2

s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you’re familiar with transformation matrices.

You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We’ll explain later why we’ve stopped there with the normal transformations.

For now, we will keep it simple, and move on to painting individual pixels.

Fragment Shaders

A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.

The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:

  • There are no more
    varying

    outputs, and

    attribute

    inputs have been replaced with

    varying

    inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader.

  • Our only output now is
    gl_FragColor

    , which is a

    vec4

    . The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you’re doing transparency. Transparency is a fairly advanced concept though, so we’ll stick to opaque objects.

  • At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.

With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.


<span class="hljs-preprocessor">#ifdef GL_ES</span>
<span class="hljs-keyword">precision</span> <span class="hljs-keyword">highp</span> <span class="hljs-keyword">float</span>;
<span class="hljs-preprocessor">#endif</span>

<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec3</span> vNormal;
<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec2</span> vUv;

<span class="hljs-keyword">void</span> main() {
    <span class="hljs-keyword">vec2</span> clampedUv = <span class="hljs-built_in">clamp</span>(vUv, <span class="hljs-number">0.</span>, <span class="hljs-number">1.</span>);
    <span class="hljs-built_in">gl_FragColor</span> = <span class="hljs-keyword">vec4</span>(clampedUv, <span class="hljs-number">1.</span>, <span class="hljs-number">1.</span>);
}

The function

clamp

just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.

With all of this in mind, all that is left is to implement this in WebGL.

Combining Shaders into a Program

The next step is to combine the shaders into a program:


<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">ShaderProgram</span> <span class="hljs-params">(gl, vertSrc, fragSrc)</span> {</span>
  <span class="hljs-keyword">var</span> vert = gl.createShader(gl.VERTEX_SHADER)
  gl.shaderSource(vert, vertSrc)
  gl.compileShader(vert)
  <span class="hljs-keyword">if</span> (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) {
    console.error(gl.getShaderInfoLog(vert))
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">'Failed to compile shader'</span>)
  }

  <span class="hljs-keyword">var</span> frag = gl.createShader(gl.FRAGMENT_SHADER)
  gl.shaderSource(frag, fragSrc)
  gl.compileShader(frag)
  <span class="hljs-keyword">if</span> (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) {
    console.error(gl.getShaderInfoLog(frag))
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">'Failed to compile shader'</span>)
  }

  <span class="hljs-keyword">var</span> program = gl.createProgram()
  gl.attachShader(program, vert)
  gl.attachShader(program, frag)
  gl.linkProgram(program)
  <span class="hljs-keyword">if</span> (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program))
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">'Failed to link program'</span>)
  }

  <span class="hljs-keyword">this</span>.gl = gl
  <span class="hljs-keyword">this</span>.position = gl.getAttribLocation(program, <span class="hljs-string">'position'</span>)
  <span class="hljs-keyword">this</span>.normal = gl.getAttribLocation(program, <span class="hljs-string">'normal'</span>)
  <span class="hljs-keyword">this</span>.uv = gl.getAttribLocation(program, <span class="hljs-string">'uv'</span>)
  <span class="hljs-keyword">this</span>.model = gl.getUniformLocation(program, <span class="hljs-string">'model'</span>)
  <span class="hljs-keyword">this</span>.view = gl.getUniformLocation(program, <span class="hljs-string">'view'</span>)
  <span class="hljs-keyword">this</span>.projection = gl.getUniformLocation(program, <span class="hljs-string">'projection'</span>)
  <span class="hljs-keyword">this</span>.vert = vert
  <span class="hljs-keyword">this</span>.frag = frag
  <span class="hljs-keyword">this</span>.program = program
}

<span class="hljs-comment">// Loads shader files from the given URLs, and returns a program as a promise</span>
ShaderProgram.load = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(gl, vertUrl, fragUrl)</span> {</span>
  <span class="hljs-keyword">return</span> Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(files)</span> {</span>
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> ShaderProgram(gl, files[<span class="hljs-number">0</span>], files[<span class="hljs-number">1</span>])
  })

  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">loadFile</span> <span class="hljs-params">(url)</span> {</span>
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> Promise(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(resolve)</span> {</span>
      <span class="hljs-keyword">var</span> xhr = <span class="hljs-keyword">new</span> XMLHttpRequest()
      xhr.onreadystatechange = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
        <span class="hljs-keyword">if</span> (xhr.readyState == XMLHttpRequest.DONE) {
          resolve(xhr.responseText)
        }
      }
      xhr.open(<span class="hljs-string">'GET'</span>, url, <span class="hljs-literal">true</span>)
      xhr.send(<span class="hljs-literal">null</span>)
    })
  }
}

There isn’t much to say about what’s happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.

Actually Drawing the Model

Last, but not least, you draw the model.

First you pick the shader program you want to use.


ShaderProgram.prototype.use = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">this</span>.gl.useProgram(<span class="hljs-keyword">this</span>.program)
}

Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.


Transformation.prototype.sendToGpu = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(gl, uniform, transpose)</span> {</span>
  gl.uniformMatrix4fv(uniform, transpose || <span class="hljs-literal">false</span>, <span class="hljs-keyword">new</span> <span class="hljs-built_in">Float32Array</span>(<span class="hljs-keyword">this</span>.fields))
}

Camera.prototype.use = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(shaderProgram)</span> {</span>
  <span class="hljs-keyword">this</span>.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection)
  <span class="hljs-keyword">this</span>.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view)
}

Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.


VBO.prototype.bindToAttribute = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(attribute)</span> {</span>
  <span class="hljs-keyword">var</span> gl = <span class="hljs-keyword">this</span>.gl
  <span class="hljs-comment">// Tell which buffer object we want to operate on as a VBO</span>
  gl.bindBuffer(gl.ARRAY_BUFFER, <span class="hljs-keyword">this</span>.data)
  <span class="hljs-comment">// Enable this attribute in the shader</span>
  gl.enableVertexAttribArray(attribute)
  <span class="hljs-comment">// Define format of the attribute array. Must match parameters in shader</span>
  gl.vertexAttribPointer(attribute, <span class="hljs-keyword">this</span>.size, gl.FLOAT, <span class="hljs-literal">false</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>)
}

Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call

drawArrays()

from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using

TRIANGLES

takes three by three vertices and draws a triangle for each triplet. Using

POINTS

would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:


Mesh.prototype.draw = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(shaderProgram)</span> {</span>
  <span class="hljs-keyword">this</span>.positions.bindToAttribute(shaderProgram.position)
  <span class="hljs-keyword">this</span>.normals.bindToAttribute(shaderProgram.normal)
  <span class="hljs-keyword">this</span>.uvs.bindToAttribute(shaderProgram.uv)
  <span class="hljs-keyword">this</span>.position.sendToGpu(<span class="hljs-keyword">this</span>.gl, shaderProgram.model)
  <span class="hljs-keyword">this</span>.gl.drawArrays(<span class="hljs-keyword">this</span>.gl.TRIANGLES, <span class="hljs-number">0</span>, <span class="hljs-keyword">this</span>.vertexCount)
}

The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.


Renderer.prototype.setShader = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(shader)</span> {</span>
  <span class="hljs-keyword">this</span>.shader = shader
}

Renderer.prototype.render = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(camera, objects)</span> {</span>
  <span class="hljs-keyword">this</span>.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
  <span class="hljs-keyword">var</span> shader = <span class="hljs-keyword">this</span>.shader
  <span class="hljs-keyword">if</span> (!shader) {
    <span class="hljs-keyword">return</span>
  }
  shader.use()
  camera.use(shader)
  objects.forEach(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(mesh)</span> {</span>
    mesh.draw(shader)
  })
}

We can combine all the elements that we have to finally draw something on the screen:


<span class="hljs-keyword">var</span> renderer = <span class="hljs-keyword">new</span> Renderer(document.getElementById(<span class="hljs-string">'webgl-canvas'</span>))
renderer.setClearColor(<span class="hljs-number">100</span>, <span class="hljs-number">149</span>, <span class="hljs-number">237</span>)
<span class="hljs-keyword">var</span> gl = renderer.getContext()

<span class="hljs-keyword">var</span> objects = []

Geometry.loadOBJ(<span class="hljs-string">'/assets/sphere.obj'</span>).then(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(data)</span> {</span>
  objects.push(<span class="hljs-keyword">new</span> Mesh(gl, data))
})
ShaderProgram.load(gl, <span class="hljs-string">'/shaders/basic.vert'</span>, <span class="hljs-string">'/shaders/basic.frag'</span>)
             .then(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(shader)</span> {</span>
               renderer.setShader(shader)
             })

<span class="hljs-keyword">var</span> camera = <span class="hljs-keyword">new</span> Camera()
camera.setOrthographic(<span class="hljs-number">16</span>, <span class="hljs-number">10</span>, <span class="hljs-number">10</span>)

loop()

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">loop</span> <span class="hljs-params">()</span> {</span>
  renderer.render(camera, objects)
  requestAnimationFrame(loop)
}

Object drawn on the canvas, with colors depending on UV coordinates

This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:


<span class="hljs-preprocessor">#ifdef GL_ES</span>
<span class="hljs-keyword">precision</span> <span class="hljs-keyword">highp</span> <span class="hljs-keyword">float</span>;
<span class="hljs-preprocessor">#endif</span>

<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec3</span> vNormal;
<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec2</span> vUv;

<span class="hljs-keyword">void</span> main() {
    <span class="hljs-keyword">vec3</span> brown = <span class="hljs-keyword">vec3</span>(<span class="hljs-number">.54</span>, <span class="hljs-number">.27</span>, <span class="hljs-number">.07</span>);
    <span class="hljs-built_in">gl_FragColor</span> = <span class="hljs-keyword">vec4</span>(brown, <span class="hljs-number">1.</span>);
}

Brown object drawn on the canvas

It doesn’t look very convincing. It looks like the scene needs some shading effects.

Adding Light

Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.

Sunlight sounds like it’s the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.

Demonstration of angles between light rays and surface normals, for both flat and smooth shading

You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.

If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.


<span class="hljs-preprocessor">#ifdef GL_ES</span>
<span class="hljs-keyword">precision</span> <span class="hljs-keyword">highp</span> <span class="hljs-keyword">float</span>;
<span class="hljs-preprocessor">#endif</span>

<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec3</span> vNormal;
<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec2</span> vUv;

<span class="hljs-keyword">void</span> main() {
    <span class="hljs-keyword">vec3</span> brown = <span class="hljs-keyword">vec3</span>(<span class="hljs-number">.54</span>, <span class="hljs-number">.27</span>, <span class="hljs-number">.07</span>);
    <span class="hljs-keyword">vec3</span> sunlightDirection = <span class="hljs-keyword">vec3</span>(-<span class="hljs-number">1.</span>, -<span class="hljs-number">1.</span>, -<span class="hljs-number">1.</span>);
    <span class="hljs-keyword">float</span> lightness = -<span class="hljs-built_in">clamp</span>(<span class="hljs-built_in">dot</span>(<span class="hljs-built_in">normalize</span>(vNormal), <span class="hljs-built_in">normalize</span>(sunlightDirection)), -<span class="hljs-number">1.</span>, <span class="hljs-number">0.</span>);
    <span class="hljs-built_in">gl_FragColor</span> = <span class="hljs-keyword">vec4</span>(brown * lightness, <span class="hljs-number">1.</span>);
}

Brown object with sunlight

We set the sun to shine in the forward-left-down direction. You can see how smooth the shading is, even though the model is very jagged. You can also notice how dark the bottom-left side is. We can add a level of ambient light, which will make the area in the shadow brighter.


<span class="hljs-preprocessor">#ifdef GL_ES</span>
<span class="hljs-keyword">precision</span> <span class="hljs-keyword">highp</span> <span class="hljs-keyword">float</span>;
<span class="hljs-preprocessor">#endif</span>

<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec3</span> vNormal;
<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec2</span> vUv;

<span class="hljs-keyword">void</span> main() {
    <span class="hljs-keyword">vec3</span> brown = <span class="hljs-keyword">vec3</span>(<span class="hljs-number">.54</span>, <span class="hljs-number">.27</span>, <span class="hljs-number">.07</span>);
    <span class="hljs-keyword">vec3</span> sunlightDirection = <span class="hljs-keyword">vec3</span>(-<span class="hljs-number">1.</span>, -<span class="hljs-number">1.</span>, -<span class="hljs-number">1.</span>);
    <span class="hljs-keyword">float</span> lightness = -<span class="hljs-built_in">clamp</span>(<span class="hljs-built_in">dot</span>(<span class="hljs-built_in">normalize</span>(vNormal), <span class="hljs-built_in">normalize</span>(sunlightDirection)), -<span class="hljs-number">1.</span>, <span class="hljs-number">0.</span>);
    <span class="hljs-keyword">float</span> ambientLight = <span class="hljs-number">0.3</span>;
    lightness = ambientLight + (<span class="hljs-number">1.</span> - ambientLight) * lightness;
    <span class="hljs-built_in">gl_FragColor</span> = <span class="hljs-keyword">vec4</span>(brown * lightness, <span class="hljs-number">1.</span>);
}

Brown object with sunlight and ambient light

You can achieve this same effect by introducing a light class, which stores the light direction and ambient light intensity. Then you can change the fragment shader to accommodate that addition.

Now the shader becomes:


<span class="hljs-preprocessor">#ifdef GL_ES</span>
<span class="hljs-keyword">precision</span> <span class="hljs-keyword">highp</span> <span class="hljs-keyword">float</span>;
<span class="hljs-preprocessor">#endif</span>

<span class="hljs-keyword">uniform</span> <span class="hljs-keyword">vec3</span> lightDirection;
<span class="hljs-keyword">uniform</span> <span class="hljs-keyword">float</span> ambientLight;
<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec3</span> vNormal;
<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec2</span> vUv;

<span class="hljs-keyword">void</span> main() {
    <span class="hljs-keyword">vec3</span> brown = <span class="hljs-keyword">vec3</span>(<span class="hljs-number">.54</span>, <span class="hljs-number">.27</span>, <span class="hljs-number">.07</span>);
    <span class="hljs-keyword">float</span> lightness = -<span class="hljs-built_in">clamp</span>(<span class="hljs-built_in">dot</span>(<span class="hljs-built_in">normalize</span>(vNormal), <span class="hljs-built_in">normalize</span>(lightDirection)), -<span class="hljs-number">1.</span>, <span class="hljs-number">0.</span>);
    lightness = ambientLight + (<span class="hljs-number">1.</span> - ambientLight) * lightness;
    <span class="hljs-built_in">gl_FragColor</span> = <span class="hljs-keyword">vec4</span>(brown * lightness, <span class="hljs-number">1.</span>);
}

Then you can define the light:


<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Light</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">this</span>.lightDirection = <span class="hljs-keyword">new</span> Vector3(-<span class="hljs-number">1</span>, -<span class="hljs-number">1</span>, -<span class="hljs-number">1</span>)
  <span class="hljs-keyword">this</span>.ambientLight = <span class="hljs-number">0.3</span>
}

Light.prototype.use = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(shaderProgram)</span> {</span>
  <span class="hljs-keyword">var</span> dir = <span class="hljs-keyword">this</span>.lightDirection
  <span class="hljs-keyword">var</span> gl = shaderProgram.gl
  gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z)
  gl.uniform1f(shaderProgram.ambientLight, <span class="hljs-keyword">this</span>.ambientLight)
}

In the shader program class, add the needed uniforms:


<span class="hljs-keyword">this</span>.ambientLight = gl.getUniformLocation(program, <span class="hljs-string">'ambientLight'</span>)
<span class="hljs-keyword">this</span>.lightDirection = gl.getUniformLocation(program, <span class="hljs-string">'lightDirection'</span>)

In the program, add a call to the new light in the renderer:


Renderer.prototype.render = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(camera, light, objects)</span> {</span>
  <span class="hljs-keyword">this</span>.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
  <span class="hljs-keyword">var</span> shader = <span class="hljs-keyword">this</span>.shader
  <span class="hljs-keyword">if</span> (!shader) {
    <span class="hljs-keyword">return</span>
  }
  shader.use()
  light.use(shader)
  camera.use(shader)
  objects.forEach(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(mesh)</span> {</span>
    mesh.draw(shader)
  })
}

The loop will then change slightly:


<span class="hljs-keyword">var</span> light = <span class="hljs-keyword">new</span> Light()

loop()

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">loop</span> <span class="hljs-params">()</span> {</span>
  renderer.render(camera, light, objects)
  requestAnimationFrame(loop)
}

If you’ve done everything right, then the rendered image should be the same as it was in the last image.

A final step to consider would be adding an actual texture to our model. Let’s do that now.

Adding Textures

HTML5 has great support for loading images, so there is no need to do crazy image parsing. Images are passed to GLSL as

sampler2D

by telling the shader which of the bound textures to sample. There is a limited number of textures one could bind, and the limit is based on the hardware used. A

sampler2D

can be queried for colors at certain positions. This is where UV coordinates come in. Here is an example where we replaced brown with sampled colors.


<span class="hljs-preprocessor">#ifdef GL_ES</span>
<span class="hljs-keyword">precision</span> <span class="hljs-keyword">highp</span> <span class="hljs-keyword">float</span>;
<span class="hljs-preprocessor">#endif</span>

<span class="hljs-keyword">uniform</span> <span class="hljs-keyword">vec3</span> lightDirection;
<span class="hljs-keyword">uniform</span> <span class="hljs-keyword">float</span> ambientLight;
<span class="hljs-keyword">uniform</span> <span class="hljs-keyword">sampler2D</span> diffuse;
<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec3</span> vNormal;
<span class="hljs-keyword">varying</span> <span class="hljs-keyword">vec2</span> vUv;

<span class="hljs-keyword">void</span> main() {
    <span class="hljs-keyword">float</span> lightness = -<span class="hljs-built_in">clamp</span>(<span class="hljs-built_in">dot</span>(<span class="hljs-built_in">normalize</span>(vNormal), <span class="hljs-built_in">normalize</span>(lightDirection)), -<span class="hljs-number">1.</span>, <span class="hljs-number">0.</span>);
    lightness = ambientLight + (<span class="hljs-number">1.</span> - ambientLight) * lightness;
    <span class="hljs-built_in">gl_FragColor</span> = <span class="hljs-keyword">vec4</span>(<span class="hljs-built_in">texture2D</span>(diffuse, vUv).rgb * lightness, <span class="hljs-number">1.</span>);
}

The new uniform has to be added to the listing in the shader program:


<span class="hljs-keyword">this</span>.diffuse = gl.getUniformLocation(program, <span class="hljs-string">'diffuse'</span>)

Finally, we’ll implement texture loading. As previously said, HTML5 provides facilities for loading images. All we need to do is send the image to the GPU:


<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Texture</span> <span class="hljs-params">(gl, image)</span> {</span>
  <span class="hljs-keyword">var</span> texture = gl.createTexture()
  <span class="hljs-comment">// Set the newly created texture context as active texture</span>
  gl.bindTexture(gl.TEXTURE_2D, texture)
  <span class="hljs-comment">// Set texture parameters, and pass the image that the texture is based on</span>
  gl.texImage2D(gl.TEXTURE_2D, <span class="hljs-number">0</span>, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
  <span class="hljs-comment">// Set filtering methods</span>
  <span class="hljs-comment">// Very often shaders will query the texture value between pixels,</span>
  <span class="hljs-comment">// and this is instructing how that value shall be calculated</span>
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
  <span class="hljs-keyword">this</span>.data = texture
  <span class="hljs-keyword">this</span>.gl = gl
}

Texture.prototype.use = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(uniform, binding)</span> {</span>
  binding = <span class="hljs-built_in">Number</span>(binding) || <span class="hljs-number">0</span>
  <span class="hljs-keyword">var</span> gl = <span class="hljs-keyword">this</span>.gl
  <span class="hljs-comment">// We can bind multiple textures, and here we pick which of the bindings</span>
  <span class="hljs-comment">// we're setting right now</span>
  gl.activeTexture(gl[<span class="hljs-string">'TEXTURE'</span> + binding])
  <span class="hljs-comment">// After picking the binding, we set the texture</span>
  gl.bindTexture(gl.TEXTURE_2D, <span class="hljs-keyword">this</span>.data)
  <span class="hljs-comment">// Finally, we pass to the uniform the binding ID we've used</span>
  gl.uniform1i(uniform, binding)
  <span class="hljs-comment">// The previous 3 lines are equivalent to:</span>
  <span class="hljs-comment">// texture[i] = this.data</span>
  <span class="hljs-comment">// uniform = i</span>
}

Texture.load = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(gl, url)</span> {</span>
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> Promise(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(resolve)</span> {</span>
    <span class="hljs-keyword">var</span> image = <span class="hljs-keyword">new</span> Image()
    image.onload = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
      resolve(<span class="hljs-keyword">new</span> Texture(gl, image))
    }
    image.src = url
  })
}

The process is not much different from the process used to load and bind VBOs. The main difference is that we’re no longer binding to an attribute, but rather binding the index of the texture to an integer uniform. The

sampler2D

type is nothing more than a pointer offset to a texture.

Now all that needs to be done is extend the

Mesh

class, to handle textures as well:


<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Mesh</span> <span class="hljs-params">(gl, geometry, texture)</span> {</span> <span class="hljs-comment">// added texture</span>
  <span class="hljs-keyword">var</span> vertexCount = geometry.vertexCount()
  <span class="hljs-keyword">this</span>.positions = <span class="hljs-keyword">new</span> VBO(gl, geometry.positions(), vertexCount)
  <span class="hljs-keyword">this</span>.normals = <span class="hljs-keyword">new</span> VBO(gl, geometry.normals(), vertexCount)
  <span class="hljs-keyword">this</span>.uvs = <span class="hljs-keyword">new</span> VBO(gl, geometry.uvs(), vertexCount)
  <span class="hljs-keyword">this</span>.texture = texture <span class="hljs-comment">// new</span>
  <span class="hljs-keyword">this</span>.vertexCount = vertexCount
  <span class="hljs-keyword">this</span>.position = <span class="hljs-keyword">new</span> Transformation()
  <span class="hljs-keyword">this</span>.gl = gl
}

Mesh.prototype.destroy = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> {</span>
  <span class="hljs-keyword">this</span>.positions.destroy()
  <span class="hljs-keyword">this</span>.normals.destroy()
  <span class="hljs-keyword">this</span>.uvs.destroy()
}

Mesh.prototype.draw = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(shaderProgram)</span> {</span>
  <span class="hljs-keyword">this</span>.positions.bindToAttribute(shaderProgram.position)
  <span class="hljs-keyword">this</span>.normals.bindToAttribute(shaderProgram.normal)
  <span class="hljs-keyword">this</span>.uvs.bindToAttribute(shaderProgram.uv)
  <span class="hljs-keyword">this</span>.position.sendToGpu(<span class="hljs-keyword">this</span>.gl, shaderProgram.model)
  <span class="hljs-keyword">this</span>.texture.use(shaderProgram.diffuse, <span class="hljs-number">0</span>) <span class="hljs-comment">// new</span>
  <span class="hljs-keyword">this</span>.gl.drawArrays(<span class="hljs-keyword">this</span>.gl.TRIANGLES, <span class="hljs-number">0</span>, <span class="hljs-keyword">this</span>.vertexCount)
}

Mesh.load = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(gl, modelUrl, textureUrl)</span> {</span> <span class="hljs-comment">// new</span>
  <span class="hljs-keyword">var</span> geometry = Geometry.loadOBJ(modelUrl)
  <span class="hljs-keyword">var</span> texture = Texture.load(gl, textureUrl)
  <span class="hljs-keyword">return</span> Promise.all([geometry, texture]).then(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(params)</span> {</span>
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> Mesh(gl, params[<span class="hljs-number">0</span>], params[<span class="hljs-number">1</span>])
  })
}

And the final main script would look as follows:


<span class="hljs-keyword">var</span> renderer = <span class="hljs-keyword">new</span> Renderer(document.getElementById(<span class="hljs-string">'webgl-canvas'</span>))
renderer.setClearColor(<span class="hljs-number">100</span>, <span class="hljs-number">149</span>, <span class="hljs-number">237</span>)
<span class="hljs-keyword">var</span> gl = renderer.getContext()

<span class="hljs-keyword">var</span> objects = []

Mesh.load(gl, <span class="hljs-string">'/assets/sphere.obj'</span>, <span class="hljs-string">'/assets/diffuse.png'</span>)
    .then(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(mesh)</span> {</span>
      objects.push(mesh)
    })

ShaderProgram.load(gl, <span class="hljs-string">'/shaders/basic.vert'</span>, <span class="hljs-string">'/shaders/basic.frag'</span>)
             .then(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(shader)</span> {</span>
               renderer.setShader(shader)
             })

<span class="hljs-keyword">var</span> camera = <span class="hljs-keyword">new</span> Camera()
camera.setOrthographic(<span class="hljs-number">16</span>, <span class="hljs-number">10</span>, <span class="hljs-number">10</span>)
<span class="hljs-keyword">var</span> light = <span class="hljs-keyword">new</span> Light()

loop()

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">loop</span> <span class="hljs-params">()</span> {</span>
  renderer.render(camera, light, objects)
  requestAnimationFrame(loop)
}

Textured object with lighting effects

Even animating comes easy at this point. If you wanted the camera to spin around our object, you can do it by just adding one line of code:


<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">loop</span> <span class="hljs-params">()</span> {</span>
  renderer.render(camera, light, objects)
  camera.position = camera.position.rotateY(<span class="hljs-built_in">Math</span>.PI / <span class="hljs-number">120</span>)
  requestAnimationFrame(loop)
}

Rotated head during camera animation

Feel free to play around with shaders. Adding one line of code will turn this realistic lighting into something cartoonish.


<span class="hljs-keyword">void</span> main() {
    <span class="hljs-keyword">float</span> lightness = -<span class="hljs-built_in">clamp</span>(<span class="hljs-built_in">dot</span>(<span class="hljs-built_in">normalize</span>(vNormal), <span class="hljs-built_in">normalize</span>(lightDirection)), -<span class="hljs-number">1.</span>, <span class="hljs-number">0.</span>);
    lightness = lightness &gt; <span class="hljs-number">0.1</span> ? <span class="hljs-number">1.</span> : <span class="hljs-number">0.</span>; <span class="hljs-comment">// new</span>
    lightness = ambientLight + (<span class="hljs-number">1.</span> - ambientLight) * lightness;
    <span class="hljs-built_in">gl_FragColor</span> = <span class="hljs-keyword">vec4</span>(<span class="hljs-built_in">texture2D</span>(diffuse, vUv).rgb * lightness, <span class="hljs-number">1.</span>);
}

It’s as simple as telling the lighting to go into its extremes based on whether it crossed a set threshold.

Head with cartoon lighting applied

Where to Go Next

There are many sources of information for learning all the tricks and intricacies of WebGL. And the best part is that if you can’t find an answer that relates to WebGL, you can look for it in OpenGL, since WebGL is pretty much based on a subset of OpenGL, with some names being changed.

In no particular order, here are some great sources for more detailed information, both for WebGL and OpenGL.

Khronos WebGL 1.0 specification for if you’re interested in understanding the more technical details of how the WebGL API should work in all edge cases.

This post originally appeared on Toptal

Follow me

Ankit Wasankar

Software Analyst at Yardi India Pvt. Ltd.
Ankit Wasankar works as a Java Developer, Software Analyst at Yardi India Pvt. Ltd. He loves writing about his experiences with new technologies specially with Python.
Follow me

Leave a Reply

Your email address will not be published. Required fields are marked *