Gör ett plask med dynamiska 2D-vatteneffekter

Sploosh! I denna handledning visar jag dig hur du kan använda enkla matte-, fysik- och partikeleffekter för att simulera snygga 2D vattenvågor och droppar.

Notera: Även om denna handledning skrivs med C # och XNA, borde du kunna använda samma tekniker och begrepp i nästan vilken spelutvecklingsmiljö som helst.


Slutresultatförhandsvisning

Om du har XNA kan du ladda ner källfilerna och kompilera demoen själv. Annars, kolla in demovideoen nedan:

Det finns två mest oberoende delar till vattensimuleringen. Först gör vi vågorna med en vårmodell. För det andra använder vi partikeleffekter för att lägga till stänk.


Göra vågorna

För att göra vågorna ska vi modellera vattennets yta som en serie vertikala fjädrar, vilket visas i detta diagram:

Detta gör det möjligt för vågorna att bobla upp och ner. Vi gör då vattenpartiklarna på sina närliggande partiklar för att tillåta vågorna att sprida sig.

Springs och Hooke's Law

En bra sak om fjädrar är att de är lätta att simulera. Fjädrar har en viss naturlig längd; Om du sträcker eller komprimerar en fjäder, försöker den återgå till den naturliga längden.

Kraften som tillhandahålls av en fjäder ges av Hooke's Law:

\ [
F = -kx
\]

F är kraften som produceras av våren, k är våren konstant, och x är vårens förskjutning från sin naturliga längd. Det negativa tecknet indikerar att kraften är i motsatt riktning mot vilken fjädern är förskjuten. om du trycker på våren ner, kommer den att trycka upp igen och vice versa.

Fjäderkonstanten, k, bestämmer fjäderns styvhet.

För att simulera fjädrar måste vi räkna ut hur man rör partiklar runt baserat på Hooke's Law. För att göra detta behöver vi ett par fler formler från fysiken. För det första Newtons andra lag om rörelse:

\ [
F = ma
\]

Här, F är kraft, m är massa och en är acceleration. Det betyder att den starkare kraften trycker på ett objekt och ju lättare objektet är desto mer accelererar det.

Kombinationen av dessa två formler och omarrangemang ger oss:

\ [
a = - \ frac k m x
\]

Detta ger oss accelerationen för våra partiklar. Vi antar att alla våra partiklar kommer att ha samma massa, så vi kan kombinera k / m in i en enda konstant.

För att bestämma positionen från accelerationen måste vi göra numerisk integration. Vi ska använda den enklaste formen av numerisk integration - varje ram gör vi helt enkelt följande:

Position + = Hastighet; Hastighet + = Acceleration;

Detta kallas Euler-metoden. Det är inte den mest exakta typen av numerisk integration, men det är snabbt, enkelt och lämpligt för våra ändamål.

Om vi ​​sätter allt ihop, kommer våra vattenyta partiklar att göra följande varje ram:

offentlig float Position, hastighet; offentlig tomhet Uppdatering () const float k = 0.025f; // justera detta värde till din smak float x = Höjd - TargetHeight; float acceleration = -k * x; Position + = Hastighet; Hastighet + = acceleration; 

Här, TargetHeight är den naturliga positionen på toppen av våren när den inte sträcks eller komprimeras. Du bör ställa in detta värde till var du vill att vattnets yta ska vara. För demo sätter jag det halvvägs ner på skärmen, vid 240 pixlar.

Spänning och dämpning

Jag nämnde tidigare att våren konstant, k, kontrollerar fjäderns styvhet. Du kan justera detta värde för att ändra vattenets egenskaper. En låg fjäderkonstant kommer att göra fjädrarna lös. Det betyder att en kraft kommer att orsaka stora vågor som svänger långsamt. Omvänt kommer en högfjäderkonstant att öka spänningen i våren. Krafter kommer att skapa små vågor som oscillerar snabbt. En hög fjäderkonstant gör att vattnet ser mer ut som jiggling Jello.

