事例紹介 - オズへの冒険: オズへの冒険

はじめに

「オズの魔法使い」は、ディズニーがウェブに導入した新しい Google Chrome 試験運用版です。インタラクティブな旅で、カンザスのサーカスを巡り、大嵐に巻き込まれ、オズの国にたどり着きます。

映画の豊かさとブラウザの技術的な能力を組み合わせて、ユーザーが強いつながりを築くことができる、楽しく没入感のあるエクスペリエンスを実現することが目標でした。

この仕事は規模が大きく、この記事ですべてを網羅することはできません。そこで、興味深いと思われる技術ストーリーの一部を抜粋してご紹介します。その過程で、難易度が徐々に上がる、特定のトピックに絞ったチュートリアルをいくつか作成しました。

この体験を実現するために、多くの人が尽力しました。ここでは書ききれないほどです。サイトにアクセスして、メニュー セクションのクレジット ページでストーリーの全文をご覧ください。

動作の仕組み

パソコン版の「オズの魔法使い」は、没入感あふれる豊かな世界です。3D と、従来の映画制作から着想を得た複数のレイヤのエフェクトを組み合わせて、リアルに近いシーンを作成します。最も注目されているテクノロジーは、Three.js を使用した WebGL、カスタムビルド シェーダー、CSS3 機能を使用した DOM アニメーション要素です。さらに、getUserMedia API(WebRTC)を使用すると、ユーザーはウェブカメラから直接画像を追加したり、3D サウンド用の WebAudio を使用したりできるインタラクティブなエクスペリエンスを実現できます。

このようなテクノロジーの魅力は、それらが一体となって機能することです。これは、視覚効果とインタラクティブな要素を 1 つのシーンに統合して、一貫性のある全体を作成するための主な課題の 1 つでもあります。この視覚的な複雑さは管理しづらく、開発のどの段階にあるかを把握するのが困難でした。

相互に関連する視覚効果と最適化の問題に対処するため、その時点で確認していた関連するすべての設定をキャプチャするコントロール パネルを頻繁に使用しました。シーンは、明るさ、被写界深度、ガンマなど、ブラウザでリアルタイムに調整できます。誰でもエクスペリエンス内の重要なパラメータの値を調整して、最適な設定を見つけることができます。

秘密を共有する前に、自動車のエンジンをいじくるとクラッシュする可能性があることを警告しておきます。重要なものが何もオンになっていないことを確認してから、サイトのメイン URL にアクセスし、アドレスに ?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)。

チュートリアル: スプライトシートと Retina のサポート

インターフェースで使用した面白い最適化手法の 1 つは、サーバー リクエストを減らすために、多くのインターフェース オーバーレイ画像を 1 つの PNG に統合したことです。このプロジェクトでは、ウェブサイトのレイテンシを低減するために、インターフェースは 70 枚以上の画像(3D テクスチャを除く)で構成され、すべて事前に読み込まれました。ライブ スプライトシートは次のとおりです。

通常のディスプレイ - http://findyourwaytooz.com/img/home/interface_1x.png Retina ディスプレイ - http://findyourwaytooz.com/img/home/interface_2x.png

以下に、Sprite シートを活用した方法と、それを Retina デバイスで使用してインターフェースをできるだけシャープできれいにする方法をご紹介します。

スプライトシートを作成する

スプライトシートを作成するために、必要な形式で出力できる TexturePacker を使用しました。この場合は 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 はスプライトシートの URL を参照します。
  • frames は、各 UI 要素の座標 [x、y、width、height] です。
  • animations は各アセットの名前です。

高密度画像を使用してスプライトシートを作成し、サイズを半分に縮小して通常バージョンを作成しました。

まとめ

準備は整いました。あとは、この機能を使用するための 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 サイトを作成するたびに、使用できるツールに制限があることが判明していたため、Google は長い間、この種の問題に取り組んできました。そこで、3D Librarian というツールを作成しました。これは内部調査の一環です。実際の業務に適用できるレベルにまで仕上がっていました。

このツールには歴史があります。元々は Flash 用で、大きな Maya シーンを 1 つの圧縮ファイルとして取り込み、ランタイムの解凍用に最適化されていました。これが最適だった理由は、レンダリングとアニメーション中に操作されるデータ構造と基本的に同じデータ構造でシーンを効果的にパックできたためです。読み込み時にファイルに対して行う必要がある解析はほとんどありません。ファイルは AMF 形式で、Flash でネイティブに解凍できたため、Flash での解凍は非常に迅速でした。WebGL で同じ形式を使用すると、CPU で少し作業が増えます。実際には、データ展開の JavaScript レイヤのコードを再作成する必要がありました。このコードは、基本的にこれらのファイルを解凍し、WebGL の動作に必要なデータ構造を再作成します。3D シーン全体の解凍は、CPU 使用率がやや高くなります。Find Your Way To Oz のシーン 1 の解凍には、中程度からハイエンドのマシンで約 2 秒かかります。そのため、ユーザーのエクスペリエンスを妨げないように、Web Workers テクノロジーを使用して「シーンのセットアップ」時(シーンが実際に起動する前)に処理されます。

