個案研究 -《找出奧茲家》

unit9 com
unit9 com

簡介

「Find Your Way to Oz」是 Disney 在網路上推出的全新 Google Chrome 實驗。你可以透過互動式旅程,在堪薩斯州馬戲團中展開冒險,並在遭到大風暴捲入後,來到奧茲國。

我們的目標是結合電影的豐富內容和瀏覽器的技術能力,打造有趣的沉浸式體驗,讓使用者能與內容建立強烈連結。

這項工作內容太龐大,無法在本篇文章中完整呈現,因此我們深入研究,並挑選出一些我們認為有趣的技術故事章節。我們也從中擷取了一些難度逐漸增加的專門教學課程。

許多人努力打造這項體驗,在此無法一一列出。請造訪網站,查看選單部分下方的版權頁面,瞭解完整的故事。

深入解析

電腦版的「前往夢幻仙境」是個豐富的沉浸式體驗,我們使用 3D 技術和多層次的傳統電影效果,結合創造出逼真的場景。最顯著的技術是 WebGL 和 Three.js、自訂建構的著色器,以及使用 CSS3 功能的 DOM 動畫元素。此外,getUserMedia API (WebRTC) 可提供互動式體驗,讓使用者直接從網路攝影機和 WebAudio 新增圖片,以便使用 3D 音效。

但這類技術體驗的魔力,就在於如何將這些元素結合在一起。這也是其中一個主要挑戰:如何在同一個場景中將視覺效果和互動元素混合,打造一致的整體效果?這種視覺複雜度很難管理,因為我們很難判斷開發進度。

為瞭解決視覺效果和最佳化之間的連結問題,我們大量使用控制面板,以便擷取我們當時正在查看的所有相關設定。您可以在瀏覽器中即時調整場景的亮度、景深、伽馬等參數。任何人都可以嘗試調整體驗中的重要參數值,並參與探索最有效的參數。

在分享秘訣前,我們想提醒您,這可能會導致應用程式停止運作,就像您在車輛引擎內部亂動一樣。請確認您沒有任何重要事項,然後前往網站的主要網址,並在網址後方加上 ?debug=on。請等待網站載入,然後按下 Ctrl-I 鍵,畫面右側就會顯示下拉式選單。如果取消勾選「退出攝影機路徑」選項,您可以使用 A、W、S、D 鍵和滑鼠,在空間中自由移動。

相機路徑。

我們不會在這裡介紹所有設定,但鼓勵您進行實驗:按鍵會顯示不同場景中的不同設定。在最後的暴風序列中,還有一個額外的鍵:Ctrl-A,您可以使用該鍵切換動畫播放,並四處飛行。在這個場景中,如果按下 Esc (退出滑鼠鎖定功能),然後再次按下 Ctrl-I,即可存取暴風場景專屬的設定。環顧四周,拍攝一些如下圖所示的風景照。

暴風雨場景

為了實現這項功能,並確保它能滿足我們的需要,我們使用了名為 dat.gui 的可愛程式庫 (請按這裡查看過去的使用教學)。這讓我們能快速變更向網站訪客顯示的設定。

有點像無光澤的繪畫

在許多迪士尼經典電影和動畫中,創作場景就是指將不同圖層結合。我們使用了多層實景、單格動畫,甚至是實體布景,並在頂層使用玻璃上繪製的圖像,也就是所謂的「無縫接合」技術。

在許多方面,我們建立的體驗結構都很相似,雖然有些「圖層」遠不只是靜態視覺效果。事實上,這些屬性會根據更複雜的運算影響顯示方式。不過,至少在整體層級上,我們會處理疊加的多個 View。頂端顯示 UI 層,下方則是 3D 場景,而場景本身是由不同的場景元件組成。

頂層介面層是使用 DOM 和 CSS 3 建立,因此可透過多種方式編輯互動,而不會影響 3D 體驗,並根據所選事件清單進行兩者間的通訊。這項通訊會使用 Backbone Router 和 onHashChange HTML5 事件,控制應以動畫方式顯示/隱藏哪個區域。(專案來源:/develop/coffee/router/Router.coffee)。

教學課程:圖像片段影格和 Retina 支援

我們在介面中採用了一個有趣的最佳化技巧,將許多介面疊加圖片合併為單一 PNG 檔案,以減少伺服器要求。在這個專案中,介面由 70 多張圖片 (不含 3D 紋理) 組成,這些圖片會預先載入,以減少網站的延遲時間。您可以在這裡查看即時圖像片段表:

一般螢幕:http://findyourwaytooz.com/img/home/interface_1x.png Retina 螢幕:http://findyourwaytooz.com/img/home/interface_2x.png

以下提供一些訣竅,說明如何善用 Sprite 圖層,以及如何在 Retina 裝置上使用 Sprite 圖層,並盡可能讓介面保持清晰整齊。

建立圖像片段表

我們使用 TexturePacker 建立 SpriteSheet,可輸出所需的任何格式。在本例中,我們已匯出 EaselJS,這項工具非常簡潔,也可以用來建立動畫精靈。

使用產生的圖像片段表

建立了圖像片段影格後,您應該會看到類似以下的 JSON 檔案:

{
   "images": ["interface_2x.png"],
   "frames": [
       [2, 1837, 88, 130],
       [2, 2, 1472, 112],
       [1008, 774, 70, 68],
       [562, 1960, 86, 86],
       [473, 1960, 86, 86]
   ],

   "animations": {
       "allow_web":[0],
       "bottomheader":[1],
       "button_close":[2],
       "button_facebook":[3],
       "button_google":[4]
   },
}

