使用 Polymer 建立光劍

Lightsaber 螢幕截圖

摘要

我們如何使用 Polymer 來建立可模組化及設定的高效能 WebGL 行動控制 Lightsaber。我們整理了專案 https://lightsaber.withgoogle.com/ 的部分重要詳細資料,可協助您在下次遇到種種憤怒的 Stormtroopers 時節省寶貴時間。

總覽

如果您想知道什麼是 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 進行模組化

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>

但在大型專案中,建議您將這三種邏輯元件 (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 檔案。這可告知 Boyer 將依附元件的儲存位置,以便我們確保同一個目錄中的結尾處只有一個:

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

這樣一來,如果多個元素宣告 THREE.js 為依附元件,當 Boyer 安裝第一個元素並開始剖析第二個元素後,就會知道這個依附元件已安裝,不會重新下載或複製。同樣地,只要 bower.json 中至少有一個元素仍定義該依附元件,檔案就會保留該依附元件檔案。

bash 指令碼會尋找巢狀元素結構中的所有 bower.json 檔案。然後逐一進入這些目錄,並在各個目錄中執行 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')

「Slush 產生器」會將變數替換成實際的元素路徑和名稱。

使用 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 中,我們使用 gulp 和指南針外掛程式,將 scss 編譯為 .css,並將 .jade 編譯為 .html,做法與上述 2 類似。

包含聚合物元素

要實際加入 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 元素最佳化至實際工作環境

大型專案可能會產生許多 Polymer 元素。我們的專案中有超過 50 名成員如果您認為每個元素都有獨立的 .js 檔案,而有些元素參照程式庫,檔案會變得超過 100 個獨立檔案。這表示瀏覽器需要發出大量要求,而且效能會下降。與串連及壓縮程序類似,我們將套用至 Angular 版本,我們最終會將 Polymer 專案用於實際工作環境。

Vulcanize 是一個 Polymer 工具,可將依附元件樹狀結構壓縮成單一 HTML 檔案,進而減少要求數量。特別適合未原生支援網頁元件的瀏覽器。

CSP (內容安全政策) 和 Polymer

開發安全的網頁應用程式時,您必須實作 CSP。CSP 是一組可防止跨網站指令碼 (XSS) 攻擊的規則:從不安全的來源執行指令碼,或從 HTML 檔案執行內嵌指令碼。

現在,Vulcanize 產生的 .html 檔案經過最佳化、串連和壓縮,所有 JavaScript 程式碼都是內嵌以不符合 CSP 格式的格式。為此,我們使用名為 Crisper 的工具。

Crisper 會將內嵌指令碼從 HTML 檔案分割成一個外部 JavaScript 檔案,以便遵守 CSP 規定。因此,我們會透過 Crisper 傳送隱形的 HTML 檔案,最後產生兩個檔案:elements.htmlelements.js。在 elements.html 中,系統也會載入產生的 elements.js

應用程式邏輯結構

在 Polymer 中,元素可以是非視覺公用程式,也可以是可重複使用的小型 UI 元素 (例如按鈕),到「頁面」等較大模組,甚至可組合完整應用程式。

應用程式的頂層邏輯結構
以 Polymer 元素表示的應用程式頂層邏輯結構。

使用 Polymer 和父項/子項架構進行後續處理

在任何 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 元素巢狀結構的所有效果。然後反覆疊代,並將其新增至作曲家。

現在,假設我們要移除「控制深度」(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>

場景只會執行,而無需變更一行實際程式碼。

Polymer 中的轉譯迴圈和更新迴圈

透過 Polymer,我們還能以優雅的方式進行算繪和引擎更新。我們使用 requestAnimationFrame 建立了 timer 元素,並計算目前時間 (t) 和差異時間 - 從最後一個影格 (dt) 經過的時間等值:

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

接著,我們使用資料繫結將 tdt 屬性繫結至引擎 (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 函式接受回呼,並儲存在回呼陣列中。接著,在更新迴圈中,我們會逐一處理每個回呼,並直接使用 dtt 引數執行該回呼,略過資料繫結或事件啟動程序。一旦不再啟用回呼,我們新增了 removeUpdateListener 函式,可讓您移除先前新增的回呼。

THREE.js 中的光劍

THREE.js 簡化了 WebGL 的低階細節,讓我們能專心解決問題。我們目前是對抗暴風雨者 我們需要武器我們來打造光劍吧

閃光刀片是光劍與任何舊雙手武器的區別。主要由兩個部分組成:橫束和移動時看到的步道。我們使用亮圓柱形狀來建構這個應用程式 加上隨玩家移動的動態軌跡

刀鋒

刀片是由兩個子刀片組成。內部和外型。兩者的材質皆是 THREE.js 網格。

內刀

我們對於內層的刀片使用具有自訂著色器的自訂材質。我們會採用由兩個點建立的線條,在平面上連接這兩個點之間的線。基本上,這個飛機基本上是你使用手機時可控制的元件,它可以讓你瞭解纖維的深度和方向。

如要建立圓形發光物體的感受,我們會查看平面上任何點與連接兩點 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" )

};

外部刀片光暈

針對外光,我們會轉譯至獨立的轉譯緩衝區,並使用後處理繁花效果,並與最終圖片混合,獲得想要的光暈。下圖顯示了想要購買香蕉的三個不同區域。也就是白色核心、中藍的光暈和外部光暈。

外刀片

光劍步道

光劍的步道是《星際大戰》系列中原始影片的完整效果,我們根據光劍的移動情形,動態產生了三角形的粉絲群接著,這些風扇會傳送至後置處理器進行進一步的視覺強化。為了建立粉絲幾何圖形,我們會依據其先前的轉換和目前的轉換,設定一條線段,並在一定長度之後捨棄尾端部分。

光劍步道 (左側)
光劍小徑 (右側)

掌握了網格後,我們就會為其指派簡易材料,並傳遞至郵遞處理器,以創造流暢效果。我們使用與外葉光暈相同的花卉效果,並取得順暢的步道,如下所示:

完整步道

步道周圍的光暈

最後一個部分要完成,我們得在實際軌道周圍處理光暈,以多種方式建立。但基於效能考量,我們的解決方案是為這個緩衝區建立自訂著色器,在轉譯緩衝區的取值周圍形成平滑的邊緣。然後,我們會在最終的轉譯結果中合併這些輸出內容,這樣就能看到步道圍繞著的光暈:

有發光的步道

結語

Polymer 是功能強大的程式庫和概念 (一般與 WebComponents 相同)。完全由您決定。從簡單的 UI 按鈕,到原尺寸的 WebGL 應用程式都屬於這個類別。在先前的章節中,我們展示了一些提示與秘訣,協助您瞭解如何在實際工作環境中使用 Polymer,以及如何建構較複雜的模組,而且效能良好。此外,我們也示範如何透過 WebGL 呈現美觀的光纖。 因此,如果結合所有事項,在部署至實際工作環境伺服器之前,請記得先 Vulcanize Polymer 元素。假如您想保持使用 Crisper 來遵守 CSP 規範,這可能對您造成的主力!

遊戲過程