diff --git a/docs/api-reference/core/animation-loop.md b/docs/api-reference/core/animation-loop.md index f17de3e6f..fd254cb77 100644 --- a/docs/api-reference/core/animation-loop.md +++ b/docs/api-reference/core/animation-loop.md @@ -146,11 +146,15 @@ The callbacks `onInitialize`, `onRender` and `onFinalize` that the app supplies | `framebuffer` | `FrameBuffer` | Availabel if `createFrameBuffer: true` was passed to the constructor. | | `_mousePosition` | `[x, y]` or `null` | (**experimental**) Current mouse position over the canvas. | | `_offScreen` | `Boolean` | (**experimental**) If the animation loop is rendering to an OffscreenCanvas. | +| `_timeline` | `Trimeline` | (**experimental**) `Timeline` object tracking the animation timeline and channels. | | ... | Any fields in the object that was returned by the `onInitialize` method. | ### Frame timers * The animation loop tracks GPU and CPU render time of each frame the in member properties `cpuTime` and `gpuTime`. If `gpuTime` is set to `-1`, then the timing for the last frame was invalid and should not be used (this rare and might occur, for example, if the GPU was throttled mid-frame). +### Timeline +* Animations should update base on the timeline time tracking in the member property `timeline` (rather than using `tick` or `Data.now`). The `timeline` time can be played, paused and rewound and supports multiple time channels elapsing at different rates. See `Timeline` class documentation for details. + ## Remarks * You can instantiate multiple `AnimationLoop` classes in parallel, rendering into the same or different `WebGLRenderingContext`s. diff --git a/docs/api-reference/core/timeline.md b/docs/api-reference/core/timeline.md new file mode 100644 index 000000000..87b6f1332 --- /dev/null +++ b/docs/api-reference/core/timeline.md @@ -0,0 +1,103 @@ +# Timeline + +Manages an animation timeline, with multiple channels that can be running at different rates, durations, etc. Many methods (`play`, `pause`) assume that the `update` method is being called once per frame with a "global time". This automatically done for `AnimationLoop.timeline` object. + +## Parallel Times + +The key concept at work in the `Timeline` is running multiple time frames in parallel: +* Global Time: The "system time" as determined by the application. Used by `Timeline` to determine the rate at which to play. +* Timeline Time: The "parent" time of all channels on the timeline. Can be played at the same rate as "Global Time" or manipulated manually. +* Channel Time: Will update in lock step with "Timeline Time", but may move at different rates, loop, etc. depending on channel parameters. + +## Usage + +Automatic update usage (assume `update` method is being called once per frame): +```js +const timeline = animationLoop.timeline; +const channel1 = timeline.addChannel({ + rate: 0.5, + duration: 4000, + wrapMode: "loop" +}); +const channel2 = timeline.addChannel({ + rate: 2, + duration: 1000, + wrapMode: "clamp" +}); + +timeline.pause(); +timeline.play(); + +model.setUniforms({ + uValue1: timeline.getChannelTime(channel1); + uValue2: timeline.getChannelTime(channel2); +}); +``` + +Manual usage: +```js +const timeline = new Timeline(); +const channel1 = timeline.addChannel({ + rate: 0.5, + duration: 4000, + wrapMode: "loop" +}); +const channel2 = timeline.addChannel({ + rate: 2, + duration: 1000, + wrapMode: "clamp" +}); +timeline.setTime(500); + +model.setUniforms({ + uValue1: timeline.getChannelTime(channel1); + uValue2: timeline.getChannelTime(channel2); +}); +``` + + +## Methods + +### addChannel([props: Object]) : Number + +Add a new channel to the timeline. Returns a handle to the channel that can be use for subsequent interactions. Valid propeties are: +* `rate` the speed of the channel's time relative to timeline time. +* `duration` the length of the channel time frame. +* `wrapMode` what to do when the timeline time moves outside the channels duration. "loop" repeat the channels timeframe, "clamp" + will clamp the channel's time to the range (0, duration). + +### getTime: Number + +Return the current timeline time. + +### getChannelTime(handle : Number) : Number + +Return the current time of the channel indicated by `handle`. + +### setTime(time : Number) + +Set the timeline time to the given value. + +### setChannelProps(handle : Number, [props: Object]) + +Update channel indicated by `handle` with the properties given in `props`. Valid propeties are: +* `rate` the speed of the channel's time relative to timeline time. +* `duration` the length of the channel time frame. +* `wrapMode` what to do when the timeline time moves outside the channels duration. "loop" repeat the channels timeframe, "clamp" + will clamp the channel's time to the range (0, duration). + +### play + +Allow timeline time to be updated by calls to `update`. + +### pause + +Prevent timeline time from being updated by calls to `update`. + +### reset + +Reset timeline time to `0`. + +### update(globalTime : Number) + +Expected to be called once per frame, with whatever is considered the "system time". Required for `play` and `pause` to work properly. diff --git a/docs/whats-new.md b/docs/whats-new.md index 488b1bbdc..b72d04fbd 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,36 @@ # What's New +## Version 7.1 + +### Animation Timeline Management + +The new `Timeline` class supports easily managing multiple timelines elapsing at different rates, as well as orchestrating playing, pausing, and rewinding behavior between them. These can be accessed via the new `AnimationLoop.timeline` property. + +```js +const timeline = animationLoop.timeline; +const channel1 = timeline.addChannel({ + rate: 0.5, // Runs at 1/2 base time + duration: 4000, + wrapMode: "loop" // Loop every 4s +}); +const channel2 = timeline.addChannel({ + rate: 2, // Runs at twice base time + duration: 1000, + wrapMode: "clamp" // Stop playing at 1s +}); + +timeline.play(); // Play with the render loop +timeline.pause(); // Don't play with the render loop +timeline.setTime(1500); // Set to specific time + +model.setUniforms({ + uValue1: timeline.getTime(); // Use base timeline time + uValue2: timeline.getChannelTime(channel1); // Use times from channels + uValue3: timeline.getChannelTime(channel2); +}); +``` + + ## Version 7.0 Date: April 19, 2019 diff --git a/examples/core/animation/app.js b/examples/core/animation/app.js new file mode 100644 index 000000000..6487a4215 --- /dev/null +++ b/examples/core/animation/app.js @@ -0,0 +1,307 @@ +/* global document */ + +import { + AnimationLoop, + setParameters, + ModelNode, + picking, + dirlight, + readPixelsToArray, + Buffer, + CubeGeometry +} from '@luma.gl/core'; +import {Matrix4, radians} from 'math.gl'; + +const INFO_HTML = ` +