在此情況下:

  • image 是 Sprite 工作表的網址
  • 框架是每個 UI 元素的座標 [x, y, 寬度, 高度]
  • 動畫是每個素材資源的名稱

請注意,我們使用高密度圖片建立 Sprite 工作表,然後建立一般版本,並將其大小調整為原來的一半。

重點回顧

一切就緒後,只需要使用 JavaScript 程式碼片段即可。

var SSAsset = function (asset, div) {
  var css, x, y, w, h;

  // Divide the coordinates by 2 as retina devices have 2x density
  x = Math.round(asset.x / 2);
  y = Math.round(asset.y / 2);
  w = Math.round(asset.width / 2);
  h = Math.round(asset.height / 2);

  // Create an Object to store CSS attributes
  css = {
    width                : w,
    height               : h,
    'background-image'   : "url(" + asset.image_1x_url + ")",
    'background-size'    : "" + asset.fullSize[0] + "px " + asset.fullSize[1] + "px",
    'background-position': "-" + x + "px -" + y + "px"
  };

  // If retina devices

  if (window.devicePixelRatio === 2) {

    /*
    set -webkit-image-set
    for 1x and 2x
    All the calculations of X, Y, WIDTH and HEIGHT is taken care by the browser
    */

    css['background-image'] = "-webkit-image-set(url(" + asset.image_1x_url + ") 1x,";
    css['background-image'] += "url(" + asset.image_2x_url + ") 2x)";

  }

  // Set the CSS to the DIV
  div.css(css);
};

使用方式如下:

logo = new SSAsset(
{
  fullSize     : [1024, 1024],               // image 1x dimensions Array [x,y]
  x            : 1790,                       // asset x coordinate on SpriteSheet         
  y            : 603,                        // asset y coordinate on SpriteSheet
  width        : 122,                        // asset width
  height       : 150,                        // asset height
  image_1x_url : 'img/spritesheet_1x.png',   // background image 1x URL
  image_2x_url : 'img/spritesheet_2x.png'    // background image 2x URL
},$('#logo'));

如要進一步瞭解可變像素密度,請參閱 Boris Smus 撰寫的這篇文章

3D 內容管道

環境體驗會在 WebGL 圖層上設定。在思考 3D 場景時,最棘手的問題之一,就是如何確保您能創作內容,充分發揮建模、動畫和特效的表現潛力。在許多方面,這個問題的核心是內容管道:為 3D 場景建立內容時,必須遵循的程序。

我們希望打造令人驚嘆的世界,因此需要一套可讓 3D 藝術家創作內容的穩固流程。他們需要在 3D 建模和動畫軟體中盡可能擁有表達自由;我們則需要透過程式碼在畫面上算繪。

我們一直在解決這類問題,因為過去每次建立 3D 網站時,都會發現可用工具有限制。因此,我們打造了這項工具,稱為 3D Librarian:一項內部研究。而且,這項技術也已準備就緒,可用於實際工作。

這項工具曾經用於 Flash,可讓您將大型 Maya 場景匯入為單一壓縮檔案,以便在執行階段解壓縮。這是因為它能有效地將場景打包成與渲染和動畫處理期間相同的資料結構。載入檔案時,系統只需進行少量剖析。由於檔案採用 AMF 格式,Flash 可以原生解壓,因此在 Flash 中解壓縮的速度相當快。在 WebGL 中使用相同格式需要在 CPU 上執行更多工作。事實上,我們必須重新建立資料解壓縮的 JavaScript 層程式碼,這層程式碼會解壓縮這些檔案,並重新建立 WebGL 運作所需的資料結構。解壓縮整個 3D 場景是 CPU 負載較重的作業:在中高階機器上,解壓縮 Find Your Way To Oz 中的場景 1 需要約 2 秒的時間。因此,這項作業會在「場景設定」時間 (實際啟動場景之前) 使用 Web Workers 技術執行,以免影響使用者體驗。

這項實用工具可匯入大部分的 3D 場景,包括模型、紋理和骨架動畫。您建立單一程式庫檔案,然後由 3D 引擎載入。您可以在這個程式庫中填入場景所需的所有模型,然後將這些模型產生至場景中。

不過,我們遇到的問題是,我們現在要處理 WebGL:這個新手。這項挑戰相當艱難,因為我們必須為瀏覽器 3D 體驗設定標準。因此,我們建立了專屬的 JavaScript 層,可使用 3D Librarian 壓縮的 3D 場景檔案,並正確將這些檔案轉譯為 WebGL 可解讀的格式。

教學課程:讓風吹拂

「Find Your Way To Oz」一曲中反覆出現的元素是風。故事情節的一個主線以風聲逐漸增強的形式呈現。

嘉年華會的第一個場景相對平靜,在經歷各種場景時,使用者會感受到越來越強的風,最後在暴風雨場景中達到高潮。

因此,提供身歷其境的風聲效果十分重要。

為了製作這部影片,我們在 3 個嘉年華會場景中加入了柔軟的物件,這些物件會受到風的影響,例如帳篷、旗幟、相片亭的表面,以及氣球本身。

軟布。

如今的電腦遊戲通常會以核心物理引擎為基礎建構。因此,當需要在 3D 世界中模擬軟性物體時,系統會執行完整的物理模擬,產生可信的軟性行為。

在 WebGL / Javascript 中,我們 (目前) 無法執行完整的物理模擬。因此,在 Oz 中,我們必須想辦法在不模擬的情況下,創造出風的效果。

