├── .gitignore ├── .travis.yml ├── README.md ├── benchmark └── benchmark.js ├── index.js ├── package.json ├── site ├── character.png ├── index.html ├── main.js ├── styles.css └── tomorrow.css ├── src ├── blend-dual-quaternions.js ├── get-previous-animation-data.js └── skeletal-animation-system.js └── test ├── blend-out-previous-anim.js └── skeletal-animation-system.js /.gitignore: -------------------------------------------------------------------------------- 1 | example-dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '5.0' 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | skeletal-animation-system [![npm version](https://badge.fury.io/js/skeletal-animation-system.svg)](http://badge.fury.io/js/skeletal-animation-system) [![Build Status](https://travis-ci.org/chinedufn/skeletal-animation-system.svg?branch=master)](https://travis-ci.org/chinedufn/skeletal-animation-system) 2 | =============== 3 | 4 | > A standalone, stateless, dual quaternion based skeletal animation system built with interactive applications in mind 5 | 6 | [View live demo](http://chinedufn.github.io/skeletal-animation-system/) 7 | 8 | TODO: Create a demo site instead of just a demo. Embed a demo inside of the demo site 9 | 10 | ## Tutorials 11 | 12 | [WebGL Skeletal Animation Sound Effects Tutorial](http://chinedufn.com/webgl-skeletal-animation-sound-effect-tutorial/) 13 | 14 | [Attaching objects to bones](http://chinedufn.com/attaching-objects-to-bones/) 15 | 16 | [WebGL Skeletal Animation Tutorial](http://chinedufn.com/webgl-skeletal-animation-tutorial) 17 | 18 | ## Background / Initial Motivation 19 | 20 | skeletal-animation-system aims to give the user a flexible module for managing skeletal animations across different 3d models and bone groups. 21 | 22 | `skeletal-animation-system` aims to provide a sane API for starting, stopping and interpolating skeletal animations. 23 | 24 | It supports blending between 25 | your previous and current animation when you switch animations. It also supports splitting your model into different bone groups such as the upper 26 | and lower body, allowing you to, for example, play a walking animation for your legs while playing a punch animation for your upper body. 27 | 28 | `skeletal-animation-system` does not maintain an internal state, but instead lets the modules consumer track things such as the current animation and the current clock time. 29 | 30 | ## I use matrices and not dual quaternions 31 | 32 | The first versions of `skeletal-animation-system` uses matrices instead of dual quaternions. 33 | 34 | The issue there was that [blending matrices can lead to unexpected artifacts](http://chinedufn.com/dual-quaternion-shader-explained/). 35 | 36 | So we switched to dual quaternions and completely dropped support for matrices. 37 | 38 | However, if you use matrices you can still make use of `skeletal-animation-system`. 39 | 40 | 1. [Convert your matrices into dual quaternions](https://github.com/chinedufn/mat4-to-dual-quat) once when you first load your model. 41 | 2. Use `skeletal-animation-system` to determine your pose dual quaternions 42 | 3. [Convert your pose dual quaternions back into matrices before each render](https://github.com/chinedufn/dual-quat-to-mat4) 43 | 4. Use your newly created matrices for skinning 44 | 45 | The 3rd step here means that you're doing some extra work on the CPU, but this hopefully bridges the gap for you until you can move to dual quaternion 46 | based skinning. 47 | 48 | TODO: Example code demonstrating how to incorporate `skeletal-animation-system` into matrix based skinning application 49 | 50 | This API is still experimental and will evolve as we use it and realize the kinks. 51 | 52 | ## To Install 53 | 54 | ```sh 55 | $ npm install --save skeletal-animation-system 56 | ``` 57 | 58 | ## Demo 59 | 60 | To run the demo locally: 61 | 62 | ```sh 63 | $ git clone https://github.com/chinedufn/skeletal-animation-system 64 | $ cd skeletal-animation-system 65 | $ npm install 66 | $ npm run demo 67 | ``` 68 | 69 | Changes to the `demo` and `src` files will now live reload in your browser. 70 | 71 | --- 72 | 73 | [View live demo](http://chinedufn.github.io/skeletal-animation-system/) 74 | 75 | ## Usage 76 | 77 | ```js 78 | var animationSystem = require('skeletal-animation-system') 79 | // Parsed using collada-dae-parser or some other parser 80 | var parsedColladaModel = require('./parsed-collada-model.json') 81 | 82 | // Keyframe data for all joints. 83 | // @see `github.com/chinedufn/blender-actions-to-json` for an example format 84 | var lowerBodyKeyframes = {...} 85 | var upperBodyKey = {...} 86 | 87 | // Convert our joint names into their associated joint index number 88 | // This number comes from collada-dae-parser 89 | // (or your parser of choice) 90 | var upperBodyJointNums = [0, 1, 5, 6, 8] 91 | var lowerBodyJointNums = [2, 3, 4, 7, 9] 92 | 93 | // Our options for animating our model's upper body 94 | var upperBodyOptions = { 95 | currentTime: 28.24, 96 | jointNums: upperBodyJointsNums, 97 | blendFunction: function (dt) { 98 | // Blend animations linearly over 2.5 seconds 99 | return 1 / 2.5 * dt 100 | }, 101 | currentAnimation: { 102 | keyframes: currentAnimKeyframes, 103 | startTime: 25 104 | }, 105 | previousAnimation: { 106 | keyframes: previousAnimKeyframes, 107 | startTime: 24.5 108 | } 109 | } 110 | 111 | // Our options for animating our model's lower body 112 | var lowerBodyOptions = { 113 | currentTime: 28.24, 114 | jointNums: lowerBodyJointNums, 115 | currentAnimation: { 116 | keyframes: currentAnimKeyframes, 117 | startTime: 24.3, 118 | noLoop: true 119 | } 120 | } 121 | 122 | var interpolatedUpperBodyJoints = animationSystem 123 | .interpolateJoints(upperBodyOptions).joints 124 | 125 | var lowerBodyData = animationSystem 126 | .interpolateJoints(lowerBodyOptions) 127 | var interpolatedLowerBodyJoints = lowerBodyData.joints 128 | 129 | console.log(lowerBodyData.currentAnimationInfo) 130 | // => {lowerKeyframeNumber: 5, upperKeyframeNumber: 6} 131 | 132 | // You now have your interpolated upper and lower body dual quaternions (joints). 133 | // You can pass these into any vertex shader that 134 | // works with dual quaternions 135 | 136 | // If you're just getting started and you still need matrices you 137 | // can convert these into matrices using dual-quat-to-mat4 138 | // @see https://github.com/chinedufn/dual-quat-to-mat4 139 | ``` 140 | 141 | ## Expected JSON model format 142 | 143 | TODO: [Link to collada-dae-parser README]() 144 | 145 | ## Benchmark 146 | 147 | ```sh 148 | npm run bench 149 | ``` 150 | 151 | ## TODO: 152 | 153 | - [x] Handle rotation quaternion lerp when dot product is < 0 154 | - [ ] Implement more from the papers linked in `References` section below (whenever we need them) 155 | - [x] Add documentation about how to approach playing a sound effect on a keyframe in your game / simulation / program 156 | - [x] Benchmark 157 | - [ ] Allow consumer to provide the sampling function between keyframes. Currently we sample linearly between all keyframes. Could make use of [chromakode/fcurve](https://github.com/chromakode/fcurve) here 158 | - [ ] Create a new demo site and demo(s) 159 | 160 | ## API 161 | 162 | ### `animationSystem.interpolateJoints(options)` -> `Object` 163 | 164 | #### options 165 | 166 | *Optional* 167 | 168 | Type: `object` 169 | 170 | ```js 171 | // Example overrides 172 | var myOptions = { 173 | // TODO: 174 | } 175 | interpolatedJoints = animationSystem.interpolateJoints(myOptions) 176 | ``` 177 | 178 | ##### currentTime 179 | 180 | Type: `Number` 181 | 182 | Default: `0` 183 | 184 | The current number of seconds elapsed. If you have an animation an loop, 185 | this will typically be the sum of all of your loops time deltas 186 | 187 | ```js 188 | // Example of tracking current time 189 | var currentTime = 0 190 | function animationLoop (dt) { 191 | currentTime += dt 192 | } 193 | ``` 194 | 195 | ##### keyframes 196 | 197 | Type: `Object` 198 | 199 | Default: `{}` 200 | 201 | TODO: Link to collada-dae-parser README on keyframes for more info, but also put an example here 202 | 203 | ##### jointNums 204 | 205 | Type: `Array` 206 | 207 | An array of joint indices that you would like to interpolate. 208 | 209 | Say your model has 4 joints. To interpolate the entire model you would pass in [0, 1, 2, 3]. 210 | To only interpolate two of the joints you might pass in [0, 2], or any desired combination. 211 | 212 | These joint indices are based on the order of the joints in your `keyframes` 213 | 214 | ##### blendFunction 215 | 216 | Type: `Function` 217 | 218 | Default: `Blend linearly over 0.2 seconds` 219 | 220 | A function that accepts a time elapsed in seconds 221 | and returns a value between `0` and `1`. 222 | 223 | This returned value represents the weight of the new animation. 224 | 225 | ```js 226 | function myBlendFunction (dt) { 227 | // Blend the old animation into the new one linearly over 5 seconds 228 | return 0.2 * dt) 229 | } 230 | ``` 231 | 232 | ##### currentAnimation 233 | 234 | Type: `Object` 235 | 236 | An object containing parameters for the current animation 237 | 238 | If you supply a previous animation your current animation will be 239 | blended in using your `blendFunction` 240 | 241 | ```js 242 | var currentAnimation = { 243 | keyframes: {0: [..], 1.66666: [...]} 244 | startTime: 10 245 | } 246 | ``` 247 | 248 | ###### currentAnimation.keyframes 249 | 250 | Type: `Array` 251 | 252 | ```js 253 | { 254 | "0": [ 255 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 256 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 257 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] 258 | ], 259 | "1.33333": [ 260 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 2, 1], 261 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 2, 1], 262 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 5, 1] 263 | ] 264 | } 265 | ``` 266 | 267 | Pose matrices for each joint in the model, organized by the animation time (`0` and `1.33333` are seconds) 268 | 269 | ###### currentAnimation.startTime 270 | 271 | Type: `Number` 272 | 273 | The time in seconds that your current animation was initiated. This gets compared with 274 | the `currentTime` in order to interpolate your joint data appropriately. 275 | 276 | ###### currentAnimation.noLoop 277 | 278 | Type: `Boolean` 279 | 280 | Whether or not your animation should loop. For example, let's say you are 13 seconds into a 4 second animation. 281 | 282 | If `noLoop === true` then you will be playing the frame at the 4th second. 283 | 284 | If `noLoop === false` then you will be playing the frame at the 1st second. 285 | 286 | ##### previousAnimation 287 | 288 | An object containing parameters for the previous animations. 289 | Your previous animation gets blended out using your `blendFunction` 290 | while your current animation gets blended in. 291 | 292 | Type: `Object` 293 | 294 | ###### previousAnimation.keyframes 295 | 296 | Type: `Array` 297 | 298 | ```js 299 | { 300 | "0": [ 301 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 302 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], 303 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] 304 | ], 305 | "1.33333": [ 306 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 2, 1], 307 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 2, 1], 308 | [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 5, 1] 309 | ] 310 | } 311 | ``` 312 | 313 | Pose matrices for each joint in the model, organized by the animation time (`0` and `1.33333` are seconds) 314 | 315 | ###### previousAnimation.startTime 316 | 317 | Type: `Number` 318 | 319 | The time in seconds that your previous animation was initiated. 320 | This is used in order to blend in the current animation. 321 | 322 | ## Returned data 323 | 324 | ```js 325 | // Example 326 | { 327 | joints: [...], 328 | currentAnimationInfo: { 329 | lowerKeyframeNumber: 0, 330 | upperKeyframeNumber:: 1 331 | } 332 | } 333 | ``` 334 | 335 | `currentAnimationInfo` is the lower and upper keyframe time bounds of the current animation. 336 | If you have three keyframes at 1 8 and 19 seconds and you are currently 12 seconds into your animation then your lower keyframe is 1 (8) and your upper keyframe is 2 (19). 337 | 338 | ## See Also 339 | 340 | - [collada-dae-parser](https://github.com/chinedufn/collada-dae-parser) 341 | - [blender-iks-to-fks](https://github.com/chinedufn/blender-iks-to-fks) 342 | - [blender-actions-to-json](https://github.com/chinedufn/blender-actions-to-json) 343 | 344 | ## References 345 | 346 | - [Anatomy of a skeletal animation system part 1](http://blog.demofox.org/2012/09/21/anatomy-of-a-skeletal-animation-system-part-1/), [part 2](http://blog.demofox.org/2012/09/21/anatomy-of-a-skeletal-animation-system-part-2/) and [part 3](http://blog.demofox.org/2012/09/21/anatomy-of-a-skeletal-animation-system-part-3/) 347 | - [Dual-Quaternions - From Classical Mechanics to Computer Graphics and Beyond](http://www.xbdev.net/misc_demos/demos/dual_quaternions_beyond/paper.pdf) 348 | - This taught us to negate one of the dual quaternions if the dot product of the rotation quaternions was less than 0 349 | 350 | ## License 351 | 352 | MIT 353 | -------------------------------------------------------------------------------- /benchmark/benchmark.js: -------------------------------------------------------------------------------- 1 | var bench = require('nanobench') 2 | var animationSystem = require('../') 3 | 4 | /** 5 | * TODO: Threw in a benchmark, but need to be more deliberate about what we're benchmarking 6 | * and how to make things faster 7 | */ 8 | bench('Interpolate 20 bones one million times', function (b) { 9 | b.start() 10 | 11 | for (var i = 0; i < 1000000; i++) { 12 | animationSystem.interpolateJoints({ 13 | currentTime: i, 14 | keyframes: { 15 | 0: [ 16 | [0, 0, 0, 0, 0, 0, 0, 0], 17 | [0, 0, 0, 0, 0, 0, 0, 0], 18 | [0, 0, 0, 0, 0, 0, 0, 0], 19 | [0, 0, 0, 0, 0, 0, 0, 0], 20 | [0, 0, 0, 0, 0, 0, 0, 0], 21 | [0, 0, 0, 0, 0, 0, 0, 0], 22 | [0, 0, 0, 0, 0, 0, 0, 0], 23 | [0, 0, 0, 0, 0, 0, 0, 0], 24 | [0, 0, 0, 0, 0, 0, 0, 0], 25 | [0, 0, 0, 0, 0, 0, 0, 0] 26 | ], 27 | 100: [ 28 | [1, 1, 1, 1, 1, 1, 1, 1], 29 | [1, 1, 1, 1, 1, 1, 1, 1], 30 | [1, 1, 1, 1, 1, 1, 1, 1], 31 | [1, 1, 1, 1, 1, 1, 1, 1], 32 | [1, 1, 1, 1, 1, 1, 1, 1], 33 | [1, 1, 1, 1, 1, 1, 1, 1], 34 | [1, 1, 1, 1, 1, 1, 1, 1], 35 | [1, 1, 1, 1, 1, 1, 1, 1], 36 | [1, 1, 1, 1, 1, 1, 1, 1], 37 | [1, 1, 1, 1, 1, 1, 1, 1] 38 | ] 39 | }, 40 | jointNums: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 41 | currentAnimation: { 42 | range: [0, 1], 43 | startTime: 0 44 | } 45 | }) 46 | } 47 | 48 | b.end() 49 | }) 50 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/skeletal-animation-system') 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skeletal-animation-system", 3 | "version": "0.8.1", 4 | "description": "A standalone, stateless, dual quaternion based skeletal animation system built with interactive applications in mind", 5 | "main": "index.js", 6 | "scripts": { 7 | "bench": "node benchmark/benchmark.js", 8 | "demo": "budo --open --live --host=localhost --dir=demo/webgl/asset demo/webgl/browser-entry.js", 9 | "regl-demo": "budo --open --live --host=localhost --dir=demo/webgl/asset demo/regl/regl.js", 10 | "deploy": "mkdirp example-dist && npm run dist:index:html && browserify demo/browser.js > example-dist/bundle.js && cp ./demo/asset/* ./example-dist/ && gh-pages -d example-dist", 11 | "dist:index:html": "echo ' 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 40 |
41 |

Skeletal-Animation-System

42 |

A standalone, stateless, dual quaternion based skeletal animation system built with interactive applications in mind.

43 | 44 |
47 |
48 | 49 |
50 | placeholder 51 |
52 | 65 |
66 |
67 | 68 |
69 |
70 |
71 |
72 |

Introduction

73 | 74 | 75 |

skeletal-animation-system aims to give the user a flexible module for managing skeletal animations across different 3d models and bone groups.

76 | 77 |

skeletal-animation-system aims to provide a sane API for starting, stopping and interpolating skeletal animations.

78 | 79 |

It supports blending between 80 | your previous and current animation when you switch animations. It also supports splitting your model into different bone groups such as the upper 81 | and lower body, allowing you to, for example, play a walking animation for your legs while playing a punch animation for your upper body.

82 | 83 |

skeletal-animation-system does not maintain an internal state, but instead lets the modules consumer track things such as the current animation and the current clock time.

84 | 85 |

I use matrices and not dual quaternions

86 | 87 |

The first versions of skeletal-animation-system uses matrices instead of dual quaternions.

88 | 89 |

The issue there was that blending matrices can lead to unexpected artifacts.

90 | 91 |

So we switched to dual quaternions and completely dropped support for matrices.

92 | 93 |

However, if you use matrices you can still make use of skeletal-animation-system.

94 | 95 |
    96 |
  1. Convert your matrices into dual quaternions once when you first load your model.
  2. 97 |
  3. Use skeletal-animation-system to determine your pose dual quaternions
  4. 98 |
  5. Convert your pose dual quaternions back into matrices before each render
  6. 99 |
  7. Use your newly created matrices for skinning
  8. 100 |
101 | 102 |

The 3rd step here means that you're doing some extra work on the CPU, but this hopefully bridges the gap for you until you can move to dual quaternion 103 | based skinning.

104 | 105 |

This API is still experimental and will evolve as we use it and realize the kinks.

106 | 107 |

To Install

108 | 109 |
$ npm install --save skeletal-animation-system
110 | 111 |

Usage

112 | 113 |
var animationSystem = require('skeletal-animation-system')
114 | // Parsed using collada-dae-parser or some other parser
115 | var parsedColladaModel = require('./parsed-collada-model.json')
116 | 
117 | // Keyframe data for all joints.
118 | // @see `github.com/chinedufn/blender-actions-to-json` for an example format
119 | var lowerBodyKeyframes = {...}
120 | var upperBodyKey = {...}
121 | 
122 | // Convert our joint names into their associated joint index number
123 | // This number comes from collada-dae-parser
124 | // (or your parser of choice)
125 | var upperBodyJointNums = [0, 1, 5, 6, 8]
126 | var lowerBodyJointNums = [2, 3, 4, 7, 9]
127 | 
128 | // Our options for animating our model's upper body
129 | var upperBodyOptions = {
130 |   currentTime: 28.24,
131 |   jointNums: upperBodyJointsNums,
132 |   blendFunction: function (dt) {
133 |     // Blend animations linearly over 2.5 seconds
134 |     return 1 / 2.5 * dt
135 |   },
136 |   currentAnimation: {
137 |     keyframes: currentAnimKeyframes,
138 |     startTime: 25
139 |   },
140 |   previousAnimation: {
141 |     keyframes: previousAnimKeyframes,
142 |     startTime: 24.5
143 |   }
144 | }
145 | 
146 | // Our options for animating our model's lower body
147 | var lowerBodyOptions = {
148 |   currentTime: 28.24,
149 |   jointNums: lowerBodyJointNums,
150 |   currentAnimation: {
151 |     keyframes: currentAnimKeyframes,
152 |     startTime: 24.3,
153 |     noLoop: true
154 |   }
155 | }
156 | 
157 | var interpolatedUpperBodyJoints = animationSystem
158 | .interpolateJoints(upperBodyOptions).joints
159 | 
160 | var lowerBodyData = animationSystem
161 | .interpolateJoints(lowerBodyOptions)
162 | var interpolatedLowerBodyJoints = lowerBodyData.joints
163 | 
164 | console.log(lowerBodyData.currentAnimationInfo)
165 | // => {lowerKeyframeNumber: 5, upperKeyframeNumber: 6}
166 | 
167 | // You now have your interpolated upper and lower body dual quaternions (joints).
168 | // You can pass these into any vertex shader that
169 | // works with dual quaternions
170 | 
171 | // If you're just getting started and you still need matrices you
172 | // can convert these into matrices using dual-quat-to-mat4
173 | //  @see https://github.com/chinedufn/dual-quat-to-mat4
174 | 175 |
176 |

Tutorials

177 | 178 |

WebGL Skeletal Animation Sound Effects Tutorial

179 | 180 |

Attaching objects to bones

181 | 182 |

WebGL Skeletal Animation Tutorial

183 | 184 |
185 |

API

186 | 187 |

animationSystem.interpolateJoints( options ) -> Object

188 | 189 |

options

190 | 191 |

Optional

192 | 193 |

Type: object

194 | 195 |
// Example overrides
196 | var myOptions = {
197 |   // TODO:
198 | }
199 | interpolatedJoints = animationSystem.interpolateJoints(myOptions)
200 | 201 |

currentTime

202 | 203 |

Type: Number

204 | 205 |

Default: 0

206 | 207 |

The current number of seconds elapsed. If you have an animation an loop, 208 | this will typically be the sum of all of your loops time deltas

209 | 210 |
// Example of tracking current time
211 | var currentTime = 0
212 | function animationLoop (dt) {
213 |   currentTime += dt
214 | }
215 | 216 |

keyframes

217 | 218 |

Type: Object

219 | 220 |

Default: {}

221 | 222 |

jointNums

223 | 224 |

Type: Array

225 | 226 |

An array of joint indices that you would like to interpolate.

227 | 228 |

Say your model has 4 joints. To interpolate the entire model you would pass in [0, 1, 2, 3]. 229 | To only interpolate two of the joints you might pass in [0, 2], or any desired combination.

230 | 231 |

These joint indices are based on the order of the joints in your keyframes

232 | 233 |

blendFunction

234 | 235 |

Type: Function

236 | 237 |

Default: Blend linearly over 0.2 seconds

238 | 239 |

A function that accepts a time elapsed in seconds 240 | and returns a value between 0 and 1.

241 | 242 |

This returned value represents the weight of the new animation.

243 | 244 |
function myBlendFunction (dt) {
245 |   // Blend the old animation into the new one linearly over 5 seconds
246 |   return 0.2 * dt)
247 | }
248 | 249 |

currentAnimation

250 | 251 |

Type: Object

252 | 253 |

An object containing parameters for the current animation

254 | 255 |

If you supply a previous animation your current animation will be 256 | blended in using your blendFunction

257 | 258 |
var currentAnimation = {
259 |   keyframes: {0: [..], 1.66666: [...]}
260 |   startTime: 10
261 | }
262 | 263 |
currentAnimation.keyframes
264 | 265 |

Type: Array

266 | 267 |
{
268 |   "0": [
269 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
270 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
271 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
272 |   ],
273 |   "1.33333": [
274 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 2, 1],
275 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 2, 1],
276 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 5, 1]
277 |   ]
278 | }
279 | 280 |

