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

はじめに

「Find Your Way to Oz」は、ディズニーがウェブに公開する Google Chrome 試験運用版の新機能です。カンザス州のサーカスでインタラクティブな旅を楽しみ、大嵐に巻き込まれた後、オズの国にたどり着きます。

私たちの目標は、映画の豊かさとブラウザの技術的機能を組み合わせて、ユーザーが強いつながりを築ける没入感のある楽しい体験を生み出すことでした。

この仕事は少し大きすぎるため、この記事では全体像をつかめません。そこで、テクノロジーに関するストーリーのいくつかの章の中から、興味深いと思われるものをまとめています。その過程で、難易度を上げることに焦点を当てたチュートリアルをいくつか抽出しました。

多くの人がこの体験を実現するために懸命に努力しました。多すぎてここにはリストできません。サイトにアクセスして、メニュー セクションの下にあるクレジット ページで全文をご覧ください。

仕組み

パソコン版の『オズへの道』は豊かで臨場感あふれる世界です。3D と、従来の映画制作からヒントを得た複数のレイヤを使用して、それらを組み合わせて、ほぼリアルなシーンを作り出しています。最も有名なテクノロジーは、Three.js を使用した WebGL、カスタムビルド シェーダー、CSS3 機能を使用した DOM アニメーション要素です。さらに、getUserMedia API(WebRTC)を使用してインタラクティブなエクスペリエンスを創出し、ユーザーがウェブカメラや WebAudio から直接画像を追加して 3D サウンドを実現することもできます。

しかし、このようなテクノロジーの経験が魔法のように融合しています。これも主な課題の一つです。視覚効果とインタラクティブな要素を 1 つのシーンに組み合わせて一貫した全体を作り出すにはどうすればよいでしょうか。この視覚的な複雑さは管理が難しく、開発のどの段階にあったかを常に把握するのは困難でした。

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

秘密をお伝えする前に、自動車のエンジン内を突き回ったときのように、衝突する可能性があることを警告しておきたいと思います。重要なものがないことを確認してから、サイトのメイン URL にアクセスし、アドレスに「?debug=on」を追加します。サイトが読み込まれるまで待ってから、内部に入ったら Ctrl-I キーを押します。右側にプルダウンが表示されます。[カメラパスを終了] オプションをオフにすると、A キー、W キー、S キー、D キー、マウスを使ってスペース内を自由に移動できます。

カメラのパス。

ここではすべての設定について説明しませんが、試してみることをおすすめします。キーにより、シーンごとに異なる設定が表示されます。最後のストーム シーケンスには追加のキー Ctrl-A があります。これを使用すると、アニメーションの再生を切り替えたり、飛行したりできます。このシーンでは、Esc を押して(マウスのロック機能を終了)Ctrl-I をもう一度押すと、暴風雨シーンに固有の設定にアクセスできます。下の表のような美しいポストカードをご覧ください。

嵐

これを実現し、ニーズに十分に対応できる柔軟性を確保するために、dat.gui という便利なライブラリを使用しました(使用方法に関する過去のチュートリアルについては、こちらをご覧ください)。おかげで、サイトの訪問者に表示する設定をすばやく変更できました。

マットな絵のようなもの

ディズニーの古典的な映画やアニメーションの多くでは、シーンの作成はさまざまなレイヤを組み合わせることを意味していました。実写、細胞アニメーション、物理的なセット、さらにはガラスにペイントすることで作成された最上層など、マットペイントと呼ばれる手法がありました。

私たちが作成したエクスペリエンスの構造は多くの点でよく似ていますが、「レイヤ」の一部は静的なビジュアル以上のものです。実際、より複雑な計算によって、物事の外観に影響を与えます。それでも、少なくともビューを扱う全体像レベルでは、ビューを重ねて合成しました。上部には UI レイヤがあり、その下に 3D シーンがあります。それ自体は、さまざまなシーン コンポーネントで構成されています。

最上位のインターフェース レイヤは DOM と CSS 3 を使用して作成されました。つまり、3D エクスペリエンスとは無関係に、イベントの選択リストに基づく両者間の通信により、さまざまな方法でインタラクションを編集できました。この通信では、バックボーン ルーターと onHashChange HTML5 イベントを使用して、アニメーション化する領域を制御します。(プロジェクト ソース: /develop/coffee/router/Router.coffee)。

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