Ett varningstecken: Ställ inte våren konstant för högt. Mycket styva fjädrar applicerar mycket starka krafter som förändras kraftigt under en mycket liten tid. Detta spelar inte bra med numerisk integration, vilket simulerar fjädrarna som en serie diskreta hoppar med regelbundna tidsintervaller. En mycket stel fjäder kan till och med ha en oscillationsperiod som är kortare än ditt tidssteg. Ännu värre tenderar Euler-metoden att integrera sig, eftersom simuleringen blir mindre noggrann, vilket medför att styva fjädrar exploderar.

Det finns ett problem med vårfjädermodellen hittills. När en vår börjar svänga, kommer den aldrig att sluta. För att lösa detta måste vi tillämpa några dämpning. Tanken är att tillämpa en kraft i motsatt riktning som vår vår rör sig för att sakta ner det. Detta kräver en liten anpassning till vårfjäderformeln:

\ [
a = - \ frac k m x - dv
\]

Här, v är hastighet och d är dämpningsfaktor - En annan konstant du kan finjustera för att justera vattnets känsla. Det ska vara ganska litet om du vill att dina vågor ska oscillera. Demon använder en dämpningsfaktor på 0,025. En hög dämpningsfaktor gör att vattnet ser tjock ut som melass, medan ett lågt värde kommer att tillåta vågorna att oscillera under lång tid.

Göra vågorna föröka

Nu när vi kan göra en fjäder, låt oss använda dem för att modellera vatten. Som visas i det första diagrammet modellerar vi vattnet med en serie parallella vertikala källor. Naturligtvis, om fjädrarna är alla oberoende, kommer vågorna aldrig att sprida ut som riktiga vågor gör.

Jag ska visa koden först och sedan gå över den:

för (int i = 0; i < springs.Length; i++) springs[i].Update(Dampening, Tension); float[] leftDeltas = new float[springs.Length]; float[] rightDeltas = new float[springs.Length]; // do some passes where springs pull on their neighbours for (int j = 0; j < 8; j++)  for (int i = 0; i < springs.Length; i++)  if (i > 0) leftDeltas [i] = Spread * (fjädrar [i] .Hightfjädrar [i - 1] .Hight); fjädrar [i - 1] .Speed ​​+ = leftDeltas [i];  om jag < springs.Length - 1)  rightDeltas[i] = Spread * (springs[i].Height - springs [i + 1].Height); springs[i + 1].Speed += rightDeltas[i];   for (int i = 0; i < springs.Length; i++)  if (i > 0) fjädrar [i - 1] .Height + = leftDeltas [i]; om jag < springs.Length - 1) springs[i + 1].Height += rightDeltas[i];  

Denna kod skulle kallas varje ram från din Uppdatering() metod. Här, fjädrar är en uppsättning fjädrar som läggs ut från vänster till höger. leftDeltas är en uppsättning floats som lagrar skillnaden i höjd mellan varje vår och dess vänstra granne. rightDeltas är motsvarigheten för rätt grannar. Vi lagrar alla dessa höjdskillnader i arrays eftersom de två sista om uttalanden ändra fjädrarnas höjder. Vi måste mäta höjdskillnaderna innan någon av höjderna ändras.

Koden börjar med att köra Hooke's Law på varje vår som beskrivits tidigare. Det ser sedan på höjdskillnaden mellan varje vår och dess grannar, och varje vår drar sina närliggande fjädrar mot sig genom att ändra grannarnas positioner och hastigheter. Granndragningssteget upprepas åtta gånger för att vågorna ska kunna sprida sig snabbare.

Det finns ytterligare ett tweakable värde som heter här Spridning. Det styr hur snabbt vågorna sprider sig. Det kan ta värden mellan 0 och 0,5, med större värden som gör att vågorna sprids ut snabbare.

För att börja vågorna rör sig, ska vi lägga till en enkel metod som heter Stänk().

offentlig tomrumsplash (int index, flythastighet) if (index> = 0 && index < springs.Length) springs[i].Speed = speed; 

När som helst du vill göra vågor, ring Stänk(). De index Parametern bestämmer vid vilken fjäder splashen ska härstamma, och fart parametern bestämmer hur stor vågorna kommer att vara.

Tolkning

