使用 Polymer 制作光剑

光剑屏幕截图

摘要

我们如何使用 Polymer 打造通过 WebGL 移动控制的高性能光剑,该光剑支持模块化且可配置。我们查看了项目 (https://lightsaber.withgoogle.com/) 的一些关键细节,以帮助您节省时间,以便下次遇到一群愤怒的暴风兵时,自行创作。

概览

如果您想知道什么是 Polymer 或 WebComponent,我们认为最好先分享实际工作项目的提取内容。以下是从项目 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 文件。这会告知 bower 存储依赖项的位置,以便我们可以确保同一目录中末尾只有一个:

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

这样一来,如果多个元素将 THREE.js 声明为依赖项,那么当 Bower 为第一个元素安装该依赖项并开始解析第二个元素后,便会意识到此依赖项已安装,并且不会重新下载或复制它。同样,只要在其 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 和 compass 插件将 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 元素。在我们的项目中,有超过五十个如果认为每个元素都有单独的 .js 文件,并且有些元素引用了库,则数量就会超过 100 个单独的文件。这意味着浏览器需要发出大量请求,性能会下降。与我们在 Angular build 中应用的串联和缩减流程类似,我们会在最后将 Polymer 项目“硬化”用于生产。

Vulcanize 是一款 Polymer 工具,可将依赖关系树扁平化为单个 HTML 文件,从而减少请求数量。这对于本身不支持 Web 组件的浏览器尤为有用。

CSP(内容安全政策)和 Polymer

在开发安全的 Web 应用时,您需要实现 CSP。CSP 是一组用于防范跨站脚本攻击 (XSS) 的规则:从不安全的来源执行脚本,或从 HTML 文件执行内嵌脚本。

现在,由 Vulcanize 生成且经过优化、串联和缩减的 .html 文件的所有 JavaScript 代码都以不符合 CSP 的格式内嵌。为了解决此问题,我们使用一个名为 Crisper 的工具。

Crisper 可将内嵌脚本从 HTML 文件中拆分,并将其放入单个外部 JavaScript 文件中,以符合 CSP 要求。因此,我们通过 Crisper 传递硬化 HTML 文件,最终生成两个文件:elements.htmlelements.js。在 elements.html 内,它还负责加载生成的 elements.js

应用逻辑结构

在 Polymer 中,元素可以是任何事物,可以是非可视化的实用程序,也可以是可重复使用的小型界面元素(例如按钮),也可以是更大的模块(例如“页面”),甚至还可以组成完整的应用。

应用的顶级逻辑结构
由 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,我们还可以顺利完成渲染和引擎更新。 我们创建了一个使用 requestAnimationFrametimer 元素,并计算当前时间 (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 来控制顶点着色器中的强度,然后使用它与 fragment 着色器中的场景混合。

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 大体一样)。具体制作什么完全由您决定。它可以是简单的界面按钮,也可以是完整尺寸的 WebGL 应用。在前面的章节中,我们介绍了一些提示和技巧,了解如何在生产环境中高效地使用 Polymer,以及如何构建性能也较好的更复杂模块。我们还向您展示了如何在 WebGL 中实现精美的光剑。因此,如果您结合以上条件,请务必在将 Polymer 元素部署到生产服务器之前对其进行 Vulcanize;此外,如果您想要保持 CSP 合规,还记得使用 Crisper,那么很有优势!

游戏内容