threepipe
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

GLTFAnimationPlugin.ts 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. import {AViewerPluginSync, ThreeViewer} from '../../viewer'
  2. import {absMax, now, onChange, onChange2, PointerDragHelper, serialize} from 'ts-browser-helpers'
  3. import {uiButton, uiDropdown, uiFolderContainer, uiMonitor, UiObjectConfig, uiSlider, uiToggle} from 'uiconfig.js'
  4. import {AnimationAction, AnimationClip, AnimationMixer, LoopOnce, LoopRepeat} from 'three'
  5. import {ProgressivePlugin} from '../pipeline/ProgressivePlugin'
  6. import {IObject3D} from '../../core'
  7. import {generateUUID} from '../../three'
  8. import type {FrameFadePlugin} from '../pipeline/FrameFadePlugin'
  9. /**
  10. * Manages playback of GLTF animations.
  11. *
  12. * The GLTF animations can be created in any 3d software that supports GLTF export like Blender.
  13. * If animations from multiple files are loaded, they will be merged in a single root object and played together.
  14. *
  15. * 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.
  16. *
  17. * 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.
  18. *
  19. * 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
  20. *
  21. * @category Plugins
  22. */
  23. @uiFolderContainer('GLTF Animations')
  24. export class GLTFAnimationPlugin extends AViewerPluginSync<'checkpointEnd'|'checkpointBegin'|'animationStep'> {
  25. enabled = true
  26. uiConfig!: UiObjectConfig
  27. static readonly PluginType = 'GLTFAnimation'
  28. /**
  29. * List of GLTF animations loaded with the models.
  30. * The animations are standard threejs AnimationClip and their AnimationAction. Each set of actions also has a mixer.
  31. */
  32. public readonly animations: {mixer: AnimationMixer, clips: AnimationClip[], actions: AnimationAction[], duration: number}[] = []
  33. /**
  34. * 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)
  35. */
  36. @serialize() autoIncrementTime = true
  37. /**
  38. * Loop the complete animation. (not individual actions)
  39. * This happens {@link loopRepetitions} times.
  40. */
  41. @onChange2(GLTFAnimationPlugin.prototype._onPropertyChange)
  42. @uiToggle('Loop')
  43. @serialize() loopAnimations = true
  44. /**
  45. * Number of times to loop the animation. (not individual actions)
  46. * Only applicable when {@link loopAnimations} is true.
  47. */
  48. @onChange2(GLTFAnimationPlugin.prototype._onPropertyChange)
  49. @serialize() loopRepetitions = Infinity
  50. /**
  51. * Timescale for the animation. (not individual actions)
  52. * If set to 0, it will be ignored.
  53. */
  54. @uiSlider('Timescale', [-2, 2], 0.01)
  55. @serialize() timeScale = 1
  56. /**
  57. * Speed of the animation. (not individual actions)
  58. * This can be set to 0.
  59. */
  60. @uiSlider('Speed', [0.1, 4], 0.1) @serialize() animationSpeed = 1
  61. /**
  62. * Automatically track mouse wheel events to seek animations
  63. * Control damping/smoothness with {@link scrollAnimationDamping}
  64. * See also {@link animateOnPageScroll}. {@link animateOnDrag}
  65. */
  66. @uiToggle() @serialize() animateOnScroll = false
  67. /**
  68. * Damping for the scroll animation, when {@link animateOnScroll} is true.
  69. */
  70. @uiSlider('Scroll Damping', [0, 1]) @serialize() scrollAnimationDamping = 0.1
  71. /**
  72. * Automatically track scroll event in window and use `window.scrollY` along with {@link pageScrollHeight} to seek animations
  73. * Control damping/smoothness with {@link pageScrollAnimationDamping}
  74. * See also {@link animateOnDrag}, {@link animateOnScroll}
  75. */
  76. @uiToggle() @serialize() animateOnPageScroll = false
  77. /**
  78. * Damping for the scroll animation, when {@link animateOnPage Scroll} is true.
  79. */
  80. @uiSlider('Page Scroll Damping', [0, 1]) @serialize() pageScrollAnimationDamping = 0.1
  81. /**
  82. * Automatically track drag events in either x or y axes to seek animations
  83. * Control axis with {@link dragAxis} and damping/smoothness with {@link dragAnimationDamping}
  84. */
  85. @uiToggle() @serialize() animateOnDrag = false
  86. /**
  87. * Axis to track for drag events, when {@link animateOnDrag} is true.
  88. * `x` will track horizontal drag, `y` will track vertical drag.
  89. */
  90. @uiDropdown('Drag Axis', [{label: 'x'}, {label: 'y'}])
  91. @serialize() dragAxis: 'x'|'y' = 'y'
  92. /**
  93. * Damping for the drag animation, when {@link animateOnDrag} is true.
  94. */
  95. @uiSlider('Drag Damping', [0, 1]) @serialize() dragAnimationDamping = 0.3
  96. /**
  97. * If true, the animation will be played automatically when the model(any model with animations) is loaded.
  98. */
  99. @uiToggle() @serialize() autoplayOnLoad = false
  100. /**
  101. * Get the current state of the animation. (read only)
  102. * use {@link playAnimation}, {@link pauseAnimation}, {@link stopAnimation} to change the state.
  103. */
  104. @uiMonitor() get animationState(): 'none' | 'playing' | 'paused' | 'stopped' {
  105. return this._animationState
  106. }
  107. /**
  108. * Get the current animation time. (read only)
  109. * The time is managed automatically.
  110. * To manage the time manually set {@link autoIncrementTime} to false and use {@link setTime} to change the time.
  111. */
  112. @uiMonitor() get animationTime(): number {
  113. return this._animationTime
  114. }
  115. /**
  116. * Get the current animation duration (max of all animations). (read only)
  117. */
  118. @uiMonitor() get animationDuration(): number {
  119. return this._animationDuration
  120. }
  121. @uiButton('Play/Pause', (that: GLTFAnimationPlugin)=>({
  122. label:()=> that.animationState === 'playing' ? 'Pause' : 'Play',
  123. limitedUi: true,
  124. }))
  125. playPauseAnimation() {
  126. this._animationState === 'playing' ? this.pauseAnimation() : this.playAnimation()
  127. }
  128. @onChange(GLTFAnimationPlugin.prototype.onStateChange)
  129. protected _animationState: 'none' | 'playing' | 'paused' | 'stopped' = 'none'
  130. private _lastAnimationTime = 0
  131. private _animationTime = 0
  132. private _animationDuration = 0
  133. private _scrollAnimationState = 0
  134. private _pageScrollAnimationState = 0
  135. private _dragAnimationState = 0
  136. private _pointerDragHelper = new PointerDragHelper()
  137. private _lastFrameTime = 0
  138. private _fadeDisabled = false
  139. constructor() {
  140. super()
  141. this.playClips = this.playClips.bind(this)
  142. this.playClip = this.playClip.bind(this)
  143. this.playAnimation = this.playAnimation.bind(this)
  144. this.playPauseAnimation = this.playPauseAnimation.bind(this)
  145. this.pauseAnimation = this.pauseAnimation.bind(this)
  146. this.stopAnimation = this.stopAnimation.bind(this)
  147. this.resetAnimation = this.resetAnimation.bind(this)
  148. this._onPropertyChange = this._onPropertyChange.bind(this)
  149. this._postFrame = this._postFrame.bind(this)
  150. this._wheel = this._wheel.bind(this)
  151. this._scroll = this._scroll.bind(this)
  152. this._pointerDragHelper.addEventListener('drag', this._drag.bind(this))
  153. }
  154. setTime(time: number) {
  155. this._animationTime = Math.max(0, Math.min(time, this._animationDuration))
  156. }
  157. async onAdded(viewer: ThreeViewer): Promise<void> {
  158. viewer.scene.addEventListener('addSceneObject', this._objectAdded)
  159. viewer.addEventListener('postFrame', this._postFrame)
  160. window.addEventListener('wheel', this._wheel)
  161. window.addEventListener('scroll', this._scroll)
  162. this._pointerDragHelper.element = viewer.canvas
  163. return super.onAdded(viewer)
  164. }
  165. async onRemove(viewer: ThreeViewer): Promise<void> {
  166. while (this.animations.length) this.animations.pop()
  167. viewer.scene.removeEventListener('addSceneObject', this._objectAdded)
  168. viewer.removeEventListener('postFrame', this._postFrame)
  169. window.removeEventListener('wheel', this._wheel)
  170. window.removeEventListener('scroll', this._scroll)
  171. this._pointerDragHelper.element = undefined
  172. return super.onRemove(viewer)
  173. }
  174. public onStateChange(): void {
  175. this.uiConfig?.uiRefresh?.(true, 'postFrame')
  176. // this.uiConfig?.children?.map(value => value && getOrCall(value)).flat(2).forEach(v=>v?.uiRefresh?.())
  177. }
  178. /**
  179. * This will play a single clip by name
  180. * It might reset all other animations, this is a bug; https://codepen.io/repalash/pen/mdjgpvx
  181. * @param name
  182. * @param resetOnEnd
  183. */
  184. async playClip(name: string, resetOnEnd = false) {
  185. return this.playClips([name], resetOnEnd)
  186. }
  187. async playClips(names: string[], resetOnEnd = false) {
  188. const anims: AnimationAction[] = []
  189. this.animations.forEach(({actions})=>{
  190. actions.forEach((action)=>{
  191. if (names.includes(action.getClip().name)) {
  192. anims.push(action)
  193. }
  194. })
  195. })
  196. return this.playAnimation(resetOnEnd, anims)
  197. }
  198. private _lastAnimId = ''
  199. /**
  200. * Starts all the animations and returns a promise that resolves when all animations are done.
  201. * @param resetOnEnd - if true, will reset the animation to the start position when it ends.
  202. * @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.
  203. */
  204. async playAnimation(resetOnEnd = false, animations?: AnimationAction[]): Promise<void> {
  205. if (!this.enabled) return
  206. let wasPlaying = false
  207. if (this._animationState === 'playing') {
  208. this.stopAnimation(false) // stop and play again. reset is done below.
  209. wasPlaying = true
  210. }
  211. // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', false)
  212. let duration = 0
  213. const isAllAnimations = !animations
  214. if (!animations) {
  215. animations = []
  216. this.animations.forEach(({actions}) => {
  217. // console.log(mixer, actions, clips)
  218. animations!.push(...actions)
  219. })
  220. }
  221. if (wasPlaying)
  222. this.resetAnimation()
  223. else if (this.animationState !== 'paused') {
  224. animations.forEach((ac)=>{
  225. ac.reset()
  226. })
  227. this._animationTime = 0
  228. }
  229. const id = generateUUID()
  230. this._lastAnimId = id // todo: check logic
  231. for (const ac of animations) {
  232. // if (Math.abs(this.timeScale) > 0) {
  233. // if (!(ac as any)._tTimeScale) (ac as any)._tTimeScale = ac.timeScale
  234. // ac.timeScale = this.timeScale
  235. // } else if ((ac as any)._tTimeScale) ac.timeScale = (ac as any)._tTimeScale
  236. ac.setLoop(this.loopAnimations ? LoopRepeat : LoopOnce, this.loopRepetitions)
  237. ac.play()
  238. duration = Math.max(duration, ac.getClip().duration / Math.abs(ac.timeScale))
  239. // if (!this._playingActions.includes(ac)) this._playingActions.push(ac)
  240. // console.log(ac)
  241. }
  242. this._animationState = 'playing'
  243. this._viewer?.setDirty()
  244. if (!isAllAnimations) {
  245. const loops = this.loopAnimations ? this.loopRepetitions : 1
  246. duration *= loops
  247. if (!isFinite(duration)) {
  248. // infinite animation
  249. return
  250. }
  251. await new Promise<void>((resolve) => {
  252. const listen = (e: any) => {
  253. if (e.time >= duration) {
  254. this.removeEventListener('animationStep', listen)
  255. resolve()
  256. }
  257. }
  258. this.addEventListener('animationStep', listen)
  259. })
  260. // const animDuration = 1000 * duration - this._animationTime / this.animationSpeed + 0.01
  261. //
  262. // if (animDuration > 0) {
  263. // await timeout(animDuration)
  264. // return
  265. // } // todo: handle pausing/early stop, converge mode for single animation playback
  266. } else {
  267. if (!isFinite(this._animationDuration)) {
  268. // infinite animation
  269. return
  270. }
  271. await new Promise<void>((resolve) => {
  272. const listen = () => {
  273. this.removeEventListener('checkpointEnd', listen)
  274. resolve()
  275. }
  276. this.addEventListener('checkpointEnd', listen)
  277. })
  278. }
  279. if (id === this._lastAnimId) { // in-case multiple animations are started.
  280. this.stopAnimation(resetOnEnd)
  281. }
  282. return
  283. }
  284. pauseAnimation() {
  285. if (this._animationState !== 'playing') {
  286. console.warn('pauseAnimation called when animation was not playing.')
  287. return
  288. }
  289. this._animationState = 'paused'
  290. // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', true)
  291. this._viewer?.setDirty()
  292. // this._lastAnimId = '' // this disables stop on timeout end, for now.
  293. }
  294. resumeAnimation() {
  295. if (this._animationState !== 'paused') {
  296. console.warn('resumeAnimation called when animation was not paused.')
  297. return
  298. }
  299. this._animationState = 'playing'
  300. // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', true)
  301. this._viewer?.setDirty()
  302. }
  303. @uiButton('Stop')
  304. stopAnimation(reset = false) {
  305. this._animationState = 'stopped'
  306. // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking'), 'enabled', true)
  307. if (reset) this.resetAnimation()
  308. else this._viewer?.setDirty()
  309. this._lastAnimId = ''
  310. if (this._viewer && this._fadeDisabled) {
  311. this._viewer.getPlugin<FrameFadePlugin>('FrameFade')?.enable(GLTFAnimationPlugin.PluginType)
  312. this._fadeDisabled = false
  313. }
  314. }
  315. @uiButton('Reset')
  316. resetAnimation() {
  317. if (this._animationState !== 'stopped' && this._animationState !== 'none') {
  318. this.stopAnimation(true) // reset and stop
  319. return
  320. }
  321. this.animations.forEach(({mixer}) => {
  322. // console.log(mixer, actions, clips)
  323. mixer.stopAllAction()
  324. mixer.setTime(0)
  325. })
  326. this._animationTime = 0
  327. this._viewer?.setDirty()
  328. }
  329. protected _postFrame() {
  330. if (!this._viewer) return
  331. const scrollAnimate = this.animateOnScroll // && this._animationState === 'paused'
  332. const pageScrollAnimate = this.animateOnPageScroll // && this._animationState === 'paused'
  333. const dragAnimate = this.animateOnDrag // && this._animationState === 'paused'
  334. if (!this.enabled || this.animations.length < 1 || this._animationState !== 'playing' && !scrollAnimate && !dragAnimate && !pageScrollAnimate) {
  335. this._lastFrameTime = 0
  336. // console.log('not anim')
  337. if (this._fadeDisabled) {
  338. this._viewer.getPlugin<FrameFadePlugin>('FrameFade')?.enable(GLTFAnimationPlugin.PluginType)
  339. this._fadeDisabled = false
  340. }
  341. return
  342. }
  343. if (this._animationTime < 0.0001) {
  344. this.dispatchEvent({type: 'checkpointBegin'})
  345. }
  346. if (this.autoIncrementTime) {
  347. const time = now() / 1000.0
  348. if (this._lastFrameTime < 1) this._lastFrameTime = time - 1.0 / 30.0
  349. let delta = time - this._lastFrameTime
  350. delta *= this.animationSpeed
  351. this._lastFrameTime = time
  352. if (pageScrollAnimate) delta *= this._pageScrollAnimationState
  353. else if (scrollAnimate && dragAnimate) delta *= absMax(this._scrollAnimationState, this._dragAnimationState)
  354. else if (scrollAnimate) delta *= this._scrollAnimationState
  355. else if (dragAnimate) delta *= this._dragAnimationState
  356. if (Math.abs(delta) < 0.0001) return
  357. const d = this._viewer.getPlugin<ProgressivePlugin>('Progressive')?.postFrameConvergedRecordingDelta()
  358. if (d && d > 0) delta = d
  359. if (d === 0) return // not converged yet.
  360. // if d < 0: not recording, do nothing
  361. const ts = Math.abs(this.timeScale)
  362. this._animationTime += delta * (ts > 0 ? ts : 1)
  363. }
  364. const animDelta = this._animationTime - this._lastAnimationTime
  365. this._lastAnimationTime = this._animationTime
  366. const t = this.timeScale < 0 ?
  367. (isFinite(this._animationDuration) ? this._animationDuration : 0) - this._animationTime :
  368. this._animationTime
  369. this.animations.map(a=>{
  370. // a.mixer.timeScale = -1
  371. a.mixer.setTime(t)
  372. })
  373. if (Math.abs(animDelta) < 0.00001) return
  374. // if (this._animationTime > this._animationDuration) this._animationTime -= this._animationDuration
  375. // if (this._animationTime < 0) this._animationTime += this._animationDuration
  376. this._pageScrollAnimationState = this.pageScrollTime - this._animationTime
  377. if (Math.abs(this._pageScrollAnimationState) < 0.001) this._pageScrollAnimationState = 0
  378. else this._pageScrollAnimationState *= 1.0 - this.pageScrollAnimationDamping
  379. if (Math.abs(this._scrollAnimationState) < 0.001) this._scrollAnimationState = 0
  380. else this._scrollAnimationState *= 1.0 - this.scrollAnimationDamping
  381. if (Math.abs(this._dragAnimationState) < 0.001) this._dragAnimationState = 0
  382. else this._dragAnimationState *= 1.0 - this.dragAnimationDamping
  383. this.dispatchEvent({type: 'animationStep', delta: animDelta, time: t})
  384. // todo: this is now checked preFrame in ThreeViewer.ts
  385. // if (this._viewer.scene.mainCamera.userData.isAnimating) { // if camera is animating
  386. // this._viewer.scene.mainCamera.setDirty()
  387. // console.log(this._viewer.scene.mainCamera, this._viewer.scene.mainCamera.getWorldPosition(new Vector3()))
  388. // }
  389. this._viewer.renderManager.resetShadows()
  390. this._viewer.setDirty()
  391. if (!this._fadeDisabled) {
  392. const ff = this._viewer.getPlugin<FrameFadePlugin>('FrameFade')
  393. if (ff) {
  394. ff.disable(GLTFAnimationPlugin.PluginType)
  395. this._fadeDisabled = true
  396. }
  397. }
  398. if (this._animationTime >= this._animationDuration) {
  399. this.dispatchEvent({type: 'checkpointEnd'})
  400. }
  401. }
  402. protected _objectAdded = (ev: any)=>{
  403. const object = ev.object as IObject3D
  404. if (!this._viewer) return
  405. let changed = false
  406. object.traverse((obj)=>{
  407. if (!this._viewer) return
  408. const clips: AnimationClip[] = obj.animations
  409. if (clips.length < 1) return
  410. const duration = Math.max(...clips.map(an=>an.duration))
  411. // clips.forEach(cp=>cp.duration = duration) // 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.
  412. const mixer = new AnimationMixer(this._viewer.scene.modelRoot) // add to modelRoot so it works with GLTF export...
  413. const actions = clips.map(an=>mixer.clipAction(an).setLoop(this.loopAnimations ? LoopRepeat : LoopOnce, this.loopRepetitions))
  414. actions.forEach(ac=>ac.clampWhenFinished = true)
  415. this.animations.push({
  416. mixer, clips, actions, duration,
  417. })
  418. // todo remove on object dispose
  419. changed = true
  420. })
  421. // this.playAnimation()
  422. if (changed) {
  423. this._onPropertyChange(!this.autoplayOnLoad)
  424. if (this.autoplayOnLoad) this.playAnimation()
  425. }
  426. return
  427. }
  428. private _onPropertyChange(replay = true): void {
  429. this._animationDuration = Math.max(...this.animations.map(({duration})=>duration)) * (this.loopAnimations ? this.loopRepetitions : 1)
  430. if (this._animationState === 'playing' && replay) {
  431. this.playAnimation()
  432. }
  433. }
  434. get pageScrollTime() {
  435. const scrollMax = this.pageScrollHeight()
  436. const time = window.scrollY / scrollMax * (this.animationDuration - 0.05)
  437. return time
  438. }
  439. private _scroll() {
  440. if (!this.enabled) return
  441. this._pageScrollAnimationState = this.pageScrollTime - this.animationTime
  442. }
  443. private _wheel({deltaY}: any | WheelEvent) {
  444. if (!this.enabled) return
  445. if (Math.abs(deltaY) > 0.001)
  446. this._scrollAnimationState = -1. * Math.sign(deltaY)
  447. }
  448. private _drag(ev: any) {
  449. if (!this.enabled || !this._viewer) return
  450. this._dragAnimationState = this.dragAxis === 'x' ?
  451. ev.delta.x * this._viewer.canvas.width / 4 :
  452. ev.delta.y * this._viewer.canvas.height / 4
  453. }
  454. pageScrollHeight = () => Math.max(
  455. document.body.scrollHeight,
  456. document.body.offsetHeight,
  457. document.documentElement.clientHeight,
  458. document.documentElement.scrollHeight,
  459. document.documentElement.offsetHeight
  460. ) - window.innerHeight
  461. }