Gör ditt spel pop med partikel effekter och quadtrees

Så du vill ha explosioner, eld, kulor eller magiska stavar i ditt spel? Partikelsystem gör stora enkla grafiska effekter för att krydda ditt spel lite. Du kan wow spelaren ännu mer genom att få partiklar att interagera med din värld, studsa av miljön och andra spelare. I denna handledning kommer vi att genomföra några enkla partikeleffekter, och här kommer vi att fortsätta att göra partiklarna studsa av världen runt dem.

Vi kommer också att optimera saker genom att implementera en datastruktur kallad quadtree. Quadtrees gör att du kan kontrollera kollisioner mycket snabbare än du kan utan en, och de är enkla att implementera och förstå.

Notera: Även om denna handledning skrivs med HTML5 och JavaScript, bör du kunna använda samma tekniker och begrepp i nästan vilken spelutvecklingsmiljö som helst.

För att se demo-artikeln, se till att du läser den här artikeln i Chrome, Firefox, IE 9 eller någon annan webbläsare som stöder HTML5 och Canvas.
Lägg märke till hur partiklarna byter färg när de faller och hur de studsar av formerna.

Vad är ett partikelsystem?

Ett partikelsystem är ett enkelt sätt att generera effekter som brand, rök och explosioner.

Du skapar en partikelemitterare, och detta lanserar små "partiklar" som du kan visa som pixlar, lådor eller små bitmappar. De följer enkla newtonska fysik och byter färg när de rör sig, vilket resulterar i dynamiska, anpassningsbara, grafiska effekter.


Starten av ett partikelsystem

Vårt partikelsystem kommer att ha några inställbara parametrar:

  • Hur många partiklar spetsar det varje sekund.
  • Hur länge en partikel kan "leva".
  • Färgerna varje partikel kommer att övergå till.
  • Den position och vinkel partiklarna kommer att hälla från.
  • Hur fort partiklarna kommer att gå när de hyser.
  • Hur mycket gravitation bör påverka partiklar.

Om varje partikel gödde exakt samma, skulle vi bara ha en ström av partiklar, inte en partikeleffekt. Så låt oss också tillåta konfigurerbar variabilitet. Detta ger oss några fler parametrar för vårt system:

  • Hur mycket deras startvinkel kan variera.
  • Hur mycket deras initialhastighet kan variera.
  • Hur mycket deras livstid kan variera.

Vi hamnar med en partikelsystemsklass som börjar så här:

 Funktion ParticleSystem (params) // Standardparametrar this.params = // Där partiklar hyser från pos: new Point (0, 0), // Hur många partiklar spolas varje sekund partiklarPerSekund: 100, // Hur länge varje partikel lever (och hur mycket detta kan variera) particleLife: 0.5, lifeVariation: 0.52, // Färggradienten partikeln kommer att resa genom färger: Ny Gradient ([ny färg (255, 255, 255, 1), ny färg (0, 0, 0, 0)]), // Vinkeln som partikeln kommer att släcka vid (och hur mycket detta kan variera) Vinkel: 0, VinkelVariation: Math.PI * 2, // Hastighetsintervallet som partikeln kommer att avfyra vid minVelocity: 20, maxVelocity: 50, // Gravitationsvektorn applicerad på varje partikel gravitation: Ny punkt (0, 30.8), // Ett objekt att testa för kollisioner mot och studsa dämpningsfaktor // för nämnda kollisioner kollider: null, bounceDamper: 0.5; // Åsidosätta våra standardparametrar med angivna parametrar för (var p i parametrar) this.params [p] = params [p];  this.particles = []; 

Göra systemflödet

Varje ram måste vi göra tre saker: skapa nya partiklar, flytta befintliga partiklar och dra partiklarna.

Skapa partiklar

Att skapa partiklar är ganska enkelt. Om vi ​​skapar 300 partiklar per sekund, och det har varit 0,05 sekunder sedan den sista bilden skapar vi 15 partiklar för ramen (som är genomsnittliga till 300 per sekund).

