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.

My model has a bicycle chain with 90+ links, two wheels, two
chain rings (with different rates of rotation, which are both
different again from the rate of rotation of the wheels), two
drive arms, and a rider. Using nodeTransformations would be
extremely complex, and any changes to the model would require
updating all the associated javascript to match the model - and
on the site where I use the model, I actually use different
models for different types of activity - so I want to just be
able to drop in a new model, and have it "just work" (to make
it work, I have to ensure that the animation's "duration" in
seconds is equal to the distance in meters the model would
move during one iteration of the animation).

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

I also added a manualAnimation property to
ModelAnimationCollection so we can still do the short circuit
exit from ModelAnimationCollection.update.

The new sandcastle example is just a clone of Time Dynamic Wheels,
rewritten to use a more complex model, and the new functionality.
  • Loading branch information
markw65 committed Apr 22, 2022
1 parent d748986 commit bb93f6c
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 39 deletions.
Binary file added Apps/SampleData/models/Elliptigo/elliptigo.glb
Binary file not shown.
172 changes: 172 additions & 0 deletions Apps/Sandcastle/gallery/Manually Controlled Animation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<!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);

const wheelRadius = 0.52; //in meters.
const circumference = Math.PI * wheelRadius * 2;
// Create a path for our vehicle 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 vehicle 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 vehicle model.
const vehiclePrimitive = viewer.scene.primitives.add(
Cesium.Model.fromGltf({
url: "../../SampleData/models/Elliptigo/elliptigo.glb",
cull: false,
})
);
const vehicleLabel = viewer.entities.add({
position: position,
orientation: new Cesium.VelocityOrientationProperty(position), // Automatically set the vehicle'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, 2.3, 0),
},
});