我們在 3D 模型中嵌入了每個物件的「風力敏感度」資訊。3D 模型的每個頂點都有「風屬性」,可指定該頂點受到風的影響程度。因此,這是 3D 物件的指定風速靈敏度。接著,我們需要建立風本身。

我們是透過產生含有 Perlin 雜訊的圖片來達成這點。這張圖片的用途是覆蓋特定的「風力區域」。因此,您可以將這張圖片視為雲朵,覆蓋 3D 場景中特定的矩形區域。這張圖片的每個像素 (灰階值) 都會指出在「周圍」3D 區域中,風在某個特定時間的強度。

為了產生風的效果,圖片會以固定速度在特定方向移動,也就是風的方向。為了確保「風力區域」不會影響場景中的所有物件,我們將風力圖片包覆在邊緣,並限制在效果範圍內。

簡易 3D 風教學

接下來,我們將在 Three.js 的簡單 3D 場景中建立風的效果。

我們將在簡單的「程序化草地」中建立風。

首先,我們來建立場景。我們將使用簡單的平坦地形,然後每個草叢都會以倒置的 3D 圓錐來表示。

草地地形
草地地形

以下是如何使用 CoffeeScriptThree.js 中建立這個簡單的場景。

首先,我們要設定 Three.js,並將其連結至攝影機、滑鼠控制器和一些燈光:

constructor: ->

   @clock =  new THREE.Clock()

   @container = document.createElement( 'div' );
   document.body.appendChild( @container );

   @renderer = new THREE.WebGLRenderer();
   @renderer.setSize( window.innerWidth, window.innerHeight );
   @renderer.setClearColorHex( 0x808080, 1 )
   @container.appendChild(@renderer.domElement);

   @camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 1, 5000 );
   @camera.position.x = 5;
   @camera.position.y = 10;
   @camera.position.z = 40;

   @controls = new THREE.OrbitControls( @camera, @renderer.domElement );
   @controls.enabled = true

   @scene = new THREE.Scene();
   @scene.add( new THREE.AmbientLight 0xFFFFFF )

   directional = new THREE.DirectionalLight 0xFFFFFF
   directional.position.set( 10,10,10)
   @scene.add( directional )

   # Demo data
   @grassTex = THREE.ImageUtils.loadTexture("textures/grass.png");
   @initGrass()
   @initTerrain()

   # Stats
   @stats = new Stats();
   @stats.domElement.style.position = 'absolute';
   @stats.domElement.style.top = '0px';
   @container.appendChild( @stats.domElement );
   window.addEventListener( 'resize', @onWindowResize, false );
   @animate()

initGrassinitTerrain 函式呼叫會分別在場景中填入草地和地形:

initGrass:->
   mat = new THREE.MeshPhongMaterial( { map: @grassTex } )
   NUM = 15
   for i in [0..NUM] by 1
       for j in [0..NUM] by 1
           x = ((i/NUM) - 0.5) * 50 + THREE.Math.randFloat(-1,1)
           y = ((j/NUM) - 0.5) * 50 + THREE.Math.randFloat(-1,1)
           @scene.add( @instanceGrass( x, 2.5, y, 5.0, mat ) )

instanceGrass:(x,y,z,height,mat)->
   geometry = new THREE.CylinderGeometry( 0.9, 0.0, height, 3, 5 )
   mesh = new THREE.Mesh( geometry, mat )
   mesh.position.set( x, y, z )
   return mesh

我們將建立 15 x 15 個格子的草地格線。我們為每個草叢位置加入一點隨機性,避免草叢排列得像士兵一樣整齊,否則看起來會很奇怪。

這塊地形只是水平平面,位於草皮底部 (y = 2.5)。

initTerrain:->
  @plane = new THREE.Mesh( new THREE.PlaneGeometry(60, 60, 2, 2), new THREE.MeshPhongMaterial({ map: @grassTex }))
  @plane.rotation.x = -Math.PI/2
  @scene.add( @plane )

到目前為止,我們只完成了建立 Three.js 場景,以及加入幾個由程序化產生的反向圓錐和簡單地形組成的草地。

目前沒有任何特別的內容。

接下來,我們要開始加入風。首先,我們要將風敏感度資訊嵌入草地 3D 模型。

我們會將這項資訊做為自訂屬性,嵌入草地 3D 模型的每個頂點。我們將使用以下規則:草地模型的底端 (圓錐的頂端) 具有零敏感度,因為它會附著地面。草地模型的頂端 (圓錐底部) 具有最高的風敏感度,因為它離地面較遠。

以下是重新編寫 instanceGrass 函式的程式碼,以便將風敏度新增為草地 3D 模型的自訂屬性。

instanceGrass:(x,y,z,height)->

  geometry = new THREE.CylinderGeometry( 0.9, 0.0, height, 3, 5 )

  for i in [0..geometry.vertices.length-1] by 1
      v = geometry.vertices[i]
      r = (v.y / height) + 0.5
      @windMaterial.attributes.windFactor.value[i] = r * r * r

  # Create mesh
  mesh = new THREE.Mesh( geometry, @windMaterial )
  mesh.position.set( x, y, z )
  return mesh

我們現在使用自訂材質 windMaterial,而非先前使用的 MeshPhongMaterialWindMaterial 會包裝 WindMeshShader,我們稍後會看到這個元素。

