Matrici di trasformazione
Nell’articolo precedente abbiamo visto come comporre più trasformazioni applicandole in sequenza:
const star = array<vec2f, 30>(13 collapsed lines
// Outer star points vec2f(0.000000, -0.197140), vec2f(0.309011, -0.425297), vec2f(0.187467, -0.060929), vec2f(0.187467, -0.060929), vec2f(0.500000, 0.162443), vec2f(0.115866, 0.159500), vec2f(0.115866, 0.159500), vec2f(0.000000, 0.525707), vec2f(-0.115866, 0.159500), vec2f(-0.115866, 0.159500), vec2f(-0.500000, 0.162443), vec2f(-0.187467, -0.060929), vec2f(-0.187467, -0.060929), vec2f(-0.309011, -0.425297), vec2f(0.000000, -0.197140),
// Inner star points vec2f(0.000000, -0.197140), vec2f(0.000000, 0.000000), vec2f(0.187467, -0.060929), vec2f(0.187467, -0.060929), vec2f(0.000000, 0.000000), vec2f(0.115866, 0.159500), vec2f(0.115866, 0.159500), vec2f(0.000000, 0.000000), vec2f(-0.115866, 0.159500), vec2f(-0.115866, 0.159500), vec2f(0.000000, 0.000000), vec2f(-0.187467, -0.060929), vec2f(-0.187467, -0.060929), vec2f(0.000000, 0.000000), vec2f(0.000000, -0.197140));
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { // Step 1: Scala let sX = 0.5; let sY = 0.5; var transformed = vec2f( star[index].x * sX, star[index].y * sY );
// Step 2: Rotazione let theta = radians(45.0); let cosT = cos(theta); let sinT = sin(theta); transformed = vec2f( transformed.x * cosT - transformed.y * sinT, transformed.x * sinT + transformed.y * cosT );
// Step 3: Traslazione let tX = 0.2; let tY = -0.1; transformed += vec2f(tX, tY);
return vec4f(transformed, 0.0, 1.0);}
L’implementazione tuttavia è poco flessibile perchè limitata alla composizione di scala, rotazione e traslazione. Se volessimo applicare delle trasformazioni diverse, o cambiarne l’ordine, saremmo costretti a modificare ogni volta il codice sorgente.
In questo articolo definiremo le trasformazioni geometriche usando le matrici, uno strumento dell’algebra lineare. Potendo rappresentare uniformemente trasformazioni e composizioni, le matrici rendono il codice generico e riutilizzabile per qualunque sequenza di trasformazioni.
Il codice finale è disponibile nel ramo 04-matrix
del repository GitHub.
#Matrici
Una matrice rettangolare di dimensioni è una “tabella” di numeri reali:
dove è il numero di righe, il numero di colonne e sono gli elementi della matrice.
Una matrice di dimensioni è detta matrice riga:
Mentre una matrice di dimensioni è detta matrice colonna:
Infine, una matrice di dimensioni è detta matrice quadrata di ordine :
Di seguito sono mostrate in ordine una matrice rettangolare , una matrice quadrata di ordine e una matrice riga:
#Prodotto matriciale
Data una matrice di dimensioni e una matrice di dimensioni , chiamiamo prodotto tra e una matrice di dimensioni così definita:
In altre parole, l’elemento si ottiene combinando l’-esima riga della matrice con la -esima colonna della matrice . Ad esempio:
Prodotto tra due matrici 2x2. Tocca o clicca sugli elementi per evidenziare le righe/colonne coinvolte nel calcolo.
Dove i singoli elementi sono:
Il prodotto è definito solo tra matrici “compatibili”, cioè quando il numero di colonne della prima matrice coincide con il numero di righe della seconda. Il risultato avrà il numero di righe della prima matrice e di colonne della seconda. Un modo più semplice per ricordare questa regola è l’esempio:
#Trasposizione
Chiamiamo matrice trasposta o semplicemente trasposta di la matrice di dimensioni ottenuta scambiando le righe con le colonne. Ad esempio:
La trasposizione del prodotto tra due matrici equivale a trasporre le singole matrici e a scambiarne l’ordine:
Una matrice di dimensioni si dice simmetrica se , cioè se coincide con la sua trasposta:
Gli elementi sono simmetrici rispetto alla diagonale principale ().
#Matrice identità
La matrice di identità è una matrice quadrata in cui tutti gli elementi della diagonale principale valgono , mentre gli altri :
La matrice d’identità è l’elemento neutro rispetto all’operazione di prodotto, cioè:
dove è una matrice quadrata di ordine .
#Matrice inversa
Una matrice quadrata di ordine si dice invertibile se esiste una matrice quadrata di ordine tale che:
In tal caso è detta inversa di . Una matrice non invertibile è anche detta singolare.
#Rappresentazione di un punto
Nelle sezioni successive useremo le matrici per definire le funzioni di trasformazione. Affinché queste trasformazioni, espresse in forma matriciale, possano agire sui punti, è necessario rappresentare i punti stessi in un formato matematico compatibile con le operazioni tra matrici.
Un punto è rappresentato dalle sue coordinate, che possiamo indicare usando un vettore, una matrice riga o una matrice colonna:
Tutte e tre le scritture definiscono lo stesso identico concetto, la differenza è solo a livello di notazione. Pertanto, considereremo equivalenti le tre forme.
#Trasformazioni lineari
Una trasformazione lineare è una funzione nella forma:
dove sono coefficienti reali. Il nome deriva dal fatto che le coordinate trasformate sono una combinazione lineare delle coordinate del punto originale. Le trasformazioni lineari possono essere espresse come prodotto tra una matrice quadrata di ordine e una matrice colonna:
In alternativa, si può definire come prodotto tra una matrice riga e una matrice quadrata di ordine :
Nelle sezioni successive descriveremo i punti tramite matrici colonna, quindi useremo la prima forma.
Le trasformazioni lineari preservano il parallelismo tra rette, ma non gli angoli e le aree: ciò significa che rette parallele prima della trasformazione rimarranno parallele anche dopo, ma angoli formati da coppie di rette potrebbero cambiare la loro ampiezza e figure geometriche potrebbero subire una variazione della loro area.
In altre parole, se prendiamo un parallelogramma, dopo una trasformazione lineare esso rimarrà un parallelogramma, ma potrebbe non essere più un rettangolo o un quadrato. Le direzioni delle rette vengono modificate, e le distanze vengono ridimensionate in modo non uniforme, causando la distorsione degli angoli e la variazione delle aree.
#Scala
La trasformazione di scala
può essere scritta come:
Eseguendo il prodotto matriciale risulta:
#Rotazione
La rotazione
può essere scritta come:
Eseguendo il prodotto matriciale risulta:
#Taglio
La trasformazione di taglio
può essere scritta come:
Eseguendo il prodotto matriciale risulta:
#Matrici in WGSL
Il linguaggio WGSL supporta le matrici di dimensioni comprese tra e con i tipi primitivi matCxRf
, dove
è il numero di colonne ed il numero di righe. La notazione è invertita rispetto a quella matematica, dove si indica
prima il numero di righe e poi il numero di colonne. Ad esempio, il tipo mat2x4f
rappresenta una matrice (quattro righe e due colonne):
La codifica binaria dei tipi matCxRf
è per colonna (in inglese column-major): ciò significa che matCxRf
è strutturato
in memoria come un array di vettori di lunghezza . Ad esempio mat2x4f
è concettualmente equivalente a array<vec4f, 2>
:
Bisogna prestare molta attenzione a non confondere la notazione matematica delle matrici, che è per righe, con la rappresentazione binaria in WGSL, che è per colonne. Per capire meglio questo concetto, esaminiamo una matrice non simmetrica, come la matrice di taglio:
Il costruttore di mat2x2f
segue la rappresentazione column-major e quindi accetta gli elementi per colonna e non per riga:
fn shearMatrix(shx: f32, shy: f32) -> mat2x2f { return mat2x2f(1, shy, shx, 1);}
fn shearMatrixTransposed(shx: f32, shy: f32) -> mat2x2f { return mat2x2f( 1, shx, shy, 1 );}
L’indentazione della funzione shearMatrixTransposed
ricalca visualmente la notazione matematica, che è per righe, ma il risultato in memoria sarà la matrice trasposta:
Se avessimo considerato una matrice simmetrica, non ci sarebbe stata alcuna differenza, poiché, per definizione, la trasposta di una matrice simmetrica coincide con la matrice stessa.
#Prodotto in WGSL
Il prodotto in WGSL è definito sia tra matrici che tra matrici e vettori. Possiamo aggiornare il nostro shader di esempio sostituendo le trasformazioni di rotazione e scala con le matrici:
fn scaleMatrix(sX: f32, sY: f32) -> mat2x2f { return mat2x2f( sX, 0, 0, sY );}
fn rotationMatrix(theta: f32) -> mat2x2f { let cosT = cos(theta); let sinT = sin(theta); return mat2x2f( cosT, sinT, -sinT, cosT );}
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { // Step 1: Scala var transformed = scaleMatrix(0.5, 0.5) * star[index];
// Step 2: Rotazione transformed = rotationMatrix(radians(45.0)) * transformed;
// Step 3: Traslazione let tX = 0.2; let tY = -0.1; transformed += vec2f(tX, tY);
return vec4f(transformed, 0.0, 1.0);}
Possiamo notare un primo vantaggio dell’uso delle matrici: il codice per applicare due trasformazioni diverse, scala e rotazione, è sempre un prodotto matriciale.
Le funzioni scaleMatrix
e rotationMatrix
costruiscono le matrici da applicare nella forma , dove è un vettore colonna.
Il codice dello shader segue questa convenzione e applica i prodotti nella forma M * p
anziché p * M
.
In generale, in WGSL i vettori non sono considerati nè righe nè colonne, tuttavia entrambe le espressioni M * p
e p * M
sono valide.
Affinché il prodotto sia sempre ben definito, i vettori vengono interpretati di volta in volta come righe o colonne in base all’ordine
in cui compaiono nel prodotto con una matrice. Le due espressioni infatti vengono calcolate nel seguente modo:
I due risultati non differiscono solo per il fatto di essere un vettore riga piuttosto che colonna (irrilevante in WGSL), ma gli elementi in sè sono differenti! Per questo motivo è fondamentale stabilire una convenzione e applicarla sempre allo stesso modo.
Come per l’esempio di codice mostrato, nelle sezioni che seguono adotteremo sempre le matrici da usare nella forma M * p
,
facendo attenzione al layout column-major nei costruttori matCxRf
.
#Composizione di trasformazioni
La versione aggiornata dello shader implementa la formula:
dove è la matrice di rotazione, è la matrice di scala, è il vertice da trasformare e è il vettore di traslazione. La sequenza logica dei passaggi è:
- Il punto viene scalato tramite prodotto matriciale con , ottenendo un primo punto intermedio.
- Il punto appena calcolato viene ruotato tramite prodotto matriciale con , ottenendo così il secondo punto intermedio.
- Infine, il secondo punto intermedio viene traslato tramite somma vettoriale con , ottenendo così il risultato finale.
In particolare, le trasformazioni ed vengono eseguite in due passaggi distinti; vediamo ora com’è possibile unificarli. In generale, il prodotto tra matrici gode della proprietà associativa, ovvero:
Sfruttando tale proprietà nella formula dello shader ( è una matrice colonna), possiamo scrivere:
Posto possiamo semplificare in:
Il risultato è significativo: la singola matrice è equivalente alle trasformazioni ed in sequenza, cioè ne rappresenta la composizione. A questo punto possiamo semplificare ulteriormente il codice dello shader:
fn scaleMatrix(sX: f32, sY: f32) -> mat2x2f {4 collapsed lines
return mat2x2f( sX, 0, 0, sY );}
fn rotationMatrix(theta: f32) -> mat2x2f {6 collapsed lines
let cosT = cos(theta); let sinT = sin(theta); return mat2x2f( cosT, sinT, -sinT, cosT );}
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { var worldTransform = rotationMatrix(radians(45.0)) * scaleMatrix(0.5, 0.5);
// Step 1: Scala + Rotazione var transformed = worldTransform * star[index];
// Step 2: Traslazione let tX = 0.2; let tY = -0.1; transformed += vec2f(tX, tY);
return vec4f(transformed, 0.0, 1.0);}
Il vantaggio di questo approccio può apparire limitato nel nostro esempio, dato che il numero complessivo di operazioni
non cambia. Tuttavia, nei casi reali worldTransform
è solitamente un parametro dello shader e, di conseguenza,
non viene ricalcolato per ogni vertice.
Generalizzando, supponiamo di voler applicare nell’ordine una serie di trasformazioni lineari ad un punto :
Sfruttando ripetutamente la proprietà associativa, possiamo rappresentare la composizione tramite una singola matrice e utilizzarla direttamente:
Così facendo, la singola matrice codifica la sequenza originale, astraendo il vertex shader da tipo, numero e ordine delle trasformazioni da applicare. 😮😱
A differenza del prodotto tra numeri reali, il prodotto tra matrici quadrate generalmente non gode della proprietà commutativa, cioè . L’ordine in cui vengono eseguite le trasformazioni è rilevante, così come avevamo visto intuitivamente nell’articolo precedente. È importante notare che l’ordine in cui le matrici compaiono nella definizione di è l’inverso dell’ordine con cui vengono “applicate” le trasformazioni. Il prodotto è equivalente a trasformare prima con , poi con e così via.
#Conclusioni
In questo articolo abbiamo introdotto le matrici e alcune loro proprietà. Abbiamo visto che scala, rotazione e taglio sono trasformazioni lineari e quindi possono essere espresse nella forma , dove è una matrice quadrata di ordine .
Tuttavia, fin’ora nella trattazione abbiamo intenzionalmente lasciato da parte la traslazione. Come vedremo infatti, non è possibile rappresentare una traslazione nel piano con le matrici quadrate di ordine !
Come risolvere questo problema? La risposta nel prossimo articolo, dedicato a spazi proiettivi e coordinate omogenee.