vehiclePrimitive.readyPromise.then(function (model) {
model.activeAnimations.addAll({
loop: Cesium.ModelAnimationLoop.REPEAT,
});
model.activeAnimations.manualAnimation = true;
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);
const dist = distance.getValue(time);
const animations = model.activeAnimations;
const length = animations.length;
for (let i = 0; i < length; ++i) {
animations.get(i).animationTime = dist;
}
});
});
viewer.trackedEntity = vehicleLabel;
vehicleLabel.viewFrom = new Cesium.Cartesian3(-10.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.
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

##### Additions :tada:

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

- `KmlDataSource` now exposes the `camera` and `canvas` properties, which are used to provide information about the state of the Viewer when making network requests for a [Link](https://developers.google.com/kml/documentation/kmlreference#link). Passing these values in the constructor is now optional.
- Added `GeoJsonSource.process` to support adding features without removing existing entities, similar to `CzmlDataSource.process`. [#9275](https://github.com/CesiumGS/cesium/issues/9275)
- Added support for morph targets in `ModelExperimental`. [#10271](https://github.com/CesiumGS/cesium/pull/10271)
Expand Down
8 changes: 8 additions & 0 deletions Source/Scene/ModelAnimation.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ 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 as the local animation time,
* rather than deriving the local animation time from the current time
* and the above parameters.
*/
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
103 changes: 64 additions & 39 deletions Source/Scene/ModelAnimationCollection.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ function ModelAnimationCollection(model) {
*/
this.animationRemoved = new Event();

/**
* When false, all the animations are time based. When true, each animation can choose to
* override the local animation time.
*/
this.manualAnimation = false;

this._model = model;
this._scheduledAnimations = [];
this._previousTime = undefined;
Expand Down Expand Up @@ -366,8 +372,11 @@ 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.manualAnimation &&
JulianDate.equals(frameState.time, this._previousTime)
) {
// Animations are all time-dependent so do not animate when paused or picking
return false;
}
this._previousTime = JulianDate.clone(frameState.time, this._previousTime);
Expand All @@ -380,54 +389,70 @@ ModelAnimationCollection.prototype.update = function (frameState) {
const scheduledAnimation = scheduledAnimations[i];
const runtimeAnimation = scheduledAnimation._runtimeAnimation;

if (!defined(scheduledAnimation._computedStartTime)) {
scheduledAnimation._computedStartTime = JulianDate.addSeconds(
defaultValue(scheduledAnimation.startTime, sceneTime),
scheduledAnimation.delay,
new JulianDate()
);
}

if (!defined(scheduledAnimation._duration)) {
scheduledAnimation._duration =
runtimeAnimation.stopTime * (1.0 / scheduledAnimation.multiplier);
}

const startTime = scheduledAnimation._computedStartTime;
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;

// Clamp delta to stop time, if defined.
if (
duration !== 0.0 &&
defined(stopTime) &&
JulianDate.greaterThan(sceneTime, stopTime)
) {
delta = JulianDate.secondsDifference(stopTime, startTime) / duration;
}
let delta;
let play;
if (this.manualAnimation && defined(scheduledAnimation.animationTime)) {
if (
scheduledAnimation.animationTime ===
scheduledAnimation._prevAnimationTime
) {
continue;
}
scheduledAnimation._prevAnimationTime = scheduledAnimation.animationTime;
delta =
duration !== 0.0 ? scheduledAnimation.animationTime / duration : 0.0;

play = true;
} else {
if (!defined(scheduledAnimation._computedStartTime)) {
scheduledAnimation._computedStartTime = JulianDate.addSeconds(
defaultValue(scheduledAnimation.startTime, sceneTime),
scheduledAnimation.delay,
new JulianDate()
);
}

const startTime = scheduledAnimation._computedStartTime;
const stopTime = scheduledAnimation.stopTime;

const pastStartTime = delta >= 0.0;
// [0.0, 1.0] normalized local animation time
delta =
duration !== 0.0
? JulianDate.secondsDifference(sceneTime, startTime) / duration
: 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
// * we did not reach a user-provided stop time.
// Clamp delta to stop time, if defined.
if (
duration !== 0.0 &&
defined(stopTime) &&
JulianDate.greaterThan(sceneTime, stopTime)
) {
delta = JulianDate.secondsDifference(stopTime, startTime) / duration;
}

const repeat =
scheduledAnimation.loop === ModelAnimationLoop.REPEAT ||
scheduledAnimation.loop === ModelAnimationLoop.MIRRORED_REPEAT;
const pastStartTime = delta >= 0.0;

const play =
(pastStartTime || (repeat && !defined(scheduledAnimation.startTime))) &&
(delta <= 1.0 || repeat) &&
(!defined(stopTime) || JulianDate.lessThanOrEquals(sceneTime, stopTime));
// 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
// * we did not reach a user-provided stop time.

const repeat =
scheduledAnimation.loop === ModelAnimationLoop.REPEAT ||
scheduledAnimation.loop === ModelAnimationLoop.MIRRORED_REPEAT;

play =
(pastStartTime || (repeat && !defined(scheduledAnimation.startTime))) &&
(delta <= 1.0 || repeat) &&
(!defined(stopTime) ||
JulianDate.lessThanOrEquals(sceneTime, stopTime));
}
// If it IS, or WAS, animating...
if (play || scheduledAnimation._state === ModelAnimationState.ANIMATING) {
// STOPPED -> ANIMATING state transition?
Expand Down
40 changes: 40 additions & 0 deletions Specs/Scene/ModelSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1910,6 +1910,46 @@ 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;
const a = animations.add({
name: "animation_1",
animationTime: 0,
});

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

animations.manualAnimation = true;
animBoxesModel.show = true;
scene.renderForSpecs(time);
a.animationTime = 0.5;
scene.renderForSpecs(JulianDate.addSeconds(time, 1.0, new JulianDate()));
scene.renderForSpecs(JulianDate.addSeconds(time, 2.0, new JulianDate()));
a.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;
animations.manualAnimation = 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 bb93f6c

Please sign in to comment.