使用 Polymer 制作光剑

光剑屏幕截图

摘要

我们如何使用 Polymer 创建模块化且可配置的高性能 WebGL 移动控制光剑。我们将介绍一些关键细节 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 是一个库, 可以有效控制如何基于可重复使用的自定义元素构建项目。 借助此 API,您可以使用 单个 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 和罗盘插件来编译 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 元素。在我们的 我们的项目已经超过五十个如果您认为每个元素都有 单独的 .js 文件,并且还引用了一些库,那么它就会超过 100 个单独的文件。这意味着浏览器需要发出很多请求, 性能下降与我们对 Angular build 应用的串联和缩减流程类似,我们会在最后对 Polymer 项目进行“硫化”处理,以便用于生产环境。

Vulcanize 是一种聚合物工具, 将依存关系树扁平化为单个 HTML 文件,从而减少 请求的数量这对于 原生支持 Web 组件。

CSP(内容安全政策)和 Polymer

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

现在,Vulcanize 生成的经过优化、串联和缩减的 .html 文件包含所有 JavaScript 代码,这些代码以不符合 CSP 要求的格式内嵌在其中。为解决这一问题,我们使用 清晰

Crisper 从 HTML 文件中拆分内嵌脚本,并将它们整合成一个单独的脚本, 外部 JavaScript 文件,以确保 CSP 合规性。我们将硬化处理后 通过 Crisper 提取 HTML 文件,并最终生成两个文件:elements.htmlelements.js。在 elements.html 内,它还负责加载 生成了 elements.js

应用逻辑结构

在 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 元素的效果, 它们所属的平台。然后,我们对其进行迭代,并将其添加到 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>

场景就会直接运行,而不会更改任何实际代码。

Polymer 中的渲染循环和更新循环

利用 Polymer,我们还可以优雅地处理渲染和引擎更新。 我们创建了一个 timer 元素,该元素使用 requestAnimationFrame 并计算 值(例如当前时间 (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 函数接受回调并将其保存在其 callback 数组。然后,在更新循环中,遍历每个回调, 我们直接使用 dtt 参数执行该函数,从而绕过数据绑定或 事件触发一旦回调不再有效,我们添加了 removeUpdateListener 函数,用于移除之前添加的回调。

3REE.js 中的光剑

THREE.js 抽象化了 WebGL 的低级别细节,让我们可以专注于问题。我们的问题是与暴风兵作战,我们需要一个 武器。接下来,我们来构建一把光剑。

发光刀片是区分光剑与任何旧式光剑的区别 武器。它主要由两个部分组成:光束和移动时看到的光迹。我们用色彩鲜艳的圆柱形设计 以及跟随其移动的动态轨迹。

刀刃

叶片由两个子叶片组成。一个内层和一个外层。 两者都是 3REE.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" )

};

外侧刀片发光效果

对于外部光晕,我们会渲染到单独的渲染缓冲区,并使用后处理 bloom 效果并与最终图片混合,以获得所需的光晕。下图显示了需要设置的三个不同区域 就得着急。即白色核心、中间的蓝色发光和外部的发光。

外叶片

光剑道

光剑的轨迹是确保完整效果的关键,就像原图一样 是《星球大战》系列的一员我们利用生成的三角形 根据光剑的运动动态变化然后,这些粉丝 传递给后处理程序以进行进一步的视觉增强。要创建 扇形几何图形有一个线段,该线段基于之前的变形 以及当前转换,我们会在网格中生成一个新的三角形, 经过一定长度后从尾部部分消失。

左侧的光剑轨迹
光剑道右侧

有了网格后,我们为其分配一种简单的材质,并将其传递给 预处理器来实现流畅的效果。我们使用的盛放效果与 我们将其应用于外部叶片发光,获得平滑的轨迹,如您所看到的:

完整轨迹

在步道周围发光

为了完成最后一部分,我们必须处理实际轨迹周围的光晕,这可以通过多种方式创建。出于性能方面的原因,我们为此缓冲区创建了一个自定义着色器,该着色器会在渲染缓冲区边界附近创建平滑边缘,但我们不会在此详细介绍该解决方案。然后在最终渲染中合并这些输出 看到小路周围的光晕:

发光的小路

总结

Polymer 是一个强大的库和概念(与 WebComponents 一样 通用条款)。您可以使用它来制作各种内容,完全由您决定。可以是 为完整尺寸的 WebGL 应用添加简单的界面按钮。在前面的章节中 我们向大家展示了一些提示和技巧 以及如何构建同样可执行功能的更复杂模块, 。我们还向您展示了如何在 WebGL 中实现美观的光剑。 如果把这些都结合起来 别忘了使用硫化聚合物元素 然后再部署到生产服务器 如果您希望遵守内容安全政策,我们诚邀您加入!

游戏内容