Создание светового меча из полимера

Скриншот светового меча

Краткое содержание

Как мы использовали 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 — это библиотека, которая дает широкие возможности для создания вашего проекта из повторно используемых пользовательских элементов. Он позволяет использовать автономные полнофункциональные модули, содержащиеся в одном 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, мы используем оператор include Jade, поэтому после компиляции у нас есть фактическое встроенное содержимое 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. В нашем проекте их больше пятидесяти. Если учесть, что каждый элемент имеет отдельный файл .js , а некоторые имеют ссылки на библиотеки, то получится более 100 отдельных файлов. Это означает, что браузер должен выполнить множество запросов с потерей производительности. Подобно процессу объединения и минимизации, который мы применяем к сборке Angular, мы «вулканизируем» проект Polymer в конце для производства.

Vulcanize — это инструмент Polymer, который объединяет дерево зависимостей в один HTML-файл, сокращая количество запросов. Это особенно удобно для браузеров, которые не поддерживают веб-компоненты изначально.

CSP (Политика безопасности контента) и Polymer

При разработке безопасных веб-приложений вам необходимо реализовать CSP. CSP — это набор правил, которые предотвращают атаки с использованием межсайтовых сценариев (XSS): выполнение сценариев из небезопасных источников или выполнение встроенных сценариев из файлов HTML.

Теперь один оптимизированный, объединенный и минимизированный файл .html , созданный Vulcanize, содержит весь встроенный код JavaScript в формате, не совместимом с CSP. Для решения этой проблемы мы используем инструмент Crisper .

Crisper отделяет встроенные сценарии из файла HTML и помещает их в один внешний файл JavaScript для обеспечения соответствия CSP. Итак, мы пропускаем вулканизированный HTML-файл через Crisper и в итоге получаем два файла: elements.html и elements.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 и querySelectorAll JavaScript, чтобы найти все эффекты, вложенные в элементы 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 мы также можем элегантно подходить к рендерингу и обновлениям движка. Мы создали элемент 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()

Затем мы используем привязку данных, чтобы привязать свойства t и dt к нашему движку ( experience.jade ):

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

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

И мы слушаем изменения t и dt в движке, и всякий раз, когда значения изменяются, будет вызываться функция _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 и позволяет нам сосредоточиться на проблеме. А наша проблема — борьба со штурмовиками, и нам нужно оружие. Итак, давайте построим световой меч.

Светящийся клинок — это то, что отличает световой меч от любого старого двуручного оружия. В основном он состоит из двух частей: луча и следа, который виден при его движении. Мы создали его с яркой цилиндрической формой и динамичным следом, который следует за ним при движении игрока.

Клинок

Лезвие состоит из двух дополнительных лезвий. Внутренний и внешний. Оба представляют собой сетки 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 в целом). Только от вас зависит, что вы с ним сделаете. Это может быть что угодно: от простой кнопки пользовательского интерфейса до полноразмерного приложения WebGL. В предыдущих главах мы показали вам несколько советов и рекомендаций о том, как эффективно использовать Polymer в производстве и как структурировать более сложные модули, которые также будут хорошо работать. Мы также показали вам, как создать красивый световой меч в WebGL. Итак, если вы объедините все это, не забудьте вулканизировать свои элементы Polymer перед развертыванием на рабочем сервере, и если вы не забудете использовать Crisper, если хотите оставаться совместимым с CSP, да пребудет с вами сила!

Игра