このインターフェースについて最適化した面白い手法の一つは、多数のインターフェース オーバーレイ画像を 1 つの PNG にまとめ、サーバー リクエストを減らすことでした。このプロジェクトでは、ウェブサイトのレイテンシを短縮するために、3D テクスチャを除く 70 枚以上の画像からなるインターフェースをあらかじめ読み込みました。ライブ スプライト シートはこちらで確認できます。

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

ここでは、スプライト シートの活用方法と、スプライト シートを Retina デバイスで使用してインターフェースをできるだけ鮮明かつ整然とする方法に関するヒントを紹介します。

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

SpriteSheet の作成には、必要な形式で出力できる 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 を参照
  • フレームは各 UI 要素の座標 [x, y, 幅, 高さ]
  • アニメーションは各アセットの名前です。

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

まとめ

これですべて設定できました。必要なのは、この 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 シーンについて考えた場合、最も難しい質問の 1 つは、モデリング、アニメーション、エフェクトの面で最大限の表現力を発揮できるコンテンツを制作するにはどうすればよいかということです。さまざまな点で、この問題の中心にあるのはコンテンツ パイプラインです。これは、3D シーンのコンテンツを作成するために従うべき合意されたプロセスです。

当社は、感動的な世界を創造したいと考えていました。そのため、3D アーティストがそれを作成できる強固なプロセスが必要でした。3D モデリング ソフトウェアとアニメーション ソフトウェアは表現の自由をできるだけ多く与えられ、コードを使用して画面上にレンダリングする必要があります。

以前から 3D サイトを作るたびに、使用できるツールに限界があったため、Google は以前からこの種の問題に取り組んできました。そこで開発されたのが 3D ライブラリアンという社内調査ツールです。実際の仕事に応用する準備がほぼ整いました。

このツールにはもともと Flash 向けのものがあり、ランタイムの展開用に最適化された単一の圧縮ファイルとしてマヤの大きなシーンを取り込むことができます。これが最適であった理由は、レンダリングとアニメーション中に操作されるのと基本的に同じデータ構造にシーンを効果的にパックするためでした。読み込み時にファイルに対して行う必要のある解析はほとんどありません。ファイルは AMF 形式であり、Flash でネイティブに展開できるため、Flash での展開は短時間で完了しました。WebGL で同じ形式を使用すると、CPU の負荷が高くなります。実際、データ展開用の JavaScript レイヤを再作成する必要がありました。これは基本的にこれらのファイルを解凍し、WebGL が動作するために必要なデータ構造を再作成するものです。3D シーン全体を展開すると、CPU 負荷が軽くなります。Find Your Way To Oz のシーン 1 を展開するには、中規模からハイエンドのマシンで約 2 秒かかります。したがって、これは、ユーザーのエクスペリエンスを低下させないように、「シーンのセットアップ」時間(実際にシーンが開始される前)に Web Worker のテクノロジーを使用して行われます。

この便利なツールを使用すると、モデル、テクスチャ、ボーン アニメーションといった 3D シーンの大部分をインポートできます。3D エンジンで読み込むことができるライブラリ ファイルを 1 つ作成します。シーンに必要なモデルをすべてこのライブラリに詰め込むだけで、それをシーンに生成できます。

しかし問題は、WebGL を扱っていたことでした。ブラウザベースの 3D 体験の標準を確立する必要がありました。そこで、3D Librarian で圧縮された 3D シーンファイルを受け取って、WebGL が理解できる形式に適切に変換する、アドホックな JavaScript レイヤを作成しました。

チュートリアル: Let That Be Wind

「オズへの冒険」に繰り返し登場したテーマは「風」でした。ストーリーの筋書きは、風のクレッシェンドのように構成されています。

カーニバルの最初のシーンは比較的穏やかです。さまざまなシーンを通過すると、ユーザーは徐々に強風に遭遇し、最後のシーンである嵐へとつながります。

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

そのために、3 つのカーニバルのシーンに、テント、写真ブースの表面に旗、風船自体など、風によって影響を受けるはずの柔らかい物体を配置しました。

柔らかい布。

最近のデスクトップ ゲームは、物理エンジンを中心として構築されているのが一般的です。そのため、ソフト オブジェクトを 3D 世界でシミュレートする必要がある場合、それに対して完全な物理シミュレーションが実行され、信頼性の高いソフト動作が作成されます。

