Skip to content

Commit

Permalink
Add support for explicit control over model animations
Browse files Browse the repository at this point in the history
Cesium currently only supports time based animation. This can
be inconvenient if the phase of the animation is related to
something other than time (eg distance along a path of an
object moving at a variable speed).

This came up before in CesiumGS#7361, but the author was persuaded that
it was better to use nodeTransformations to explicitly control
the model. That was (just) doable with that example, because
there were just 3 pairs of wheels, all of which needed the
exact same, relatively trivial, transformations. The proposed
solution was also cumbersome, relying on modifying `multiplier`
on the fly, with the downside that modifying multiplier also
reset the phase of the animation.

For more complex models, with less uniform animations, this
approach isn't really doable - especially if you want the same
code to work for multiple models.

This adds an animationTime function to ModelAnimation. If set,
it's used by ModelAnimationCollection.update to compute the
localAnimationTime, rather than using the current clock time.

I also added an animateWhilePaused property to
ModelAnimationCollection. When false (the default), we continue
to do the short circuit exit from ModelAnimationCollection.update
when the scene time hasn't changed. When true, a suitable
animationTime function can continue to animate the model, even when
scene time is paused.

The new sandcastle example is just a clone of Time Dynamic Wheels,
rewritten to use Cesium_Man.glb, and the new functionality.
  • Loading branch information
markw65 committed May 10, 2022
1 parent 96ba179 commit cd8bfda
Show file tree
Hide file tree
Showing 6 changed files with 286 additions and 18 deletions.
167 changes: 167 additions & 0 deletions Apps/Sandcastle/gallery/Manually Controlled Animation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"
/>
<meta name="description" content="Manually control a model's animations." />
<meta name="cesium-sandcastle-labels" content="Showcases" />
<title>Cesium Demo</title>
<script type="text/javascript" src="../Sandcastle-header.js"></script>
<script
type="text/javascript"
src="../../../Build/CesiumUnminified/Cesium.js"
nomodule
></script>
<script type="module" src="../load-cesium-es6.js"></script>
</head>
<body
class="sandcastle-loading"
data-sandcastle-bucket="bucket-requirejs.html"
>
<style>
@import url(../templates/bucket.css);
</style>
<div id="cesiumContainer" class="fullSize"></div>
<div id="loadingOverlay"><h1>Loading...</h1></div>
<div id="toolbar"></div>
<script id="cesium_sandcastle_script">
function startup(Cesium) {
"use strict";
//Sandcastle_Begin
const viewer = new Cesium.Viewer("cesiumContainer", {
shouldAnimate: true,
});

//Make sure viewer is at the desired time.
const start = Cesium.JulianDate.fromDate(new Date(2018, 11, 12, 15));
const totalSeconds = 30;
const stop = Cesium.JulianDate.addSeconds(
start,
totalSeconds,
new Cesium.JulianDate()
);
viewer.clock.startTime = start.clone();
viewer.clock.stopTime = stop.clone();
viewer.clock.currentTime = start.clone();
viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP;
viewer.timeline.zoomTo(start, stop);

// Create a path for our model by lerping between two positions.
const position = new Cesium.SampledPositionProperty();
const distance = new Cesium.SampledProperty(Number);
const startPosition = new Cesium.Cartesian3(
-2379556.799372864,
-4665528.205030263,
3628013.106599678
);
const endPosition = new Cesium.Cartesian3(
-2379603.7074103747,
-4665623.48990283,
3627860.82704567
);
// A velocity vector property will give us the entity's speed and direction at any given time.
const velocityVectorProperty = new Cesium.VelocityVectorProperty(
position,
false
);
const velocityVector = new Cesium.Cartesian3();

const numberOfSamples = 100;
let prevLocation = startPosition;
let totalDistance = 0;
for (let i = 0; i <= numberOfSamples; ++i) {
const factor = i / numberOfSamples;
const time = Cesium.JulianDate.addSeconds(
start,
factor * totalSeconds,
new Cesium.JulianDate()
);

// Lerp using a non-linear factor so that the model accelerates.
const locationFactor = Math.pow(factor, 2);
const location = Cesium.Cartesian3.lerp(
startPosition,
endPosition,
locationFactor,
new Cesium.Cartesian3()
);
position.addSample(time, location);
distance.addSample(
time,
(totalDistance += Cesium.Cartesian3.distance(
location,
prevLocation
))
);
prevLocation = location;
}

function updateSpeedLabel(time, result) {
velocityVectorProperty.getValue(time, velocityVector);
const metersPerSecond = Cesium.Cartesian3.magnitude(velocityVector);
const kmPerHour = Math.round(metersPerSecond * 3.6);

return `${kmPerHour} km/hr`;
}

// Add our model.
const modelPrimitive = viewer.scene.primitives.add(
Cesium.Model.fromGltf({
url: "../../SampleData/models/CesiumMan/Cesium_Man.glb",
scale: 4,
})
);
const modelLabel = viewer.entities.add({
position: position,
orientation: new Cesium.VelocityOrientationProperty(position), // Automatically set the model's orientation to the direction it's facing.
label: {
text: new Cesium.CallbackProperty(updateSpeedLabel, false),
font: "20px sans-serif",
showBackground: true,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0.0,
100.0
),
eyeOffset: new Cesium.Cartesian3(0, 7.2, 0),
},
});