Vi använder XNA PrimitiveBatch klass från XNA PrimitivesSample. De PrimitiveBatch klassen hjälper oss att rita linjer och trianglar direkt med GPU. Du använder det som så:

// i LoadContent () primitiveBatch = nya PrimitiveBatch (GraphicsDevice); // i Draw () primitiveBatch.Begin (PrimitiveType.TriangleList); foreach (Triangle triangle i trianglesToDraw) primitiveBatch.AddVertex (triangle.Point1, Color.Red); primitiveBatch.AddVertex (triangle.Point2, Color.Red); primitiveBatch.AddVertex (triangle.Point3, Color.Red);  primitiveBatch.End ();

En sak att notera är att du som standard måste ange trianglarna i en ordning medurs. Om du lägger till dem i en moturs ordning kommer triangeln att slängas och du kommer inte se den.

Det är inte nödvändigt att ha en fjäder för varje pixel med bredd. I den demo som jag använde sträckte 201 fjädrar över ett 800 pixel stort fönster. Det ger exakt 4 pixlar mellan varje vår, med första våren vid 0 och den sista vid 800 pixlar. Du kan nog använda ännu färre fjädrar och har fortfarande vattnet ser smidigt ut.

Vad vi vill göra är att dra tunna, långa trapezoider som sträcker sig från botten av skärmen till vattennets yta och kopplar fjädrarna som visas i det här diagrammet:

Eftersom grafikkort inte ritar trapezor direkt måste vi dra varje trapezoid som två trianglar. För att få det att se lite snyggare, kommer vi också att göra vattnet mörkare när det blir djupare genom att färga bottenhjulen mörkblå. GPU-enheten interpolerar automatiskt färgerna mellan punkterna.

primitiveBatch.Begin (PrimitiveType.TriangleList); Färg midnattBlue = ny Färg (0, 15, 40) * 0,9f; Färg ljusblå = ny färg (0.2f, 0.5f, 1f) * 0.8f; var viewport = GraphicsDevice.Viewport; float bottom = viewport.Hight; // sträcka fjädrarna x positioner för att ta upp hela fönstret float scale = viewport.Width / (springs.Length - 1f); // var noga med att använda float division för (int i = 1; i < springs.Length; i++)  // create the four corners of our triangle. Vector2 p1 = new Vector2((i - 1) * scale, springs[i - 1].Height); Vector2 p2 = new Vector2(i * scale, springs[i].Height); Vector2 p3 = new Vector2(p2.X, bottom); Vector2 p4 = new Vector2(p1.X, bottom); primitiveBatch.AddVertex(p1, lightBlue); primitiveBatch.AddVertex(p2, lightBlue); primitiveBatch.AddVertex(p3, midnightBlue); primitiveBatch.AddVertex(p1, lightBlue); primitiveBatch.AddVertex(p3, midnightBlue); primitiveBatch.AddVertex(p4, midnightBlue);  primitiveBatch.End();

Här är resultatet:


Göra stänk

Vågorna ser ganska bra ut, men jag skulle vilja se ett stänk när vaggan träffar vattnet. Partikeleffekterna är perfekta för detta.

Partikeleffekter

En partikel effekt använder ett stort antal små partiklar för att ge viss visuell effekt. De används ibland för saker som rök eller gnistor. Vi ska använda partiklar för vattendropparna i stänk.

Det första vi behöver är vår partikelklass:

Klasspartikel allmän vektor2 position; allmän vektor2 hastighet; offentlig float Orientering; offentlig partikel (vektor2 position, vektor2 hastighet, flottörorientering) Position = position; Hastighet = hastighet; Orientering = orientering; 

Denna klass håller bara de egenskaper en partikel kan ha. Därefter skapar vi en lista med partiklar.

Lista partiklar = ny lista();

Varje ram måste vi uppdatera och rita partiklarna.

void UpdateParticle (Partikelpartikel) const float Gravity = 0.3f; partikel.Velocity.Y + = Gravity; partikel.Position + = partikel.Velocity; partikel.Orientation = GetAngle (partikel.Velocity);  privat float GetAngle (Vector2 vector) returnera (float) Math.Atan2 (vector.Y, vector.X);  public void Update () foreach (var partikel i partiklar) UpdateParticle (partikel); // radera partiklar som är avskärmade eller under vattenpartiklar = partiklar.Where (x => x.Position.X> = 0 && x.Position.X <= 800 && x.Position.Y <= GetHeight(x.Position.X)).ToList(); 

