Vai al contenuto

Animazioni 3D in SVG

Pubblicato:

Era il Lunedì di Pasquetta, e io ricevo una tremenda notizia: il cubo di Rubik sulla mensola della mia camera era stato mescolato. Le 6 facce con i loghi della QRT erano ormai irriconoscibili, e il cubo non era altro che un groviglio di tinte di azzurro e bianco. Come se non bastasse, la responsabile, mia sorella, accenna una scusa con un ironico “Ops”.
Beh, niente a cui un fratello maggiore non sia abituato. Inoltre, era da tempo che volevo mettermi alla prova imparando un algoritmo per risolvere il cubo di Rubik, e questa era l’occasione perfetta. Come se non bastasse, già in passato qualcuno mi aveva fatto trovare il cubo completamente mescolato, e quando avevo chiesto aiuto ad un collega affinché lo risolvesse, il risultato appariva corretto ad una prima analisi, ma mi era bastato osservare le facce con più attenzione per notare che alcuni quadrati centrali erano ruotati incorrettamente. Insomma, questa era l’occasione perfetta per rimuovere dalla mia mente un’idea che si era accasata lì da fin troppo tempo. Se non fosse che, dopo aver visto e riprodotto con successo uno degli algoritmi più semplici per risolvere il cubo, mi sono accorto di aver ripetuto lo stesso errore: i centri erano ruotati in maniera incorretta. Mi sono messo quindi a cercare un tutorial che mi spiegasse come risolvere questo enigma, e senza particolari difficoltà, ho trovato il video che faceva al caso mio. È proprio in quel video che noto la notazione standard per descrivere le mosse da applicare al cubo, e questo mi ispira a cercare ulteriori informazioni sull’argomento. Cosa molto più grave, mi dà immediatamente l’idea di scrivere un piccolo post in cui avrei presentato le mie scoperte, descrivendo gli algoritmi più importanti, e soprattutto accompagnando ognuno di essi con un’animazione in 3D che mostrasse come si applicano al cubo.
L’unico progetto che ho trovato online sull’argomento non mi soddisfaceva appieno, perciò ho preferito fare di testa mia. Mi aspettavo si trattasse di un’operazione piuttosto semplice. Mi sbagliavo di grosso. Si è trattato di un processo piuttosto lungo e intricato che mi ha portato a scoprire parecchie cose relative alle potenzialità del formato SVG ed ad approfondire concetti a me fino ad allora conosciuti solo per sentito dire riguardo alla computer grafica, quali proiezioni prospettiche, back-face culling e formato OBJ. In questo post, cercherò di spiegare, passo passo, tutti i miei risultati, le difficoltà che ho incontrato e le soluzioni che ho adottato.

Requisiti

Iniziamo con lo stilare una serie di requisiti che il risultato finale dovrà soddisfare:

Approccio

Definiti i requisiti, è il momento di rimboccarsi le maniche e iniziare a studiare tutte le nozioni di computer grafica necessarie a raggiungere l’obiettivo.

Lettura di un file OBJ

Il primo passaggio è quello di ottenere una rappresentazione digitale della geometria 3D del modello che vogliamo animare. Ci sono parecchi formati, anche molto complessi e potenti che descrivono in maniera dettagliata non solo i la forma dell’oggetto, ma anche le sue proprietà fisiche, come la riflettività, o la sua texture. In questo caso, volendo mantenere la semplicità dell’approccio, ho scelto di concentrarmi su un sottoinsieme di funzionalità del formato OBJ. Si tratta di un tipo di file molto semplice, che descrive in plaintext le coordinate dei vertici delle figure che compongono il modello, le facce che li collegano, e una serie di informazioni più avanzate per la rappresentazione di texture e materiali, che mi sono limitato a supportare in maniera molto basilare, solo per poter distinguere il risultato renderizzato con un po’ di colore.

# object.obj

