Creëer een gloeiende, stromende Lava-rivier met Bézier-curven en -schilfers

Meestal is het gebruik van conventionele grafische technieken de juiste manier om te gaan. Soms kunnen experimenten en creativiteit op de fundamentele niveaus van een effect echter gunstig zijn voor de stijl van het spel, waardoor het meer opvalt. In deze tutorial laat ik je zien hoe je een geanimeerde 2D-lavarivier maakt met behulp van Bézier-curven, aangepaste textuurgeometrie en vertex shaders.

Notitie: Hoewel deze tutorial geschreven is met behulp van AS3 en Flash, zou je in bijna elke game-ontwikkelomgeving dezelfde technieken en concepten moeten kunnen gebruiken.


Eindresultaat voorbeeld

Klik op het plusteken om meer opties te openen: u kunt de dikte en snelheid van de rivier aanpassen en de besturingspunten en positiepunten rond slepen.

Geen flash? Bekijk in plaats daarvan de YouTube-video:


Opstelling

De demo-implementatie hierboven gebruikt AS3 en Flash met Starling Framework voor versnelde GPU-rendering en de verenbibliotheek voor gebruikersinterface-elementen. In onze beginscène gaan we een grondafbeelding en een voorgrondrotsafbeelding plaatsen. Later gaan we een rivier toevoegen, tussen deze twee lagen invoegen.


Geometrie

Rivieren worden gevormd door complexe natuurlijke wisselwerkingsprocessen tussen een vloeibare massa en de grond eronder. Het zou onpraktisch zijn om een ​​fysiek correcte simulatie voor een spel te doen. We willen gewoon de juiste visuele representatie krijgen, en om dat te doen gaan we een vereenvoudigd model van een rivier gebruiken.

Het modelleren van de rivier als een bocht is een van de oplossingen die we kunnen gebruiken, waardoor we een goede controle hebben en een meanderende blik krijgen. Ik heb ervoor gekozen om vierkante Bézier-curven te gebruiken om dingen eenvoudig te houden.

Bézier-curven zijn parametrische curven die vaak worden gebruikt in computergraphics; in kwadratische Bézier-curven passeert de curve twee gespecificeerde punten en de vorm ervan wordt bepaald door het derde punt, dat gewoonlijk een besturingspunt wordt genoemd.

Zoals hierboven weergegeven, passeert de curve de positiepunten terwijl het besturingspunt de koers die het volgt, beheert. Als u bijvoorbeeld het besturingspunt direct tussen de positiepunten plaatst, wordt een rechte lijn gedefinieerd, terwijl andere waarden voor het besturingspunt de curve "trekken" om in de buurt van dat punt te komen.

Dit type curve wordt gedefinieerd met behulp van de volgende wiskundige formule:

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

Op t = 0 staan ​​we aan het begin van onze curve; op t = 1 zijn we aan het einde.

Technisch gaan we meerdere Bézier-curven gebruiken waarbij het einde van de ene het begin is van de andere, een ketting vormt.

Nu moeten we het probleem oplossen van het daadwerkelijk weergeven van onze rivier. Curven hebben geen dikte, dus we gaan er een geometrische primitieve omheen bouwen.

Eerst hebben we een manier nodig om curve te nemen en om te zetten in lijnsegmenten. Om dit te doen nemen we onze punten en pluggen ze in de wiskundige definitie van de curve. Het mooie hiervan is dat we eenvoudig een parameter kunnen toevoegen om de kwaliteit van deze operatie te regelen.

Hier is de code om de punten te genereren uit de definitie van de curve:

 // Bereken punt uit kwadratische Bezier-expressie private functie kwadratischBezier (P0: Punt, P1: Punt, C: Punt, t: Getal): Punt 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; retourneer nieuw punt (x, y); 

En zo kunt u de curve converteren naar lijnsegmenten:

 // Dit is een methode die een lijst met knooppunten gebruikt // Elk knooppunt is gedefinieerd als: position, control public function convertToPoints (quality: Number = 10): Vector. var points: Vector. = nieuwe Vector. (); var precision: Number = 1 / quality; // Ga door alle knooppunten om lijnsegmenten te genereren voor (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; 

We kunnen nu een willekeurige curve nemen en deze omzetten in een aangepast aantal lijnsegmenten - hoe meer segmenten, hoe hoger de kwaliteit:

Om bij de geometrie te komen die we gaan genereren, genereren we twee nieuwe curven op basis van de originele. Hun positie en controlepunten worden verplaatst door een normale vector-offsetwaarde, die we kunnen zien als de dikte. De eerste curve wordt verplaatst in de negatieve richting, terwijl de tweede curve in de positieve richting wordt verplaatst.

We gebruiken nu de eerder gedefinieerde functie om lijnsegmenten van de curven te maken. Dit vormt een grens rond de oorspronkelijke curve.

Hoe doen we dit in code? We moeten normaalwaarden berekenen voor positie- en controlepunten, ze vermenigvuldigen met de offset en deze toevoegen aan de oorspronkelijke waarden. Voor de positiepunten zullen we moeten interpoleren normalen gevormd door lijnen naar aangrenzende controlepunten.

 // Itereer door alle punten voor (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 ); 

Je kunt nu al zien dat we die punten kunnen gebruiken om kleine vierzijdige veelhoeken - "quads" te definiëren. Onze implementatie maakt gebruik van een aangepast Starling DisplayObject, dat onze geometrische gegevens rechtstreeks aan de GPU geeft.

Een probleem, afhankelijk van de implementatie, is dat we geen quads rechtstreeks kunnen verzenden; in plaats daarvan moeten we driehoeken verzenden. Maar het is gemakkelijk genoeg om twee driehoeken uit te kiezen met behulp van vier punten:

Resultaat:


texturing

Schone geometrische stijl is leuk, en het kan zelfs een goede stijl zijn voor sommige experimentele games. Maar om onze rivier er echt goed uit te laten zien, zouden we nog een paar details kunnen gebruiken. Het gebruik van een textuur is een goed idee. Dat brengt ons bij het probleem om het weer te geven op aangepaste geometrie die eerder is gemaakt.

We zullen extra informatie aan onze hoekpunten moeten toevoegen; posities alleen zullen niet meer doen. Elke vertex kan naar wens onze extra parameters opslaan en om texture mapping te ondersteunen, zullen we textuurcoördinaten moeten definiëren.

Textuurcoördinaten bevinden zich in textuurruimte en brengen pixelwaarden van het beeld toe aan de wereldposities van hoekpunten. Voor elke pixel die op het scherm verschijnt, berekenen we geïnterpoleerde textuurcoördinaten en gebruiken deze om pixelwaarden op te zoeken voor posities in de textuur. Waarden 0 en 1 in textuurruimte komen overeen met textuurranden; als waarden dat bereik verlaten, hebben we een aantal opties:

  • Herhaling - herhaal voor onbepaalde tijd de textuur.
  • Klem - snij de textuur buiten de grenzen van het interval af [0, 1].

Degenen die een beetje weten over texture mapping zijn zich zeker bewust van mogelijke complexiteit van de techniek. Ik heb goed nieuws voor jou! Deze manier om rivieren te representeren is gemakkelijk in kaart te brengen naar een textuur.

Vanaf de zijkanten wordt de textuurhoogte in zijn geheel in kaart gebracht, terwijl de lengte van de rivier is gesegmenteerd in kleinere brokken van de textuurruimte, met de juiste afmetingen voor textuurbreedte.

Nu om het in de code te implementeren:

 // _texture is een Starling-textuur var-afstand: Number = 0; // Itereer door alle punten voor (var i: int = 0; i < _points.length; i++)  if (i > 0) // afstand in textuurruimte voor huidige lijnsegmentafstand + = puntafstand (laatste punt, _punten [i]) / _texture.width;  // Textuurcoördinaten toewijzen aan geometrie _vertexData.setTexCoords (vertexId ++, distance, 0); _vertexData.setTexCoords (vertexId ++, afstand, 1); 

Nu lijkt het veel meer op een rivier:


animatie

Onze rivier lijkt nu veel meer op een echte rivier, met één grote uitzondering: hij staat stil!

Oké, dus we moeten het animeren. Het eerste dat u misschien bedenkt, is het gebruik van sprite sheet-animatie. En dat werkt misschien wel, maar om meer flexibiliteit te behouden en een beetje textuurgeheugen te besparen, zullen we iets interessants doen.

In plaats van de textuur te veranderen, kunnen we de manier wijzigen waarop de textuur wordt toegewezen aan de geometrie. We doen dit door de textuurcoördinaten voor onze hoekpunten te wijzigen. Dit werkt alleen voor betegelbare structuren waarvoor mapping is ingesteld herhaling.