Vi borde ha en enkel slinga som ser ut så här:

 var newParticlesThisFrame = this.params.particlesPerSecond * frameTime; för (var i = 0; i < newParticlesThisFrame; i++)  this.spawnParticle((1.0 + i) / newParticlesThisFrame * frameTime); 

Vår spawnParticle () funktion skapar en ny partikel baserat på våra systemparametrar:

 ParticleSystem.prototype.spawnParticle = function (offset) // Vi vill avfyra partikeln i en slumpvinkel och en slumpmässig hastighet // inom parametrarna dikterade för detta system varvinkel = randVariation (this.params.angle, this. params.angleVariation); var hastighet = randRange (this.params.minVelocity, this.params.maxVelocity); var liv = randVariation (this.params.particleLife, this.params.particleLife * this.params.lifeVariation); // Vår initialhastighet kommer att röra sig med den hastighet vi valde ovan i // riktningen av den vinkel vi valde varhastighet = Ny punkt (). FrånPolar (vinkel, hastighet); // Om vi ​​skapade varje enskild partikel vid "pos", skulle varje partikel // som skapades i en ram startas på samma plats. // I stället fungerar vi som om vi skapade partikeln kontinuerligt mellan // den här ramen och den föregående ramen, genom att starta den med en viss förskjutning // längs dess väg. var pos = this.params.pos.clone (). add (hastighet.times (offset)); // Konstruera ett nytt partikelobjekt från parametrarna vi valde this.particles.push (new Particle (this.params, pos, hastighet, liv)); ;

Vi väljer vår initialhastighet från slumpvis vinkel och hastighet. Vi använder sedan fromPolar () metod för att skapa en kartesisk hastighetsvektor från vinkel / hastighetskombinationen.

Grundläggande trigonometri ger fromPolar metod:

 Point.prototype.fromPolar = funktion (ang, rad) this.x = Math.cos (ang) * rad; this.y = Math.sin (ang) * rad; returnera detta; ;

Om du behöver borsta på trigonometri lite, är allt trigonometri som vi använder härledda från Unit Circle.

Partikelrörelse

Partikelrörelse följer grundläggande newtonska lagar. Partiklar har alla hastighet och position. Vår hastighet påverkas av tyngdkraften, och vår position förändras proportionellt mot gravitationen. Slutligen måste vi hålla koll på varje partikels liv, annars skulle partiklar aldrig dö, vi skulle sluta ha för många, och systemet skulle halka ner. Alla dessa åtgärder sker proportionellt till tiden mellan ramar.

 Particle.prototype.step = function (frameTime) this.velocity.add (this.params.gravity.times (frameTime)); this.pos.add (this.velocity.times (frameTime)); this.life - = frameTime; ;

Ritningspartiklar

Slutligen måste vi rita våra partiklar. Hur du implementerar detta i ditt spel varierar kraftigt från plattform till plattform, och hur avancerad du vill att rendering ska vara. Detta kan vara lika enkelt som att placera en enda färgad pixel för att flytta ett par trianglar för varje partikel, ritad av en komplex GPU-skuggare.

I vårt fall utnyttjar vi Canvas API för att rita en liten rektangel för partikeln.

 Particle.prototype.draw = funktion (ctx, frameTime) // Ingen anledning att dra partikeln om den är borta. om (this.isDead ()) returnerar; // Vi vill resa genom vår färggradient eftersom partikelålderna var lifePercent = 1.0 - this.life / this.maxLife; var färg = this.params.colors.getColor (lifePercent); // Ställ in färgerna ctx.globalAlpha = color.a; ctx.fillStyle = color.toCanvasColor (); // Fyll i rektangeln vid partikelns position ctx.fillRect (this.pos.x - 1, this.pos.y - 1,3,3); ;

