Commits

Michael Ludwig committed 23e1b8f

Rudimentary, but functional skeleton animator with support for ASF motion capture files.

  • Participants
  • Parent commits a0e026b

Comments (0)

Files changed (14)

File ferox-demos/src/main/java/com/ferox/anim/SimpleSkeletonDemo.java

+package com.ferox.anim;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import com.ferox.input.KeyEvent.KeyCode;
+import com.ferox.input.logic.Action;
+import com.ferox.input.logic.InputManager;
+import com.ferox.input.logic.InputState;
+import com.ferox.input.logic.Predicates;
+import com.ferox.math.ColorRGB;
+import com.ferox.math.Matrix4;
+import com.ferox.math.Vector3;
+import com.ferox.math.Vector4;
+import com.ferox.math.bounds.QuadTree;
+import com.ferox.renderer.OnscreenSurface;
+import com.ferox.renderer.impl.lwjgl.LwjglFramework;
+import com.ferox.resource.VertexBufferObject.StorageMode;
+import com.ferox.resource.geom.Cylinder;
+import com.ferox.resource.geom.Geometry;
+import com.ferox.resource.geom.Sphere;
+import com.ferox.scene.AmbientLight;
+import com.ferox.scene.BlinnPhongMaterial;
+import com.ferox.scene.Camera;
+import com.ferox.scene.DiffuseColor;
+import com.ferox.scene.PointLight;
+import com.ferox.scene.Renderable;
+import com.ferox.scene.Transform;
+import com.ferox.scene.task.BuildVisibilityIndexTask;
+import com.ferox.scene.task.ComputeCameraFrustumTask;
+import com.ferox.scene.task.ComputePVSTask;
+import com.ferox.scene.task.UpdateWorldBoundsTask;
+import com.ferox.scene.task.ffp.FixedFunctionRenderTask;
+import com.ferox.scene.task.light.ComputeLightGroupTask;
+import com.ferox.scene.task.light.ComputeShadowFrustumTask;
+import com.ferox.util.ApplicationStub;
+import com.ferox.util.profile.Profiler;
+import com.lhkbob.entreri.Entity;
+import com.lhkbob.entreri.EntitySystem;
+import com.lhkbob.entreri.task.Job;
+import com.lhkbob.entreri.task.Timers;
+
+public class SimpleSkeletonDemo extends ApplicationStub {
+    // positive half-circle
+    private static final double MAX_THETA = Math.PI;
+    private static final double MIN_THETA = 0;
+
+    // positive octant
+    private static final double MAX_PHI = Math.PI / 2.0;
+    private static final double MIN_PHI = Math.PI / 12.0;
+
+    private static final double MIN_ZOOM = 1.0;
+    private static final double MAX_ZOOM = 140;
+
+    private static final double ANGLE_RATE = Math.PI / 4.0;
+    private static final double ZOOM_RATE = 10.0;
+
+    private Job renderJob;
+    private EntitySystem system;
+
+    private Entity camera;
+
+    private double theta; // angle of rotation around global y-axis
+    private double phi; // angle of rotation from xz plane
+    private double zoom; // distance from origin
+
+    public SimpleSkeletonDemo() {
+        super(LwjglFramework.create());
+    }
+
+    @Override
+    protected void installInputHandlers(InputManager io) {
+        io.on(Predicates.keyPress(KeyCode.O)).trigger(new Action() {
+            @Override
+            public void perform(InputState prev, InputState next) {
+                Profiler.getDataSnapshot().print(System.out);
+            }
+        });
+
+        // camera controls
+        io.on(Predicates.keyHeld(KeyCode.W)).trigger(new Action() {
+            @Override
+            public void perform(InputState prev, InputState next) {
+                phi += ANGLE_RATE * ((next.getTimestamp() - prev.getTimestamp()) / 1e9);
+                if (phi > MAX_PHI) {
+                    phi = MAX_PHI;
+                }
+                updateCameraOrientation();
+            }
+        });
+        io.on(Predicates.keyHeld(KeyCode.S)).trigger(new Action() {
+            @Override
+            public void perform(InputState prev, InputState next) {
+                phi -= ANGLE_RATE * ((next.getTimestamp() - prev.getTimestamp()) / 1e9);
+                if (phi < MIN_PHI) {
+                    phi = MIN_PHI;
+                }
+                updateCameraOrientation();
+            }
+        });
+        io.on(Predicates.keyHeld(KeyCode.D)).trigger(new Action() {
+            @Override
+            public void perform(InputState prev, InputState next) {
+                theta -= ANGLE_RATE * ((next.getTimestamp() - prev.getTimestamp()) / 1e9);
+                if (theta < MIN_THETA) {
+                    theta = MIN_THETA;
+                }
+                updateCameraOrientation();
+            }
+        });
+        io.on(Predicates.keyHeld(KeyCode.A)).trigger(new Action() {
+            @Override
+            public void perform(InputState prev, InputState next) {
+                theta += ANGLE_RATE * ((next.getTimestamp() - prev.getTimestamp()) / 1e9);
+                if (theta > MAX_THETA) {
+                    theta = MAX_THETA;
+                }
+                updateCameraOrientation();
+            }
+        });
+        io.on(Predicates.keyHeld(KeyCode.X)).trigger(new Action() {
+            @Override
+            public void perform(InputState prev, InputState next) {
+                zoom += ZOOM_RATE * ((next.getTimestamp() - prev.getTimestamp()) / 1e9);
+                if (zoom > MAX_ZOOM) {
+                    zoom = MAX_ZOOM;
+                }
+                updateCameraOrientation();
+            }
+        });
+        io.on(Predicates.keyHeld(KeyCode.Z)).trigger(new Action() {
+            @Override
+            public void perform(InputState prev, InputState next) {
+                zoom -= ZOOM_RATE * ((next.getTimestamp() - prev.getTimestamp()) / 1e9);
+                if (zoom < MIN_ZOOM) {
+                    zoom = MIN_ZOOM;
+                }
+                updateCameraOrientation();
+            }
+        });
+    }
+
+    private void updateCameraOrientation() {
+        Vector3 pos = new Vector3();
+        double r = zoom * Math.cos(phi);
+        pos.x = r * Math.cos(theta);
+        pos.y = zoom * Math.sin(phi);
+        pos.z = r * Math.sin(theta);
+
+        camera.get(Transform.class).getData()
+              .setMatrix(new Matrix4().lookAt(new Vector3(), pos, new Vector3(0, 1, 0)));
+    }
+
+    @Override
+    protected void init(OnscreenSurface surface) {
+        system = new EntitySystem();
+
+        renderJob = system.getScheduler()
+                          .createJob("render",
+                                     Timers.measuredDelta(),
+                                     new SkeletonAnimationTask(),
+                                     new BoneTransformTask(),
+                                     new BoneLinkTask(),
+                                     new UpdateWorldBoundsTask(),
+                                     new ComputeCameraFrustumTask(),
+                                     new ComputeShadowFrustumTask(),
+                                     new BuildVisibilityIndexTask(new QuadTree<Entity>()),
+                                     new ComputePVSTask(),
+                                     new ComputeLightGroupTask(),
+                                     new FixedFunctionRenderTask(surface.getFramework(),
+                                                                 1024,
+                                                                 false));
+
+        Entity mainSkeleton = system.addEntity();
+        try {
+            AcclaimSkeleton skeleton = new AcclaimSkeleton();
+            InputStream in = new FileInputStream("/Users/michaelludwig/Desktop/U of M/Semesters/Spring 2013/Directed Study/cmu_motions/01.asf");
+            skeleton.load(in);
+            skeleton.addSkeleton(mainSkeleton);
+
+            in.close();
+
+            in = new FileInputStream("/Users/michaelludwig/Desktop/U of M/Semesters/Spring 2013/Directed Study/cmu_motions/01_01.amc");
+            SkeletonAnimation anim = skeleton.loadAnimation(in, 1 / 120.0);
+
+            mainSkeleton.add(Animated.class).getData().setAnimation(anim).setTimeScale(1);
+        } catch (IOException i) {
+            throw new RuntimeException(i);
+        }
+
+        // visualize skeleton
+        for (Bone b : mainSkeleton.get(Skeleton.class).getData().getBones()) {
+            if (b.getName().equals("root")) {
+                continue;
+            }
+
+            // main bone
+            Entity boneLink = system.addEntity();
+            boneLink.setOwner(mainSkeleton);
+
+            boneLink.add(BoneLink.class).getData().setLinkedBone(b);
+
+            Matrix4 r = b.getRelativeBoneTransform();
+            Matrix4 ri = new Matrix4().inverse(r);
+            Vector3 offset = new Vector3(ri.m03, ri.m13, ri.m23).scale(.5);
+            //            Geometry g = Cylinder.create(offset, new Vector3().scale(offset, -.5), .1,
+            //                                         offset.length(), 16, StorageMode.GPU_STATIC);
+            System.out.println(b.getName() + " " + r);
+            Geometry g = Cylinder.create(offset, offset, .1, offset.length() * 2, 16,
+                                         StorageMode.GPU_STATIC);
+
+            boneLink.add(Renderable.class)
+                    .getData()
+                    .setVertices(g.getVertices())
+                    .setIndices(g.getPolygonType(), g.getIndices(), g.getIndexOffset(),
+                                g.getIndexCount()).setLocalBounds(g.getBounds());
+
+            boneLink.add(BlinnPhongMaterial.class).getData().setNormals(g.getNormals());
+            boneLink.add(DiffuseColor.class).getData().setColor(new ColorRGB(1, 1, 1));
+
+            // origin link
+            boneLink = system.addEntity();
+            boneLink.setOwner(mainSkeleton);
+
+            boneLink.add(BoneLink.class).getData().setLinkedBone(b);
+
+            g = Sphere.create(.3, 16, StorageMode.GPU_STATIC);
+
+            boneLink.add(Renderable.class)
+                    .getData()
+                    .setVertices(g.getVertices())
+                    .setIndices(g.getPolygonType(), g.getIndices(), g.getIndexOffset(),
+                                g.getIndexCount()).setLocalBounds(g.getBounds());
+
+            boneLink.add(BlinnPhongMaterial.class).getData().setNormals(g.getNormals());
+            boneLink.add(DiffuseColor.class).getData().setColor(new ColorRGB(1, 0, 0));
+        }
+
+        // a point light
+        Entity point = system.addEntity();
+        point.add(PointLight.class).getData().setColor(new ColorRGB(1, 1, 1));
+        point.get(Transform.class)
+             .getData()
+             .setMatrix(new Matrix4().setIdentity().setCol(3, new Vector4(10, 10, 10, 1)));
+
+        Entity ambient = system.addEntity();
+        ambient.add(AmbientLight.class).getData().setColor(new ColorRGB(.7, .7, .7));
+
+        // camera
+        theta = (MAX_THETA + MIN_THETA) / 2.0;
+        phi = (MAX_PHI + MIN_PHI) / 2.0;
+        zoom = (MAX_ZOOM + MIN_ZOOM) / 2.0;
+
+        camera = system.addEntity();
+        camera.add(Camera.class).getData().setSurface(surface).setZDistances(1.0, 500);
+        updateCameraOrientation();
+    }
+
+    @Override
+    protected void renderFrame(OnscreenSurface surface) {
+        system.getScheduler().runOnCurrentThread(renderJob);
+    }
+
+    public static void main(String[] args) throws IOException {
+        new SimpleSkeletonDemo().run();
+    }
+}

