WebGL from Scratch: Loading a Mesh

In this post, I’m going to create a mesh using the Blender 3D modelling tool and load it into my scene. The cube that I’ve been playing with for the past few posts is going to disappear—as is all of the code that defines its vertices and calculates vertex normal vectors—and be replaced by a simple OBJ loader. What I’ll have by the end of this post is going to look like this:

monkey

However, one of the many things that WebGL doesn’t know about is loading meshes. It’s your job to load data from whichever resource that you have and turn it into the vertex data that WebGL needs. The difficulty of that task is determined by whoever designed the file format in question, so I’m going to start with an easy one: Wavefront OBJ.

Creating an OBJ File

The easiest way to create an OBJ file is to export one from Blender. It’s quite an intimidating tool at first (and second) glance, but the simplest way to create a reasonably complex mesh is with the following steps:

  1. Start Blender
  2. A default cube shape will be highlighted. Press ‘x’ on your keyboard, and Enter to confirm deletion.
  3. From the ‘Add’ menu (toward the bottom-left of the main window), choose ‘Mesh’ and then ‘Monkey’.
  4. Just to the right of the ‘Add’ menu, switch from ‘Object Mode’ to ‘Edit Mode’, and then click ‘Subdivide’ on the left pane twice (this isn’t really required: it just generates more polygons for a better effect).
  5. Switch back to ‘Object Mode’, and choose ‘Smooth’ from the ‘Shading’ part of the left pane (again, this isn’t required, but it results in a model that looks nicer with our lighting model).
  6. From the ‘File’ menu (top-left), select ‘Export’ and ‘Wavefront (.obj)’
  7. From the checkbox options on the lower-left side, select only ‘Write Normals’, ‘Triangulate Faces’ and ‘Objects as OBJ Objects’. Un-check anything else.
  8. Choose a directory and filename (I use ‘monkey.obj’ in the code below), and click ‘Export OBJ’
  9. Quit Blender.

Alternatively, you can just download the model from here, and rename the file as monkey.obj.

Parsing the Model

An OBJ file is plain text, and can be inspected in your text editor of choice. It’s line-based: a single line will define a single property (e.g. a position, texture coordinate, etc.)

The good news is that you don’t have to write a bulletproof, cover-all-cases OBJ parser to get at the model data. All you have to do is note that vertex coordinates are in lines starting ‘v’, vertex normals are in lines starting ‘vn’, and that faces are defined in lines starting ‘f’. All the ‘v’s appear in a single block before the first ‘vn’, and all of those appear in a single block before the first ‘f’. Every other line can be ignored for the purposes of this demonstration.

When I walked through the above instructions for exporting an OBJ file, the first position was defined as:

v 0.437500 0.164062 0.765625

This is just an XYZ position. Many lines starting ‘v’ follow it, until the first ‘vn’, which for my instance is:

vn 0.666000 -0.204900 0.717200

This is a plain vector of 3 floats, and is followed by a block of additional vertex normals.

The first face line is:

f 1//1 3//1 45//1

Which is the first line to warrant some explanation. It states that the face has three vertices, with each vertex component defined by indices into the corresponding data array. There are three indices per vertex, the first indexing the positions array, the second (not used here, so blank) indexing an array of texture coordinates, and the third indexing the normals array.

Note that indices start at 1, not 0. This is a common gotcha.

So, imagine that you’ve loaded the file as a single string, possibly via AJAX. This function will return an object with a ready-to-glBufferData Float32Array with interleaved position/normal data.