WebGL / JavaScript では、本格的な物理シミュレーションを実行する余裕が(まだ)ありません。そのため、オズでは、実際に風をシミュレートすることなく、風の効果を生み出す方法を見つける必要がありました。

各物体の「風に対する感度」の情報を 3D モデル自体に埋め込んでいます。3D モデルの各頂点には、その頂点が風によってどの程度影響を受けるかを指定する「Wind Attribute」がありました。これは 3D オブジェクトの特定の風に対する感度です。次に、風そのものを作る必要がありました。

そのために、Perlin Noise を含む画像を生成しました。この画像は、特定の「風の領域」をカバーすることを目的としています。つまり、3D シーンのある長方形の領域に、雲のようなノイズが敷かれているような絵を想像してみるのがよいでしょう。この画像の各ピクセル(グレーレベル値)は、「周囲」の 3D 領域における特定の瞬間の風の強さを示します。

風効果を生み出すために、画像を特定の方向(風の方向)に一定の速度で時間で動かします。また、「風が強いエリア」がシーン内のすべてに影響を及ぼさないようにするために、効果のあるエリアに限定して、風の画像を縁の周囲にラップします。

風が吹き込む簡単な 3D チュートリアル

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

シンプルな「手続き型草原」で風を作ります。

まずシーンを作成しましょう。ここでは、テクスチャのあるシンプルな平坦な地形を作成します。そして、草が 1 枚ずつ逆さまの 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()

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

ここでは、15x15 ビットの芝生のグリッドを作成します。各草の位置にランダム化を加えて、兵士のように並んで見えないようにしています。

この地形は単なる水平面であり、草の根元に配置されます(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

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

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

もう 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)からそのまま引用したものです。

NoiseShader は、時間、スケール、パラメータのオフセット セットをユニフォームとして受け取り、パーリン ノイズの適切な 2 次元分布を出力します。

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) }

...

このシェーダーを使用して、パーリン ノイズをテクスチャにレンダリングします。これは 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 の MeshPhongMaterial シェーダーから始め、これに変更を加えました。これは、シェーダーをゼロから作成するのではなく、機能するシェーダーをすぐに使い始めるための優れた方法です。

ここではシェーダーのコード全体をコピーしません(ソースコード ファイルで確認できます)。ほとんどが MeshPhongMaterial シェーダーのレプリカであるためです。ここでは、Vertex Shader で変更した風に関連するパーツを見てみましょう。

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;

そのため、このシェーダーはまず、頂点の 2 次元の xz(水平)位置に基づいて、まず windUV テクスチャのルックアップ座標を計算します。この UV 座標は、パーリン ノイズ風テクスチャから風力 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
...

これで完了です。風に影響を受ける「手続き用草」のシーンを作成しました。

粉末を混ぜる

シーンにちょっとしたスパイスを加えましょう飛ぶほこりを少し加えて、シーンをより面白くしましょう。

ほこりを追加しています
ほこりを追加する

ほこりは、結局のところ風の影響を受けることになっているため、風が吹き飛ぶ中、ほこりが舞い込んでいるのは当然です。

Dust は、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;

この頂点シェーダーは、草を風で変形させるために使用していたシェーダーとそれほど変わりません。パーリン ノイズ テクスチャを入力として受け取り、ゴミの世界の位置に応じて、ノイズ テクスチャの vWindForce の値を検索します。次に、この値を使用して、ほこりの粒子の位置を変更します。

ライダーズ オン ザ ストーム

WebGL の中で最も冒険的なシーンはおそらく最後のシーンでしょう。このシーンでは、バルーンをくぐって竜巻の目線に向かって進むと、サイト内での旅の終わりにたどり着くことがわかります。また、今後のリリースの独占動画も紹介しています。

気球に乗るシーン

このシーンを制作したとき、インパクトのあるエクスペリエンスの中心となる機能が必要であるとわかっていました。回転する竜巻が目玉として機能し、他のコンテンツのレイヤがこの特徴を所定の位置に成形し、劇的な効果を生み出します。そのために、この奇妙なシェーダーを中心とした映画スタジオに相当するものを作りました。

複合的なアプローチを使用して、現実的な合成手法を作成しました。レンズフレア効果を出すための光の形や、見ているシーンの上にレイヤとしてアニメーション化する雨粒などの視覚的なトリックもあります。他のケースでは、飛ぶ雲の層がパーティクル システム コードに従って動くように、平らな面が動き回っているように描画されます。3D シーンでは、竜巻の周りを回っている破片が層状に積み上げられ、竜巻の前後に動くように分類されていました。