Vi uppdaterar partiklarna för att falla under tyngdkraften och ställa in partikelns orientering för att matcha riktningen den går in. Vi släcker sedan några partiklar som är avskärmade eller under vatten genom att kopiera alla partiklar som vi vill behålla på en ny lista och tilldela det till partiklar. Nästa ritar vi partiklarna.

void DrawParticle (Particle particle) Vector2 ursprung = ny Vector2 (ParticleImage.Width, ParticleImage.Height) / 2f; spriteBatchDraw (ParticleImage, particle.Position, null, Color.White, partikel.Orientation, ursprung, 0,6f, 0, 0);  offentligt tomrum Draw () foreach (var partikel i partiklar) DrawParticle (partikel); 

Nedan är den textur som jag använde för partiklarna.

Nu, när vi skapar ett stänk gör vi en massa partiklar.

private void CreateSplashParticles (float xPosition, float speed) float y = GetHeight (xPosition); om (hastighet> 60) för (int i = 0; i < speed / 8; i++)  Vector2 pos = new Vector2(xPosition, y) + GetRandomVector2(40); Vector2 vel = FromPolar(MathHelper.ToRadians(GetRandomFloat(-150, -30)), GetRandomFloat(0, 0.5f * (float)Math.Sqrt(speed))); particles.Add(new Particle(pos, velocity, 0));   

Du kan ringa den här metoden från Stänk() metod vi använder för att göra vågor. Parameterhastigheten är hur snabbt vaggan träffar vattnet. Vi kommer att göra större stänk om klippan rör sig snabbare.

GetRandomVector2 (40) returnerar en vektor med en slumpmässig riktning och en slumpmässig längd mellan 0 och 40. Vi vill lägga till en liten slumpmässighet i positionerna så att partiklarna inte alla visas på en enda punkt. FromPolar () returnerar a Vector2 med en given riktning och längd.

Här är resultatet:

Använda Metaballs som partiklar

Våra stänk ser ganska anständigt ut, och några bra spel, som World of Goo, har partikel-effektstänk som ser ut som våra. Jag kommer emellertid att visa dig en teknik för att stänk ska se mer likformigt ut. Tekniken använder metaballer, organiskt utseende blobs som jag har skrivit en handledning om tidigare. Om du är intresserad av detaljer om metaballer och hur de fungerar, läs den handledningen. Om du bara vill veta hur man applicerar dem på våra stänk, fortsätt läsa.

Metaballer ser likformigt ut i det sätt de smälter samman, vilket gör dem till en bra match för våra flytande stänk. För att göra metaballen måste vi lägga till nya klassvariabler:

RenderTarget2D metaballTarget; AlphaTestEffect alphaTest;

Som vi initierar så här:

var view = GraphicsDevice.Viewport; metaballTarget = ny RenderTarget2D (GraphicsDevice, view.Width, view.Height); alphaTest = ny AlphaTestEffect (GraphicsDevice); alfaTest.ReferenceAlpha = 175; alfaTest.Projection = Matrix.CreateTranslation (-0.5f, -0.5f, 0) * Matrix.CreateOrthographicOffCenter (0, view.Width, view.Hight, 0, 0, 1);

Sedan ritar vi metaballerna:

GraphicsDevice.SetRenderTarget (metaballTarget); GraphicsDevice.Clear (Color.Transparent); Färg ljusblå = ny färg (0.2f, 0.5f, 1f); spriteBatch.Begin (0, BlendState.Additive); foreach (var partikel i partiklar) Vector2 ursprung = ny Vector2 (ParticleImage.Width, ParticleImage.Height) / 2f; spriteBatchDraw (ParticleImage, particle.Position, null, lightBlue, particle.Orientation, ursprung, 2f, 0, 0);  spriteBatch.End (); GraphicsDevice.SetRenderTarget (null); device.Clear (Color.CornflowerBlue); spriteBatch.Begin (0, null, null, null, null, alfaTest); spriteBatch.Draw (metaballTarget, Vector2.Zero, Color.White); spriteBatch.End (); // rita vågor och andra saker