因此,instanceGrass 中的程式碼會循環處理草地模型的所有頂點,並為每個頂點新增名為 windFactor 的自訂頂點屬性。此風因係數設為 0,表示草地模型的底端 (應與地形接觸),而值為 1 表示草地模型的頂端。

我們還需要在場景中加入實際的風。如先前所述,我們會使用 Perlin 雜訊。我們將以程序產生 Perlin 雜訊紋理。

為了方便說明,我們會將這個紋理指派給地形本身,取代先前的綠色紋理。這樣一來,您就能更輕鬆地掌握風的動態。

因此,這個 Perlin 雜訊紋理會在空間上涵蓋地形的延伸範圍,而紋理的每個像素都會指定該像素落在哪個地形區域的風強度。地形長方形即為「風力區」。

Perlin 雜訊會透過稱為 NoiseShader 的著色器以程序化方式產生。此著色器使用 3D 簡單噪音演算法,詳情請參閱:https://github.com/ashima/webgl-noise。這個 WebGL 版本是從 MrDoob 的 Three.js 範例中複製而來,網址為:http://mrdoob.github.com/three.js/examples/webgl_terrain_dynamic.html

NoiseShader 會將時間、比例和偏移參數組合做為均勻變數,並輸出精美的 Perlin 雜訊 2D 分布。

class NoiseShader

  uniforms:     
    "fTime"  : { type: "f", value: 1 }
    "vScale"  : { type: "v2", value: new THREE.Vector2(1,1) }
    "vOffset"  : { type: "v2", value: new THREE.Vector2(1,1) }

...

我們將使用這個著色器,將 Perlin 雜訊算繪至紋理。這可透過 initNoiseShader 函式完成。

initNoiseShader:->
  @noiseMap  = new THREE.WebGLRenderTarget( 256, 256, { minFilter: THREE.LinearMipmapLinearFilter, magFilter: THREE.LinearFilter, format: THREE.RGBFormat } );
  @noiseShader = new NoiseShader()
  @noiseShader.uniforms.vScale.value.set(0.3,0.3)
  @noiseScene = new THREE.Scene()
  @noiseCameraOrtho = new THREE.OrthographicCamera( window.innerWidth / - 2, window.innerWidth / 2,  window.innerHeight / 2, window.innerHeight / - 2, -10000, 10000 );
  @noiseCameraOrtho.position.z = 100
  @noiseScene.add( @noiseCameraOrtho )

  @noiseMaterial = new THREE.ShaderMaterial
      fragmentShader: @noiseShader.fragmentShader
      vertexShader: @noiseShader.vertexShader
      uniforms: @noiseShader.uniforms
      lights:false

  @noiseQuadTarget = new THREE.Mesh( new THREE.PlaneGeometry(window.innerWidth,window.innerHeight,100,100), @noiseMaterial )
  @noiseQuadTarget.position.z = -500
  @noiseScene.add( @noiseQuadTarget )

上述程式碼的作用是將 noiseMap 設為 Three.js 算繪目標,並為其配備 NoiseShader,然後使用正交攝影機算繪,以免產生透視扭曲。

如前文所述,我們現在也會將這個紋理用於地形的主要算繪紋理。這並非風效應運作所需的必要條件。但這項功能很實用,可以讓我們更清楚瞭解風力發電的情況。

以下是重新設計的 initTerrain 函式,使用 noiseMap 做為紋理:

initTerrain:->
  @plane = new THREE.Mesh( new THREE.PlaneGeometry(60, 60, 2, 2), new THREE.MeshPhongMaterial( { map: @noiseMap, lights: false } ) )
  @plane.rotation.x = -Math.PI/2
  @scene.add( @plane )

既然我們已完成風紋理,接下來就來看看 WindMeshShader,這個函式會根據風力變形草地模型。

為了建立這個著色器,我們從標準的 Three.js MeshPhongMaterial 著色器開始,並加以修改。這是快速且粗略的方法,可讓您開始使用可運作的著色器,而無須從頭開始。

我們不會在此處複製完整的著色器程式碼 (歡迎您在原始程式碼檔案中查看),因為大部分的內容都是 MeshPhongMaterial 著色器的複本。不過,我們來看看頂點著色器中經過修改的風相關部分。

vec4 wpos = modelMatrix * vec4( position, 1.0 );
vec4 wpos = modelMatrix * vec4( position, 1.0 );

wpos.z = -wpos.z;
vec2 totPos = wpos.xz - windMin;
vec2 windUV = totPos / windSize;
vWindForce = texture2D(tWindForce,windUV).x;

float windMod = ((1.0 - vWindForce)* windFactor ) * windScale;
vec4 pos = vec4(position , 1.0);
pos.x += windMod * windDirection.x;
pos.y += windMod * windDirection.y;
pos.z += windMod * windDirection.z;

mvPosition = modelViewMatrix *  pos;

因此,這個著色器會先根據頂點的 2D、xz (水平) 位置,計算windUV 紋理查詢座標。這個 UV 座標會用於從 Perlin 雜訊風紋材質中查詢風力強度 vWindForce

這個 vWindForce 值會與頂點專屬的 windFactor 自訂屬性組合,以便計算頂點需要的變形量。我們也提供了全域 windScale 參數,用於控制風的整體強度,以及 windDirection 向量,用於指定風的變形方向。

因此,這會讓草叢產生風的變形效果。不過,我們仍未完成。目前這個變形效果是靜態的,無法呈現風大的區域效果。

如先前所述,我們需要在時間軸上滑動雜訊紋理,並橫跨風力區域,讓玻璃產生波浪效果。

