threepipe
Version:
A 3D viewer framework built on top of three.js in TypeScript with a focus on quality rendering, modularity and extensibility.
564 lines • 24.6 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var GLTFAnimationPlugin_1;
import { AViewerPluginSync } from '../../viewer';
import { absMax, now, onChange, onChange2, PointerDragHelper, serialize } from 'ts-browser-helpers';
import { uiButton, uiDropdown, uiFolderContainer, uiMonitor, uiSlider, uiToggle } from 'uiconfig.js';
import { AnimationMixer, LoopOnce, LoopRepeat } from 'three';
import { generateUUID } from '../../three';
/**
* Manages playback of GLTF animations.
*
* The GLTF animations can be created in any 3d software that supports GLTF export like Blender.
* If animations from multiple files are loaded, they will be merged in a single root object and played together.
*
* The time playback is managed automatically, but can be controlled manually by setting {@link autoIncrementTime} to false and using {@link setTime} to set the time.
*
* This plugin is made for playing, pausing, stopping, all the animations at once, while it is possible to play individual animations, it is not recommended.
*
* To play individual animations, with custom choreography, use the {@link GLTFAnimationPlugin.animations} property to get reference to the animation clips and actions. Create your own mixers and control the animation playback like in three.js
*
* @category Plugins
*/
let GLTFAnimationPlugin = GLTFAnimationPlugin_1 = class GLTFAnimationPlugin extends AViewerPluginSync {
/**
* Get the current state of the animation. (read only)
* use {@link playAnimation}, {@link pauseAnimation}, {@link stopAnimation} to change the state.
*/
get animationState() {
return this._animationState;
}
/**
* Get the current animation time. (read only)
* The time is managed automatically.
* To manage the time manually set {@link autoIncrementTime} to false and use {@link setTime} to change the time.
*/
get animationTime() {
return this._animationTime;
}
/**
* Get the current animation duration (max of all animations). (read only)
*/
get animationDuration() {
return this._animationDuration;
}
playPauseAnimation() {
this._animationState === 'playing' ? this.pauseAnimation() : this.playAnimation();
}
constructor() {
super();
this.enabled = true;
/**
* List of GLTF animations loaded with the models.
* The animations are standard threejs AnimationClip and their AnimationAction. Each set of actions also has a mixer.
*/
this.animations = [];
/**
* If true, the animation time will be automatically incremented by the time delta, otherwise it has to be set manually between 0 and the animationDuration using `setTime`. (default: true)
*/
this.autoIncrementTime = true;
/**
* Loop the complete animation. (not individual actions)
* This happens {@link loopRepetitions} times.
*/
this.loopAnimations = true;
/**
* Number of times to loop the animation. (not individual actions)
* Only applicable when {@link loopAnimations} is true.
*/
this.loopRepetitions = Infinity;
/**
* Timescale for the animation. (not individual actions)
* If set to 0, it will be ignored.
*/
this.timeScale = 1;
/**
* Speed of the animation. (not individual actions)
* This can be set to 0.
*/
this.animationSpeed = 1;
/**
* Automatically track mouse wheel events to seek animations
* Control damping/smoothness with {@link scrollAnimationDamping}
* See also {@link animateOnPageScroll}. {@link animateOnDrag}
*/
this.animateOnScroll = false;
/**
* Damping for the scroll animation, when {@link animateOnScroll} is true.
*/
this.scrollAnimationDamping = 0.1;
/**
* Automatically track scroll event in window and use `window.scrollY` along with {@link pageScrollHeight} to seek animations
* Control damping/smoothness with {@link pageScrollAnimationDamping}
* See also {@link animateOnDrag}, {@link animateOnScroll}
*/
this.animateOnPageScroll = false;
/**
* Damping for the scroll animation, when {@link animateOnPageScroll} is true.
*/
this.pageScrollAnimationDamping = 0.1;
/**
* Automatically track drag events in either x or y axes to seek animations
* Control axis with {@link dragAxis} and damping/smoothness with {@link dragAnimationDamping}
*/
this.animateOnDrag = false;
/**
* Axis to track for drag events, when {@link animateOnDrag} is true.
* `x` will track horizontal drag, `y` will track vertical drag.
*/
this.dragAxis = 'y';
/**
* Damping for the drag animation, when {@link animateOnDrag} is true.
*/
this.dragAnimationDamping = 0.3;
/**
* If true, the animation will be played automatically when the model(any model with animations) is loaded.
*/
this.autoplayOnLoad = false;
/**
* Sync the duration of all clips based on the max duration, helpful for things like timeline markers
*/
this.syncMaxDuration = false;
this._animationState = 'none';
this._lastAnimationTime = 0;
this._animationTime = 0;
this._animationDuration = 0;
this._scrollAnimationState = 0;
this._pageScrollAnimationState = 0;
this._dragAnimationState = 0;
this._pointerDragHelper = new PointerDragHelper();
this._lastFrameTime = 0;
this._fadeDisabled = false;
this._lastAnimId = '';
this._objectAdded = (ev) => {
const object = ev.object;
if (!this._viewer)
return;
let changed = false;
object.traverse((obj) => {
if (!this._viewer)
return;
const clips = obj.animations;
if (clips.length < 1)
return;
const duration = Math.max(...clips.map(an => an.duration));
if (object.userData.gltfAnim_SyncMaxDuration ?? this.syncMaxDuration) {
clips.forEach(cp => cp.duration = duration);
object.userData.gltfAnim_SyncMaxDuration = true;
} // todo: check why do we need to do this? wont this create problems with looping or is it for that so that looping works in sync.
const mixer = new AnimationMixer(this._viewer.scene.modelRoot); // add to modelRoot so it works with GLTF export...
const actions = clips.map(an => mixer.clipAction(an).setLoop(this.loopAnimations ? LoopRepeat : LoopOnce, this.loopRepetitions));
actions.forEach(ac => ac.clampWhenFinished = true);
this.animations.push({
mixer, clips, actions, duration,
});
// todo remove on object dispose
changed = true;
});
// this.playAnimation()
if (changed) {
this._onPropertyChange(!this.autoplayOnLoad);
if (this.autoplayOnLoad)
this.playAnimation();
}
return;
};
this.pageScrollHeight = () => Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight) - window.innerHeight;
this.playClips = this.playClips.bind(this);
this.playClip = this.playClip.bind(this);
this.playAnimation = this.playAnimation.bind(this);
this.playPauseAnimation = this.playPauseAnimation.bind(this);
this.pauseAnimation = this.pauseAnimation.bind(this);
this.stopAnimation = this.stopAnimation.bind(this);
this.resetAnimation = this.resetAnimation.bind(this);
this._onPropertyChange = this._onPropertyChange.bind(this);
this._postFrame = this._postFrame.bind(this);
this._wheel = this._wheel.bind(this);
this._scroll = this._scroll.bind(this);
this._pointerDragHelper.addEventListener('drag', this._drag.bind(this));
}
setTime(time) {
this._animationTime = Math.max(0, Math.min(time, this._animationDuration));
}
onAdded(viewer) {
super.onAdded(viewer);
viewer.scene.addEventListener('addSceneObject', this._objectAdded);
viewer.addEventListener('postFrame', this._postFrame);
window.addEventListener('wheel', this._wheel);
window.addEventListener('scroll', this._scroll);
this._pointerDragHelper.element = viewer.canvas;
}
onRemove(viewer) {
while (this.animations.length)
this.animations.pop();
viewer.scene.removeEventListener('addSceneObject', this._objectAdded);
viewer.removeEventListener('postFrame', this._postFrame);
window.removeEventListener('wheel', this._wheel);
window.removeEventListener('scroll', this._scroll);
this._pointerDragHelper.element = undefined;
return super.onRemove(viewer);
}
onStateChange() {
this.uiConfig?.uiRefresh?.(true, 'postFrame');
// this.uiConfig?.children?.map(value => value && getOrCall(value)).flat(2).forEach(v=>v?.uiRefresh?.())
}
/**
* This will play a single clip by name
* It might reset all other animations, this is a bug; https://codepen.io/repalash/pen/mdjgpvx
* @param name
* @param resetOnEnd
*/
async playClip(name, resetOnEnd = false) {
return this.playClips([name], resetOnEnd);
}
async playClips(names, resetOnEnd = false) {
const anims = [];
this.animations.forEach(({ actions }) => {
actions.forEach((action) => {
if (names.includes(action.getClip().name)) {
anims.push(action);
}
});
});
return this.playAnimation(resetOnEnd, anims);
}
/**
* Starts all the animations and returns a promise that resolves when all animations are done.
* @param resetOnEnd - if true, will reset the animation to the start position when it ends.
* @param animations - play specific animations, otherwise play all animations. Note: the promise returned (if this is set) from this will resolve before time if the animations was ever paused, or converged mode is on in recorder.
*/
async playAnimation(resetOnEnd = false, animations) {
if (this.isDisabled())
return;
let wasPlaying = false;
if (this._animationState === 'playing') {
this.stopAnimation(false); // stop and play again. reset is done below.
wasPlaying = true;
}
// safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', false)
let duration = 0;
const isAllAnimations = !animations;
if (!animations) {
animations = [];
this.animations.forEach(({ actions }) => {
// console.log(mixer, actions, clips)
animations.push(...actions);
});
}
if (wasPlaying)
this.resetAnimation();
else if (this.animationState !== 'paused') {
animations.forEach((ac) => {
ac.reset();
});
this._animationTime = 0;
}
const id = generateUUID();
this._lastAnimId = id; // todo: check logic
for (const ac of animations) {
// if (Math.abs(this.timeScale) > 0) {
// if (!(ac as any)._tTimeScale) (ac as any)._tTimeScale = ac.timeScale
// ac.timeScale = this.timeScale
// } else if ((ac as any)._tTimeScale) ac.timeScale = (ac as any)._tTimeScale
ac.setLoop(this.loopAnimations ? LoopRepeat : LoopOnce, this.loopRepetitions);
ac.play();
duration = Math.max(duration, ac.getClip().duration / Math.abs(ac.timeScale));
// if (!this._playingActions.includes(ac)) this._playingActions.push(ac)
// console.log(ac)
}
this._animationState = 'playing';
this._viewer?.setDirty();
if (!isAllAnimations) {
const loops = this.loopAnimations ? this.loopRepetitions : 1;
duration *= loops;
if (!isFinite(duration)) {
// infinite animation
return;
}
await new Promise((resolve) => {
const listen = (e) => {
if (e.time >= duration) {
this.removeEventListener('animationStep', listen);
resolve();
}
};
this.addEventListener('animationStep', listen);
});
// const animDuration = 1000 * duration - this._animationTime / this.animationSpeed + 0.01
//
// if (animDuration > 0) {
// await timeout(animDuration)
// return
// } // todo: handle pausing/early stop, converge mode for single animation playback
}
else {
if (!isFinite(this._animationDuration)) {
// infinite animation
return;
}
await new Promise((resolve) => {
const listen = () => {
this.removeEventListener('checkpointEnd', listen);
resolve();
};
this.addEventListener('checkpointEnd', listen);
});
}
if (id === this._lastAnimId) { // in-case multiple animations are started.
this.stopAnimation(resetOnEnd);
}
return;
}
pauseAnimation() {
if (this._animationState !== 'playing') {
console.warn('pauseAnimation called when animation was not playing.');
return;
}
this._animationState = 'paused';
// safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', true)
this._viewer?.setDirty();
// this._lastAnimId = '' // this disables stop on timeout end, for now.
}
resumeAnimation() {
if (this._animationState !== 'paused') {
console.warn('resumeAnimation called when animation was not paused.');
return;
}
this._animationState = 'playing';
// safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', true)
this._viewer?.setDirty();
}
stopAnimation(reset = false) {
this._animationState = 'stopped';
// safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking'), 'enabled', true)
if (reset)
this.resetAnimation();
else
this._viewer?.setDirty();
this._lastAnimId = '';
if (this._viewer && this._fadeDisabled) {
this._viewer.getPlugin('FrameFade')?.enable(this);
this._fadeDisabled = false;
}
}
resetAnimation() {
if (this._animationState !== 'stopped' && this._animationState !== 'none') {
this.stopAnimation(true); // reset and stop
return;
}
this.animations.forEach(({ mixer }) => {
// console.log(mixer, actions, clips)
mixer.stopAllAction();
mixer.setTime(0);
});
this._animationTime = 0;
this._viewer?.setDirty();
}
_postFrame() {
if (!this._viewer)
return;
const scrollAnimate = this.animateOnScroll; // && this._animationState === 'paused'
const pageScrollAnimate = this.animateOnPageScroll; // && this._animationState === 'paused'
const dragAnimate = this.animateOnDrag; // && this._animationState === 'paused'
if (this.isDisabled() || this.animations.length < 1 || this._animationState !== 'playing' && !scrollAnimate && !dragAnimate && !pageScrollAnimate) {
this._lastFrameTime = 0;
// console.log('not anim')
if (this._fadeDisabled) {
this._viewer.getPlugin('FrameFade')?.enable(this);
this._fadeDisabled = false;
}
return;
}
if (this._animationTime < 0.0001) {
this.dispatchEvent({ type: 'checkpointBegin' });
}
if (this.autoIncrementTime) {
const time = now() / 1000.0;
if (this._lastFrameTime < 1)
this._lastFrameTime = time - 1.0 / 30.0;
let delta = time - this._lastFrameTime;
delta *= this.animationSpeed;
this._lastFrameTime = time;
if (pageScrollAnimate)
delta *= this._pageScrollAnimationState;
else if (scrollAnimate && dragAnimate)
delta *= absMax(this._scrollAnimationState, this._dragAnimationState);
else if (scrollAnimate)
delta *= this._scrollAnimationState;
else if (dragAnimate)
delta *= this._dragAnimationState;
if (Math.abs(delta) < 0.0001)
return;
const d = this._viewer.getPlugin('Progressive')?.postFrameConvergedRecordingDelta();
if (d && d > 0)
delta = d;
if (d === 0)
return; // not converged yet.
// if d < 0: not recording, do nothing
const ts = Math.abs(this.timeScale);
this._animationTime += delta * (ts > 0 ? ts : 1);
}
const animDelta = this._animationTime - this._lastAnimationTime;
this._lastAnimationTime = this._animationTime;
const t = this.timeScale < 0 ?
(isFinite(this._animationDuration) ? this._animationDuration : 0) - this._animationTime :
this._animationTime;
this.animations.map(a => {
// a.mixer.timeScale = -1
a.mixer.setTime(t);
});
if (Math.abs(animDelta) < 0.00001)
return;
// if (this._animationTime > this._animationDuration) this._animationTime -= this._animationDuration
// if (this._animationTime < 0) this._animationTime += this._animationDuration
this._pageScrollAnimationState = this.pageScrollTime - this._animationTime;
if (Math.abs(this._pageScrollAnimationState) < 0.001)
this._pageScrollAnimationState = 0;
else
this._pageScrollAnimationState *= 1.0 - this.pageScrollAnimationDamping;
if (Math.abs(this._scrollAnimationState) < 0.001)
this._scrollAnimationState = 0;
else
this._scrollAnimationState *= 1.0 - this.scrollAnimationDamping;
if (Math.abs(this._dragAnimationState) < 0.001)
this._dragAnimationState = 0;
else
this._dragAnimationState *= 1.0 - this.dragAnimationDamping;
this.dispatchEvent({ type: 'animationStep', delta: animDelta, time: t });
// todo: this is now checked preFrame in ThreeViewer.ts
// if (this._viewer.scene.mainCamera.userData.isAnimating) { // if camera is animating
// this._viewer.scene.mainCamera.setDirty()
// console.log(this._viewer.scene.mainCamera, this._viewer.scene.mainCamera.getWorldPosition(new Vector3()))
// }
this._viewer.renderManager.resetShadows();
this._viewer.setDirty();
if (!this._fadeDisabled) {
const ff = this._viewer.getPlugin('FrameFade');
if (ff) {
ff.disable(GLTFAnimationPlugin_1.PluginType);
this._fadeDisabled = true;
}
}
if (this._animationTime >= this._animationDuration) {
this.dispatchEvent({ type: 'checkpointEnd' });
}
}
_onPropertyChange(replay = true) {
this._animationDuration = Math.max(...this.animations.map(({ duration }) => duration)) * (this.loopAnimations ? this.loopRepetitions : 1);
if (this._animationState === 'playing' && replay) {
this.playAnimation();
}
}
get pageScrollTime() {
const scrollMax = this.pageScrollHeight();
const time = window.scrollY / scrollMax * (this.animationDuration - 0.05);
return time;
}
_scroll() {
if (this.isDisabled())
return;
this._pageScrollAnimationState = this.pageScrollTime - this.animationTime;
}
_wheel({ deltaY }) {
if (this.isDisabled())
return;
if (Math.abs(deltaY) > 0.001)
this._scrollAnimationState = -1. * Math.sign(deltaY);
}
_drag(ev) {
if (this.isDisabled() || !this._viewer)
return;
this._dragAnimationState = this.dragAxis === 'x' ?
ev.delta.x * this._viewer.canvas.width / 4 :
ev.delta.y * this._viewer.canvas.height / 4;
}
};
GLTFAnimationPlugin.PluginType = 'GLTFAnimation';
__decorate([
serialize()
], GLTFAnimationPlugin.prototype, "autoIncrementTime", void 0);
__decorate([
onChange2(GLTFAnimationPlugin.prototype._onPropertyChange),
uiToggle('Loop'),
serialize()
], GLTFAnimationPlugin.prototype, "loopAnimations", void 0);
__decorate([
onChange2(GLTFAnimationPlugin.prototype._onPropertyChange),
serialize()
], GLTFAnimationPlugin.prototype, "loopRepetitions", void 0);
__decorate([
uiSlider('Timescale', [-2, 2], 0.01),
serialize()
], GLTFAnimationPlugin.prototype, "timeScale", void 0);
__decorate([
uiSlider('Speed', [0.1, 4], 0.1),
serialize()
], GLTFAnimationPlugin.prototype, "animationSpeed", void 0);
__decorate([
uiToggle(),
serialize()
], GLTFAnimationPlugin.prototype, "animateOnScroll", void 0);
__decorate([
uiSlider('Scroll Damping', [0, 1]),
serialize()
], GLTFAnimationPlugin.prototype, "scrollAnimationDamping", void 0);
__decorate([
uiToggle(),
serialize()
], GLTFAnimationPlugin.prototype, "animateOnPageScroll", void 0);
__decorate([
uiSlider('Page Scroll Damping', [0, 1]),
serialize()
], GLTFAnimationPlugin.prototype, "pageScrollAnimationDamping", void 0);
__decorate([
uiToggle(),
serialize()
], GLTFAnimationPlugin.prototype, "animateOnDrag", void 0);
__decorate([
uiDropdown('Drag Axis', [{ label: 'x' }, { label: 'y' }]),
serialize()
], GLTFAnimationPlugin.prototype, "dragAxis", void 0);
__decorate([
uiSlider('Drag Damping', [0, 1]),
serialize()
], GLTFAnimationPlugin.prototype, "dragAnimationDamping", void 0);
__decorate([
uiToggle(),
serialize()
], GLTFAnimationPlugin.prototype, "autoplayOnLoad", void 0);
__decorate([
uiToggle('syncMaxDuration(dev)'),
serialize()
], GLTFAnimationPlugin.prototype, "syncMaxDuration", void 0);
__decorate([
uiMonitor()
], GLTFAnimationPlugin.prototype, "animationState", null);
__decorate([
uiMonitor()
], GLTFAnimationPlugin.prototype, "animationTime", null);
__decorate([
uiMonitor()
], GLTFAnimationPlugin.prototype, "animationDuration", null);
__decorate([
uiButton('Play/Pause', (that) => ({
label: () => that.animationState === 'playing' ? 'Pause' : 'Play',
}))
], GLTFAnimationPlugin.prototype, "playPauseAnimation", null);
__decorate([
onChange(GLTFAnimationPlugin.prototype.onStateChange)
], GLTFAnimationPlugin.prototype, "_animationState", void 0);
__decorate([
uiButton('Stop', { sendArgs: false })
], GLTFAnimationPlugin.prototype, "stopAnimation", null);
__decorate([
uiButton('Reset', { sendArgs: false })
], GLTFAnimationPlugin.prototype, "resetAnimation", null);
GLTFAnimationPlugin = GLTFAnimationPlugin_1 = __decorate([
uiFolderContainer('GLTF Animations')
], GLTFAnimationPlugin);
export { GLTFAnimationPlugin };
//# sourceMappingURL=GLTFAnimationPlugin.js.map