WebGL from Scratch: Directional Lighting

In this post, we’re going to go from the spinning cube defined in the last one to one where the faces are lit from a specific direction. This means that geometry facing that light will be rendered more brightly, and geometry facing away will be darker, something like this:

lit-cube

But there’s a catch:

WebGL knows nothing about lighting.

What it does know about is what you tell it: where a point is in space and where a light source is. It also provides the functions that you need to do the necessary calculations to figure out—according to your lighting model of choice—what the colour of a given pixel should be.

That’s horribly vague, isn’t it? Briefly, accurately modelling light is hideously expensive. You’d have to account for the path of each individual photon from source to your eye, accounting for any materials that it bounces off or refracts through. So WebGL puts the tools in your hands with which to approximate lighting according to whichever model you want. Having that amount of choice can be paralysing, though. Where should you start? Well, a common model is to account for ambient, diffuse and specular effects, and to multiply those with ambient, diffuse and specular coefficients for the materials that you decide your objects are to be made of. The specular calculation also accounts for the ‘shininess’ of a material. I’ll ignore the specular component for just now: it’s possible to get something visible using just the ambient and diffuse components. The specular contribution can always be calculated later.

But first, the ambient component. Ambient light is the approximating term that WebGL uses for light that has no definitive source. In reality, all lights have a source, but in some cases the light is scattered so much that trying to model it accurately is, for all intents and purposes, impossible. Yes, that means that ambient light is a hack, but it’s good enough. It’s just a colour value that’s added to whatever colour you specified for a given vertex. Given that it’s global to a scene, it can be effectively modeled as a uniform vec3, and can be set once and forgotten.

Second, the diffuse component. This is light that’s scattered equally in all directions by a given surface, but which comes from a definite direction. The sun is a good example.

Since directional light has, by definition, a direction, its contribution to a given fragment is dependent upon the angle of that fragment against that direction, combined with the direction that you’re viewing the fragment from. This involves some math. We can place the code for this in either the vertex or the fragment shader. If we do it in the vertex shader, the value calculated at each vertex by the vertex shader will be interpolated—not recalculated—for each fragment. This makes it a) cheap, and; b) less accurate. Conversely, doing it in the fragment shader will increase both the computational cost and accuracy of the effect. Which one you pick is up to you, based on the complexity of your scene, the capabilities of the hardware that will be rendering it, whether you’re optimising for performance or power, etc. First, I’ll be trying implementing the lighting model in the vertex shader.

Remember that, as with previous posts, you’re going to need to need the gl-matrix library.

Code first, explanation later.

