Parallaxin'

HTML5 Rocks

Introduzione

L'effetto parallasse nei siti è molto di moda nell'ultimo periodo, basta dare un'occhiata a questi:

Se non li conosci, essi sono siti in cui la struttura visuale della pagina cambia quando viene eseguito lo scroll. Generalmente gli elementi nella pagina vengono scalati, ruotati o mossi in modo proporzionale a tale scroll.

Una pagina demo
La nostra pagina demo con l'effetto di parallasse completata.

Indipendentemente dal fatto che possa piacere o no l'introduzione dell'effetto di parallasse, possiamo dire abbastanza tranquillamente che essi rappresentano un buco nero di performance. La ragione è che i browser tendono ad essere ottimizzati nel caso in cui nuovi contenuti appaiano in cima o in basso allo schermo (in base alla direzione) quando viene eseguito lo scroll e, in termini generali, essi lavorano al meglio quando visivamente non ci sono molti cambiamenti. Per un sito in parallasse questo caso è raro, dal momento in cui molte volte grandi elementi visuali cambiano su tutta la pagina, obbligando il browser a ridisegnarla interamente.

È possibile schematizzare un sito in parallasse in:

  • Elementi sullo sfondo che, durante lo scroll, cambiano la propria posizione, rotazione e dimensione.
  • Contenuto della pagina, come ad esempio testo o immagini più piccole, che scrollano nella maniera tipica dall'alto verso il basso.

Abbiamo già precedentemente trattato le performance di scrolling e le tecniche attraverso le quali è possibile migliorare la reattività della propria app. Questo articolo è stato scritto su queste basi, quindi potrebbe essere importante leggerle.

Quindi la domanda è: se stai costruendo un sito con scrolling in parallasse sei obbligato ad effettuare dispendiosi ridisegnamenti di pagine o è possibile applicare approcci alternativi così da migliorarne le prestazioni? Diamo uno sguardo alle varie opzioni.

Opzione 1: utilizzo di elementi DOM e di posizioni assolute

Questo risulta essere l'approccio di default utilizzato dalla maggior parte delle persone. C'è un mucchio di elementi nella pagina e, nel caso venga sparato un evento di scroll, vengono effettuati un mucchio di aggiornamenti visuali per trasformarli. Ho creato una pagina demo in cui viene fatto questo.

Se esegui DevTools Timeline in modalità frame ed effettui uno scroll, noterai che verranno eseguite dispendiose operazioni di ridisegnamento dell'intera pagina. Aumentando il numero di scroll potrai vedere numerosi eventi all'interno di una singola frame, ciascuno dei quali innescherà del lavoro sul layout.

Chrome DevTools senza l'evento di scroll debounce.
DevTools mostra complessi ridisegnamenti e numerosi layout event-triggered in una frame.

La cosa importante da tenere a mente è che per raggiungere i 60fps (corrispondenti al refresh rate di 60Hz di un classico monitor) abbiamo solo 16ms per fare tutto. In questa prima versione bisogna effettuare gli aggiornamenti visuali ogni volta che viene generato un evento di scroll, ma come abbiamo già discusso nei precedenti articoli leaner, meaner animations with requestAnimationFrame e scrolling performance, questo non coincide con la schedulazione degli aggiornamenti del browser e quindi potremmo sia perdere frame che fare troppo lavoro. Come risultato si ottiene un effetto innaturale sul proprio sito, che porta alla disapprovazione degli utenti e gattini tristi.

Spostiamo il codice di aggiornamento dall'evento di scroll alla callback requestAnimationFrame e catturiamo il valore dello scroll dalla callback dell'evento di scroll. La nostra seconda demo mostra questa procedura in azione.

Se viene rpetuto il test dello scroll è possibile notare un lieve miglioramento, anche se non eccessivo. La ragione è che l'operazione sul layout che viene triggerata dallo scroll non è molto dispendiosa, ma in altri casi potrebbe esserlo. Per ora stiamo esegendo una operazione di un unico layout per ciascun frame.

Chrome DevTools with debounced scroll events.
DevTools mostra grandi ridisegnamenti e molteplici event-triggered layout in un'unica frame.

È possibile gestire una o un centinaio di eventi di scroll per ciascuna frame, ma possiamo memorizzare unicamente il valore più recente per utilizzarlo quando la callback requestAnimationFrame viene eseguita e quindi effettuato l'aggiornamento della visuale. Il punto è che si è spostato dal tentativo di forzare gli aggiornamenti visuali ogni volta che un evento di scroll è rilevato alla richiedere che il browser dia una finestra appropriata in cui farlo. Non è carino?

