Riepilogo
Come abbiamo usato Polymer per creare una spada laser ad alte prestazioni controllata da dispositivi mobile, modulare e configurabile. Esaminiamo alcuni dettagli chiave del nostro progetto https://lightsaber.withgoogle.com/ per aiutarti a risparmiare tempo quando crei il tuo prossimo video in cerca di assalti infuriati.
Panoramica
Se ti stai chiedendo cosa pensi di Polymer o WebComponenti, abbiamo pensato che sarebbe meglio iniziare condividendo un estratto di un vero progetto di lavoro. Ecco un esempio tratto dalla pagina di destinazione del nostro progetto https://lightsaber.withgoogle.com. È un normale file HTML ma contiene un po' di 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>
Esistono quindi molte opzioni per creare un'applicazione basata su HTML5. API, framework, librerie, motori di gioco ecc. Nonostante tutte le scelte, è difficile ottenere una configurazione che sia una buona combinazione tra controllo su prestazioni elevate della grafica e struttura e scalabilità modulare pulite. Abbiamo scoperto che Polymer poteva aiutarci a mantenere il progetto organizzato pur consentendo ottimizzazioni delle prestazioni di basso livello. Abbiamo quindi realizzato con cura il modo in cui abbiamo suddiviso il nostro progetto in componenti per sfruttare al meglio le capacità di Polymer.
Modularità con polimero
Polymer è una libreria che offre molta potenza sul modo in cui il progetto viene creato a partire da elementi personalizzati riutilizzabili. Consente di utilizzare moduli autonomi e completamente funzionali contenuti in un singolo file HTML. Non solo contengono la struttura (markup HTML), ma anche la logica e gli stili incorporati.
Osserva il seguente esempio:
<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>
Tuttavia, in un progetto più grande potrebbe essere utile separare questi tre componenti logici (HTML, CSS, JS) e unirli solo al momento della compilazione. Una cosa che abbiamo fatto è stato assegnare a ogni elemento del progetto una cartella separata:
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
La cartella di ogni elemento ha la stessa struttura interna, con directory e file separati per logica (file caffè), stili (file scss) e modello (file jade).
Ecco un esempio di 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 esamini il file .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')
Puoi vedere come sono organizzati in modo chiaro includendo stili
e logica in file separati. Per includere i nostri stili nei nostri elementi Polymer,
utilizziamo l'istruzione include
di Jade, in modo da avere i contenuti effettivi del file CSS
incorporato dopo la compilazione. L'elemento script sw-ui-logo.js
verrà eseguito in fase di runtime.
Dipendenze modulari con Bower
Solitamente manteniamo le librerie e altre dipendenze a livello di progetto.
Tuttavia, nella configurazione precedente noterai un bower.json
nella cartella dell'elemento: dipendenze a livello di elemento. L'idea alla base di questo approccio è che, in una situazione in cui ci sono molti elementi con dipendenze diverse, possiamo assicurarci di caricare solo le dipendenze effettivamente utilizzate. Se rimuovi un elemento, non devi ricordarti di
rimuovere la sua dipendenza, in quanto avrai rimosso anche il file bower.json
che dichiara queste dipendenze. Ogni elemento carica in modo indipendente
le dipendenze.
Tuttavia, per evitare duplicati di dipendenze, includiamo un file .bowerrc
anche nella cartella di ogni elemento. Questo indica dove archiviare le dipendenze in modo da poter garantire che ne contenga solo una alla fine nella stessa directory:
{
"directory" : "../../../../../bower_components"
}
In questo modo, se più elementi dichiarano THREE.js
come dipendenza, dopo che la periferica lo installa per il primo elemento e inizia l'analisi del secondo, capirai che questa dipendenza è già installata e non verrà scaricata o duplicata di nuovo. Allo stesso modo, manterrà quei file delle dipendenze finché è presente almeno un elemento che li definisce ancora nel suo bower.json
.
Uno script bash trova tutti i file bower.json
nella struttura degli elementi nidificati.
Quindi inserisce queste directory una alla volta ed esegue bower install
in ciascuna di esse:
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
Modello Nuovo elemento rapido
Ogni volta che vuoi creare un nuovo elemento, occorre un po' di tempo: generare la cartella e la struttura di base dei file con i nomi corretti. Utilizziamo quindi Slush per scrivere un semplice generatore di elementi.
Puoi chiamare lo script dalla riga di comando:
$ slush element path/to/your/element-name
Viene così creato il nuovo elemento, inclusi tutta la struttura e i contenuti del file.
Abbiamo definito i modelli per i file di elemento, ad esempio il modello di file .jade
ha il seguente aspetto:
// 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')
Il generatore Slush sostituisce le variabili con i percorsi e i nomi effettivi degli elementi.
Utilizzo di Gulp per creare elementi
Gulp tiene sotto controllo il processo di compilazione. Nella nostra struttura, per creare gli elementi, abbiamo bisogno che Gulp segua questi passaggi:
- Compila i file
.coffee
degli elementi in.js
- Compila i file
.scss
degli elementi in.css
- Compila i file
.jade
degli elementi in.html
, incorporando i file.css
.
In modo più dettagliato:
Compilazione dei file .coffee
degli elementi in .js
in corso...
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')));
});
Per i passaggi 2 e 3 utilizziamo gulp e un plug-in per la bussola per compilare scss
in .css
e .jade
in .html
, in un approccio simile al precedente 2.
Inserimento di elementi in polimero
Per includere effettivamente gli elementi Polymer, utilizziamo le importazioni 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">
Ottimizzazione degli elementi polimerici per la produzione
Un progetto di grandi dimensioni può finire per avere molti elementi Polymer. Nel nostro progetto, ne abbiamo più di 50. Se consideri ogni elemento come un
file .js
separato e alcuni con librerie a cui viene fatto riferimento, il risultato diventa più di
100 file separati. Significa che il browser deve effettuare molte richieste,
con una perdita di prestazioni. Analogamente a un processo concatenato e minimizzato che
applichiamo a una build Angular, "vulcanizza" il progetto Polymer alla fine
per la produzione.
Vulcanize è uno strumento Polymer che unisce l'albero delle dipendenze in un unico file HTML, riducendo il numero di richieste. Ciò è particolarmente utile per i browser che non supportano i componenti web in modo nativo.
CSP (Content Security Policy) e Polymer
Quando sviluppi applicazioni web sicure, devi implementare CSP. Il CSP è un insieme di regole che impediscono gli attacchi cross-site scripting (XSS): esecuzione di script da origini non sicure o esecuzione di script incorporati da file HTML.
Ora il file .html
ottimizzato, concatenato e minimizzato generato
da Vulcanize include tutto il codice JavaScript incorporato in un formato
non conforme a CSP. Per risolvere questo problema, utilizziamo uno strumento chiamato
Crisper.
Crisper suddivide gli script incorporati da un file HTML e li inserisce in un unico file JavaScript esterno per la conformità a CSP. Quindi passiamo il file HTML vulcanizzato tramite Crisper e finiamo con due file: elements.html
e elements.js
. All'interno di elements.html
si occupa anche di caricare
il elements.js
generato.
Struttura logica dell'applicazione
In Polymer, gli elementi possono essere qualsiasi cosa, da un'utilità non visiva a elementi UI piccoli, autonomi e riutilizzabili (come i pulsanti) a moduli più grandi come "pagine" e persino alla scrittura di applicazioni complete.
Post-elaborazione con architettura polimerica e padre-figlio
In qualsiasi pipeline grafica 3D, c'è sempre un ultimo passaggio in cui gli effetti vengono aggiunti sopra l'intera immagine come una sorta di overlay. Si tratta della fase di post-elaborazione e prevede effetti come bagliori, raggi divinità, profondità di campo, bokeh, sfocature e così via. Gli effetti vengono combinati e applicati a diversi elementi a seconda di come viene creata la scena. In THREE.js potremmo creare uno shadower personalizzato per la post-elaborazione in JavaScript oppure possiamo fare questo con Polymer, grazie alla sua struttura padre-figlio.
Se guardi il codice HTML dell'elemento del nostro post-processore:
<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>
Gli effetti vengono specificati come elementi Polymer nidificati all'interno di una classe comune. Poi,
in sw-experience-postprocessor.js
facciamo questo:
effects = @querySelectorAll '.effect'
@composer.addPass effect.getPass() for effect in effects
Utilizziamo la funzionalità HTML e querySelectorAll
di JavaScript per trovare tutti gli effetti nidificati come elementi HTML all'interno del post-elaborazione, nell'ordine in cui sono stati specificati. Le ripetiamo e le aggiungiamo al compositore.
Ora, supponiamo di voler rimuovere l'effetto DOF (profondità di campo) e modificare l'ordine di fioritura e vignettatura. Non devi fare altro che modificare la definizione del post-responsabile in modo simile a questo:
<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 la scena verrà eseguita senza modificare una sola riga di codice effettivo.
Loop di rendering e di aggiornamento in Polymer
Con Polymer possiamo anche affrontare il rendering e gli aggiornamenti del motore in modo elegante.
Abbiamo creato un elemento timer
che utilizza requestAnimationFrame
e calcola valori come l'ora attuale (t
) e il tempo delta - tempo trascorso dall'ultimo 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()
Quindi, utilizziamo l'associazione di dati per associare le proprietà t
e dt
al nostro
motore (experience.jade
):
sw-timer(
t='{ % templatetag openvariable % }t}}',
dt='{ % templatetag openvariable % }dt}}'
)
sw-experience-engine(
t='[t]',
dt='[dt]'
)
Ascoltiamo le modifiche di t
e dt
nel motore e ogni volta che
i valori cambiano, la funzione _update
viene chiamata:
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 invece vuoi usare FPS, potresti rimuovere l'associazione dati di Polymer nel loop di rendering per risparmiare un paio di millisecondi necessari per notificare gli elementi alle modifiche. Abbiamo implementato gli osservatori personalizzati nel seguente modo:
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
# ...
La funzione addUpdateListener
accetta un callback e lo salva nel suo array di callback. Poi, nel loop di aggiornamento, ripetiamo ogni callback e lo eseguiamo direttamente con argomenti dt
e t
, ignorando l'associazione di dati o l'attivazione degli eventi. Quando un callback non deve più essere attivo, abbiamo aggiunto una funzione removeUpdateListener
che consente di rimuovere un callback aggiunto in precedenza.
Una spada laser in THREE.js
THREE.js astrae i dettagli di basso livello di WebGL e ci permette di concentrarci sul problema. Il nostro problema è combattere gli Stormtrooper e abbiamo bisogno di un'arma. Allora costruiamo una spada laser.
La lama incandescente è ciò che distingue una spada da una vecchia arma a due mani. È costituito principalmente da due parti: la trave e il sentiero che si vede quando la si sposta. L'abbiamo costruita con una forma cilindrica luminosa e una scia dinamica che la segue man mano che il giocatore si muove.
The Blade - La lama
La lama è costituita da due lame secondarie. Interno ed esterno. Entrambi sono mesh THREE.js con i rispettivi materiali.
The Inner Blade
Per la lama interna abbiamo usato un materiale personalizzato con uno Shaker personalizzato. Prendiamo una linea creata da due punti e progettiamo la linea tra questi due punti su un piano. Questo aereo è quello che controlli quando combatti con il tuo cellulare, dà un senso di profondità e orientamento alla sciabola.
Per creare la sensazione di un oggetto luminoso rotondo, osserviamo la distanza ortogonale di un punto qualsiasi dell'aereo dalla linea principale che unisce i due punti A e B, come indicato di seguito. Più un punto è vicino all'asse principale, più luminoso è.
L'origine riportata di seguito mostra come calcoliamo un valore vFactor
per controllare l'intensità
in vertex Shaper e poi utilizzarlo per fonderlo con la scena nel
segmento Shader.
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" )
};
Il bagliore della lama esterna
Per il bagliore esterno, eseguiamo il rendering in un buffer di rendering separato, usiamo un effetto bloom post-elaborazione e lo fondiamo con l'immagine finale per ottenere il bagliore desiderato. L'immagine qui sotto mostra le tre diverse aree che devi avere per avere una sciabola di qualità. ovvero il nucleo bianco, il bagliore blu medio e il bagliore esterno.
Sentiero con la spada laser
La scia della spada laser è la chiave di tutti gli effetti dell'originale della serie Star Wars. Abbiamo creato il sentiero con un ventaglio di triangoli generati dinamicamente in base al movimento della spada laser. Queste ventole vengono poi trasferite al postprocessore per ulteriori miglioramenti visivi. Per creare la geometria della ventola abbiamo un segmento lineare e, in base alla trasformazione e alla trasformazione di corrente precedenti, generiamo un nuovo triangolo nella rete mesh, rimuovendo la parte della coda dopo una certa lunghezza.
Una volta ottenuta una mesh, gli assegniamo un materiale semplice e lo passiamo al postprocessore per ottenere un effetto uniforme. Utilizziamo lo stesso effetto Fioritura che abbiamo applicato al bagliore della lama esterna e otteniamo una scia fluida, come puoi vedere:
Illumina il sentiero
Per completare l'ultimo pezzo, abbiamo dovuto gestire il bagliore intorno al sentiero effettivo, che potrebbe essere creato in vari modi. Per motivi legati alle prestazioni, la nostra soluzione, che non entreremo nel dettaglio, è stata quella di creare uno Shader personalizzato per questo buffer che creasse un bordo uniforme intorno a un morsetto del renderbuffer. Quindi combiniamo questo output nel rendering finale, qui puoi vedere il bagliore che circonda la traccia:
Conclusione
Polymer è una libreria e un concetto potenti (come i componenti Webcomponenti in generale). La scelta è tua. Può essere qualsiasi cosa, da un semplice pulsante dell'interfaccia utente a un'applicazione WebGL di dimensioni standard. Nei capitoli precedenti ti abbiamo mostrato alcuni suggerimenti utili su come utilizzare in modo efficiente Polymer in produzione e su come strutturare moduli più complessi con un buon rendimento. Ti abbiamo anche mostrato come ottenere una bella spada laser in WebGL. Quindi, se combini tutte queste attività, ricordati di Vulcanizzare gli elementi Polymer prima di eseguire il deployment nel server di produzione e, se non dimentichi di usare Crisper se vuoi mantenere la conformità a CSP, la forza potrebbe essere tua.