Pose matrices for each joint in the model, organized by the animation time (0 and 1.33333 are seconds)

281 | 282 |
currentAnimation.startTime
283 | 284 |

Type: Number

285 | 286 |

The time in seconds that your current animation was initiated. This gets compared with 287 | the currentTime in order to interpolate your joint data appropriately.

288 | 289 |
currentAnimation.noLoop
290 | 291 |

Type: Boolean

292 | 293 |

Whether or not your animation should loop. For example, let's say you are 13 seconds into a 4 second animation.

294 | 295 |

If noLoop === true then you will be playing the frame at the 4th second.

296 | 297 |

If noLoop === false then you will be playing the frame at the 1st second.

298 | 299 |

previousAnimation

300 | 301 |

An object containing parameters for the previous animations. 302 | Your previous animation gets blended out using your blendFunction 303 | while your current animation gets blended in.

304 | 305 |

Type: Object

306 | 307 |
previousAnimation.keyframes
308 | 309 |

Type: Array

310 | 311 |
{
312 |   "0": [
313 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
314 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
315 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
316 |   ],
317 |   "1.33333": [
318 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 2, 1],
319 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 2, 1],
320 |     [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 5, 1]
321 |   ]
322 | }
323 | 324 |

Pose matrices for each joint in the model, organized by the animation time (0 and 1.33333 are seconds)