# 'v' indica le coordinate dei vertici
v 18.12325 20.862595 15.546507
v 17.791109 22.35527 14.910569
v 18.12325 20.862595 14.813557
# 'vt' indica le coordinate della texture
vt 0.232721 0.243411 0
vt 0.233178 0.243579 0
vt 0.234037 0.243387 0
# 'f' indica le facce, che collegano i vertici
# indicati con 'v' e le coordinate della texture indicate con 'vt'
# In questo caso, la prima faccia è composta dai vertici 1, 2 e 3,
# e dalle coordinate della texture 1, 2 e 3.
# Gli indici partono da 1.
# La sintassi 'f v/vt/vn' permette di specificare anche le normali
f 1/1 2/2 3/3

Per colorare l’immagine, anche ignorando tutta la complessità legata all’illuminazione, è necessario introdurre il concetto di materiali. Questi sono spesso definiti in un file .mtl separato a cui viene fatto riferimento all’interno del file .obj con la direttiva usemtl, che indica al renderer quale materiale utilizzare per le facce che seguono.

# object.obj

# 'mtllib' indica il file che contiene la definizione dei materiali
mtllib material.mtl
# 'usemtl' indica il materiale da utilizzare per le facce che seguono
# Può essere sovrascritto da un'altra direttiva 'usemtl'
usemtl Mat
# material.mtl

# 'newmtl' indica l'inizio della definizione di un materiale, seguito dal suo nome
newmtl Mat
# 'Ns' indica la lucentezza.
# Influenza la dimensione e l'intensità dei riflessi speculari
Ns 323.999994
# 'Ka' indica il colore ambientale.
# Rappresenta la quantità di luce ambientale riflessa dall'oggetto
Ka 1.000000 1.000000 1.000000
# 'Kd' indica il colore diffuso.
# Rappresenta la quantità di luce diffusa riflessa dall'oggetto
Kd 0.048172 0.048172 0.048172
# 'Ks' indica il colore speculare.
# Rappresenta la quantità di luce speculare riflessa dall'oggetto
Ks 0.500000 0.500000 0.500000
# 'Ke' indica il colore emissivo.
# Rappresenta la quantità di luce emessa dall'oggetto
Ke 0.000000 0.000000 0.000000
# 'Ni' indica l'indice di rifrazione.
# Rappresenta la quantità di luce rifratta dall'oggetto
Ni 1.450000
# 'd' indica la trasparenza. Da 1.0 (opaco) a 0.0 (trasparente)
d 1.000000
# 'illum' indica il modello di illuminazione da utilizzare per il materiale.
# Rappresenta la combinazione di luci ambientali, diffuse e speculari
illum 2

# Alternativamente, i valori kd possono essere indicati
# tramite una texture, spesso un'immagine PNG
# Le coordinate, normalizzate, della texture da utilizzare
# per ogni vertice sono indicate dalle direttive 'vt'
newmtl Mat2
  map_Kd mat.png

Dopo aver parsaato il file OBJ di input, operazione piuttosto semplice che consiste nel procedere riga per riga seguendo le direttive incontrate ed ignorando bellamente i commenti o le funzionalità non necessarie, si ottiene una rappresentazione del modello simile a questa:

type Vec4 = [number, number, number, number];

type Point = {
  coordinates: Vec4;
};

type Shape = {
  points: Point[];
  fill: Vec4;
  stroke: Vec4;
};

function parseOBJ(obj: string): Shape[] {
  // ...
}

Il risultato è un array di Shape, ognuna delle quali rappresenta una faccia del modello, composta da un numero arbitrario di punti (generalmente 3). I Point hanno coordinate quadridimensionale, con il quarto elemento del vettore che verrà utilizzato successivamente nella proiezione prospettica ed è inizializzato a 1.

Come funziona un renderer 3D

