Het creëren van dynamische 2D-watereffecten in eenheid

In deze zelfstudie gaan we een dynamisch 2D-waterlichaam simuleren met behulp van eenvoudige natuurkunde. We zullen een combinatie van een lijnrenderer, mesh-renderers, triggers en deeltjes gebruiken om ons effect te creëren. Het eindresultaat wordt compleet met golven en spatten, klaar om toe te voegen aan je volgende spel. Een demobron Unity (Unity3D) is inbegrepen, maar je moet in staat zijn om iets soortgelijks te implementeren met dezelfde principes in elke game-engine.

gerelateerde berichten
  • Maak een plons met dynamische 2D-watereffecten
  • Een aangepaste 2D-physics-engine maken: de basisprincipes en impulsresolutie
  • Turbulentie aan een deeltjessysteem toevoegen

Eindresultaat

Dit is waar we uiteindelijk mee zullen eindigen. Je hebt de Unity-browserplug-in nodig om het uit te proberen.

Klik om een ​​nieuw object te maken dat in het water valt.

Onze waterbeheerder opzetten

In zijn tutorial liet Michael Hoffman zien hoe we het oppervlak van water kunnen modelleren met een rij veren.

We gaan de top van ons water renderen met behulp van een van de lijnrenderers van Unity, en gebruiken zoveel knooppunten dat het verschijnt als een continue golf.


We zullen wel de posities, snelheden en versnellingen van elk knooppunt in de gaten moeten houden. Om dat te doen, gaan we arrays gebruiken. Dus bij de top van onze klas voegen we deze variabelen toe:

zweven [] xpositions; zweven [] ypositions; zwevende [] snelheden; zwevende [] versnellingen; LineRenderer Body;

De LineRenderer zal al onze nodes opslaan en ons lichaam van water schetsen. We hebben echter nog steeds het water zelf nodig; we zullen dit maken met mazen. We zullen ook voorwerpen nodig hebben om deze netten vast te houden.

GameObject [] meshobjects; Mesh [] mazen;

We hebben ook botsers nodig zodat dingen kunnen interageren met ons water:

GameObject [] botsers;

En we zullen ook al onze constanten opslaan:

 const float springconstant = 0.02f; Const float-demping = 0.04f; const float spread = 0.05f; const float z = -1f;

Deze constanten zijn dezelfde soort als waarover Michael sprak, met uitzondering van z-dit is onze z-offset voor ons water. We gaan gebruiken -1 hiervoor zodat het voor onze objecten wordt weergegeven. (Je zou dit misschien willen veranderen, afhankelijk van wat je ervoor voor- en achteraan wilt zien, je zult de z-coördinaat moeten gebruiken om te bepalen waar sprites ten opzichte van zitten.)

Vervolgens houden we enkele waarden vast:

 zwevende basishoogte; zweven links; zwevende bodem;

Dit zijn slechts de afmetingen van het water.

We zullen een aantal openbare variabelen nodig hebben die we ook in de editor kunnen instellen. Ten eerste, het deeltjessysteem dat we gaan gebruiken voor onze spatten:

openbare splash van GameObject:

Vervolgens, het materiaal dat we zullen gebruiken voor onze lijnrenderer (voor het geval je het script opnieuw wilt gebruiken voor zuur, lava, chemicaliën of wat dan ook):

publiek Materiaalmat:

Plus, het soort gaas dat we gaan gebruiken voor het waterlichaam:

openbaar GameObject watermesh:

Deze zullen allemaal gebaseerd zijn op prefabs, die allemaal zijn opgenomen in de bronbestanden.

We willen een game-object dat al deze gegevens kan bevatten, als een manager kan fungeren en ons lichaam van water ingame kan spawnen naar specificatie. Om dat te doen, zullen we een functie schrijven met de naam SpawnWater ().

Deze functie neemt de invoer van de linkerkant, de breedte, de bovenkant en de bodem van het water in beslag.

openbare leegte SpawnWater (zwevend Links, zwevend Breedte, zwevend Boven, zwevend Onder) 

(Hoewel dit inconsistent lijkt, handelt het in het belang van snel niveauontwerp bij het bouwen van links naar rechts).


