rigging and animation

> bits.coop / articles / rigging-and-animation
published august 29 2018

In this tutorial, we'll create a webgl demo of a dog with a cute animation:

Click and drag to look around.

We'll be creating geometries by hand instead of using a 3d modeling program. This way we can explore the essentials of setting up a tree of matrix calculations to model joints without worrying about the complexities of 3d file formats.

Let's begin by creating a box centered at the origin:

var regl = require('regl')({
  extensions: [ 'oes_standard_derivatives' ] })
var camera = require('regl-camera')(regl, { phi: 0.4, theta: 0.7 })

var box = {
  positions: [[+0.5,-0.5,+0.5],[+0.5,-0.5,-0.5],[-0.5,-0.5,-0.5],
    [-0.5,-0.5,+0.5],[+0.5,+0.5,+0.5],[+0.5,+0.5,-0.5],
    [-0.5,+0.5,-0.5],[-0.5,+0.5,+0.5]],
  cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
    [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]]
}

var draw = {
  solid: solid(regl)
}
regl.frame(function (context) {
  regl.clear({ color: [0.3,0.22,0.22,1], depth: true })
  camera(function () {
    draw.solid(box)
  })
})

function solid (regl) {
  return regl({
    frag: `
      precision highp float;
      #extension GL_OES_standard_derivatives: enable
      varying vec3 vpos;
      void main () {
        vec3 N = normalize(cross(dFdx(vpos),dFdy(vpos)));
        gl_FragColor = vec4(N*0.5+0.5,1);
      }
    `,
    vert: `
      precision highp float;
      uniform mat4 projection, view;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * vec4(position,1);
      }
    `,
    attributes: {
      position: regl.prop('positions')
    },
    elements: regl.prop('cells')
  })
}

To run this code, install node.js and npm, then from a new project directory do: npm install -g budo && npm install regl regl-camera gl-mat4 gl-vec3. Save this source code to main.js and do budo main.js. Finally visit http://localhost:9966 in your browser.

In this example, we have a box with 8 vertices and 12 triangles (2 for each of the 6 sides).

This example uses the standard derivatives extension to give our model a flat shaded coloring scheme calculated from the normal of each face.

Now let's create two more boxes to go with the first box: one above (+y) and one below (-y):

var regl = require('regl')({
  extensions: [ 'oes_standard_derivatives' ] })
var camera = require('regl-camera')(regl, { phi: 0.4, theta: 0.7 })

var boxes = [
  {
    positions: [[+0.5,+1.0,+0.5],[+0.5,+1.0,-0.5],[-0.5,+1.0,-0.5],
      [-0.5,+1.0,+0.5],[+0.5,+2.0,+0.5],[+0.5,+2.0,-0.5],
      [-0.5,+2.0,-0.5],[-0.5,+2.0,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]]
  },
  {
    positions: [[+0.5,-0.5,+0.5],[+0.5,-0.5,-0.5],[-0.5,-0.5,-0.5],
      [-0.5,-0.5,+0.5],[+0.5,+0.5,+0.5],[+0.5,+0.5,-0.5],
      [-0.5,+0.5,-0.5],[-0.5,+0.5,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]]
  },
  {
    positions: [[+0.5,-2.0,+0.5],[+0.5,-2.0,-0.5],[-0.5,-2.0,-0.5],
      [-0.5,-2.0,+0.5],[+0.5,-1.0,+0.5],[+0.5,-1.0,-0.5],
      [-0.5,-1.0,-0.5],[-0.5,-1.0,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]]
  }
]

var draw = {
  solid: solid(regl)
}
regl.frame(function (context) {
  regl.clear({ color: [0.3,0.22,0.22,1], depth: true })
  camera(function () {
    draw.solid(boxes)
  })
})

function solid (regl) {
  return regl({
    frag: `
      precision highp float;
      #extension GL_OES_standard_derivatives: enable
      varying vec3 vpos;
      void main () {
        vec3 N = normalize(cross(dFdx(vpos),dFdy(vpos)));
        gl_FragColor = vec4(N*0.5+0.5,1);
      }
    `,
    vert: `
      precision highp float;
      uniform mat4 projection, view;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * vec4(position,1);
      }
    `,
    attributes: {
      position: regl.prop('positions')
    },
    elements: regl.prop('cells')
  })
}

The box variable has become an array of boxes. +1.5 has been added to the y coordinate of every vertex in the first box and -1.5 has been added to every y coordinate of the last box. The middle box is the same as before.

Now let's create a model matrix for each box. In an update(time) function called each frame, we'll set each model matrix to the identity, then we'll rotate the bottom box about the x axis.