File ferox-math/src/main/java/com/ferox/math/Matrix3.java

     }
 
     /**
+     * Set this matrix to be a rotation about the X axis by the given radian
+     * amount.
+     * 
+     * @param radians The rotation amount about the X axis
+     * @return This matrix
+     */
+    public Matrix3 rotateX(double radians) {
+        double sin = Math.sin(radians);
+        double cos = Math.cos(radians);
+        return set(1, 0, 0, 0, cos, -sin, 0, sin, cos);
+    }
+
+    /**
+     * Set this matrix to be a rotation about the Y axis by the given radian
+     * amount.
+     * 
+     * @param radians The rotation amount about the Y axis
+     * @return This matrix
+     */
+    public Matrix3 rotateY(double radians) {
+        double sin = Math.sin(radians);
+        double cos = Math.cos(radians);
+        return set(cos, 0, sin, 0, 1, 0, -sin, 0, cos);
+    }
+
+    /**
+     * Set this matrix to be a rotation about the Z axis by the given radian
+     * amount.
+     * 
+     * @param radians The rotation amount about the Z axis
+     * @return This matrix
+     */
+    public Matrix3 rotateZ(double radians) {
+        double sin = Math.sin(radians);
+        double cos = Math.cos(radians);
+        return set(cos, -sin, 0, sin, cos, 0, 0, 0, 1);
+    }
+
+    /**
      * Compute and return the determinant for this matrix. If this value is 0,
      * then the matrix is not invertible. If it is very close to 0, then the
      * matrix may be ill-formed and inversions, multiplications and linear

File ferox-renderer/ferox-renderer-api/src/main/java/com/ferox/resource/GlslShader.java

         // FIXME are there more?
         // should I really limit it to an enum, I could just have
         // it be an integer that is parsed that is probably safer
+        V1_10,
         V1_20,
         V1_30,
         V1_40,
     }
 
     private Version version;
-    private EnumMap<ShaderType, String> shaders;
+    private final EnumMap<ShaderType, String> shaders;
 
     public GlslShader() {
-        version = Version.V1_20;
+        version = Version.V1_10;
         shaders = new EnumMap<ShaderType, String>(ShaderType.class);
     }
 
                 Version v = null;
 
                 switch (version) {
+                case 110:
+                    throw new IllegalArgumentException("GLSL 1.1 does not use #version");
                 case 120:
                     v = Version.V1_20;
                     break;
             }
         }
 
-        return (detectedVersion == null ? Version.V1_20 : detectedVersion);
+        return (detectedVersion == null ? Version.V1_10 : detectedVersion);
     }
 }

File ferox-renderer/ferox-renderer-api/src/main/java/com/ferox/resource/geom/Cylinder.java

      * @throws NullPointerException if mode is null
      */
     public static Geometry create(double radius, double height, int res, StorageMode mode) {
-        return create(new Vector3(0, 1, 0), radius, height, res, mode);
+        return create(new Vector3(0, 1, 0), new Vector3(0, 0, 0), radius, height, res,
+                      mode);
     }
 
     /**
      * resolution and StorageMode.
      * 
      * @param axis The vertical axis of the cylinder
+     * @param origin The point this cylinder is centered about
      * @param radius The radius of the cylinder, in local space
      * @param height The height of the cylinder
      * @param res The resolution of the sphere
      * @throws IllegalArgumentException if radius <= 0 or if res < 4
      * @throws NullPointerException if mode is null
      */