Al fine di portare a termine l’obiettivo, è importante comprendere come funziona un renderer 3D. La mia conoscenza in materia era, e rimane, parecchio carente, ma fortunatamente ho trovato un blog meraviglioso sull’argomento. In tinyrenderer, il professor Dmitry V. Sokolov descrive in maniera molto chiara e senza tralasciare alcun dettaglio, tutti i passaggi necessari a realizzare un software per renderizzare delle immagini in 3D, partendo dalle primitive essenziali, come, ad esempio, tracciare un segmento a schermo. Non è questa la sede adatta per eviscerare tutte le spiegazioni fornite dal professore, anche perché sarebbe un lavoro derivativo e scevro della profonda conoscenza che l’autore originale dimostra di avere sull’argomento, che io ovviamente non posso vantare. Per di più, il contesto è leggermente diverso, in quanto il formato SVG ci mette a disposizione strumenti più avanzati di quelli che si hanno a disposizione scrivendo un renderer da zero in C. Inoltre, il blog a cui faccio riferimento si concentra principalmente sul rendering di un immagine statica, mentre il mio obiettivo è quello di realizzare un’animazione, il ché si traduce in una naturale, ma non per questo banale, estensione delle funzionalità del renderer di base.
Di conseguenza, mi limiterò ad evidenziare i concetti più importanti che ho imparato e che mi sono serviti nella mia impresa, sorvolando su quelli che, seppur interessanti, non sono stati fondamentali per il mio scopo, e invitando chiunque fosse interessato ad approfondire l’argomento a leggere la serie di blog post in autonomia.

Ci sono due concetti fondamentali che ho utilizzato nel progetto: back-face culling e proiezione prospettica.

Back-face culling

Il back-face culling è una tecnica utilizzata nei renderer 3D per migliorare le prestazioni, evitando di renderizzare le facce di un oggetto che non sono visibili all’osservatore. L’unica difficoltà, dunque, è quella di determinare l’orientamento della faccia. Per fortuna, è un problema facilmente risolvibile: assumendo che i vertici di ogni faccia siano ordinati in senso antiorario, è sufficiente calcolare il prodotto vettoriale fra due dei suoi lati per ottenere un vettore normale alla faccia, e poi calcolare il prodotto scalare fra questo vettore e il vettore che va dalla camera alla faccia. Se siamo già usando le coordinate della camera, il segno del prodotto vettoriale sarà sufficiente per i nostri scopi, e ci basterà non renderizzarle per salvare, potenzialmente, un sacco di risorse.

Proiezione prospettica

La parte più complessa da comprendere, invece, è stata la trafila di cambi di coordinate che bisogna effettuare per far si che il disegno finale, in due dimensioni, dia l’illusione di rappresentare un oggetto tridimensionale. Si parte con le coordinate dei vertici, definite secondo un sistema di riferimento arbitrario del file OBJ, che viene chiamato “object space”. Se volessimo mettere insieme più oggetti nella stessa scena, dovremmo preoccuparci di definire un sistema di riferimento che li metta in relazione, il “world space”. In questo caso decidiamo di semplificarci la vita, ed facciamo coincidere entrambi i sistemi di riferimento. Infine, dobbiamo proiettare le coordinate dei vertici su un piano bidimensionale, ovvero lo “screen space”.

Chiunque abbia un minimo di dimestichezza con la computer grafica sa bene che tutto ruota attorno alle matrici. Grazie a queste è possibile ottenere una formulazione compatta per applicare tutte le trasformazioni che ci interessano alle coordinate dei nostri oggetti, come rotazioni, zoom e trasformazioni di taglio, tutte trasformazioni lineari che possono essere rappresentate da una matrice

[a1,1a1,2a1,3a2,1a2,2a2,3a3,1a3,2a3,3].\begin{bmatrix} a_{1,1} & a_{1,2} & a_{1,3} \\ a_{2,1} & a_{2,2} & a_{2,3} \\ a_{3,1} & a_{3,2} & a_{3,3} \\ \end{bmatrix} .

Ad esempio

Zooms=[s000s000s]Rotθy=[cosθ0sinθ010sinθ0cosθ]\text{Zoom}_s = \begin{bmatrix} s & 0 & 0 \\ 0 & s & 0 \\ 0 & 0 & s \\ \end{bmatrix} \quad \text{Rot}^y_\theta = \begin{bmatrix} \cos \theta & 0 & \sin \theta \\ 0 & 1 & 0 \\ -\sin \theta & 0 & \cos \theta \\ \end{bmatrix}