方法是隨著時間推移,將 vOffset 統一值傳遞至 NoiseShader。這是一個 vec2 參數,可讓我們沿著特定方向 (風向) 指定雜訊偏移。

我們會在每個影格中呼叫的 render 函式中執行這項操作:

render: =>
  delta = @clock.getDelta()

  if @windDirection
      @noiseShader.uniforms[ "fTime" ].value += delta * @noiseSpeed
      @noiseShader.uniforms[ "vOffset" ].value.x -= (delta * @noiseOffsetSpeed) * @windDirection.x
      @noiseShader.uniforms[ "vOffset" ].value.y += (delta * @noiseOffsetSpeed) * @windDirection.z
...

這樣就完成了!我們剛剛建立了一個場景,其中的「程序化草地」會受到風的影響。

加入灰塵

接下來,我們來為場景增添一點趣味。讓我們加入一些飛舞的灰塵,讓場景更有趣。

新增灰塵
加入灰塵

畢竟灰塵會受到風的影響,因此在風景中加入灰塵飛舞的效果,是相當合理的做法。

initDust 函式中,塵埃會設為粒子系統。

initDust:->
  for i in [0...5] by 1
      shader = new WindParticleShader()
      params = {}
      params.fragmentShader = shader.fragmentShader
      params.vertexShader   = shader.vertexShader
      params.uniforms       = shader.uniforms
      params.attributes     = { speed: { type: 'f', value: [] } }

      mat  = new THREE.ShaderMaterial(params)
      mat.map = shader.uniforms["map"].value = THREE.ImageUtils.loadCompressedTexture("textures/dust#{i}.dds")
      mat.size = shader.uniforms["size"].value = Math.random()
      mat.scale = shader.uniforms["scale"].value = 300.0
      mat.transparent = true
      mat.sizeAttenuation = true
      mat.blending = THREE.AdditiveBlending
      shader.uniforms["tWindForce"].value      = @noiseMap
      shader.uniforms[ "windMin" ].value       = new THREE.Vector2(-30,-30 )
      shader.uniforms[ "windSize" ].value      = new THREE.Vector2( 60, 60 )
      shader.uniforms[ "windDirection" ].value = @windDirection            

      geom = new THREE.Geometry()
      geom.vertices = []
      num = 130
      for k in [0...num] by 1

          setting = {}

          vert = new THREE.Vector3
          vert.x = setting.startX = THREE.Math.randFloat(@dustSystemMinX,@dustSystemMaxX)
          vert.y = setting.startY = THREE.Math.randFloat(@dustSystemMinY,@dustSystemMaxY)
          vert.z = setting.startZ = THREE.Math.randFloat(@dustSystemMinZ,@dustSystemMaxZ)

          setting.speed =  params.attributes.speed.value[k] = 1 + Math.random() * 10
          
          setting.sinX = Math.random()
          setting.sinXR = if Math.random() < 0.5 then 1 else -1
          setting.sinY = Math.random()
          setting.sinYR = if Math.random() < 0.5 then 1 else -1
          setting.sinZ = Math.random()
          setting.sinZR = if Math.random() < 0.5 then 1 else -1

          setting.rangeX = Math.random() * 5
          setting.rangeY = Math.random() * 5
          setting.rangeZ = Math.random() * 5

          setting.vert = vert
          geom.vertices.push vert
          @dustSettings.push setting

      particlesystem = new THREE.ParticleSystem( geom , mat )
      @dustSystems.push particlesystem
      @scene.add particlesystem

這裡會產生 130 個灰塵粒子。請注意,每個物件都會配備特殊的 WindParticleShader

接下來,我們將使用 CoffeeScript 在每個影格中移動粒子,不受風的影響。以下是程式碼。

moveDust:(delta)->

  for setting in @dustSettings

    vert = setting.vert
    setting.sinX = setting.sinX + (( 0.002 * setting.speed) * setting.sinXR)
    setting.sinY = setting.sinY + (( 0.002 * setting.speed) * setting.sinYR)
    setting.sinZ = setting.sinZ + (( 0.002 * setting.speed) * setting.sinZR) 

    vert.x = setting.startX + ( Math.sin(setting.sinX) * setting.rangeX )
    vert.y = setting.startY + ( Math.sin(setting.sinY) * setting.rangeY )
    vert.z = setting.startZ + ( Math.sin(setting.sinZ) * setting.rangeZ )

除此之外,我們會根據風向偏移每個粒子的位置。這項操作是在 WindParticleShader 中完成。尤其是在頂點著色器中。

這個著色器的程式碼是經過修改的 Three.js ParticleMaterial,其核心如下所示:

vec4 mvPosition;
vec4 wpos = modelMatrix * vec4( position, 1.0 );
wpos.z = -wpos.z;
vec2 totPos = wpos.xz - windMin;
vec2 windUV = totPos / windSize;
float vWindForce = texture2D(tWindForce,windUV).x;
float windMod = (1.0 - vWindForce) * windScale;
vec4 pos = vec4(position , 1.0);
pos.x += windMod * windDirection.x;
pos.y += windMod * windDirection.y;
pos.z += windMod * windDirection.z;

mvPosition = modelViewMatrix *  pos;

fSpeed = speed;
float fSize = size * (1.0 + sin(time * speed));

#ifdef USE_SIZEATTENUATION
    gl_PointSize = fSize * ( scale / length( mvPosition.xyz ) );
#else,
    gl_PointSize = fSize;
#endif

gl_Position = projectionMatrix * mvPosition;

