Crea un fiume di lava incandescente che scorre usando curve e shader di Bézier

Il più delle volte, usare le tecniche grafiche convenzionali è la strada giusta da percorrere. A volte, tuttavia, la sperimentazione e la creatività ai livelli fondamentali di un effetto possono essere utili allo stile del gioco, facendolo risaltare di più. In questo tutorial ti mostrerò come creare un fiume lavico 2D animato utilizzando le curve di Bézier, la geometria strutturata personalizzata e gli shader dei vertici.

Nota: Sebbene questo tutorial sia scritto usando AS3 e Flash, dovresti essere in grado di utilizzare le stesse tecniche e concetti in quasi tutti gli ambienti di sviluppo di giochi.


Anteprima del risultato finale

Fai clic sul segno più per aprire più opzioni: puoi regolare lo spessore e la velocità del fiume e trascinare i punti di controllo e i punti di posizionamento intorno.

Senza il flash? Guarda invece il video di YouTube:


Impostare

L'implementazione demo qui sopra utilizza AS3 e Flash con Starling Framework per il rendering accelerato GPU e la libreria Feathers per gli elementi dell'interfaccia utente. Nella nostra scena iniziale metteremo un'immagine di terra e un'immagine di roccia in primo piano. Successivamente aggiungeremo un fiume, inserendolo tra questi due livelli.


Geometria

I fiumi sono formati da complessi processi naturali di interazione tra una massa fluida e il terreno sottostante. Sarebbe impraticabile eseguire una simulazione fisicamente corretta per un gioco. Vogliamo solo ottenere la giusta rappresentazione visiva, e per farlo useremo un modello semplificato di un fiume.

La modellazione del fiume come una curva è una delle soluzioni che possiamo usare, che ci consente di avere un buon controllo e di ottenere un aspetto sinuoso. Ho scelto di usare le curve quadratiche di Bézier per mantenere le cose semplici.

Le curve di Bézier sono curve parametriche usate spesso nella computer grafica; nelle curve quadratiche di Bézier, la curva passa attraverso due punti specificati e la sua forma è determinata dal terzo punto, che di solito è chiamato punto di controllo.

Come mostrato sopra, la curva passa attraverso i punti di posizione mentre il punto di controllo gestisce il percorso che prende. Ad esempio, ponendo il punto di controllo direttamente tra i punti di posizione definisce una linea retta, mentre altri valori per il punto di controllo "attraggono" la curva per avvicinarsi a quel punto.

Questo tipo di curva viene definita utilizzando la seguente formula matematica:

[latex] \ Large B (t) = (1 - t) ^ 2 P_0 + (2t - 2t ^ 2) C + t ^ 2 P_1 [/ latex]

A t = 0 siamo all'inizio della nostra curva; at = 1 siamo alla fine.

Tecnicamente useremo più curve di Bézier dove la fine di una è l'inizio dell'altra, formando una catena.

Ora dobbiamo risolvere il problema di mostrare effettivamente il nostro fiume. Le curve non hanno spessore, quindi costruiremo intorno a sé un primitivo geometrico.

Per prima cosa abbiamo bisogno di un modo per prendere la curva e convertirla in segmenti di linea. Per fare ciò prendiamo i nostri punti e li inseriamo nella definizione matematica della curva. La cosa bella di questo è che possiamo facilmente aggiungere un parametro per controllare la qualità di questa operazione.

Ecco il codice per generare i punti dalla definizione della curva:

 // Calcola il punto da quadratica Bezier espressione private function quadraticBezier (P0: Point, P1: Point, C: Point, t: Number): Point var x = (1 - t) * (1 - t) * P0.x + (2 - 2 * t) * t * Cx + t * t * P1.x; var y = (1 - t) * (1 - t) * P0.y + (2 - 2 * t) * t * C.y + t * t * P1.y; restituisce un nuovo punto (x, y); 

Ed ecco come convertire la curva in segmenti di linea:

 // Questo è un metodo che utilizza un elenco di nodi // Ogni nodo è definito come: position, control public function convertToPoints (quality: Number = 10): Vector. var points: Vector. = nuovo vettore. (); var precision: Number = 1 / quality; // Passa attraverso tutti i nodi per generare segmenti di linea per (var i: int = 0; i < _nodes.length - 1; i++)  var current:CurveNode = _nodes[i]; var next:CurveNode = _nodes[i + 1]; // Sample Bezier curve between two nodes // Number of steps is determined by quality parameter for (var step:Number = 0; step < 1; step += precision)  var newPoint:Point = quadraticBezier(current.position, next.position, current.control, step); points.push(newPoint);   return points; 

Ora possiamo prendere una curva arbitraria e convertirla in un numero personalizzato di segmenti di linea: più segmenti, maggiore è la qualità:

Per arrivare alla geometria stiamo generando due nuove curve basate su quella originale. La loro posizione e i punti di controllo saranno mossi da un normale valore di offset vettoriale, che possiamo considerare come lo spessore. La prima curva verrà spostata nella direzione negativa, mentre la seconda verrà spostata nella direzione positiva.

Ora utilizzeremo la funzione definita in precedenza per creare segmenti di linea che formano le curve. Questo formerà un contorno attorno alla curva originale.

Come lo facciamo nel codice? Dovremo calcolare le normali per i punti di controllo e di posizione, moltiplicarli per l'offset e aggiungerli ai valori originali. Per i punti di posizione dovremo interpolare normali formate da linee a punti di controllo adiacenti.

 // Iterate attraverso tutti i punti per (var i: int = 0; i < _nodes.length; i++)  var normal:Point; var surface:Point; // Normal formed by position points if (i == 0)  // First point - take normal from first line segment normal = lineNormal(_nodes[i].position, _nodes[i].control); surface = lineNormal(_nodes[i].position, _nodes[i + 1].position);  else if (i + 1 == _nodes.length)  // Last point - take normal from last line segment normal = lineNormal(_nodes[i - 1].control, _nodes[i].position); surface = lineNormal(_nodes[i - 1].position, _nodes[i].position);  else  // Middle point - take 2 normals from segments // adjecent to the point, and interpolate them normal = lineNormal(_nodes[i].position, _nodes[i].control); normal = normal.add( lineSegmentNormal(_nodes[i - 1].control, _nodes[i].position)); normal.normalize(1); // This causes a slight visual issue for thicker rivers // It can be avoided by adding more nodes surface = lineNormal(_nodes[i].position, _nodes[i + 1].position);  // Add offsets to the original node, forming a new one. nodesWithOffset.add( _nodes[i].position.x + normal.x * offset, _nodes[i].position.y + normal.y * offset, _nodes[i].control.x + surfaceNormal.x * offset, _nodes[i].control.y + surfaceNormal.y * offset ); 

Puoi già vedere che possiamo usare quei punti per definire poligoni a quattro lati piccoli - "quad". La nostra implementazione utilizza uno Starling DisplayObject personalizzato, che fornisce i nostri dati geometrici direttamente alla GPU.

Un problema, a seconda dell'implementazione, è che non possiamo inviare quad direttamente; invece, dobbiamo inviare triangoli. Ma è abbastanza facile scegliere due triangoli usando quattro punti:

Risultato:


texturing

Lo stile geometrico pulito è divertente e potrebbe anche essere un buon stile per alcuni giochi sperimentali. Ma, per rendere il nostro fiume davvero bello, potremmo fare qualche altro dettaglio. Usare una texture è una buona idea. Il che ci porta al problema di visualizzarlo sulla geometria personalizzata creata in precedenza.

Dovremo aggiungere ulteriori informazioni ai nostri vertici; le posizioni da sole non lo faranno più. Ogni vertice può memorizzare parametri aggiuntivi a nostro piacimento e per supportare la mappatura della trama sarà necessario definire le coordinate della trama.

Le coordinate della trama sono nello spazio delle texture e mappano i valori dei pixel dell'immagine nelle posizioni dei vertici del mondo. Per ogni pixel che appare sullo schermo, calcoliamo le coordinate della trama interpolata e le usiamo per cercare i valori dei pixel per le posizioni nella trama. I valori 0 e 1 nello spazio della trama corrispondono ai bordi della trama; se i valori lasciano quell'intervallo, abbiamo un paio di opzioni:

  • Ripetere - ripetere indefinitamente la trama.
  • morsetto - taglia la trama fuori dai limiti dell'intervallo [0, 1].

Coloro che conoscono un po 'la mappatura della trama sono certamente consapevoli delle possibili complessità della tecnica. Ho buone notizie per te! Questo modo di rappresentare i fiumi è facilmente mappato su una trama.

Dai lati l'altezza della trama viene mappata nella sua interezza, mentre la lunghezza del fiume viene segmentata in blocchi più piccoli dello spazio della trama, opportunamente dimensionati per la larghezza della trama.

Ora per implementarlo nel codice:

 // _texture è una trama Starling var var: Number = 0; // Iterate attraverso tutti i punti per (var i: int = 0; i < _points.length; i++)  if (i > 0) // Distanza nello spazio di trama per la distanza del segmento di linea corrente + = Distanza punti (lastPoint, _points [i]) / _texture.width;  // Assegna le coordinate della trama alla geometria _vertexData.setTexCoords (vertexId ++, distance, 0); _vertexData.setTexCoords (vertexId ++, distance, 1); 

Ora sembra molto più simile a un fiume:


Animazione

Il nostro fiume ora sembra molto più simile a un vero, con una grande eccezione: è fermo!