sono le matrici di zoom e di rotazione attorno all’asse y, rispettivamente. Ciò che rende ulteriormente più comodo l’utilizzo delle matrici è il fatto che possono venire composte a piacimento: applicare prima una rotazione e poi uno zoom è equivalente ad applicare una matrice che è data dal prodotto fra le due matrici. Le cose diventano un po’ più complicate se prendiamo in considerazioni le translazioni, in quanto dovremmo andare a considerare un vettore da sommare al punto che vogliamo trasformare. Fortunatamente, esiste una rappresentazione alternativa che ci permette di correggere il tiro: basta aggiungere una quarta coordinata ai nostri punti, inizializzata a 1, e utilizzare matrici 4x4 invece di 3x3, per ottenere

[a1,1a1,2a1,3a1,4a2,1a2,2a2,3a2,4a3,1a3,2a3,3a3,40001][xyz1]=[a1,1x+a1,2y+a1,3z+a1,4a2,1x+a2,2y+a2,3z+a2,4a3,1x+a3,2y+a3,3z+a3,41].\begin{bmatrix} a_{1,1} & a_{1,2} & a_{1,3} & a_{1,4} \\ a_{2,1} & a_{2,2} & a_{2,3} & a_{2,4} \\ a_{3,1} & a_{3,2} & a_{3,3} & a_{3,4} \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \cdot \begin{bmatrix}x \\ y \\ z \\ 1\end{bmatrix} = \begin{bmatrix}a_{1,1}x + a_{1,2}y + a_{1,3}z + a_{1,4} \\ a_{2,1}x + a_{2,2}y + a_{2,3}z + a_{2,4} \\ a_{3,1}x + a_{3,2}y + a_{3,3}z + a_{3,4} \\ 1\end{bmatrix} .

Per tornare alle nostre coordinate tridimensionali, basta dividere le prime tre componenti per la quarta, assicurandoci dunque che il vettore risultante abbia sempre l’ultima componente pari a 1.

Quello che ci rimane da fare è costruire una serie di trasformazioni per passare dalle coordinate globali a quelle dello schermo. Sorvolando sui dettagli che troverete spiegati in maniera più approfondita nella pagina predisposta del blog, ci basti sapere che le quattro trasformazioni principali sono:

Viewport=[w200w20h20h20012120001]Perspective=[100001000010001f1]ModelView=[lxlylz0mxmymz0nxnynz00001][000Cx010Cy000Cz0001]n=eyecentereyecenterl=up×nup×nm=n×l\begin{array}{c} \text{Viewport} = \begin{bmatrix} \frac{w}{2} & 0 & 0 & \frac{w}{2} \\ 0 & \frac{h}{2} & 0 & \frac{h}{2} \\ 0 & 0 & \frac{1}{2} & \frac{1}{2} \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \quad \text{Perspective} = \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & -\frac{1}{f} & 1 \\ \end{bmatrix} \newline \newline \text{ModelView} = \begin{bmatrix} \overrightarrow{l}_x & \overrightarrow{l}_y & \overrightarrow{l}_z & 0 \\ \overrightarrow{m}_x & \overrightarrow{m}_y & \overrightarrow{m}_z & 0 \\ \overrightarrow{n}_x & \overrightarrow{n}_y & \overrightarrow{n}_z & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \cdot \begin{bmatrix} 0 & 0 & 0 & -C_x \\ 0 & 1 & 0 & -C_y \\ 0 & 0 & 0 & -C_z \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \newline \newline \overrightarrow{n} = \frac{\overrightarrow{eye} - \overrightarrow{center}}{|\overrightarrow{eye} - \overrightarrow{center}|} \overrightarrow{l} = \frac{\overrightarrow{up} \times \overrightarrow{n}}{|\overrightarrow{up} \times \overrightarrow{n}|} \quad \overrightarrow{m} = \overrightarrow{n} \times \overrightarrow{l} \quad \end{array}

Ci basta quindi comporre tutte queste trasformazioni per ottenere la matrice finale da applicare alle coordinate dei vertici.

Primitive in SVG

Nel contesto di SVG, la primitiva principale che ci interessa sono le Shape, ovvero delle figure chiuse definite da un certo numero di vertici connessi fra loro da segmenti retti. Queste possono essere disegnate con l’elemento <polygon>, specificando le coordinate dei vertici.