這個頂點著色器與我們用於草地風力變形的著色器並無太大差異。它會將 Perlin 雜訊紋理做為輸入內容,並根據塵埃世界位置查詢雜訊紋理中的 vWindForce 值。然後使用這個值修改灰塵粒的位置。

Riders On The Storm

在 WebGL 場景中,最具冒險精神的場景可能是最後一個場景,只要點選氣球,就能進入龍捲風的風眼,並在網站中完成旅程,以及觀看即將推出的獨家影片。

熱氣球飛行場景

在製作這個場景時,我們知道需要為體驗提供一個重點功能,才能產生影響力。旋轉的龍捲風會成為主題,其他內容層會將這項功能塑造成位,以營造出強烈的效果。為此,我們建構了一個奇特著色器的電影製片廠場景。

我們採用混合方法來製作寫實的合成圖。其中有些是視覺效果技巧,例如使用光線形狀營造鏡頭閃光效果,或是在你觀看的場景上方以圖層呈現動畫雨滴。在其他情況下,我們會繪製平面,讓畫面看起來會移動,例如根據粒子系統程式碼移動的低飛雲層。而環繞龍捲風的碎片是 3D 場景中的圖層,並且已排序,以便在龍捲風前後移動。

我們之所以要以這種方式建構場景,主要是為了確保有足夠的 GPU 來處理龍捲風著色器,並與我們套用的其他效果取得平衡。起初我們遇到 GPU 平衡問題,但後來這個場景經過最佳化處理,比主要場景更輕。

教學課程:Storm 著色器

為了製作最終的暴風場景,我們結合了許多不同的技術,但這項作品的重點是看起來像龍捲風的自訂 GLSL 著色器。我們嘗試過許多不同的技巧,從頂點著色器到有趣的幾何迴旋渦,以及以粒子為基礎的動畫,甚至是扭曲幾何圖形的 3D 動畫。沒有任何特效能重現龍捲風的感受,也沒有任何特效需要過多處理。

最後,我們從另一個完全不同的專案中找到答案。馬克斯·普朗克研究所 (brainflight.org) 的平行專案,是透過科學遊戲繪製老鼠的大腦地圖,並產生有趣的視覺效果。我們已成功使用自訂體積著色器,製作出老鼠神經元內部的影片。

使用自訂體積著色器的鼠腦神經元內部
使用自訂體積著色器的鼠腦神經元內部

我們發現腦細胞的內部有點像龍捲風的漏斗,由於我們使用的是體積技術,因此可以從空間中的所有方向查看此著色器。我們可以設定著色器的算繪,以便與暴風場景結合,特別是在夾在雲層下方,以及位於壯麗背景上方時。

著色器技巧包含一個技巧,基本上會使用單一 GLSL 著色器,搭配簡化的算繪演算法 (稱為「使用距離場算繪的光線行進算繪」) 算繪整個物件。在這個技巧中,系統會建立像素著色器,用來估算螢幕上每個點與表面最近的距離。

您可以參考 iq 的總覽,瞭解這個演算法:以兩個三角形算繪世界 - Iñigo Quilez。您也可以探索 glsl.heroku.com 上的著色器庫,這裡有許多這項技巧的範例,您可以嘗試使用。

著色器的核心會從主函式開始,這個函式會設定相機轉換,並進入迴圈,重複評估與表面的距離。呼叫 RaytraceFoggy( direction_vector, max_iterations, color, color_multiplier ) 時,就會執行核心的光線追蹤計算。

for(int i=0;i < number_of_steps;i++) // run the ray marching loop
{
  old_d=d;
  float shape_value=Shape(q); // find out the approximate distance to or density of the tornado cone
  float density=-shape_value;
  d=max(shape_value*step_scaling,0.0);// The max function clamps values smaller than 0 to 0

  float step_dist=d+extra_step; // The point is advanced by larger steps outside the tornado,
  //  allowing us to skip empty space quicker.

  if (density>0.0) {  // When density is positive, we are inside the cloud
    float brightness=exp(-0.6*density);  // Brightness decays exponentially inside the cloud

    // This function combines density layers to create a translucent fog
    FogStep(step_dist*0.2,clamp(density, 0.0,1.0)*vec3(1,1,1), vec3(1)*brightness, colour, multiplier); 
  }
  if(dist>max_dist || multiplier.x < 0.01) { return;  } // if we've gone too far stop, we are done
  dist+=step_dist; // add a new step in distance
  q=org+dist*dir; // trace its direction according to the ray casted
}

這項概念的概念是,當我們進入龍捲風的形狀時,會定期將顏色貢獻加入像素的最終顏色值,以及沿著光線貢獻不透明度。這會為龍捲風的紋理建立分層的柔和質感。

龍捲風的下一個核心層面是實際形狀本身,它是透過組合多個函式而建立。首先是圓錐,它會與雜訊組合,產生自然粗糙的邊緣,然後沿著主軸扭曲並在時間內旋轉。

mat2 Spin(float angle){
  return mat2(cos(angle),-sin(angle),sin(angle),cos(angle)); // a rotation matrix
}

// This takes noise function and makes ridges at the points where that function crosses zero
float ridged(float f){ 
  return 1.0-2.0*abs(f);
}