<!doctype html>
<html>
  <head>
    <title>Hacking WebGL</title>
    <script type="x-shader/x-vertex" id="vertex-shader">
    precision mediump float;

    attribute vec3 pos;
    attribute vec3 normal;

    varying vec3 col;

    uniform mat4 projectionMatrix, viewMatrix, modelMatrix;
    uniform mat3 normalMatrix;
    uniform float time;
    uniform vec3 directionalLight;
    uniform vec3 ambientLightColour;
    uniform float materialAmbient;
    uniform float materialDiffuse;

    /* 
     * A function to determine the colour of a vertex, accounting
     * for ambient and directional light
     */
    vec3 ad( vec4 position, vec3 norm )
    {
      vec3 s = normalize(vec3(vec4(directionalLight,1.0) - position));
      vec3 r = reflect(-s, norm);
      return ambientLightColour + materialDiffuse * max(dot(s,norm), 0.0);
    }

    void main() {
      mat4 mvMatrix = viewMatrix * modelMatrix;
      vec3 eyeNormal = normalize(normalMatrix * normal);
      vec4 eyePosition = mvMatrix * vec4(pos, 1.0);
      col = min(ad(eyePosition, eyeNormal), 1.0);
      gl_Position = projectionMatrix * mvMatrix * vec4(pos, 1.0);       
    }
    </script>
    <script type="x-shader/x-fragment" id="fragment-shader">
    precision mediump float;

    uniform vec3 ambientLightColour;

    varying vec3 col;
    void main() {
      gl_FragColor = vec4(col, 1.0);
    }
    </script>
    <script type="text/javascript" src="gl-matrix.js"></script>
    <script type="text/javascript">

    function render(gl,scene,timestamp,previousTimestamp) {

      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
      gl.useProgram(scene.program);

      var light = vec3.fromValues(
        (document.getElementById('light-x').value - 50.0) / 10.0,
        (document.getElementById('light-y').value - 50.0) / 10.0, 
        (document.getElementById('light-z').value - 50.0) / 10.0);

      gl.uniform3fv(scene.program.directionalLightUniform, light);

      gl.uniform1f(scene.program.timeUniform, timestamp);

      var delta = (0.125 * Math.PI) / (timestamp - previousTimestamp);

      var rotateX = (document.getElementById('rotate-x').value - 5) / 10;
      var rotateY = (document.getElementById('rotate-y').value - 5) / 10;
      var rotateZ = (document.getElementById('rotate-z').value - 5) / 10;

      mat4.rotate(
        scene.object.modelMatrix, scene.object.modelMatrix,
        delta, [rotateX, rotateY, rotateZ]);
      gl.uniformMatrix4fv(
        scene.program.modelMatrixUniform, gl.FALSE, scene.object.modelMatrix
      );

      var normalMatrix = mat3.create();
      mat3.normalFromMat4(
        normalMatrix, mat4.multiply(
          mat4.create(), scene.object.modelMatrix, scene.viewMatrix
      ));
      gl.uniformMatrix3fv(
        scene.program.normalMatrixUniform, gl.FALSE, normalMatrix);

      gl.bindBuffer(gl.ARRAY_BUFFER, scene.object.vertexBuffer);
      gl.drawArrays(
        scene.object.primitiveType, 0, scene.object.vertexCount
      );

      gl.bindBuffer(gl.ARRAY_BUFFER, null);
      gl.useProgram(null);
      requestAnimationFrame(function(time) {
        render(gl,scene,time,timestamp);
      });
    }

    function createProgram(gl, shaderSpecs) {
      var program = gl.createProgram();
      for ( var i = 0 ; i < shaderSpecs.length ; i++ ) {
        var spec = shaderSpecs[i];
        var shader = gl.createShader(spec.type);
        gl.shaderSource(
          shader, document.getElementById(spec.container).text
        );
        gl.compileShader(shader);
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
          throw gl.getShaderInfoLog(shader);
        }
        gl.attachShader(program, shader);
        gl.deleteShader(shader);
      }
      gl.linkProgram(program);
      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        throw gl.getProgramInfoLog(program);
      }
      return program;
    }
    
    function init() {
      var surface = document.getElementById('rendering-surface');
      var gl = surface.getContext('experimental-webgl');
      gl.viewport(0,0,surface.width,surface.height);
      gl.enable(gl.DEPTH_TEST);
      gl.enable(gl.CULL_FACE);
      gl.cullFace(gl.BACK);
      gl.clearColor(0.0, 0.0, 0.0, 0.0);

      var program = createProgram(
        gl,
        [{container: 'vertex-shader', type: gl.VERTEX_SHADER},
         {container: 'fragment-shader', type: gl.FRAGMENT_SHADER}]
      );

      var squareVertices = [
        /* front face */
          /*  position */     /* normal */
        +0.75, +0.75, +0.75, 0.0, 0.0, 0.0, /* front-top-right */
        -0.75, +0.75, +0.75, 0.0, 0.0, 0.0, /* front-top-left  */
        +0.75, -0.75, +0.75, 0.0, 0.0, 0.0, /* front-bottom-right */
        -0.75, -0.75, +0.75, 0.0, 0.0, 0.0, /* front-bottom-left */
        +0.75, -0.75, +0.75, 0.0, 0.0, 0.0, /* front-bottom-right */
        -0.75, +0.75, +0.75, 0.0, 0.0, 0.0, /* front-top-left */

        /* right face */
        +0.75, +0.75, -0.75, 0.0, 0.0, 0.0, /* rear-top-right */
        +0.75, +0.75, +0.75, 0.0, 0.0, 0.0, /* front-top-right */
        +0.75, -0.75, -0.75, 0.0, 0.0, 0.0, /* rear-bottom-right */
        +0.75, -0.75, +0.75, 0.0, 0.0, 0.0, /* front-bottom-right */
        +0.75, -0.75, -0.75, 0.0, 0.0, 0.0, /* rear-bottom-right */
        +0.75, +0.75, +0.75, 0.0, 0.0, 0.0, /* front-top-right */

        /* back face */
        -0.75, +0.75, -0.75, 0.0, 0.0, 0.0, /* rear-top-left */
        +0.75, +0.75, -0.75, 0.0, 0.0, 0.0, /* rear-top-right */
        -0.75, -0.75, -0.75, 0.0, 0.0, 0.0, /* rear-bottom-left */
        +0.75, -0.75, -0.75, 0.0, 0.0, 0.0, /* rear-bottom-right */
        -0.75, -0.75, -0.75, 0.0, 0.0, 0.0, /* rear-bottom-left */
        +0.75, +0.75, -0.75, 0.0, 0.0, 0.0, /* rear-top-right */

        /* left face */
        -0.75, +0.75, +0.75, 0.0, 0.0, 0.0, /* front-top-left */
        -0.75, +0.75, -0.75, 0.0, 0.0, 0.0, /* rear-top-left */
        -0.75, -0.75, +0.75, 0.0, 0.0, 0.0, /* front-bottom-left */
        -0.75, -0.75, -0.75, 0.0, 0.0, 0.0, /* rear-bottom-left */
        -0.75, -0.75, +0.75, 0.0, 0.0, 0.0, /* front-bottom-left */
        -0.75, +0.75, -0.75, 0.0, 0.0, 0.0, /* rear-top-left */

        /* top face */
        +0.75, +0.75, -0.75, 0.0, 0.0, 0.0, /* rear-top-right */
        -0.75, +0.75, -0.75, 0.0, 0.0, 0.0, /* rear-top-left */
        +0.75, +0.75, +0.75, 0.0, 0.0, 0.0, /* front-top-right */
        -0.75, +0.75, +0.75, 0.0, 0.0, 0.0, /* front-top-left */
        +0.75, +0.75, +0.75, 0.0, 0.0, 0.0, /* front-top-right */
        -0.75, +0.75, -0.75, 0.0, 0.0, 0.0, /* rear-top-left */

        /* bottom face */
        +0.75, -0.75, +0.75, 0.0, 0.0, 0.0, /* front-bottom-right */
        -0.75, -0.75, +0.75, 0.0, 0.0, 0.0, /* front-bottom-left */
        +0.75, -0.75, -0.75, 0.0, 0.0, 0.0, /* rear-bottom-right */        
        -0.75, -0.75, -0.75, 0.0, 0.0, 0.0, /* rear-bottom-left */
        +0.75, -0.75, -0.75, 0.0, 0.0, 0.0, /* rear-bottom-right */
        -0.75, -0.75, +0.75, 0.0, 0.0, 0.0  /* front-bottom-left */
      ];

      var fpv = 6; // 9 floats per vertex
      for ( var i = 0 ; i < 6 ; i++ ) {  // tackle each of 6 faces
        var offset = i * fpv * 6;        // offset to a face 'block'
        var normal = vec3.create();      // temp vertex normal
        var cross = vec3.create();       // temp cross product
        var right = vec3.create();       // temp right-side vector
        var left  = vec3.create();       // temp left-side vector

        // Despite the intimidating looking code, calculating
        // the normal is pretty simple:
        //   1. find the vector from one vertex to its neighbour by
        //      subtracting the vertex from that neighbour
        //   2. repeat for a second vertex.
        //   3. get the normal vector by taking the cross-product of
        //      those vectors, ensuring that you're always choosing
        //      neighbours in the same order---left/right or
        //      right/left---at each vertex.  If you mix them up, the
        //      normal vector will be flipped
        //   4. normalise the normal vector (unfortunate terminology:
        //      they have nothing to do with each other)

        vec3.normalize(
          normal,
          vec3.cross(
            cross,
            vec3.subtract(
              right,
              vec3.fromValues(
                squareVertices[offset+fpv*1+0],
                squareVertices[offset+fpv*1+1],
                squareVertices[offset+fpv*1+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*0+0],
                squareVertices[offset+fpv*0+1],
                squareVertices[offset+fpv*0+2])),
            vec3.subtract(
              left,
              vec3.fromValues(
                squareVertices[offset+fpv*2+0],
                squareVertices[offset+fpv*2+1],
                squareVertices[offset+fpv*2+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*0+0],
                squareVertices[offset+fpv*0+1],
                squareVertices[offset+fpv*0+2]))));

        // Write the calculated normal vector into its
        // reserved place in the vertex data array
        squareVertices[offset + fpv * 0 + 3] = normal[0];
        squareVertices[offset + fpv * 0 + 4] = normal[1];
        squareVertices[offset + fpv * 0 + 5] = normal[2];

        vec3.normalize(
          normal,
          vec3.cross(
            cross,
            vec3.subtract(
              right,
              vec3.fromValues(
                squareVertices[offset+fpv*2+0],
                squareVertices[offset+fpv*2+1],
                squareVertices[offset+fpv*2+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*1+0],
                squareVertices[offset+fpv*1+1],
                squareVertices[offset+fpv*1+2])),
            vec3.subtract(
              left,
              vec3.fromValues(
                squareVertices[offset+fpv*0+0],
                squareVertices[offset+fpv*0+1],
                squareVertices[offset+fpv*0+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*1+0],
                squareVertices[offset+fpv*1+1],
                squareVertices[offset+fpv*1+2]))));

        squareVertices[offset + fpv * 1 + 3] = normal[0];
        squareVertices[offset + fpv * 1 + 4] = normal[1];
        squareVertices[offset + fpv * 1 + 5] = normal[2];

        vec3.normalize(
          normal,
          vec3.cross(
            cross,
            vec3.subtract(
              right,
              vec3.fromValues(
                squareVertices[offset+fpv*0+0],
                squareVertices[offset+fpv*0+1],
                squareVertices[offset+fpv*0+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*2+0],
                squareVertices[offset+fpv*2+1],
                squareVertices[offset+fpv*2+2])),
            vec3.subtract(
              left,
              vec3.fromValues(
                squareVertices[offset+fpv*1+0],
                squareVertices[offset+fpv*1+1],
                squareVertices[offset+fpv*1+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*2+0],
                squareVertices[offset+fpv*2+1],
                squareVertices[offset+fpv*2+2]))));

        squareVertices[offset + fpv * 2 + 3] = normal[0];
        squareVertices[offset + fpv * 2 + 4] = normal[1];
        squareVertices[offset + fpv * 2 + 5] = normal[2];

        vec3.normalize(
          normal,
          vec3.cross(
            cross,
            vec3.subtract(
              right,
              vec3.fromValues(
                squareVertices[offset+fpv*4+0],
                squareVertices[offset+fpv*4+1],
                squareVertices[offset+fpv*4+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*3+0],
                squareVertices[offset+fpv*3+1],
                squareVertices[offset+fpv*3+2])),
            vec3.subtract(
              left,
              vec3.fromValues(
                squareVertices[offset+fpv*5+0],
                squareVertices[offset+fpv*5+1],
                squareVertices[offset+fpv*5+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*3+0],
                squareVertices[offset+fpv*3+1],
                squareVertices[offset+fpv*3+2]))));

        squareVertices[offset + fpv * 3 + 3] = normal[0];
        squareVertices[offset + fpv * 3 + 4] = normal[1];
        squareVertices[offset + fpv * 3 + 5] = normal[2];

        vec3.normalize(
          normal,
          vec3.cross(
            cross,
            vec3.subtract(
              right,
              vec3.fromValues(
                squareVertices[offset+fpv*5+0],
                squareVertices[offset+fpv*5+1],
                squareVertices[offset+fpv*5+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*4+0],
                squareVertices[offset+fpv*4+1],
                squareVertices[offset+fpv*4+2])),
            vec3.subtract(
              left,
              vec3.fromValues(
                squareVertices[offset+fpv*3+0],
                squareVertices[offset+fpv*3+1],
                squareVertices[offset+fpv*3+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*4+0],
                squareVertices[offset+fpv*4+1],
                squareVertices[offset+fpv*4+2]))));

        squareVertices[offset + fpv * 4 + 3] = normal[0];
        squareVertices[offset + fpv * 4 + 4] = normal[1];
        squareVertices[offset + fpv * 4 + 5] = normal[2];

        vec3.normalize(
          normal,
          vec3.cross(
            cross,
            vec3.subtract(
              right,
              vec3.fromValues(
                squareVertices[offset+fpv*3+0],
                squareVertices[offset+fpv*3+1],
                squareVertices[offset+fpv*3+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*5+0],
                squareVertices[offset+fpv*5+1],
                squareVertices[offset+fpv*5+2])),
            vec3.subtract(
              right,
              vec3.fromValues(
                squareVertices[offset+fpv*4+0],
                squareVertices[offset+fpv*4+1],
                squareVertices[offset+fpv*4+2]),
              vec3.fromValues(
                squareVertices[offset+fpv*5+0],
                squareVertices[offset+fpv*5+1],
                squareVertices[offset+fpv*5+2]))));

        squareVertices[offset + fpv * 5 + 3] = normal[0];
        squareVertices[offset + fpv * 5 + 4] = normal[1];
        squareVertices[offset + fpv * 5 + 5] = normal[2];
      }

      gl.useProgram(program);

      var square = {
        vertexCount: squareVertices.length / fpv,
        primitiveType: gl.TRIANGLES,
        vertices: squareVertices,
        material: { ambient: 0.1, diffuse: 0.3 }
      };

      var vertexBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

      program.positionAttribute = gl.getAttribLocation(program, 'pos');
      gl.enableVertexAttribArray(program.positionAttribute);
      gl.vertexAttribPointer(
        program.positionAttribute, 3, gl.FLOAT, false,
        Float32Array.BYTES_PER_ELEMENT * 6, 0);
      program.normalAttribute = gl.getAttribLocation(program, 'normal');
      gl.enableVertexAttribArray(program.normalAttribute);
      gl.vertexAttribPointer(
        program.normalAttribute, 3, gl.FLOAT, false,
        Float32Array.BYTES_PER_ELEMENT * 6,
        Float32Array.BYTES_PER_ELEMENT * 3);

      gl.bufferData(
        gl.ARRAY_BUFFER, new Float32Array(squareVertices), gl.STATIC_DRAW
      );

      square.vertexBuffer = vertexBuffer;

      program.timeUniform = gl.getUniformLocation(program, 'time');

      // New properties: the ambient and directional light values,
      // as well as values to determine how the surface material reacts
      // to these lights.
      program.ambientLightColourUniform =
        gl.getUniformLocation(program, 'ambientLightColour');
      program.directionalLightUniform =
        gl.getUniformLocation(program, 'directionalLight');
      square.materialAmbientUniform =
        gl.getUniformLocation(program, 'materialAmbient');
      square.materialDiffuseUniform =
        gl.getUniformLocation(program, 'materialDiffuse');

      // Also, a uniform to disable the vertex colour, so that
      // the effect of the light itself can be more easily observed.
      var ambientLightColour = vec3.fromValues(0.0, 0.0, 0.2);
      gl.uniform3fv(
        program.ambientLightColourUniform, ambientLightColour
      );
      var directionalLight = vec3.fromValues(-0.5,0.5,0.5);
      gl.uniform3fv(
        program.directionalLightUniform, directionalLight
      );

      gl.uniform1f(
        square.materialAmbientUniform, square.material.ambient
      );
      gl.uniform1f(
        square.materialDiffuseUniform, square.material.diffuse
      );

      var projectionMatrix = mat4.create();
      mat4.perspective(
        projectionMatrix, 0.75, surface.width/surface.height, 0.1, 100
      );
      program.projectionMatrixUniform = 
        gl.getUniformLocation(program, 'projectionMatrix');
      gl.uniformMatrix4fv(
        program.projectionMatrixUniform, gl.FALSE, projectionMatrix
      );

      var viewMatrix = mat4.create();
      program.viewMatrixUniform =
        gl.getUniformLocation(program, 'viewMatrix');
      gl.uniformMatrix4fv(
        program.viewMatrixUniform, gl.FALSE, viewMatrix
      );

      var modelMatrix = mat4.create();
      mat4.identity(modelMatrix);
      mat4.translate(
        modelMatrix, modelMatrix, [0, 0, -4]
      );
      program.modelMatrixUniform =
        gl.getUniformLocation(program, 'modelMatrix');
      gl.uniformMatrix4fv(
        program.modelMatrixUniform, gl.FALSE, modelMatrix
      );

      program.normalMatrixUniform =
        gl.getUniformLocation(program, 'normalMatrix');

      square.modelMatrix = modelMatrix;

      gl.bindBuffer(gl.ARRAY_BUFFER, null);
      gl.useProgram(null);

      var scene = {
        program: program,
        object: square,
        start: Date.now(),
        projectionMatrix: projectionMatrix,
        viewMatrix: viewMatrix
      };

      requestAnimationFrame(function(timestamp) {
        render(gl, scene, timestamp, 0);
      });
    }

    </script>
  </head>
  <body onLoad="init()">
    <canvas id="rendering-surface" height="500" width="500"></canvas>
    <form>
      <!-- Some new controls to feed values into the program uniforms -->
      <div>
        <label for="light-x">Light X
          <input type="range" name="light-x" id="light-x" min="0" max="100"/>
        </label>
        <label for="light-y">Light Y
          <input type="range" name="light-y" id="light-y" min="0" max="100"/>
        </label>
        <label for="light-z">Light Z
          <input type="range" name="light-z" id="light-z" min="0" max="100"/>
        </label>
      </div>
      <div>
        <label for="rotate-x">Rotate X
          <input type="range" name="rotate-x" id="rotate-x" min="0" max="10" value="5"/>
        </label>
        <label for="rotate-y">Rotate Y
          <input type="range" name="rotate-y" id="rotate-y" min="0" max="10" value="5"/>
        </label>
        <label for="rotate-z">Rotate Z
          <input type="range" name="rotate-z" id="rotate-z" min="0" max="10" value="5"/>
        </label>
      </div>
    </form>
  </body>
