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

unit9 com
unit9 com

簡介

「尋找奧茲之旅」是 Disney 推出的新 Google Chrome 實驗功能。您可以在堪薩斯馬戲團體驗互動之旅,從沉浸於奧茲的旅程中。

我們的目標是將電影的豐富性與瀏覽器的技術功能相結合,打造出充滿樂趣的沉浸式體驗,讓使用者能緊密連結。

這份工作有點大,無法完整納入這一節,因此我們已深入研究相關的技術故事。在擷取一些增加難度的教學課程的過程中,

很多人都努力實現這個目標,因為其中沒有太多人。如要查看完整新聞內容,請造訪網站,查看選單專區下方的「製作人員名單」頁面。

揭開幕後秘辛

在電腦上尋找奧茲之旅,體驗身歷其境的感受。我們利用 3D 和多層傳統電影手法的特效,相輔相成,創造出近乎逼真的場景。最知名的技術是採用 Three.js 的 WebGL、使用 CSS3 功能的自訂建構著色器和 DOM 動畫元素。除此之外,我們也提供互動式體驗的 getUserMedia API (WebRTC),讓使用者直接透過網路攝影機和 WebAudio 來新增 3D 音效的圖片。

不過,這樣的技術體驗的神奇之處就在於結合在一起。這也是我們面臨的一大挑戰,那就是如何在單一場景中融合視覺效果和互動式元素,打造一致的整體形象?視覺上的複雜度很難管理,讓人難以判斷我們任何時候在開發中的階段。

為瞭解決相互連結視覺效果和最佳化的問題,我們大量使用控制台,能擷取當時我們正在檢視的所有相關設定。可以在瀏覽器中即時調整場景,例如亮度、景深、伽瑪等

在我們分享秘密前,我們想警告你,它可能會因此當機,就像你在汽車引擎中四處走動一樣。請確認您沒有設定任何重要項目,然後造訪網站的主網址,並在網址後方加上 ?debug=on 兩個字。等待網站載入。當你位於 (按下) 鍵時 Ctrl-I,右側會顯示下拉式選單。如果取消勾選「離開相機路徑」選項,可以使用 A、W、S、D 和滑鼠在空間中任意移動。

相機路徑。

我們不會一一說明所有設定,但建議您多方嘗試:這些鍵在不同場景中呈現不同的設定。最後一個風暴序列有一個額外鍵:Ctrl-A,可用來切換動畫播放及飛行。在這個場景中,如果您按下 Esc (退出滑鼠鎖定功能) 並再次按下 Ctrl-I,即可存取風暴場景專用的設定。看看四周,吸引幾張美麗的明信片效果如下方所示。

暴雨場景

為了實現這個目標,並確保空間彈性足以滿足我們的需求,我們使用了一款叫做 dat.gui 的程式庫。您可以在這裡查看過去的教學課程,瞭解如何使用這個程式庫。讓我們可以快速變更網站訪客所看到的設定。

喜歡霧面畫

在許多經典的迪士尼電影和動畫中,創造出結合不同層次的場景。還包括活體動作、細胞動畫,甚至是實體場景,以及由玻璃畫所打造的層層,稱為「霧面畫」。

我們打造的這項體驗架構在許多方面都很類似,但儘管某些「層」的不只是靜態影像而已。實際上,這些屬性會影響依據更複雜運算而呈現的樣貌。儘管如此,至少我們處理的是檢視畫面,至少合併一個畫面。您會看見一個 UI 層,下方有一個 3D 場景:本身是由不同的場景元件組成。

頂端介面層是使用 DOM 和 CSS 3 建立而成,因此編輯互動的方式很多,可以單獨根據特定事件清單,透過 3D 體驗與兩者之間的通訊。這項通訊會使用 Backbone Router + onHashChange HTML5 事件控制要在哪些區域建立動畫,(專案來源:/develop/coffee/router/Router.coffee)。

教學課程:支援 Sprite 工作表和 Retina 支援功能

我們為介面執行的一項有趣最佳化技術,是將多個介面重疊圖片結合單一 PNG 以減少伺服器要求。在這個專案中,介面會預先載入超過 70 張圖片 (而非計算 3D 紋理),縮短網站的延遲時間。您可以在此查看即時 Sprite 工作表:

正常顯示 - http://findyourwaytooz.com/img/home/interface_1x.png Retina 螢幕 - http://findyourwaytooz.com/img/home/interface_2x.png