// the isosurface shape function, the surface is at o(q)=0 
float Shape(vec3 q) 
{
    float t=time;

    if(q.z < 0.0) return length(q);

    vec3 spin_pos=vec3(Spin(t-sqrt(q.z))*q.xy,q.z-t*5.0); // spin the coordinates in time

    float zcurve=pow(q.z,1.5)*0.03; // a density function dependent on z-depth

    // the basic cloud of a cone is perturbed with a distortion that is dependent on its spin 
    float v=length(q.xy)-1.5-zcurve-clamp(zcurve*0.2,0.1,1.0)*snoise(spin_pos*vec3(0.1,0.1,0.1))*5.0; 

    // create ridges on the tornado
    v=v-ridged(snoise(vec3(Spin(t*1.5+0.1*q.z)*q.xy,q.z-t*4.0)*0.3))*1.2; 

    return v;
}

建立這類著色器的工作相當棘手。除了與您建立的運算抽象相關的問題外,還有嚴重的最佳化和跨平台相容性問題,您必須先追蹤並解決這些問題,才能在實際運作環境中使用這項工作。

問題的第一部分:為場景最佳化此著色器。為解決這個問題,我們需要採用「安全」的做法,以防著色器過於龐大。為此,我們以與場景其他部分不同的取樣解析度合成龍捲風著色器。這是 stormTest.coffee 檔案的內容 (沒錯,這是測試內容)。

我們會從與場景寬度和高度相符的算繪目標開始,這樣就能讓龍捲風著色器的解析度與場景保持獨立。接著,我們會根據取得的幀率,動態決定暴風著色器的降採樣解析度。

...
Line 1383
@tornadoRT = new THREE.WebGLRenderTarget( @SCENE_WIDTH, @SCENE_HEIGHT, paramsN )

... 
Line 1403 
# Change settings based on FPS
if @fpsCount > 0
    if @fpsCur < 20
        @tornadoSamples = Math.min( @tornadoSamples + 1, @MAX_SAMPLES )
    if @fpsCur > 25
        @tornadoSamples = Math.max( @tornadoSamples - 1, @MIN_SAMPLES )
    @tornadoW = @SCENE_WIDTH  / @tornadoSamples // decide tornado resWt
    @tornadoH = @SCENE_HEIGHT / @tornadoSamples // decide tornado resHt

最後,我們使用簡化的 sal2x 演算法 (以免產生塊狀外觀),將龍捲風算繪至螢幕上,位於 stormTest.coffee 中的第 1107 行。也就是說,在最糟的情況下,我們會得到更模糊的龍捲風,但至少不會讓使用者失去控制權。

下一個最佳化步驟需要深入瞭解演算法。著色器中的驅動運算因素,是指在每個像素上執行的迭代作業,以便嘗試近似表面函式的距離:即光線追蹤迴圈的迭代次數。使用較大的步長,我們可以在雲層外取得較少迭代的龍捲風表面估計值。在內部,我們會降低步進大小以提高精確度,並混合值以產生朦朧效果。此外,建立邊界圓柱以取得投擲光線的深度預估值,也能大幅提升速度。

接下來要解決的問題,是確保此著色器能在不同顯示卡上執行。我們每次都會進行一些測試,並開始針對可能遇到的相容性問題類型建立直覺。我們無法比直覺更有效地解決問題,是因為我們無法隨時取得錯誤的詳細偵錯資訊。典型情況是 GPU 發生錯誤,但系統仍可繼續運作,甚至發生當機!

跨影片板的相容性問題也有類似的解決方法:請務必輸入靜態常數的確切資料類型,例如 float 為 0.0,int 為 0。編寫較長的函式時請小心,最好將內容分成多個較簡單的函式和暫時變數,因為編譯器似乎無法正確處理某些情況。請確認紋理都是 2 的冪次方,且不會過大,並在任何情況下都以「謹慎」的態度在迴圈中查詢紋理資料。

相容性方面,我們遇到的最大問題是暴風天氣的燈光效果。我們使用預先製作的紋理包覆龍捲風,以便為其絲狀雲彩上色。這項效果非常精美,而且可以輕鬆將龍捲風融入場景色彩,但在其他平台上執行時,需要花費很長的時間。

龍捲風

行動版網站

行動版體驗無法直接轉換成電腦版,因為技術和處理要求太高。我們必須建立專門鎖定行動使用者的全新內容。

我們認為,如果能將電腦版嘉年華相片亭做為行動網頁應用程式,並使用使用者的行動裝置相機,那就太棒了。這是我們到目前為止未曾見過的做法。

為了增添風味,我們在 CSS3 中編寫 3D 轉換程式碼。將其與陀螺儀和加速計連結後,我們就能為使用者提供更深刻的體驗。網站會根據你握住、移動和觀看手機的方式做出回應。

在撰寫本文時,我們認為有必要提供一些提示,說明如何順利執行行動裝置開發程序。這就是!歡迎查看這項功能,瞭解您可以從中學到什麼!

行動裝置提示與秘訣

預先載入器是必要的,並非應避免的。我們知道有時會發生後者。這主要是因為您需要隨著專案規模擴大,持續維護預先載入的項目清單。更糟的是,如果您同時提取多個不同資源,則很難明確計算載入進度。這時,我們自訂的非常通用的抽象類別「Task」就派上用場。其主要概念是允許無限巢狀結構,讓工作可擁有自己的子工作,而子工作可擁有自己的子工作等等。此外,每項工作會根據子工作的進度計算自己的進度 (但不會根據父項的進度)。讓所有 MainPreloadTask、AssetPreloadTask 和 TemplatePreFetchTask 都從 Task 衍生,我們建立了如下結構:

友善載入器

