上個月底,在朋友圈看到一個號稱「這多是地球上最美的h5」的分享,點進入後發現這個h5還很別緻,思考了一會,決定要不高仿一個?css
到今天爲止,高仿基本完成html
除了手機端的media控制沒有去兼容,其餘的基本都給仿了。 那爲了讓你以爲是高仿,最好使用chrome的手機調試模式進行訪問。微信打開將聽不見聲音看不到視頻... (後面再有時間看是否是仿的再進一步)vue
之因此要仿它,由於以爲這個h5還挺酷,想看看本身須要花多長時間找到並實現它的技術路徑。html5
首先來張效果圖:git
本案例主要用到了vue.js+three.js+html5。github
這個h5的主要玩法很簡單:地球自轉的時候會播放背景音樂(好比海浪聲),爲了找到這個聲音是從哪一個地球上哪一個地方傳來的,須要長按下方的按鈕,這時地球會自動轉動到目標地點,而後鏡頭拉近,穿過雲層,最後你會看到和這段聲音相關的視頻內容;鬆開手以後,上面的過程會倒退回去,地球又開始自轉,播放着下段神祕的背景音樂。web
我的以爲這個設計仍是很新穎的,不是說用了3D的效果,而是將一個看起來很複雜的動畫(從宇宙拉近到地表的過程),使用最基礎的3D效果和其餘一些常規的動畫手法去實現,而且能流暢的運行在手機瀏覽器上。另外還有聲音和視頻的完美搭配,用戶體驗不錯。chrome
反覆觀察,理清頁面功能:npm
加載:加載進度百分比,饒橢圓軌道運行的小行星做爲loading動畫(這個動畫我沒有作)canvas
地球:3D球體,旋轉入場動畫,自轉,漂移的雲層,城市的座標點,鏡頭的旋轉與拉近,穿越雲層動畫
星空背景:靜態星空背景圖,動態(閃爍的)星星,劃過的流星
隱藏的音頻和視頻:按內容(地理位置)劃分的音頻和視頻內容
其餘:操做指引示意動畫,地球上方會顯示當前城市的經緯度,「瞭解更多」的結語頁面等
一、尋找技術路徑
打開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這部分代碼。
知道如何製做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點個贊!
一、文件截圖
二、運行操做:
雙擊dist/index.html便可看到效果。
目前只兼容Chrome,firefox,360瀏覽器等主流瀏覽器