De knooppunten maken

Nu gaan we uitvinden hoeveel nodes we nodig hebben:

int edgecount = Mathf.RoundToInt (Width) * 5; int nodecount = edgecount + 1;

We gaan vijf per eenheid breedte gebruiken, om ons een vloeiende beweging te geven die niet te veeleisend is. (Je kunt dit variëren om efficiëntie in evenwicht te brengen met gladheid.) Dit geeft ons al onze lijnen, dan hebben we de + 1 voor de extra knoop aan het einde.

Het eerste dat we gaan doen, is ons lichaam van water maken met de LineRenderer component:

 Body = gameObject.AddComponent(); Body.material = mat; Body.material.renderQueue = 1000; Body.SetVertexCount (nodecount); Body.SetWidth (0.1f, 0.1f);

Wat we hier ook hebben gedaan, is ons materiaal selecteren en instellen dat het boven water wordt weergegeven door zijn positie in de renderwachtrij te kiezen. We hebben het juiste aantal knooppunten ingesteld en de breedte van de regel ingesteld op 0.1.

Je kunt dit variëren, afhankelijk van hoe dik je je lijn wilt hebben. Je hebt dat misschien gemerkt SetWidth () neemt twee parameters; dit zijn de breedte aan het begin en aan het einde van de regel. We willen dat die breedte constant is.

Nu we onze knooppunten hebben gemaakt, zullen we al onze topvariabelen initialiseren:

 xpositions = nieuwe float [nodecount]; ypositions = nieuwe float [nodecount]; snelheden = nieuwe float [nodecount]; acceleraties = nieuwe float [nodecount]; meshobjects = nieuw GameObject [edgecount]; meshes = new Mesh [edgecount]; colliders = nieuw GameObject [edgecount]; baseheight = Top; onderkant = onderkant; links = links;

Dus nu hebben we al onze arrays en houden we vast aan onze gegevens.

Nu om de waarden van onze arrays daadwerkelijk in te stellen. We beginnen met de knooppunten:

 voor (int i = 0; i < nodecount; i++)  ypositions[i] = Top; xpositions[i] = Left + Width * i / edgecount; accelerations[i] = 0; velocities[i] = 0; Body.SetPosition(i, new Vector3(xpositions[i], ypositions[i], z)); 

Hier plaatsen we alle y-posities bovenaan het water en voegen vervolgens stapsgewijs alle knooppunten naast elkaar toe. Onze snelheden en versnellingen zijn aanvankelijk nul, omdat het water stil is.

We beëindigen de lus door elk knooppunt in onze LineRenderer (Lichaam) naar hun juiste positie.


De mazen maken

Hier wordt het lastig.

We hebben onze lijn, maar we hebben het water zelf niet. En de manier waarop we dit kunnen maken is het gebruik van Meshes. We beginnen met het maken van deze:

voor (int i = 0; i < edgecount; i++)  meshes[i] = new Mesh();

Nu bewaart Meshes een aantal variabelen. De eerste variabele is vrij eenvoudig: deze bevat alle hoekpunten (of hoeken).


Het diagram laat zien hoe we onze mesh-segmenten eruit willen laten zien. Voor het eerste segment zijn de hoekpunten gemarkeerd. We willen er in totaal vier.

 Vector3 [] Vertices = new Vector3 [4]; Vertices [0] = nieuwe Vector3 (xpositions [i], ypositions [i], z); Vertices [1] = nieuwe Vector3 (xpositions [i + 1], ypositions [i + 1], z); Vertices [2] = nieuwe Vector3 (xpositions [i], bottom, z); Vertices [3] = nieuwe Vector3 (xpositions [i + 1], bottom, z);

Nu, zoals je hier kunt zien, vertex 0 is de linkerbovenhoek, 1 is de rechterbovenhoek, 2 is linksonder, en 3 is de rechterbovenhoek. We zullen dit voor later moeten onthouden.