modelPrimitive.readyPromise.then(function (model) {
model.activeAnimations.addAll({
loop: Cesium.ModelAnimationLoop.REPEAT,
animationTime: function (duration) {
return distance.getValue(viewer.clock.currentTime) / duration;
},
multiplier: 0.25,
});
const rot = new Cesium.Matrix3();
viewer.scene.preUpdate.addEventListener(function () {
const time = viewer.clock.currentTime;
const pos = position.getValue(time);
const vel = velocityVectorProperty.getValue(time);
Cesium.Cartesian3.normalize(vel, vel);
Cesium.Transforms.rotationMatrixFromPositionVelocity(
pos,
vel,
viewer.scene.globe.ellipsoid,
rot
);
Cesium.Matrix4.fromRotationTranslation(rot, pos, model.modelMatrix);
});
});
viewer.trackedEntity = modelLabel;
modelLabel.viewFrom = new Cesium.Cartesian3(-20.0, -7.0, 4.0);
//Sandcastle_End
Sandcastle.finishedLoading();
}
if (typeof Cesium !== "undefined") {
window.startupCalled = true;
startup(Cesium);
}
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
- `Cesium3DTileStyle` constructor parameters of `string` or `Resource` type have been deprecated and will be removed in CesiumJS 1.96. If loading a style from a url, use `Cesium3DTileStyle.fromUrl` instead. [#10348](https://github.com/CesiumGS/cesium/pull/10348)
- `Cesium3DTileStyle.readyPromise` and `Cesium3DTileStyle.ready` have been deprecated and will be removed in CesiumJS 1.96. If loading a style from a url, use `Cesium3DTileStyle.fromUrl` instead. [#10348](https://github.com/CesiumGS/cesium/pull/10348)

##### Additions :tada:

- Added `ModelAnimationCollection.animateWhilePaused` and `ModelAnimation.animationTime` to allow explicit control over a model's animations.

### 1.93 - 2022-05-02

##### Breaking Changes :mega:
Expand Down
32 changes: 32 additions & 0 deletions Source/Scene/ModelAnimation.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ function ModelAnimation(options, model, runtimeAnimation) {
this._reverse = defaultValue(options.reverse, false);
this._loop = defaultValue(options.loop, ModelAnimationLoop.NONE);

/**
* If this is defined, it will be used to compute the local animation time
* instead of the scene's time.
*
* @type {ModelAnimation.AnimationTimeCallback}
* @default undefined
*/
this.animationTime = options.animationTime;
this._prevAnimationTime = undefined;

/**
* The event fired when this animation is started. This can be used, for
* example, to play a sound or start a particle system, when the animation starts.
Expand Down Expand Up @@ -230,4 +240,26 @@ Object.defineProperties(ModelAnimation.prototype, {
},
},
});
/**
* A function used to compute the local animation time for a ModelAnimation.
* @callback ModelAnimation.AnimationTimeCallback
*
* @param {Number} duration The animation's original duration in seconds.
* @param {Number} seconds The seconds since the animation started, in scene time.
* @returns {Number} Returns the local animation time.
*
* @example
* // Use real time for model animation (also set
* // ModelAnimationCollection#animateWhilePaused)
* function animationTime(duration) {
* return Date.now() / 1000 / duration;
* }
*
* @example
* // Offset the phase of the animation, so it starts halfway
* // through its cycle.
* function animationTime(duration, seconds) {
* return seconds / duration + .5;
* }
*/
export default ModelAnimation;
60 changes: 42 additions & 18 deletions Source/Scene/ModelAnimationCollection.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ function ModelAnimationCollection(model) {
*/
this.animationRemoved = new Event();

/**
* When true, the animation will play even when the scene time is paused. However,
* whether animation takes place will depend on the animationTime functions assigned
* to the model's animations. By default, this is based on scene time, so models using
* the default will not animate regardless of this setting.
*
* @type {Boolean}
* @default false
*/
this.animateWhilePaused = false;

this._model = model;
this._scheduledAnimations = [];
this._previousTime = undefined;
Expand Down Expand Up @@ -93,6 +104,7 @@ function add(collection, index, options) {
* @param {Number} [options.multiplier=1.0] Values greater than <code>1.0</code> increase the speed that the animation is played relative to the scene clock speed; values less than <code>1.0</code> decrease the speed.
* @param {Boolean} [options.reverse=false] When <code>true</code>, the animation is played in reverse.
* @param {ModelAnimationLoop} [options.loop=ModelAnimationLoop.NONE] Determines if and how the animation is looped.
* @param {ModelAnimation.AnimationTimeCallback} [options.animationTime=undefined] If defined, computes the local animation time for this animation.
* @returns {ModelAnimation} The animation that was added to the collection.
*
* @exception {DeveloperError} Animations are not loaded. Wait for the {@link Model#readyPromise} to resolve.
Expand Down Expand Up @@ -204,6 +216,7 @@ ModelAnimationCollection.prototype.add = function (options) {
* @param {Number} [options.multiplier=1.0] Values greater than <code>1.0</code> increase the speed that the animations play relative to the scene clock speed; values less than <code>1.0</code> decrease the speed.
* @param {Boolean} [options.reverse=false] When <code>true</code>, the animations are played in reverse.
* @param {ModelAnimationLoop} [options.loop=ModelAnimationLoop.NONE] Determines if and how the animations are looped.
* @param {ModelAnimation.AnimationTimeCallback} [options.animationTime=undefined] If defined, computes the local animation time for all of the animations.
* @returns {ModelAnimation[]} An array of {@link ModelAnimation} objects, one for each animation added to the collection. If there are no glTF animations, the array is empty.
*
* @exception {DeveloperError} Animations are not loaded. Wait for the {@link Model#readyPromise} to resolve.
Expand Down Expand Up @@ -366,8 +379,10 @@ ModelAnimationCollection.prototype.update = function (frameState) {
return false;
}

if (JulianDate.equals(frameState.time, this._previousTime)) {
// Animations are currently only time-dependent so do not animate when paused or picking
if (
!this.animateWhilePaused &&
JulianDate.equals(frameState.time, this._previousTime)
) {
return false;
}
this._previousTime = JulianDate.clone(frameState.time, this._previousTime);
Expand Down Expand Up @@ -397,23 +412,22 @@ ModelAnimationCollection.prototype.update = function (frameState) {
const duration = scheduledAnimation._duration;
const stopTime = scheduledAnimation.stopTime;

// [0.0, 1.0] normalized local animation time
let delta =
duration !== 0.0
? JulianDate.secondsDifference(sceneTime, startTime) / duration
: 0.0;
const pastStartTime = JulianDate.lessThanOrEquals(startTime, sceneTime);
const pastStopTime =
defined(stopTime) && JulianDate.greaterThan(sceneTime, stopTime);

// Clamp delta to stop time, if defined.
if (
duration !== 0.0 &&
defined(stopTime) &&
JulianDate.greaterThan(sceneTime, stopTime)
) {
delta = JulianDate.secondsDifference(stopTime, startTime) / duration;
// [0.0, 1.0] normalized local animation time
let delta = 0.0;
if (duration !== 0.0) {
const seconds = JulianDate.secondsDifference(
pastStopTime ? stopTime : sceneTime,
startTime
);
delta = scheduledAnimation.animationTime
? scheduledAnimation.animationTime(duration, seconds)
: seconds / duration;
}

const pastStartTime = delta >= 0.0;

// Play animation if
// * we are after the start time or the animation is being repeated, and
// * before the end of the animation's duration or the animation is being repeated, and
Expand All @@ -426,7 +440,17 @@ ModelAnimationCollection.prototype.update = function (frameState) {
const play =
(pastStartTime || (repeat && !defined(scheduledAnimation.startTime))) &&
(delta <= 1.0 || repeat) &&
(!defined(stopTime) || JulianDate.lessThanOrEquals(sceneTime, stopTime));
!pastStopTime;

if (
delta === scheduledAnimation._prevAnimationTime &&
!play === (scheduledAnimation._state === ModelAnimationState.STOPPED)
) {
// no change to delta, and no change to the animation state means we can
// skip the update this time around.
continue;
}
scheduledAnimation._prevAnimationTime = delta;

// If it IS, or WAS, animating...
if (play || scheduledAnimation._state === ModelAnimationState.ANIMATING) {
Expand All @@ -446,7 +470,7 @@ ModelAnimationCollection.prototype.update = function (frameState) {
) {
const floor = Math.floor(delta);
const fract = delta - floor;
// When even use (1.0 - fract) to mirror repeat
// When odd use (1.0 - fract) to mirror repeat
delta = floor % 2 === 1.0 ? 1.0 - fract : fract;
}

Expand Down
41 changes: 41 additions & 0 deletions Specs/Scene/ModelSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1910,6 +1910,47 @@ describe(
animBoxesModel.show = false;
});

it("animates with an explicit animation time", function () {
const time = JulianDate.fromDate(
new Date("January 1, 2014 12:00:00 UTC")
);
const animations = animBoxesModel.activeAnimations;
let animationTime = 0;
const a = animations.add({
name: "animation_1",
animationTime: function (duration) {
return animationTime / duration;
},
});

const spyUpdate = jasmine.createSpy("listener");
a.update.addEventListener(spyUpdate);

animBoxesModel.show = true;
scene.renderForSpecs(time);
animationTime = 0.5;
scene.renderForSpecs(JulianDate.addSeconds(time, 1.0, new JulianDate()));
scene.renderForSpecs(JulianDate.addSeconds(time, 2.0, new JulianDate()));
animationTime = 1.7;
scene.renderForSpecs(JulianDate.addSeconds(time, 3.0, new JulianDate()));

expect(spyUpdate.calls.count()).toEqual(3);
expect(spyUpdate.calls.argsFor(0)[2]).toEqualEpsilon(
0.0,
CesiumMath.EPSILON14
);
expect(spyUpdate.calls.argsFor(1)[2]).toEqualEpsilon(
0.5,
CesiumMath.EPSILON14
);
expect(spyUpdate.calls.argsFor(2)[2]).toEqualEpsilon(
1.7,
CesiumMath.EPSILON14
);
expect(animations.remove(a)).toEqual(true);
animBoxesModel.show = false;
});

it("animates with a multiplier", function () {
const time = JulianDate.fromDate(
new Date("January 1, 2014 12:00:00 UTC")
Expand Down

0 comments on commit cd8bfda

Please sign in to comment.