Resumo
Como usamos o Polymer para criar um sabre de luz WebGL de alto desempenho controlado para dispositivos móveis, modular e configurável. Reunimos alguns detalhes importantes do nosso projeto https://lightsaber.withgoogle.com/ para ajudar você a economizar tempo criando seu próprio projeto na próxima vez que encontrar um grupo de Stormtroopers irritados.
Informações gerais
Se você está se perguntando o que são Polymer ou WebComponents, achamos que seria melhor começar compartilhando um trecho de um projeto real. Confira um exemplo retirado da página de destino do nosso projeto https://lightsaber.withgoogle.com. Ele é um arquivo HTML normal, mas com um pouco de magia:
<!-- 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>
Portanto, existem muitas opções hoje em dia quando se você quiser criar um aplicativo baseado em HTML5. APIs, frameworks, bibliotecas, mecanismos de jogos etc. Apesar de todas as opções, é difícil ter uma configuração que combine entre o controle sobre o alto desempenho de gráficos e a escalonabilidade e a estrutura modulares limpas. Descobrimos que a Polymer poderia nos ajudar a manter o projeto organizado e, ao mesmo tempo, permitir otimizações de desempenho de baixo nível, e elaboramos cuidadosamente a maneira como dividimos nosso projeto em componentes para aproveitar melhor os recursos da Polymer.
Modularidade com polímero
A Polymer (link em inglês) é uma biblioteca que oferece muito poder sobre como seu projeto é criado com elementos personalizados reutilizáveis. Com ele, é possível usar módulos autônomos e totalmente funcionais contidos em um único arquivo HTML. Eles contêm não apenas a estrutura (marcação HTML), mas também estilos e lógicas in-line.
Veja o exemplo abaixo:
<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>
Porém, em um projeto maior, pode ser útil separar esses três componentes lógicos (HTML, CSS, JS) e apenas mesclá-los no momento da compilação. Então, uma coisa que fizemos foi dar a cada elemento do projeto sua própria pasta separada:
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
E a pasta de cada elemento tem a mesma estrutura interna com diretórios e arquivos separados para lógica (arquivos de café), estilos (arquivos scss) e modelo (arquivo jade).
Confira um exemplo de elemento sw-ui-logo
:
sw-ui-logo/
|-- bower.json
|-- scripts
| `-- sw-ui-logo.coffee
|-- styles
| `-- sw-ui-logo.scss
`-- sw-ui-logo.jade
E se você observar o arquivo .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')
É possível conferir como tudo é organizado de maneira limpa, incluindo estilos
e lógica de arquivos separados. Para incluir estilos nos elementos Polymer,
usamos a instrução include
de Jade. Assim, temos o conteúdo real do arquivo CSS inline
após a compilação. O elemento do script sw-ui-logo.js
será executado durante a execução.
Dependências modulares com Bower
Normalmente, mantemos bibliotecas e outras dependências no nível do projeto.
No entanto, na configuração acima, você vai perceber que um bower.json
está na
pasta do elemento: dependências no nível do elemento. A ideia por trás dessa abordagem
é que, em uma situação em que você tem muitos elementos com dependências
diferentes, podemos carregar apenas as dependências que são
realmente usadas. E se você remover um elemento, não precisará se lembrar de
remover a dependência dele, porque também terá removido o arquivo bower.json
que declara essas dependências. Cada elemento carrega de maneira independente as
dependências relacionadas a ele.
No entanto, para evitar a duplicação de dependências, também incluímos um arquivo .bowerrc
na pasta de cada elemento. Isso informa ao bower onde armazenar
as dependências para que possamos garantir que haja apenas uma no final no mesmo
diretório:
{
"directory" : "../../../../../bower_components"
}
Dessa forma, se vários elementos declararem THREE.js
como uma dependência, depois que o
bower a instalar para o primeiro elemento e começar a analisar o segundo,
ele perceberá que essa dependência já está instalada e não
vai fazer o download novamente ou duplicá-la. Da mesma forma, ele manterá esses arquivos de dependência
enquanto houver pelo menos um elemento que ainda o defina
no bower.json
.
Um script bash encontra todos os arquivos bower.json
na estrutura de elementos aninhados.
Em seguida, ele insere esses diretórios um por um e executa bower install
em
cada um deles:
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
Modelo de novo elemento rápido
Toda vez que você quer criar um novo elemento pode demorar um pouco: gerar a estrutura de pastas e arquivos básica com os nomes corretos. Portanto, usamos o Slush para criar um gerador de elementos simples.
É possível chamar o script na linha de comando:
$ slush element path/to/your/element-name
E o novo elemento é criado, incluindo toda a estrutura e todo o conteúdo do arquivo.
Definimos modelos para os arquivos de elementos, por exemplo, o modelo de arquivo .jade
tem esta aparência:
// 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')
O gerador de slush substitui as variáveis por caminhos e nomes de elementos reais.
Como usar o Gulp para criar elementos
A Gulp mantém o processo de compilação sob controle. E, na nossa estrutura, para construir os elementos, precisamos que o Gulp siga estas etapas:
- Compile os arquivos
.coffee
dos elementos para.js
. - Compile os arquivos
.scss
dos elementos para.css
. - Compile os arquivos
.jade
dos elementos em.html
, incorporando os arquivos.css
.
Em mais detalhes:
Compilação dos arquivos .coffee
dos elementos em .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')));
});
Nas etapas 2 e 3, usamos gulp e um plug-in de bússola para compilar scss
para
.css
e .jade
para .html
, de maneira semelhante à etapa 2 acima.
Inclusão de elementos polimétricos
Para incluir os elementos Polymer, usamos importações 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">
Otimização de elementos Polymer para produção
Um projeto grande pode ter muitos elementos da plataforma Polymer. Em nosso projeto, temos mais de 50. Se você considerar que cada elemento tem um
arquivo .js
separado e alguns têm bibliotecas referenciadas, ele se tornará mais de
100 arquivos separados. Isso significa que o navegador precisa fazer muitas solicitações,
com perda de desempenho. De forma semelhante a um processo de concatenação e minificação que
aplicamos a um build do Angular, "vulcanizamos" o projeto Polymer
no final para produção.
Vulcanize é uma ferramenta Polymer que nivela a árvore de dependências em um único arquivo html, reduzindo o número de solicitações. Isso é especialmente ótimo para navegadores que não são compatíveis com componentes da Web de forma nativa.
CSP (Política de Segurança de Conteúdo) e Polymer
Ao desenvolver aplicativos da Web seguros, é preciso implementar a CSP. O CSP é um conjunto de regras que evitam ataques de scripting em vários sites (XSS), que são a execução de scripts de fontes não seguras ou a execução de scripts in-line de arquivos HTML.
Agora, o arquivo .html
otimizado, concatenado e minificado gerado
pelo Vulcanize tem todo o código JavaScript inline em um formato
não compatível com a CSP. Para resolver isso, usamos uma ferramenta chamada
Crisper.
A Crisper divide os scripts inline de um arquivo HTML e os coloca em um único
arquivo JavaScript externo para conformidade com a CSP. Assim, transmitimos o arquivo HTML
vulcanizado pelo Crisper e terminamos com dois arquivos: elements.html
e
elements.js
. Dentro de elements.html
, ele também cuida de carregar o
elements.js
gerado.
Estrutura lógica do aplicativo
No Polymer, os elementos podem ser qualquer coisa, desde um utilitário não visual até pequenos elementos de IU, independentes e reutilizáveis, como botões, até módulos maiores, como "páginas" e até mesmo compor aplicativos completos.
Pós-processamento com polímero e arquitetura pai-filho
Em qualquer pipeline de gráficos 3D, há sempre uma última etapa em que os efeitos são adicionados sobre a imagem inteira como uma espécie de sobreposição. Essa é a etapa de pós-processamento que envolve efeitos como brilhos, raios-divinos, profundidade de campo, bokeh, desfoques etc. Os efeitos são combinados e aplicados a diferentes elementos de acordo com a forma como a cena é criada. No THREE.js, é possível criar um sombreador personalizado para o pós-processamento em JavaScript. Se preferir, podemos fazer isso com o Polymer, graças à estrutura pai-filho.
Se observar o código HTML do elemento do nosso pós-processador:
<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>
Especificamos os efeitos como elementos Polymer aninhados em uma classe comum. Em seguida,
em sw-experience-postprocessor.js
, fazemos o seguinte:
effects = @querySelectorAll '.effect'
@composer.addPass effect.getPass() for effect in effects
Usamos o recurso HTML e o querySelectorAll
do JavaScript para encontrar todos
os efeitos aninhados como elementos HTML no pós-processador, na ordem
em que foram especificados. Em seguida, iteramos sobre elas e as adicionamos ao compositor.
Agora, digamos que você queira remover o efeito DOF (profundidade de campo) e mudar a ordem dos efeitos de flores e vinheta. Tudo o que precisamos fazer é editar a definição do pós-processador para algo como:
<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>
e a cena será executada automaticamente, sem mudar uma linha sequer do código real.
Repetição de renderização e atualização no Polymer
Com o Polymer, também podemos realizar atualizações de renderização e mecanismo de maneira elegante.
Criamos um elemento timer
que usa requestAnimationFrame
e calcula
valores como tempo atual (t
) e tempo delta - tempo decorrido desde o
último frame (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()
Em seguida, usamos a vinculação de dados para vincular as propriedades t
e dt
ao
mecanismo (experience.jade
):
sw-timer(
t='{ % templatetag openvariable % }t}}',
dt='{ % templatetag openvariable % }dt}}'
)
sw-experience-engine(
t='[t]',
dt='[dt]'
)
Além disso, detectamos as mudanças de t
e dt
no mecanismo e, sempre que os
valores mudarem, a função _update
será chamada:
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
No entanto, se você estiver com fome de QPS, remova a vinculação de dados do Polymer no loop de renderização para economizar alguns milissegundos necessários para notificar os elementos sobre as mudanças. Implementamos observadores personalizados da seguinte maneira:
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
# ...
A função addUpdateListener
aceita um callback e o salva na matriz de callbacks. Em seguida, na repetição de atualização, iteramos cada callback e
os executamos com argumentos dt
e t
diretamente, ignorando a vinculação de dados ou
o disparo de eventos. Quando um callback não precisa mais estar ativo, adicionamos uma
função removeUpdateListener
que permite remover um callback adicionado anteriormente.
Um sabre de luz em THREE.js
O THREE.js abstrai os detalhes de baixo nível do WebGL e nos permite focar no problema. E nosso problema é lutar contra os Stormtroopers e precisamos de uma arma. Então, vamos construir um sabre de luz.
A lâmina brilhante é o que diferencia um sabre de luz de qualquer arma de duas mãos antiga. Ele é composto principalmente de duas partes: a viga e a trilha, que é vista ao movê-la. Ele tem um formato de cilindro brilhante e uma trilha dinâmica que o acompanha à medida que o jogador se move.
A lâmina
A lâmina é composta por duas sub-pás. Um interno e outro externo. Ambas são malhas TRÊS.js com os respectivos materiais.
A lâmina interna
Para a lâmina interna, usamos um material personalizado com um sombreador personalizado. Pegamos uma linha criada por dois pontos e projetamos a linha entre esses dois pontos em um plano. Esse avião é basicamente o que você controla quando luta com seu dispositivo móvel. Ele dá uma sensação de profundidade e orientação ao sabre.
Para criar a sensação de um objeto brilhante redondo, observamos a distância de ponto ortogonal de qualquer ponto no plano a partir da linha principal que une os dois pontos A e B, conforme mostrado abaixo. Quanto mais próximo um ponto estiver do eixo principal, mais brilhante ele será.
A fonte abaixo mostra como computamos um vFactor
para controlar a intensidade
no sombreador de vértice e usá-lo para combinar com a cena no
sombreador de fragmentos.
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" )
};
O brilho da lâmina externa
Para o brilho externo, renderizamos em um renderbuffer separado e usamos um efeito de flor pós-processamento e mesclamos com a imagem final para ter o brilho desejado. A imagem abaixo mostra as três regiões diferentes que você precisa ter para ter um sabre decente. Ou seja, o núcleo branco, o brilho azul-médio e o brilho externo.
Trilha com sabre de luz
O rastro do sabre de luz é a chave para o efeito completo como o original visto na série Star Wars. Criamos a trilha com um leque de triângulos gerados dinamicamente com base no movimento do sabre de luz. Esses ventiladores são transmitidos ao pós-processador para ainda mais melhorias visuais. Para criar a geometria em leque, temos um segmento de linha e, com base na transformação anterior e atual, geramos um novo triângulo na malha, descartando a parte da cauda após um determinado comprimento.
Depois de criar uma malha, atribuímos um material simples a ela e o transmitimos ao pós-processador para criar um efeito suave. Usamos o mesmo efeito de flor que aplicamos ao brilho da lâmina externa e conseguimos um rastro suave, como você pode ver:
Brilho ao redor da trilha
Para que a peça final ficasse completa, tivemos que lidar com o brilho ao redor da trilha real, que poderia ser criado de várias maneiras. Nossa solução que não vamos entrar em detalhes aqui, por motivos de desempenho, foi criar um sombreador personalizado para esse buffer que cria uma borda suave em torno de um limite do buffer de renderização. Em seguida, combinamos essa saída na renderização final. Aqui é possível ver o brilho ao redor da trilha:
Conclusão
O Polymer é uma biblioteca e um conceito avançados (assim como os WebComponents são em geral). Cabe apenas a você escolher o que fazer com ele. Pode ser qualquer coisa, desde um simples botão de interface até um aplicativo WebGL de tamanho normal. Nos capítulos anteriores, mostramos algumas dicas e sugestões para usar o Polymer de forma eficiente na produção e como estruturar módulos mais complexos que também funcionam bem. Também mostramos como encontrar um sabre de luz bonito em WebGL. Se você combinar tudo isso, lembre-se de vulcanizar seus elementos do Polymer antes de implantá-los no servidor de produção. Se você não se esquecer de usar a Crisper para manter a conformidade com a CSP, talvez seja necessário ajudar.