function loadMeshData(string) {
  var lines = string.split("\n");
  var positions = [];
  var normals = [];
  var vertices = [];

  for ( var i = 0 ; i < lines.length ; i++ ) {
    var parts = lines[i].trimRight().split(' ');
    if ( parts.length > 0 ) {
      switch(parts[0]) {
        case 'v':  positions.push(
          vec3.fromValues(
            parseFloat(parts[1]),
            parseFloat(parts[2]),
            parseFloat(parts[3])
          ));
          break;
        case 'vn':
          normals.push(
            vec3.fromValues(
              parseFloat(parts[1]),
              parseFloat(parts[2]),
              parseFloat(parts[3])
          ));
          break;
        case 'f': {
          var f1 = parts[1].split('/');
          var f2 = parts[2].split('/');
          var f3 = parts[3].split('/');
          Array.prototype.push.apply(
            vertices, positions[parseInt(f1[0]) - 1]
          );
          Array.prototype.push.apply(
            vertices, normals[parseInt(f1[2]) - 1]
          );
          Array.prototype.push.apply(
            vertices, positions[parseInt(f2[0]) - 1]
          );
          Array.prototype.push.apply(
            vertices, normals[parseInt(f2[2]) - 1]
          );
          Array.prototype.push.apply(
            vertices, positions[parseInt(f3[0]) - 1]
          );
          Array.prototype.push.apply(
            vertices, normals[parseInt(f3[2]) - 1]
          );
          break;
        }
      }
    }
  }
  var vertexCount = vertices.length / 6;
  console.log("Loaded mesh with " + vertexCount + " vertices");
  return {
    primitiveType: 'TRIANGLES',
    vertices: new Float32Array(vertices),
    vertexCount: vertexCount
  };
}

This takes advantage of the ordering within the file, knowing that an ‘f’ line won’t be encountered for a given mesh until after all the ‘v’s and ‘vn’s have been seen. Those ‘v’s and ‘vn’s are held only long enough to create the interleaved array, and are eligible for garbage collection when this function returns: we don’t need to hold onto them.

If the incomplete nature of this scanner offends you, feel free to read up on the OBJ spec and write a full parser, or find one online that you can plug in.

The New Code

I’m going to grab the OBJ file using an AJAX call, and for that I’m going to pull in jQuery, not because I couldn’t do without it, but it has a nice syntax for making the call and processing it.