325 | 326 |
previousAnimation.startTime
327 | 328 |

Type: Number

329 | 330 |

The time in seconds that your previous animation was initiated. 331 | This is used in order to blend in the current animation.

332 | 333 |

Returned data

334 | 335 |
// Example
336 | {
337 |   joints: [...],
338 |   currentAnimationInfo: {
339 |     lowerKeyframeNumber: 0,
340 |     upperKeyframeNumber:: 1
341 |   }
342 | }
343 | 344 |

See Also

345 | 346 | 351 | 352 |

References

353 | 354 | 358 | 359 |

License

360 | 361 |

MIT

362 |
363 |
364 |
365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | -------------------------------------------------------------------------------- /site/main.js: -------------------------------------------------------------------------------- 1 | window.onload = function () { 2 | var mobile = false 3 | 4 | var mask 5 | if (navigator.userAgent.match(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i)) { 6 | mask = document.getElementById('mask') 7 | mask.parentNode.removeChild(mask) 8 | document.getElementById('lines-dots').style.fill = '#fafafa' 9 | mobile = true 10 | } else { 11 | mask = document.getElementById('mask') 12 | mask.setAttribute('width', window.innerWidth * 2) 13 | mask.setAttribute('height', window.innerHeight * 2) 14 | mask.setAttribute('x', -window.innerWidth) 15 | mask.setAttribute('y', -window.innerHeight) 16 | document.onmousemove = function (evt) { 17 | mask.setAttribute('x', evt.clientX - mask.getAttribute('width') / 2) 18 | mask.setAttribute('y', evt.clientY - mask.getAttribute('height') / 2) 19 | } 20 | } 21 | 22 | document.onscroll = function () { 23 | var st = document.body.scrollTop || document.documentElement.scrollTop 24 | if (st > 100) { 25 | document.getElementsByTagName('nav')[0].style.background = '#FFF' 26 | } else if (st < 100 && 27 | window.innerWidth > 763) { 28 | document.getElementsByTagName('nav')[0].style.background = 'transparent' 29 | } 30 | } 31 | 32 | var demoBtn = document.getElementById('demo-btn') 33 | window.anime({ 34 | targets: '#demo-btn svg', 35 | top: '+=5', 36 | direction: 'alternate', 37 | loop: true, 38 | easing: 'easeInOutSine', 39 | duration: 400 40 | }) 41 | 42 | demoBtn.onclick = function () { 43 | scrollTo(document.getElementById('demo-btn').offsetTop) 44 | } 45 | 46 | document.getElementById('tuts-a').onclick = function () { 47 | scrollTo(document.getElementById('tuts').offsetTop) 48 | } 49 | 50 | document.getElementById('api-a').onclick = function () { 51 | scrollTo(document.getElementById('ap').offsetTop) 52 | } 53 | 54 | document.getElementById('abt-a').onclick = function () { 55 | scrollTo(document.getElementById('abt').offsetTop) 56 | } 57 | 58 | var demoBtns = document.getElementsByClassName('lbl') 59 | for (var i = 0; i < demoBtns.length; ++i) { 60 | demoBtns[i].anime = btnAnimation(demoBtns[i]) 61 | if (!mobile) { 62 | demoBtns[i].onmouseenter = function () { 63 | this.anime.out.pause() 64 | this.anime.in.restart() 65 | } 66 | demoBtns[i].onmouseleave = function () { 67 | this.anime.in.pause() 68 | this.anime.out.pause() 69 | this.anime.out.restart() 70 | } 71 | demoBtns[i].onclick = function () { 72 | } 73 | } else { 74 | demoBtns[i].onclick = function () { 75 | this.anime.in.pause() 76 | this.anime.click.restart() 77 | } 78 | } 79 | } 80 | 81 | var gsBtn = document.getElementById('get-started-btn') 82 | gsBtn.anime = btnAnimation(gsBtn) 83 | 84 | if (!mobile) { 85 | gsBtn.onmouseenter = function () { 86 | gsBtn.anime.out.pause() 87 | gsBtn.anime.in.restart() 88 | } 89 | 90 | gsBtn.onmouseleave = function () { 91 | gsBtn.anime.in.pause() 92 | gsBtn.anime.out.restart() 93 | } 94 | } 95 | 96 | gsBtn.onclick = function () { 97 | gsBtn.anime.in.pause() 98 | gsBtn.anime.click.restart() 99 | scrollTo(document.getElementById('abt').offsetTop) 100 | } 101 | } 102 | 103 | function btnAnimation (target) { 104 | var ain = window.anime({ 105 | targets: target, 106 | scale: 1.05, 107 | duration: 1500 108 | }) 109 | ain.pause() 110 | var cl = window.anime({ 111 | targets: target, 112 | scale: 0.9, 113 | direction: 'alternate', 114 | ease: 'outCirc', 115 | duration: 50 116 | }) 117 | cl.pause() 118 | var aout = window.anime({ 119 | targets: target, 120 | scale: [1.05, 1] 121 | }) 122 | 123 | return {in: ain, out: aout, click: cl} 124 | } 125 | 126 | function scrollTo (target) { 127 | // BS scrollTop check for browser compatibility 128 | var doc 129 | document.documentElement.scrollTop += 1 130 | if (document.documentElement.scrollTop !== 0) doc = document.documentElement 131 | else doc = document.body 132 | window.anime({ 133 | targets: doc, 134 | scrollTop: target, 135 | easing: 'easeInOutCubic', 136 | duration: 500 137 | }) 138 | } 139 | -------------------------------------------------------------------------------- /site/styles.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Roboto:100'); 2 | 3 | html, body { 4 | font-family: Helvetica, sans-serif; 5 | margin: 0; 6 | padding: 0; 7 | width: 100%; 8 | height: 100%; 9 | } 10 | 11 | #svg { 12 | position: fixed; 13 | top: 0; 14 | z-index: -1; 15 | background: #FFF; 16 | } 17 | 18 | #front { 19 | width: 100vw; 20 | height: 100vh; 21 | } 22 | 23 | #lines-dots { 24 | -webkit-transform: scale(0.8) translate(65px, -100px); 25 | transform: scale(0.8) translate(65px, -100px); 26 | fill: #EFEFEF; 27 | } 28 | 29 | nav { 30 | position: fixed; 31 | z-index: 1000; 32 | width: 100%; 33 | } 34 | 35 | nav ul { 36 | list-style: none; 37 | display: -webkit-box; 38 | display: -ms-flexbox; 39 | display: flex; 40 | -webkit-box-pack: end; 41 | -ms-flex-pack: end; 42 | justify-content: flex-end; 43 | padding: 15px 0; 44 | margin: 0; 45 | } 46 | 47 | nav li { 48 | font-size: 1.1em; 49 | font-weight: 300; 50 | color: #999; 51 | margin: 0 15px 0 10px; 52 | } 53 | 54 | a { 55 | text-decoration: none; 56 | color: #111; 57 | } 58 | 59 | a:hover { 60 | color: #FF659B; 61 | } 62 | 63 | a:hover .mark { 64 | fill: #FF659B; 65 | } 66 | 67 | #hero { 68 | width: 47vw; 69 | margin: 0; 70 | padding: 25vh 0 0 11vw; 71 | display: block; 72 | float: left; 73 | } 74 | 75 | h1 { 76 | font-family: 'Roboto', sans-serif; 77 | margin: 0; 78 | font-weight: 100; 79 | font-size: 3.2em; 80 | } 81 | 82 | h2 { 83 | margin: 10px 0; 84 | width: 87%; 85 | font-weight: 300; 86 | font-size: 1em; 87 | font-style: italic; 88 | } 89 | button, label { 90 | font-weight: bold; 91 | border: none; 92 | letter-spacing: 0.5px; 93 | cursor: pointer; 94 | outline: none; 95 | display: block; 96 | } 97 | 98 | #get-started-btn { 99 | width: 190px; 100 | height: 50px; 101 | margin: 40px auto; 102 | font-size: 1.3em; 103 | -webkit-box-shadow: 0px 3px 5px #999; 104 | box-shadow: 0px 3px 5px #999; 105 | border-radius: 6px; 106 | background-color: #FF659B; 107 | color: white; 108 | } 109 | 110 | 111 | #demo-btn { 112 | font-size: 1em; 113 | color: #FF659B; 114 | position: absolute; 115 | margin: 0 auto; 116 | text-align: center; 117 | left: 0; 118 | right: 0; 119 | width: 63px; 120 | bottom: 5vh; 121 | display: none; 122 | background: transparent; 123 | } 124 | 125 | #demo-btn svg { 126 | width: 50px; 127 | position: absolute; 128 | left: 0; 129 | right: 0; 130 | margin: 0 auto; 131 | top: 25px; 132 | } 133 | 134 | 135 | #demo { 136 | width: 30vw; 137 | display: block; 138 | float:left; 139 | padding: 15vh 12vw 0 0; 140 | } 141 | 142 | #demo div img { 143 | width: 140px; 144 | display: block; 145 | margin: 0 auto; 146 | position: relative; 147 | top: 20px; 148 | } 149 | 150 | #canvas { 151 | width: 320px; 152 | height: 350px; 153 | } 154 | 155 | #demo-ctrls { 156 | margin: 0; 157 | padding: 0; 158 | width: 120px; 159 | height: 112px; 160 | list-style: none; 161 | 162 | display: -webkit-box; 163 | 164 | display: -ms-flexbox; 165 | 166 | display: flex; 167 | text-align: center; 168 | -webkit-box-pack: justify; 169 | -ms-flex-pack: justify; 170 | justify-content: space-between; 171 | padding: 0 100px; 172 | } 173 | 174 | 175 | #demo-ctrls input { 176 | display: none; 177 | position: absolute; 178 | top: 0; 179 | left: 0; 180 | } 181 | 182 | #demo-ctrls label { 183 | cursor: pointer; 184 | border: 2px solid #111; 185 | width: 50px; 186 | height: 50px; 187 | border-radius: 4px; 188 | cursor: pointer; 189 | } 190 | 191 | #walk-btn:checked ~ #walk-lbl { 192 | border-color: #FF659B; 193 | } 194 | 195 | #wave-btn:checked ~ #wave-lbl { 196 | border-color: #FF659B; 197 | } 198 | 199 | input[type="checkbox"]:checked ~ label svg path { 200 | fill: #FF659B; 201 | } 202 | 203 | #walk-icon { 204 | position: relative; 205 | margin: 7px 0 0 0; 206 | width: 20px; 207 | } 208 | 209 | #wave-icon { 210 | position: relative; 211 | margin: 10px 0 0 3px; 212 | width: 27px; 213 | } 214 | 215 | #content { 216 | width: 70vw; 217 | margin: 0 auto; 218 | } 219 | 220 | #abt { 221 | height: 4px; 222 | } 223 | 224 | h3 { 225 | font-weight: 300; 226 | font-size: 2em; 227 | margin: 90px auto 20px auto; 228 | padding-bottom: 10px; 229 | border-bottom: 1px solid #ccc; 230 | } 231 | p, ol { 232 | font-weight: 300; 233 | } 234 | 235 | code { 236 | border-radius: 9px; 237 | margin: 0 3px; 238 | } 239 | 240 | #content ul { 241 | list-style: circle; 242 | margin: 0; 243 | padding-left: 2%; 244 | width: 100%; 245 | display: -webkit-box; 246 | display: -ms-flexbox; 247 | display: flex; 248 | -webkit-box-orient: vertical; 249 | -webkit-box-direction: normal; 250 | -ms-flex-flow: column; 251 | flex-flow: column; 252 | -webkit-box-align: start; 253 | -ms-flex-align: start; 254 | align-items: flex-start; 255 | -webkit-box-pack:start; 256 | -ms-flex-pack:start; 257 | justify-content:flex-start; 258 | -ms-flex-line-pack: start; 259 | align-content: flex-start; 260 | } 261 | 262 | #content ol li { 263 | margin: 5px; 264 | } 265 | 266 | #content ul li { 267 | margin: 15px 0; 268 | border-bottom: 1px dotted #111; 269 | padding-bottom: 8px; 270 | } 271 | 272 | #content h5 { 273 | font-style: italic; 274 | } 275 | 276 | #content a { 277 | color: #FF659B; 278 | 279 | } 280 | 281 | #content a:hover { 282 | text-decoration: underline; 283 | } 284 | 285 | #toc_6 { 286 | margin-top:20px; 287 | width: 100%; 288 | font-size: 2em; 289 | border:none; 290 | } 291 | 292 | /* ============= */ 293 | /* MEDIA QUERIES */ 294 | /* ============= */ 295 | @media only screen and (max-width: 750px) { 296 | #toc_6 { 297 | font-size: 1.5em; 298 | } 299 | } 300 | @media only screen and (max-width: 570px) { 301 | #toc_6 { 302 | font-size: 1em; 303 | } 304 | } 305 | @media only screen and (max-width: 380px) { 306 | #toc_6 { 307 | font-size: 0.85em; 308 | } 309 | } 310 | @media only screen and (max-width: 763px) { 311 | #hero { 312 | padding: 12vh 3% 0 3%; 313 | width: 94%; 314 | height: 88vh; 315 | text-align: center; 316 | } 317 | #demo { 318 | margin: 0; 319 | padding: 0; 320 | width: 100%; 321 | height: 93vh; 322 | } 323 | #canvas, h1, #demo-ctrls { 324 | margin: 0 auto; 325 | } 326 | h2 { 327 | margin: 10px auto; 328 | } 329 | nav { 330 | background: #fff; 331 | } 332 | nav ul { 333 | -ms-flex-pack: distribute; 334 | justify-content: space-around; 335 | } 336 | nav li { 337 | margin: 0; 338 | } 339 | #shim { 340 | height: 75vh; 341 | } 342 | #demo-btn { 343 | display: block; 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /site/tomorrow.css: -------------------------------------------------------------------------------- 1 | /* http://jmblog.github.com/color-themes-for-google-code-highlightjs */ 2 | 3 | /* Tomorrow Comment */ 4 | .hljs-comment, 5 | .hljs-quote { 6 | color: #8e908c; 7 | } 8 | 9 | /* Tomorrow Red */ 10 | .hljs-variable, 11 | .hljs-template-variable, 12 | .hljs-tag, 13 | .hljs-name, 14 | .hljs-selector-id, 15 | .hljs-selector-class, 16 | .hljs-regexp, 17 | .hljs-deletion { 18 | color: #c82829; 19 | } 20 | 21 | /* Tomorrow Orange */ 22 | .hljs-number, 23 | .hljs-built_in, 24 | .hljs-builtin-name, 25 | .hljs-literal, 26 | .hljs-type, 27 | .hljs-params, 28 | .hljs-meta, 29 | .hljs-link { 30 | color: #f5871f; 31 | } 32 | 33 | /* Tomorrow Yellow */ 34 | .hljs-attribute { 35 | color: #eab700; 36 | } 37 | 38 | /* Tomorrow Green */ 39 | .hljs-string, 40 | .hljs-symbol, 41 | .hljs-bullet, 42 | .hljs-addition { 43 | color: #718c00; 44 | } 45 | 46 | /* Tomorrow Blue */ 47 | .hljs-title, 48 | .hljs-section { 49 | color: #4271ae; 50 | } 51 | 52 | /* Tomorrow Purple */ 53 | .hljs-keyword, 54 | .hljs-selector-tag { 55 | color: #8959a8; 56 | } 57 | 58 | .hljs { 59 | display: block; 60 | overflow-x: auto; 61 | background: rgb(249, 249, 249); 62 | color: #4d4d4c; 63 | padding: 2em; 64 | } 65 | 66 | .hljs-emphasis { 67 | font-style: italic; 68 | } 69 | 70 | .hljs-strong { 71 | font-weight: bold; 72 | } 73 | -------------------------------------------------------------------------------- /src/blend-dual-quaternions.js: -------------------------------------------------------------------------------- 1 | // TODO: Pull this out into it's standalone own open source repo 2 | module.exports = blendDualQuaternions 3 | 4 | // Blend between two dual quaternions using the shortest path. 5 | // 6 | // startDualQuat -> your first dual quaternion that you are blending away from 7 | // endDualQuat -> your second dual quaternion that you are blending towards 8 | // blendValue -> Number between 0 and 1. This is the blend fraction 9 | // 0 means startDualQuat, 0.5 means halfway between, 1 means endDualQuat 10 | // etc... 11 | // 12 | // NOTE: We sacrifice some readability for performance, but try to make up for this 13 | // with commenting 14 | function blendDualQuaternions (outputArray, startDualQuat, endDualQuat, blendValue) { 15 | // Get the dot product between start and end rotation quaternions 16 | // If it's negative we need to negate one of the quaternions in order 17 | // to ensure the shortest path interpolation 18 | // see this paper -> http://www.xbdev.net/misc_demos/demos/dual_quaternions_beyond/paper.pdf 19 | // 20 | // NOTE: We pass in the entire dual quaternion, but we're only using the first four elements 21 | // for our dot product. This is a perf optimization to avoid needing to create a new array 22 | if (dotProduct(startDualQuat, endDualQuat) < 0) { 23 | lerpNegatedDualQuaternions(outputArray, startDualQuat, endDualQuat, blendValue) 24 | } else { 25 | lerpDualQuaternions(outputArray, startDualQuat, endDualQuat, blendValue) 26 | } 27 | 28 | return outputArray 29 | } 30 | 31 | /** 32 | * These functions are taken and repurposed from stackgl/gl-vec4 33 | * see: https://github.com/stackgl/gl-vec4/blob/23449f51b38fd8cb543ccf585a8bca0009a8420b/lerp.js 34 | * 35 | * TODO: See if in-lining these functions measurably increases performance 36 | */ 37 | 38 | /** 39 | * Interpolate between to dual quaternions 40 | */ 41 | function lerpDualQuaternions (out, a, b, t) { 42 | out[0] = a[0] + t * (b[0] - a[0]) 43 | out[1] = a[1] + t * (b[1] - a[1]) 44 | out[2] = a[2] + t * (b[2] - a[2]) 45 | out[3] = a[3] + t * (b[3] - a[3]) 46 | 47 | out[4] = a[4] + t * (b[4] - a[4]) 48 | out[5] = a[5] + t * (b[5] - a[5]) 49 | out[6] = a[6] + t * (b[6] - a[6]) 50 | out[7] = a[7] + t * (b[7] - a[7]) 51 | return out 52 | } 53 | 54 | /** 55 | * Negate our first dual quaternion before interpolating 56 | * We do this when the dot product is < 0 to ensure 57 | * shortest path rotation 58 | */ 59 | function lerpNegatedDualQuaternions (out, a, b, t) { 60 | out[0] = -a[0] + t * (b[0] + a[0]) 61 | out[1] = -a[1] + t * (b[1] + a[1]) 62 | out[2] = -a[2] + t * (b[2] + a[2]) 63 | out[3] = -a[3] + t * (b[3] + a[3]) 64 | 65 | out[4] = -a[4] + t * (b[4] + a[4]) 66 | out[5] = -a[5] + t * (b[5] + a[5]) 67 | out[6] = -a[6] + t * (b[6] + a[6]) 68 | out[7] = -a[7] + t * (b[7] + a[7]) 69 | } 70 | 71 | function dotProduct (a, b) { 72 | return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3] 73 | } 74 | -------------------------------------------------------------------------------- /src/get-previous-animation-data.js: -------------------------------------------------------------------------------- 1 | module.exports = getPreviousAnimationData 2 | 3 | /** 4 | * Get animation data about the previous animation 5 | * 6 | * TODO: IIRC I split this out just to make it easier to wrap my head around it 7 | * while I was writing it, but this is pretty much the exact same logic as our 8 | * current animation data extraction. So we should normalize the two into 9 | * `getAnimationData` 10 | */ 11 | function getPreviousAnimationData (opts, previousKeyframeTimes) { 12 | var prevAnimElapsedTime = opts.currentTime - opts.previousAnimation.startTime 13 | var previousAnimLowerKeyframe 14 | var previousAnimUpperKeyframe 15 | 16 | // The amount of time that the previous animation was running before the new animation began 17 | // during this time it is okay to loop the animation 18 | // TODO: Better naming 19 | var leway = opts.currentAnimation.startTime - opts.previousAnimation.startTime 20 | // If the previous animation wasn't looping we make sure to start from the 21 | // final frame no matter what. We do not provide any leway to loop the animation 22 | // up until the current animation starts. 23 | // TODO: This file needs to be refactored and commented.. It's getting messier as I add more 24 | if (opts.previousAnimation.noLoop) { 25 | leway = 0 26 | } 27 | var currentAnimTimeElapsed = opts.currentTime - opts.currentAnimation.startTime 28 | 29 | // TODO: My mind is jelly but the tests have passed... Refactor this! 30 | var prevAnimTimeRelToFirstFrame = Number(previousKeyframeTimes[0]) + Number(prevAnimElapsedTime) 31 | if (prevAnimTimeRelToFirstFrame - leway > previousKeyframeTimes[previousKeyframeTimes.length - 1]) { 32 | previousAnimLowerKeyframe = previousAnimUpperKeyframe = previousKeyframeTimes[previousKeyframeTimes.length - 1] 33 | prevAnimElapsedTime = 0 34 | } else { 35 | prevAnimTimeRelToFirstFrame = Number(previousKeyframeTimes[0]) + Number(prevAnimElapsedTime) 36 | var range = previousKeyframeTimes[previousKeyframeTimes.length - 1] - previousKeyframeTimes[0] 37 | if (prevAnimTimeRelToFirstFrame > range) { 38 | if (leway > range) { 39 | var lewayStart = leway % range 40 | if (lewayStart + currentAnimTimeElapsed > previousKeyframeTimes[previousKeyframeTimes.length - 1]) { 41 | previousAnimLowerKeyframe = previousAnimUpperKeyframe = previousKeyframeTimes[previousKeyframeTimes.length - 1] 42 | prevAnimElapsedTime = 0 43 | } else { 44 | prevAnimElapsedTime = prevAnimElapsedTime % range 45 | prevAnimTimeRelToFirstFrame = prevAnimElapsedTime + Number(previousKeyframeTimes[0]) 46 | } 47 | } else { 48 | prevAnimElapsedTime = prevAnimElapsedTime % range 49 | prevAnimTimeRelToFirstFrame = prevAnimElapsedTime + Number(previousKeyframeTimes[0]) 50 | } 51 | } 52 | 53 | // Get the surrounding keyframes for our previous animation 54 | previousKeyframeTimes.forEach(function (keyframeTime) { 55 | if (previousAnimLowerKeyframe && previousAnimUpperKeyframe) { return } 56 | if (prevAnimTimeRelToFirstFrame >= keyframeTime) { 57 | previousAnimLowerKeyframe = keyframeTime 58 | } 59 | if (prevAnimTimeRelToFirstFrame <= keyframeTime) { 60 | previousAnimUpperKeyframe = keyframeTime 61 | } 62 | }) 63 | } 64 | prevAnimElapsedTime = prevAnimTimeRelToFirstFrame - previousAnimLowerKeyframe 65 | 66 | return { 67 | lower: previousAnimLowerKeyframe, 68 | upper: previousAnimUpperKeyframe, 69 | elapsedTime: prevAnimElapsedTime 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/skeletal-animation-system.js: -------------------------------------------------------------------------------- 1 | var blendDualQuaternions = require('./blend-dual-quaternions.js') 2 | 3 | module.exports = { 4 | interpolateJoints: interpolateJoints 5 | } 6 | 7 | // TODO: Finishing adding comments 8 | // TODO: Benchmark performance and optimize 9 | function interpolateJoints (opts) { 10 | // Get the amount of time that the current animation has been running. 11 | // We use this when interpolating the current animation. Depending on 12 | // how long the animation has been running we'll sample from different 13 | // keyframe times 14 | var currentAnimElapsedTime = opts.currentTime - opts.currentAnimation.startTime 15 | 16 | // Sort all of our animations keyframe times numerically so that 17 | // they're next to each other when we're sampling them. 18 | // ex: {1: [...], '6.5': [...], 2: [...]} becomes [1, 2, 6.5] 19 | // All keyframe times are in seconds 20 | var currentKeyframeTimes = Object.keys(opts.currentAnimation.keyframes) 21 | .sort(function (a, b) { 22 | // NOTE: This breaks if you have the same keyframe twice. But you 23 | // really shouldn't have the same keyframe twice. In the future 24 | // we might have a separate package for linting your model 25 | return Number(a) > Number(b) ? 1 : -1 26 | }) 27 | var previousKeyframeTimes 28 | if (opts.previousAnimation) { 29 | previousKeyframeTimes = Object.keys(opts.previousAnimation.keyframes) 30 | .sort(function (a, b) { 31 | return Number(a) > Number(b) ? 1 : -1 32 | }) 33 | } 34 | 35 | // Get the current animation's time relative to the first possible time. 36 | // For example, if our keyframe times are [1, 2, 6.5] and the current animation's 37 | // elapsed time is `3`, then our time relative to our first time is `1 + 3` or `4` 38 | var timeRelativeToFirst = Number(currentKeyframeTimes[0]) + Number(currentAnimElapsedTime) 39 | // Our duration is the number of seconds from the first keyframe time to the last 40 | // in our current animation. So for a current animation of [1, 2, 6.5] our duration is 4.5 41 | var duration = currentKeyframeTimes[currentKeyframeTimes.length - 1] - currentKeyframeTimes[0] 42 | if (currentAnimElapsedTime > duration) { 43 | // If we are NOT LOOPING then we set our upper bound of elapsed time to the duration of the animation 44 | if (opts.currentAnimation.noLoop) { 45 | currentAnimElapsedTime = Math.min(currentAnimElapsedTime, duration) 46 | } else { 47 | // If we ARE LOOPING then we use the modulus of the animation duration to 48 | // always re-start from the beginning 49 | currentAnimElapsedTime = currentAnimElapsedTime % duration 50 | } 51 | timeRelativeToFirst = currentAnimElapsedTime + Number(currentKeyframeTimes[0]) 52 | } 53 | 54 | var currentAnimLowerKeyframe 55 | var currentAnimUpperKeyframe 56 | if (currentKeyframeTimes.length === 1) { 57 | currentAnimLowerKeyframe = currentAnimUpperKeyframe = currentKeyframeTimes[0] 58 | } else { 59 | // Get the surrounding keyframes for our current animation 60 | currentKeyframeTimes.forEach(function (keyframeTime) { 61 | if (currentAnimLowerKeyframe && currentAnimUpperKeyframe) { return } 62 | if (timeRelativeToFirst > keyframeTime) { 63 | currentAnimLowerKeyframe = keyframeTime 64 | } else if (timeRelativeToFirst < keyframeTime) { 65 | currentAnimUpperKeyframe = keyframeTime 66 | } else if (timeRelativeToFirst === Number(keyframeTime)) { 67 | // TODO: Perform fewer calculations in places that we already know 68 | // that the keyframe time doesn't need to be blended against an upper 69 | // and lower keyframe. For now we don't handle this special case 70 | currentAnimLowerKeyframe = currentAnimUpperKeyframe = keyframeTime 71 | } 72 | }) 73 | } 74 | // Set the elapsed time relative to our current lower bound keyframe instead of our lowest out of all keyframes 75 | currentAnimElapsedTime = timeRelativeToFirst - currentAnimLowerKeyframe 76 | 77 | var previousAnimLowerKeyframe 78 | var previousAnimUpperKeyframe 79 | var prevAnimElapsedTime 80 | if (opts.previousAnimation) { 81 | var previousKeyframeData = require('./get-previous-animation-data.js')(opts, previousKeyframeTimes) 82 | previousAnimLowerKeyframe = previousKeyframeData.lower 83 | previousAnimUpperKeyframe = previousKeyframeData.upper 84 | prevAnimElapsedTime = previousKeyframeData.elapsedTime 85 | } 86 | 87 | // Calculate the interpolated joint matrices for our consumer's animation 88 | // TODO: acc is a bad variable name. Renaame it 89 | var interpolatedJoints = opts.jointNums.reduce(function (acc, jointName) { 90 | // If there is a previous animation 91 | var blend = (opts.blendFunction || defaultBlend)(opts.currentTime - opts.currentAnimation.startTime) 92 | 93 | if (opts.previousAnimation && blend < 1) { 94 | var previousAnimJointDualQuat 95 | var currentAnimJointDualQuat 96 | 97 | if (previousAnimLowerKeyframe === previousAnimUpperKeyframe) { 98 | // If our current frame happens to be one of our defined keyframes we use the existing frame 99 | previousAnimJointDualQuat = opts.previousAnimation.keyframes[previousAnimLowerKeyframe][jointName] 100 | } else { 101 | // Blend the dual quaternions for our previous animation that we are about to blend out 102 | previousAnimJointDualQuat = blendDualQuaternions( 103 | [], 104 | opts.previousAnimation.keyframes[previousAnimLowerKeyframe][jointName], 105 | opts.previousAnimation.keyframes[previousAnimUpperKeyframe][jointName], 106 | prevAnimElapsedTime / (previousAnimUpperKeyframe - previousAnimLowerKeyframe) 107 | ) 108 | } 109 | 110 | if (currentAnimLowerKeyframe === currentAnimUpperKeyframe) { 111 | // If our current frame happens to be one of our defined keyframes we use the existing frame 112 | currentAnimJointDualQuat = opts.currentAnimation.keyframes[currentAnimLowerKeyframe][jointName] 113 | } else { 114 | currentAnimJointDualQuat = blendDualQuaternions( 115 | [], 116 | opts.currentAnimation.keyframes[currentAnimLowerKeyframe][jointName], 117 | opts.currentAnimation.keyframes[currentAnimUpperKeyframe][jointName], 118 | currentAnimElapsedTime / (currentAnimUpperKeyframe - currentAnimLowerKeyframe) 119 | ) 120 | } 121 | 122 | acc[jointName] = blendDualQuaternions([], previousAnimJointDualQuat, currentAnimJointDualQuat, blend) 123 | } else { 124 | // If we are on an exact, pre-defined keyframe there is no need to blend 125 | if (currentAnimUpperKeyframe === currentAnimLowerKeyframe) { 126 | acc[jointName] = opts.currentAnimation.keyframes[currentAnimLowerKeyframe][jointName] 127 | } else { 128 | // Blend the two dual quaternions based on where we are in the current keyframe 129 | acc[jointName] = blendDualQuaternions( 130 | [], 131 | // The defined keyframe right below our current frame 132 | opts.currentAnimation.keyframes[currentAnimLowerKeyframe][jointName], 133 | // The defined keyframe right above our current frame 134 | opts.currentAnimation.keyframes[currentAnimUpperKeyframe][jointName], 135 | currentAnimElapsedTime / (currentAnimUpperKeyframe - currentAnimLowerKeyframe) 136 | ) 137 | } 138 | } 139 | return acc 140 | }, {}) 141 | 142 | // Calculate the keyframe number of our upper and lower keyframe 143 | // TODO: Handle this while we do other stuff so we don't need to loop through again 144 | // this is a minor perf optimization that we can implement when we benchmark 145 | var currentAnimLowerKeyframeNumber 146 | var currentAnimUpperKeyframeNumber 147 | currentKeyframeTimes.forEach(function (keyTime, keyframeNumber) { 148 | currentAnimLowerKeyframeNumber = currentAnimLowerKeyframe === keyTime ? keyframeNumber : currentAnimLowerKeyframeNumber 149 | currentAnimUpperKeyframeNumber = currentAnimUpperKeyframe === keyTime ? keyframeNumber : currentAnimUpperKeyframeNumber 150 | }) 151 | 152 | // Return the freshly interpolated dual quaternions for each of the joints that were passed in 153 | return { 154 | joints: interpolatedJoints, 155 | currentAnimationInfo: { 156 | lowerKeyframeNumber: currentAnimLowerKeyframeNumber, 157 | upperKeyframeNumber: currentAnimUpperKeyframeNumber 158 | } 159 | } 160 | } 161 | 162 | // Give then number of seconds elapsed between the previous animation 163 | // and the current animation we return a blend factor between 164 | // zero and one 165 | function defaultBlend (dt) { 166 | // If zero time has elapsed we avoid dividing by 0 167 | if (!dt) { return 0 } 168 | // Blender linearly over 0.5s 169 | return 2 * dt 170 | } 171 | -------------------------------------------------------------------------------- /test/blend-out-previous-anim.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var animationSystem = require('../') 3 | 4 | // Blend linearly over 2 seconds 5 | function blendFunction (dt) { 6 | return 0.5 * dt 7 | } 8 | 9 | // TODO: Thoroughly comment tests. Hard to understand without more context 10 | test('Blend out previous animation', function (t) { 11 | var currentKeyframes = { 12 | '5.0': [ 13 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 14 | ], 15 | '8.0': [ 16 | [1, 1, 1, 1, 1, 1, 1, 1] 17 | ] 18 | } 19 | 20 | var previousKeyframes = { 21 | '0': [ 22 | [0, 0, 0, 0, 0, 0, 0, 0] 23 | ], 24 | '5.0': [ 25 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 26 | ] 27 | } 28 | 29 | var options = { 30 | // Our application clock has been running for 100.5 seconds 31 | blendFunction: blendFunction, 32 | currentTime: 100.5, 33 | jointNums: [0], 34 | currentAnimation: { 35 | keyframes: currentKeyframes, 36 | // Our new animation has been playing for 1.5 seconds 37 | // This means that it is halfway done 38 | // Making it's dual quaternion: 39 | // [0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75] 40 | startTime: 99.0 41 | }, 42 | previousAnimation: { 43 | keyframes: previousKeyframes, 44 | // Our previous animation started 2.5 seconds before our current time 45 | // This means that it has (5.0 - 2.5) seconds remaining 46 | // Making it's dual quaternion: 47 | // [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25] 48 | startTime: 98.0 49 | } 50 | } 51 | 52 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 53 | 54 | t.deepEqual( 55 | interpolatedJoints[0], 56 | // Our new animation has been playing for 1.5 seconds 57 | // This means that it should have 3/4th of the dual quaternion weight 58 | // 3/4th of the way between 0.25 and 0.75 = 0.625 59 | [0.625, 0.625, 0.625, 0.625, 0.625, 0.625, 0.625, 0.625], 60 | 'Uses default 2 second linear blend' 61 | ) 62 | t.end() 63 | }) 64 | 65 | test('Blending while passed previous animations upper keyframe', function (t) { 66 | var currentKeyframes = { 67 | '1.0': [ 68 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 69 | ], 70 | '4.0': [ 71 | [1, 1, 1, 1, 1, 1, 1, 1] 72 | ] 73 | } 74 | 75 | var previousKeyframes = { 76 | '0': [ 77 | [0, 0, 0, 0, 0, 0, 0, 0] 78 | ], 79 | '1.0': [ 80 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 81 | ] 82 | } 83 | 84 | var options = { 85 | blendFunction: blendFunction, 86 | // Our application clock has been running for 100.5 seconds 87 | currentTime: 100.5, 88 | jointNums: [0], 89 | currentAnimation: { 90 | keyframes: currentKeyframes, 91 | // Our new animation has been playing for 1.5 seconds 92 | // This means that it is halfway done 93 | // Making it's dual quaternion: 94 | // [0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75] 95 | startTime: 99.0 96 | }, 97 | previousAnimation: { 98 | keyframes: previousKeyframes, 99 | // Our previous animation started 1.5 seconds before our current time 100 | // Meaning that it has passed it's final frame of 1.0. It should not loop 101 | // if it's being blended. We will use it's final frame 102 | // Making it's dual quaternion: 103 | // [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 104 | startTime: 99.0 105 | } 106 | } 107 | 108 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 109 | 110 | t.deepEqual( 111 | interpolatedJoints[0], 112 | // Our new animation has been playing for 1.5 seconds 113 | // This means that it should have 3/4th of the dual quaternion weight 114 | // 3/4th of the way between 0.25 and 0.75 = 0.625 115 | [0.6875, 0.6875, 0.6875, 0.6875, 0.6875, 0.6875, 0.6875, 0.6875], 116 | `Uses the previous animations final keyframe when blending if 117 | the previous animation has elapsed but there is still blend time remaining` 118 | ) 119 | t.end() 120 | }) 121 | 122 | test('Blend is above 100% complete', function (t) { 123 | var currentKeyframes = { 124 | '5.0': [ 125 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 126 | ], 127 | '10.0': [ 128 | [1, 1, 1, 1, 1, 1, 1, 1] 129 | ] 130 | } 131 | 132 | var previousKeyframes = { 133 | '0': [ 134 | [0, 0, 0, 0, 0, 0, 0, 0] 135 | ], 136 | '5.0': [ 137 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 138 | ] 139 | } 140 | 141 | var options = { 142 | blendFunction: blendFunction, 143 | // Our application clock has been running for 100.5 seconds 144 | currentTime: 101.5, 145 | jointNums: [0], 146 | currentAnimation: { 147 | keyframes: currentKeyframes, 148 | // Our new animation has been playing for 2.5 seconds 149 | // This means that it is halfway done 150 | // Making it's dual quaternion: 151 | // [0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75] 152 | startTime: 99.0 153 | }, 154 | previousAnimation: { 155 | keyframes: previousKeyframes, 156 | // Our previous animation started 2.5 seconds before our current time 157 | // This means that it has (5.0 - 2.5) seconds remaining 158 | // Making it's dual quaternion: 159 | // [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25] 160 | startTime: 99.0 161 | } 162 | } 163 | 164 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 165 | 166 | t.deepEqual( 167 | interpolatedJoints[0], 168 | // Our new animation has been playing for 1.5 seconds 169 | // This means that it should have 3/4th of the dual quaternion weight 170 | // 3/4th of the way between 0.25 and 0.75 = 0.625 171 | [0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75], 172 | 'Ignores previous animation if blend above 100%' 173 | ) 174 | t.end() 175 | }) 176 | 177 | test('Blends using time since current animation frame set began', function (t) { 178 | var currentKeyframes = { 179 | '5.0': [ 180 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 181 | ], 182 | '6.0': [ 183 | [1, 1, 1, 1, 1, 1, 1, 1] 184 | ], 185 | '7.0': [ 186 | [3, 3, 3, 3, 3, 3, 3, 3] 187 | ] 188 | } 189 | 190 | var previousKeyframes = { 191 | '1': [ 192 | [-0.5, -0.5, -0.5, -0.5, -0.5, -0.5, -0.5, -0.5] 193 | ], 194 | '5.0': [ 195 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 196 | ] 197 | } 198 | 199 | var options = { 200 | blendFunction: blendFunction, 201 | // Our application clock has been running for 100.5 seconds 202 | currentTime: 100.5, 203 | jointNums: [0], 204 | currentAnimation: { 205 | keyframes: currentKeyframes, 206 | // Our new animation has been playing for 1.5 seconds 207 | // Making it's dual quaternion: 208 | // [2, 2, 2, 2, 2, 2, 2, 2] 209 | startTime: 99.0 210 | }, 211 | previousAnimation: { 212 | keyframes: previousKeyframes, 213 | // Our previous animation started 2 seconds before our current time 214 | // Making it's dual quaternion: 215 | // [0, 0, 0, 0, 0, 0, 0, 0] 216 | startTime: 98.5 217 | } 218 | } 219 | 220 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 221 | 222 | t.deepEqual( 223 | interpolatedJoints[0], 224 | // This is 1.625 because we negate one of the dual quats to ensure shortest path interpolation 225 | [1.625, 1.625, 1.625, 1.625, 1.625, 1.625, 1.625, 1.625], 226 | 'Blends using time since current animation frame set first started' 227 | ) 228 | t.end() 229 | }) 230 | 231 | test('Previous animation in middle of loop', function (t) { 232 | var currentKeyframes = { 233 | '2.0': [ 234 | [2, 2, 2, 2, 2, 2, 2, 2] 235 | ], 236 | '3.0': [ 237 | [4, 4, 4, 4, 4, 4, 4, 4] 238 | ] 239 | } 240 | 241 | var previousKeyframes = { 242 | '0': [ 243 | [0, 0, 0, 0, 0, 0, 0, 0] 244 | ], 245 | '2.0': [ 246 | [2, 2, 2, 2, 2, 2, 2, 2] 247 | ] 248 | } 249 | 250 | var options = { 251 | blendFunction: blendFunction, 252 | // Our application clock has been running for 100.5 seconds 253 | currentTime: 101, 254 | jointNums: [0], 255 | currentAnimation: { 256 | keyframes: currentKeyframes, 257 | // TODO: Change all "frames" to "seconds". We're dealing with seconds 258 | // as our keyframe key 259 | // Our new animation has been playing for 0.5 seconds 260 | // Making it's dual quaternion: 261 | // [3, 3, 3, 3, 3, 3, 3, 3] 262 | startTime: 100.5 263 | }, 264 | previousAnimation: { 265 | keyframes: previousKeyframes, 266 | // Our previous animation has been playing for 3 seconds 267 | // Making it's dual quaternion: 268 | // [1, 1, 1, 1, 1, 1, 1, 1] 269 | startTime: 98.0 270 | } 271 | } 272 | 273 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 274 | 275 | t.deepEqual( 276 | interpolatedJoints[0], 277 | [1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5], 278 | 'Supports looping when previous animation started before current' 279 | ) 280 | t.end() 281 | }) 282 | 283 | test('Previous animation started in middle of loop but now passed final frame', function (t) { 284 | var currentKeyframes = { 285 | '1.0': [ 286 | [2, 2, 2, 2, 2, 2, 2, 2] 287 | ], 288 | '3.0': [ 289 | [4, 4, 4, 4, 4, 4, 4, 4] 290 | ] 291 | } 292 | 293 | var previousKeyframes = { 294 | '0': [ 295 | [0, 0, 0, 0, 0, 0, 0, 0] 296 | ], 297 | '1.0': [ 298 | [2, 2, 2, 2, 2, 2, 2, 2] 299 | ] 300 | } 301 | 302 | var options = { 303 | blendFunction: blendFunction, 304 | // Our application clock has been running for 100.5 seconds 305 | currentTime: 102.5, 306 | jointNums: [0], 307 | currentAnimation: { 308 | keyframes: currentKeyframes, 309 | // TODO: Change all of the "seconds" to frames 310 | // Our new animation has been playing for 1.0 seconds 311 | // Making it's dual quaternion: 312 | // [3, 3, 3, 3, 3, 3, 3, 3] 313 | startTime: 101.5 314 | }, 315 | previousAnimation: { 316 | keyframes: previousKeyframes, 317 | // Our previous animation has been playing for 2.5 frames, 318 | // but only 1 frame since the new animation started 319 | // This means that it has hit it's final frame and should 320 | // no longer loop. 321 | startTime: 100 322 | } 323 | } 324 | 325 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 326 | 327 | t.deepEqual( 328 | interpolatedJoints[0], 329 | [2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5], 330 | 'Supports looping when previous animation started before current' 331 | ) 332 | t.end() 333 | }) 334 | 335 | // Test that previous animation elapsed time is properly calculated against 336 | // the lowest keyframe 337 | test('Previous animation elapsed time when previous animation starts from non first keyframe in set', function (t) { 338 | var currentKeyframes = { 339 | '6.0': [ 340 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 341 | ], 342 | '9.0': [ 343 | [1, 1, 1, 1, 1, 1, 1, 1] 344 | ] 345 | } 346 | 347 | var previousKeyframes = { 348 | '0': [ 349 | [100, 100, 100, 100, 100, 100, 100, 100] 350 | ], 351 | '1': [ 352 | [0, 0, 0, 0, 0, 0, 0, 0] 353 | ], 354 | '6.0': [ 355 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 356 | ] 357 | } 358 | 359 | var options = { 360 | blendFunction: blendFunction, 361 | // Our application clock has been running for 100.5 seconds 362 | currentTime: 100.5, 363 | jointNums: [0], 364 | currentAnimation: { 365 | keyframes: currentKeyframes, 366 | // Our new animation has been playing for 1.5 seconds 367 | // This means that it is halfway done 368 | // Making it's dual quaternion: 369 | // [0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75] 370 | startTime: 99.0 371 | }, 372 | previousAnimation: { 373 | keyframes: previousKeyframes, 374 | // Our previous animation started 3.5 seconds before our current time 375 | // Making it's dual quaternion: 376 | // [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25] 377 | startTime: 97.0 378 | } 379 | } 380 | 381 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 382 | 383 | t.deepEqual( 384 | interpolatedJoints[0], 385 | // Our new animation has been playing for 1.5 seconds 386 | // This means that it should have 3/4th of the dual quaternion weight 387 | // 3/4th of the way between 0.25 and 0.75 = 0.625 388 | [0.625, 0.625, 0.625, 0.625, 0.625, 0.625, 0.625, 0.625], 389 | 'Uses default 2 second linear blend' 390 | ) 391 | t.end() 392 | }) 393 | 394 | // If there are multiple keyframes above our previous animation's 395 | // current keyframe it should be sure to chose the correct one 396 | test('Multiple keyframes larger than the current one', function (t) { 397 | var currentKeyframes = { 398 | '10.0': [ 399 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 400 | ], 401 | '13.0': [ 402 | [1, 1, 1, 1, 1, 1, 1, 1] 403 | ] 404 | } 405 | 406 | var previousKeyframes = { 407 | '0': [ 408 | [0, 0, 0, 0, 0, 0, 0, 0] 409 | ], 410 | '5.0': [ 411 | [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 412 | ], 413 | '8.0': [ 414 | [100, 100, 100, 100, 100, 100, 100, 100] 415 | ] 416 | } 417 | 418 | var options = { 419 | blendFunction: blendFunction, 420 | // Our application clock has been running for 100.5 seconds 421 | currentTime: 100.5, 422 | jointNums: [0], 423 | currentAnimation: { 424 | keyframes: currentKeyframes, 425 | // Our new animation has been playing for 1.5 seconds 426 | // This means that it is halfway done 427 | // Making it's dual quaternion: 428 | // [0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75] 429 | startTime: 99.0 430 | }, 431 | previousAnimation: { 432 | keyframes: previousKeyframes, 433 | // Our previous animation started 2.5 seconds before our current time 434 | // This means that it has (5.0 - 2.5) seconds remaining 435 | // Making it's dual quaternion: 436 | // [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25] 437 | startTime: 98.0 438 | } 439 | } 440 | 441 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 442 | 443 | t.deepEqual( 444 | interpolatedJoints[0], 445 | // Our new animation has been playing for 1.5 seconds 446 | // This means that it should have 3/4th of the dual quaternion weight 447 | // 3/4th of the way between 0.25 and 0.75 = 0.625 448 | [0.625, 0.625, 0.625, 0.625, 0.625, 0.625, 0.625, 0.625], 449 | 'Uses default 2 second linear blend' 450 | ) 451 | t.end() 452 | }) 453 | 454 | test('single frame animations', function (t) { 455 | var options = { 456 | currentTime: 0.016666666666666666, 457 | jointNums: [ 458 | 0 459 | ], 460 | currentAnimation: { 461 | keyframes: { 462 | '0.0': [ 463 | [-7.278466024329688e-14, 2.0600566680306368e-10, -5.126635227448162e-12, 1, 5.187854313327257e-18, -8.827153992227743e-19, 2.7275390652703847e-16, 1.580531756253426e-27] 464 | ] 465 | }, 466 | startTime: 0, 467 | noLoop: false 468 | } 469 | } 470 | 471 | t.doesNotThrow(function () { 472 | animationSystem.interpolateJoints(options) 473 | }, 'interpolateJoints should not throw') 474 | t.end() 475 | }) 476 | -------------------------------------------------------------------------------- /test/skeletal-animation-system.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var animationSystem = require('../') 3 | 4 | test('Animate without blending previous animation', function (t) { 5 | var currentKeyframes = { 6 | '0': [ 7 | [0, 0, 0, 0, 1, 1, 1, 1] 8 | ], 9 | '2': [ 10 | [1, 1, 1, 1, 0, 0, 0, 0] 11 | ] 12 | } 13 | 14 | var options = { 15 | // Our application clock has been running for 1.5 seconds 16 | // which is 3/4 of the curent animations duration 17 | currentTime: 1.5, 18 | jointNums: [0], 19 | currentAnimation: { 20 | keyframes: currentKeyframes, 21 | startTime: 0 22 | } 23 | } 24 | 25 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 26 | 27 | t.deepEqual( 28 | interpolatedJoints[0], 29 | [0.75, 0.75, 0.75, 0.75, 0.25, 0.25, 0.25, 0.25], 30 | 'Interpolated the passed in joint' 31 | ) 32 | t.end() 33 | }) 34 | 35 | /* 36 | * Remnant from the old API, keeping this test around until things work 37 | test('Chooses proper minimum and maximum keyframe', function (t) { 38 | var options = { 39 | currentTime: 1.5, 40 | keyframes: { 41 | '1.0': [ 42 | [100, 100, 100, 100, 100, 100, 100, 100] 43 | ], 44 | // Correct lower 45 | '2.0': [ 46 | [0, 0, 0, 0, 1, 1, 1, 1] 47 | ], 48 | // Correct upper 49 | '4.0': [ 50 | [1, 1, 1, 1, 0, 0, 0, 0] 51 | ], 52 | '200': [ 53 | [1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000] 54 | ] 55 | }, 56 | jointNums: [0], 57 | currentAnimation: { 58 | range: [0, 3], 59 | startTime: 0 60 | } 61 | } 62 | 63 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 64 | 65 | t.deepEqual( 66 | interpolatedJoints[0], 67 | [0.25, 0.25, 0.25, 0.25, 0.75, 0.75, 0.75, 0.75], 68 | 'Chooses correct maximum keyframe' 69 | ) 70 | t.end() 71 | }) 72 | */ 73 | 74 | test('Looping animation', function (t) { 75 | var currentAnimationKeyframes = { 76 | '1': [ 77 | [0, 0, 0, 0, 1, 1, 1, 1] 78 | ], 79 | '3': [ 80 | [1, 1, 1, 1, 0, 0, 0, 0] 81 | ] 82 | } 83 | 84 | var options = { 85 | currentTime: 4.0, 86 | jointNums: [0], 87 | currentAnimation: { 88 | keyframes: currentAnimationKeyframes, 89 | startTime: 0.0 90 | } 91 | } 92 | 93 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 94 | 95 | t.deepEqual( 96 | interpolatedJoints[0], 97 | [0, 0, 0, 0, 1, 1, 1, 1], 98 | 'Loop is frame is outside of provided frame range' 99 | ) 100 | t.end() 101 | }) 102 | 103 | // In this case we should start from the lowest frame as zero 104 | // in the future we might add a flag to actually treat the lowest 105 | // frame as the number specified. But since Blender defaults to frame 106 | // `1` it's too easy to accidentally not start at zero 107 | test('Current time lower than first keyframe', function (t) { 108 | var currentAnimationKeyframes = { 109 | '1': [ 110 | [0, 0, 0, 0, 1, 1, 1, 1] 111 | ], 112 | '3': [ 113 | [1, 1, 1, 1, 0, 0, 0, 0] 114 | ] 115 | } 116 | 117 | var options = { 118 | currentTime: 0.0, 119 | jointNums: [0], 120 | currentAnimation: { 121 | keyframes: currentAnimationKeyframes, 122 | startTime: 0.0 123 | } 124 | } 125 | 126 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 127 | 128 | t.deepEqual( 129 | interpolatedJoints[0], 130 | [0, 0, 0, 0, 1, 1, 1, 1], 131 | 'Current frame is an exact match of a passed in keyframe' 132 | ) 133 | t.end() 134 | }) 135 | 136 | /* 137 | * Remnant from the old API, keeping this test around until things work 138 | * 139 | // In this case we should start from the lowest frame as zero 140 | // in the future we might add a flag to actually treat the lowest 141 | // frame as the number specified. But since Blender defaults to frame 142 | // `1` it's too easy to accidentally not start at zero 143 | test('Looping when not using lowest keyframe range', function (t) { 144 | var options = { 145 | currentTime: 7.0, 146 | keyframes: { 147 | '1': [ 148 | [0, 0, 0, 0, 1, 1, 1, 1] 149 | ], 150 | '3': [ 151 | [1, 1, 1, 1, 0, 0, 0, 0] 152 | ], 153 | '5': [ 154 | [3, 3, 3, 3, 1, 1, 1, 1] 155 | ], 156 | '7': [ 157 | [1, 1, 1, 1, 0, 0, 0, 0] 158 | ] 159 | }, 160 | jointNums: [0], 161 | currentAnimation: { 162 | range: [1, 2], 163 | startTime: 0.0 164 | } 165 | } 166 | 167 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 168 | 169 | t.deepEqual( 170 | interpolatedJoints[0], 171 | [2, 2, 2, 2, 0.5, 0.5, 0.5, 0.5], 172 | 'Properly loops the specified upper and lower keyframes' 173 | ) 174 | t.end() 175 | }) 176 | */ 177 | 178 | // The noLoop flag is useful for animations that shouldn't repeat. For example, 179 | // you'll likely want a walk animation to loop as your player walks, 180 | // but it is unlikely that you will want a punch animation to loop 181 | // (assuming your player only punched once) 182 | test('Playing a non looping animation', function (t) { 183 | var currentAnimationKeyframes = { 184 | '3': [ 185 | [1, 1, 1, 1, 0, 0, 0, 0] 186 | ], 187 | '5': [ 188 | [3, 3, 3, 3, 1, 1, 1, 1] 189 | ] 190 | } 191 | 192 | var options = { 193 | currentTime: 7.0, 194 | jointNums: [0], 195 | currentAnimation: { 196 | keyframes: currentAnimationKeyframes, 197 | startTime: 0.0, 198 | // Notice that we are passing in `noLoop` in this test 199 | noLoop: true 200 | } 201 | } 202 | 203 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 204 | 205 | t.deepEqual( 206 | interpolatedJoints[0], 207 | // Our highest keyframe is '5'. Since we aren't looping that's where we 208 | // should end 209 | [3, 3, 3, 3, 1, 1, 1, 1], 210 | 'Bound to highest keyframe when `noLoop` is true' 211 | ) 212 | t.end() 213 | }) 214 | 215 | // This is useful for knowing to play an animation on a certain keyframe 216 | // for example, you might keep track of the previous lower keyframe, and whenever 217 | // the new lower keyframe is different from the previous one and greater than a 218 | // certain value you might play a sound. 219 | // i.e. let's say keyframe #6 is when your ax hits a tree. 220 | // you might then play a sound if your lower keyframe is keyframe 6 and your previous lower 221 | // keyframe is not 6, because this means that you are crossing keyframe 6 for 222 | // the first time 223 | // All of this is handled outside of skeletal-animation-system, skeletal-animation-system 224 | // only concerns itself with letting you know the current lower keyframe 225 | test('Information about the frames that were sampled', function (t) { 226 | var currentAnimationKeyframes = { 227 | '0': [ 228 | [0, 0, 0, 0, 1, 1, 1, 1] 229 | ], 230 | '2.222': [ 231 | [1, 1, 1, 1, 0, 0, 0, 0] 232 | ], 233 | '5': [ 234 | [3, 3, 3, 3, 1, 1, 1, 1] 235 | ], 236 | '7': [ 237 | [1, 1, 1, 1, 0, 0, 0, 0] 238 | ] 239 | } 240 | 241 | var options = { 242 | currentTime: 7.0, 243 | jointNums: [0], 244 | currentAnimation: { 245 | keyframes: currentAnimationKeyframes, 246 | startTime: 0.0 247 | } 248 | } 249 | 250 | var currentAnimationInfoExact = animationSystem.interpolateJoints(options).currentAnimationInfo 251 | 252 | // We are 7 seconds into our animation which is exactly frame #3 so our lower and upper are exactly 3 253 | t.equal(currentAnimationInfoExact.lowerKeyframeNumber, 3, 'Returns correct lower keyframe (On an exact frame)') 254 | t.equal(currentAnimationInfoExact.upperKeyframeNumber, 3, 'Returns correct upper keyframe (On an exact frame)') 255 | 256 | options.currentTime = 6.9 257 | var currentAnimationInfoNonExact = animationSystem.interpolateJoints(options).currentAnimationInfo 258 | 259 | // We are 6.9 seconds into our animation so lower frame is 2 and upper frame is 3 260 | t.equal(currentAnimationInfoNonExact.lowerKeyframeNumber, 2, 'Returns correct lower keyframe (non exact frame time)') 261 | t.equal(currentAnimationInfoNonExact.upperKeyframeNumber, 3, 'Returns correct upper keyframe (non exact frame time)') 262 | 263 | t.end() 264 | }) 265 | 266 | // Was an edge case error where the lower keyframe would be equal to the 267 | // current elapsed time. We were checking for `>` but should have been 268 | // checking for `>=` 269 | test('Start time is equal to the current time with an outlived skeletal animation', function (t) { 270 | var currentAnimationKeyframes = { 271 | '0': [ 272 | [0, 0, 0, 0, 1, 1, 1, 1] 273 | ], 274 | '2': [ 275 | [1, 1, 1, 1, 0, 0, 0, 0] 276 | ] 277 | } 278 | 279 | var options = { 280 | // Our application clock has been running for 1.5 seconds 281 | // which is 3/4 of the curent animations duration 282 | currentTime: 4.0, 283 | blendFunc: function (dt) { 284 | return 5 * dt 285 | }, 286 | jointNums: [0], 287 | currentAnimation: { 288 | keyframes: currentAnimationKeyframes, 289 | startTime: 4.0 290 | }, 291 | previousAnimation: { 292 | keyframes: currentAnimationKeyframes, 293 | startTime: 0 294 | } 295 | } 296 | 297 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 298 | 299 | t.deepEqual( 300 | interpolatedJoints[0], 301 | [0, 0, 0, 0, 1, 1, 1, 1], 302 | 'Works when start time is equal to current time' 303 | ) 304 | t.end() 305 | }) 306 | 307 | // This prevents us from thinking that the previous animation was looping 308 | // when it wasn't. That was causing an issue where our interpolation was 309 | // wrong because we didn't specify that the old animation wasn't looping 310 | // in the first place. 311 | // In short.. before this.. our previous -> current interpolation 312 | // always assumed that the previous animation was looping 313 | test('Previous animation uses `noLoop`', function (t) { 314 | var currentAnimationKeyframes = { 315 | '5': [ 316 | [3, 3, 3, 3, 1, 1, 1, 1] 317 | ], 318 | '7': [ 319 | [1, 1, 1, 1, 0, 0, 0, 0] 320 | ] 321 | } 322 | 323 | var previousAnimationKeyframes = { 324 | '1': [ 325 | [0, 0, 0, 0, 1, 1, 1, 1] 326 | ], 327 | '3': [ 328 | [1, 1, 1, 1, 0, 0, 0, 0] 329 | ] 330 | } 331 | 332 | var options = { 333 | currentTime: 10.0, 334 | keyframes: { 335 | '1': [ 336 | [0, 0, 0, 0, 1, 1, 1, 1] 337 | ], 338 | '3': [ 339 | [1, 1, 1, 1, 0, 0, 0, 0] 340 | ], 341 | '5': [ 342 | [3, 3, 3, 3, 1, 1, 1, 1] 343 | ], 344 | '7': [ 345 | [1, 1, 1, 1, 0, 0, 0, 0] 346 | ] 347 | }, 348 | jointNums: [0], 349 | currentAnimation: { 350 | keyframes: currentAnimationKeyframes, 351 | startTime: 10.0 352 | }, 353 | previousAnimation: { 354 | keyframes: previousAnimationKeyframes, 355 | startTime: 0.0, 356 | noLoop: true 357 | } 358 | } 359 | 360 | var interpolatedJoints = animationSystem.interpolateJoints(options).joints 361 | 362 | t.deepEqual( 363 | interpolatedJoints[0], 364 | // The old keyframe had noloop so we should be blending away from the final keyframe 365 | // of the previous animation 366 | [1, 1, 1, 1, 0, 0, 0, 0], 367 | 'Use final keyframe when blending away from previous keyframe with noLoop' 368 | ) 369 | t.end() 370 | }) 371 | --------------------------------------------------------------------------------