上一篇知道如何制作threejs地球之后,就正式coding了,当然还是使用最心爱的Vue。本篇会有一些代码,但是都是十几行的独立片段,相信你不用担心。

布局

在进入本篇主题前,要简单看一下xplan中的自适应解决方案,即如何在不同尺寸设备中,都保证地球最合适的大小和位置,并且与其配套的一些图片(虚线的椭圆轨道、正中心白色的圆环等)都不会显示的错位。

xplan用的方式简单直接,固定大小内作布局,然后针对不同的设备尺寸进行缩放。

固定画布大小(375 * 600),所有和地球相关的元素都可以在这个范围内绝对定位,之后scale一下,保证在设备实际尺寸中是被包含(contain)的。这种方式比REM等其他的自适应方式更适合这个项目,毕竟threejs中不能使用REM单位。

感谢Vue,我得以将上面这个自行缩放的逻辑写成一个Page组件,之后再也不用操心布局问题了。

动画

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就是我最终完成的动画控制)。这个给自己添加额外的难度,困扰了我很久。

分步实现:地球

我创建了一个Earth类,负责3D地球(包括光线,光晕,地表的云,浮动坐标点等)的创建和渲染,同时向外提供几个public方法:

  • setCameraPosition()

  • getCameraPosition()

  • startAutoRotation()

  • stopAutoRotation()

地球旋转到指定坐标点,其实就是设置camera的position来完成了。要有流畅动画的感觉,就使用tween去做position的更新。

new TWEEN.Tween(
earth.getCameraPosition()
).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类似的方法做云层动画。其实有这样的库,比如Film(这个好像也是qq下面的团队做的),但是我还是更想从npm中install一个,由于没有找到合适的,就索性自己写一个好了,于是就发布了一个小工具——image-sprite

操作由ImageSprite类创建云层对象,只用到了两个public方法,主要控制播放前一帧和后一帧:

  • imageSprite.next()

  • 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中,你甚至连接口类都不用写!

举个简单的例子,上一篇中谈到的ImageSprite,用来将一系列图片进行播放,本质上就是绘制图片而已。但是我这里提供两种模式,一种绘制在canvas里,一种绘制在dom里(即image展示)。

不使用模式,可以简单的写成这样:

class ImageSprite {
constructor () {
this.renderMode = 'canvas'
this.context = null
this.imageElement = null
this.images = []
}
drawImage () {
if (this.renderMode === 'canvas') {
this.context.drawImage()
} else if (this.rendererMode === 'dom') {
this.imageElement.src = '...'
}
}
}

使用了状态机模式(这里的场景来看,叫策略模式更贴切,渲染策略不同):

class ImageSprite {
constructor () {
this.renderer = new CanvasRenderer(this)
this.images = []
}
drawImage () {
this.renderer.drawImage()
}
} class CanvasRenderer {
constructor (imageSprite) {
this.imageSprite = imageSprite
this.context = null
}
drawImage () {
this.context.drawImage()
}
} class DomRenderer {
constructor (imageSprite) {
this.imageSprite = imageSprite
this.imageElement = null
}
drawImage () {
this.imageElement.src = '...'
}
}

可以看到使用了模式之后,contextimageElement这样的和状态相关的变量,还有绘制canvas图片和绘制dom图片的不同代码,都从主体ImageSprite中抽离出去,单独的封装到了不同的状态对象中去了。

想想一下如果有第三种渲染模式,比如渲染在webgl中去,在不使用模式的代码中,要添加变量,要修改drawImage函数;但是在使用了模式的代码中,现有代码都不用改变,只需要添加一个新类WebglRenderer就可以了。这就是代码的可扩展性和可维护性的体现。(在Java中,还能省去代码的重新编译的过程)

整合

回到xplan的动画中去。在前面分析动画阶段的时候,其实就得到了每个状态,这些状态的统一接口就是向前帧动画(forward)和向后帧动画(backward)。

先不管每个state中逻辑该怎样,有了约定的接口,就可以把我们的中控器(Controller)写个基本框架了:

class Controller {
constructor (earth, cloud) {
this.earth = earth
this.cloud = cloud
this.touchDown = false
this.state = new IdleState(this) // 初始状态为IdleState
this._init()
}
_loop () {
requestAnimationFrame(this._loop.bind(this))
if (this.touchDown) { // 如果touchDown,则向前一帧
this.state.forward()
} else { // 否则,向后一帧
this.state.backward()
}
handleTouchStart () {
this.touchDown = true
}
handleTouchEnd () {
this.touchDown = false
} // ...
}

因为要做到帧级别的控制,因此这里用到requestAnimationFrame来制作渲染循环。代码是不是很清晰简单!在渲染循环中,根本不在乎动画逻辑怎么执行,只知道touchDown了,就做向前动画,否则做向后动画,其他的都在各自的状态类里去实现。

下面拿两个状态类举例,其他的请移步这里

IdleState

class IdleState {
constructor (controller) {
this.controller = controller
}
forward () {
this.controller.state = new RotatingState(this.controller)
}
backward () {
// do nothing
}
}

这里IdleState没有向后的动画,因此backward()里面是空的;而该状态下的touchDown都会让earth开始旋转到指定坐标,而这个过程我们知道是RotatingState该做的,所以在RotatingState的‘forward()`里会去实现旋转控制。

DivingState

class DivingState {
constructor (controller) {
this.controller = controller
}
forward () {
let cloud = this.controller.cloud
if (cloud.currentFrame is last frame) { // 最后一帧时,进入下一个状态
this.controller.state = new PresentingState(this.controller)
} else {
cloud.next() // 播放下一帧
}
}
backward () {
let cloud = this.controller.cloud
if (cloud.currentFrame is first frame) { // 回退到第一帧时,进入上一个状态
this.controller.state = new ZoomingState(this.controller)
} else {
cloud.prev() // 播放前一帧
}
}
}

记得么,diving是指穿越云层的那个过程。因此它往前(forward)是presenting,往后(backward)是zooming。而什么时候切换到下一个或者前一个状态,和往前或者往后的每一帧动画该如何执行,都只有这个DivingState知道,完美的逻辑封装。

完整的动画逻辑里,还包含着一些音频和视频的控制逻辑。比如地球自转时播放背景音乐,动画一旦开始则停止;穿越云层后播放视频,其他时候视频是停止的。这些逻辑,能够很容易的添加到上面的状态中去。比如在IdleState的contructor中播放音乐,在RotatingState的contructor中停止播放音乐;在PresentingState的constructor中播放视频,在DivingState的contructor中停止视频。

所以,一旦逻辑清晰了,代码清晰了,添加功能时显得很容易。

意外收获

完成上面的所有动画状态之后,我发现地球其实还有一个动画,那就是开场的逆向旋转并放大的入场动画。在上面做动画分析的时候,是把这个开场动画分开来设想的,但是上面的controller用上状态机之后,意外的发现这个入场动画可以以另外一个state放进来。

入场动画状态类:

class EnteringState {
constructor (controller) {
this.controller = controller
this.tween = new TWEEN.Tween({
// 起点位置
}).to({
// 终点位置
}, 1600).onUpdate(function () {
// 设置earth的缩放和旋转
}).onComplete(function () {
this.controller.state = new IdleState(this.controller) // 完成后进入IdleState
}).easing(TWEEN.Easing.Cubic.Out).start()
}
forward () {
TWEEN.update()
}
backward () {
// do nothing
}
}

最后将Controller初始化时的第一个state赋值改为EnteringState即可。这真算是一个意外的收获,本来是打算单独(在controller之外)去实现的。

小结

到这里就差不多了,xplan主要的东西都讲到了,高(shan)仿(zhai)的过程还不错,了解了three,顺便还publish了几个小的工具库;有不足、也有超越。这个h5看似复杂,但是技术也没有多高深,主要还是创意,还是要给xplan点个赞!

threejs地球之后:动画的控制的更多相关文章

  1. Adobe Edge Animate –地球自转动画的实现,类似flash遮罩层的效果

    Adobe Edge Animate –地球自转动画的实现,类似flash遮罩层的效果 版权声明: 本文版权属于 北京联友天下科技发展有限公司. 转载的时候请注明版权和原文地址. 目前Edge的功能尚 ...

  2. GSAP JS基础教程--动画的控制及事件

    好多天没有写无博文啦,今天无聊就再写一下! 今天要讲的是TweenLite的一些事件以及,TweenLite动画的控制,TweenMax类似,请自行参考官方文档:http://api.greensoc ...

  3. threejs 贴图动画总结

    引言 在三维可视化中,会涉及到很多动画,其中贴图动画是其中很重要的一种,本文介绍几种贴图动画的思路,供大家一起探讨. 流动动画 流动动画通过设置贴图的repeat属性,并不断改变贴图对象的offset ...

  4. Unity3D 几个基本动画(控制物体移动、旋转、缩放)

    Transform基本移动函数: 1.指定方向移动: //移动速度 float TranslateSpeed = 10f; //Vector3.forward 表示"向前" tra ...

  5. jquery动画(控制动画隐藏、显示时间轴)

    <!DOCTYPE html><html> <head>    <meta http-equiv="Content-type" conte ...

  6. 基于脚本的动画的计时控制(“requestAnimationFrame”)(转)

    requestAnimationFrame 方法的支持,该方法通过在系统准备好绘制动画帧时调用该帧,从而为创建动画网页提供了一种更平滑更高效的方法.在此 API 之前,使用 setTimeout 和  ...

  7. WPF控制动画开始、停止、暂停和恢复

    1.闲言 好久也没更新一博客了,自己有点发懒,同时确实这几个月来也有点忙.风机监测软件,项目中,有这样一个小需求:正常风机在旋转的时候,上位机软要做一个风机的图片,让它不停地旋转,一但检测到下面风机停 ...

  8. ThreeJS之动画交互逻辑及特效

    工作需要,研究了一下 threejs 简单逻辑动画交互方法.写了一个小示例,分享一下,挺丑的. 第一步 当然就是初始化 threejs 的渲染场景了. var camera; //相机 var sce ...

  9. CSS3属性animation-play-state控制动画运行或暂停的技巧

    animation-play-state介绍 animation-play-state 属性规定动画正在运行还是暂停. div{ animation-play-state:paused; -webki ...

随机推荐

  1. hdu2492树状数组

    题目链接:http://icpc.njust.edu.cn/Problem/Hdu/2492/ 题目大意:给定一个序列,求长度为三的子序列(a,b,c)使得a<b<c或a>b> ...

  2. 洛谷 P3935 Calculating 题解

    原题链接 一看我感觉是个什么很难的式子-- 结果读完了才发现本质太简单. 算法一 完全按照那个题目所说的,真的把质因数分解的结果保留. 最后乘. 时间复杂度:\(O(r \sqrt{r})\). 实际 ...

  3. 2019牛客多校第四场 A meeting

    链接:https://ac.nowcoder.com/acm/contest/884/A来源:牛客网 时间限制:C/C++ 1秒,其他语言2秒 空间限制:C/C++ 524288K,其他语言10485 ...

  4. 解决 Mac Android Studio Gradle Sync 慢的问题

    1.启动Android Studio 2.从项目的 gradle/wrapper/gradle-wrapper.properties 目录中找到 distributionUrl 这个字段,查看后面对应 ...

  5. 刷oj之类的题时java Scanner读取太慢解决之道

    1.转载自一个 https://www.cpe.ku.ac.th/~jim/java-io.html 2.工具代码 class Reader { static BufferedReader reade ...

  6. 编译原理:LL(1)文法的判断,递归下降分析程序

    1. 文法 G(S): (1)S -> AB (2)A ->Da|ε (3)B -> cC (4)C -> aADC |ε (5)D -> b|ε 验证文法 G(S)是不 ...

  7. 045.集群存储-CSI存储机制

    一 CSI存储机制 1.1 CSI简介 Kubernetes从1.9版本开始引入容器存储接口Container Storage Interface(CSI)机制,用于在Kubernetes和外部存储系 ...

  8. arcgis连接oracle发布服务,提示数据未注册到服务器,手动注册服务器失败

    arcgis连接oracle数据库发布服务时候,分析之后提示:数据未注册到服务器上. 手动注册之后提示:数据客户端没有正确配置.实际上数据库客户端已经安装完成也可以使用. 设置 PATH 环境变量(仅 ...

  9. CDN 内容分发

    1,传统架构访问服务器资源: www.aiyuesheng.com/page/logo.png 这是部署在服务器上的一张图片,因为服务器部署在上海,所以在上海或周边的人访问要稍微快一点,但是,若是云南 ...

  10. flask中温柔显示404等错误

    写下下面两个视图函数,然后在模板中写下错误时展现的内容,当然模板名,函数名是可以改的哟@app.errorhandler(404)def page_not_found(error): return r ...