Il problema principale con questo approccio, con requestAnimationFrame o no, è che si ha un unico livello per l'intera pagina e spostando questi elementi visuali si ha bisogno grandi (ed onerosi) ridisegnamenti. Parlando in generale il ridisegnamento è un operazione bloccante (sebben si stia cambiando), significa che il browser non può fare nient'altro e spesso si perde l'obiettivo dei 16ms e le cose rimangono pessime.

Opzione 2: Utilizzo degli elementi DOM e delle trasformazioni 3D

Invece di utilizzare la posizione assoluta un altro approccio è quello di applicare la trasformazione 3D degli elementi. In questa situazione si vede che gli elementi su cui è applicata la trasformazione 3D sono dichiarati come nuovi layer. Nell'opzione 1, di contrasto, si ha un unico grande layer per pagina che ha bisogno di essere ridisegnato quando qualcosa cambia.

Ciò significa che con questo approccio le cose sono totalmente differenti: noi abbiamo un unico layer per ciascun elemento in cui abbiamo applicato la trasformazione 3D. Se a questo punto applichiamo delle trasformazioni sugli elementi, non abbiamo più bisogno di ridisegnare il layer e la GPU può spostare gli elementi e contemporaneamente effettuare il compositing della pagina. Nel caso vi stiate domandando il perchè vengano utilizzate le trasformazioni 3D invece delle trasformazioni 2D, la ragione è che le trasformazioni 2D non garantiscono l'allocazione di un nuovo layer.

In quest'altra demo è mostrata la trasformazione 3D. Se si effettua lo scroll si vedrà che la situazione è migliorata molto.

Molte volte le persone utilizzano l'hack -webkit-transform: translateZ(0); per ottenere magici miglioramenti sulle performance, ma tale soluzione porta alcuni problemi:

  1. Non è cross-platform.
  2. Impone la creazione dei nuovi layer ad ogni elemento trasformato da parte del browser. Molti layer possono portare altri colli di bottiglia nelle performance, quindi deve essere usato con parsimonia!
  3. È stato disabilitato per alcuni port del WebKit (quarto punto dal basso!).

Quindi, alla fine questa risulta essere una soluzione altamente temporanea! In un mondo perfetto non dovremmo considerarlo e i browser sono migliorati in continuazione ed un giorno tutto questo sarebbe non necessario!

Opzione 3: Utilizzo di una canvas a posizione fissa o WebGL (opzione liscia come la seta)

L'opzione finale che consideriamo è l'utilizzo di una canvas a posizione fissa sul retro della pagina in cui andremo a disegnare le nostre immagini trasformate. A prima vista non sembra essere la soluzione più performante, ma ci sono una serie di benefici con questo approccio:

  • Non è necessario un gran lavoro di compositing poichè vi è un unico elemento.
  • Si ha a che fare con una sola bitmap hardware accelerated.
  • Le API Canvas2D sono molto adatte per il tipo di trasformazioni che vogliamo applicare, quindi lo sviluppo ed il mantenimento è maggiormente gestibile.

L'utilizzo di un elemento canvas ci fornisce un nuovo layer, ma esso è solo un layer, mentre nell'opzione 2 abbiamo fornito un nuovo layer per ogni elemento applicandogli una trasformazione 3D, quindi abbiamo un carico nel compositing di tutti questi layer.

Se si da uno sguardo alla demo per questo approccio e la si testa con DevTools, si noterà che le performance sono molto migliorate. Per questo approccio abbiamo semplicemente utilizzato la chiamata drawImage all'API di canvas, fornendo la nostra immagine di sfondo e ciascuno dei blob colorati nella giusta posizione dello schermo.


/**
 * Updates and draws in the underlying visual elements to the canvas.
 */
function updateElements () {

  var relativeY = lastScrollY / h;

  // Fill the canvas up
  context.fillStyle = "#1e2124";
  context.fillRect(0, 0, canvas.width, canvas.height);

  // Draw the background
  context.drawImage(bg, 0, pos(0, -3600, relativeY, 0));

  // Draw each of the blobs in turn
  context.drawImage(blob1, 484, pos(254, -4400, relativeY, 0));
  context.drawImage(blob2, 84, pos(954, -5400, relativeY, 0));
  context.drawImage(blob3, 584, pos(1054, -3900, relativeY, 0));
  context.drawImage(blob4, 44, pos(1400, -6900, relativeY, 0));
  context.drawImage(blob5, -40, pos(1730, -5900, relativeY, 0));
  context.drawImage(blob6, 325, pos(2860, -7900, relativeY, 0));
  context.drawImage(blob7, 725, pos(2550, -4900, relativeY, 0));
  context.drawImage(blob8, 570, pos(2300, -3700, relativeY, 0));
  context.drawImage(blob9, 640, pos(3700, -9000, relativeY, 0));

  // Allow another rAF call to be scheduled
  ticking = false;
}