Färginterpolering beror på om plattformen du använder levererar en färgklass (eller representationsformat), om det ger en interpolator för dig och hur du vill närma sig hela problemet. Jag skrev en liten gradient klass som möjliggör enkel interpolering mellan flera färger och en liten färg klass som ger funktionaliteten att interpolera mellan två färger.

 Color.prototype.interpolate = funktion (procent, annat) returnera ny färg (this.r + (other.r - this.r) * procent, this.g + (other.g - this.g) * procent, detta .b + (andra.b - this.b) * procent, this.a + (andra.a - this.a) * procent); ; Gradient.prototype.getColor = funktion (procent) // Flytpunktsfärg plats inom array var colorF = percent * (this.colors.length - 1); //Avrunda nedåt; Detta är den angivna färgen i arrayen // under vår nuvarande färg var color1 = parseInt (colorF); //Runda upp; Detta är den angivna färgen i arrayen // ovanför vår nuvarande färg var color2 = parseInt (colorF + 1); // Interpolera mellan de två närmaste färgerna (med hjälp av ovanstående metod) returnera this.colors [color1] .interpolate ((colorF - color1) / (color2 - color1), this.colors [color2]); ;

Här är vårt partikelsystem i aktion!

Studsande partiklar

Som du kan se i demo ovan, nu har vi några grundläggande partikeleffekter. De saknar dock någon interaktion med omgivningen kring dem. För att göra dessa effekter till en del av vår spelvärld kommer vi att få dem att studsa av väggarna runt dem.

För att starta kommer partikelsystemet nu att ta en collider som en parameter. Det kommer att vara colliderens jobb att berätta för en partikel om den har kraschat in i någonting. De steg() Metoden för en partikel ser nu ut så här:

 Particle.prototype.step = function (frameTime) // Spara vår senaste position var lastPos = this.pos.clone (); // Flytta this.velocity.add (this.params.gravity.times (frameTime)); this.pos.add (this.velocity.times (frameTime)); // Kan denna partikel studsa? om (this.params.collider) // Kontrollera om vi slår vad som helst som skärs = this.params.collider.getIntersection (new Line (lastPos, this.pos)); om (korsa! = null) // Om så, vi återställer vår position och uppdaterar vår hastighet // för att återspegla kollisionen this.pos = lastPos; this.velocity = intersect.seg.reflect (this.velocity) x (this.params.bounceDamper);  this.life - = frameTime; ;

Nu varje gång partikeln rör sig, frågar vi kollideraren om dess rörelsebana har "kolliderat" via getIntersection () metod. Om så är fallet återställer vi sin position (så att den inte ligger inuti vad den korsade) och reflekterar hastigheten.

Ett grundläggande "collider" -implementering kan se ut så här:

 // Tar en samling linjesegment som representerar spelvärldsfunktionen Collider (linjer) this.lines = lines;  // Returnerar något linjesegment som skärs av "sökväg", annars null Collider.prototype.getIntersection = funktion (sökväg) för (var i = 0; i < this.lines.length; i++)  var intersection = this.lines[i].getIntersection(path); if (intersection) return intersection;  return null; ;

Lägg märke till ett problem? Varje partikel behöver ringa collider.getIntersection () och sedan varje getIntersection samtal måste kontrolleras mot varje "vägg" i världen. Om du har 300 partiklar (typ av lågt antal) och 200 väggar i din värld (inte orimligt), utför du 60 000 linjekorsningstest! Detta skulle kunna stoppa ditt spel, särskilt med fler partiklar (eller mer komplexa världar).


Snabbare kollisionsdetektion med quadtrees

Problemet med vår enkla collider är att det kontrollerar varje vägg för varje partikel. Om vår partikel ligger i skärmens övre högra kvadrant, borde vi inte slösa bort tiden om det kraschade i väggar som bara finns längst ner eller till vänster på skärmen. Så idealiskt vill vi klippa ut några kontroller för korsningar utanför den högra högra kvadranten:


Vi söker bara efter kollisioner mellan den blå punkten och de röda linjerna.

Det är bara en fjärdedel av kontrollerna! Låt oss gå ännu längre: om partikeln befinner sig i den övre vänstra kvadranten i skärmens övre högra kvadrant, behöver vi bara kolla dessa väggar i samma kvadrant:

Quadtrees låter dig göra just detta! Snarare än att testa mot Allt väggar delar du väggar i kvadranterna och subkvadranterna de upptar, så du behöver bara kolla några kvadranter. Du kan enkelt gå från 200 kontroller per partikel till bara 5 eller 6.

Stegen för att skapa en quadtree är som följer:

  1. Börja med en rektangel som fyller hela skärmen.
  2. Ta den nuvarande rektangeln, räkna hur många "väggar" faller inuti den.
  3. Om du har mer än tre linjer (du kan välja ett annat tal), dela rektangeln i fyra lika kvadranter. Upprepa steg 2 med varje kvadrant.
  4. Efter att du har repeterat steg 2 och 3 slutar du med ett "träd" av rektanglar, med ingen av de minsta rektanglarna som innehåller mer än tre linjer (eller vad du än väljer).

Bygga en quadtree. Numren representerar antalet linjer inom kvadranten, rött är för högt och behöver delas upp.

För att bygga vår quadtree tar vi en uppsättning "väggar" (linjesegment) som en parameter, och om alltför många finns i vår rektangel delas vi in ​​i mindre rektanglar och processen upprepas.

 QuadTree.prototype.addSegments = funktion (segs) for (var i = 0; i < segs.length; i++)  if (this.rect.overlapsWithLine(segs[i]))  this.segs.push(segs[i]);   if (this.segs.length > 3) this.subdivide (); ; QuadTree.prototype.subdivide = function () var w2 = this.rect.w / 2, h2 = this.rect.h / 2, x = this.rect.x, y = this.rect.y; this.quads.push (new QuadTree (x, y, w2, h2)); this.quads.push (new QuadTree (x + w2, y, w2, h2)); this.quads.push (new QuadTree (x + w2, y + h2, w2, h2)); this.quads.push (ny QuadTree (x, y + h2, w2, h2)); för (var i = 0; i < this.quads.length; i++)  this.quads[i].addSegments(this.segs);  this.segs = []; ;

Du kan se hela QuadTree-klassen här:

 / ** * @constructor * / function QuadTree (x, y, w, h) this.thresh = 4; this.segs = []; this.quads = []; this.rect = new Rect2D (x, y, w, h);  QuadTree.prototype.addSegments = funktion (segs) for (var i = 0; i < segs.length; i++)  if (this.rect.overlapsWithLine(segs[i]))  this.segs.push(segs[i]);   if (this.segs.length > this.thresh) this.subdivide (); ; QuadTree.prototype.getIntersection = funktion (seg) om (! This.rect.overlapsWithLine (seg)) returnera null; för (var i = 0; i < this.segs.length; i++)  var s = this.segs[i]; var inter = s.getIntersection(seg); if (inter)  var o = ; return s;   for (var i = 0; i < this.quads.length; i++)  var inter = this.quads[i].getIntersection(seg); if (inter) return inter;  return null; ; QuadTree.prototype.subdivide = function()  var w2 = this.rect.w / 2, h2 = this.rect.h / 2, x = this.rect.x, y = this.rect.y; this.quads.push(new QuadTree(x, y, w2, h2)); this.quads.push(new QuadTree(x + w2, y, w2, h2)); this.quads.push(new QuadTree(x + w2, y + h2, w2, h2)); this.quads.push(new QuadTree(x, y + h2, w2, h2)); for (var i = 0; i < this.quads.length; i++)  this.quads[i].addSegments(this.segs);  this.segs = []; ; QuadTree.prototype.display = function(ctx, mx, my, ibOnly)  var inBox = this.rect.containsPoint(new Point(mx, my)); ctx.strokeStyle = inBox ? '#FF44CC' : '#000000'; if (inBox || !ibOnly)  ctx.strokeRect(this.rect.x, this.rect.y, this.rect.w, this.rect.h); for (var i = 0; i < this.quads.length; i++)  this.quads[i].display(ctx, mx, my, ibOnly);   if (inBox)  ctx.strokeStyle = '#FF0000'; for (var i = 0 ; i < this.segs.length; i++)  var s = this.segs[i]; ctx.beginPath(); ctx.moveTo(s.a.x, s.a.y); ctx.lineTo(s.b.x, s.b.y); ctx.stroke();   ;