<!--
Crea un triangolo con coordinate (-1, -0.1), (2, 4) e (0.6, 0.1),
con bordo rosso di spessore 0.1 e riempimento blu
-->
<polygon
  points="-1 -.1, 2 4, .6 .1"
  stroke="red"
  stroke-width="0.1"
  fill="blue"
></polygon>

Il renderer si occuperà di tracciare le linee che collegano i punti indicati, usando il colore e spessore indicati con le proprietà stroke e stroke-width, inclusa quella che chiude la figura. L’interno del triangolo verrà riempito con un colore a nostra scelta, indicato con la proprietà fill.

In effetti, questo è tutto ciò di cui abbiamo bisogno per disegnare il modello 3D statico. Tuttavia, se vogliamo fare in modo che l’oggetto sia animato, dobbiamo utilizzare uno strumento più avanzato, ovvero la direttiva <animate>, che ci permette di modificare nel tempo le proprietà degli elementi SVG, come ad esempio la posizione dei vertici o il colore.

<!--
Anima la posizione dei vertici da (-2, -.1), (2, 4), (0.4, 0.1)
a (-1, -0.1), (3, 2), (0.7, 0.5) nell'arco di 2 secondi,
con interpolazione lineare.
L'animazione si ripete all'infinito
-->
<polygon stroke="red" stroke-width="0.1" fill="blue">
  <animate
    attributeName="points"
    dur="2s"
    repeatCount="indefinite"
    values="-2 -.1, 2 4, .4 .1; -1 -.1, 3 2, .7 .5"
  />
</polygon>

Adesso abbiamo davvero tutto il necessario per realizzare la nostra animazione. Ci basta calcolare la posizione dei vertici di ogni faccia del modello per ogni frame ed utilizzare la direttiva <animate> per animare la posizione dei vertici nel tempo. Potremmo anche essere provare ad essere particolarmente parsimoniosi e ottimizzare al massimo il numero di keyframe lasciando che l’interpolazione lineare si occupi di calcolare tutte le posizioni intermedie, anche se l’efficacia di questo approccio dipende in larga parte dal tipo di movimento che vogliamo ottenere.

Tip

Esistono anche gli elementi <animateTransform> e <animateMotion>, che permettono di animare rispettivamente le trasformazioni e i movimenti degli elementi SVG, ma in questo caso non sono adatti al nostro scopo, in quanto non ci permettono di animare la posizione dei singoli vertici, ma solo di applicare una trasformazione globale all’intero elemento.

Tuttavia, basterà provare ad applicare questa tecnica ad una rotazione qualsiasi per rendersi immediatamente conto che qualcosa non va.

I limiti di SVG

Partiamo da uno dei primi tentativi che ho costruito. Dando per scontato di avere un array con tutte le Shape che compongono il modello, possiamo applicare la trasformazione prospettica ad ognuno dei vertici e disegnare le facce con l’elemento <polygon>, animando la posizione dei vertici con la direttiva <animate>.

Animazione di un cubo di Rubik che ruota su se stesso, con le facce che si sovrappongono in maniera errata
La disposizione è da rivedere

Il problema è evidente: le facce si sovrappongono in maniera errata e delle Shape che dovrebbero essere nascoste, risultano invece visibili. Niente di troppo complicato da aggiustare. Basta applicare il back-face culling e riordinare le Shape in base alla loro distanza dalla camera, in modo da disegnare prima quelle più lontane e poi quelle più vicine, in modo da ottenere un risultato migliore. Evitare di mostrare una faccia che rimane sempre nascosta è banale, basta non disegnarla affatto. Ma come si fa a far sparire una Shape che era visibile fino al frame precedente? Un approccio semplice ma funzionale è semplicemente fare in modo che le facce nascoste “spariscano”, ovvero si riducano ad un punto. Insomma, basta impostare tutti i vertici della Shape alla stessa posizione, diciamo l’origine.

Animazione di un cubo di Rubik che ruota su se stesso, con le facce nascoste che slittano verso l'origine
Le facce slittano da e verso l'origine