Metaball-effekten beror på att du har en partikelstruktur som bleknar när du kommer längre från mitten. Här är vad jag använt, satt på en svart bakgrund för att göra den synlig:

Så här ser det ut:

Vattendropparna smälter nu samman när de är nära. De smälter dock inte med vattnets yta. Vi kan fixa detta genom att lägga till en gradient till vattens yta som gör att det gradvis bleknar ut och gör det till vårt metaball gör mål.

Lägg till följande kod till ovanstående metod före linjen GraphicsDevice.SetRendertarget (null):

primitiveBatch.Begin (PrimitiveType.TriangleList); const float tjocklek = 20; float scale = GraphicsDevice.Viewport.Width / (springs.Length - 1f); för (int i = 1; i < springs.Length; i++)  Vector2 p1 = new Vector2((i - 1) * scale, springs[i - 1].Height); Vector2 p2 = new Vector2(i * scale, springs[i].Height); Vector2 p3 = new Vector2(p1.X, p1.Y - thickness); Vector2 p4 = new Vector2(p2.X, p2.Y - thickness); primitiveBatch.AddVertex(p2, lightBlue); primitiveBatch.AddVertex(p1, lightBlue); primitiveBatch.AddVertex(p3, Color.Transparent); primitiveBatch.AddVertex(p3, Color.Transparent); primitiveBatch.AddVertex(p4, Color.Transparent); primitiveBatch.AddVertex(p2, lightBlue);  primitiveBatch.End();

Nu kommer partiklarna att smälta med vattenytan.

Lägga till den befellingseffekten

Vattenpartiklarna ser lite platt ut, och det skulle vara trevligt att ge dem lite skuggning. Helst skulle du göra det i en skugga. Men för att hålla denna handledning enkel, ska vi använda ett snabbt och enkelt trick: vi ska helt enkelt dra partiklarna tre gånger med olika färgtoner och förskjutningar, vilket illustreras i diagrammet nedan.

För att göra detta vill vi fånga metaballpartiklarna i ett nytt rendermål. Vi ska sedan rita det som gör mål en gång för varje nyans.

Förklara en ny RenderTarget2D precis som vi gjorde för metaballerna:

particlesTarget = nya RenderTarget2D (GraphicsDevice, view.Width, view.Hight);

Sedan istället för att rita metaballsTarget direkt till backbufferen vill vi dra den på particlesTarget. För att göra detta, gå till metoden där vi ritar metaballarna och ändrar bara dessa linjer:

GraphicsDevice.SetRenderTarget (null); device.Clear (Color.CornflowerBlue);

... till:

GraphicsDevice.SetRenderTarget (particlesTarget); device.Clear (Color.Transparent);

Använd sedan följande kod för att rita partiklarna tre gånger med olika tints och offsets:

Färg ljusblå = ny färg (0.2f, 0.5f, 1f); GraphicsDevice.SetRenderTarget (null); device.Clear (Color.CornflowerBlue); spriteBatch.Begin (); spriteBatchDraw (partiklarTarget, -Vector2.One, ny färg (0.8f, 0.8f, 1f)); spriteBatchDraw (partiklarTarget, Vector2.One, new Color (0f, 0f, 0.2f)); spriteBatch.Draw (partiklarTarget, Vector2.Zero, lightBlue); spriteBatch.End (); // rita vågor och andra saker

Slutsats

Det är det för grundläggande 2D vatten. För demo har jag lagt till en sten som du kan släppa in i vattnet. Jag drar vattnet med viss genomskinlighet ovanpå berget för att se ut som det är under vattnet och gör det långsamt när det är under vatten på grund av vattenbeständighet.

För att få demoen att se lite snyggare gick jag till opengameart.org och hittade en bild för berget och en himmelbakgrund. Du kan hitta rock och himmel på http://opengameart.org/content/rocks och opengameart.org/content/sky-backdrop respektive.