<!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 vec3 ambientLightColour, directionalLight, materialSpecular;
    uniform float materialAmbient, materialDiffuse, shininess;

    /* A function to determine the colour of a vertex, accounting
       for ambient and directional light */
    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);
    }

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

    varying vec3 col;

    void main() {
      gl_FragColor = vec4(col, 1.0);
    }
    </script>
    <script type="text/javascript" src="jquery-2.1.1.js"></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 delta = (0.125 * Math.PI) / (timestamp - previousTimestamp);

      var light = vec3.fromValues(
        ($('#light-x').val() - 50.0) / 10.0,
        ($('#light-y').val() - 50.0) / 10.0,
        ($('#light-z').val() - 50.0) / 10.0);

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

      var rotateX = ($('#rotate-x').val() - 5) / 10;
      var rotateY = ($('#rotate-y').val() - 5) / 10;
      var rotateZ = ($('#rotate-z').val() - 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(gl.TRIANGLES, 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(object) {

      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}]);

      gl.useProgram(program);

      program.positionAttribute = gl.getAttribLocation(program, 'pos');
      gl.enableVertexAttribArray(program.positionAttribute);
      program.normalAttribute = gl.getAttribLocation(program, 'normal');
      gl.enableVertexAttribArray(program.normalAttribute);

      var vertexBuffer = gl.createBuffer();

      gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, object.vertices, gl.STATIC_DRAW);
      gl.vertexAttribPointer(
        program.positionAttribute, 3, gl.FLOAT, gl.FALSE, 
        Float32Array.BYTES_PER_ELEMENT * 6, 0);
      gl.vertexAttribPointer(
        program.normalAttribute, 3, gl.FLOAT, gl.FALSE,
        Float32Array.BYTES_PER_ELEMENT * 6,
        Float32Array.BYTES_PER_ELEMENT * 3);

      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);

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

      program.ambientLightColourUniform = gl.getUniformLocation(
        program, 'ambientLightColour');
      program.directionalLightUniform = gl.getUniformLocation(
        program, 'directionalLight');
      program.materialSpecularUniform = gl.getUniformLocation(
        program, 'materialSpecular');
      object.materialAmbientUniform = gl.getUniformLocation(
        program, 'materialAmbient');
      object.materialDiffuseUniform = gl.getUniformLocation(
        program, 'materialDiffuse');
      object.shininessUniform = gl.getUniformLocation(
        program, 'shininess');

      var ambientLightColour = vec3.fromValues(0.2, 0.2, 0.2);
      gl.uniform3fv(
        program.ambientLightColourUniform, ambientLightColour);
      var directionalLight = vec3.fromValues(-0.5,0.5,0.5);
      gl.uniform3fv(
        program.directionalLightUniform, directionalLight);
      var materialSpecular = vec3.fromValues(0.5, 0.5, 0.5);
      gl.uniform3fv(
        program.materialSpecularUniform, materialSpecular);
      gl.uniform1f(
        object.shininessUniform, object.material.shininess);

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

      object.modelMatrix = modelMatrix;
      object.vertexBuffer = vertexBuffer;

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

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

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

    function loadMeshData(string) {
      var lines = string.split("\n");
      var positions = [];
      var normals = [];
      var vertices = [];

      for ( var i = 0 ; i < lines.length ; i++ ) {
        var parts = lines[i].trimRight().split(' ');
        if ( parts.length > 0 ) {
          switch(parts[0]) {
            case 'v':  positions.push(
              vec3.fromValues(
                parseFloat(parts[1]),
                parseFloat(parts[2]),
                parseFloat(parts[3])
              ));
              break;
            case 'vn':
              normals.push(
                vec3.fromValues(
                  parseFloat(parts[1]),
                  parseFloat(parts[2]),
                  parseFloat(parts[3])));
              break;
            case 'f': {
              var f1 = parts[1].split('/');
              var f2 = parts[2].split('/');
              var f3 = parts[3].split('/');
              Array.prototype.push.apply(
                vertices, positions[parseInt(f1[0]) - 1]);
              Array.prototype.push.apply(
                vertices, normals[parseInt(f1[2]) - 1]);
              Array.prototype.push.apply(
                vertices, positions[parseInt(f2[0]) - 1]);
              Array.prototype.push.apply(
                vertices, normals[parseInt(f2[2]) - 1]);
              Array.prototype.push.apply(
                vertices, positions[parseInt(f3[0]) - 1]);
              Array.prototype.push.apply(
                vertices, normals[parseInt(f3[2]) - 1]);
              break;
            }
          }
        }
      }
      console.log(
        "Loaded mesh with " + (vertices.length / 6) + " vertices");
      return {
        primitiveType: 'TRIANGLES',
        vertices: new Float32Array(vertices),
        vertexCount: vertices.length / 6,
        material: {ambient: 0.2, diffuse: 0.5, shininess: 10.0}
      };
    }

    function loadMesh(filename) {
      $.ajax({
        url: filename,
        dataType: 'text'
      }).done(function(data) {
        init(loadMeshData(data));
      }).fail(function() {
        alert('Faild to retrieve [' + filename + "]");
      });
    }

    $(document).ready(function() {
      loadMesh('monkey.obj')
    });

    </script>
  </head>
  <body>
    <canvas id="rendering-surface" height="500" width="500"></canvas>
    <form>
      <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

Aside from the slightly more complex initialisation process, and the move from an HTML onLoad to jQuery’s $(document).ready, what you might notice is that there’s much less code than last time. Since the mesh data is being imported, nothing is being generated or calculated in-code. Note how little impact this has had on the rest of the code: in particular, the shaders are unchanged (but I am, however, including the specular lighting component that I mentioned last post, because it makes the model look much better). The init function is no longer responsible for generating the object mesh, so it’s now explicitly passed into it.

The Really Good News

Now that there’s code to load the model from an OBJ file, you can load anything that you can convert into that format from Blender. You can download models from sites on the web, as long as Blender can import it, and put it into a format that can be loaded into the browser. Hand-crafted cubes and pyramids are a thing of the past.

A Note on Inconsistent Browser Security

Different browsers have different ideas about when it’s acceptable to load resources from the filesystem. Firefox and Safari, for example, have no problems pulling the OBJ resource directly from the disk via AJAX if the HTML file itself was loaded from the filesystem. This means that your OBJ file can be in the same directory as your HTML file, and everything will work fine.

Chrome has other ideas, and you’ll get the ‘Failed to retrieve [monkey.obj]’ message in the .fail handler to the AJAX call. The only way to get around this is to actually serve the content from a web server. If you have Python available, a call to python -m SimpleHTTPServer in the hosting directory will give you something useable.

Advertisements

2 thoughts on “WebGL from Scratch: Loading a Mesh

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