この便利なツールでは、モデル、テクスチャ、ボーン アニメーションなど、3D シーンのほとんどをインポートできます。1 つのライブラリ ファイルを作成し、3D エンジンによって読み込むことができます。シーンに必要なすべてのモデルをこのライブラリに詰め込み、シーンにスポーンします。

ただし、WebGL という新しい技術を扱う必要がありました。かなり厳しい課題でした。ブラウザベースの 3D エクスペリエンスの標準を設定するものでした。そこで、3D Librarian で圧縮された 3D シーン ファイルを受け取り、WebGL が理解できる形式に適切に変換するアドホックな JavaScript レイヤを作成しました。

チュートリアル: 風を起こす

「Find Your Way To Oz」では、風が繰り返しテーマとして取り上げられています。ストーリーラインのスレッドは、風の強まりを表現するように構成されています。

カーニバルの最初のシーンは比較的落ち着いています。さまざまなシーンを進むにつれて、風が強くなり、最終シーンの嵐に至ります。

そのため、没入感のある風の効果を提供することが重要でした。

そのため、3 つのカーニバル シーンには、テント、旗、フォト ブースの表面、風船など、風の影響を受けやすい柔らかいオブジェクトを配置しました。

柔らかい布。

最近の PC ゲームは、通常、コア物理エンジンを中心に構築されています。そのため、3D 世界で柔らかいオブジェクトをシミュレートする必要がある場合は、完全な物理シミュレーションが実行され、信頼できる柔らかい動作が作成されます。

WebGL / Javascript では、(まだ)本格的な物理シミュレーションを実行する余裕がありません。そのため、オーストラリアでは、実際に風をシミュレートせずに風の効果を作り出す方法を見つける必要がありました。

各オブジェクトの「風に対する感度」に関する情報を 3D モデル自体に埋め込みました。3D モデルの各頂点には、「風属性」があり、その頂点が風の影響を受ける程度を指定していました。3D オブジェクトの風に対する感度を指定します。次に、風自体を作成する必要がありました。

これは、Perlin ノイズを含む画像を生成することで実現しました。この画像は、特定の「風の領域」をカバーすることを目的としています。3D シーンの特定の長方形の領域に雲のようなノイズの画像が重ねられていると考えるといいでしょう。この画像の各ピクセル(グレーレベル値)は、そのピクセルを「囲む」3D 領域内の特定の瞬間の風の強さを指定します。

風の効果を出すには、画像を一定の速度で特定の方向(風の方向)に時間とともに移動させます。また、「風の強いエリア」がシーン内のすべてに影響しないように、風の画像をエッジに沿って巻き付けて、効果のある範囲に限定しています。

シンプルな 3D 風のチュートリアル

次に、Three.js でシンプルな 3D シーンに風の効果を作成しましょう。

簡単な「プロシージャル グラス フィールド」に風を作成します。

まずシーンを作成しましょう。シンプルなテクスチャ付きの平坦な地形を作成します。草の部分は、上下逆さまの 3D 円錐で表現されます。

草地
芝生に覆われた地形

CoffeeScript を使用して Three.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()

initGrass 関数と initTerrain 関数を呼び出すと、シーンにそれぞれ草と地形が配置されます。

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 モデルの各頂点にカスタム属性として埋め込みます。草モデルの下端(円錐の先端)は地面に接しているため、感度がゼロというルールを使用します。芝生モデルの上部(円錐の底部)は、地面から離れているため、風の影響を受けやすくなります。

以下は、草の 3D モデルのカスタム属性として風の影響を追加するために、instanceGrass 関数を再コード化したものです。

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

以前使用していた MeshPhongMaterial の代わりに、カスタム マテリアル windMaterial を使用しています。WindMaterial は、後ほど説明する WindMeshShader をラップします。

そのため、instanceGrass のコードは、草のモデルのすべての頂点をループし、各頂点に windFactor というカスタム頂点属性を追加します。この windFactor は、芝生モデルの下端(地形に接する部分)では 0 に設定され、芝生モデルの上端では 1 に設定されます。

もう一つの要素として、実際の風をシーンに追加する必要があります。前述のとおり、ここでは 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)の 1 つからそのまま使用しています。

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 を装備して、透視の歪みを回避するために正投影カメラでレンダリングします。

前述のように、このテクスチャを地形のメインのレンダリング テクスチャとしても使用します。風の効果自体が機能するためには、この設定は必要ありません。風力発電の状況を視覚的に把握できるため、便利な機能です。