Testning för skärning mot ett linjesegment utförs på ett liknande sätt. För varje rektangel gör vi följande:

  1. Börja med den största rektangeln i quadtree.
  2. Kontrollera om linjesegmentet skär eller ligger inuti den aktuella rektangeln. Om det inte gör det, bry dig inte om att göra mer testning på den här vägen.
  3. Om linjesegmentet faller inom den aktuella rektangeln eller skär den, kontrollera om den aktuella rektangeln har några barnrektanglar. Om det gör, gå tillbaka till steg 2, men använd vart och ett av barnrektanglarna.
  4. Om den nuvarande rektangeln inte har barnrektanglar men det är a bladkod (det vill säga den har endast linjesegment som barn), testa mållinjesegmentet mot de här linjesegmenten. Om man är ett korsning, returnera korsningen. Vi är klara!

Söker på en Quadtree. Vi börjar vid den största rektangeln och letar efter mindre och mindre, tills vi äntligen testar enskilda linjesegment. Med quadtree utför vi endast fyra rektangeltest och två linjetester istället för att testa mot alla 21 linjesegment. Skillnaden blir bara mer dramatisk med större datasatser.
 QuadTree.prototype.getIntersection = funktion (seg) om (! This.rect.overlapsWithLine (seg)) returnera null; för (var i = 0; i < this.segs.length; i++)  var s = this.segs[i]; var inter = s.getIntersection(seg); if (inter)  var o = ; return s;   for (var i = 0; i < this.quads.length; i++)  var inter = this.quads[i].getIntersection(seg); if (inter) return inter;  return null; ;

När vi passerar en quadtree protesterar mot vårt partikelsystem som "collider" vi får blixtiga snabba uppslag. Kolla in den interaktiva demon nedan - använd musen för att se vilka linjesegment som quadtree skulle behöva testa mot!


Håll över en (sub) kvadrant för att se vilka linjesegment den innehåller.

Något att tänka på

Partikelsystemet och quadtree som presenteras i denna artikel är rudimentära undervisningssystem. Några andra idéer du kanske vill överväga när du implementerar dessa själv:

  • Du kanske vill hålla objekt utöver linjesegment i quadtree. Hur skulle du expandera den för att inkludera cirklar? Squares?
  • Du kanske vill ha ett sätt att hämta enskilda objekt (för att meddela dem att de har drabbats av en partikel), medan du fortfarande hämtar reflekterbara segment.
  • Fysikekvationerna lider av skillnader som Euler-ekvationer bygger upp över tiden med instabila ramhastigheter. Medan det här inte generellt kommer att göra något för ett partikelsystem, varför läser du inte på mer avancerade rörelseförhållanden? (Ta en titt på denna handledning, till exempel.)
  • Det finns många sätt att lagra partikellistan i minnet. En array är enklaste men kanske inte det bästa valet, eftersom partiklar ofta tas bort från systemet och nya är ofta införda. En länkad lista kan passa bättre men har dålig cacheplads. Den bästa representationen för partiklar kan bero på det ramverk eller språk du använder.
relaterade inlägg
  • Använd Quadtrees för att upptäcka sannolika kollisioner i 2D-utrymme