以下分享幾個訣竅,說明我們如何運用 Sprite 試算表、如何將其用於視網膜裝置,並盡可能讓介面變得更加清晰。

建立 Sprite 工作表

為建立 SpriteSheets,我們使用 TexturePacker,它會以任何需要的格式輸出。在本例中,我們匯出的 EaselJS 檔案非常簡潔,可用於製作 Sprite 動畫。

使用產生的 Sprite 工作表

建立 Sprite 工作表後,您應該會看到如下的 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]
   },
}

在此情況下:

  • 圖片是指 Sprite 工作表的網址
  • 則是指每個 UI 元素的座標 [x、y、width、height]
  • 動畫是各項素材資源的名稱

請注意,我們已使用高密度圖片建立 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 圖書館」:一項內部研究。而且即將應用於真正的工作。

這項工具保留了一些歷史,也就是最初是針對 Flash 設計,可讓你將大型馬雅場景合併成一個專為執行階段解除封裝的壓縮檔案。這麼做的最佳原因是,能夠有效封裝場景,基本上與轉譯和動畫期間操作的資料結構相同。檔案載入時,幾乎不需要進行剖析。解壓縮到 Flash 的速度很快,因為檔案採用 AMF 格式,Flash 就能以原生方式解壓縮。如要在 WebGL 中使用相同格式,則需要對 CPU 稍做一些工作。事實上,我們必須重新建立用於資料解壓縮的 JavaScript 程式碼層,基本上會壓縮這些檔案,並重新建立 WebGL 運作所需的資料結構。拆分整個 3D 場景是一種輕度的 CPU 作業:在「Find Your Way To Oz」中,從中到高端機器上拆解場景 1 時,需要約 2 秒的時間。因此,這項作業會使用網路工作人員技術,在「場景設定」的時間 (在場景實際啟動前) 完成,以免帶給使用者不便。

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

但過去我們遇到一個問題,就是現在要處理 WebGL:也就是之後的新孩子。這真是太可惜了,當時瀏覽器就是為瀏覽器式的 3D 體驗制定標準。因此,我們建立了臨時的 JavaScript 圖層,用來擷取 3D Librarian 壓縮的 3D 場景檔案,並正確將其轉譯為 WebGL 能理解的格式。

教學課程:〈Let’s Be Wind〉

《Find Your Way To Oz》一貫的主題是風水,故事線的架構以風力為強

嘉年華的第一個場景相對平靜。使用者在瀏覽各個場景時,都會感受到逐漸加強的風力,在最終場景中看到風暴。

因此,提供沉浸式風力效果至關重要。

為了建立這個模型,我們在 3 個嘉年華場景中填入柔軟的物品,因此可能會受風影響,例如帳篷、標記相片攤位的表面和氣球本身。

柔軟布。

推出電腦遊戲時,通常是以核心物理引擎為基礎。因此,當需要在 3D 世界中模擬軟質物件時,系統會執行完整的物理模擬,產生可靠的軟行為。

在 WebGL / JavaScript 中,我們還沒有令人高興的意願,可以執行一整手的物理模擬。所以在奧茲境內,我們必須想辦法在不實際模擬風力的情況下,建立風力效果。

我們也將每個物件的「風感敏感度」資訊嵌入 3D 模型中。3D 模型的每個頂點都有「Wind 屬性」,其中指定某個頂點會受到風力影響。因此,指定的 3D 物件風靈敏度。接著,我們需要建立風力本身。

具體做法是產生包含Perlin Noise 的圖片。這張圖片旨在涵蓋某特定「風力」區塊。因此,規劃雲端的最好方法,就是想像在 3D 場景中,雜訊等到特定矩形區域上的雜訊。這張圖片的每個像素 (灰色) 值代表 3D 區域中特定時間點的風力有多高。

為了產生風化效果,圖片是在時間、以固定速度、特定方向移動,也就是風向的移動方向。此外,為確保「風情區域」與場景周圍的一切都沒有影響,我們會將邊緣圖片圍繞在邊緣周圍,並限制在效果區域。

簡易 3D 風管教學課程

現在讓我們在 Three.js 中透過簡易 3D 場景建立風扇效果。

我們要在簡單的「程序草原」內製造風力。

首先來建立場景我們要建立簡單、紋理清楚的平坦地形。接著,每大草塊會以顛倒的 3D 圓環表示。

充滿草地的地形
灰階地形

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