Il miglioramento è netto, ma a causa dell’interpolazione, le facce non scompaiono immediatamente ed è possibile notare l’istante in cui si muovono verso l’origine, causando un leggero sfarfallio che diventa molto più evidente per le animazioni più rapide o se la faccia deve “percorrere” parecchia strada. Al costo di dover aumentare la dimensione del file finale per non sacrificare troppo la fluidità dell’animazione, possiamo risolvere il problema passando da un’interpolazione lineare ad una discreta dando all’attributo calcMode il valore discrete. In questo modo ogni keyframe dell’animazione avverrà istantaneamente, motivo per cui sarà necessario aumentarne il numero.

Animazione di un cubo di Rubik che ruota su se stesso, con le facce nascoste che scompaiono istantaneamente
Alcune facce si sovrappongono in maniera errata durante la rotazione

C’è un ultimo problema da risolvere, il più subdolo, e quello che mi aveva fatto desistere dall’impresa durante il mio primo tentativo. SVG non supporta alcun tipo di ordinamento nell’ordine in cui vengono disegnati gli elementi: non c’è uno z-index, per intenderci. Di conseguenza, l’unica regola che governa cosa verrà renderizzato sopra cosa è l’ordine in cui gli elementi sono dichiarati all’interno del file, una caratteristica che non c’è modo di alterare se non tramite codice JavaScript, che però non è ammesso dai requisiti che ci siamo prefissati. È qui che, grazie alla spinta datami da un amico e il suo Claude Code, ho trovato una soluzione funzionale: l’unica caratteristica che differenzia una Shape dall’altra è il suo colore, quindi, se vogliamo simulare un ordinamento, ci basta ordinare manualmente le facce in base alla loro distanza dalla camera, e poi assegnarle nello stesso ordine ai poligoni SVG, aggiornando non solo la posizione ma anche il colore delle stesse.

Animazione di un cubo di Rubik che ruota su se stesso, con le facce nascoste che scompaiono istantaneamente e con l'ordinamento corretto
Il risultato finale

Non avrei osato sperare in un risultato migliore. Il trucco è presto svelato: ogni figura geometrica si teletrasporta e cambia colore in continuazione, in accordo con l’ordinamento calcolato per ogni frame.

Considerazioni

Il risultato finale è sicuramente soddisfacente e riesce a soddisfare tutti i requisiti prefissati. Non dovrebbe essere difficile estenderlo per realizzare animazioni persino più complesse, con più oggetti e movimenti più articolati, nonché ottimizzare ulteriormente la dimensione del file rimuovendo keyframe superflui, come ad esempio quelli di una faccia al di fuori del campo visivo.
Rimangono inoltre alcune importanti limitazioni che bisogna tenere a mente: innanzitutto, poiché stiamo renderizzato l’intera faccia in una sola passata, non è possibile renderizzare immagini come questa

Immagine impossibile da realizzare con il nostro approccio, in cui una faccia è parzialmente nascosta da un'altra
Immagine di Dmitry V. Sokolov

senza dover fare i conti con il problema che almeno una faccia rimarrà dietro le altre, a meno di non separarla in almeno due componenti, renderizzati individualmente. Inoltre, c’è il problema delle dimensioni. Con l’aumentare dei poligoni e dei keyframe, la dimensione del file SVG prodotto cresce molto rapidamente, e potrebbe facilmente diventare ingestibile, soprattutto in un contesto, ovvero quello web, in cui ci si aspetta che le risorse siano leggere e veloci da scaricare.

In ogni caso, è stata un’esperienza decisamente istruttiva, e che magari potrei provare ad estendere in futuro. Poter mostrare un’animazione 3D piuttosto complessa in un README su Github è pur sempre una soddisfazione.

Examples

Una semplice scrivania. Sembra ci sia qualche difetto con alcune delle superfici laterali, forse causato dalla dal fatto che non tutte le facce hanno i vertici definiti nello stesso ordine.

Una macchina che prima ruota su se stessa e poi si allontana, con un effetto di zoom-out. Da notare come i finestrini appaiano solo ad una certa distanza, a causa della prospettiva.

Una strada vista dall’alto che ruota su se stessa.

Una pistola che ruota su se stessa, prima in un verso e poi nell’altro.

I file .obj utilizzati sono reperibili online gratuitamente.