var regl = require('regl')({
  extensions: [ 'oes_standard_derivatives' ] })
var camera = require('regl-camera')(regl, { phi: 0.4, theta: 0.7 })
var mat4 = require('gl-mat4')

var boxes = [
  {
    positions: [[+0.5,+1.0,+0.5],[+0.5,+1.0,-0.5],[-0.5,+1.0,-0.5],
      [-0.5,+1.0,+0.5],[+0.5,+2.0,+0.5],[+0.5,+2.0,-0.5],
      [-0.5,+2.0,-0.5],[-0.5,+2.0,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16)
  },
  {
    positions: [[+0.5,-0.5,+0.5],[+0.5,-0.5,-0.5],[-0.5,-0.5,-0.5],
      [-0.5,-0.5,+0.5],[+0.5,+0.5,+0.5],[+0.5,+0.5,-0.5],
      [-0.5,+0.5,-0.5],[-0.5,+0.5,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16)
  },
  {
    positions: [[+0.5,-2.0,+0.5],[+0.5,-2.0,-0.5],[-0.5,-2.0,-0.5],
      [-0.5,-2.0,+0.5],[+0.5,-1.0,+0.5],[+0.5,-1.0,-0.5],
      [-0.5,-1.0,-0.5],[-0.5,-1.0,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16)
  }
]

function update (t) {
  mat4.identity(boxes[0].model)
  mat4.identity(boxes[1].model)
  mat4.identity(boxes[2].model)
  mat4.rotateX(boxes[2].model, boxes[2].model, Math.sin(t*5)*0.8)
}

var draw = {
  solid: solid(regl)
}
regl.frame(function (context) {
  regl.clear({ color: [0.3,0.22,0.22,1], depth: true })
  camera(function () {
    draw.solid(boxes)
  })
  update(context.time)
})

function solid (regl) {
  return regl({
    frag: `
      precision highp float;
      #extension GL_OES_standard_derivatives: enable
      varying vec3 vpos;
      void main () {
        vec3 N = normalize(cross(dFdx(vpos),dFdy(vpos)));
        gl_FragColor = vec4(N*0.5+0.5,1);
      }
    `,
    vert: `
      precision highp float;
      uniform mat4 projection, view, model;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * model * vec4(position,1);
      }
    `,
    uniforms: {
      model: regl.prop('model')
    },
    attributes: {
      position: regl.prop('positions')
    },
    elements: regl.prop('cells')
  })
}

This looks cool, with the bottom box swinging like a pendulum about the origin, but to demonstrate rigging, we should move that pivot point closer to each box's center of mass. For the top box, that's [+0.0,+1.5,+0.0] and for the bottom box it's [+0.0,-1.5,+0.0]. The center box's pivot will be [+0.0,+0.0,+0.0]. We'll store the pivot alongside the positions, cells, and model for each box.

var regl = require('regl')({
  extensions: [ 'oes_standard_derivatives' ] })
var camera = require('regl-camera')(regl, { phi: 0.4, theta: 0.7 })
var mat4 = require('gl-mat4')
var vec3 = require('gl-vec3')

var boxes = [
  {
    positions: [[+0.5,+1.0,+0.5],[+0.5,+1.0,-0.5],[-0.5,+1.0,-0.5],
      [-0.5,+1.0,+0.5],[+0.5,+2.0,+0.5],[+0.5,+2.0,-0.5],
      [-0.5,+2.0,-0.5],[-0.5,+2.0,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16),
    pivot: [+0.0,+1.5,+0.0]
  },
  {
    positions: [[+0.5,-0.5,+0.5],[+0.5,-0.5,-0.5],[-0.5,-0.5,-0.5],
      [-0.5,-0.5,+0.5],[+0.5,+0.5,+0.5],[+0.5,+0.5,-0.5],
      [-0.5,+0.5,-0.5],[-0.5,+0.5,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16),
    pivot: [+0.0,+0.0,+0.0]
  },
  {
    positions: [[+0.5,-2.0,+0.5],[+0.5,-2.0,-0.5],[-0.5,-2.0,-0.5],
      [-0.5,-2.0,+0.5],[+0.5,-1.0,+0.5],[+0.5,-1.0,-0.5],
      [-0.5,-1.0,-0.5],[-0.5,-1.0,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16),
    pivot: [+0.0,-1.5,+0.0]
  }
]

var tmpv = new Float32Array(3)

function update (t) {
  mat4.identity(boxes[0].model)
  mat4.identity(boxes[1].model)
  mat4.identity(boxes[2].model)
  mat4.translate(boxes[0].model,boxes[0].model,boxes[0].pivot)
  mat4.translate(boxes[1].model,boxes[1].model,boxes[1].pivot)
  mat4.translate(boxes[2].model,boxes[2].model,boxes[2].pivot)

  mat4.rotateX(boxes[2].model, boxes[2].model, Math.sin(t*5)*0.8)

  mat4.translate(boxes[0].model,boxes[0].model,vec3.negate(tmpv,boxes[0].pivot))
  mat4.translate(boxes[1].model,boxes[1].model,vec3.negate(tmpv,boxes[1].pivot))
  mat4.translate(boxes[2].model,boxes[2].model,vec3.negate(tmpv,boxes[2].pivot))
}

var draw = {
  solid: solid(regl)
}
regl.frame(function (context) {
  regl.clear({ color: [0.3,0.22,0.22,1], depth: true })
  camera(function () {
    draw.solid(boxes)
  })
  update(context.time)
})

function solid (regl) {
  return regl({
    frag: `
      precision highp float;
      #extension GL_OES_standard_derivatives: enable
      varying vec3 vpos;
      void main () {
        vec3 N = normalize(cross(dFdx(vpos),dFdy(vpos)));
        gl_FragColor = vec4(N*0.5+0.5,1);
      }
    `,
    vert: `
      precision highp float;
      uniform mat4 projection, view, model;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * model * vec4(position,1);
      }
    `,
    uniforms: {
      model: regl.prop('model')
    },
    attributes: {
      position: regl.prop('positions')
    },
    elements: regl.prop('cells')
  })
}

To apply the pivots, in the update function we first translate the model matrix by the pivot coordinate, then we perform any rotations, and finally we translate the model matrix back by the negated pivot coordinates.

We reuse a tmpv variable declared outside of the update() function for the negation to avoid allocating memory inside the update() loop which runs every frame. Avoiding allocations in your code that runs every frame will keep the garbage collector from activating and introducing jitter into your demos.

Now that we can apply rotations to each box's pivot point, let's refactor our data structures so that we can establish a tree of joints. This way, when we rotate a parent joint, any child joints attached to that parent joint will also rotate about the parent joint's pivot. And each child joint can further have children that rotate according to this tree of joints.

First let's convert our boxes array into an object so we can give each box a name. Then we can add a parent key to each box to describe the tree of joints. We'll get around to using this parent key later, but for now let's just get the existing code working with the new data structure.

We'll call the boxes top, middle, and bottom. The bottom box will be the root of our joint tree with the middle box a child of the bottom box and the top box a child of the middle box.

var regl = require('regl')({
  extensions: [ 'oes_standard_derivatives' ] })
var camera = require('regl-camera')(regl, { phi: 0.4, theta: 0.7 })
var mat4 = require('gl-mat4')
var vec3 = require('gl-vec3')

var boxes = {
  top: {
    positions: [[+0.5,+1.0,+0.5],[+0.5,+1.0,-0.5],[-0.5,+1.0,-0.5],
      [-0.5,+1.0,+0.5],[+0.5,+2.0,+0.5],[+0.5,+2.0,-0.5],
      [-0.5,+2.0,-0.5],[-0.5,+2.0,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16),
    pivot: [+0.0,+1.5,+0.0],
    parent: 'middle'
  },
  middle: {
    positions: [[+0.5,-0.5,+0.5],[+0.5,-0.5,-0.5],[-0.5,-0.5,-0.5],
      [-0.5,-0.5,+0.5],[+0.5,+0.5,+0.5],[+0.5,+0.5,-0.5],
      [-0.5,+0.5,-0.5],[-0.5,+0.5,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16),
    pivot: [+0.0,+0.0,+0.0],
    parent: 'bottom'
  },
  bottom: {
    positions: [[+0.5,-2.0,+0.5],[+0.5,-2.0,-0.5],[-0.5,-2.0,-0.5],
      [-0.5,-2.0,+0.5],[+0.5,-1.0,+0.5],[+0.5,-1.0,-0.5],
      [-0.5,-1.0,-0.5],[-0.5,-1.0,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16),
    pivot: [+0.0,-1.5,+0.0],
    parent: null
  }
}
var boxProps = Object.values(boxes)

var tmpv = new Float32Array(3)

function update (t) {
  mat4.identity(boxes.top.model)
  mat4.identity(boxes.middle.model)
  mat4.identity(boxes.bottom.model)
  mat4.translate(boxes.top.model,boxes.top.model,boxes.top.pivot)
  mat4.translate(boxes.middle.model,boxes.middle.model,boxes.middle.pivot)
  mat4.translate(boxes.bottom.model,boxes.bottom.model,boxes.bottom.pivot)

  mat4.rotateX(boxes.bottom.model, boxes.bottom.model, Math.sin(t*5)*0.8)

  mat4.translate(boxes.top.model,boxes.top.model,
    vec3.negate(tmpv,boxes.top.pivot))
  mat4.translate(boxes.middle.model,boxes.middle.model,
    vec3.negate(tmpv,boxes.middle.pivot))
  mat4.translate(boxes.bottom.model,boxes.bottom.model,
    vec3.negate(tmpv,boxes.bottom.pivot))
}

var draw = {
  solid: solid(regl)
}
regl.frame(function (context) {
  regl.clear({ color: [0.3,0.22,0.22,1], depth: true })
  camera(function () {
    draw.solid(boxProps)
  })
  update(context.time)
})

function solid (regl) {
  return regl({
    frag: `
      precision highp float;
      #extension GL_OES_standard_derivatives: enable
      varying vec3 vpos;
      void main () {
        vec3 N = normalize(cross(dFdx(vpos),dFdy(vpos)));
        gl_FragColor = vec4(N*0.5+0.5,1);
      }
    `,
    vert: `
      precision highp float;
      uniform mat4 projection, view, model;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * model * vec4(position,1);
      }
    `,
    uniforms: {
      model: regl.prop('model')
    },
    attributes: {
      position: regl.prop('positions')
    },
    elements: regl.prop('cells')
  })
}

Good so far with the same behavior as before.

We'll next add an extra matrix for every box called pose. Instead of modifying the model matrix, we'll modify the pose matrix in our update() function and create a new function updateModels() to calculate the model matrix from each pose matrix in a box's joint tree.

var regl = require('regl')({
  extensions: [ 'oes_standard_derivatives' ] })
var camera = require('regl-camera')(regl, { phi: 0.4, theta: 0.7 })
var mat4 = require('gl-mat4')
var vec3 = require('gl-vec3')

var boxes = {
  top: {
    positions: [[+0.5,+1.0,+0.5],[+0.5,+1.0,-0.5],[-0.5,+1.0,-0.5],
      [-0.5,+1.0,+0.5],[+0.5,+2.0,+0.5],[+0.5,+2.0,-0.5],
      [-0.5,+2.0,-0.5],[-0.5,+2.0,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16),
    pose: new Float32Array(16),
    pivot: [+0.0,+1.5,+0.0],
    parent: 'middle'
  },
  middle: {
    positions: [[+0.5,-0.5,+0.5],[+0.5,-0.5,-0.5],[-0.5,-0.5,-0.5],
      [-0.5,-0.5,+0.5],[+0.5,+0.5,+0.5],[+0.5,+0.5,-0.5],
      [-0.5,+0.5,-0.5],[-0.5,+0.5,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16),
    pose: new Float32Array(16),
    pivot: [+0.0,+0.0,+0.0],
    parent: 'bottom'
  },
  bottom: {
    positions: [[+0.5,-2.0,+0.5],[+0.5,-2.0,-0.5],[-0.5,-2.0,-0.5],
      [-0.5,-2.0,+0.5],[+0.5,-1.0,+0.5],[+0.5,-1.0,-0.5],
      [-0.5,-1.0,-0.5],[-0.5,-1.0,+0.5]],
    cells: [[2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]],
    model: new Float32Array(16),
    pose: new Float32Array(16),
    pivot: [+0.0,-1.5,+0.0],
    parent: null
  }
}
var boxProps = Object.values(boxes)

var tmpv = new Float32Array(3)
var tmpm = new Float32Array(16)

function update (t) {
  boxProps.forEach(function (box) {
    mat4.identity(box.pose)
  })
  mat4.rotateX(boxes.bottom.pose, boxes.bottom.pose, Math.sin(t*1)*0.8)
  mat4.rotateY(boxes.middle.pose, boxes.middle.pose, Math.sin(t*2)*1.5)
  mat4.rotateZ(boxes.top.pose, boxes.top.pose, Math.sin(t*8)*0.5)
  updateModels()
}

function updateModels () {
  boxProps.forEach(function (box) {
    mat4.identity(box.model)
    var b = box
    while (b) {
      mat4.identity(tmpm)
      mat4.translate(tmpm, tmpm, b.pivot)
      mat4.multiply(tmpm, tmpm, b.pose)
      mat4.translate(tmpm, tmpm, vec3.negate(tmpv,b.pivot))
      mat4.multiply(box.model, tmpm, box.model)
      b = boxes[b.parent]
    }
  })
}

var draw = {
  solid: solid(regl)
}
regl.frame(function (context) {
  regl.clear({ color: [0.3,0.22,0.22,1], depth: true })
  camera(function () {
    draw.solid(boxProps)
  })
  update(context.time)
})

function solid (regl) {
  return regl({
    frag: `
      precision highp float;
      #extension GL_OES_standard_derivatives: enable
      varying vec3 vpos;
      void main () {
        vec3 N = normalize(cross(dFdx(vpos),dFdy(vpos)));
        gl_FragColor = vec4(N*0.5+0.5,1);
      }
    `,
    vert: `
      precision highp float;
      uniform mat4 projection, view, model;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * model * vec4(position,1);
      }
    `,
    uniforms: {
      model: regl.prop('model')
    },
    attributes: {
      position: regl.prop('positions')
    },
    elements: regl.prop('cells')
  })
}

In this example all three boxes have associated rotations on separate axes, and as a parent box moves, so do its children. The joint tree is working how we need it to work.

Let's take a closer look at the code that updates the models:

function updateModels () {
  boxProps.forEach(function (box) {
    mat4.identity(box.model)
    var b = box
    while (b) {
      mat4.identity(tmpm)
      mat4.translate(tmpm, tmpm, b.pivot)
      mat4.multiply(tmpm, tmpm, b.pose)
      mat4.translate(tmpm, tmpm, vec3.negate(tmpv,b.pivot))
      mat4.multiply(box.model, tmpm, box.model)
      b = boxes[b.parent]
    }
  })
}

Before, we translated each box by its pivot point, performed our rotation, then translated each box by the negated pivot. In the updated code, we perform the rotations on the pose matrix. A translation by the pivot is first performed on a temporary matrix tmpm, then the pose matrix rotations are multiplied into tmpm, and finally tmpm is translated by the negated pivot. These steps are repeated for every pose matrix along the box's joint tree, from the leaf joint to the root joint. The transformations for the whole joint tree are collected into the model matrix for each box.

With patience or the assistance of a 3d modeling program, you can build up a more complicated collection of shape geometries to animate.

Here is a the dog model from the title demo that I created by hand:

module.exports = {
  chest: {
    pivot: [0,0,0],
    positions: [
      [+0.05,-0.09,+0.15],[+0.10,-0.07,-0.25],[-0.10,-0.07,-0.25],[-0.05,-0.09,+0.15],
      [+0.11,+0.18,+0.15],[+0.11,+0.20,-0.22],[-0.11,+0.20,-0.22],[-0.11,+0.18,+0.15]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,7],[7,6,2],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  rear: {
    parent: 'chest',
    pivot: [+0.00,+0.00,-0.25],
    positions: [
      [+0.11,-0.06,-0.26],[+0.08,-0.08,-0.40],[-0.08,-0.08,-0.40],[-0.11,-0.06,-0.26],
      [+0.11,+0.20,-0.23],[+0.08,+0.15,-0.45],[-0.08,+0.15,-0.45],[-0.11,+0.20,-0.23]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,5],[5,4,0],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  neck: {
    parent: 'chest',
    pivot: [+0.00,+0.00,+0.15],
    positions: [
      [+0.08,+0.15,+0.30],[+0.08,-0.08,+0.16],[-0.08,-0.08,+0.16],[-0.08,+0.15,+0.30],
      [+0.06,+0.28,+0.25],[+0.10,+0.17,+0.05],[-0.10,+0.17,+0.05],[-0.06,+0.28,+0.25]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,5],[5,4,0],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  head: {
    parent: 'neck',
    pivot: [0,0,+0.20],
    positions: [
      [+0.08,+0.34,+0.35],[+0.06,+0.20,+0.08],[-0.06,+0.20,+0.08],[-0.08,+0.34,+0.35],
      [+0.04,+0.43,+0.30],[+0.03,+0.39,+0.15],[-0.03,+0.39,+0.15],[-0.04,+0.43,+0.30]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,5],[5,4,0],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  snout: {
    parent: 'head',
    pivot: [+0.00,+0.36,+0.34],
    positions: [
      [+0.02,+0.36,+0.47],[+0.06,+0.34,+0.35],[-0.06,+0.34,+0.35],[-0.02,+0.36,+0.47],
      [+0.02,+0.40,+0.46],[+0.05,+0.40,+0.32],[-0.05,+0.40,+0.32],[-0.02,+0.40,+0.46]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,5],[6,5,2],
      [2,3,7],[7,6,2],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  jaw: {
    parent: 'head',
    pivot: [+0.00,+0.30,+0.30],
    positions: [
      [+0.02,+0.30,+0.46],[+0.04,+0.28,+0.30],[-0.04,+0.28,+0.30],[-0.02,+0.30,+0.46],
      [+0.02,+0.32,+0.46],[+0.05,+0.35,+0.27],[-0.06,+0.35,+0.27],[-0.02,+0.32,+0.46]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,5],[5,4,0],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  leftEar: {
    parent: 'head',
    pivot: [+0.05,+0.40,+0.20],
    positions: [
      [+0.09,+0.36,+0.25],[+0.09,+0.36,+0.16],[+0.07,+0.36,+0.16],[+0.07,+0.36,+0.25],
      [+0.05,+0.43,+0.22],[+0.05,+0.41,+0.18],[+0.03,+0.40,+0.16],[+0.04,+0.42,+0.25]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,6],[6,5,1],
      [2,3,7],[7,6,2],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  rightEar: {
    parent: 'head',
    pivot: [-0.05,+0.40,+0.20],
    positions: [
      [-0.09,+0.36,+0.25],[-0.09,+0.36,+0.16],[-0.07,+0.36,+0.16],[-0.07,+0.36,+0.25],
      [-0.05,+0.43,+0.22],[-0.05,+0.41,+0.18],[-0.03,+0.40,+0.16],[-0.04,+0.42,+0.25]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,4],[5,4,1],[1,2,6],[6,5,1],
      [2,3,7],[7,6,2],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  leftFrontLeg: {
    parent: 'chest',
    pivot: [+0.10,+0.05,+0.20],
    positions: [
      [+0.10,-0.40,+0.10],[+0.10,-0.40,+0.05],[+0.05,-0.40,+0.05],[+0.05,-0.40,+0.10],
      [+0.13,+0.10,+0.15],[+0.13,+0.10,+0.05],[+0.07,+0.10,+0.05],[+0.07,+0.10,+0.15]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,5],[5,4,0],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  rightFrontLeg: {
    parent: 'chest',
    pivot: [-0.10,+0.05,+0.20],
    positions: [
      [-0.10,-0.40,+0.10],[-0.10,-0.40,+0.05],[-0.05,-0.40,+0.05],[-0.05,-0.40,+0.10],
      [-0.13,+0.10,+0.15],[-0.13,+0.10,+0.05],[-0.07,+0.10,+0.05],[-0.07,+0.10,+0.15]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,5],[5,4,0],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  leftBackLeg: {
    parent: 'rear',
    pivot: [+0.10,+0.05,-0.30],
    positions: [
      [+0.10,-0.40,-0.38],[+0.10,-0.40,-0.43],[+0.05,-0.40,-0.43],[+0.05,-0.40,-0.38],
      [+0.13,+0.10,-0.33],[+0.13,+0.10,-0.43],[+0.07,+0.10,-0.43],[+0.07,+0.10,-0.33]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,5],[5,4,0],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  rightBackLeg: {
    parent: 'rear',
    pivot: [-0.10,+0.05,-0.30],
    positions: [
      [-0.10,-0.40,-0.38],[-0.10,-0.40,-0.43],[-0.05,-0.40,-0.43],[-0.05,-0.40,-0.38],
      [-0.13,+0.10,-0.33],[-0.13,+0.10,-0.43],[-0.07,+0.10,-0.43],[-0.07,+0.10,-0.33]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,5],[5,4,0],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  },
  tail: {
    parent: 'rear',
    pivot: [0,0,-0.40],
    positions: [
      [+0.02,+0.38,-0.58],[+0.02,+0.35,-0.61],[-0.02,+0.35,-0.61],[-0.02,+0.38,-0.58],
      [+0.02,+0.19,-0.41],[+0.02,+0.16,-0.45],[-0.02,+0.16,-0.45],[-0.02,+0.19,-0.41]
    ],
    cells: [
      [2,1,0],[3,2,0],[0,1,5],[5,4,0],[1,2,5],[6,5,2],
      [2,3,6],[7,6,3],[7,3,0],[0,4,7],[4,5,6],[4,6,7]
    ]
  }
}

dog.js

Adapting the previous box rigging code, we can make the dog wag its tail while slowly moving its head side to side:

var regl = require('regl')({
  extensions: [ 'oes_standard_derivatives' ] })
var camera = require('regl-camera')(regl,
  { phi: 0.4, theta: 0.7, distance: 3.2 })
var mat4 = require('gl-mat4')
var vec3 = require('gl-vec3')

var shapes = Object.assign({}, require('./dog.js'))
var pose = {}
Object.keys(shapes).forEach(function (key) {
  shapes[key] = Object.assign({
    model: new Float32Array(16),
    pose: new Float32Array(16)
  }, shapes[key])
  pose[key] = shapes[key].pose
})
var shapeProps = Object.values(shapes)

var tmpv = new Float32Array(3)
var tmpm = new Float32Array(16)

function update (t) {
  shapeProps.forEach(function (shape) {
    mat4.identity(shape.pose)
  })
  mat4.rotateY(pose.tail, pose.tail, Math.sin(t*12))
  mat4.rotateY(pose.neck, pose.neck, Math.sin(t)*0.5)
  mat4.rotateY(pose.head, pose.head, Math.sin(t)*0.5)
  updateModels()
}

function updateModels () {
  shapeProps.forEach(function (shape) {
    mat4.identity(shape.model)
    var s = shape
    while (s) {
      mat4.identity(tmpm)
      mat4.translate(tmpm, tmpm, s.pivot)
      mat4.multiply(tmpm, tmpm, s.pose)
      mat4.translate(tmpm, tmpm, vec3.negate(tmpv,s.pivot))
      mat4.multiply(shape.model, tmpm, shape.model)
      s = shapes[s.parent]
    }
  })
}

var draw = {
  solid: solid(regl)
}
regl.frame(function (context) {
  regl.clear({ color: [0.3,0.22,0.22,1], depth: true })
  camera(function () {
    draw.solid(shapeProps)
  })
  update(context.time)
})

function solid (regl) {
  return regl({
    frag: `
      precision highp float;
      #extension GL_OES_standard_derivatives: enable
      varying vec3 vpos;
      void main () {
        vec3 N = normalize(cross(dFdx(vpos),dFdy(vpos)));
        gl_FragColor = vec4(N*0.5+0.5,1);
      }
    `,
    vert: `
      precision highp float;
      uniform mat4 projection, view, model;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * model * vec4(position,1);
      }
    `,
    uniforms: {
      model: regl.prop('model')
    },
    attributes: {
      position: regl.prop('positions')
    },
    elements: regl.prop('cells')
  })
}

Adding axes, a moving grid, and a full walking animation, we get:

var regl = require('regl')({
  extensions: [ 'oes_standard_derivatives' ] })
var camera = require('regl-camera')(regl, {
  phi: 0.4, theta: 0.7, distance: 3.2, center: [0,0.2,0] })
var mat4 = require('gl-mat4')
var vec3 = require('gl-vec3')
var quat = require('gl-quat')

var shapes = Object.assign({}, require('./dog.js'))
var pose = {}
Object.keys(shapes).forEach(function (key) {
  shapes[key] = Object.assign({
    model: new Float32Array(16),
    pose: new Float32Array(16)
  }, shapes[key])
  pose[key] = shapes[key].pose
})
var shapeProps = Object.values(shapes)

var tmpv = new Float32Array(3)
var tmpm = new Float32Array(16)

var PI = Math.PI

function update (t) {
  shapeProps.forEach(function (shape) {
    mat4.identity(shape.pose)
  })
  var w = 12
  mat4.rotateY(pose.neck, pose.neck, Math.sin(t)*0.5)
  mat4.rotateY(pose.head,pose.head,powSign(Math.sin(t),0.7)*0.5)
  mat4.rotateY(pose.tail, pose.tail, Math.sin(t*w))
  mat4.rotateX(pose.leftFrontLeg,pose.leftFrontLeg,Math.sin(t*w)*0.2)
  mat4.rotateX(pose.rightFrontLeg,pose.rightFrontLeg,Math.sin(t*w+PI*0.5)*0.2)
  mat4.rotateX(pose.leftBackLeg,pose.leftBackLeg,Math.sin(t*w+Math.PI*0.5)*0.2)
  mat4.rotateX(pose.rightBackLeg,pose.rightBackLeg,Math.sin(t*w)*0.2)
  mat4.rotateX(pose.chest,pose.chest,Math.sin(t*w*2)*0.02)
  mat4.rotateX(pose.rear,pose.rear,Math.sin(t*w*2)*0.02)
  mat4.rotateX(pose.neck,pose.neck,Math.sin(t*0.25)*0.2-Math.sin(t*w*2)*0.015)
  mat4.rotateX(pose.jaw,pose.jaw,Math.max(-0.2,Math.sin(t*w)*0.02))
  mat4.rotateX(pose.snout,pose.snout,Math.min(-0.2,Math.sin(t*w)*0.01))
  mat4.rotateZ(pose.leftEar,pose.leftEar,Math.max(0,Math.sin(t*w*2)*0.2))
  mat4.rotateZ(pose.rightEar,pose.rightEar,Math.max(0,-Math.sin(t*w*2)*0.2))
  updateModels()
}

function powSign (x,n) { return Math.pow(Math.abs(x),n)*Math.sign(x) }

function updateModels () {
  shapeProps.forEach(function (shape) {
    mat4.identity(shape.model)
    var s = shape
    while (s) {
      mat4.identity(tmpm)
      mat4.translate(tmpm, tmpm, s.pivot)
      mat4.multiply(tmpm, tmpm, s.pose)
      mat4.translate(tmpm, tmpm, vec3.negate(tmpv,s.pivot))
      mat4.multiply(shape.model, tmpm, shape.model)
      s = shapes[s.parent]
    }
  })
}

var axes = [ { axis: [1,0,0] }, { axis: [0,1,0] }, { axis: [0,0,1] } ]
var draw = {
  axis: axis(regl,1.3),
  grid: grid(regl, 1),
  solid: solid(regl)
}
regl.frame(function (context) {
  regl.clear({ color: [0.3,0.22,0.22,1], depth: true })
  camera(function () {
    draw.axis(axes)
    draw.solid(shapeProps)
    draw.grid()
  })
  update(context.time)
})

function solid (regl) {
  return regl({
    frag: `
      precision highp float;
      #extension GL_OES_standard_derivatives: enable
      varying vec3 vpos;
      void main () {
        vec3 N = normalize(cross(dFdx(vpos),dFdy(vpos)));
        gl_FragColor = vec4(N*0.5+0.5,1);
      }
    `,
    vert: `
      precision highp float;
      uniform mat4 projection, view, model;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view
          * (model * vec4(position,1) + vec4(0,0.4,0,0));
      }
    `,
    uniforms: {
      model: regl.prop('model')
    },
    attributes: {
      position: regl.prop('positions')
    },
    elements: regl.prop('cells')
  })
}

function axis (regl, d) {
  var model = new Float32Array(16)
  var positions = new Float32Array(3*6)
  return regl({
    frag: `
      precision highp float;
      uniform vec3 axis;
      void main () {
        gl_FragColor = vec4(axis,1);
      }
    `,
    vert: `
      precision highp float;
      uniform mat4 projection, view, model;
      uniform vec3 eye, center;
      attribute vec3 position;
      void main () {
        gl_Position = projection * view * model * vec4(position,1);
      }
    `,
    uniforms: {
      axis: regl.prop('axis'),
      model: function (context, props) {
        mat4.identity(model)
        var angle = 0
        if (props.axis[0] > 0) {
          angle = Math.atan2(context.eye[2],context.eye[1]) + Math.PI/2
        } else if (props.axis[1] > 0) {
          angle = Math.atan2(context.eye[0],context.eye[2])
        } else if (props.axis[2] > 0) {
          angle = Math.atan2(context.eye[1],context.eye[0]) + Math.PI/2
        }
        mat4.rotate(model, model, angle, props.axis)
        var tmpm = new Float32Array(16)
        var q = [0,0,0,0]
        quat.rotationTo(q,[0,1,0],props.axis)
        mat4.fromQuat(tmpm,q)
        mat4.multiply(model, model, tmpm)
        return model
      }
    },
    attributes: {
      position: [
        [0,0,0],
        [0,d-0.2,0],
        [+0.05,d-0.2,0],
        [0,d,0],
        [-0.05,d-0.2,0],
        [0,d-0.2,0]
      ],
      axis: regl.prop('axis')
    },
    count: 6,
    primitive: 'line strip'
  })
}

function grid (regl, d) {
  return regl({
    frag: `
      precision highp float;
      #extension GL_OES_standard_derivatives: enable
      varying vec3 vpos;
      uniform float time, d;
      void main () {
        float q = 4.0;
        vec2 p = vpos.xz*q + vec2(0,time*3.0);
        vec2 m = abs(vec2(1)-mod(p,vec2(1))*2.0)*2.0-1.0;
        m = max(m,pow(abs(vpos.xz)/d,vec2(8)));
        vec2 fw = fwidth(p);
        float fm = min(fw.x,fw.y);
        float x = pow(max(0.0,max(m.x,m.y)),1.0/4.0/fm);
        //if (x < 0.5) discard;
        gl_FragColor = vec4(vec3(x),x);
      }
    `,
    vert: `
      precision highp float;
      uniform mat4 projection, view;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * vec4(position,1);
      }
    `,
    uniforms: {
      time: regl.context('time'),
      d: d
    },
    attributes: {
      position: [[+d,+0,+d],[+d,+0,-d],[-d,+0,-d],[-d,+0,+d]]
    },
    elements: [[0,1,2],[0,2,3]],
    blend: {
      enable: true,
      func: { src: 'src alpha', dst: 'one minus src alpha' }
    },
    depth: { mask: false }
  })
}

walking-dog-final.js

Pretty cool. This should be enough for you to get starting making your own 3d animations. If you want to animate a single 3d surface with a skeletal armature instead of separate boxes, check out chinedufn's article on dual quaternions.