</html>

What’s New

The explicit colour values are gone from the vertices, as is their presence in the vertex shader and the binding code in the init function.

Instead, the vertices have floats that define the vertex normal vector. The normal vector is perpendicular to the vectors between the vertex position and the positions of the other two vertices of the triangle that it is part of. This is a handy thing to have when it comes to calculating how much a light source affects the value of a pixel: a light behind a face won’t affect it at all, whereas one in front it will to an extent dependent upon the angle that it strikes it. Notice that they’re all zeroes. This is because they’re just placeholders. Their values are actually calculated in a subsequent partially-unrolled loop. Note that there are two possible directions that the normal vector can point, so which of the two a given vector represents is dependent opon whether you calculated your normal by taking the cross product of the right-hand vector with the left-hand one, or vice versa. With the counter-clockwise winding scheme that we’re using, cross the right one with the left. To figure out which is right and which is left, pretend that you’re the normal vector you want, standing on the vertex. For example, in this scenario:

cross-product

the normal vector (in blue) is calculated by taking the cross product of v1 minus v0 and v2 minus v0.

Since the normal for a vertex is going to be passed over to the shader as an attribute, the mapping into the buffer is performed with the usual getAttribLocation/enableVertexAttribArray/vertexAttribPointer triplet.

There are additional uniforms: normalMatrix, a mat3, as well as vec3s defining the colour of the ambient light and the direction of the directional light, and floats defining how the object material interacts with those lights. An int allows the vertex colour to be selectively disabled.