以下は、noiseMap をテクスチャとして使用する、変更後の 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 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 もあります。

こうして、風によって草が変形します。ただし、これで終わりではありません。現在のところ、この変形は静的であり、風の強い地域の効果を伝えることはできません。

前述のように、ガラスが揺れるようにするには、ノイズ テクスチャを時間の経過とともに風の領域全体にスライドさせる必要があります。

これは、NoiseShader に渡される vOffset ユニフォームを時間とともにシフトすることで行われます。これは 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 のバランス調整に大きな問題がありましたが、このシーンは最適化され、メインシーンよりも軽くなりました。

チュートリアル: 嵐シェーダー

最終的な嵐のシーンを作成するために、さまざまな手法が組み合わせられましたが、この作品の中心は竜巻のようなカスタム GLSL シェーダーでした。さまざまなテクニックを試しました。頂点シェーダーによる興味深い幾何学的な渦巻きから、パーティクル ベースのアニメーション、ねじれた幾何学的な形状の 3D アニメーションまで、さまざまなテクニックを試しました。どのエフェクトも竜巻の雰囲気を再現しているようには見えず、処理の面でも負担が大きすぎるように思えました。

最終的に、まったく別のプロジェクトで解決策が見つかりました。マックス プランク研究所(brainflight.org)の、マウスの脳をマッピングする科学ゲームに関する並行プロジェクトでは、興味深い視覚効果が生成されました。カスタム ボリューメトリック シェーダーを使用して、マウスのニューロン内部の動画を作成することができました。

カスタム ボリューメトリック シェーダーを使用したマウスのニューロン内部
カスタム ボリューメトリック シェーダーを使用したマウスのニューロン内部

脳細胞の内部は竜巻の漏斗に似ていることがわかりました。ボリューメトリック手法を使用しているため、このシェーダーは空間のあらゆる方向から見ることができるとわかっていました。特に雲の層の下で、ドラマチックな背景の上にサンドイッチされている場合は、シェーダーのレンダリングを嵐のシーンと組み合わせるように設定できます。

このシェーダー手法では、基本的に 1 つの GLSL シェーダーを使用して、距離フィールドを使用したレイマーチング レンダリングという単純なレンダリング アルゴリズムでオブジェクト全体をレンダリングするというトリックが使用されます。この手法では、画面上の各ポイントのサーフェスまでの最短距離を推定するピクセル シェーダーが作成されます。

このアルゴリズムの参考資料として、iq による概要: Rendering Worlds With Two Triangles - Iñigo Quilez をご覧ください。また、glsl.heroku.com のシェーダー ギャラリーを探索すると、この手法の多くの例を見つけて試すことができます。

シェーダーの中心は main 関数です。この関数はカメラ変換を設定し、サーフェスまでの距離を繰り返し評価するループに入ります。コア レイ マーチング計算は、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 変換をコーディングしました。ジャイロスコープと加速度計と連携させることで、エクスペリエンスに深みを加えることができ、スマートフォンの持ち方、動かし方、見方によってサイトが反応します。

この記事では、モバイル開発プロセスをスムーズに進めるためのヒントをご紹介します。以下に、ぜひ、この機会に学びを深めてください。

モバイルのヒントとコツ

プリローダーは必要なものであり、避けるべきものではありません。後者の場合もあります。これは主に、プロジェクトの成長に伴ってプリロードする内容のリストを維持し続ける必要があることによるものです。さらに悪いことに、さまざまなリソースを同時に pull する場合の読み込みの進捗状況を計算する方法は明確ではありません。ここで、カスタムかつ非常に汎用性の高い抽象クラス「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 と通信する方法に加えて、プラットフォームに依存するアセットを読み込む方法にも注目してください。基本的に、画像には 4 つのタイプがあります。モバイル標準(.ext、ext はファイル拡張子、通常は .png または .jpg)、モバイル レチナ(-2x.ext)、タブレット標準(-tab.ext)、タブレット レチナ(-tab-2x.ext)。MainPreloadTask で検出して 4 つのアセット配列をハードコードするのではなく、プリロードするアセットの名前と拡張子、アセットがプラットフォームに依存しているかどうか(レスポンシブ = 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. アップロードまたはファイル選択のキャンセル 開発プロセスで確認されたもう 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();            
    }
    }

その他の機能は、プラットフォームを問わずスムーズに動作します。どうぞお楽しみください!

まとめ

「Find Your Way To Oz」の巨大な規模と、使用されているさまざまなテクノロジーの多様性を考えると、この記事では使用したアプローチの一部しか紹介できませんでした。

全体を詳しく確認したい場合は、Find Your Way To Oz のソースコードをご覧ください。

クレジット

クレジットの一覧は、こちらをご覧ください。

参照