この方法でシーンを作成する主な理由は、適用している他のエフェクトとのバランスをとりながら、竜巻シェーダーを処理するために十分な GPU を確保することでした。当初は GPU のバランスに大きな問題がありましたが、後にこのシーンが最適化され、メインシーンよりも軽量になりました。

チュートリアル: ストーム シェーダー

最終的な嵐のシーケンスを作成するためにさまざまな手法が組み合わされましたが、今回の作業の中心となるのは、竜巻のように見えるカスタム GLSL シェーダーでした。頂点シェーダーからさまざまな手法を試して、興味深い幾何学的な渦巻きを作り、粒子ベースのアニメーション、さらにはねじれた幾何学形状の 3D アニメーションまで作成しました。いずれのエフェクトも、竜巻の感覚を再現しているようには見られず、処理の点で過度な必要もありました。

まったく別のプロジェクトで、最終的に答えが得られました。マックス プランク研究所(brainflight.org)による、マウスの脳を地図化する科学ゲームを使った並列プロジェクトでは、興味深い視覚効果が得られました。カスタム ボリューム シェーダーを使用して、マウスのニューロン内部の動画を作成することができました。

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

私たちは、脳細胞の内部が竜巻のじょうごに少し似ていることを発見しました。ボリューム測定手法を使用しているので、このシェーダーを空間内のあらゆる方向から表示できることがわかっていました。特に雲の層の下やドラマチックな背景の上に挟まれている場合は、シェーダーのレンダリングを嵐のシーンと組み合わせるように設定できます。

シェーダーには、基本的には 1 つの GLSL シェーダーを使用し、距離フィールドを使用したレイ マーチング レンダリングと呼ばれる簡素化されたレンダリング アルゴリズムでオブジェクト全体をレンダリングする手法があります。この手法では、画面上の各ポイントについて、表面への最も近い距離を推定するピクセル シェーダーを作成します。

このアルゴリズムについては、iq の概要(Rendering Worlds With Two Triangles - Iñigo Quilez)をご覧ください。glsl.herku.com のシェーダー ギャラリーにも、この手法の例が数多くありますので、試してみてください。

シェーダーの核心部分は main 関数から始まります。この関数によってカメラの変換が設定され、表面までの距離を繰り返し評価するループに入ります。RaytraceFoggy( directional_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;
}

この種のシェーダーの作成には手間がかかります。作成しているオペレーションの抽象化に関する問題だけでなく、本番環境で作業を使用する前に、トレースと解決が必要な重大な最適化やクロス プラットフォーム互換性の問題もあります。

問題の最初の部分は、シーンに合わせてこのシェーダーを最適化することです。これに対処するには、シェーダーが重くなりすぎた場合に備えて「安全な」アプローチが必要でした。そのために、シーンの他の部分とは異なるサンプリング解像度でトルネード シェーダーを合成しました。これはファイル stoneTest.coffee のものです(これはテストです)。

まず、シーンの幅と高さに合致する renderTarget を使用します。これにより、シーンに対するトルネード シェーダーの解像度を独立させることができます。次に、取得されるフレームレートに応じて、ストーム シェーダーの解像度のダウンサンプリングを動的に設定します。

...
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

最後に、ストームテスト .coffee の @line 1107 で、(ブロック状の見た目を避けるために)簡素化された sal2x アルゴリズムを使用して、竜巻を画面にレンダリングします。つまり、最悪の場合、よりぼやけた竜巻が発生しても、少なくともユーザーが制御できなくなることはありません。

最適化の次のステップでは、アルゴリズムを詳しく調べる必要があります。シェーダーでの計算係数は、サーフェス関数の距離(レイマーチ ループの反復回数)を近似するために各ピクセルで実行される反復処理です。ステップサイズを大きくすることで、雲に覆われた地表の外にいるときに、反復回数を減らして竜巻表面の推定値を算出できます。内部の場合は、精度のためにステップサイズを小さくし、値を混在させて霧効果を生み出すことができます。また、キャスト光線の深度を推定する境界円柱を作成することで、速度が大幅に向上しました。

