Trasformazioni geometriche
Nell’articolo precedente abbiamo appreso i fondamenti di WebGPU disegnando un triangolo colorato. Tuttavia, per creare scene più complesse, dobbiamo essere in grado di manipolare queste forme per spostarle, ridimensionarle e ruotarle. È qui che entrano in gioco le trasformazioni geometriche.
Prendiamo come esempio una stella a 5 punte. Questa figura può essere scomposta in 10 triangoli, 5 interni che formano un pentagono più altri 5 esterni, ciascuno con la base su un lato del pentagono:
Le coordinate dei vertici sono definite in uno spazio locale, indipendente dalla scena finale che si vuole renderizzare. Questo sistema di riferimento è detto spazio dell’oggetto od object space. Una scelta tipica, che rende più intuitiva l’applicazione delle trasformazioni, è quella di organizzare i vertici in modo che l’intera figura sia centrata nell’origine e contenuta in un quadrato di lato unitario. In questo modo, senza trasformazioni nel vertex shader, se volessimo disegnare più stelle (o altre figure), tutte apparirebbero sovrapposte nel centro della scena.
Uno shader minimale che disegna la stella è il seguente:
const star = array<vec2f, 30>( // Outer star points5 collapsed lines
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 points5 collapsed lines
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 { return vec4f(star[index], 0.0, 1.0);}
@fragmentfn fs() -> @location(0) vec4f { return vec4(1, 1, 1, 1);}
L’array star
contiene le coordinate dei vertici della stella nell’object space. La geometria è definita staticamente
nel codice dello shader, ma vedremo più avanti come passare i dati in modo dinamico.
Poichè la stella è composta da 10 triangoli, la render pass invoca il vertex shader 30 volte (3 vertici per triangolo):
renderPass.draw(30);
#Funzioni di trasformazione
In matematica, una figura piana è rappresentata dall’insieme infinito dei suoi punti. Possiamo definire la stella in modo generico e astratto con il seguente insieme:
Nel piano, una trasformazione geometrica è una funzione:
che associa ad ogni punto un altro punto , detto immagine di . Traslazione, rotazione e cambio di scala sono le trasformazioni più comuni per cambiare posizione, orientamento e dimensione delle figure piane.
L’applicazione di una trasformazione ad una figura piana produce un’altra figura piana , definita come:
ossia l’insieme di tutti i punti di trasformati con . La figura è detta immagine di tramite .
#Traslazione
Per spostare la stella in una posizione diversa sul piano, possiamo sommare un vettore di offset alle coordinate dei suoi vertici:
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { let dX = 0.3; let dY = 0.2; return vec4f( star[index] + vec2f(dX, dY), 0.0, 1.0 );}
In formule:
Di seguito un widget interattivo che mostra l’effetto della traslazione in base all’offset:
Un offset positivo sposta la stella rispettivamente verso destra (asse X) o verso l’alto (asse Y), mentre un offset negativo la sposta verso sinistra o verso il basso.
Se il centro della figura nell’object space coincide con l’origine, allora il vettore di traslazione indica direttamente dove andrà a posizionarsi il centro della figura trasformata. In altre parole, traslare la figura equivale a decidere dove vogliamo si trovi che il suo centro.
#Cambio di scala
Per variare la dimensione della stella, possiamo moltiplicare le coordinate dei suoi vertici per un fattore di scala :
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { let sX = 0.5; let sY = 0.5; return vec4f( star[index] * vec2f(sX, sY), 0.0, 1.0 );}
In formule:
Di seguito un widget interattivo che mostra come la stella cambia forma e dimensione al variare del fattore di scala:
Un fattore di scala inferiore a 1 riduce la dimensione della stella, mentre un fattore maggiore di 1 la ingrandisce. Se il
fattore di scala è negativo, la stella viene riflessa rispetto all’asse corrispondente. È anche possibile applicare una scala
non uniforme utilizzando valori diversi per sX
e sY
, modificando così la dimensione della stella in modo indipendente sui due assi.
Se la figura nell’object space è contenuta in un quadrato unitario, allora applicare un fattore di scala significa che la figura scalata sarà contenuta in un rettangolo di dimensioni e .
#Rotazione
Il seguente codice implementa la rotazione:
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { let theta = radians(45.0); let cosT = cos(theta); let sinT = sin(theta); let x = star[index].x; let y = star[index].y; let rotated = vec2f( x * cosT - y * sinT, x * sinT + y * cosT );
return vec4f(rotated, 0.0, 1.0);}
La rotazione è in senso antiorario, ha come fulcro l’origine, e utilizza un angolo espresso in radianti. La formula corrispondente è:
Un angolo positivo ruota in senso antiorario, mentre un angolo negativo nel senso opposto. Di seguito un widget interattivo che mostra come la stella ruota in base all’angolo specificato:
Per dedurre la formula di rotazione, iniziamo considerando un punto e la sua rotazione attorno all’origine:
Le coordinate e sono relative agli assi , . Possiamo rendere esplicita questa dipendenza scrivendo:
Consideriamo adesso gli assi e ottenuti applicando la rotazione agli assi :
Possiamo notare che le coordinate di rispetto agli assi ruotati e sono uguali alle coordinate di rispetto agli assi originali e , cioè:
A questo punto basta calcolare ruotando gli assi .
Ricordando che e sono, per definizione, le coordinate x e y del punto ruotato di radianti in senso antiorario attorno all’origine, abbiamo immediatamente:
Per calcolare sfruttiamo il fatto che deve essere perpendicolare ad . In 2D, per trovare un vettore ortogonale a quello dato, è sufficiente scambiare le sue componenti e negarne una. Applicando questa regola otteniamo:
Avendo a disposizione le coordinate degli assi ruotati, possiamo infine calcolare le coordinate del punto ruotato:
che è la formula implementata dallo shader.
#Taglio (Shearing)
Lo shearing inclina la stella lungo uno degli assi, alterando gli angoli interni della figura senza modificarne l’area. L’effetto viene ottenuto aggiungendo alle coordinate di un asse un termine proporzionale alle coordinate dell’altro:
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { let shX = 0.5; let shY = 0.2; let x = star[index].x; let y = star[index].y;
return vec4f( x + shX * y, y + shY * x, 0.0, 1.0 );}
In formule:
Di seguito un widget interattivo che mostra l’effetto di shearing:
Un fattore positivo inclina la figura nella direzione corrispondente, mentre un fattore negativo la inclina nella direzione opposta. È possibile applicare lo shearing su uno solo degli assi o entrambi. Un aspetto interessante è che la rotazione geometrica può essere ottenuta componendo un taglio orizzontale con uno verticale.
#Composizione di trasformazioni
In pratica, raramente una singola trasformazione è sufficiente per posizionare o modificare un oggetto come desiderato. Spesso è necessario applicare una serie di trasformazioni in sequenza. Ad esempio, potremmo voler scalare un oggetto per ridimensionarlo e poi ruotarlo attorno al suo centro per orientarlo correttamente. La combinazione di trasformazioni geometriche si ottiene applicandole una dopo l’altra. Per illustrare questo processo, vediamo uno shader che applica prima la scala, poi la rotazione e infine la traslazione:
@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);}
È possibile trovare il sorgente completo di questo esempio su GitHub.
Di seguito un widget interattivo che consente di variare i parametri di traslazione, scala e rotazione:
L’ordine con cui vengono applicate le trasformazioni è fondamentale, poiché influisce sul risultato finale. Per esempio, ruotare e poi traslare una figura può dare un risultato diverso rispetto a traslare e poi ruotare.
#Conclusioni e passi successivi
In questo articolo abbiamo definito le trasformazioni geometriche più comuni. Nel prossimo vedremo come utilizzare matrici e coordinate omogenee per gestire in modo flessibile tutte le trasformazioni esaminate.