De tweede eigenschap die nodig is, is UV-straling. Meshes hebben texturen en de UV's kiezen welk deel van de texturen we willen pakken. In dit geval willen we alleen de hoeken linksboven, rechtsboven, linksonder en onderaan rechts van onze textuur.

 Vector2 [] UV's = nieuwe Vector2 [4]; UV's [0] = nieuwe Vector2 (0, 1); UV's [1] = nieuwe Vector2 (1, 1); UV's [2] = nieuwe Vector2 (0, 0); UV's [3] = nieuwe Vector2 (1, 0);

Nu hebben we die nummers van tevoren nodig. Mazen zijn opgebouwd uit driehoeken en we weten dat elke vierhoek uit twee driehoeken kan bestaan, dus nu moeten we de maas vertellen hoe deze die driehoeken moet tekenen.


Bekijk de hoeken met de gelabelde knooppuntvolgorde. Driehoek EEN verbindt knooppunten 0, 1 en 3; Driehoek B verbindt knooppunten 3, 2 en 0. Daarom willen we een array maken die zes gehele getallen bevat, wat precies dat weergeeft:

int [] tris = new int [6] 0, 1, 3, 3, 2, 0;

Dit creëert onze vierhoek. Nu stellen we de mesh-waarden in.

 mazen [i] .vertices = Vertices; mazen [i] .uv = UV's; mazen [i] .triangles = tris;

Nu hebben we onze meshes, maar we hebben geen Game Objects om ze in scène te zetten. Dus we gaan ze van onze maken watermesh prefab die een Mesh Renderer en Mesh Filter bevat.

 meshobjects [i] = Instantiate (watermesh, Vector3.zero, Quaternion.identity) als GameObject; meshobjects [i] .GetComponent() .mesh = meshes [i]; meshobjects [i] .transform.parent = transformeren;

We plaatsen de mesh en we stellen het in als het kind van de waterbeheerder om de boel op te ruimen.


Onze botsingen creëren

Nu willen we ook onze spanner:

 colliders [i] = nieuw GameObject (); colliders [i] .name = "Trigger"; colliders [i] .AddComponent(); colliders [i] .transform.parent = transformeren; colliders [i] .transform.position = new Vector3 (links + breedte * (i + 0,5f) / edgecount, top - 0,5f, 0); colliders [i] .transform.localScale = new Vector3 (Width / edgecount, 1, 1); colliders [i] .GetComponent() .isTrigger = true; colliders [i] .AddComponent();

Hier maken we box-colliders, geven ze een naam, zodat ze een beetje opgeruimder in de scène zijn, en maken ze elke keer weer kinderen van de waterbeheerder. We plaatsen hun positie om halverwege tussen de knooppunten te zijn, stellen hun grootte in en voegen een toe watervoeler klasse voor hen.

Nu we onze mesh hebben, hebben we een functie nodig om deze bij te werken terwijl het water beweegt:

void UpdateMeshes () for (int i = 0; i < meshes.Length; i++)  Vector3[] Vertices = new Vector3[4]; Vertices[0] = new Vector3(xpositions[i], ypositions[i], z); Vertices[1] = new Vector3(xpositions[i+1], ypositions[i+1], z); Vertices[2] = new Vector3(xpositions[i], bottom, z); Vertices[3] = new Vector3(xpositions[i+1], bottom, z); meshes[i].vertices = Vertices;  

U merkt misschien dat deze functie alleen de code gebruikt die we eerder hebben geschreven. Het enige verschil is dat we deze keer niet de tris en UV's hoeven in te stellen, omdat deze hetzelfde blijven.

Onze volgende taak is om het water zelf te laten werken. We zullen gebruiken FixedUpdate () om ze allemaal stapsgewijs aan te passen.

void FixedUpdate () 

De natuurkunde implementeren

Ten eerste gaan we de Wet van Hooke combineren met de Euler-methode om de nieuwe posities, versnellingen en snelheden te vinden.

De Wet van Hooke is dus \ (F = kx \), waarbij \ (F \) de kracht is die wordt geproduceerd door een veer (onthoud, we modelleren het oppervlak van het water als een rij veren), \ (k \) is de veer constant, en \ (x \) is de verplaatsing. Onze verplaatsing wordt eenvoudigweg de y-positie van elk knooppunt minus de basishoogte van de knooppunten.

