高仿QQ Xplan的H5页面
概述
详细
上个月底,在朋友圈看到一个号称“这可能是地球上最美的h5”的分享,点进入后发现这个h5还很别致,思考了一会,决定要不高仿一个?
到今天为止,高仿基本完成
除了手机端的media控制没有去兼容,其他的基本都给仿了。 那为了让你觉得是高仿,最好使用chrome的手机调试模式进行访问。微信打开将听不见声音看不到视频... (后面再有时间看是不是仿的再进一步)
之所以要仿它,因为觉得这个h5还挺酷,想看看自己需要花多长时间找到并实现它的技术路径。
首先来张效果图:
本案例主要用到了vue.js+three.js+html5。
一、需求分析
这个h5的主要玩法很简单:地球自转的时候会播放背景音乐(比如海浪声),为了找到这个声音是从哪个地球上哪个地方传来的,需要长按下方的按钮,这时地球会自动转动到目标地点,然后镜头拉近,穿过云层,最后你会看到和这段声音相关的视频内容;松开手之后,上面的过程会倒退回去,地球又开始自转,播放着下段神秘的背景音乐。
个人觉得这个设计还是很新颖的,不是说用了3D的效果,而是将一个看起来很复杂的动画(从宇宙拉近到地表的过程),使用最基础的3D效果和其他一些常规的动画手法去实现,并且能流畅的运行在手机浏览器上。另外还有声音和视频的完美搭配,用户体验不错。
反复观察,理清页面功能:
加载:加载进度百分比,饶椭圆轨道运行的小行星作为loading动画(这个动画我没有做)
地球:3D球体,旋转入场动画,自转,漂移的云层,城市的坐标点,镜头的旋转与拉近,穿越云层动画
星空背景:静态星空背景图,动态(闪烁的)星星,划过的流星
隐藏的音频和视频:按内容(地理位置)划分的音频和视频内容
其他:操作指引示意动画,地球上方会显示当前城市的经纬度,“了解更多”的结语页面等
二、绘制地球
1、寻找技术路径
打开chrome inspect一下。
首先是这个地球,得看看它是真3D还是假3D(因为很多3D效果是拿雪碧图做的,比如这里的旋转的3D飞机),结果找到了:
<div class="ns-webgl-page"> <canvas width="750" height="1200" style="width: 375px; height: 600px;"></canvas> </div>
并且在网站source文件中搜到了THREE,那就是threejs没跑了。
然后是那个穿越云层的效果,猜测可能是GIF,可能是SpriteSheet Animation,也有可能是一段视频。但是考虑到这个穿越的动画可以正反双方播放,那么就很可能是是SpriteSheet Animation了,否则GIF或者视频文件需要两个动画方向各准备一份。这个从chrome debug工具的network下找到了证据—— 页面下载了一系列名为kf_cloud_0000X.jpg的图片文件。顺手就把它们down下来,备用。
再就是背景音乐和隐藏视频的问题,同样在network下,找到了两个文件,一个mp3一个mp4,每个文件都包含了所有片段,就像是media的雪碧图,只在需要的时候控制播放对应片段而已。
其他的内容都没什么问题,CSS动画或者CANVAS都好做。那么到此,技术路径都清楚了,准备开始写代码。
2、难点突破
对于我而言,用threejs绘制地球可能会是难点,threejs没有用过,而且印象中对3D的东西,一直比较敬畏。如果3D的地球弄不出来,这个项目其他的都做完了,在浩瀚的宇宙中是怎么也找不到“声音来自何方”了。
OK,来看threejs怎么能弄出个地球来。(这个阶段并没有开始项目代码,而是尽量的在一个临时文件中进行涂鸦,快速随意的达到绘制出地球的目的就行了)
官网
对于新的技术,首先得看官网。这里并不是来全面学习threejs的,而是抱着很强的目的性去实现特定功能,因此直接去示例中找,是否有类似实现可以借鉴。在官网首页中,通过缩略图,找到了下面三个关于地球的例子。
可惜,貌似这里的例子都是一些产品应用,代码都是压缩过的。于是开始去寻找官方示例,最后在examples里找到了canvas_geometry_earth,最棒的是在github上有源码。
示例代码
clone下threejs的项目代码,找到上面的示例文件。示例代码不到200行,阅读之后发现其实threejs和之前接触过的一些2D的游戏引擎(createjs,pixijs)等比较类似,都需要有场景(scene),要有渲染循环(render loop),在scene上添加对象(Mesh)或者是group;而Mesh由形状(Geometry)和材质(Material)组成,Material则又是由图片创建的纹理(Texture)而来。不同的是,这里有相机(Camera),有光线(Light),还有一些一直都不明白的距离单位问题。
稍微改动一下示例代码,就能创建出来了earth。但是从使用的资源来看,只有一个地表纹理贴图(earth4.jpg),而xplan中还有3个关于earth的图片文件:
不确定bump和spec是什么,我的思路是先在官方文档中找这些关键词,如果找不到,就加上threejs一起去做google。官网上找到了bump相关的东西,但帮助最大的是google出来的一篇详细的如何使用threejs创建earth的教程。(如果这个教程早点冒出来,也省了前面改示例代码的时间了。主要也源于对threejs不熟悉,没有想到哪些示例可能已经有很多教程了)
换上了earth4.jpg贴图之后:
教程中的步骤不再这里重复,下面仅仅对一些关键东西作简单的解释。
earth_bump
了解到bumpmap:
Bump mapping is a technique to simulate bumps and wrinkles on the surface of an object. The result is an apparently bumpy surface rather than a smooth surface although the surface of the underlying object is not actually changed. I'm sorry, you can't tilt the camera to see 3D mountains with this technique. You can adjust the bump effect (how much the map affects lighting) with the bumpScaleparameter
threejs中bumpmap是调节对光线的感知,来使人能明显感觉到不光滑的表面,而并没有在mesh中添加起伏,即没有真的改变形状。
官方bumpmap示例效果图如下:
其实这里的earth_bump.jpg就是一个DEM,在threejs中称作bumpmap,在其他一些地方也有被叫做heightmap。即用灰度图表达高程,越黑表示高程越低,越亮表示高程越高。GIS专业中常用,unity3D中创建地形也会用到这个。
添加了earth_bump之后:
earth_spec
了解到了earth_spec.jpg是specular map,用来调节镜面反射的,这里主要是调节海洋对光线的反射,增加真实性。
添加了earth_spec之后:
漂移的云层
云层的添加, 前面的教程里已经很详细了,其实就是一个同心,半径大一点的球体而已。
添加了云层之后:
浮动的标签
xplan中地球表面有城市标签,会随着地球的自转而移动,同时又保持了水平的方向。google关键词:threejs floating label。于是找到:
找到方向就好办,稍微参考一下官方API文档和找到的示例代码,能够很容易的在earth上添加上浮动标签。
小结
到这里,3D地球的绘制基本差不多了。虽然threejs是新东西,但是绝大部分功能都容易找到方向,并且改动一下示例代码都够快速的实现我们想要的效果,所这个过程并不难。重点是如何在一个未知的领域内找到想要的东西,并且快速的为自己所用。
但过程中我碰到一个性能问题,耽误了很久。xplan的页面在chrome的PC和手机模式都有近60的FPS,但是我创建的earth在PC有60,但是在手机模式却不到30!最后逐一调试代码,修改参数,花了好久才找到原因:
renderer.setPixelRatio(window.devicePixelRatio)
threejs的示例代码中都有这么一行,就是这一行导致了我的代码比xplan的代码在手机上绘制的像素点翻倍,从而导致了性能成倍的下降。
另外,前面也提到,我对于3D框架中的距离单位和坐标问题,很模糊。于是这里,关于earth的大小,camera朝向,每个城市标签的三维坐标和其他关与三维坐标的问题,我都硬抄了xplan的参数(幸好他们的代码没有压缩...)。还有一个要承认的,就是地球后面的淡蓝色光晕效果,貌似用了一些高级的渲染技术,我也就硬搬了xplan这部分代码。
三、动画控制
知道如何制作threejs地球之后,就正式coding了,当然还是使用最心爱的Vue。本篇会有一些代码,但是都是十几行的独立片段,相信你不用担心。知道如何制作threejs地球之后,就正式coding了,当然还是使用最心爱的Vue。本篇会有一些代码,但是都是十几行的独立片段,相信你不用担心。
布局
在进入本篇主题前,要简单看一下xplan中的自适应解决方案,即如何在不同尺寸设备中,都保证地球最合适的大小和位置,并且与其配套的一些图片(虚线的椭圆轨道、正中心白色的圆环等)都不会显示的错位。
xplan用的方式简单直接,固定大小内作布局,然后针对不同的设备尺寸进行缩放。
固定画布大小(375 * 600),所有和地球相关的元素都可以在这个范围内绝对定位,之后scale一下,保证在设备实际尺寸中是被包含(contain)的。这种方式比REM等其他的自适应方式更适合这个项目,毕竟threejs中不能使用REM单位。
感谢Vue,我得以将上面这个自行缩放的逻辑写成一个Page组件,之后再也不用操心布局问题了。
动画
xplan中的动画是最吸引我的地方,特别是地球放大,穿越云层的那一刻,想想还有点小激动。
其实之前看到过一些项目有做从外太空俯冲进地球表面的动画,但是那些基本都是纯图片制作的SpriteSheet Animation,动画的前进后退控制都很容易。但xplan项目中则不同,动画过程中需要控制多个动画对象,还要配合其他资源(音频和视频)。
分析
xplan中动画的逻辑是,在地球自转过程中,长按按钮,会依次发生:
地球旋转到目的坐标
地球放大(相机推进)到该坐标
到足够近的时候,播放云层穿越动画
云层穿越结束后,展示对应坐标的视频内容
任何时刻松开长按按钮,动画都会回退到地球自转的状态
为了方便讨论,将上面分析到的动画阶段命名一下:
地球自转过程:idle阶段
地球转动到指定坐标的过程:rotating阶段
地球距离被拉近拉远的过程:zooming阶段
穿越云层的过程:diving阶段
云层过后的视频展示: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知道自己的目的地,控制自己的旋转动画。但后面发现对于这个项目中动画可控制的灵活性,这样封装在内部的动画逻辑,将很难写成清晰的代码,让其能和后面的云层动画统一来控制起来。
分步实现:云层
决定使用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情况,一定要避免,否则大项目中代码很难维护。这样的情况使用有限状态机模式或者策略模式都是很容易解决的。
第一印象告诉我:
-
要使用状态机设计模式
-
要从帧级别去做控制
状态机
写代码过程中肯定会遇到状态,最常见的状态会被记录成布尔值或者字符串常量,然后在做某个行为的时候对状态变量进行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 = '...' } }
可以看到使用了模式之后,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.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点个赞!
四、演示效果
五、文件截图以及运行操作
1、文件截图
2、运行操作:
双击dist/index.html即可看到效果。
五、浏览器兼容性
目前只兼容Chrome,firefox,360浏览器等主流浏览器