Ok, quindi abbiamo bisogno di animarlo. La prima cosa che si potrebbe pensare è usare l'animazione del foglio sprite. E potrebbe funzionare, ma per mantenere più flessibilità e risparmiare un po 'sulla memoria delle texture, faremo qualcosa di più interessante.

Invece di cambiare la trama, possiamo cambiare il modo in cui la trama si adatta alla geometria. Lo facciamo modificando le coordinate della trama per i nostri vertici. Questo funzionerà solo con texture piastrellabili con mappatura impostata su ripetere.

Un modo semplice per implementare questo è cambiare le coordinate della trama sulla CPU e inviare i risultati alla GPU ogni fotogramma. Di solito è un buon modo per iniziare un'implementazione di questo tipo di tecnica, poiché il debugging è molto più semplice. Tuttavia, ci addentreremo direttamente nel modo migliore per farlo: animare le coordinate della trama usando i vertex shader.

Dall'esperienza posso dire che a volte le persone sono intimidite dagli shader, probabilmente a causa della loro connessione con gli effetti grafici avanzati dei giochi di successo. A dire il vero, il concetto dietro di loro è estremamente semplice, e se puoi scrivere un programma, puoi scrivere uno shader - è tutto ciò che sono, piccoli programmi in esecuzione sulla GPU. Useremo un vertex shader per animare il nostro fiume, ci sono molti altri tipi di shader, ma possiamo fare a meno di loro.

Come suggerisce il nome, i vertex shader elaborano i vertici. Corrono per ogni vertice e prendono come attributi di vertici di input: posizione, coordinate della trama e colore.

Il nostro obiettivo è quello di compensare il valore X della coordinata della trama del fiume per simulare il flusso. Manteniamo un contatore di flusso e lo aumentiamo ogni frame di volta in volta. Possiamo specificare un parametro aggiuntivo per la velocità dell'animazione. Il valore di offset deve essere passato allo shader come valore uniforme (costante), un modo per fornire al programma shader più informazioni rispetto ai soli vertici. Questo valore è solitamente un vettore a quattro componenti; useremo solo il componente X per memorizzare il valore, mentre impostiamo Y, Z e W a 0.

 // Offset texture all'indice 5, che successivamente si fa riferimento nello shader context.setProgramConstantsFromVector (Context3DProgramType.VERTEX, 5, new [-_textureOffset, 0, 0, 0], 1);

Questa implementazione utilizza il linguaggio shader AGAL. Può essere un po 'difficile da capire, in quanto è un assemblaggio come il linguaggio. Puoi saperne di più qui.

Vertex shader:

 m44 op, va0, vc0 // Calcola la posizione del vertice mondiale mul v0, va1, vc4 // Calcola il colore del vertice // Aggiungi la coordinata della trama del vertice (va2) e la nostra costante di offset della trama (vc5): aggiungi v1, va2, vc5

Animazione in azione:


Perché fermarsi qui?

Abbiamo praticamente finito, tranne che il nostro fiume sembra ancora innaturale. Il semplice taglio tra lo sfondo e il fiume è un vero pugno nell'occhio. Per risolvere questo problema è possibile utilizzare uno strato aggiuntivo del fiume, leggermente più spesso, e una trama speciale, che si sovrapporrebbe alle sponde del fiume e coprirà la transizione brutta.

E dal momento che la demo rappresenta il fiume di lava fusa, non possiamo andare senza un po 'di bagliore! Crea un'altra istanza della geometria del fiume, ora usando una texture a luminescenza e imposta la sua modalità di fusione su "aggiungi". Per ancora più divertimento, aggiungi un'animazione fluida del valore alfa del bagliore.

Demo finale:

Certo, puoi fare molto di più dei soli fiumi usando questo tipo di effetto. L'ho visto usato per effetti di particelle fantasma, cascate o persino per catene animate. C'è molto spazio per ulteriori miglioramenti, la versione finale delle prestazioni dall'alto può essere eseguita utilizzando una chiamata di estrazione se le trame vengono unite ad un atlante. I fiumi lunghi dovrebbero essere suddivisi in più parti e abbattuti. Un'estensione importante sarebbe quella di implementare il biforcarsi dei nodi di curva per abilitare più percorsi fluviali e simulare a loro volta la biforcazione.

Sto usando questa tecnica nel nostro ultimo gioco e sono molto contento di quello che possiamo fare con esso. Lo stiamo usando per fiumi e strade (senza animazione, ovviamente). Sto pensando di usare un effetto simile per i laghi.


Conclusione

Spero di averti dato alcune idee su come pensare al di fuori delle normali tecniche grafiche, come usare fogli sprite o set di tessere per realizzare effetti come questo. Richiede un po 'più di lavoro, un po' di matematica e alcune conoscenze di programmazione GPU, ma in cambio si ottiene una maggiore flessibilità.