Vervolgens voegen we een toe dempingsfactor evenredig met de snelheid van de kracht om de kracht te dempen.

voor (int i = 0; i < xpositions.Length ; i++)  float force = springconstant * (ypositions[i] - baseheight) + velocities[i]*damping ; accelerations[i] = -force; ypositions[i] += velocities[i]; velocities[i] += accelerations[i]; Body.SetPosition(i, new Vector3(xpositions[i], ypositions[i], z)); 

De Euler-methode is eenvoudig; we voegen gewoon de versnelling toe aan de snelheid en de snelheid aan de positie, aan elk frame.

Opmerking: ik nam gewoon aan dat de massa van elke knoop was 1 hier, maar je zult willen gebruiken:

 versnellingen [i] = -kracht / massa;

als je een andere massa wilt voor je knooppunten.

Tip: Voor precieze fysica zouden we Verlet-integratie gebruiken, maar omdat we demping toevoegen, kunnen we alleen de Euler-methode gebruiken, die een veel snellere berekening is. Over het algemeen echter, zal de Euler-methode exponentieel kinetische energie vanuit het niets introduceren in uw fysische systeem, dus gebruik het niet voor iets nauwkeurigs.

Nu gaan we creëren golfvoortplanting. De volgende code is overgenomen uit de zelfstudie van Michael Hoffman.

 zweven [] linksDeltas = nieuwe zweeftekst [xpositions.Length]; zweven [] rightDeltas = nieuwe zweefvlucht [xpositions.Length];

Hier maken we twee arrays. Voor elk knooppunt gaan we de hoogte van het vorige knooppunt vergelijken met de hoogte van het huidige knooppunt en zetten we het verschil in leftDeltas.

Vervolgens controleren we de hoogte van het volgende knooppunt tegen de hoogte van het knooppunt dat we controleren en plaatsen we dat verschil rightDeltas. (We zullen ook alle waarden vermenigvuldigen met een spreidingsconstante).

 voor (int j = 0; j < 8; j++)  for (int i = 0; i < xpositions.Length; i++)  if (i > 0) leftDeltas [i] = spread * (ypositions [i] - ypositions [i-1]); snelheden [i - 1] + = linksDeltas [i];  als ik < xpositions.Length - 1)  rightDeltas[i] = spread * (ypositions[i] - ypositions[i + 1]); velocities[i + 1] += rightDeltas[i];   

We kunnen de snelheden op basis van het hoogteverschil onmiddellijk wijzigen, maar we moeten de verschillen in posities alleen opslaan op dit punt. Als we de positie van het eerste knooppunt direct van de knuppel hebben veranderd, tegen de tijd dat we naar het tweede knooppunt keken, is het eerste knooppunt al verplaatst, dus dat zal al onze berekeningen verpesten.

voor (int i = 0; i < xpositions.Length; i++)  if (i > 0) ypositions [i-1] + = leftDeltas [i];  als ik < xpositions.Length - 1)  ypositions[i + 1] += rightDeltas[i];  

Dus zodra we al onze hoogtedata hebben verzameld, kunnen we deze aan het einde toepassen. We kunnen niet rechts van het knooppunt helemaal links of helemaal links van het knooppunt helemaal links kijken, vandaar de voorwaarden i> 0 en ik < xpositions.Length - 1.

Merk ook op dat we deze hele code in een lus hebben opgeslagen en deze acht keer hebben uitgevoerd. Dit komt omdat we dit proces meerdere keren in kleine doses willen uitvoeren, in plaats van één grote berekening, die een stuk minder vloeiend zou zijn.


Spatten toevoegen

Nu hebben we water dat stroomt, en dat blijkt. Vervolgens moeten we het water kunnen verstoren!

Laten we hiervoor een functie toevoegen met de naam Plons(), die de x-positie van de plons, en de snelheid van wat dan ook raakt, zal controleren. Het moet openbaar zijn, zodat we het later van onze colliders kunnen bellen.

public void Splash (float xpos, float velocity) 

Ten eerste moeten we ervoor zorgen dat de opgegeven positie daadwerkelijk binnen de grenzen van ons water ligt:

 if (xpos> = xpositions [0] && xpos <= xpositions[xpositions.Length-1]) 

