Resumo
Como usamos o Polymer para criar um sabre de luz WebGL de alto desempenho controlado por dispositivos móveis que é modular e configurável. Analisamos alguns detalhes importantes do nosso projeto https://lightsaber.withgoogle.com/ para ajudar você a economizar tempo ao criar o seu próprio da próxima vez que se deparar com um pacote de Stormtroopers bravos.
Visão geral
Se você está se perguntando o que são Polymer ou WebComponents, achamos que é melhor começar compartilhando um trecho de um projeto de trabalho real. Aqui está um exemplo retirado da página inicial do nosso projeto https://lightsaber.withgoogle.com. Está um arquivo HTML normal, mas com uma mágica dentro:
<!-- 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, há muitas opções disponíveis hoje em dia um aplicativo baseado em HTML5. APIs, frameworks, bibliotecas, mecanismos de jogos etc. Apesar de todas as opções, é difícil conseguir uma boa combinação entre o controle sobre o alto desempenho dos gráficos e o design modular limpo estrutura e escalonabilidade. Descobrimos que a Polymer poderia nos ajudar a manter o projeto organizado e, ao mesmo tempo, permitir um desempenho de baixo nível para otimizações, e nós cuidadosamente elaboramos a maneira como dividimos nosso projeto em componentes para aproveitar melhor os recursos da plataforma.
Modularidade com o Polymer
A Polymer é uma biblioteca que permite muito poder sobre como seu projeto é criado a partir de elementos personalizados reutilizáveis. Ela permite usar módulos independentes e totalmente funcionais contidos em um um único arquivo HTML. Eles contêm não apenas a estrutura (marcação HTML), mas também estilos e lógica inline.
Confira 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>
Mas, em um projeto maior, pode ser útil separar esses três conceitos (HTML, CSS, JS) e mesclá-los somente durante a compilaçã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 valores separados diretórios e arquivos de 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')
Você pode ver como as coisas estão organizadas de forma limpa, incluindo estilos
e lógica de arquivos separados. Para incluir nossos estilos em nossa plataforma
elementos, usamos a instrução include
de Jade, então temos um CSS in-line real.
o conteúdo do arquivo após a compilação. O elemento de script sw-ui-logo.js
será
executado no momento da execução.
Dependências modulares com o Bower
Normalmente, mantemos as bibliotecas e outras dependências no nível do projeto.
No entanto, na configuração acima, você vai notar que um bower.json
está no
da pasta do elemento: dependências no nível do elemento. A ideia por trás dessa abordagem
é que, em uma situação em que há muitos elementos com diferentes
podemos ter certeza de carregar apenas as dependências que estão
usados. E se você remover um elemento, não precisará se lembrar de
remover a dependência, porque você também terá removido o arquivo bower.json
.
que declara essas dependências. Cada elemento carrega independentemente
dependências relacionadas a ele.
No entanto, para evitar a duplicação de dependências, incluímos um arquivo .bowerrc
na pasta de cada elemento também. Isso informa ao vendedor onde armazenar
dependências para garantir que haja apenas um no final no mesmo
diretório:
{
"directory" : "../../../../../bower_components"
}
Dessa forma, se vários elementos declararem THREE.js
como uma dependência, uma vez
Boke o instala para o primeiro elemento e começa a analisar o segundo,
ele perceberá que a dependência já está instalada e não
faça o download novamente ou duplique-o. Da mesma forma, ele vai 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 entra nesses diretórios um por um e executa bower install
em
para 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
Cada vez que você quer criar um novo elemento demora um pouco: gerar e a estrutura básica de arquivos com os nomes corretos. Então, usamos o Slush para escrever um gerador de elementos simples.
É possível chamar o script na linha de comando:
$ slush element path/to/your/element-name
O novo elemento é criado, incluindo toda a estrutura e o conteúdo do arquivo.
Definimos modelos para os arquivos de elementos, por exemplo, o modelo de arquivo .jade
é a seguinte:
// 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
O gulp mantém o processo de build sob controle. E em nossa estrutura, para construir elementos necessários para que o Gulp siga estas etapas:
- compilar o arquivo
.coffee
arquivos para.js
- Compilar os arquivos
.scss
dos elementos para.css
- compilar o arquivo Arquivos
.jade
para.html
, incorporando os arquivos.css
.
Mais detalhes:
Compilar os elementos .coffee
arquivos para .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')));
});
Para as etapas 2 e 3, usamos o gulp e um plug-in de bússola para compilar o scss
para
.css
e .jade
para .html
, em uma abordagem semelhante ao item 2 acima.
Como incluir elementos poliméricos
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 do polímero para produção
Um projeto grande pode acabar com muitos elementos Polymer. Em nossa
projeto, temos mais de cinquenta. Se você considerar que cada elemento tem um
um arquivo .js
separado e algumas com bibliotecas referenciadas, ele se torna mais de
100 arquivos separados. Isso significa que há muitas solicitações
que o navegador precisa fazer,
com perda de desempenho. Assim como em um processo de concatenação e minificação,
seriam aplicadas a uma versão do Angular, "vulcanizamos" o projeto do
final para produção.
O Vulcanize é uma ferramenta da plataforma Polymer que nivela a árvore de dependências em um único arquivo html, reduzindo o de solicitações. Isso é ótimo para navegadores que não têm oferecem suporte nativo a componentes da Web.
CSP (Política de Segurança de Conteúdo) e Polymer
Ao desenvolver aplicativos da Web seguros, você precisa implementar o CSP. CSP é um conjunto de regras que evitam ataques de scripting em vários locais (XSS): a execução de scripts de fontes não seguras ou de scripts inline de arquivos HTML.
Agora o arquivo .html
otimizado, concatenado e minificado gerado
da Vulcanize tem todo o código JavaScript inline em uma versão não compatível com a CSP
. Para resolver isso, usamos uma ferramenta chamada
Crisper (link em inglês).
O Crisper divide scripts inline de um arquivo HTML e os coloca em um único arquivo
JavaScript externo para compliance com o CSP. Passamos a camada vulcanizada
arquivo HTML pela Crisper e acabará com dois arquivos: elements.html
e
elements.js
. Dentro de elements.html
, ele também carrega o
gerou elements.js
.
Estrutura lógica do aplicativo
No Polymer, os elementos podem ser qualquer coisa, desde um utilitário não visual até pequenos elementos de interface independentes e reutilizáveis (como botões) para módulos maiores, como "páginas" e até mesmo compondo aplicativos completos.
Pós-processamento com a arquitetura pai-filho e a plataforma Polymer
Em qualquer pipeline de gráficos 3D, há sempre uma última etapa em que os efeitos são adicionados em cima de toda a imagem como uma espécie de sobreposição. Esta é a etapa de pós-processamento e envolve efeitos como brilhos, raios-deus, profundidade de campo, bokeh, desfoques etc. Os efeitos são combinados e aplicados a diferentes elementos de acordo com como o cenário é construído. Em THREE.js, criar um shader personalizado para o pós-processamento em JavaScript ou podemos fazer isso com o Polymer, graças à sua estrutura pai-filho.
Se você 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 a querySelectorAll
do JavaScript para encontrar todos
os efeitos aninhados como elementos HTML no pós-processador, na ordem
em que foram especificados. Em seguida, fazemos iterações neles e as adicionamos ao composer.
Agora, digamos que queremos remover o efeito DOF (profundidade de campo) e alterar 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 sem alterar nenhuma linha do código real.
Loop de renderização e loop de atualização no Polymer
Com o Polymer, também podemos abordar a renderização e as atualizações de mecanismos de maneira elegante.
Criamos um elemento timer
que usa requestAnimationFrame
e calcula
valores como hora atual (t
) e tempo delta - tempo decorrido desde
ú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
à nossa
mecanismo (experience.jade
):
sw-timer(
t='{ % templatetag openvariable % }t}}',
dt='{ % templatetag openvariable % }dt}}'
)
sw-experience-engine(
t='[t]',
dt='[dt]'
)
Ouvimos mudanças de t
e dt
no mecanismo e sempre que o
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
Se você preferir QPS, convém remover os dados do Polymer vinculação no loop de renderização para economizar alguns milissegundos necessários para notificar sobre as mudanças. Implementamos observadores personalizados da seguinte forma:
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
de callbacks do sistema. Em seguida, no loop de atualização, iteramos sobre cada callback e
o executamos com os argumentos dt
e t
diretamente, ignorando a vinculação de dados ou
o disparo de eventos. Quando uma chamada de retorno não está mais ativa, adicionamos um
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 para resolver o problema. Nosso problema é combater Stormtroopers, e precisamos arma. Vamos criar um sabre de luz.
A lâmina brilhante é o que diferencia um sabre de luz de qualquer arma de duas mãos. Ela é formada principalmente por duas partes: a barra e a trilha. que aparece ao movê-lo. Nós o construímos com um formato de cilindro brilhante e uma trilha dinâmica que o segue à medida que o jogador se move.
The Blade
A lâmina é composta por duas sub-lâminas. Interno e externo. Ambas são malhas THREE.js com os respectivos materiais.
Lâmina interna
Para a lâmina interna, usamos um material personalizado com um shader personalizado. Qa pegar uma linha criada por dois pontos e projetar a linha entre esses dois em um plano. Esse plano é basicamente o que você controla quando lutam com o celular, isso dá a sensação de profundidade e orientação ao sabre.
Para criar a sensação de um objeto brilhante redondo, olhamos para o distância do ponto ortogonal de qualquer ponto do plano em relação ao ponto principal juntando os dois pontos A e B, conforme mostrado abaixo. Quanto mais próximo um ponto estiver de maior o eixo principal.
A fonte abaixo mostra como calculamos um vFactor
para controlar a intensidade
no sombreador de vértice para usá-lo na mesclagem com a cena
sombreador de fragmento.
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 buffer de renderização separado e usamos uma o efeito de flor de pós-processamento e mistura com a imagem final para obter 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 meio azul-azul e o brilho externo.
Trilha do sabre de luz
O rastro do sabre de luz é fundamental para o efeito completo, como o original visto na série "Star Wars". Fizemos a trilha com um fã de triângulos gerados dinamicamente com base no movimento do sabre de luz. Esses fãs se tornam passados para o pós-processador para aprimoramento visual. Para criar o geometria de torcedor, temos um segmento de linha e, com base em sua transformação anterior, e a transformação atual, geramos um novo triângulo na malha, descartando parte da cauda depois de um certo comprimento.
Depois de criar uma malha, atribuímos um material simples a ela e a transmitimos ao pós-processador para criar um efeito suave. Usamos o mesmo efeito de florescência que aplicamos ao brilho externo da lâmina e temos um rastro suave, como você pode ver:
Brilhe ao redor da trilha
Para a última peça ficar completa, tivemos que lidar com o brilho em torno do que pode ser criado de várias maneiras. Nossa solução que nós não entramos em detalhes aqui, já que por motivos de desempenho, era criar um para esse buffer que cria uma borda suave ao redor de um fecho do o renderbuffer. Em seguida, combinamos essa saída na renderização final. Aqui é possível veja o brilho ao redor da trilha:
Conclusão
O Polymer é uma biblioteca e um conceito poderosos (assim como os WebComponents em geral). O que você cria com ele depende de você. Pode ser qualquer coisa, um botão de IU simples para um aplicativo WebGL de tamanho normal. Nos capítulos anteriores, mostramos algumas dicas e truques sobre como usar eficientemente o Polymer em produção e como estruturar módulos mais complexos que também muito bem. Também mostramos como criar um sabre de luz bonito no WebGL. Se você combinar tudo isso, lembre-se de vulcanizar seus elementos Polymer antes de implantar no servidor de produção e, se não se esquecer de usar a Crisper se você quiser manter a conformidade com a CSP, que a força esteja com você!