Polymer を使ってライトセーバーを作成する

ライトセーバーのスクリーンショット

概要

Polymer を使用して、モジュール式で構成可能な高性能な WebGL モバイル制御ライトセーバーを作成した方法を紹介します。Google プロジェクト(https://lightsaber.withgoogle.com/)の重要な詳細をいくつか確認し、怒っているストームトルーパーに遭遇した場合に自分で独自のツールを作成する際の時間を節約できるようにします。

概要

Polymer や WebComponents とは何か、とお考えなら、実際に作業中のプロジェクトから抜粋して公開することから始めるのが最善だと考えたのです。以下に、プロジェクト https://lightsaber.withgoogle.com のランディング ページのサンプルを示します。通常の HTML ファイルですが、内部にいくつかの魔法があります。

<!-- Element-->
<dom-module id="sw-page-landing">
    <!-- Template-->
    <template>
    <style>
        <!-- include elements/sw/pages/sw-page-landing/styles/sw-page-landing.css-->
    </style>
    <div class="centered content">
        <sw-ui-logo></sw-ui-logo>
        <div class="connection-url-wrapper">
        <sw-t key="landing.type" class="type"></sw-t>
        <div id="url" class="connection-url">.</div>
        <sw-ui-toast></sw-ui-toast>
        </div>
    </div>
    <div class="disclaimer epilepsy">
        <sw-t key="disclaimer.epilepsy" class="type"></sw-t>
    </div>
    <sw-ui-footer state="extended"></sw-ui-footer>
    </template>
    <!-- Polymer element script-->
    <script src="scripts/sw-page-landing.js"></script>
</dom-module>

昨今では、HTML5 ベースのアプリケーションを作成する際に多くの選択肢があります。API、フレームワーク、ライブラリ、ゲームエンジンなど。あらゆる選択肢がありますが、グラフィックの高パフォーマンス制御と、クリーンなモジュラー構造とスケーラビリティをうまく両立させるセットアップは困難です。Polymer では、低レベルのパフォーマンス最適化を行いながらプロジェクトを整理された状態に保つことができることがわかりました。そこで、Polymer の機能を最大限に活用できるように、プロジェクトをコンポーネントに分割する方法を慎重に検討しました。

ポリマーによるモジュール性

Polymer は、再利用可能なカスタム要素からプロジェクトを作成する方法を大幅に強化できるライブラリです。単一の HTML ファイルに含まれるスタンドアロンで、完全に機能するモジュールを使用できます。これには、構造(HTML マークアップ)だけでなく、インライン スタイルとロジックも含まれます。

下記の例をご覧ください。

<link rel="import" href="bower_components/polymer/polymer.html">

<dom-module id="picture-frame">
    <template>
    <!-- scoped CSS for this element -->
    <style>
        div {
        display: inline-block;
        background-color: #ccc;
        border-radius: 8px;
        padding: 4px;
        }
    </style>
    <div>
        <!-- any children are rendered here -->
        <content></content>
    </div>
    </template>

    <script>
    Polymer({
        is: "picture-frame",
    });
    </script>
</dom-module>

大規模なプロジェクトでは、これら 3 つの論理コンポーネント(HTML、CSS、JS)を分離して、コンパイル時にのみマージする方が効率的です。そのため、プロジェクトの各要素を個別のフォルダとして用意しました。

src/elements/
|-- elements.jade
`-- sw
    |-- debug
    |   |-- sw-debug
    |   |-- sw-debug-performance
    |   |-- sw-debug-version
    |   `-- sw-debug-webgl
    |-- experience
    |   |-- effects
    |   |-- sw-experience
    |   |-- sw-experience-controller
    |   |-- sw-experience-engine
    |   |-- sw-experience-input
    |   |-- sw-experience-model
    |   |-- sw-experience-postprocessor
    |   |-- sw-experience-renderer
    |   |-- sw-experience-state
    |   `-- sw-timer
    |-- input
    |   |-- sw-input-keyboard
    |   `-- sw-input-remote
    |-- pages
    |   |-- sw-page-calibration
    |   |-- sw-page-connection
    |   |-- sw-page-connection-error
    |   |-- sw-page-error
    |   |-- sw-page-experience
    |   `-- sw-page-landing
    |-- sw-app
    |   |-- bower.json
    |   |-- scripts
    |   |-- styles
    |   `-- sw-app.jade
    |-- system
    |   |-- sw-routing
    |   |-- sw-system
    |   |-- sw-system-audio
    |   |-- sw-system-config
    |   |-- sw-system-environment
    |   |-- sw-system-events
    |   |-- sw-system-remote
    |   |-- sw-system-social
    |   |-- sw-system-tracking
    |   |-- sw-system-version
    |   |-- sw-system-webrtc
    |   `-- sw-system-websocket
    |-- ui
    |   |-- experience
    |   |-- sw-preloader
    |   |-- sw-sound
    |   |-- sw-ui-button
    |   |-- sw-ui-calibration
    |   |-- sw-ui-disconnected
    |   |-- sw-ui-final
    |   |-- sw-ui-footer
    |   |-- sw-ui-help
    |   |-- sw-ui-language
    |   |-- sw-ui-logo
    |   |-- sw-ui-mask
    |   |-- sw-ui-menu
    |   |-- sw-ui-overlay
    |   |-- sw-ui-quality
    |   |-- sw-ui-select
    |   |-- sw-ui-toast
    |   |-- sw-ui-toggle-screen
    |   `-- sw-ui-volume
    `-- utils
        `-- sw-t

各要素のフォルダは内部構造が同じで、ロジック(コーヒー ファイル)、スタイル(scss ファイル)、テンプレート(jade ファイル)用の個別のディレクトリとファイルがあります。

sw-ui-logo 要素の例を次に示します。

sw-ui-logo/
|-- bower.json
|-- scripts
|   `-- sw-ui-logo.coffee
|-- styles
|   `-- sw-ui-logo.scss
`-- sw-ui-logo.jade

.jade ファイルを調べると、次のようになります。

// Element
dom-module(id='sw-ui-logo')

    // Template
    template
    style
        include elements/sw/ui/sw-ui-logo/styles/sw-ui-logo.css

    img(src='[[url]]')

    // Polymer element script
    script(src='scripts/sw-ui-logo.js')

別々のファイルからスタイルとロジックを含めることで、物事がクリーンに整理されているのを確認できます。Polymer 要素にスタイルを含めるには、Jade の include ステートメントを使用します。したがって、コンパイル後に実際のインライン CSS ファイルの内容になります。sw-ui-logo.js スクリプト要素は実行時に実行されます。

Bower によるモジュラー依存関係

通常、ライブラリやその他の依存関係はプロジェクト レベルで保持します。ただし、上記の設定では、要素のフォルダ内に bower.json があります。つまり、要素レベルの依存関係です。このアプローチの背後にある考え方は、依存関係が異なる多くの要素がある状況で、実際に使用される依存関係のみを読み込むようにすることです。また、要素を削除しても、その依存関係を宣言している bower.json ファイルも削除するため、その依存関係を削除する必要はありません。各要素は、それに関連する依存関係を個別に読み込みます。

ただし、依存関係の重複を避けるため、各要素のフォルダに .bowerrc ファイルも配置しています。これにより、依存関係の保存場所が指示されるため、同じディレクトリの最後には 1 つだけ配置できます。

{
    "directory" : "../../../../../bower_components"
}

このように、複数の要素が THREE.js を依存関係として宣言している場合、最初の要素にインストールして 2 番目の要素の解析を開始すると、この依存関係がすでにインストールされていると認識され、再ダウンロードや複製は行われません。同様に、bower.json で定義する要素が 1 つ以上ある限り、その依存関係ファイルを保持します。

bash スクリプトは、ネストされた要素構造内のすべての bower.json ファイルを検索します。次に、これらのディレクトリに 1 つずつアクセスし、それぞれで bower install を実行します。

echo installing bower components...
modules=$(find /vagrant/app -type f -name "bower.json" -not -path "*node_modules*" -not -path "*bower_components*")
for module in $modules; do
    pushd $(dirname $module)
    bower install --allow-root -q
    popd
done

新しいクイック要素テンプレート

新しい要素を作成するたびに、正しい名前でフォルダと基本ファイル構造を生成するため、少し時間がかかります。そのため、Slush を使用して単純な要素生成ツールを作成します。

コマンドラインからスクリプトを呼び出すことができます。

$ slush element path/to/your/element-name

ファイル構造と内容をすべて含む新しい要素が作成されます。

要素ファイルのテンプレートを定義しました。たとえば、.jade ファイル テンプレートは次のようになります。

// Element
dom-module(id='<%= name %>')

    // Template
    template
    style
        include elements/<%= path %>/styles/<%= name %>.css

    span This is a '<%= name %>' element.

    // Polymer element script
    script(src='scripts/<%= name %>.js')

スラッシュ ジェネレータは、変数を実際の要素のパスと名前に置き換えます。

Gulp を使用して要素をビルドする

Gulp によってビルドプロセスが制御されます。この構造で要素を作成するには Gulp が以下のステップを踏む必要があります

  1. 要素の .coffee ファイルを .js にコンパイルします。
  2. 要素の .scss ファイルを .css にコンパイルします。
  3. 要素の .jade ファイルを .html にコンパイルし、.css ファイルを埋め込みます。

詳細:

要素の .coffee ファイルを .js にコンパイルする

gulp.task('elements-coffee', function () {
    return gulp.src(abs(config.paths.app + '/elements/**/*.coffee'))
    .pipe($.replaceTask({
        patterns: [{json: getVersionData()}]
    }))
    .pipe($.changed(abs(config.paths.static + '/elements'), {extension: '.js'}))
    .pipe($.coffeelint())
    .pipe($.coffeelint.reporter())
    .pipe($.sourcemaps.init())
    .pipe($.coffee({
    }))
    .on('error', gutil.log)
    .pipe($.sourcemaps.write())
    .pipe(gulp.dest(abs(config.paths.static + '/elements')));
});

ステップ 2 と 3 では、上記の 2 と同様の方法で、gulp とコンパス プラグインを使用して scss.css に、.jade.html にコンパイルします。

ポリマー要素を含む

実際に Polymer 要素を含めるには、HTML インポートを使用します。

<link rel="import" href="elements.html">

<!-- Polymer -->
<link rel="import" href="../bower_components/polymer/polymer.html">

<!-- Custom elements -->
<link rel="import" href="sw/sw-app/sw-app.html">
<link rel="import" href="sw/system/sw-system/sw-system.html">
<link rel="import" href="sw/system/sw-routing/sw-routing.html">
<link rel="import" href="sw/system/sw-system-version/sw-system-version.html">
<link rel="import" href="sw/system/sw-system-environment/sw-system-environment.html">
<link rel="import" href="sw/pages/sw-page-landing/sw-page-landing.html">
<link rel="import" href="sw/pages/sw-page-connection/sw-page-connection.html">
<link rel="import" href="sw/pages/sw-page-calibration/sw-page-calibration.html">
<link rel="import" href="sw/pages/sw-page-experience/sw-page-experience.html">
<link rel="import" href="sw/ui/sw-preloader/sw-preloader.html">
<link rel="import" href="sw/ui/sw-ui-overlay/sw-ui-overlay.html">
<link rel="import" href="sw/ui/sw-ui-button/sw-ui-button.html">
<link rel="import" href="sw/ui/sw-ui-menu/sw-ui-menu.html">

生産向けにポリマー要素を最適化する

大規模なプロジェクトでは、多数の Polymer 要素が含まれる可能性があります。私たちのプロジェクトには 50 以上あります各要素に個別の .js ファイルがあり、一部のライブラリが参照されている場合、個別のファイルの数は 100 個を超えます。つまり、ブラウザは多数のリクエストを行う必要があり、パフォーマンスは低下します。Angular ビルドに適用する連結と圧縮のプロセスと同様に、本番環境で最後に Polymer プロジェクトを「加熱」します。

Vulcanize は、依存関係ツリーをフラットにして 1 つの html ファイルに作成し、リクエストの数を減らす Polymer ツールです。これは、ウェブ コンポーネントをネイティブにサポートしていないブラウザで特に適しています。

CSP(コンテンツ セキュリティ ポリシー)と Polymer

安全なウェブ アプリケーションを開発する場合は、CSP を実装する必要があります。CSP は、クロスサイト スクリプティング(XSS)攻撃を防ぐ一連のルールです。安全でないソースからスクリプトを実行するか、HTML ファイルからインライン スクリプトを実行します。

Sense で生成され、最適化、連結、圧縮された 1 つの .html ファイルに、すべての JavaScript コードが CSP 準拠ではない形式でインライン化されます。これに対処するために、Crisper というツールを使用します。

Crisper は、HTML ファイルからインライン スクリプトを分割し、CSP 準拠のため単一の外部 JavaScript ファイルに配置します。したがって、加速度化された HTML ファイルを Crisper に渡すと、elements.htmlelements.js の 2 つのファイルが作成されます。elements.html 内で、生成された elements.js の読み込みも処理します。

アプリケーションの論理構造

Polymer の要素には、非視覚的なユーティリティから、再利用可能な小さな UI 要素(ボタンなど)から「ページ」などの大きなモジュールまで、さらにはアプリ全体の作成まで、さまざまなものがあります。

アプリケーションの最上位の論理構造
ポリマー要素で表されるアプリケーションの最上位の論理構造。

ポリマーと親子アーキテクチャによる後処理

どのような 3D グラフィック パイプラインでも、必ず最後のステップがあり、エフェクトが一種のオーバーレイとして画像全体に追加されます。これは後処理ステップであり、グロー、ゴッドレイ、被写界深度、ボケ、ブラーなどの効果が含まれます。これらの効果は、シーンの作成方法に応じてさまざまな要素に適用されます。THREE.js では、JavaScript での後処理用のカスタム シェーダーを作成できます。また、親子構造のおかげで、Polymer を使用してこれを行うことができます。

ポスト プロセッサの要素の HTML コードを見てみましょう。

<dom-module id="sw-experience-postprocessor">
    <!-- Template-->
    <template>
    <sw-experience-effect-bloom class="effect"></sw-experience-effect-bloom>
    <sw-experience-effect-dof class="effect"></sw-experience-effect-dof>
    <sw-experience-effect-vignette class="effect"></sw-experience-effect-vignette>
    </template>
    <!-- Polymer element script-->
    <script src="scripts/sw-experience-postprocessor.js"></script>
</dom-module>

効果は、共通のクラスの下にネストされた Polymer 要素として指定します。次に、sw-experience-postprocessor.js で次のように処理します。

effects = @querySelectorAll '.effect'
@composer.addPass effect.getPass() for effect in effects

HTML 機能と JavaScript の querySelectorAll を使用して、ポスト プロセッサ内で HTML 要素としてネストされているすべての効果を、指定された順序で検索します。次に、それらを反復処理して、Composer に追加します。

次に、DOF(被写界深度)効果を削除して、ブルーム効果と周辺減光効果の順序を変更します。必要な作業は、ポストプロセッサの定義を次のように編集することだけです。

<dom-module id="sw-experience-postprocessor">
    <!-- Template-->
    <template>
    <sw-experience-effect-vignette class="effect"></sw-experience-effect-vignette>
    <sw-experience-effect-bloom class="effect"></sw-experience-effect-bloom>
    </template>
    <!-- Polymer element script-->
    <script src="scripts/sw-experience-postprocessor.js"></script>
</dom-module>

実際のコードを 1 行も変更することなく、シーンが実行されるだけです。

Polymer でのレンダリング ループと更新ループ

Polymer では、レンダリングとエンジンの更新も適切に行うことができます。requestAnimationFrame を使用して、現在時刻(t)や最終フレームからの経過時間(dt)などの値を計算する timer 要素を作成しました。

Polymer
    is: 'sw-timer'

    properties:
    t:
        type: Number
        value: 0
        readOnly: true
        notify: true
    dt:
        type: Number
        value: 0
        readOnly: true
        notify: true

    _isRunning: false
    _lastFrameTime: 0

    ready: ->
    @_isRunning = true
    @_update()

    _update: ->
    if !@_isRunning then return
    requestAnimationFrame => @_update()
    currentTime = @_getCurrentTime()
    @_setT currentTime
    @_setDt currentTime - @_lastFrameTime
    @_lastFrameTime = @_getCurrentTime()

    _getCurrentTime: ->
    if window.performance then performance.now() else new Date().getTime()

次に、データ バインディングを使用して t プロパティと dt プロパティをエンジン(experience.jade)にバインドします。

sw-timer(
    t='{ % templatetag openvariable % }t}}',
    dt='{ % templatetag openvariable % }dt}}'
)

sw-experience-engine(
    t='[t]',
    dt='[dt]'
)

エンジン内の tdt の変更をリッスンし、値が変更されるたびに _update 関数が呼び出されます。

Polymer
    is: 'sw-experience-engine'

    properties:
    t:
        type: Number

    dt:
        type: Number

    observers: [
    '_update(t)'
    ]

    _update: (t) ->
    dt = @dt
    @_physics.update dt, t
    @_renderer.render dt, t

ただし、FPS が必要な場合は、レンダリング ループ内の Polymer のデータ バインディングを削除して、変更について要素に通知するために必要な数ミリ秒を節約することをおすすめします。カスタム オブザーバーを次のように実装しました。

sw-timer.coffee:

addUpdateListener: (listener) ->
    if @_updateListeners.indexOf(listener) == -1
    @_updateListeners.push listener
    return

removeUpdateListener: (listener) ->
    index = @_updateListeners.indexOf listener
    if index != -1
    @_updateListeners.splice index, 1
    return

_update: ->
    # ...
    for listener in @_updateListeners
        listener @dt, @t
    # ...

addUpdateListener 関数は、コールバックを受け取り、そのコールバック配列に保存します。次に、更新ループですべてのコールバックを反復処理し、データ バインディングやイベントの呼び出しをバイパスして、dt 引数と t 引数を直接使用して実行します。コールバックをアクティブにする必要がなくなったため、以前に追加したコールバックを削除できる removeUpdateListener 関数を追加しました。

ライトセーバー(THREE.js)

THREE.js は WebGL の下位レベルを抽象化するため、問題に集中できます。問題はストームトルーパーとの戦い。だから武器が必要だ。それではライトセーバーを作ろう。

ライトセーバーと古い両手武器は、光沢のあるブレードで見分けがついてます。主に、ビームと、それを動かすと見えるトレイルの 2 つの部分で構成されます。明るい円柱形状と、プレーヤーの移動に伴って追随する動的なトレイルを備えています。

ブレード

ブレードは 2 つのサブブレードで構成されています。内側と外側の 2 つ。 どちらも THREE.js メッシュで、それぞれの素材が使われています。

インナーブレード

インナー ブレードには、カスタム シェーダーを備えたカスタム マテリアルを使用しました。2 つの点から作成される線を引き、この 2 点を結ぶ線を平面に投影します。基本的にこの機はモバイルとの戦いの際に制御するもので、サーベルに奥行きと向きを与えます。

以下のように、2 つのポイント A と B を結ぶメインラインから平面上の任意のポイントが直交するポイントの距離を調べます。ポイントが主軸に近いほど、明るくなります。

インナーブレードのグロー

以下のソースは、vFactor を計算して頂点シェーダーの強度を制御し、それを使用してフラグメント シェーダーのシーンとブレンドする方法を示しています。

THREE.LaserShader = {

    uniforms: {
    "uPointA": {type: "v3", value: new THREE.Vector3(0, -1, 0)},
    "uPointB": {type: "v3", value: new THREE.Vector3(0, 1, 0)},
    "uColor": {type: "c", value: new THREE.Color(1, 0, 0)},
    "uMultiplier": {type: "f", value: 3.0},
    "uCoreColor": {type: "c", value: new THREE.Color(1, 1, 1)},
    "uCoreOpacity": {type: "f", value: 0.8},
    "uLowerBound": {type: "f", value: 0.4},
    "uUpperBound": {type: "f", value: 0.8},
    "uTransitionPower": {type: "f", value: 2},
    "uNearPlaneValue": {type: "f", value: -0.01}
    },

    vertexShader: [

    "uniform vec3 uPointA;",
    "uniform vec3 uPointB;",
    "uniform float uMultiplier;",
    "uniform float uNearPlaneValue;",
    "varying float vFactor;",

    "float getDistanceFromAB(vec2 a, vec2 b, vec2 p) {",

        "vec2 l = b - a;",
        "float l2 = dot( l, l );",
        "float t = dot( p - a, l ) / l2;",
        "if( t < 0.0 ) return distance( p, a );",
        "if( t > 1.0 ) return distance( p, b );",
        "vec2 projection = a + (l * t);",
        "return distance( p, projection );",

    "}",

    "vec3 getIntersection(vec4 a, vec4 b) {",

        "vec3 p = a.xyz;",
        "vec3 q = b.xyz;",
        "vec3 v = normalize( q - p );",
        "float t = ( uNearPlaneValue - p.z ) / v.z;",
        "return p + (v * t);",

    "}",

    "void main() {",

        "vec4 a = modelViewMatrix * vec4(uPointA, 1.0);",
        "vec4 b = modelViewMatrix * vec4(uPointB, 1.0);",
        "if(a.z > uNearPlaneValue) a.xyz = getIntersection(a, b);",
        "if(b.z > uNearPlaneValue) b.xyz = getIntersection(a, b);",
        "a = projectionMatrix * a; a /= a.w;",
        "b = projectionMatrix * b; b /= b.w;",
        "vec4 p = projectionMatrix * modelViewMatrix * vec4(position, 1.0);",
        "gl_Position = p;",
        "p /= p.w;",
        "float d = getDistanceFromAB(a.xy, b.xy, p.xy) * gl_Position.z;",
        "vFactor = 1.0 - clamp(uMultiplier * d, 0.0, 1.0);",

    "}"

    ].join( "\n" ),

    fragmentShader: [

    "uniform vec3 uColor;",
    "uniform vec3 uCoreColor;",
    "uniform float uCoreOpacity;",
    "uniform float uLowerBound;",
    "uniform float uUpperBound;",
    "uniform float uTransitionPower;",
    "varying float vFactor;",

    "void main() {",

        "vec4 col = vec4(uColor, vFactor);",
        "float factor = smoothstep(uLowerBound, uUpperBound, vFactor);",
        "factor = pow(factor, uTransitionPower);",
        "vec4 coreCol = vec4(uCoreColor, uCoreOpacity);",
        "vec4 finalCol = mix(col, coreCol, factor);",
        "gl_FragColor = finalCol;",

    "}"

    ].join( "\n" )

};

アウターブレードグロー

アウターグローについては、別のレンダリング バッファにレンダリングし、後処理のブルーム効果を使用して最終的な画像とブレンドすることで、希望のグローにします。下の図は、適切なサーベルが必要な場合に必要な 3 つの異なる領域を示しています。白のコア、真ん中の青みがかったグロー、アウターグローです。

外刃

ライトセーバー トレイル

ライトセーバーの軌跡は、スター・ウォーズ シリーズの原作と同様、効果を生み出すカギとなります。ライトセーバーの動きに基づいて 動的に生成される三角形の扇形を使いながらトレイルを進みましたこれらのファンはさらに視覚的な補正のためにポストプロセッサに渡されます。ファン ジオメトリを作成するため、線分があります。以前の変換と現在の変換に基づいて、メッシュ内に新しい三角形を生成し、一定長後にテール部分からドロップします。

ライトセーバー トレイル(左)
ライトセーバー トレイル(右)

メッシュを作成したら、シンプルなマテリアルを割り当て、ポストプロセッサに渡して、滑らかな効果を作り出します。次のように、外側のブレードのグローに適用したブルーム エフェクトを使用して、滑らかなトレイルを取得します。

全行程

トレイルの周りに光る

最後のピースを完成させるには、実際のトレイルの周りの光量を処理する必要がありました。これはさまざまな方法で作成できます。ここでは詳しく説明しませんが、パフォーマンス上の理由から、このバッファ用のカスタム シェーダーを作成して、レンダリング バッファのクランプの周囲にスムーズなエッジを作成するという解決策を提示しました。次に、この出力を最終的なレンダリングで結合します。ここでは、トレイルを囲む光が確認できます。

光るトレイル

まとめ

Polymer は強力なライブラリであり、コンセプトです(一般的な WebComponents と同様)。何を作るかはあなた次第です。シンプルな UI ボタンからフルサイズの WebGL アプリケーションまで、さまざまなものがあります。これまでの章では、本番環境で Polymer を効率的に使用する方法と、パフォーマンスに優れた複雑なモジュールを構成するためのヒントとコツをご紹介しました。また、WebGL で見栄えのよいライトセーバーを作成する方法も紹介しました。 したがって、これらをすべて組み合わせる場合は、本番環境サーバーにデプロイする前に Polymer 要素を Sense で加熱することを忘れないでください。CSP コンプライアンスを維持したい場合は、Crisper を使用することを忘れないでください。

ゲームプレイ