threepipe
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

GLTFAnimationPlugin.ts 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  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. * Sync the duration of all clips based on the max duration, helpful for things like timeline markers
  102. */
  103. @uiToggle('syncMaxDuration(dev)') @serialize() syncMaxDuration = false
  104. /**
  105. * Get the current state of the animation. (read only)
  106. * use {@link playAnimation}, {@link pauseAnimation}, {@link stopAnimation} to change the state.
  107. */
  108. @uiMonitor() get animationState(): 'none' | 'playing' | 'paused' | 'stopped' {
  109. return this._animationState
  110. }
  111. /**
  112. * Get the current animation time. (read only)
  113. * The time is managed automatically.
  114. * To manage the time manually set {@link autoIncrementTime} to false and use {@link setTime} to change the time.
  115. */
  116. @uiMonitor() get animationTime(): number {
  117. return this._animationTime
  118. }
  119. /**
  120. * Get the current animation duration (max of all animations). (read only)
  121. */
  122. @uiMonitor() get animationDuration(): number {
  123. return this._animationDuration
  124. }
  125. @uiButton('Play/Pause', (that: GLTFAnimationPlugin)=>({
  126. label:()=> that.animationState === 'playing' ? 'Pause' : 'Play',
  127. }))
  128. playPauseAnimation() {
  129. this._animationState === 'playing' ? this.pauseAnimation() : this.playAnimation()
  130. }
  131. @onChange(GLTFAnimationPlugin.prototype.onStateChange)
  132. protected _animationState: 'none' | 'playing' | 'paused' | 'stopped' = 'none'
  133. private _lastAnimationTime = 0
  134. private _animationTime = 0
  135. private _animationDuration = 0
  136. private _scrollAnimationState = 0
  137. private _pageScrollAnimationState = 0
  138. private _dragAnimationState = 0
  139. private _pointerDragHelper = new PointerDragHelper()
  140. private _lastFrameTime = 0
  141. private _fadeDisabled = false
  142. constructor() {
  143. super()
  144. this.playClips = this.playClips.bind(this)
  145. this.playClip = this.playClip.bind(this)
  146. this.playAnimation = this.playAnimation.bind(this)
  147. this.playPauseAnimation = this.playPauseAnimation.bind(this)
  148. this.pauseAnimation = this.pauseAnimation.bind(this)
  149. this.stopAnimation = this.stopAnimation.bind(this)
  150. this.resetAnimation = this.resetAnimation.bind(this)
  151. this._onPropertyChange = this._onPropertyChange.bind(this)
  152. this._postFrame = this._postFrame.bind(this)
  153. this._wheel = this._wheel.bind(this)
  154. this._scroll = this._scroll.bind(this)
  155. this._pointerDragHelper.addEventListener('drag', this._drag.bind(this))
  156. }
  157. setTime(time: number) {
  158. this._animationTime = Math.max(0, Math.min(time, this._animationDuration))
  159. }
  160. async onAdded(viewer: ThreeViewer): Promise<void> {
  161. viewer.scene.addEventListener('addSceneObject', this._objectAdded)
  162. viewer.addEventListener('postFrame', this._postFrame)
  163. window.addEventListener('wheel', this._wheel)
  164. window.addEventListener('scroll', this._scroll)
  165. this._pointerDragHelper.element = viewer.canvas
  166. return super.onAdded(viewer)
  167. }
  168. async onRemove(viewer: ThreeViewer): Promise<void> {
  169. while (this.animations.length) this.animations.pop()
  170. viewer.scene.removeEventListener('addSceneObject', this._objectAdded)
  171. viewer.removeEventListener('postFrame', this._postFrame)
  172. window.removeEventListener('wheel', this._wheel)
  173. window.removeEventListener('scroll', this._scroll)
  174. this._pointerDragHelper.element = undefined
  175. return super.onRemove(viewer)
  176. }
  177. public onStateChange(): void {
  178. this.uiConfig?.uiRefresh?.(true, 'postFrame')
  179. // this.uiConfig?.children?.map(value => value && getOrCall(value)).flat(2).forEach(v=>v?.uiRefresh?.())
  180. }
  181. /**
  182. * This will play a single clip by name
  183. * It might reset all other animations, this is a bug; https://codepen.io/repalash/pen/mdjgpvx
  184. * @param name
  185. * @param resetOnEnd
  186. */
  187. async playClip(name: string, resetOnEnd = false) {
  188. return this.playClips([name], resetOnEnd)
  189. }
  190. async playClips(names: string[], resetOnEnd = false) {
  191. const anims: AnimationAction[] = []
  192. this.animations.forEach(({actions})=>{
  193. actions.forEach((action)=>{
  194. if (names.includes(action.getClip().name)) {
  195. anims.push(action)
  196. }
  197. })
  198. })
  199. return this.playAnimation(resetOnEnd, anims)
  200. }
  201. private _lastAnimId = ''
  202. /**
  203. * Starts all the animations and returns a promise that resolves when all animations are done.
  204. * @param resetOnEnd - if true, will reset the animation to the start position when it ends.
  205. * @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.
  206. */
  207. async playAnimation(resetOnEnd = false, animations?: AnimationAction[]): Promise<void> {
  208. if (!this.enabled) return
  209. let wasPlaying = false
  210. if (this._animationState === 'playing') {
  211. this.stopAnimation(false) // stop and play again. reset is done below.
  212. wasPlaying = true
  213. }
  214. // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', false)
  215. let duration = 0
  216. const isAllAnimations = !animations
  217. if (!animations) {
  218. animations = []
  219. this.animations.forEach(({actions}) => {
  220. // console.log(mixer, actions, clips)
  221. animations!.push(...actions)
  222. })
  223. }
  224. if (wasPlaying)
  225. this.resetAnimation()
  226. else if (this.animationState !== 'paused') {
  227. animations.forEach((ac)=>{
  228. ac.reset()
  229. })
  230. this._animationTime = 0
  231. }
  232. const id = generateUUID()
  233. this._lastAnimId = id // todo: check logic
  234. for (const ac of animations) {
  235. // if (Math.abs(this.timeScale) > 0) {
  236. // if (!(ac as any)._tTimeScale) (ac as any)._tTimeScale = ac.timeScale
  237. // ac.timeScale = this.timeScale
  238. // } else if ((ac as any)._tTimeScale) ac.timeScale = (ac as any)._tTimeScale
  239. ac.setLoop(this.loopAnimations ? LoopRepeat : LoopOnce, this.loopRepetitions)
  240. ac.play()
  241. duration = Math.max(duration, ac.getClip().duration / Math.abs(ac.timeScale))
  242. // if (!this._playingActions.includes(ac)) this._playingActions.push(ac)
  243. // console.log(ac)
  244. }
  245. this._animationState = 'playing'
  246. this._viewer?.setDirty()
  247. if (!isAllAnimations) {
  248. const loops = this.loopAnimations ? this.loopRepetitions : 1
  249. duration *= loops
  250. if (!isFinite(duration)) {
  251. // infinite animation
  252. return
  253. }
  254. await new Promise<void>((resolve) => {
  255. const listen = (e: any) => {
  256. if (e.time >= duration) {
  257. this.removeEventListener('animationStep', listen)
  258. resolve()
  259. }
  260. }
  261. this.addEventListener('animationStep', listen)
  262. })
  263. // const animDuration = 1000 * duration - this._animationTime / this.animationSpeed + 0.01
  264. //
  265. // if (animDuration > 0) {
  266. // await timeout(animDuration)
  267. // return
  268. // } // todo: handle pausing/early stop, converge mode for single animation playback
  269. } else {
  270. if (!isFinite(this._animationDuration)) {
  271. // infinite animation
  272. return
  273. }
  274. await new Promise<void>((resolve) => {
  275. const listen = () => {
  276. this.removeEventListener('checkpointEnd', listen)
  277. resolve()
  278. }
  279. this.addEventListener('checkpointEnd', listen)
  280. })
  281. }
  282. if (id === this._lastAnimId) { // in-case multiple animations are started.
  283. this.stopAnimation(resetOnEnd)
  284. }
  285. return
  286. }
  287. pauseAnimation() {
  288. if (this._animationState !== 'playing') {
  289. console.warn('pauseAnimation called when animation was not playing.')
  290. return
  291. }
  292. this._animationState = 'paused'
  293. // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', true)
  294. this._viewer?.setDirty()
  295. // this._lastAnimId = '' // this disables stop on timeout end, for now.
  296. }
  297. resumeAnimation() {
  298. if (this._animationState !== 'paused') {
  299. console.warn('resumeAnimation called when animation was not paused.')
  300. return
  301. }
  302. this._animationState = 'playing'
  303. // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking')?.transformControls, 'enabled', true)
  304. this._viewer?.setDirty()
  305. }
  306. @uiButton('Stop')
  307. stopAnimation(reset = false) {
  308. this._animationState = 'stopped'
  309. // safeSetProperty(this._viewer?.getPlugin<PickingPlugin>('Picking'), 'enabled', true)
  310. if (reset) this.resetAnimation()
  311. else this._viewer?.setDirty()
  312. this._lastAnimId = ''
  313. if (this._viewer && this._fadeDisabled) {
  314. this._viewer.getPlugin<FrameFadePlugin>('FrameFade')?.enable(GLTFAnimationPlugin.PluginType)
  315. this._fadeDisabled = false
  316. }
  317. }
  318. @uiButton('Reset')
  319. resetAnimation() {
  320. if (this._animationState !== 'stopped' && this._animationState !== 'none') {
  321. this.stopAnimation(true) // reset and stop
  322. return
  323. }
  324. this.animations.forEach(({mixer}) => {
  325. // console.log(mixer, actions, clips)
  326. mixer.stopAllAction()
  327. mixer.setTime(0)
  328. })
  329. this._animationTime = 0
  330. this._viewer?.setDirty()
  331. }
  332. protected _postFrame() {
  333. if (!this._viewer) return
  334. const scrollAnimate = this.animateOnScroll // && this._animationState === 'paused'
  335. const pageScrollAnimate = this.animateOnPageScroll // && this._animationState === 'paused'
  336. const dragAnimate = this.animateOnDrag // && this._animationState === 'paused'
  337. if (!this.enabled || this.animations.length < 1 || this._animationState !== 'playing' && !scrollAnimate && !dragAnimate && !pageScrollAnimate) {
  338. this._lastFrameTime = 0
  339. // console.log('not anim')
  340. if (this._fadeDisabled) {
  341. this._viewer.getPlugin<FrameFadePlugin>('FrameFade')?.enable(GLTFAnimationPlugin.PluginType)
  342. this._fadeDisabled = false
  343. }
  344. return
  345. }
  346. if (this._animationTime < 0.0001) {
  347. this.dispatchEvent({type: 'checkpointBegin'})
  348. }
  349. if (this.autoIncrementTime) {
  350. const time = now() / 1000.0
  351. if (this._lastFrameTime < 1) this._lastFrameTime = time - 1.0 / 30.0
  352. let delta = time - this._lastFrameTime
  353. delta *= this.animationSpeed
  354. this._lastFrameTime = time
  355. if (pageScrollAnimate) delta *= this._pageScrollAnimationState
  356. else if (scrollAnimate && dragAnimate) delta *= absMax(this._scrollAnimationState, this._dragAnimationState)
  357. else if (scrollAnimate) delta *= this._scrollAnimationState
  358. else if (dragAnimate) delta *= this._dragAnimationState
  359. if (Math.abs(delta) < 0.0001) return
  360. const d = this._viewer.getPlugin<ProgressivePlugin>('Progressive')?.postFrameConvergedRecordingDelta()
  361. if (d && d > 0) delta = d
  362. if (d === 0) return // not converged yet.
  363. // if d < 0: not recording, do nothing
  364. const ts = Math.abs(this.timeScale)
  365. this._animationTime += delta * (ts > 0 ? ts : 1)
  366. }
  367. const animDelta = this._animationTime - this._lastAnimationTime
  368. this._lastAnimationTime = this._animationTime
  369. const t = this.timeScale < 0 ?
  370. (isFinite(this._animationDuration) ? this._animationDuration : 0) - this._animationTime :
  371. this._animationTime
  372. this.animations.map(a=>{
  373. // a.mixer.timeScale = -1
  374. a.mixer.setTime(t)
  375. })
  376. if (Math.abs(animDelta) < 0.00001) return
  377. // if (this._animationTime > this._animationDuration) this._animationTime -= this._animationDuration
  378. // if (this._animationTime < 0) this._animationTime += this._animationDuration
  379. this._pageScrollAnimationState = this.pageScrollTime - this._animationTime
  380. if (Math.abs(this._pageScrollAnimationState) < 0.001) this._pageScrollAnimationState = 0
  381. else this._pageScrollAnimationState *= 1.0 - this.pageScrollAnimationDamping
  382. if (Math.abs(this._scrollAnimationState) < 0.001) this._scrollAnimationState = 0
  383. else this._scrollAnimationState *= 1.0 - this.scrollAnimationDamping
  384. if (Math.abs(this._dragAnimationState) < 0.001) this._dragAnimationState = 0
  385. else this._dragAnimationState *= 1.0 - this.dragAnimationDamping
  386. this.dispatchEvent({type: 'animationStep', delta: animDelta, time: t})
  387. // todo: this is now checked preFrame in ThreeViewer.ts
  388. // if (this._viewer.scene.mainCamera.userData.isAnimating) { // if camera is animating
  389. // this._viewer.scene.mainCamera.setDirty()
  390. // console.log(this._viewer.scene.mainCamera, this._viewer.scene.mainCamera.getWorldPosition(new Vector3()))
  391. // }
  392. this._viewer.renderManager.resetShadows()
  393. this._viewer.setDirty()
  394. if (!this._fadeDisabled) {
  395. const ff = this._viewer.getPlugin<FrameFadePlugin>('FrameFade')
  396. if (ff) {
  397. ff.disable(GLTFAnimationPlugin.PluginType)
  398. this._fadeDisabled = true
  399. }
  400. }
  401. if (this._animationTime >= this._animationDuration) {
  402. this.dispatchEvent({type: 'checkpointEnd'})
  403. }
  404. }
  405. protected _objectAdded = (ev: any)=>{
  406. const object = ev.object as IObject3D
  407. if (!this._viewer) return
  408. let changed = false
  409. object.traverse((obj)=>{
  410. if (!this._viewer) return
  411. const clips: AnimationClip[] = obj.animations
  412. if (clips.length < 1) return
  413. const duration = Math.max(...clips.map(an=>an.duration))
  414. if (object.userData.gltfAnim_SyncMaxDuration ?? this.syncMaxDuration) {
  415. clips.forEach(cp=>cp.duration = duration)
  416. object.userData.gltfAnim_SyncMaxDuration = true
  417. } // 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.
  418. const mixer = new AnimationMixer(this._viewer.scene.modelRoot) // add to modelRoot so it works with GLTF export...
  419. const actions = clips.map(an=>mixer.clipAction(an).setLoop(this.loopAnimations ? LoopRepeat : LoopOnce, this.loopRepetitions))
  420. actions.forEach(ac=>ac.clampWhenFinished = true)
  421. this.animations.push({
  422. mixer, clips, actions, duration,
  423. })
  424. // todo remove on object dispose
  425. changed = true
  426. })
  427. // this.playAnimation()
  428. if (changed) {
  429. this._onPropertyChange(!this.autoplayOnLoad)
  430. if (this.autoplayOnLoad) this.playAnimation()
  431. }
  432. return
  433. }
  434. private _onPropertyChange(replay = true): void {
  435. this._animationDuration = Math.max(...this.animations.map(({duration})=>duration)) * (this.loopAnimations ? this.loopRepetitions : 1)
  436. if (this._animationState === 'playing' && replay) {
  437. this.playAnimation()
  438. }
  439. }
  440. get pageScrollTime() {
  441. const scrollMax = this.pageScrollHeight()
  442. const time = window.scrollY / scrollMax * (this.animationDuration - 0.05)
  443. return time
  444. }
  445. private _scroll() {
  446. if (!this.enabled) return
  447. this._pageScrollAnimationState = this.pageScrollTime - this.animationTime
  448. }
  449. private _wheel({deltaY}: any | WheelEvent) {
  450. if (!this.enabled) return
  451. if (Math.abs(deltaY) > 0.001)
  452. this._scrollAnimationState = -1. * Math.sign(deltaY)
  453. }
  454. private _drag(ev: any) {
  455. if (!this.enabled || !this._viewer) return
  456. this._dragAnimationState = this.dragAxis === 'x' ?
  457. ev.delta.x * this._viewer.canvas.width / 4 :
  458. ev.delta.y * this._viewer.canvas.height / 4
  459. }
  460. pageScrollHeight = () => Math.max(
  461. document.body.scrollHeight,
  462. document.body.offsetHeight,
  463. document.documentElement.clientHeight,
  464. document.documentElement.scrollHeight,
  465. document.documentElement.offsetHeight
  466. ) - window.innerHeight
  467. }