Краткое содержание
Как мы использовали 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 выполнил следующие шаги:
- Скомпилируйте файлы
.coffee
элементов в.js
- Скомпилируйте файлы
.scss
элементов в.css
- Скомпилируйте файлы
.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 и родительско-дочерней архитектуры
В любом конвейере 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, да пребудет с вами сила!