有了這種方法和 Task 類別,我們就能輕鬆瞭解全域進度 (MainPreloadTask)、資產進度 (AssetPreloadTask) 或載入範本的進度 (TemplatePreFetchTask)。甚至是特定檔案的進度。如要瞭解執行方式,請參閱 /m/javascripts/raw/util/Task.js 中的 Task 類別,以及 /m/javascripts/preloading/task 中的實際工作執行項目。以下是我們設定 /m/javascripts/preloading/task/MainPreloadTask.js 類別 (也就是最終的預先載入包裝函式) 的摘錄:

Package('preloading.task', [
  Import('util.Task'),
...

  Class('public MainPreloadTask extends Task', {

    _public: {
      
  MainPreloadTask : function() {
        
    var subtasks = [
      new AssetPreloadTask([
        {name: 'cutout/cutout-overlay-1', ext: 'png', type: ImagePreloader.TYPE_BACKGROUND, responsive: true},
        {name: 'journey/scene1', ext: 'jpg', type: ImagePreloader.TYPE_IMG, responsive: false}, ...
...
      ]),

      new TemplatePreFetchTask([
        'page.HomePage',
        'page.CutoutPage',
        'page.JourneyToOzPage1', ...
...
      ])
    ];
    
    this._super(subtasks);

      }
    }
  })
]);

在 /m/javascripts/preloading/task/subtask/AssetPreloadTask.js 類別中,除了指出它如何透過共用 Task 實作與 MainPreloadTask 通訊,也值得一提的是,我們如何載入平台相關資產。基本上,我們有四種圖片類型。行動裝置標準 (.ext,其中 ext 是檔案副檔名,通常為 .png 或 .jpg)、行動裝置 Retina 螢幕 (-2x.ext)、平板電腦標準 (-tab.ext) 和平板電腦 Retina 螢幕 (-tab-2x.ext)。我們並未在 MainPreloadTask 中進行偵測,也不會將四個素材資源陣列硬式編碼,而是直接指出要預先載入的素材資源名稱和副檔名,以及素材資源是否依平台而異 (responsive = true / false)。接著,AssetPreloadTask 會為我們產生檔案名稱:

resolveAssetUrl : function(assetName, extension, responsive) {
  return AssetPreloadTask.ASSETS_ROOT + assetName + (responsive === true ? ((Detection.getInstance().tablet ? '-tab' : '') + (Detection.getInstance().retina ? '-2x' : '')) : '') + '.' +  extension;
}

在類別鏈結的後面,實際執行資產預先載入的程式碼如下所示 (/m/javascripts/raw/util/ImagePreloader.js):

loadUrl : function(url, type, completeHandler) {
  if(type === ImagePreloader.TYPE_BACKGROUND) {
    var $bg = $('<div>').hide().css('background-image', 'url(' + url + ')');
    this.$preloadContainer.append($bg);
  } else {
    var $img= $('<img />').attr('src', url).hide();
    this.$preloadContainer.append($img);
  }

  var image = new Image();
  this.cache[this.generateKey(url)] = image;
  image.onload = completeHandler;
  image.src = url;
}

generateKey : function(url) {
  return encodeURIComponent(url);
}

教學課程:HTML5 相框 (iOS 6/Android)

在開發 OZ mobile 時,我們發現自己花費大量時間使用相片亭,而不是工作 :D 這只是因為相片亭實在太有趣了。因此,我們製作了示範檔案供您試用。

行動快照亭
行動自助沖印機

您可以在此處查看即時示範 (在 iPhone 或 Android 手機上執行):

http://u9html5rocks.appspot.com/demos/mobile_photo_booth

如要設定這項服務,您需要一個可執行後端的免費 Google App Engine 應用程式執行個體。前端程式碼並不複雜,但可能會遇到幾個陷阱。讓我們現在就來瞭解這些原則:

  1. 允許的圖片檔案類型 我們希望使用者只能上傳圖片 (因為這是相片亭,而非影片亭)。理論上,您只需在 HTML 中指定篩選器,如下所示: input id="fileInput" class="fileInput" type="file" name="file" accept="image/*" 不過,這似乎只適用於 iOS,因此我們需要在選取檔案後,再額外檢查 RegExp:
   this.$fileInput.fileupload({
          
   dataType: 'json',
   autoUpload : true,
   
   add : function(e, data) {
     if(!data.files[0].name.match(/(\.|\/)(gif|jpe?g|png)$/i)) {
      return self.onFileTypeNotSupported();
     }
   }
   });
  1. 取消上傳或檔案選取作業 在開發過程中,我們發現另一個不一致之處,是不同裝置通知取消檔案選取作業的方式。iOS 手機和平板電腦完全沒有通知,因此,我們不需要為這種情況採取任何特殊動作,但 Android 手機會在任何情況下觸發 add() 函式,即使未選取任何檔案也一樣。以下是解決方法:
    add : function(e, data) {

    if(data.files.length === 0 || (data.files[0].size === 0 && data.files[0].name === "" && data.files[0].fileName === "")) {
            
    return self.onNoFileSelected();

    } else if(data.files.length > 1) {

    return self.onMultipleFilesSelected();            
    }
    }

其餘功能在各平台上運作順暢。祝你玩得愉快!;-)

結論

由於「尋找前往 Oz 的路」規模龐大,且涉及多種不同的技術,因此我們在這篇文章中只能介紹其中幾種我們採用的方法。

如果您想深入瞭解整個 enchilada,歡迎前往這個連結,查看 Find Your Way To Oz 的完整原始碼。

抵免額

如要查看完整的演員表,請按這裡

參考資料