The render() function now calculates the normal matrix—it needs to be recalculated after each change to either the model or view matrices that it’s derived from, and we’re adjusting the model matrix here—and uploads it to the GPU with a call to uniformMatrix3fv.

The vertex shader is significantly different, and now includes the function ad to calculate ambient and directional contributions to the intensity of a vertex. This is used from main to determine the final colour for a vertex.

Finally, there are some changes to the HTML to include range controls for moving the light and specifying the angle of rotation.

Wait, There’s More…

People who’ve been around the WebGL/OpenGL block before (you guys are still reading this?) might be surprised by my ad function: it’s called ads in almost every other demonstration, where the ‘s’ stands for ‘specular’. Well, I avoided accounting for the specular term because I personally think that it adds unhelpful complexity at the start. Whether or not your directional lighting model works can be quite easily determined just by having ambient and diffuse properties.

But now that they’re done, it’s not inappropriate to look at the specular component, and give our vertex shader function its full ads name.

The specular component captures a material’s ‘shininess’ or glossiness. You might have noticed with the diffuse light that, although you could easily see which faces of the cube were facing the light versus those which weren’t, the surface material was a bit matte. Even looking at it head-on, there was no real reflection of a light source. While this is fine for some surfaces (e.g. a brick wall), it doesn’t work for others (e.g. a polished wooden tabletop). So we add a specular term to the light source, and a shininess term to the object, that allow for the calculation of highlights on the surface, providing two new uniform float variables—materialSpecular and shininess—and an ads function that looks like this:

    vec3 ads( vec4 position, vec3 norm )
    {
      vec3 s = normalize(vec3(vec4(directionalLight,1.0) - position));
      vec3 v = normalize(vec3(-position));
      vec3 r = reflect(-s, norm);
      return ambientLightColour +
        materialDiffuse * max(dot(s,norm), 0.0) +
        materialSpecular * pow(max(dot(r,v), 0.0), shininess);
    }