首先,我們要設定 Three.js,並使用「相機」、「滑鼠控制器」和「部分 Light」來進行連結,例如:

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 會納入稍後將在 1 分鐘內看到的 WindMeshShader

因此,instanceGrass 中的程式碼會循環播放草地模型的所有頂點,而每個頂點都會新增名為 windFactor 的自訂頂點屬性。這個風法會設定為 0,以草地底座的底部 (應碰觸地形) 為 0,而草坪模型終點則為 1。

我們需要的另一個食材,是在場景中加上實際風。如先前所述,我們會利用 Perlin 雜訊來解決這個問題。我們會逐步產生 Perlin 雜訊紋理。

為了方便起見,我們將這個紋理指派給地形本身,取代先前的綠色紋理。這樣就能更輕鬆地感受到風動。

因此,這個 Perlin 雜訊紋理會在空間內覆蓋我們地形的延伸,而每個紋理像素都會指定該像素掉落的地形區域風強度。地形矩形將是「風力區域」。

Perlin 雜訊是透過名為 NoiseShader 的著色器依序產生。這個著色器使用來自 https://github.com/ashima/webgl-noise 的 3D 簡單雜訊演算法。這的 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 函式,使用雜訊對應做為紋理:

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 網格 PhongMaterial 著色器開始,並修改了該著色器。這是一種快速且骯髒的著色器入門方法,無需從頭開始。

我們不會在這裡複製整個著色器程式碼 (您可以在原始碼檔案中查看),因為大部分的著色器是網格 PhongMaterial 著色器的複製品。現在我們來看看 Vertex 著色器中經過修整、風力相關的零件。

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: =>
  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 顆星球創造出 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 )

此外,我們還會根據風力調整每個粒子位置。這是在 WinParticleShader 中完成。具體來說,在頂點著色器中。

這個著色器的程式碼是 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 值。然後使用這個值修改灰塵粒子的位置。

暴風雨的乘客

我們最先在 WebGL 場景中出現最先進的場景就是最後一個場景,你可以點選氣球穿越龍捲風的目光,直到抵達現場的故事結束,以及即將推出的版本獨家影片。

氣球遊樂設施

建立這個場景時,我們瞭解我們必須提供可有效提升體驗的核心功能。不斷旋轉的龍捲風就像是其他內容的核心,可以突顯這個特色,營造戲劇效果。為了達成這個目標,我們打造了功能相當於圍繞這個奇異著色器的電影工作室。

我們利用混合方法建立寫實的複合材料。像是利用光形狀營造鏡頭耀光效果等視覺美術,或是以圖層形式呈現在你觀看場景上方的雨滴。在其他情況下,我們曾繪製出平坦的表面來移動,例如根據粒子系統程式碼移動的低飛雲層。龍捲風周圍繞著龍捲風的軌道是一道多層 3D 場景,移到了龍捲風的前後移動。

採用這種方式建立場景的主因是確保具備充足的 GPU 來處理龍捲風著色器,同時兼顧了我們套用的其他效果。起初,我們遇到了嚴重的 GPU 平衡問題,但之後這個場景已經過最佳化,而且比主要場景更少。

教學課程:暴風雪雨

為了建立最終風暴序列,我們結合了多種不同技術,但本工作的核心部分是自訂 GLSL 著色器,看起來像龍捲風。我們嘗試了頂點著色器等多種技術,創造出有趣的幾何形狀、粒子式動畫,甚至是扭曲幾何形狀的 3D 動畫。所有效果似乎無法重現龍捲風的感受,或是處理龍捲風的需求過多。

這個專案終究是完全不同的專案,讓我們找到了答案。同時,這項專案含有來自 Max Planck Institute (brainflight.org) 開發滑鼠大腦的平行專案,產生了有趣的視覺效果。我們以前用自訂的音量著色器,在滑鼠神經元內部拍攝電影,

使用自訂的音量著色器在滑鼠神經元內部
使用自訂的音量著色器在滑鼠神經元內部

我們發現,腦細胞的內部看起來有點像龍捲風的漏斗。而且,由於我們採用了音量調技術,所以我們可以從太空中從任何方向看到這個著色器。我們可以設定著色器的轉譯方式,與暴風雨環境合併,特別是在多層雲層下方和壯麗背景上方三明治。