+Cube drawn with instanced rendering. +

+A luma.gl Cube, rendering 65,536 instances in a +single GPU draw call using instanced vertex attributes. +`; + +const controls = document.createElement('div'); +controls.innerHTML = ` + + +
+ Time:
+ Transform rate:
+ Eye X rate:
+ Eye Y rate:
+ Eye Z rate:
+`; +controls.style.position = 'absolute'; +controls.style.top = '10px'; +controls.style.left = '10px'; +controls.style.background = 'white'; +controls.style.padding = '0.5em'; +document.body.appendChild(controls); + +const playButton = document.getElementById('play'); +const pauseButton = document.getElementById('pause'); +const resetButton = document.getElementById('reset'); +const timeSlider = document.getElementById('time'); +const xformSlider = document.getElementById('xformRate'); +const eyeXSlider = document.getElementById('eyeXRate'); +const eyeYSlider = document.getElementById('eyeYRate'); +const eyeZSlider = document.getElementById('eyeZRate'); + +function getDevicePixelRatio() { + return typeof window !== 'undefined' ? window.devicePixelRatio : 1; +} + +const SIDE = 256; + +// Make a cube with 65K instances and attributes to control offset and color of each instance +class InstancedCube extends ModelNode { + constructor(gl, props) { + let offsets = []; + for (let i = 0; i < SIDE; i++) { + const x = ((-SIDE + 1) * 3) / 2 + i * 3; + for (let j = 0; j < SIDE; j++) { + const y = ((-SIDE + 1) * 3) / 2 + j * 3; + offsets.push(x, y); + } + } + offsets = new Float32Array(offsets); + + const pickingColors = new Uint8ClampedArray(SIDE * SIDE * 2); + for (let i = 0; i < SIDE; i++) { + for (let j = 0; j < SIDE; j++) { + pickingColors[(i * SIDE + j) * 2 + 0] = i; + pickingColors[(i * SIDE + j) * 2 + 1] = j; + } + } + + const colors = new Float32Array(SIDE * SIDE * 3).map(() => Math.random() * 0.75 + 0.25); + + const vs = `\ +attribute float instanceSizes; +attribute vec3 positions; +attribute vec3 normals; +attribute vec2 instanceOffsets; +attribute vec3 instanceColors; +attribute vec2 instancePickingColors; + +uniform mat4 uModel; +uniform mat4 uView; +uniform mat4 uProjection; +uniform float uTime; + +varying vec3 color; + +void main(void) { + vec3 normal = vec3(uModel * vec4(normals, 1.0)); + + // Set up data for modules + color = instanceColors; + project_setNormal(normal); + picking_setPickingColor(vec3(0., instancePickingColors)); + + // Vertex position (z coordinate undulates with time), and model rotates around center + float delta = length(instanceOffsets); + vec4 offset = vec4(instanceOffsets, sin((uTime + delta) * 0.1) * 16.0, 0); + gl_Position = uProjection * uView * (uModel * vec4(positions * instanceSizes, 1.0) + offset); +} +`; + const fs = `\ +precision highp float; + +varying vec3 color; + +void main(void) { + gl_FragColor = vec4(color, 1.); + gl_FragColor = dirlight_filterColor(gl_FragColor); + gl_FragColor = picking_filterColor(gl_FragColor); +} +`; + + const offsetsBuffer = new Buffer(gl, offsets); + const colorsBuffer = new Buffer(gl, colors); + const pickingColorsBuffer = new Buffer(gl, pickingColors); + + super( + gl, + Object.assign({}, props, { + vs, + fs, + modules: [picking, dirlight], + isInstanced: 1, + instanceCount: SIDE * SIDE, + geometry: new CubeGeometry(), + attributes: { + instanceSizes: new Float32Array([1]), // Constant attribute + instanceOffsets: [offsetsBuffer, {divisor: 1}], + instanceColors: [colorsBuffer, {divisor: 1}], + instancePickingColors: [pickingColorsBuffer, {divisor: 1}] + } + }) + ); + } +} + +export default class AppAnimationLoop extends AnimationLoop { + constructor() { + super({createFramebuffer: true, debug: true}); + } + + static getInfo() { + return INFO_HTML; + } + + onInitialize({gl, _animationLoop}) { + setParameters(gl, { + clearColor: [0, 0, 0, 1], + clearDepth: 1, + depthTest: true, + depthFunc: gl.LEQUAL + }); + + const timeRate = 0.01; + const eyeXRate = 0.0003; + const eyeYRate = 0.0004; + const eyeZRate = 0.0002; + + const timeChannel = this.timeline.addChannel({ + rate: timeRate + }); + + const eyeXChannel = this.timeline.addChannel({ + rate: eyeXRate + }); + + const eyeYChannel = this.timeline.addChannel({ + rate: eyeYRate + }); + + const eyeZChannel = this.timeline.addChannel({ + rate: eyeZRate + }); + + playButton.addEventListener('click', () => { + this.timeline.play(); + }); + + pauseButton.addEventListener('click', () => { + this.timeline.pause(); + }); + + resetButton.addEventListener('click', () => { + this.timeline.reset(); + }); + + timeSlider.addEventListener('input', event => { + this.timeline.setTime(parseFloat(event.target.value)); + }); + + xformSlider.value = timeRate; + eyeXSlider.value = eyeXRate; + eyeYSlider.value = eyeYRate; + eyeZSlider.value = eyeZRate; + + xformSlider.addEventListener('input', event => { + this.timeline.setChannelProps(timeChannel, { + rate: parseFloat(event.target.value) + }); + }); + + eyeXSlider.addEventListener('input', event => { + this.timeline.setChannelProps(eyeXChannel, { + rate: parseFloat(event.target.value) + }); + }); + + eyeYSlider.addEventListener('input', event => { + this.timeline.setChannelProps(eyeYChannel, { + rate: parseFloat(event.target.value) + }); + }); + + eyeZSlider.addEventListener('input', event => { + this.timeline.setChannelProps(eyeZChannel, { + rate: parseFloat(event.target.value) + }); + }); + + this.cube = new InstancedCube(gl, { + _animationLoop, + uniforms: { + uTime: ({_timeline}) => _timeline.getChannelTime(timeChannel), + // Basic projection matrix + uProjection: ({aspect}) => + new Matrix4().perspective({fov: radians(60), aspect, near: 1, far: 2048.0}), + // Move the eye around the plane + uView: ({_timeline}) => + new Matrix4().lookAt({ + center: [0, 0, 0], + eye: [ + (Math.cos(_timeline.getChannelTime(eyeXChannel)) * SIDE) / 2, + (Math.sin(_timeline.getChannelTime(eyeYChannel)) * SIDE) / 2, + ((Math.sin(_timeline.getChannelTime(eyeZChannel)) + 1) * SIDE) / 4 + 32 + ] + }), + // Rotate all the individual cubes + uModel: ({tick}) => new Matrix4().rotateX(tick * 0.01).rotateY(tick * 0.013) + } + }); + } + + onRender(animationProps) { + const {gl} = animationProps; + timeSlider.value = this.timeline.getTime(); + + const {framebuffer, useDevicePixels, _mousePosition} = animationProps; + + if (_mousePosition) { + const dpr = useDevicePixels ? getDevicePixelRatio() : 1; + + const pickX = _mousePosition[0] * dpr; + const pickY = gl.canvas.height - _mousePosition[1] * dpr; + + pickInstance(gl, pickX, pickY, this.cube, framebuffer); + } + + // Draw the cubes + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + this.cube.draw(); + } + + onFinalize({gl}) { + this.cube.delete(); + } +} + +function pickInstance(gl, pickX, pickY, model, framebuffer) { + framebuffer.clear({color: true, depth: true}); + // Render picking colors + /* eslint-disable camelcase */ + model.setUniforms({picking_uActive: 1}); + model.draw({framebuffer}); + model.setUniforms({picking_uActive: 0}); + + const color = readPixelsToArray(framebuffer, { + sourceX: pickX, + sourceY: pickY, + sourceWidth: 1, + sourceHeight: 1, + sourceFormat: gl.RGBA, + sourceType: gl.UNSIGNED_BYTE + }); + + if (color[0] + color[1] + color[2] > 0) { + model.updateModuleSettings({ + pickingSelectedColor: color + }); + } else { + model.updateModuleSettings({ + pickingSelectedColor: null + }); + } +} + +/* global window */ +if (typeof window !== 'undefined' && !window.website) { + const animationLoop = new AppAnimationLoop(); + animationLoop.start(); +} diff --git a/examples/core/animation/package.json b/examples/core/animation/package.json new file mode 100644 index 000000000..ebc7686e2 --- /dev/null +++ b/examples/core/animation/package.json @@ -0,0 +1,16 @@ +{ + "scripts": { + "start": "webpack-dev-server --progress --hot --open -d", + "start-local": "webpack-dev-server --env.local --progress --hot --open -d", + "build": "webpack --env.local --env.analyze --profile --json > stats.json" + }, + "dependencies": { + "@luma.gl/core": "^7.0.0-beta", + "math.gl": "^2.3.1" + }, + "devDependencies": { + "html-webpack-plugin": "^3.2.0", + "webpack": "^4.3.0", + "webpack-dev-server": "^3.1.1" + } +} diff --git a/examples/core/animation/webpack.config.js b/examples/core/animation/webpack.config.js new file mode 100644 index 000000000..e38bed025 --- /dev/null +++ b/examples/core/animation/webpack.config.js @@ -0,0 +1,16 @@ +const {resolve} = require('path'); +// eslint-disable-next-line import/no-extraneous-dependencies +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +const CONFIG = { + mode: 'development', + + entry: { + app: resolve('./app.js') + }, + + plugins: [new HtmlWebpackPlugin({title: 'Animation'})] +}; + +// This line enables bundling against src in this repo rather than installed module +module.exports = env => (env ? require('../../webpack.config.local')(CONFIG)(env) : CONFIG); diff --git a/examples/core/instancing/app.js b/examples/core/instancing/app.js index 6a6af5495..9dece5c1f 100644 --- a/examples/core/instancing/app.js +++ b/examples/core/instancing/app.js @@ -129,21 +129,37 @@ export default class AppAnimationLoop extends AnimationLoop { depthFunc: gl.LEQUAL }); + const timeChannel = this.timeline.addChannel({ + rate: 0.01 + }); + + const eyeXChannel = this.timeline.addChannel({ + rate: 0.0003 + }); + + const eyeYChannel = this.timeline.addChannel({ + rate: 0.0004 + }); + + const eyeZChannel = this.timeline.addChannel({ + rate: 0.0002 + }); + this.cube = new InstancedCube(gl, { _animationLoop, uniforms: { - uTime: ({tick}) => tick * 0.1, + uTime: ({_timeline}) => _timeline.getChannelTime(timeChannel), // Basic projection matrix uProjection: ({aspect}) => new Matrix4().perspective({fov: radians(60), aspect, near: 1, far: 2048.0}), // Move the eye around the plane - uView: ({tick}) => + uView: ({_timeline}) => new Matrix4().lookAt({ center: [0, 0, 0], eye: [ - (Math.cos(tick * 0.005) * SIDE) / 2, - (Math.sin(tick * 0.006) * SIDE) / 2, - ((Math.sin(tick * 0.0035) + 1) * SIDE) / 4 + 32 + (Math.cos(_timeline.getChannelTime(eyeXChannel)) * SIDE) / 2, + (Math.sin(_timeline.getChannelTime(eyeYChannel)) * SIDE) / 2, + ((Math.sin(_timeline.getChannelTime(eyeZChannel)) + 1) * SIDE) / 4 + 32 ] }), // Rotate all the individual cubes diff --git a/modules/core/src/lib/animation-loop.js b/modules/core/src/lib/animation-loop.js index bd8f407ff..a4751af17 100644 --- a/modules/core/src/lib/animation-loop.js +++ b/modules/core/src/lib/animation-loop.js @@ -13,6 +13,7 @@ import { // TODO - remove dependency on framebuffer (bundle size impact) Framebuffer } from '@luma.gl/webgl'; +import {Timeline} from './timeline'; import {log, assert} from '../utils'; @@ -65,6 +66,7 @@ export default class AnimationLoop { // state this.gl = gl; this.needsRedraw = null; + this.timeline = new Timeline(); this.stats = stats; this.cpuTime = this.stats.get('CPU Time'); this.gpuTime = this.stats.get('GPU Time'); @@ -136,6 +138,7 @@ export default class AnimationLoop { this._startEventHandling(); // Initialize the callback data + this.timeline.play(); this._initializeCallbackData(); this._updateCallbackData(); @@ -338,12 +341,15 @@ export default class AnimationLoop { // Animation props startTime: Date.now(), - time: 0, + engineTime: 0, tick: 0, tock: 0, - // canvas + + // Timeline time for back compatibility + time: 0, // Experimental + _timeline: this.timeline, _loop: this, _animationLoop: this, _mousePosition: null // Event props @@ -366,11 +372,16 @@ export default class AnimationLoop { this.animationProps.needsRedraw = this.needsRedraw; - // Increment tick - this.animationProps.time = Date.now() - this.animationProps.startTime; + // Update time properties + this.animationProps.engineTime = Date.now() - this.animationProps.startTime; + + this.timeline.update(this.animationProps.engineTime); this.animationProps.tick = Math.floor((this.animationProps.time / 1000) * 60); this.animationProps.tock++; + // For back compatibility + this.animationProps.time = this.timeline.getTime(); + // experimental this.animationProps._offScreen = this.offScreen; } diff --git a/modules/core/src/lib/timeline.js b/modules/core/src/lib/timeline.js new file mode 100644 index 000000000..d45337e71 --- /dev/null +++ b/modules/core/src/lib/timeline.js @@ -0,0 +1,111 @@ +const WRAP_LOOP = 0; +const WRAP_CLAMP = 1; +const WRAP_MAP = { + loop: WRAP_LOOP, + clamp: WRAP_CLAMP +}; + +let ids = 0; + +export class Timeline { + constructor() { + this.time = 0; + this.duration = Number.POSITIVE_INFINITY; + this.wrapMode = WRAP_LOOP; + this.channels = new Map(); + this.rate = 1; + this.playing = false; + this.lastEngineTime = -1; + } + + addChannel(props) { + const {duration = Number.POSITIVE_INFINITY, wrapMode = 'loop', rate = 1} = props; + + const handle = ids++; + const channel = { + time: 0, + duration: duration * rate, + wrapMode: WRAP_MAP[wrapMode], + rate + }; + this._setChannelTime(channel, this.time); + this.channels.set(handle, channel); + + return handle; + } + + removeChannel(handle) { + this.channels.delete(handle); + } + + getTime() { + return this.time; + } + + getChannelTime(handle) { + const channel = this.channels.get(handle); + + if (channel === undefined) { + return -1; + } + + return channel.time; + } + + setTime(time) { + this._setChannelTime(this, time); + const channels = this.channels.values(); + for (const channel of channels) { + this._setChannelTime(channel, this.time); + } + } + + setChannelProps(handle, props = {}) { + const channel = this.channels.get(handle); + + if (channel === undefined) { + return; + } + + const {duration = channel.duration, rate = channel.rate} = props; + + channel.duration = duration; + channel.rate = rate; + + if (props.wrapMode) { + channel.wrapMode = WRAP_MAP[props.wrapMode]; + } + } + + play() { + this.playing = true; + } + + pause() { + this.playing = false; + this.lastEngineTime = -1; + } + + reset() { + this.setTime(0); + } + + update(engineTime) { + if (this.playing) { + if (this.lastEngineTime === -1) { + this.lastEngineTime = engineTime; + } + this.setTime(this.time + (engineTime - this.lastEngineTime)); + this.lastEngineTime = engineTime; + } + } + + _setChannelTime(channel, time) { + channel.time = time * channel.rate; + if (channel.wrapMode === WRAP_LOOP) { + channel.time %= channel.duration; + } else { + channel.time = Math.max(0, Math.min(channel.time, channel.duration)); + } + } +} diff --git a/modules/core/test/lib/animation-loop.spec.js b/modules/core/test/lib/animation-loop.spec.js index 57a9d3612..3c6222a92 100644 --- a/modules/core/test/lib/animation-loop.spec.js +++ b/modules/core/test/lib/animation-loop.spec.js @@ -170,3 +170,64 @@ test('core#AnimationLoop a start/stop/start should not call initialize again', t t.end(); }, 150); }); + +test('core#AnimationLoop timeline', t => { + if (typeof document === 'undefined') { + t.comment('browser-only test'); + t.end(); + return; + } + + const {gl} = fixture; + + const animationLoop = new AnimationLoop({ + gl + }); + const timeline = animationLoop.timeline; + timeline.pause(); + timeline.reset(); + t.is(timeline.getTime(), 0, 'Timeline was reset'); + const channel1 = timeline.addChannel({ + rate: 2, + duration: 4, + wrapMode: 'loop' + }); + const channel2 = timeline.addChannel({ + rate: 3, + duration: 4, + wrapMode: 'clamp' + }); + t.is(timeline.getChannelTime(channel1), 0, 'Channel 1 initialized'); + t.is(timeline.getChannelTime(channel2), 0, 'Channel 2 initialized'); + + timeline.setTime(2); + t.is(timeline.getTime(), 2, 'Timeline was set'); + t.is(timeline.getChannelTime(channel1), 4, 'Channel 1 was set'); + t.is(timeline.getChannelTime(channel2), 6, 'Channel 2 was set'); + + timeline.setTime(6); + t.is(timeline.getChannelTime(channel1), 4, 'Channel 1 looped'); + t.is(timeline.getChannelTime(channel2), 12, 'Channel 2 clamped'); + + timeline.reset(); + t.is(timeline.getTime(), 0, 'Timeline was reset'); + timeline.play(); + timeline.update(4); + timeline.update(6); + t.is(timeline.getTime(), 2, 'Timeline was set on update while playing'); + t.is(timeline.getChannelTime(channel1), 4, 'Channel 1 was set on update while playing'); + t.is(timeline.getChannelTime(channel2), 6, 'Channel 2 was set on update while playing'); + + timeline.reset(); + t.is(timeline.getTime(), 0, 'Timeline was reset'); + timeline.pause(); + timeline.update(4); + timeline.update(6); + t.is(timeline.getTime(), 0, 'Timeline was not set on update while paused'); + t.is(timeline.getChannelTime(channel1), 0, 'Channel 1 was not set on update while paused'); + t.is(timeline.getChannelTime(channel2), 0, 'Channel 2 was not set on update while paused'); + + timeline.removeChannel(channel1); + t.is(timeline.getChannelTime(channel1), -1, 'Channel 1 was deleted'); + t.end(); +});