/**
 * Calculates a relative disposition given the page’s scroll
 * range normalized from 0 to 1
 * @param {number} base The starting value.
 * @param {number} range The amount of pixels it can move.
 * @param {number} relY The normalized scroll value.
 * @param {number} offset A base normalized value from which to start the scroll behavior.
 * @returns {number} The updated position value.
 */
function pos(base, range, relY, offset) {
  return base + limit(0, 1, relY - offset) * range;
}

/**
 * Clamps a number to a range.
 * @param {number} min The minimum value.
 * @param {number} max The maximum value.
 * @param {number} value The value to limit.
 * @returns {number} The clamped value.
 */
function limit(min, max, value) {
  return Math.max(min, Math.min(max, value));
}

Questo approccio funziona davvero quando si ha a che fare con immagini grandi (o altri elementi che possono essere facilmente disegnati in una canvas), e sicuramente quando si ha a che fare con grandi blocchi di testo potrebbe diventare un pò più impegnativo, ma in dipendenza dal proprio sito si può dimostrare che questa sia la soluzione migliore. Se si deve avere a che fare con del testo nel canvas si può usare il metodo fillText, ma a costo dell'accessibilità (il testo viene rasterizzato in un'immagine!) e si deve avere a che fare con la sovrapposizione delle righe ed un mucchio di altri problemi. Se si può evitare, bisognerebbe farlo.

Dato che stiamo considerando questo caso come fattibile, non c'è alcuna ragione di presumere che il lavoro di parallasse debba essere fatto all'interno di un elemento canvas. Se il browser lo supporta, si potrebbe utilizzare WebGL. La chiave è che WebGL ha una strada diretta verso la scheda grafica e quindi è la migliore candidata a raggiungere i 60fps, specialmente nel caso di effetti complessi.

La prima reazione potrebbe essere che WebGL sia eccessivo, o che non sia ovunque supportato, ma se si utilizza qualcosa come Three.js allora si può sempre ripiegare sull'utilizzo di un elemento canvas ed il proprio codice risulta astratto in modo consistente e semplice. Tutto quello che bisogna fare è utilizzare Modernizr per verificare che ci sia l'appropriato supporto delle API:


// check for WebGL support, otherwise switch to canvas
if (Modernizr.webgl) {
  renderer = new THREE.WebGLRenderer();
} else if (Modernizr.canvas) {
  renderer = new THREE.CanvasRenderer();
}

Qui una demo che supporta entrambi i rendering, assumendo che anche il tuo browser lo faccia!

Infine, se non si è un gran fan di aggiungere elementi extra alla pagina, si può sempre utilizzare un canvas come sfondo sia in Firefox che nei browser Webkit-based. Questo non è sempre possibile, quindi si deve usare sempre con cautela.

Degradazione Progressiva

La principale ragione per la quale gli sviluppatori utilizzino il posizionamento assoluto degli elementi rispetto a tutte le altre opzioni potrebbe essere il tentativo di ottenere il supporto universale. Questo, in un certo senso, è illusorio, poichè i browser più vecchi possono fornire un'esperienza di rendering pessima. In più i browser moderni che utilizzano il posizionamento assoluto degli elementi non ottengono buone performance!

Potrebbe essere vantaggioso, quindi, evitare di implemetare l'effetto di parallasse anche per i vecchi browser e focalizzarsi sui browser capaci di renderizzare il sito utilizzando le API giuste. Certamente utilizzando Three.js è possibile cambiare molto facilmente tra i diversi tipi di rendering in dipendenza del supporto di cui si ha a disposizione.

Conclusioni

Sono stati valutati alcuni approcci per trattare il ridisegnamento di grandi aree, dal posizionamento assoluto degli elementi all'utilizzo dei canvas a posizione fissa. L'implementazione utilizzata, naturalmente, dipende da cosa si sta cercando di ottenere e dallo specifico design, ma è sempre buona cosa conoscere l'esistenza di altre possibilità. Nel nostro caso siamo riusciti a passare da un relativamente pessimo 30fps ad un setoso 60fps!

E come sempre, qualsiasi approccio si scelga: non domandare, testalo.

Comments

0