En dan zullen we veranderen xpos dus het geeft ons de positie ten opzichte van de start van het waterlichaam:

 xpos - = xpositions [0];

Vervolgens gaan we uitzoeken welk knooppunt het aanraakt. We kunnen dat als volgt berekenen:

int index = Mathf.RoundToInt ((xpositions.Length-1) * (xpos / (xpositions [xpositions.Length-1] - xpositions [0])));

Dus, hier is wat hier gebeurt:

  1. We nemen de positie van de plons ten opzichte van de positie van de linkerrand van het water (xpos).
  2. We verdelen dit door de positie van de rechterrand ten opzichte van de positie van de linkerrand van het water.
  3. Dit geeft ons een breuk die ons vertelt waar de plons is. Bijvoorbeeld, een plons driekwart van de weg langs het water geeft een waarde van 0.75.
  4. We vermenigvuldigen dit met het aantal randen en rond dit aantal, wat ons het knooppunt geeft waar onze splash het meest dichtbij was.
snelheden [index] = snelheid;

Nu stellen we de snelheid van het object dat ons water raakt op de snelheid van dat knooppunt, zodat het naar beneden wordt getrokken door het object.

Notitie: Je zou deze regel kunnen veranderen naar wat bij je past. U zou bijvoorbeeld de snelheid aan zijn huidige snelheid kunnen toevoegen, of u zou momentum kunnen gebruiken in plaats van aanslagsnelheid en delen door de massa van uw knooppunt.

Nu willen we een deeltjessysteem maken dat de plons produceert. We hebben dat eerder gedefinieerd; het wordt "splash" genoemd (creatief genoeg). Zorg ervoor dat je het niet verwart Plons(). Degene die ik ga gebruiken is opgenomen in de bronbestanden.

Ten eerste willen we de parameters van de splash instellen om te veranderen met de snelheid van het object.

 levensduur van de vlotter = 0,93f + Mathf.Abs (velocity) * 0,07f; splash.GetComponent() .startSpeed ​​= 8 + 2 * Mathf.Pow (Mathf.Abs (velocity), 0.5f); splash.GetComponent() .startSpeed ​​= 9 + 2 * Mathf.Pow (Mathf.Abs (velocity), 0.5f); splash.GetComponent() .startLifetime = levensduur;

Hier hebben we onze deeltjes genomen, hun levensduur zo ingesteld dat ze niet zullen sterven kort nadat ze het wateroppervlak hebben geraakt, en hun snelheid hebben ingesteld op basis van het kwadraat van hun snelheid (plus een constante, voor kleine spatten).

Misschien kijk je naar die code en denk je: "Waarom heeft hij de code opgesteld? StartSpeed twee keer? ", en je hebt gelijk om je af te vragen. Het probleem is dat we een deeltjessysteem gebruiken (Shuriken, meegeleverd met het project) waarvan de startsnelheid is ingesteld op" willekeurig tussen twee constanten ". hebben niet veel toegang tot Shuriken via scripts, dus om dat gedrag te laten werken, moeten we de waarde twee keer instellen.

Nu ga ik een regel toevoegen die je misschien wel of niet wilt weglaten uit je script:

Vector3 positie = nieuwe Vector3 (xpositions [index], ypositions [index] -0.35f, 5); Quaternion rotatie = Quaternion.LookRotation (nieuwe Vector3 (xpositions [Mathf.FloorToInt (xpositions.Length / 2)], baseheight + 8, 5) - positie);

Shuriken-deeltjes worden niet vernietigd wanneer ze je objecten raken, dus als je zeker wilt zijn dat ze niet voor je objecten zullen landen, kun je twee maatregelen nemen:

  1. Plak ze op de achtergrond. (Je kunt dit zien aan de hand van de z-positie 5).
  2. Kantel het deeltjessysteem om altijd naar het midden van je waterlichaam te wijzen - op deze manier zullen de deeltjes niet op het land spatten.