If you use this with the current model, you might be a little disappointed: no specular highlights appear! This is because I’m calculating the effects of lighting at vertices: they’re being interpolated across the face, and any focussed highlights are being smoothed out as a result. There are two options here: 1) move the lighting calculations into the fragment shader, at which point you’ll get perfect, per-pixel valuation of the lighing model; or 2) subdivide the face a few more times so that some vertices fall on/near highlights.

This post has rambled on for quite long enough, though, so I’ll leave these approaches for another time.

What’s Next

Looking at this code, you’d be forgiven for thinking that complex models are going to be a pain to specify. All those manually-entered floats into an array, and all that calculation of normal vectors. After all, a spinning cube with some lighting took everything above to get going!

Fear not. This is not how complex models are built. Instead, specialised programs are used to interactively build models and export them to files in one of wide array of formats. These models can then be imported—with varying degrees of difficulty—to appear within our programs.

Since that’s more exciting, I’m going to look at generating a 3D mesh using the free Blender 3D modelling tool in the next post. I’ll load the exported .obj file with an AJAX call, and use it to build the buffer that WebGL will render. The model will interact with our ambient and directional lights, but there will be one important difference: we’ll stop playing with the 36 vertices that define our square and jump to a few tens of thousands.

If anyone’s wondering about where textures have gone, I’ll get to those after I’ve done some fun stuff.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s