Een eenvoudige manier om dit te implementeren, is door de structuurcoördinaten op de CPU te wijzigen en de resultaten elk frame naar de GPU te verzenden. Dat is meestal een goede manier om een ​​implementatie van dit soort technieken te starten, omdat debuggen veel eenvoudiger is. We gaan echter rechtstreeks duiken op de beste manier om dit te bereiken: het animeren van textuurcoördinaten met behulp van vertex shaders.

Uit ervaring kan ik zien dat mensen soms worden geïntimideerd door shaders, waarschijnlijk vanwege hun connectie met de geavanceerde grafische effecten van blockbuster-games. Eerlijk gezegd is het concept erachter extreem eenvoudig en als je een programma kunt schrijven, kun je een shader schrijven - dat is alles wat ze zijn, kleine programma's die op de GPU draaien. We gaan een vertex shader gebruiken om onze rivier te animeren, er zijn verschillende andere soorten shaders, maar we kunnen het zonder doen.

Zoals de naam al aangeeft, verwerken vertex shaders hoekpunten. Ze lopen voor elke vertex en nemen als input vertex attributen: positie, textuurcoördinaten en kleur.

Ons doel is om de X-waarde van rivierstructuurcoördinaten te compenseren om stroming te simuleren. We houden een flowteller en vergroten deze per frame per tijdsinterval. We kunnen een extra parameter specificeren voor de snelheid van de animatie. De offsetwaarde moet worden doorgegeven aan de arcering als een uniforme (constante) waarde, een manier om het shaderprogramma meer informatie te geven dan alleen hoekpunten. Deze waarde is meestal een viercomponentvector; we gaan gewoon de X-component gebruiken om de waarde op te slaan, terwijl Y, Z en W op 0 worden ingesteld.

 // Textuurverschuiving bij index 5, waarnaar we later verwijzen in de arceringcontext.setProgramConstantsFromVector (Context3DProgramType.VERTEX, 5, nieuw [-_textureOffset, 0, 0, 0], 1);

Deze implementatie maakt gebruik van de AGAL-shader-taal. Het kan een beetje moeilijk te begrijpen zijn, omdat het een assembly-achtige taal is. Je kunt hier meer informatie over vinden.

Vertex-arcering:

 m44 op, va0, vc0 // Bereken hoekpuntpositie mul v0, va1, vc4 // Bereken hoekkleur // Voeg vertex-textuurcoördinaat (va2) en onze textuuroffsetconstante (vc5) toe: voeg v1, va2, vc5 toe

Animatie in actie:


Waarom hier stoppen?

We zijn bijna klaar, behalve dat onze rivier er nog steeds onnatuurlijk uitziet. De vlakte tussen de achtergrond en de rivier is een doorn in het oog. Om dit op te lossen kun je een extra laag van de rivier gebruiken, iets dikker, en een speciale textuur, die over de rivieroevers zou liggen en de lelijke overgang zou bedekken.

En aangezien de demo de rivier van gesmolten lava vertegenwoordigt, kunnen we onmogelijk een beetje gloeien! Maak nog een instantie van riviergeometrie, gebruik nu een gloeietextuur en stel de overvloeimodus in op "toevoegen". Voeg voor nog meer plezier vloeiende animaties toe van de gloed-alpha-waarde.

Laatste demo:

Natuurlijk kun je veel meer doen dan alleen rivieren die dit soort effect gebruiken. Ik heb het voor spookdeeltjeseffecten, watervallen of zelfs voor het animeren van kettingen gezien. Er is veel ruimte voor verdere verbetering, de Performance Wise definitieve versie van hierboven kan worden gedaan met één tekenoproep als texturen worden samengevoegd met een atlas. Lange rivieren moeten in meerdere delen worden verdeeld en worden geruimd. Een belangrijke uitbreiding zou zijn het implementeren van het aftasten van curve-knooppunten om meerdere rivierpaden mogelijk te maken en op hun beurt bifurcatie te simuleren.

Ik gebruik deze techniek in onze nieuwste game en ik ben erg blij met wat we ermee kunnen doen. We gebruiken het voor rivieren en wegen (zonder animatie, uiteraard). Ik denk aan het gebruik van een vergelijkbaar effect voor meren.


Conclusie

Ik hoop dat ik je een aantal ideeën heb gegeven over hoe te denken buiten reguliere grafische technieken, zoals het gebruik van sprite sheets of tile sets om dit soort effecten te bereiken. Het vereist een beetje meer werk, een beetje wiskunde en wat GPU-programmeerkennis, maar in ruil daarvoor krijg je veel meer flexibiliteit.