高仿QQ Xplan的H5页面

概述

这个h5的主要玩法很简单:地球自转的时候会播放背景音乐(好比海浪声),为了找到这个声音是从哪一个地球上哪一个地方传来的,须要长按下方的按钮,这时地球会自动转动到目标地点,而后镜头拉近,穿过云层,最后你会看到和这段声音相关的视频内容;松开手以后,上面的过程会倒退回去,地球又开始自转,播放着下段神秘的背景音乐。

详细

上个月底,在朋友圈看到一个号称“这多是地球上最美的h5”的分享,点进入后发现这个h5还很别致,思考了一会,决定要不高仿一个?css

到今天为止,高仿基本完成html

除了手机端的media控制没有去兼容,其余的基本都给仿了。 那为了让你以为是高仿,最好使用chrome的手机调试模式进行访问。微信打开将听不见声音看不到视频... (后面再有时间看是否是仿的再进一步)vue

之因此要仿它,由于以为这个h5还挺酷,想看看本身须要花多长时间找到并实现它的技术路径。html5

首先来张效果图:git

 

4.gif

本案例主要用到了vue.js+three.js+html5。github

1、需求分析

 

这个h5的主要玩法很简单:地球自转的时候会播放背景音乐(好比海浪声),为了找到这个声音是从哪一个地球上哪一个地方传来的,须要长按下方的按钮,这时地球会自动转动到目标地点,而后镜头拉近,穿过云层,最后你会看到和这段声音相关的视频内容;松开手以后,上面的过程会倒退回去,地球又开始自转,播放着下段神秘的背景音乐。web

我的以为这个设计仍是很新颖的,不是说用了3D的效果,而是将一个看起来很复杂的动画(从宇宙拉近到地表的过程),使用最基础的3D效果和其余一些常规的动画手法去实现,而且能流畅的运行在手机浏览器上。另外还有声音和视频的完美搭配,用户体验不错。chrome

反复观察,理清页面功能:npm

加载:加载进度百分比,饶椭圆轨道运行的小行星做为loading动画(这个动画我没有作)canvas

地球:3D球体,旋转入场动画,自转,漂移的云层,城市的坐标点,镜头的旋转与拉近,穿越云层动画

星空背景:静态星空背景图,动态(闪烁的)星星,划过的流星

隐藏的音频和视频:按内容(地理位置)划分的音频和视频内容

其余:操做指引示意动画,地球上方会显示当前城市的经纬度,“了解更多”的结语页面等

2、绘制地球

一、寻找技术路径

打开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都好作。那么到此,技术路径都清楚了,准备开始写代码。

二、难点突破

对于我而言,用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这部分代码。

3、动画控制

知道如何制做threejs地球以后,就正式coding了,固然仍是使用最心爱的Vue。本篇会有一些代码,可是都是十几行的独立片断,相信你不用担忧。知道如何制做threejs地球以后,就正式coding了,固然仍是使用最心爱的Vue。本篇会有一些代码,可是都是十几行的独立片断,相信你不用担忧。

布局


 

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

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

 

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

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

动画


 

xplan中的动画是最吸引个人地方,特别是地球放大,穿越云层的那一刻,想一想还有点小激动。

 

其实以前看到过一些项目有作从外太空俯冲进地球表面的动画,可是那些基本都是纯图片制做的SpriteSheet Animation,动画的前进后退控制都很容易。但xplan项目中则不一样,动画过程当中须要控制多个动画对象,还要配合其余资源(音频和视频)。

4.gif

分析


 

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状况,必定要避免,不然大项目中代码很难维护。这样的状况使用有限状态机模式或者策略模式都是很容易解决的。

第一印象告诉我:

  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点个赞!

4、演示效果

演示demo效果

5、文件截图以及运行操做

一、文件截图

blob.png

二、运行操做:

双击dist/index.html便可看到效果。

5、浏览器兼容性

目前只兼容Chrome,firefox,360浏览器等主流浏览器

注:本文著做权归做者,由demo大师发表,拒绝转载,转载须要做者受权

相关文章
相关标签/搜索