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();
+});