| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550 |
- import {AViewerPluginSync, ThreeViewer} from '../../viewer'
- import {absMax, now, onChange, onChange2, PointerDragHelper, serialize} from 'ts-browser-helpers'
- import {uiButton, uiDropdown, uiFolderContainer, uiMonitor, UiObjectConfig, uiSlider, uiToggle} from 'uiconfig.js'
- import {AnimationAction, AnimationClip, AnimationMixer, LoopOnce, LoopRepeat} from 'three'
- import {ProgressivePlugin} from '../pipeline/ProgressivePlugin'
- import {IObject3D} from '../../core'
- import {generateUUID} from '../../three'
- import type {FrameFadePlugin} from '../pipeline/FrameFadePlugin'
-
- /**
- * 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
- */
- @uiFolderContainer('GLTF Animations')
- export class GLTFAnimationPlugin extends AViewerPluginSync<'checkpointEnd'|'checkpointBegin'|'animationStep'> {
- enabled = true
- uiConfig!: UiObjectConfig
-
- static readonly PluginType = 'GLTFAnimation'
- /**
- * 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.
- */
- public readonly animations: {mixer: AnimationMixer, clips: AnimationClip[], actions: AnimationAction[], duration: number}[] = []
-
- /**
- * 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)
- */
- @serialize() autoIncrementTime = true
-
- /**
- * Loop the complete animation. (not individual actions)
- * This happens {@link loopRepetitions} times.
- */
- @onChange2(GLTFAnimationPlugin.prototype._onPropertyChange)
- @uiToggle('Loop')
- @serialize() loopAnimations = true
-
- /**
- * Number of times to loop the animation. (not individual actions)
- * Only applicable when {@link loopAnimations} is true.
- */
- @onChange2(GLTFAnimationPlugin.prototype._onPropertyChange)
- @serialize() loopRepetitions = Infinity
-
- /**
- * Timescale for the animation. (not individual actions)
- * If set to 0, it will be ignored.
- */
- @uiSlider('Timescale', [-2, 2], 0.01)
- @serialize() timeScale = 1
-
- /**
- * Speed of the animation. (not individual actions)
- * This can be set to 0.
- */
- @uiSlider('Speed', [0.1, 4], 0.1) @serialize() animationSpeed = 1
-
- /**
- * Automatically track mouse wheel events to seek animations
- * Control damping/smoothness with {@link scrollAnimationDamping}
- * See also {@link animateOnPageScroll}. {@link animateOnDrag}
- */
- @uiToggle() @serialize() animateOnScroll = false
-
- /**
- * Damping for the scroll animation, when {@link animateOnScroll} is true.
- */
- @uiSlider('Scroll Damping', [0, 1]) @serialize() 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}
- */
- @uiToggle() @serialize() animateOnPageScroll = false
-
- /**
- * Damping for the scroll animation, when {@link animateOnPageScroll} is true.
- */
- @uiSlider('Page Scroll Damping', [0, 1]) @serialize() 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}
- */
- @uiToggle() @serialize() animateOnDrag = false
-
- /**
- * Axis to track for drag events, when {@link animateOnDrag} is true.
- * `x` will track horizontal drag, `y` will track vertical drag.
- */
- @uiDropdown('Drag Axis', [{label: 'x'}, {label: 'y'}])
- @serialize() dragAxis: 'x'|'y' = 'y'
-
- /**
- * Damping for the drag animation, when {@link animateOnDrag} is true.
- */
- @uiSlider('Drag Damping', [0, 1]) @serialize() dragAnimationDamping = 0.3
-
- /**
- * If true, the animation will be played automatically when the model(any model with animations) is loaded.
- */
- @uiToggle() @serialize() autoplayOnLoad = false
-
- /**
- * Sync the duration of all clips based on the max duration, helpful for things like timeline markers
- */
- @uiToggle('syncMaxDuration(dev)') @serialize() syncMaxDuration = false
-
- /**
- * Get the current state of the animation. (read only)
- * use {@link playAnimation}, {@link pauseAnimation}, {@link stopAnimation} to change the state.
- */
- @uiMonitor() get animationState(): 'none' | 'playing' | 'paused' | 'stopped' {
- 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.
- */
- @uiMonitor() get animationTime(): number {
- return this._animationTime
- }
-
- /**
- * Get the current animation duration (max of all animations). (read only)
- */
- @uiMonitor() get animationDuration(): number {
- return this._animationDuration
- }
-
-
- @uiButton('Play/Pause', (that: GLTFAnimationPlugin)=>({
- label:()=> that.animationState === 'playing' ? 'Pause' : 'Play',
- }))
- playPauseAnimation() {
- this._animationState === 'playing' ? this.pauseAnimation() : this.playAnimation()
- }
-
- @onChange(GLTFAnimationPlugin.prototype.onStateChange)
- protected _animationState: 'none' | 'playing' | 'paused' | 'stopped' = 'none'
-
- private _lastAnimationTime = 0
- private _animationTime = 0
- private _animationDuration = 0
- private _scrollAnimationState = 0
- private _pageScrollAnimationState = 0
- private _dragAnimationState = 0
- private _pointerDragHelper = new PointerDragHelper()
- private _lastFrameTime = 0
- private _fadeDisabled = false
-
- constructor() {
- super()
- 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: number) {
- this._animationTime = Math.max(0, Math.min(time, this._animationDuration))
- }
-
-
- onAdded(viewer: ThreeViewer): void {
- 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: ThreeViewer): void {
- 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)
- }
-
- public onStateChange(): void {
- 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: string, resetOnEnd = false) {
- return this.playClips([name], resetOnEnd)
- }
- async playClips(names: string[], resetOnEnd = false) {
- const anims: AnimationAction[] = []
- this.animations.forEach(({actions})=>{
- actions.forEach((action)=>{
- if (names.includes(action.getClip().name)) {
- anims.push(action)
- }
- })
- })
- return this.playAnimation(resetOnEnd, anims)
- }
-
- private _lastAnimId = ''
-
- /**
- * 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?: AnimationAction[]): Promise<void> {
- 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<void>((resolve) => {
- const listen = (e: any) => {
- 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<void>((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()
- }
-
- @uiButton('Stop', {sendArgs: false})
- 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<FrameFadePlugin>('FrameFade')?.enable(this)
- this._fadeDisabled = false
- }
-
- }
-
- @uiButton('Reset', {sendArgs: 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()
- }
-
-
- protected _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<FrameFadePlugin>('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<ProgressivePlugin>('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<FrameFadePlugin>('FrameFade')
- if (ff) {
- ff.disable(GLTFAnimationPlugin.PluginType)
- this._fadeDisabled = true
- }
- }
-
- if (this._animationTime >= this._animationDuration) {
- this.dispatchEvent({type: 'checkpointEnd'})
- }
- }
-
- protected _objectAdded = (ev: any)=>{
- const object = ev.object as IObject3D
- if (!this._viewer) return
- let changed = false
-
- object.traverse((obj)=>{
- if (!this._viewer) return
-
- const clips: AnimationClip[] = 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
- }
-
- private _onPropertyChange(replay = true): void {
- 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
- }
-
- private _scroll() {
- if (this.isDisabled()) return
- this._pageScrollAnimationState = this.pageScrollTime - this.animationTime
- }
-
- private _wheel({deltaY}: any | WheelEvent) {
- if (this.isDisabled()) return
- if (Math.abs(deltaY) > 0.001)
- this._scrollAnimationState = -1. * Math.sign(deltaY)
- }
-
- private _drag(ev: any) {
- 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
- }
-
-
- pageScrollHeight = () => Math.max(
- document.body.scrollHeight,
- document.body.offsetHeight,
- document.documentElement.clientHeight,
- document.documentElement.scrollHeight,
- document.documentElement.offsetHeight
- ) - window.innerHeight
-
-
- }
|