著色器技術牽涉到一個技巧,基本上會使用單一 GLSL 著色器,以距離欄位包含光線欄位的簡化算繪演算法來算繪整個物件。使用這項技術時,會建立像素著色器,可估算畫面上每個點與表面的最遠距離。

如需參考演算法的實用參考資料,請參閱 iq:「Rendering Worlds With Two Triangles - Iñigo Quilez」。此外,也在 glsl.heroku.com 瀏覽著色器庫,其中有許多範例可以嘗試利用。

著色器的核心是從主要功能開始,負責設定相機轉換並進入迴圈,此迴圈會重複評估與表面的距離。呼叫 RaytraceFoggy( 方向_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;
}

建立這類著色器的工作並不容易。除了建立作業的抽象化作業流程之外,您必須追蹤及解決嚴重的最佳化和跨平台相容性問題,才能在實際工作環境中使用相關工作。

第一個問題是:針對場景最佳化這個著色器。為解決這個問題,我們需要採用「安全」的方法,以防著色器太重。為此,我們以與場景其他部分不同的取樣解析度合成龍捲風著色器。這來自於 OdorTest.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 演算法將龍捲風算繪至螢幕,避免像積木般的 @line 1107 in ExposureTest.coffee。也就是說,我們最終會發現龍捲風會比較模糊,但至少在沒有使用者控管的情況下也能正常運作。

下一個最佳化步驟便需要深入瞭解演算法。著色器中的行車計算因子是對每個像素執行的疊代,嘗試估算表面函式的距離:流域迴圈的疊代次數。使用較大的步伐規模後,當我們在雲霧表面時,就能以較少的疊代次數估算龍捲風表面。在裡面,我們會縮小步伐以提高精確度,並能夠混合值,製造出錯效果。此外,也可以建立定界圓柱,針對投射的光線取得深度估計值,加快了速度。

接下來的問題是,要確保這個著色器可在不同的顯示卡上執行。我們每次都會進行一項測試,並思考可能遇到的相容性問題類型。根據直覺判斷,我們不一定能取得完善的錯誤偵錯資訊,這點實在不盡完善。典型案例只是 GPU 錯誤,需要處理的多一點工時,甚至是系統當機!

跨影片板相容性問題也有類似的解決方案:請確認將靜態常數輸入符合定義的資料類型,IE:0.0 代表浮點值,0 代表 int。編寫較長的函式時請特別留意,最好使用多個較簡單的函式和暫時性變數分解,因為編譯器似乎無法正確處理某些情況。紋理資料必須是 2 的力量,不會過大,且在循環檢視紋理資料時,遇到「情況」時遇到的情況。

在相容性方面,我們最大的問題是來自於雷雨的燈光效果。我們使用包裝在龍捲風周圍的預製紋理,為它上色。這是令人驚豔的效果,讓能輕鬆將龍捲風融入場景色彩中,但需要很長的時間才嘗試在其他平台上執行。

龍捲風

行動網站

行動版網站的技術和處理要求太過繁瑣,所以無法直接翻譯電腦版的使用者體驗。因此,我們必須打造新服務,專門指定行動裝置使用者。

如果將電腦版的 Carnival Photo-Booth 設為行動網頁應用程式,系統會直接使用使用者的行動相機,應該會是件很棒的事。目前為止我們都沒看過的東西。

為了加入 flavour,我們在 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 類別中,除了指出應用程式如何與 MainPreloadTask 通訊 (透過共用的工作實作),您也值得注意,我們如何載入因平台而異的資產。基本上我們有四種圖片行動標準 (.ext,其中 ext 為檔案副檔名,通常是 .png 或 .jpg)、行動 Retina (-2x.ext)、平板電腦標準 (-tab.ext) 以及平板電腦 Retina (-tab-2x.ext)。我們不會在 MainPreloadTask 中偵測,並對四個資產陣列進行硬式編碼,只需說出要預先載入的資產名稱和副檔名,以及資產是否依賴平台 (回應式 = 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 相片攤位 (iOS6/Android)

我們在開發 OZ 行動裝置時發現,許多時間都花在實際操作相片攤位,而不是實際運作 :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();            
    }
    }

其餘的公司則能在不同平台之間順暢運作。創作愉快!

結論

鑒於「尋找奧茲」的規模和涵蓋的不同技術,本文中只能介紹我們使用的幾種方法。

如果您想探索完整的安吉拉州,歡迎瀏覽這個連結的完整原始碼。

抵免額

如要查看完整的抵免額清單,請按這裡

參考資料