問題の次の部分は、このシェーダーがさまざまなビデオカードで実行されることを確認することでした。毎回いくつかのテストを実施し、直面する可能性のある互換性の問題を直感的に把握できるようにしました。直感に勝るものがない理由は、必ずしもエラーに関する適切なデバッグ情報が得られるとは限らないからです。典型的なシナリオは、GPU のエラーや、その後の作業が少し増えることもあれば、システム クラッシュが発生することさえあります。

ビデオボード間の互換性の問題にも同様の解決策がありました。静的定数が定義された正確なデータ型(IE: 浮動小数点の場合は 0.0、int の場合は 0)で入力されていることを確認してください。長い関数を記述する場合は注意してください。コンパイラが特定のケースを正しく処理しない可能性があるため、複数の単純な関数と暫定変数に分割することをおすすめします。テクスチャはすべて 2 のべき乗であり、大きすぎず、ループでテクスチャ データを検索する場合は「注意」を払うようにしてください。

互換性に関して最も問題だったのは、嵐の照明効果でした。既製のテクスチャを竜巻の周りに巻き付けて、手首に着色できるようにしました。素晴らしい効果で、竜巻をシーンの色に溶け込ませるのは簡単ですが、他のプラットフォームで実行するには時間がかかりました。

竜巻

モバイルサイト

テクノロジーと処理の要件が高すぎるため、モバイル エクスペリエンスは PC 版をそのままそのままにすることはできませんでした。そのため、モバイル ユーザーをターゲットとする新しいソリューションを構築する必要がありました。

ユーザーのモバイルカメラを使用するモバイル ウェブ アプリケーションとして、デスクトップの Carnival Photo-Booth 機能があると考えたのです。今までになかったことです。

風味を高めるために、3D 変換を CSS3 でコーディングしました。これをジャイロスコープや加速度計とリンクしたことで、エクスペリエンスに奥行きが加わりました。サイトはユーザーがスマートフォンの持ち方、動き方、見方に反応します。

この記事を書くにあたり、モバイル開発プロセスを円滑に進めるためのヒントをご紹介したいと思います。やった!さっそく本題に入りましょう。

モバイルに関するヒントとアドバイス

プリローダーは必要なものであり、避けるべきものではありません。そのような場合があることは承知しています。これは主に、プロジェクトの拡大に合わせてプリロードする項目のリストを維持する必要があるためです。さらに悪いことに、さまざまなリソースを同時に pull する場合、読み込みの進捗状況をどのように計算すべきかが明確ではありません。ここで便利なのが、カスタムの汎用的な抽象クラス「Task」です。その主なアイデアは、タスクが独自のサブタスクを持つことができる、無限にネストされた構造を許可することです。さらに、各タスクは、サブタスクの進捗状況に関する進捗を計算します(親の進捗状況については計算しません)。MainPreloadTask、AssetPreloadTask、TemplatePreFetchTask をすべて Task から派生させ、次のような構造を作成しました。

プリローダー

このようなアプローチと Task クラスのおかげで、グローバルな進行状況(MainPreloadTask)、アセットの進行状況のみ(AssetPreloadTask)、テンプレートの読み込みの進行状況(TemplatePreFetchTask)などを簡単に把握することができます。特定のファイルの進捗状況。その方法については、Task クラス(/m/javascripts/raw/util/Task.js)と実際のタスクの実装(/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 と(共有の Task 実装を介して)どのように通信するかだけでなく、プラットフォームに依存するアセットをどのように読み込むかにも注目してください。4 種類の画像があります。標準モバイル(.ext、拡張子は .png または .jpg など)、モバイル Retina(-2x.ext)、タブレット標準(-tab.ext)、タブレット Retina(-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 Mobile を開発していたとき、私たちは実際の作業ではなく写真ブースで遊ぶことに多くの時間を費やしていたことがわかりました。それは単純に楽しいからでした。実際にお使いいただけるデモを用意しました。

モバイル写真ブース
モバイル フォトブース

ライブデモはこちらでご覧いただけます(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();            
    }
    }

それ以外は、プラットフォーム間でスムーズに機能します。ご健闘をお祈りいたします。

おわりに

『オズへの道を探す』は規模が大きく、さまざまなテクノロジーが多種多様であるため、この記事では使用したアプローチのごく一部のみを取り上げました。

エンチラーダの全容を知りたい場合は、こちらのリンクから「オズへの道を探す」のソースコード全体をご覧ください。

クレジット

クレジットの全リストについては、こちらをクリックしてください。

リファレンス