De tweede coderegel neemt het middelpunt van de posities, beweegt een beetje naar boven en wijst de deeltjesemitter ernaartoe. Ik heb dit gedrag opgenomen in de demo. Als u echt veel water gebruikt, wilt u dit gedrag waarschijnlijk niet. Als je water zich in een klein zwembad in een kamer bevindt, wil je het misschien wel gebruiken. Dus, voel je vrij om die regel te schrappen over rotatie.

 GameObject splish = Instantiate (splash, position, rotation) als GameObject; Destroy (splish, lifetime + 0.3f); 

Nu maken we onze plons, en vertellen het om een ​​beetje te sterven nadat de deeltjes moeten sterven. Waarom een ​​beetje later? Omdat ons deeltjessysteem een ​​paar opeenvolgende bursts van deeltjes uitzendt, dus ook al duurt de eerste batch pas tot het einde Time.time + levensduur, onze uiteindelijke uitbarstingen zullen daarna nog een tijdje duren.

Ja! We zijn eindelijk klaar, goed?


Collision Detection

Fout! We moeten onze objecten detecteren, of dit was allemaal voor niets!

Weet je nog dat we dat script eerder aan al onze colliders hebben toegevoegd? De geroepene watervoeler?

Nou we gaan het nu redden! We willen slechts één functie erin:

void OnTriggerEnter2D (Collider2D Hit) 

Gebruik makend van OnTriggerEnter2D (), we kunnen specificeren wat er gebeurt als een 2D rigide lichaam in ons waterlichaam komt. Als we een parameter van doorgeven Collider2D we kunnen meer informatie over dat object vinden.

if (Hit.rigidbody2D! = null) 

We willen alleen objecten die een bevatten rigidbody2D.

 transform.parent.GetComponent() .Splash (transform.position.x, Hit.rigidbody2D.velocity.y * Hit.rigidbody2D.mass / 40f); 

Nu zijn al onze colliders kinderen van de waterbeheerder. Dus we pakken gewoon de Water component van hun ouder en oproep Plons(), vanuit de positie van de collider.

Onthoud nogmaals, ik zei dat je snelheid of momentum kon doorgeven, als je wilde dat het fysiek nauwkeuriger was? Nou, hier moet je de juiste doorgeven. Als je de y-snelheid van het object vermenigvuldigt met zijn massa, heb je zijn momentum. Als je alleen de snelheid ervan wilt gebruiken, verwijder dan de massa van die lijn.

Eindelijk, wil je bellen SpawnWater () van ergens. Laten we het doen bij de lancering:

void Start () SpawnWater (-10,20,0, -10); 

En nu zijn we klaar! Nu elk rigidbody2D met een botsing die het water raakt, zal een plons ontstaan ​​en zullen de golven correct bewegen.


Bonusoefening

Als een extra bonus heb ik een paar regels code aan de bovenkant toegevoegd SpawnWater ().

gameObject.AddComponent(); gameObject.GetComponent() .center = new Vector2 (links + breedte / 2, (boven + onder) / 2); gameObject.GetComponent() .size = new Vector2 (Width, Top - Bottom); gameObject.GetComponent() .isTrigger = true;

Deze coderegels voegen een box-collider toe aan het water zelf. Je kunt dit gebruiken om dingen in je water te laten zweven, gebruikmakend van wat je hebt geleerd.

U wilt een functie maken met de naam OnTriggerStay2D () die een parameter van neemt Collider2D Hit. Vervolgens kunt u een aangepaste versie van de voorjaarsformule gebruiken die we eerder hebben gebruikt, die de massa van het object controleert en een kracht of snelheid toevoegt aan uw rigidbody2D om het in het water te laten zweven.


Maak een plons

In deze zelfstudie hebben we een eenvoudige watersimulatie geïmplementeerd voor gebruik in 2D-games met eenvoudige natuurkundige code en een lijnrenderer, mesh-renderers, triggers en deeltjes. Misschien voeg je golvende lichamen van vloeibaar water toe als een obstakel voor je volgende platformgame, klaar voor je personages om in te duiken of voorzichtig over te steken met zwevende stapstenen, of misschien zou je dit kunnen gebruiken in een zeil- of windsurferspel, of zelfs een spel waarbij je verspringt eenvoudig stenen over het water vanaf een zonnig strand. Succes!