threejs地球之后:动画的控制
知道如何制作threejs地球之后,就正式coding了,当然还是使⽤最⼼爱的Vue。本篇会有⼀些代码,但是都是⼗⼏⾏的独⽴⽚段,相信你不⽤担⼼。
布局
在进⼊本篇主题前,要简单看⼀下xplan中的⾃适应解决⽅案,即如何在不同尺⼨设备中,都保证地球最合适的⼤⼩和位置,并且与其配套的⼀些图⽚(虚线的椭圆轨道、正中⼼⽩⾊的圆环等)都不会显⽰的错位。
xplan⽤的⽅式简单直接,固定⼤⼩内作布局,然后针对不同的设备尺⼨进⾏缩放。
固定画布⼤⼩(375 * 600),所有和地球相关的元素都可以在这个范围内绝对定位,之后scale⼀下,保证在设备实际尺⼨中是被包含(contain)的。这种⽅式⽐REM等其他的⾃适应⽅式更适合这个项⽬,毕竟threejs中不能使⽤REM单位。
感谢Vue,我得以将上⾯这个⾃⾏缩放的逻辑写成⼀个,之后再也不⽤操⼼布局问题了。
动画
xplan中的动画是最吸引我的地⽅,特别是地球放⼤,穿越云层的那⼀刻,想想还有点⼩激动。
其实之前看到过⼀些项⽬有做从外太空俯冲进地球表⾯的动画,但是那些基本都是纯图⽚制作的SpriteSheet Animation,动画的前进后退控制都很容易。但xplan项⽬中则不同,动画过程中需要控制多个动画对象,还要配合其他资源(⾳频和视频)。
分析
xplan中动画的逻辑是,在地球⾃转过程中,长按按钮,会依次发⽣:
1. 地球旋转到⽬的坐标
2. 地球放⼤(相机推进)到该坐标
3. 到⾜够近的时候,播放云层穿越动画
4. 云层穿越结束后,展⽰对应坐标的视频内容
5. 任何时刻松开长按按钮,动画都会回退到地球⾃转的状态
为了⽅便讨论,将上⾯分析到的动画阶段命名⼀下:
1. 地球⾃转过程:idle阶段
2. 地球转动到指定坐标的过程:rotating阶段
3. 地球距离被拉近拉远的过程:zooming阶段
4. 穿越云层的过程:diving阶段
5. 云层过后的视频展⽰:presenting阶段
具体分析⼏个过程:
在idle阶段,只要touchstart,就算你只长按了0.1s,那么rotating的动画就会完整的触发,然后状态跳回idle(rotating没有反向旋转)。如上⽰意图。
如果长按⾄了zooming阶段,松开⼿指之后,zooming动画会⽴刻反向播放,直⾄回到idle阶段。如上⽰意图。
如果zooming过程松开⼿指后,但是在离开zooming阶段前再次按下去,那么zooming动画会再⼀次正向播放。如上⽰意图。
diving阶段貌似⼜回到了和rotating类似的⾏为,就算中途结束,也会完成当前阶段的动画。但是和rotating不⼀样的是,diving阶段是有反向动画的。因此可以看到上⾯的⽰意图。
我在考虑的过程中,阴差阳错的误以为还有⼀个条件:即除了rotating阶段外,其他动画过程都可以随时进和退(上⾯的GIF就是我最终完成的动画控制)。这个给⾃⼰添加额外的难度,困扰了我很久。
分步实现:地球
我创建了⼀个,负责3D地球(包括光线,光晕,地表的云,浮动坐标点等)的创建和渲染,同时向外提供⼏个public⽅法:setCameraPosition()
getCameraPosition()
startAutoRotation()
stopAutoRotation()
地球旋转到指定坐标点,其实就是设置camera的position来完成了。要有流畅动画的感觉,就使⽤去做position的更新。
new TWEEN.Tween(
).to(
targetCameraPosition,
1000
).onUpdate(function () {
earth.setCameraPosition(this.x, this.y, this.z)
})
关于tween和threejs动画,这⾥有。
其实最开始,这个Earth类没有这么纯粹,我在⾥⾯加了targetLocation代表当前要转到的⽬标地点;还将tween的逻辑写在了这个类⾥⾯,让earth知道⾃⼰的⽬的地,控制⾃⼰的旋转动画。但后⾯发现对于这个项⽬中动画可控制的灵活性,这样封装在内部的动画逻辑,将很难写成清晰的代码,让其能和后⾯的云层动画统⼀来控制起来。
其实最开始,这个Earth类没有这么纯粹,我在⾥⾯加了targetLocation代表当前要转到的⽬标地点;还将tween的逻辑写在了这个类⾥⾯,让earth知道⾃⼰的⽬的地,控制⾃⼰的旋转动画。但后⾯发现对于这个项⽬中动画可控制的灵活性,这样封装在内部的动画逻辑,将很难写成清晰的代码,让其能和后⾯的云层动画统⼀来控制起来。
分步实现:云层
决定使⽤SpriteSheet Animation类似的⽅法做云层动画。其实有这样的库,⽐如(这个好像也是qq下⾯的团队做的),但是我还是更想从npm中install⼀个,由于没有到合适的,就索性⾃⼰写⼀个好了,于是就发布了⼀个⼩⼯具——。
操作由ImageSprite类创建云层对象,只⽤到了两个public⽅法,主要控制播放前⼀帧和后⼀帧:
<()
imageSprite.prev()
其实应该使⽤⾃动播放(play)和暂停(pause)应该也能完成,anyway
云层动画功能单⼀,想把它写的不纯粹也难。个⼈觉得coding的艺术就在于如何去划分这个纯粹。
第⼀印象
上⾯两个关键动画对象都实现了,⽤户的⾏为也很简单,只有touchstart和touchend,那么⽤⼀个touchDown标志位记录⼀下就可以了。所以可以有⼀个中控器(controller),根据⽤户产⽣的状态,来调⽤不同的动画对象播放动画。
最先开始,脑⼦⾥⾯第⼀印象是下⾯这样的解决⽅案:
function handleTouchDown () {
touchDown = true
if (currentState is idle) {
playRotatingForwardAnimation(handleAnimationComplete)
} else if (currentState is rotating) {
playZoomingForwardAnimation(handleAnimationComplete)
} else if (currentState is zooming) {
playDivingForwardAnimation(handleAnimationComplete)
} else if (currentState is diving) {
playPresentingForwardAnimation(handleAnimationComplete)
} else if (currentState is presenting) {
// nothing to do
}
}
function handleTouchEnd () {
touchDown = false
}
function handleAnimationComplete () {
if (touchDown) {
// 到下⼀个阶段,正向播放动画
findNextState()
play<nextstate>ForwardAnimation(handleAnimationComplete)
} else {
// 到上⼀个阶段,反向播放动画
findPrevState()
play<prevstate>BackwardAnimation(handleAnimationComplete)
}
}
这样的⽅案能解决动画的⼤⽅向,即动画阶段之间的前进和后退,⽆法控制阶段内的每⼀帧的⽅向。⽽且也能看到,上⾯有太多的if判
断,handleTouchDown函数中的那种if情况,⼀定要避免,否则⼤项⽬中代码很难维护。这样的情况使⽤有限状态机模式或者策略模式都是很容
易解决的。
第⼀印象告诉我:
1. 要使⽤状态机设计模式
2. 要从帧级别去做控制
状态机
写代码过程中肯定会遇到状态,最常见的状态会被记录成布尔值或者字符串常量,然后在做某个⾏为的时候对状态变量进⾏if-else判断。如果只有2个状态,还⾏,但是状态如果会变多,那么这样的代码就很难维护,将在主体中引⼊越来越多的if-else,越来越多的与特定状态相关的变量和逻辑。
个⼈⾮常喜欢状态机模式或者策略模式,它们本质都⼀样,都是使⽤组合代替继承,完成统⼀接⼝下的⾏为的多样性。最开⼼的是,这个模式将混杂在主体中的状态量和⾏为抽离出来,单独封装,让主体变的清清爽爽;还有,在JS中,你甚⾄连接⼝类都不⽤写!
举个简单的例⼦,上⼀篇中谈到的,⽤来将⼀系列图⽚进⾏播放,本质上就是绘制图⽚⽽已。但是我这⾥提供两种模式,⼀种绘制在canvas ⾥,⼀种绘制在dom⾥(即image展⽰)。
不使⽤模式,可以简单的写成这样:
class ImageSprite {
constructor () {
this.imageElement = null
this.images = []
}
drawImage () {
if (derMode === 'canvas') {
} else if (dererMode === 'dom') {
this.imageElement.src = '...'
}
}
}
使⽤了状态机模式(这⾥的场景来看,叫策略模式更贴切,渲染策略不同):
class ImageSprite {
constructor () {
this.images = []
}
drawImage () {
}
}
class CanvasRenderer {
constructor (imageSprite) {
this.imageSprite = imageSprite
}
drawImage () {
}
}
class DomRenderer {
constructor (imageSprite) {
this.imageSprite = imageSprite
this.imageElement = null
}
drawImage () {
this.imageElement.src = '...'
}
}
可以看到使⽤了模式之后,context和imageElement这样的和状态相关的变量,还有绘制canvas图⽚和绘制dom图⽚的不同代码,都从主体ImageSprite中抽离出去,单独的封装到了不同的状态对象中去了。
想想⼀下如果有第三种渲染模式,⽐如渲染在webgl中去,在不使⽤模式的代码中,要添加变量,要修改drawImage函数;但是在使⽤了模式的代码中,现有代码都不⽤改变,只需要添加⼀个新类WebglRenderer就可以了。这就是代码的可扩展性和可维护性的体现。(在Java中,还能省去代码的重新编译的过程)
整合
回到xplan的动画中去。在前⾯分析动画阶段的时候,其实就得到了每个状态,这些状态的统⼀接⼝就是向前帧动画(forward)和向后帧动画(backward)。
先不管每个state中逻辑该怎样,有了约定的接⼝,就可以把我们的中控器(Controller)写个基本框架了:
class Controller {
constructor (earth, cloud) {
this.earth = earth
this.cloud = cloud
this.state = new IdleState(this) // 初始状态为IdleState
this._init()
}
_loop () {
canvas动画requestAnimationFrame(this._loop.bind(this))
if (uchDown) { // 如果touchDown,则向前⼀帧
this.state.forward()
} else { // 否则,向后⼀帧
this.state.backward()
}
handleTouchStart () {
}
handleTouchEnd () {
}
// ...
}
因为要做到帧级别的控制,因此这⾥⽤到requestAnimationFrame来制作渲染循环。代码是不是很清晰简单!在渲染循环中,根本不在乎动画逻辑怎么执⾏,只知道touchDown了,就做向前动画,否则做向后动画,其他的都在各⾃的状态类⾥去实现。
下⾯拿两个状态类举例,其他的请移步。
IdleState
class IdleState {
constructor (controller) {
}
forward () {
}
backward () {
// do nothing
}
}
这⾥IdleState没有向后的动画,因此backward()⾥⾯是空的;⽽该状态下的touchDown都会让earth开始旋转到指定坐标,⽽这个过程我们知道是RotatingState该做的,所以在RotatingState的‘forward()`⾥会去实现旋转控制。
DivingState
class DivingState {
constructor (controller) {
}
forward () {
let cloud = ller.cloud
if (cloud.currentFrame is last frame) { // 最后⼀帧时,进⼊下⼀个状态
} else {
<() // 播放下⼀帧
}
}
backward () {
let cloud = ller.cloud
if (cloud.currentFrame is first frame) { // 回退到第⼀帧时,进⼊上⼀个状态
} else {
cloud.prev() // 播放前⼀帧
}
}
}
记得么,diving是指穿越云层的那个过程。因此它往前(forward)是presenting,往后(backward)是zooming。⽽什么时候切换到下⼀个或者前⼀个状态,和往前或者往后的每⼀帧动画该如何执⾏,都只有这个DivingState知道,完美的逻辑封装。
完整的动画逻辑⾥,还包含着⼀些⾳频和视频的控制逻辑。⽐如地球⾃转时播放背景⾳乐,动画⼀旦开始则停⽌;穿越云层后播放视频,其他时候视频是停⽌的。这些逻辑,能够很容易的添加到上⾯的状态中去。⽐如在IdleState的contructor中播放⾳乐,在RotatingState的contructor中停⽌播放⾳乐;在PresentingState的constructor中播放视频,在DivingState的contructor中停⽌视频。
所以,⼀旦逻辑清晰了,代码清晰了,添加功能时显得很容易。
意外收获
完成上⾯的所有动画状态之后,我发现地球其实还有⼀个动画,那就是开场的逆向旋转并放⼤的⼊场动画。在上⾯做动画分析的时候,是把这个开场动画分开来设想的,但是上⾯的controller⽤上状态机之后,意外的发现这个⼊场动画可以以另外⼀个state放进来。
⼊场动画状态类:
class EnteringState {
constructor (controller) {
this.tween = new TWEEN.Tween({
// 起点位置
}).to({
// 终点位置
}, 1600).onUpdate(function () {
// 设置earth的缩放和旋转
}).onComplete(function () {
}).easing(TWEEN.Easing.Cubic.Out).start()
}
forward () {
TWEEN.update()
}
backward () {
/
/ do nothing
}
}
最后将Controller初始化时的第⼀个state赋值改为EnteringState即可。这真算是⼀个意外的收获,本来是打算单独(在controller之外)去实现的。
⼩结
到这⾥就差不多了,xplan主要的东西都讲到了,⾼(shan)仿(zhai)的过程还不错,了解了three,顺便还publish了⼏个⼩的⼯具库;有不⾜、也有超越。这个h5看似复杂,但是技术也没有多⾼深,主要还是创意,还是要给xplan点个赞!
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论