Skip to main content

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:

x y 1 1
La stella a 5 punte nello spazio locale dell'oggetto.

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 points
5 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 points
5 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)
);
@vertex
fn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
return vec4f(star[index], 0.0, 1.0);
}
@fragment
fn 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:

S={(x,y)R2la coppia (x,y) eˋ un punto della stella}S = \{ (x, y) \in \mathbb{R}^2 \mid \text{la coppia } (x, y) \text{ è un punto della stella} \}

Nel piano, una trasformazione geometrica è una funzione:

f:R2R2f: \mathbb{R}^2 \rightarrow \mathbb{R}^2

che associa ad ogni punto (x,y)(x, y) un altro punto (x,y)(x', y'), detto immagine di (x,y)(x, y). 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 ff ad una figura piana SS produce un’altra figura piana f(S)f(S), definita come:

f(S)={f(p)pS}f(S) = \{ f(p) \mid p \in S \}

ossia l’insieme di tutti i punti di SS trasformati con ff. La figura f(S)f(S) è detta immagine di SS tramite ff.

#Traslazione

Per spostare la stella in una posizione diversa sul piano, possiamo sommare un vettore di offset (Δx,Δy)(\Delta x, \Delta y) alle coordinate dei suoi vertici:

@vertex
fn 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:

fT(x,y)=(x+Δx,y+Δy)f_T(x, y) = (x + \Delta x, y + \Delta y)

Di seguito un widget interattivo che mostra l’effetto della traslazione in base all’offset:

x y
Modifica i parametri di trasformazione con gli slider. La stella tratteggiata è la forma originale.

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 (Δx,Δy)(\Delta x, \Delta y) 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 (sx,sy)(s_x, s_y):

@vertex
fn 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:

fS(x,y)=(xsx,ysy)f_S(x, y) = (x \cdot s_x, y \cdot s_y)

Di seguito un widget interattivo che mostra come la stella cambia forma e dimensione al variare del fattore di scala:

x y
Modifica i parametri di trasformazione con gli slider. La stella tratteggiata è la forma originale.

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 (sx,sy)(s_x, s_y) significa che la figura scalata sarà contenuta in un rettangolo di dimensioni sxs_x e sys_y.

#Rotazione

Il seguente codice implementa la rotazione:

@vertex
fn 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 θ\theta espresso in radianti. La formula corrispondente è:

fR(x,y)=(xcos(θ)ysin(θ),xsin(θ)+ycos(θ))f_R(x, y) = (x \cdot \cos (\theta) - y \cdot \sin (\theta), x \cdot \sin (\theta) + y \cdot \cos (\theta))

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:

x y
Modifica i parametri di trasformazione con gli slider. La stella tratteggiata è la forma originale.

Per dedurre la formula di rotazione, iniziamo considerando un punto P(x,y)P(x, y) e la sua rotazione Pθ(xθ,yθ)P_\theta(x_\theta, y_\theta) attorno all’origine:

θ P θ P y θ y x θ x x ^ y ^
Il punto P viene ruotato di 25° per ottenere Pθ

Le coordinate (x,y)(x, y) e (xθ,yθ)(x_\theta, y_\theta) sono relative agli assi x^=(1,0)\hat{x} = (1, 0), y^=(0,1)\hat{y} = (0, 1). Possiamo rendere esplicita questa dipendenza scrivendo:

P=xx^+yy^Pθ=xθx^+yθy^\begin{align*} P &= x \cdot \hat{x} + y \cdot \hat{y} \\ P_\theta &= x_\theta \cdot \hat{x} + y_\theta \cdot \hat{y} \end{align*}

Consideriamo adesso gli assi xθ^\hat{x_\theta} e yθ^\hat{y_\theta} ottenuti applicando la rotazione agli assi x^,y^\hat{x}, \hat{y}:

θ P θ P x θ ^ y θ ^ x ^ y ^
La rotazione applicata agli assi anzichè ai punti.

Possiamo notare che le coordinate di PθP_\theta rispetto agli assi ruotati xθ^\hat{x_\theta} e yθ^\hat{y_\theta} sono uguali alle coordinate di PP rispetto agli assi originali x^\hat{x} e y^\hat{y}, cioè:

Pθ=xxθ^+yyθ^P_\theta = x \cdot \hat{x_\theta} + y \cdot \hat{y_\theta}

A questo punto basta calcolare xθ^,yθ^\hat{x_\theta}, \hat{y_\theta} ruotando gli assi x^=(1,0),y^=(0,1)\hat{x} = (1, 0), \hat{y} = (0, 1).

Ricordando che cos(θ)\cos (\theta) e sin(θ)\sin (\theta) sono, per definizione, le coordinate x e y del punto (1,0)(1, 0) ruotato di θ\theta radianti in senso antiorario attorno all’origine, abbiamo immediatamente:

xθ^=(cos(θ),sin(θ))\hat{x_\theta} = ( \cos (\theta), \sin (\theta) )

Per calcolare yθ^\hat{y_\theta} sfruttiamo il fatto che deve essere perpendicolare ad xθ^\hat{x_\theta}. In 2D, per trovare un vettore ortogonale a quello dato, è sufficiente scambiare le sue componenti e negarne una. Applicando questa regola otteniamo:

yθ^=(sin(θ),cos(θ))\hat{y_\theta} = (-\sin (\theta), \cos (\theta))

Avendo a disposizione le coordinate degli assi ruotati, possiamo infine calcolare le coordinate del punto ruotato:

Pθ=xxθ^+yyθ^=x(cos(θ),sin(θ))+y(sin(θ),cos(θ))=(xcos(θ)ysin(θ),xsin(θ)+ycos(θ))\begin{align*} P_\theta &= x \cdot \hat{x_\theta} + y \cdot \hat{y_\theta} \\ &= x \cdot ( \cos (\theta), \sin (\theta) ) + y \cdot ( -\sin (\theta), \cos (\theta) ) \\ &= (x \cdot \cos (\theta) - y \cdot \sin (\theta), x \cdot \sin (\theta) + y \cdot \cos (\theta) ) \end{align*}

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:

@vertex
fn 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:

fSH(x,y)=(x+shxy,y+shyx)f_{SH}(x, y) = (x + sh_x \cdot y, y + sh_y \cdot x)

Di seguito un widget interattivo che mostra l’effetto di shearing:

x y
Modifica i parametri di trasformazione con gli slider. La stella tratteggiata è la forma originale.

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:

@vertex
fn 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:

x y
Modifica i parametri di trasformazione con gli slider. La stella tratteggiata è la forma originale.

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.

x y
Una stella prima ruota e poi trasla, mentre l'altra esegue le stesse trasformazioni nell'ordine inverso.

#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.