-    public static Geometry create(@Const Vector3 axis, double radius, double height,
-                                  int res, StorageMode mode) {
-        return new CylinderImpl(axis, radius, height, res, mode);
+    public static Geometry create(@Const Vector3 axis, @Const Vector3 origin,
+                                  double radius, double height, int res, StorageMode mode) {
+        return new CylinderImpl(axis, origin, radius, height, res, mode);
     }
 
     private static class CylinderImpl implements Geometry {
 
         private final AxisAlignedBox bounds;
 
-        public CylinderImpl(@Const Vector3 axis, double radius, double height, int res,
-                            StorageMode mode) {
+        public CylinderImpl(@Const Vector3 axis, @Const Vector3 origin, double radius,
+                            double height, int res, StorageMode mode) {
             if (radius <= 0) {
                 throw new IllegalArgumentException("Invalid radius, must be > 0, not: " + radius);
             }
             Vector3 v = new Vector3().normalize(axis);
             Matrix3 m = new Matrix3();
 
-            if (axis.equals(u)) {
+            if (Math.abs(v.x - 1.0) < 0.0001) {
                 // update it to get the right ortho-normal basis
                 u.set(0, -1, 0);
+            } else if (Math.abs(v.x + 1.0) < 0.0001) {
+                // update it to get the right ortho-normal basis
+                u.set(0, 1, 0);
             } else {
                 // compute orthogonal x-axis in the direction of (1, 0, 0)
-                u.ortho(axis);
+                u.ortho(v).normalize();
             }
 
             m.setCol(0, u);
             for (int i = 0; i < va.length; i += 8) {
                 // vertex
                 u.set(va, i);
-                u.mul(m, u);
+                u.mul(m, u).add(origin);
                 u.get(va, i);
 
                 // normal

File ferox-scene/src/main/java/com/ferox/anim/AcclaimSkeleton.java

+package com.ferox.anim;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.ferox.math.Matrix3;
+import com.ferox.math.Matrix4;
+import com.ferox.math.Vector3;
+import com.ferox.math.Vector4;
+import com.lhkbob.entreri.Entity;
+
+public class AcclaimSkeleton {
+    private String name;
+    private ASFRoot root;
+    private List<ASFBone> boneData;
+    private Map<Object, List<ASFBone>> hierarchy; // key is Bone or Root instance
+
+    public String getName() {
+        return name;
+    }
+
+    public Skeleton addSkeleton(Entity entity) {
+        Skeleton skeleton = entity.add(Skeleton.class).getData();
+
+        Bone rootBone = createRootBone();
+        skeleton.addBone(rootBone);
+        skeleton.setRootBone(rootBone);
+
+        addBones(root, rootBone, skeleton);
+
+        return skeleton;
+    }
+
+    public SkeletonAnimation loadAnimation(InputStream acmFile, double frameRate) throws IOException {
+        BufferedReader in = new BufferedReader(new InputStreamReader(acmFile));
+
+        SkeletonAnimation animation = new SkeletonAnimation();
+        KeyFrame.Builder currentKeyFrame = null;
+        boolean inDegrees = true; // assume this is the default just like in ASF
+
+        String line;
+        while ((line = in.readLine()) != null) {
+            line = line.replaceAll("#.*", "").trim();
+            if (line.isEmpty()) {
+                continue;
+            }
+
+            String[] tokens = line.split("\\s+");
+
+            if (tokens[0].equalsIgnoreCase(":degrees")) {
+                inDegrees = true;
+            } else if (tokens[0].equalsIgnoreCase(":radians")) {
+                inDegrees = false;
+            } else if (tokens[0].startsWith(":")) {
+                // ignore
+            } else if (tokens.length == 1) {
+                try {
+                    int frame = Integer.parseInt(tokens[0]);
+                    if (currentKeyFrame != null && (frame - 1) % 5 == 0) {
+                        animation.addKeyFrame(currentKeyFrame.build());
+                    }
+                    currentKeyFrame = new KeyFrame.Builder(frameRate * (frame - 1));
+                } catch (NumberFormatException nfe) {
+                    throw new IOException("Expected single integer on line: " + line);
+                }
+            } else {
+                if (tokens[0].equals("root")) {
+                    double[] dofValues = new double[root.order.length];
+                    if (dofValues.length != tokens.length - 1) {
+                        throw new IOException("Incorrect number of motion values");
+                    }
+
+                    for (int i = 0; i < dofValues.length; i++) {
+                        dofValues[i] = Double.parseDouble(tokens[i + 1]);
+                        if (inDegrees && (root.order[i] == DoF.RX || root.order[i] == DoF.RY || root.order[i] == DoF.RZ)) {
+                            dofValues[i] = Math.toRadians(dofValues[i]);
+                        }
+                    }
+
+                    currentKeyFrame.setBone("root", buildRoot(root.order, dofValues));
+                } else {
+                    ASFBone bone = getBone(tokens[0]);
+                    if (bone == null) {
+                        throw new IOException("Bone with name: " + tokens[0] + " is undefined");
+                    }
+
+                    double[] dofValues = new double[bone.motionDoF.length];
+                    if (dofValues.length != tokens.length - 1) {
+                        throw new IOException("Incorrect number of motion values");
+                    }
+
+                    for (int i = 0; i < dofValues.length; i++) {
+                        dofValues[i] = Double.parseDouble(tokens[i + 1]);
+                        if (inDegrees && (bone.motionDoF[i] == DoF.RX || bone.motionDoF[i] == DoF.RY || bone.motionDoF[i] == DoF.RZ)) {
+                            dofValues[i] = Math.toRadians(dofValues[i]);
+                        }
+                    }
+
+                    Matrix4 localBasis = new Matrix4().setIdentity()
+                                                      .setUpper(bone.childToParentBasis);
+                    Matrix4 motion = new Matrix4().setIdentity()
+                                                  .setUpper(buildBasis(bone.motionDoF,
+                                                                       dofValues));
+                    Matrix4 translate = new Matrix4().setIdentity()
+                                                     .setCol(3,
+                                                             new Vector4(bone.localDirection.x * bone.length,
+                                                                         bone.localDirection.y * bone.length,
+                                                                         bone.localDirection.z * bone.length,
+                                                                         1));
+
+                    localBasis.mul(motion).mul(translate);
+                    currentKeyFrame.setBone(bone.name, localBasis);
+                }
+            }
+        }
+
+        if (currentKeyFrame != null) {
+            animation.addKeyFrame(currentKeyFrame.build());
+        }
+        return animation;
+    }
+
+    private void addBones(Object asfParent, Bone parent, Skeleton skeleton) {
+        List<ASFBone> children = hierarchy.get(asfParent);
+        if (children != null) {
+            for (ASFBone bone : children) {
+                Bone childBone = createBone(bone);
+                skeleton.addBone(childBone);
+                skeleton.connect(parent, childBone);
+
+                addBones(bone, childBone, skeleton);
+            }
+        }
+    }
+
+    private Bone createBone(ASFBone bone) {
+        Matrix4 translate = new Matrix4().setIdentity();
+        translate.m03 = bone.localDirection.x * bone.length;
+        translate.m13 = bone.localDirection.y * bone.length;
+        translate.m23 = bone.localDirection.z * bone.length;
+
+        Matrix4 rotate = new Matrix4().setIdentity().setUpper(bone.childToParentBasis);
+        rotate.mul(translate);
+
+        Bone newBone = new Bone(bone.name);
+        newBone.setRelativeBoneTransform(rotate);
+        return newBone;
+    }
+
+    private void computeLocalSpace() {
+        List<ASFBone> rootChildren = hierarchy.get(root);
+        if (rootChildren != null) {
+            Matrix3 inverseParent;
+            if (root.axis != null) {
+                inverseParent = buildBasis(root.axis, root.presetOrientation).inverse();
+            } else {
+                inverseParent = new Matrix3().setIdentity();
+            }
+
+            for (ASFBone child : rootChildren) {
+                computeLocalSpace(child, inverseParent);
+            }
+        }
+    }
+
+    private void computeLocalSpace(ASFBone bone, Matrix3 inverseParent) {
+        Matrix3 boneGlobalBasis = buildBasis(bone.axis, bone.axisRotation);
+        bone.childToParentBasis = new Matrix3().mul(inverseParent, boneGlobalBasis);
+
+        // this now holds the inverse of the bone's global basis
+        bone.localDirection = new Vector3().mul(boneGlobalBasis.inverse(), bone.direction);
+        bone.localDirection.normalize();
+
+        List<ASFBone> children = hierarchy.get(bone);
+        if (children != null) {
+            for (ASFBone child : children) {
+                computeLocalSpace(child, boneGlobalBasis);
+            }
+        }
+    }
+
+    private Bone createRootBone() {
+        Bone rootBone = new Bone("root");
+        Matrix4 m = rootBone.getRelativeBoneTransform();
+        m.setIdentity();
+
+        if (root.axis != null) {
+            m.setUpper(buildBasis(root.axis, root.presetOrientation));
+        }
+
+        if (root.presetPosition != null) {
+            m.m03 = root.presetPosition.x;
+            m.m13 = root.presetPosition.y;
+            m.m23 = root.presetPosition.z;
+        }
+        rootBone.setRelativeBoneTransform(m);
+        return rootBone;
+    }
+
+    private Matrix3 buildBasis(DoF[] axis, Vector3 values) {
+        return buildBasis(axis, new double[] {values.x, values.y, values.z});
+    }
+
+    private Matrix4 buildRoot(DoF[] order, double[] values) {
+        //        Matrix3 temp = new Matrix3();
+        Matrix4 root = new Matrix4().setIdentity();
+
+        // FIXME this is brittle, and odd, because it expects the rotations to
+        // be transposed and reversed like in buildBasis(), but the translation
+        // must be at the end, even though the  provided order is different.
+
+        // What they give: TX TY TZ RX RY RZ
+        // So what the expected translation would be: RZ RY RX TZ TY TX
+        // What works: TX TY TZ RZ RY RX
+        //
+        // Perhaps the site is incorrect, and you just use the order values
+        // but construct it using the same axis definition for presetOrientation, etc.?
+        // seems plausible
+        root.setUpper(buildBasis(new DoF[] {order[3], order[4], order[5]},
+                                 new double[] {values[3], values[4], values[5]}));
+        root.m03 = values[0];
+        root.m13 = values[1];
+        root.m23 = values[2];
+
+        //        Matrix4 dof = new Matrix4();
+        //
+        //        // FIXME order?
+        //        for (int i = 0; i < order.length; i++) {
+        //            dof.setIdentity();
+        //            switch (order[i]) {
+        //            case RX:
+        //                temp.rotateX(values[i]);
+        //                dof.setUpper(temp);
+        //                break;
+        //            case RY:
+        //                temp.rotateY(values[i]);
+        //                dof.setUpper(temp);
+        //                break;
+        //            case RZ:
+        //                temp.rotateZ(values[i]);
+        //                dof.setUpper(temp);
+        //                break;
+        //            case TX:
+        //                dof.m03 = values[i];
+        //                break;
+        //            case TY:
+        //                dof.m13 = values[i];
+        //                break;
+        //            case TZ:
+        //                dof.m23 = values[i];
+        //                break;
+        //            default:
+        //                throw new IllegalStateException("Bad degree of freedom: " + order[i]);
+        //            }
+        //
+        //            root.mul(dof);
+        //        }
+
+        return root;
+    }
+
+    private Matrix3 buildBasis(DoF[] axis, double[] values) {
+        Matrix3 basis = new Matrix3().setIdentity();
+        Matrix3 axisAngle = new Matrix3();
+
+        for (int i = axis.length - 1; i >= 0; i--) {
+            switch (axis[i]) {
+            case RX:
+                axisAngle.rotateX(values[i]);
+                break;
+            case RY:
+                axisAngle.rotateY(values[i]);
+                break;
+            case RZ:
+                axisAngle.rotateZ(values[i]);
+                break;
+            default:
+                throw new IllegalStateException("Bad degree of freedom: " + axis[i]);
+            }
+
+            basis.mul(axisAngle);
+        }
+
+        return basis;
+    }
+
+    public void load(InputStream asf) throws IOException {
+        BufferedReader in = new BufferedReader(new InputStreamReader(asf));
+
+        // state tracking while progressively loading
+        FileSection section = null;
+
+        // when in units
+        boolean anglesInDegrees = true; // degrees is default
+
+        // when in bone data
+        ASFBone currentBone = null;
+        List<Double> minLimits = null;
+        List<Double> maxLimits = null;
+
+        // when in hierarchy
+        boolean buildingHierarchy = false;
+
+        String line;
+        while ((line = in.readLine()) != null) {
+            line = line.replaceAll("#.*", "").trim();
+            if (line.isEmpty()) {
+                continue;
+            }
+
+            String[] tokens = line.split("\\s+");
+
+            if (tokens[0].equalsIgnoreCase(":version")) {
+                if (tokens.length != 2) {
+                    throw new IOException("Malformed :version token: " + line);
+                }
+                if (!tokens[1].equals("1.10") && !tokens[1].equals("1.1")) {
+                    throw new IOException("Unsupported version: " + tokens[1]);
+                }
+                section = FileSection.VERSION;
+            } else if (tokens[0].equalsIgnoreCase(":name")) {
+                if (tokens.length != 2) {
+                    throw new IOException("Malformed :name token: " + line);
+                }
+                name = tokens[1];
+                section = FileSection.NAME;
+            } else if (tokens[0].equalsIgnoreCase(":units")) {
+                section = FileSection.UNITS;
+            } else if (tokens[0].equalsIgnoreCase(":documentation")) {
+                section = FileSection.DOCUMENTATION;
+            } else if (tokens[0].equalsIgnoreCase(":root")) {
+                root = new ASFRoot();
+                section = FileSection.ROOT;
+            } else if (tokens[0].equalsIgnoreCase(":bonedata")) {
+                boneData = new ArrayList<ASFBone>();
+                section = FileSection.BONE_DATA;
+            } else if (tokens[0].equalsIgnoreCase(":hierarchy")) {
+                section = FileSection.HIERARCHY;
+            } else if (tokens[0].equalsIgnoreCase(":skin")) {
+                section = FileSection.SKIN;
+            } else if (section == FileSection.UNITS) {
+                if (tokens[0].equalsIgnoreCase("angle")) {
+                    if (tokens.length != 2) {
+                        throw new IOException("Malformed angle token: " + line);
+                    }
+                    if (!tokens[1].equalsIgnoreCase("deg") && !tokens[1].equalsIgnoreCase("rad")) {
+                        throw new IOException("Invalid angle unit: " + tokens[1]);
+                    }
+                    anglesInDegrees = tokens[1].equalsIgnoreCase("deg");
+                }
+            } else if (section == FileSection.ROOT) {
+                if (tokens[0].equals("order")) {
+                    root.order = new DoF[tokens.length - 1];
+
+                    for (int i = 0; i < root.order.length; i++) {
+                        root.order[i] = DoF.byToken(tokens[i + 1]);
+                    }
+                } else if (tokens[0].equals("axis")) {
+                    if (tokens.length != 2 || tokens[1].length() != 3) {
+                        throw new IOException("Unexpected axis format: " + line);
+                    }
+
+                    root.axis = new DoF[3];
+                    String axisOrder = tokens[1].toLowerCase();
+                    for (int i = 0; i < 3; i++) {
+                        if (axisOrder.charAt(i) == 'x') {
+                            root.axis[i] = DoF.RX;
+                        } else if (axisOrder.charAt(i) == 'y') {
+                            root.axis[i] = DoF.RY;
+                        } else if (axisOrder.charAt(i) == 'z') {
+                            root.axis[i] = DoF.RZ;
+                        } else {
+                            throw new IOException("Unknown axis token: " + tokens[1]);
+                        }
+                    }
+                } else if (tokens[0].equals("position")) {
+                    if (tokens.length != 4) {
+                        throw new IOException("Badly formed position specifier: " + line);
+                    }
+                    root.presetPosition = new Vector3(Double.parseDouble(tokens[1]),
+                                                      Double.parseDouble(tokens[2]),
+                                                      Double.parseDouble(tokens[3]));
+                } else if (tokens[0].equals("orientation")) {
+                    if (tokens.length != 4) {
+                        throw new IOException("Badly formed orientation specifier: " + line);
+                    }
+                    root.presetOrientation = new Vector3(Double.parseDouble(tokens[1]),
+                                                         Double.parseDouble(tokens[2]),
+                                                         Double.parseDouble(tokens[3]));
+                    toRadians(root.presetOrientation, anglesInDegrees);
+                }
+            } else if (section == FileSection.BONE_DATA) {
+                if (currentBone != null) {
+                    if (tokens[0].equals("name")) {
+                        if (tokens.length != 2) {
+                            throw new IOException("Malformed name token: " + line);
+                        }
+                        currentBone.name = tokens[1];
+                    } else if (tokens[0].equals("direction")) {
+                        if (tokens.length != 4) {
+                            throw new IOException("Badly formed direction specifier: " + line);
+                        }
+                        currentBone.direction = new Vector3(Double.parseDouble(tokens[1]),
+                                                            Double.parseDouble(tokens[2]),
+                                                            Double.parseDouble(tokens[3]));
+                        currentBone.direction.normalize();
+                    } else if (tokens[0].equals("length")) {
+                        if (tokens.length != 2) {
+                            throw new IOException("Malformed length token: " + line);
+                        }
+                        currentBone.length = Double.parseDouble(tokens[1]);
+                    } else if (tokens[0].equals("axis")) {
+                        if (tokens.length != 5 || tokens[4].length() != 3) {
+                            throw new IOException("Malformed axis token: " + line);
+                        }
+
+                        currentBone.axis = new DoF[3];
+                        currentBone.axisRotation = new Vector3(Double.parseDouble(tokens[1]),
+                                                               Double.parseDouble(tokens[2]),
+                                                               Double.parseDouble(tokens[3]));
+                        toRadians(currentBone.axisRotation, anglesInDegrees);
+
+                        String axisOrder = tokens[4].toLowerCase();
+                        for (int i = 0; i < 3; i++) {
+                            if (axisOrder.charAt(i) == 'x') {
+                                currentBone.axis[i] = DoF.RX;
+                            } else if (axisOrder.charAt(i) == 'y') {
+                                currentBone.axis[i] = DoF.RY;
+                            } else if (axisOrder.charAt(i) == 'z') {
+                                currentBone.axis[i] = DoF.RZ;
+                            } else {
+                                throw new IOException("Unknown axis token: " + tokens[1]);
+                            }
+                        }
+                    } else if (tokens[0].equals("dof")) {
+                        currentBone.motionDoF = new DoF[tokens.length - 1];
+
+                        for (int i = 0; i < currentBone.motionDoF.length; i++) {
+                            currentBone.motionDoF[i] = DoF.byToken(tokens[i + 1]);
+                        }
+                    } else if (tokens[0].equals("limits")) {
+                        minLimits = new ArrayList<Double>();
+                        maxLimits = new ArrayList<Double>();
+
+                        String min, max;
+                        if (tokens.length == 3) {
+                            // tokens are ['limits', '(number', 'number)']
+                            min = tokens[1].substring(1);
+                            max = tokens[2].substring(0, tokens[2].length() - 1);
+                        } else if (tokens.length == 2) {
+                            // tokens are ['limits', '(number,number)']
+                            String[] split = tokens[1].split(",");
+                            min = split[0].substring(1);
+                            max = split[1].substring(0, split[1].length() - 1);
+                        } else {
+                            throw new IOException("Malformed limits token: " + line);
+                        }
+
+                        parseLimits(min, max, minLimits, maxLimits);
+                    } else if (tokens[0].startsWith("(")) {
+                        if (minLimits == null) {
+                            throw new IOException("Unexpected limit specifier: " + line);
+                        }
+
+                        String min, max;
+                        if (tokens.length == 2) {
+                            // tokens are ['(number', 'number)']
+                            min = tokens[0].substring(1);
+                            max = tokens[1].substring(0, tokens[1].length() - 1);
+                        } else if (tokens.length == 1) {
+                            // tokens are ['(number,number)']
+                            String[] split = tokens[0].split(",");
+                            min = split[0].substring(1);
+                            max = split[1].substring(0, split[1].length() - 1);
+                        } else {
+                            throw new IOException("Malformed limits token: " + line);
+                        }
+
+                        parseLimits(min, max, minLimits, maxLimits);
+                    } else if (tokens[0].equals("end")) {
+                        // process accumulated limits text
+                        if (currentBone.motionDoF != null) {
+                            currentBone.minLimits = new double[currentBone.motionDoF.length];
+                            currentBone.maxLimits = new double[currentBone.motionDoF.length];
+
+                            if (minLimits != null) {
+                                if (minLimits.size() != currentBone.motionDoF.length) {
+                                    throw new IOException("Bone does not have same limit count as degrees of freedom");
+                                }
+
+                                for (int i = 0; i < currentBone.motionDoF.length; i++) {
+                                    if (anglesInDegrees && currentBone.motionDoF[i].isAngle()) {
+                                        currentBone.minLimits[i] = Math.toRadians(minLimits.get(i));
+                                        currentBone.maxLimits[i] = Math.toRadians(maxLimits.get(i));
+                                    } else {
+                                        currentBone.minLimits[i] = minLimits.get(i);
+                                        currentBone.maxLimits[i] = maxLimits.get(i);
+                                    }
+                                }
+                            } else {
+                                // fill in infinite limits
+                                for (int i = 0; i < currentBone.motionDoF.length; i++) {
+                                    currentBone.minLimits[i] = Double.NEGATIVE_INFINITY;
+                                    currentBone.maxLimits[i] = Double.POSITIVE_INFINITY;
+                                }
+                            }
+                        }
+
+                        boneData.add(currentBone);
+                        currentBone = null;
+                    }
+                } else {
+                    if (tokens[0].equals("begin")) {
+                        currentBone = new ASFBone();
+                        minLimits = null;
+                        maxLimits = null;
+                    }
+                }
+            } else if (section == FileSection.HIERARCHY) {
+                if (!buildingHierarchy) {
+                    if (tokens[0].equals("begin")) {
+                        hierarchy = new HashMap<Object, List<ASFBone>>();
+                        buildingHierarchy = true;
+                    }
+                } else {
+                    if (tokens[0].equals("end")) {
+                        // process the completed hierarchy to compute local
+                        // transforms for each bone
+                        computeLocalSpace();
+                        buildingHierarchy = false;
+                    } else {
+                        // assume tokens represent hierarchy
+                        if (tokens.length < 2) {
+                            throw new IOException("Bad hierarchy definition: " + line);
+                        }
+
+                        Object parent = (tokens[0].equals("root") ? root : getBone(tokens[0]));
+                        if (parent == null) {
+                            throw new IOException("Parent bone does not exist: " + tokens[0]);
+                        }
+                        List<ASFBone> children = new ArrayList<ASFBone>();
+                        for (int i = 1; i < tokens.length; i++) {
+                            ASFBone child = getBone(tokens[i]);
+                            if (child == null) {
+                                throw new IOException("Child bone does not exist: " + tokens[i]);
+                            }
+                            children.add(child);
+                        }
+
+                        hierarchy.put(parent, children);
+                    }
+                }
+            }
+        }
+    }
+
+    private ASFBone getBone(String name) {
+        for (ASFBone b : boneData) {
+            if (b.name.equals(name)) {
+                return b;
+            }
+        }
+        return null;
+    }
+
+    private void toRadians(Vector3 angles, boolean inDegrees) {
+        if (inDegrees) {
+            angles.x = Math.toRadians(angles.x);
+            angles.y = Math.toRadians(angles.y);
+            angles.z = Math.toRadians(angles.z);
+        }
+        // else already in radians
+    }
+
+    private void parseLimits(String min, String max, List<Double> minLimits,
+                             List<Double> maxLimits) {
+        if (min.equals("-inf")) {
+            minLimits.add(Double.NEGATIVE_INFINITY);
+        } else if (min.equals("inf")) {
+            minLimits.add(Double.POSITIVE_INFINITY);
+        } else {
+            minLimits.add(Double.parseDouble(min));
+        }
+
+        if (max.equals("-inf")) {
+            maxLimits.add(Double.NEGATIVE_INFINITY);
+        } else if (max.equals("inf")) {
+            maxLimits.add(Double.POSITIVE_INFINITY);
+        } else {
+            maxLimits.add(Double.parseDouble(max));
+        }
+    }
+
+    private static enum FileSection {
+        VERSION,
+        NAME,
+        UNITS,
+        DOCUMENTATION,
+        ROOT,
+        BONE_DATA,
+        HIERARCHY,
+        SKIN
+    }
+
+    private static enum DoF {
+        TX(false),
+        TY(false),
+        TZ(false),
+        RX(true),
+        RY(true),
+        RZ(true),
+        L(false);
+
+        private final boolean angle;
+
+        private DoF(boolean angle) {
+            this.angle = angle;
+        }
+
+        public static DoF byToken(String token) throws IOException {
+            for (DoF d : values()) {
+                if (d.name().equalsIgnoreCase(token)) {
+                    return d;
+                }
+            }
+            throw new IOException("Unsupported/unknown DoF token: " + token);
+        }
+
+        public boolean isAngle() {
+            return angle;
+        }
+    }
+
+    private static class ASFRoot {
+        private DoF[] order; // will not contain L
+        private DoF[] axis; // will only contain RX, RY, and RZ
+        private Vector3 presetPosition;
+        private Vector3 presetOrientation;
+    }
+
+    private static class ASFBone {
+        private String name;
+        private Vector3 direction; // FIXME I think this might be in global as well
+        private double length;
+
+        private Vector3 axisRotation; // rotations about global axis
+        private DoF[] axis; // axis order in above vector
+
+        private Matrix3 childToParentBasis;
+        private Vector3 localDirection; // direction converted to bone's coordinate frame
+
+        private DoF[] motionDoF; // DoF's present in AMC files, won't be TX, TY, or TZ
+        private double[] minLimits;
+        private double[] maxLimits;
+    }
+}

File ferox-scene/src/main/java/com/ferox/anim/Animated.java

+package com.ferox.anim;
+
+import com.lhkbob.entreri.ComponentData;
+import com.lhkbob.entreri.property.BooleanProperty;
+import com.lhkbob.entreri.property.BooleanProperty.DefaultBoolean;
+import com.lhkbob.entreri.property.DoubleProperty;
+import com.lhkbob.entreri.property.DoubleProperty.DefaultDouble;
+import com.lhkbob.entreri.property.ObjectProperty;
+
+public class Animated extends ComponentData<Animated> {
+    private ObjectProperty<SkeletonAnimation> animation;
+
+    @DefaultDouble(0.0)
+    private DoubleProperty playTime;
+
+    @DefaultDouble(1.0)
+    private DoubleProperty timeScale;
+
+    @DefaultBoolean(true)
+    private BooleanProperty loop;
+
+    private Animated() {}
+
+    public Animated setLoopPlayback(boolean loop) {
+        this.loop.set(loop, getIndex());
+        return this;
+    }
+
+    public boolean getLoopPlayback() {
+        return loop.get(getIndex());
+    }
+
+    public Animated setTimeScale(double scale) {
+        timeScale.set(scale, getIndex());
+        return this;
+    }
+
+    public double getTimeScale() {
+        return timeScale.get(getIndex());
+    }
+
+    public Animated setCurrentTime(double time) {
+        playTime.set(time, getIndex());
+        return this;
+    }
+
+    public double getCurrentTime() {
+        return playTime.get(getIndex());
+    }
+
+    public Animated setAnimation(SkeletonAnimation animation) {
+        this.animation.set(animation, getIndex());
+        return this;
+    }
+
+    public SkeletonAnimation getAnimation() {
+        return animation.get(getIndex());
+    }
+}

File ferox-scene/src/main/java/com/ferox/anim/Bone.java

+package com.ferox.anim;
+
+import com.ferox.math.Const;
+import com.ferox.math.Matrix4;
+
+public class Bone {
+    private final Matrix4 relativeBoneTransform;
+    // FIXME should this actually be global? or just relative to the entity's transform
+    // keep it global for now, we can make skinning work around that (on GPU or CPU),
+    // and it works a lot better with setting bones to be physics objects
+    private final Matrix4 globalBoneTransform;
+    private final String name;
+
+    public Bone(String name) {
+        this.name = name;
+        relativeBoneTransform = new Matrix4().setIdentity();
+        globalBoneTransform = new Matrix4();
+    }
+
+    @Const
+    public Matrix4 getRelativeBoneTransform() {
+        return relativeBoneTransform;
+    }
+
+    public void setRelativeBoneTransform(@Const Matrix4 relativeBoneTransform) {
+        this.relativeBoneTransform.set(relativeBoneTransform);
+    }
+
+    @Const
+    public Matrix4 getGlobalBoneTransform() {
+        return globalBoneTransform;
+    }
+
+    public void setGlobalBoneTransform(@Const Matrix4 globalBoneTransform) {
+        this.globalBoneTransform.set(globalBoneTransform);
+    }
+
+    public String getName() {
+        return name;
+    }
+}

File ferox-scene/src/main/java/com/ferox/anim/BoneLink.java

+package com.ferox.anim;
+
+import com.ferox.scene.Transform;
+import com.lhkbob.entreri.ComponentData;
+import com.lhkbob.entreri.Requires;
+import com.lhkbob.entreri.property.ObjectProperty;
+
+@Requires(Transform.class)
+public class BoneLink extends ComponentData<BoneLink> {
+    private ObjectProperty<Bone> linkedBone;
+
+    private BoneLink() {}
+
+    public Bone getLinkedBone() {
+        return linkedBone.get(getIndex());
+    }
+
+    public BoneLink setLinkedBone(Bone bone) {
+        linkedBone.set(bone, getIndex());
+        return this;
+    }
+}

File ferox-scene/src/main/java/com/ferox/anim/BoneLinkTask.java

+package com.ferox.anim;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import com.ferox.scene.Transform;
+import com.lhkbob.entreri.ComponentData;
+import com.lhkbob.entreri.ComponentIterator;
+import com.lhkbob.entreri.EntitySystem;
+import com.lhkbob.entreri.task.Job;
+import com.lhkbob.entreri.task.ParallelAware;
+import com.lhkbob.entreri.task.Task;
+
+public class BoneLinkTask implements Task, ParallelAware {
+    private static final Set<Class<? extends ComponentData<?>>> COMPONENTS;
+    static {
+        Set<Class<? extends ComponentData<?>>> set = new HashSet<Class<? extends ComponentData<?>>>();
+        set.add(BoneLink.class);
+        set.add(Transform.class);
+        COMPONENTS = Collections.unmodifiableSet(set);
+    }
+
+    private BoneLink boneLink;
+    private Transform transform;
+
+    private ComponentIterator iterator;
+
+    @Override
+    public Task process(EntitySystem system, Job job) {
+        while (iterator.next()) {
+            transform.setMatrix(boneLink.getLinkedBone().getGlobalBoneTransform());
+        }
+
+        return null;
+    }
+
+    @Override
+    public void reset(EntitySystem system) {
+        if (boneLink == null) {
+            boneLink = system.createDataInstance(BoneLink.class);
+            transform = system.createDataInstance(Transform.class);
+
+            iterator = new ComponentIterator(system);
+            iterator.addRequired(boneLink);
+            iterator.addRequired(transform);
+        }
+
+        iterator.reset();
+    }
+
+    @Override
+    public Set<Class<? extends ComponentData<?>>> getAccessedComponents() {
+        return COMPONENTS;
+    }
+
+    @Override
+    public boolean isEntitySetModified() {
+        return false;
+    }
+}

File ferox-scene/src/main/java/com/ferox/anim/BoneTransformTask.java

+package com.ferox.anim;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.ferox.math.Matrix4;
+import com.ferox.scene.Transform;
+import com.lhkbob.entreri.ComponentData;
+import com.lhkbob.entreri.ComponentIterator;
+import com.lhkbob.entreri.EntitySystem;
+import com.lhkbob.entreri.task.Job;
+import com.lhkbob.entreri.task.ParallelAware;
+import com.lhkbob.entreri.task.Task;
+
+public class BoneTransformTask implements Task, ParallelAware {
+    private static final Set<Class<? extends ComponentData<?>>> COMPONENTS;
+    static {
+        Set<Class<? extends ComponentData<?>>> set = new HashSet<Class<? extends ComponentData<?>>>();
+        set.add(Skeleton.class);
+        set.add(Transform.class);
+        COMPONENTS = Collections.unmodifiableSet(set);
+    }
+
+    private Skeleton skeleton;
+    private Transform transform;
+
+    private ComponentIterator iterator;
+
+    @Override
+    public Task process(EntitySystem system, Job job) {
+        Matrix4 m = new Matrix4();
+        while (iterator.next()) {
+            Bone root = skeleton.getRootBone();
+            m.mul(transform.getMatrix(), root.getRelativeBoneTransform());
+            root.setGlobalBoneTransform(m);
+
+            updateChildren(root, m);
+        }
+
+        return null;
+    }
+
+    private void updateChildren(Bone parent, Matrix4 store) {
+        List<Bone> children = skeleton.getChildren(parent);
+
+        if (children != null) {
+            Bone child;
+            for (int i = 0; i < children.size(); i++) {
+                child = children.get(i);
+                store.mul(parent.getGlobalBoneTransform(),
+                          child.getRelativeBoneTransform());
+                child.setGlobalBoneTransform(store);
+
+                updateChildren(child, store);
+            }
+        }
+    }
+
+    @Override
+    public void reset(EntitySystem system) {
+        if (skeleton == null) {
+            skeleton = system.createDataInstance(Skeleton.class);
+            transform = system.createDataInstance(Transform.class);
+
+            iterator = new ComponentIterator(system);
+            iterator.addRequired(skeleton);
+            iterator.addRequired(transform);
+        }
+
+        iterator.reset();
+    }
+
+    @Override
+    public Set<Class<? extends ComponentData<?>>> getAccessedComponents() {
+        return COMPONENTS;
+    }
+
+    @Override
+    public boolean isEntitySetModified() {
+        return false;
+    }
+}

File ferox-scene/src/main/java/com/ferox/anim/KeyFrame.java

+package com.ferox.anim;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.ferox.math.Const;
+import com.ferox.math.Matrix4;
+
+public class KeyFrame {
+    private final double keyTime;
+    private final Map<String, Matrix4> boneTransforms;
+
+    private KeyFrame(double time, Map<String, Matrix4> transforms) {
+        keyTime = time;
+        boneTransforms = Collections.unmodifiableMap(transforms);
+    }
+
+    public double getFrameTime() {
+        return keyTime;
+    }
+
+    @Const
+    public Matrix4 getBoneTransform(String name) {
+        return boneTransforms.get(name);
+    }
+
+    public Map<String, Matrix4> getBoneTransforms() {
+        return boneTransforms;
+    }
+
+    public static Builder newKeyFrame(double keyTime) {
+        return new Builder(keyTime);
+    }
+
+    public static class Builder {
+        private final double keyTime;
+        private final Map<String, Matrix4> boneTransforms;
+
+        public Builder(double keyTime) {
+            this.keyTime = keyTime;
+            boneTransforms = new HashMap<String, Matrix4>();
+        }
+
+        public Builder setBone(String name, @Const Matrix4 transform) {
+            boneTransforms.put(name, new Matrix4(transform));
+            return this;
+        }
+
+        public KeyFrame build() {
+            return new KeyFrame(keyTime, new HashMap<String, Matrix4>(boneTransforms));
+        }
+    }
+}

File ferox-scene/src/main/java/com/ferox/anim/Skeleton.java

+package com.ferox.anim;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.ferox.scene.Transform;
+import com.lhkbob.entreri.ComponentData;
+import com.lhkbob.entreri.Requires;
+import com.lhkbob.entreri.property.ObjectProperty;
+
+@Requires(Transform.class)
+public class Skeleton extends ComponentData<Skeleton> {
+    // FIXME for templating to work correctly, this will need a custom
+    // factory that clones properly
+    private ObjectProperty<Map<String, Bone>> bones;
+    private ObjectProperty<Map<Bone, List<Bone>>> parentToChildHierarchy;
+    private ObjectProperty<Map<Bone, Bone>> childToParentHierarchy;
+
+    private ObjectProperty<Bone> root;
+
+    private Skeleton() {}
+
+    public Skeleton addBone(Bone bone) {
+        Bone old = getBoneMap().put(bone.getName(), bone);
+        if (old != null) {
+            removeBone(old);
+        }
+        return this;
+    }
+
+    public Collection<Bone> getBones() {
+        return getBoneMap().values();
+    }
+
+    public Bone getRootBone() {
+        return root.get(getIndex());
+    }
+
+    public Skeleton setRootBone(Bone bone) {
+        if (getBoneMap().get(bone.getName()) != bone) {
+            throw new IllegalArgumentException("Bone is not in skeleton");
+        }
+        root.set(bone, getIndex());
+        return this;
+    }
+
+    public List<Bone> getChildren(Bone bone) {
+        // FIXME read-only
+        return getParentToChildMap().get(bone);
+    }
+
+    public Skeleton removeBone(Bone bone) {
+        Map<String, Bone> boneMap = getBoneMap();
+        Map<Bone, Bone> childToParent = getChildToParentMap();
+        Map<Bone, List<Bone>> parentToChild = getParentToChildMap();
+
+        // perform check to make sure we're not removing a bone
+        // instance with the same name that is replacing this bone
+        if (boneMap.get(bone.getName()) == bone) {
+            boneMap.remove(bone.getName());
+        }
+
+        // remove bone from child-to-parent map
+
+        Bone parent = childToParent.remove(bone);
+        if (parent != null) {
+            // remove child from its parent's list of child bones
+            List<Bone> neighbors = parentToChild.get(parent);
+            neighbors.remove(bone);
+        }
+
+        // remove all of bone's children from the child-to-parent map,
+        // since they no longer have a parent
+        List<Bone> children = parentToChild.remove(bone);
+        if (children != null) {
+            for (int i = 0; i < children.size(); i++) {
+                childToParent.remove(children.get(i));
+            }
+        }
+
+        return this;
+    }
+
+    public Skeleton connect(Bone parent, Bone child) {
+        Map<String, Bone> boneMap = getBoneMap();
+        Map<Bone, Bone> childToParent = getChildToParentMap();
+        Map<Bone, List<Bone>> parentToChild = getParentToChildMap();
+
+        if (boneMap.get(parent.getName()) != parent) {
+            throw new IllegalArgumentException("Parent bone is not in this skeleton");
+        }
+        if (boneMap.get(child.getName()) != child) {
+            throw new IllegalArgumentException("Child bone is not in this skeleton");
+        }
+
+        // disconnect child from its current parent
+        Bone oldParent = childToParent.remove(child);
+        if (oldParent != null) {
+            parentToChild.get(oldParent).remove(child);
+        }
+
+        // attach child to the new parent bone
+        childToParent.put(child, parent);
+        List<Bone> children = parentToChild.get(parent);
+        if (children == null) {
+            children = new ArrayList<Bone>();
+            parentToChild.put(parent, children);
+        }
+        children.add(child);
+
+        return this;
+    }
+
+    public Bone getBone(String name) {
+        return getBoneMap().get(name);
+    }
+
+    private Map<String, Bone> getBoneMap() {
+        Map<String, Bone> boneMap = bones.get(getIndex());
+        if (boneMap == null) {
+            boneMap = new HashMap<String, Bone>();
+            bones.set(boneMap, getIndex());
+        }
+
+        return boneMap;
+    }
+
+    private Map<Bone, Bone> getChildToParentMap() {
+        Map<Bone, Bone> boneMap = childToParentHierarchy.get(getIndex());
+        if (boneMap == null) {
+            boneMap = new HashMap<Bone, Bone>();
+            childToParentHierarchy.set(boneMap, getIndex());
+        }
+
+        return boneMap;
+    }
+
+    private Map<Bone, List<Bone>> getParentToChildMap() {
+        Map<Bone, List<Bone>> boneMap = parentToChildHierarchy.get(getIndex());
+        if (boneMap == null) {
+            boneMap = new HashMap<Bone, List<Bone>>();
+            parentToChildHierarchy.set(boneMap, getIndex());
+        }
+
+        return boneMap;
+    }
+}

File ferox-scene/src/main/java/com/ferox/anim/SkeletonAnimation.java

+package com.ferox.anim;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import com.ferox.math.Matrix3;
+import com.ferox.math.Matrix4;
+import com.ferox.math.Quat4;
+import com.ferox.math.Vector3;
+
+public class SkeletonAnimation {
+    private final List<KeyFrame> frames;
+    private boolean sortDirty;
+
+    public SkeletonAnimation() {
+        frames = new ArrayList<KeyFrame>();
+    }
+
+    public double getAnimationLength() {
+        return (frames.isEmpty() ? 0 : frames.get(frames.size() - 1).getFrameTime());
+    }
+
+    public void addKeyFrame(KeyFrame frame) {
+        frames.add(frame);
+        sortDirty = true;
+    }
+
+    public void removeKeyFrame(KeyFrame frame) {
+        frames.remove(frame);
+    }
+
+    public List<KeyFrame> getKeyFrames() {
+        return Collections.unmodifiableList(frames);
+    }
+
+    public void updateSkeleton(Skeleton skeleton, double animationTime) {
+        if (sortDirty) {
+            Collections.sort(frames, new Comparator<KeyFrame>() {
+                @Override
+                public int compare(KeyFrame o1, KeyFrame o2) {
+                    return Double.compare(o1.getFrameTime(), o2.getFrameTime());
+                }
+            });
+            sortDirty = false;
+        }
+
+        int startFrame = getStartFrame(animationTime);
+
+        Matrix3 upper = new Matrix3();
+        Quat4 aRot = new Quat4();
+        Quat4 bRot = new Quat4();
+        Vector3 aPos = new Vector3();
+        Vector3 bPos = new Vector3();
+
+        for (Bone b : skeleton.getBones()) {
+            int boneStartTarget = getStartFrame(startFrame, b.getName());
+            int boneEndTarget = getEndFrame(startFrame, b.getName());
+
+            double alpha = 0;
+            if (boneStartTarget != boneEndTarget) {
+                alpha = (animationTime - frames.get(boneStartTarget).getFrameTime()) / (frames.get(boneEndTarget)
+                                                                                              .getFrameTime() - frames.get(boneStartTarget)
+                                                                                                                      .getFrameTime());
+            }
+
+            Matrix4 aMat = frames.get(boneStartTarget).getBoneTransform(b.getName());
+            Matrix4 bMat = frames.get(boneEndTarget).getBoneTransform(b.getName());
+
+            if (aMat == null || bMat == null) {
+                continue;
+            }
+
+            // rotation
+            aRot.set(upper.setUpper(aMat));
+            bRot.set(upper.setUpper(bMat));
+
+            aRot.slerp(aRot, bRot, alpha);
+            upper.set(aRot);
+
+            // position
+            aPos.set(aMat.m03, aMat.m13, aMat.m23);
+            bPos.set(bMat.m03, bMat.m13, bMat.m23);
+
+            aPos.scale(1 - alpha).add(bPos.scale(alpha));
+
+            Matrix4 m = b.getRelativeBoneTransform();
+            m.setUpper(upper);
+            m.m03 = aPos.x;
+            m.m13 = aPos.y;
+            m.m23 = aPos.z;
+            b.setRelativeBoneTransform(m);
+        }
+    }
+
+    private int getStartFrame(double time) {
+        // FIXME do a binary search
+        for (int i = 0; i < frames.size() - 1; i++) {
+            if (frames.get(i).getFrameTime() <= time && frames.get(i + 1).getFrameTime() > time) {
+                return i;
+            }
+        }
+        return frames.size() - 1;
+    }
+
+    private int getStartFrame(int bestStartFrame, String bone) {
+        for (int i = bestStartFrame; i > 0; i--) {
+            if (frames.get(i).getBoneTransform(bone) != null) {
+                return i;
+            }
+        }
+
+        return 0;
+    }
+
+    private int getEndFrame(int bestStartFrame, String bone) {
+        for (int i = bestStartFrame + 1; i < frames.size(); i++) {
+            if (frames.get(i).getBoneTransform(bone) != null) {
+                return i;
+            }
+        }
+
+        return frames.size() - 1;
+    }
+}

File ferox-scene/src/main/java/com/ferox/anim/SkeletonAnimationTask.java

+package com.ferox.anim;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import com.lhkbob.entreri.ComponentData;
+import com.lhkbob.entreri.ComponentIterator;
+import com.lhkbob.entreri.EntitySystem;
+import com.lhkbob.entreri.task.ElapsedTimeResult;
+import com.lhkbob.entreri.task.Job;
+import com.lhkbob.entreri.task.ParallelAware;
+import com.lhkbob.entreri.task.Task;
+
+public class SkeletonAnimationTask implements Task, ParallelAware {
+    private static final Set<Class<? extends ComponentData<?>>> COMPONENTS;
+    static {
+        Set<Class<? extends ComponentData<?>>> set = new HashSet<Class<? extends ComponentData<?>>>();
+        set.add(Skeleton.class);
+        set.add(Animated.class);
+        COMPONENTS = Collections.unmodifiableSet(set);
+    }
+
+    private Skeleton skeleton;
+    private Animated animated;
+    private ComponentIterator iterator;
+
+    private double dt;
+
+    public void report(ElapsedTimeResult r) {
+        dt = r.getTimeDelta();
+    }
+
+    @Override
+    public Task process(EntitySystem system, Job job) {
+        while (iterator.next()) {
+            SkeletonAnimation anim = animated.getAnimation();
+            double newTime = animated.getCurrentTime() + dt * animated.getTimeScale();
+            if (newTime > anim.getAnimationLength()) {
+                if (animated.getLoopPlayback()) {
+                    newTime = newTime - anim.getAnimationLength();
+                } else {
+                    newTime = anim.getAnimationLength();
+                }
+            }
+
+            animated.setCurrentTime(newTime);
+            anim.updateSkeleton(skeleton, newTime);
+        }
+
+        return null;
+    }
+
+    @Override
+    public void reset(EntitySystem system) {
+        if (skeleton == null) {
+            skeleton = system.createDataInstance(Skeleton.class);
+            animated = system.createDataInstance(Animated.class);
+
+            iterator = new ComponentIterator(system);
+            iterator.addRequired(skeleton);
+            iterator.addRequired(animated);
+        }
+
+        iterator.reset();
+    }
+
+    @Override
+    public Set<Class<? extends ComponentData<?>>> getAccessedComponents() {
+        return COMPONENTS;
+    }
+
+    @Override
+    public boolean isEntitySetModified() {
+        return false;
+    }
+}