1. Code
  2. Game Development

Haga su juego Explotar con efectos de partículas y árboles cuaternarios

Scroll to top

Spanish (Español) translation by Elías Nicolás (you can also view the original English article)

¿Así que quieres explosiones, fuego, balas o hechizos mágicos en tu juego? Los sistemas de partículas producen grandes efectos gráficos sencillos para incrementar un poco su juego. Usted puede wow el jugador aún más haciendo partículas interactuar con su mundo, rebotando fuera del medio ambiente y otros jugadores. En este tutorial vamos a implementar algunos efectos de partículas simples, ya partir de aquí vamos a pasar a hacer que las partículas reboten fuera del mundo de ellos.

También optimizaremos las cosas implementando una estructura de datos llamada árbol cuaternario. Arbol cuaternario le permiten comprobar las colisiones mucho más rápido de lo que podría sin uno, y son fáciles de implementar y entender.

Nota: Aunque este tutorial está escrito usando HTML5 y JavaScript, debería ser capaz de usar las mismas técnicas y conceptos en casi cualquier entorno de desarrollo de juegos.

Para ver las demostraciones en el artículo, asegúrese de leer este artículo en Chrome, Firefox, IE 9 o cualquier otro navegador que admita HTML5 y Canvas.

Observe cómo las partículas cambian de color a medida que caen, y cómo rebotan en las formas.

¿Qué es un sistema de partículas?

Un sistema de partículas es una forma sencilla de generar efectos como el fuego, el humo y las explosiones.

Creas un emisor de partículas, y esto lanza pequeñas "partículas" que puedes mostrar como píxeles, cajas o pequeños mapas de bits. Siguen la física newtoniana simple y cambian de color a medida que se mueven, dando como resultado efectos dinámicos, personalizables y gráficos.


El inicio de un sistema de partículas

Nuestro sistema de partículas tendrá algunos parámetros personalizables:

  • Cuántas partículas muestra cada segundo.
  • Cuánto tiempo una partícula puede "vivir".
  • Los colores que cada partícula tendrá en la transición .
  • La posición y el ángulo de las partículas que se generan.
  • ¿Qué tan rápido irán las partículas cuando se reproducen?
  • ¿Cuánta gravedad deben afectar las partículas?

Si cada partícula produjera exactamente lo mismo, sólo tendríamos una corriente de partículas, no un efecto de partícula. Permítanos también permitir la variabilidad configurable. Esto nos da algunos parámetros más para nuestro sistema:

  • Cuánto puede variar su ángulo de lanzamiento.
  • Cuánto puede variar su velocidad inicial.
  • Cuánto de su vida puede variar.

Terminamos con una clase del sistema de partículas que comienza así:

1
2
function ParticleSystem(params) {
3
  //Default parameters

4
	this.params = {
5
		//Where particles spawn from

6
		pos : new Point(0, 0),
7
		
8
		//How many particles spawn every second

9
		particlesPerSecond : 100,
10
11
		//How long each particle lives (and how much this can vary)

12
		particleLife: 0.5,
13
		lifeVariation: 0.52,
14
15
		//The gradient of colors the particle will travel through

16
		colors: new Gradient([ new Color(255, 255, 255, 1), new Color(0, 0, 0, 0) ]),
17
18
		//The angle the particle will fire off at (and how much this can vary)

19
		angle: 0,
20
		angleVariation: Math.PI * 2,
21
22
		//The velocity range the particle will fire off at

23
		minVelocity : 20,
24
		maxVelocity : 50,
25
26
		//The gravity vector applied to each particle

27
		gravity: new Point(0, 30.8),
28
29
		//An object to test for collisions against, and bounce damping factor

30
		//for said collisions

31
		collider : null,
32
		bounceDamper: 0.5
33
	};
34
35
	//Override our default parameters with supplied parameters

36
	for (var p in params) {
37
		this.params[p] = params[p];
38
	}
39
40
	this.particles = [];
41
}

Haciendo el flujo del sistema

Cada marco necesitamos hacer tres cosas: crear nuevas partículas, mover las partículas existentes, y dibujar las partículas.

Creación de partículas

Crear partículas es bastante simple. Si estamos creando 300 partículas por segundo, y ha sido 0.05 segundos desde el último cuadro, creamos 15 partículas para el marco (que promedia a 300 por segundo).

Debemos tener un bucle simple que se parece a esto:

1
2
var newParticlesThisFrame = this.params.particlesPerSecond * frameTime;
3
for (var i = 0; i < newParticlesThisFrame; i++) {
4
	this.spawnParticle((1.0 + i) / newParticlesThisFrame * frameTime);
5
}

Nuestra función spawnParticle() crea una nueva partícula basada en los parámetros de nuestro sistema:

1
2
ParticleSystem.prototype.spawnParticle = function(offset) {
3
	//We want to fire the particle off at a random angle and a random velocity

4
	//within the parameters dictated for this system

5
	var angle = randVariation(this.params.angle, this.params.angleVariation);
6
	var speed = randRange(this.params.minVelocity, this.params.maxVelocity);
7
	var life = randVariation(this.params.particleLife, this.params.particleLife * this.params.lifeVariation);
8
9
	//Our initial velocity will be moving at the speed we chose above in the 

10
	//direction of the angle we chose

11
	var velocity = new Point().fromPolar(angle, speed);
12
13
	//If we created every single particle at "pos", then every particle 

14
	//created within one frame would start at the same place.  

15
	//Instead, we act as if we created the particle continuously between 

16
	//this frame and the previous frame, by starting it at a certain offset

17
	//along its path.

18
	var pos = this.params.pos.clone().add(velocity.times(offset));
19
20
	//Contruct a new particle object from the parameters we chose

21
	this.particles.push(new Particle(this.params, pos, velocity, life));
22
};

Elegimos nuestra velocidad inicial desde un ángulo y velocidad aleatorios. A continuación, utilizamos el método fromPolar() para crear un vector de velocidad cartesiana a partir de la combinación ángulo / velocidad.

La trigonometría básica produce el método fromPolar:

1
2
Point.prototype.fromPolar = function(ang, rad) {
3
	this.x = Math.cos(ang) * rad;
4
	this.y = Math.sin(ang) * rad;
5
6
	return this;
7
};

Si necesita un poco de trigonometría un poco, toda la trigonometría que estamos utilizando se deriva del Unit Circle.

Movimiento de Partículas

El movimiento de partículas sigue las leyes newtonianas básicas. Las partículas tienen una velocidad y posición. Nuestra velocidad actúa sobre la fuerza de la gravedad, y nuestra posición cambia proporcionalmente a la gravedad. Finalmente necesitamos seguir la vida de cada partícula, de lo contrario las partículas nunca morirían, terminaríamos teniendo demasiadas y el sistema se pararía. Todas estas acciones se producen proporcionalmente al tiempo entre fotogramas.

1
2
Particle.prototype.step = function(frameTime) {
3
	this.velocity.add(this.params.gravity.times(frameTime));
4
	this.pos.add(this.velocity.times(frameTime));
5
	this.life -= frameTime;
6
};

Dibujo de las Partículas

Finalmente tenemos que dibujar nuestras partículas. La forma en que implementa esto en su juego variará mucho de una plataforma a otra, y lo avanzado que desea que sea la representación. Esto puede ser tan simple como colocar un solo píxel de color, para mover un par de triángulos para cada partícula, dibujado por un sombreado de GPU complejo.

En nuestro caso, aprovecharemos la API de lienzo para dibujar un pequeño rectángulo para la partícula.

1
2
Particle.prototype.draw = function(ctx, frameTime) {
3
	//No need to draw the particle if it is out of life.

4
	if (this.isDead())
5
		return;
6
7
	//We want to travel through our gradient of colors as the particle ages

8
	var lifePercent = 1.0 - this.life / this.maxLife;
9
	var color = this.params.colors.getColor(lifePercent);
10
	
11
	//Set up the colors

12
	ctx.globalAlpha = color.a;
13
	ctx.fillStyle = color.toCanvasColor();
14
15
	//Fill in the rectangle at the particle's position

16
	ctx.fillRect(this.pos.x - 1, this.pos.y - 1, 3, 3);
17
};

La interpolación de color depende de si la plataforma que está utilizando proporciona una clase de color (o formato de representación), si proporciona un interpolador para usted y cómo desea abordar todo el problema. Escribí una clase pequeña del gradiente que permite la interpolación fácil entre los colores múltiples, y una clase pequeña del color que proporciona la funcionalidad para interpolar entre cualesquiera dos colores.

1
2
Color.prototype.interpolate = function(percent, other) {
3
	return new Color(
4
		this.r + (other.r - this.r) * percent,
5
		this.g + (other.g - this.g) * percent,
6
		this.b + (other.b - this.b) * percent,
7
		this.a + (other.a - this.a) * percent);
8
};
9
10
11
Gradient.prototype.getColor = function(percent) {
12
	//Floating point color location within the array

13
	var colorF = percent * (this.colors.length - 1);
14
15
	//Round down; this is the specified color in the array 

16
	//below our current color

17
	var color1 = parseInt(colorF);
18
	//Round up; this is the specified color in the array 

19
	//above our current color

20
	var color2 = parseInt(colorF + 1);
21
22
	//Interpolate between the two nearest colors (using above method)

23
	return this.colors[color1].interpolate(
24
			(colorF - color1) / (color2 - color1),
25
			this.colors[color2]
26
		);
27
};

¡Aquí está nuestro sistema de partículas en acción!

Rebote de Partículas

Como se puede ver en la demostración anterior, ahora tenemos algunos efectos básicos de partículas. Sin embargo, carecen de interacción con el entorno que los rodea. Para hacer que estos efectos formen parte de nuestro mundo de juego, vamos a hacer que reboten fuera de las paredes a su alrededor.

Para empezar, el sistema de partículas tomará ahora un colisionador como parámetro. Será tarea del colisionador decirle a una partícula si se ha estrellado contra cualquier cosa. El método step() de una partícula ahora se parece a esto:

1
2
Particle.prototype.step = function(frameTime) {
3
	//Save our last position

4
	var lastPos = this.pos.clone();
5
6
	//Move

7
	this.velocity.add(this.params.gravity.times(frameTime));
8
	this.pos.add(this.velocity.times(frameTime));
9
10
11
	//Can this particle bounce?

12
	if (this.params.collider) {
13
		
14
		//Check if we hit anything

15
		var intersect = this.params.collider.getIntersection(
16
					new Line(lastPos, this.pos)
17
				);
18
		if (intersect != null) {
19
			//If so, we reset our position, and update our velocity 

20
			//to reflect the collision

21
			this.pos = lastPos;
22
			this.velocity = intersect.seg.reflect(this.velocity).times(this.params.bounceDamper);
23
		}
24
	}
25
26
	this.life -= frameTime;
27
};

Ahora cada vez que la partícula se mueve, le preguntamos al colisionador si su trayectoria de movimiento ha "colisionado" a través del método getIntersection(). Si es así, reajustamos su posición (de modo que no esté dentro de lo que intersectó), y reflejamos la velocidad.

Una implementación básica de "colisionador" podría tener este aspecto:

1
2
//Takes a collection of line segments representing the game world

3
function Collider(lines) {
4
	this.lines = lines;
5
}
6
7
//Returns any line segment intersected by "path", otherwise null

8
Collider.prototype.getIntersection = function(path) {
9
	for (var i = 0; i < this.lines.length; i++) {
10
		var intersection = this.lines[i].getIntersection(path);
11
		if (intersection)
12
			return intersection;	
13
	}
14
15
	return null;
16
};

¿Notó un problema? Cada partícula necesita llamar a collider.getIntersection() y luego cada llamada getIntersection debe comprobar contra cada "pared" en el mundo. Si tiene 300 partículas (tipo de un número bajo) y 200 paredes en su mundo (no es razonable tampoco), está realizando 60.000 pruebas de intersección de líneas! Esto podría retrasar su juego, especialmente con más partículas (o mundos más complejos).


Detección más rápida de colisiones con árboles cuaternarios

El problema con nuestro colisionador simple es que comprueba cada pared para cada partícula. Si nuestra partícula está en el cuadrante de la parte superior derecha de la pantalla, no deberíamos perder el tiempo comprobando si se estrelló contra paredes que sólo están en la parte inferior o izquierda de la pantalla. Así que idealmente queremos recortar cualquier control de intersecciones fuera del cuadrante superior derecho:

Particle effects and quadtreesParticle effects and quadtreesParticle effects and quadtrees
Sólo comprobamos si hay colisiones entre el punto azul y las líneas rojas.

Eso es sólo una cuarta parte de los cheques! Ahora vamos más allá: si la partícula está en el cuadrante superior izquierdo del cuadrante superior derecho de la pantalla, sólo debemos comprobar aquellas paredes en el mismo cuadrante:

Particle effects and quadtreesParticle effects and quadtreesParticle effects and quadtrees

El Árbol cuaternario le permiten hacer exactamente esto! En lugar de probar contra todas las paredes, dividir paredes en los cuadrantes y sub-cuadrantes que ocupan, por lo que sólo hay que comprobar algunos cuadrantes. Puede pasar fácilmente de 200 cheques por partícula a sólo 5 o 6.

Los pasos para crear un árbol cuaternario son los siguientes:

  1. Comience con un rectángulo que llena toda la pantalla.
  2. Tome el rectángulo actual, cuente cuántas "paredes" caen dentro de él.
  3. Si tiene más de tres líneas (puede elegir un número diferente), divida el rectángulo en cuatro cuadrantes iguales. Repita el paso 2 con cada cuadrante.
  4. Después de repetir los pasos 2 y 3, se termina con un "árbol" de rectángulos, con ninguno de los rectángulos más pequeños que contienen más de tres líneas (o lo que usted eligió).
Particle effects and quadtreesParticle effects and quadtreesParticle effects and quadtrees
Construyendo un árbol cuaternario. Los números representan el número de líneas dentro del cuadrante, siendo el rojo demasiado alto y necesitando subdividirlo.

Para construir nuestro árbol cuaternario tomamos un conjunto de "muros" (segmentos de línea) como un parámetro, y si demasiados están contenidos dentro de nuestro rectángulo, subdividimos en rectángulos más pequeños, y el proceso se repite.

1
2
QuadTree.prototype.addSegments = function(segs) {
3
	for (var i = 0; i < segs.length; i++) {
4
		if (this.rect.overlapsWithLine(segs[i])) {
5
			this.segs.push(segs[i]);
6
		}
7
	}
8
9
	if (this.segs.length > 3) {
10
		this.subdivide();
11
	}
12
};
13
14
QuadTree.prototype.subdivide = function() {
15
	var w2 = this.rect.w / 2,
16
	    h2 = this.rect.h / 2,
17
	    x = this.rect.x,
18
	    y = this.rect.y;
19
20
	this.quads.push(new QuadTree(x, y, w2, h2));
21
	this.quads.push(new QuadTree(x + w2, y, w2, h2));
22
	this.quads.push(new QuadTree(x + w2, y + h2, w2, h2));
23
	this.quads.push(new QuadTree(x, y + h2, w2, h2));
24
25
	for (var i = 0; i < this.quads.length; i++) {
26
		this.quads[i].addSegments(this.segs);
27
	}
28
29
	this.segs = [];
30
};

Puede ver la clase completa del árbol cuaternario aquí:

1
2
/**

3
 * @constructor

4
 */
5
function QuadTree(x, y, w, h) {
6
	this.thresh = 4;
7
	this.segs = [];
8
	this.quads = [];
9
10
	this.rect = new Rect2D(x, y, w, h);
11
}
12
13
QuadTree.prototype.addSegments = function(segs) {
14
	for (var i = 0; i < segs.length; i++) {
15
		if (this.rect.overlapsWithLine(segs[i])) {
16
			this.segs.push(segs[i]);
17
		}
18
	}
19
20
	if (this.segs.length > this.thresh) {
21
		this.subdivide();
22
	}
23
};
24
25
QuadTree.prototype.getIntersection = function(seg) {
26
	if (!this.rect.overlapsWithLine(seg))
27
		return null;
28
29
	for (var i = 0; i < this.segs.length; i++) {
30
		var s = this.segs[i];
31
		var inter = s.getIntersection(seg);
32
		if (inter) {
33
			var o = {};
34
			return s;
35
		}
36
	}
37
38
	for (var i = 0; i < this.quads.length; i++) {
39
		var inter = this.quads[i].getIntersection(seg);
40
		if (inter)
41
			return inter;
42
	}
43
44
	return null;
45
};
46
47
QuadTree.prototype.subdivide = function() {
48
	var w2 = this.rect.w / 2,
49
	    h2 = this.rect.h / 2,
50
	    x = this.rect.x,
51
	    y = this.rect.y;
52
53
	this.quads.push(new QuadTree(x, y, w2, h2));
54
	this.quads.push(new QuadTree(x + w2, y, w2, h2));
55
	this.quads.push(new QuadTree(x + w2, y + h2, w2, h2));
56
	this.quads.push(new QuadTree(x, y + h2, w2, h2));
57
58
	for (var i = 0; i < this.quads.length; i++) {
59
		this.quads[i].addSegments(this.segs);
60
	}
61
62
	this.segs = [];
63
};
64
65
QuadTree.prototype.display = function(ctx, mx, my, ibOnly) {
66
67
	var inBox = this.rect.containsPoint(new Point(mx, my));
68
69
	ctx.strokeStyle = inBox ? '#FF44CC' : '#000000';
70
71
	if (inBox || !ibOnly) {
72
		ctx.strokeRect(this.rect.x, this.rect.y, this.rect.w, this.rect.h);
73
		for (var i = 0; i < this.quads.length; i++) {
74
			this.quads[i].display(ctx, mx, my, ibOnly);
75
		}
76
	}
77
78
	if (inBox) {
79
		ctx.strokeStyle = '#FF0000';
80
		for (var i = 0 ; i < this.segs.length; i++) {
81
			var s = this.segs[i];
82
			ctx.beginPath();
83
			ctx.moveTo(s.a.x, s.a.y);
84
			ctx.lineTo(s.b.x, s.b.y);
85
			ctx.stroke();
86
		}
87
	}
88
};

La prueba para la intersección con un segmento de línea se realiza de una manera similar. Para cada rectángulo hacemos lo siguiente:

  1. Comience con el rectángulo más grande en el árbol cuaternario.
  2. Compruebe si el segmento de línea intersecta o está dentro del rectángulo actual. Si no lo hace, no se moleste en hacer más pruebas en este camino.
  3. Si el segmento de línea cae dentro del rectángulo actual o lo cruza, compruebe si el rectángulo actual tiene algún rectángulo hijo. Si lo hace, vuelva al Paso 2, pero usando cada uno de los rectángulos hijos.
  4. Si el rectángulo actual no tiene rectángulos hijos, pero es un nodo hoja (es decir, sólo tiene segmentos de línea como hijos), pruebe el segmento de línea de destino con esos segmentos de línea. Si se trata de una intersección, devuelva la intersección. ¡Hemos terminado!
Particle effects and quadtreesParticle effects and quadtreesParticle effects and quadtrees
Buscando un árbol cuaternario. Comenzamos en el rectángulo más grande y buscamos pequeños y pequeños, hasta finalmente probar segmentos de línea individuales. Con el árbol cuaternario, sólo realizamos cuatro pruebas de rectángulo y dos pruebas de línea, en lugar de probar contra todos los 21 segmentos de línea. La diferencia sólo crece más dramática con conjuntos de datos más grandes.
1
2
QuadTree.prototype.getIntersection = function(seg) {
3
	if (!this.rect.overlapsWithLine(seg))
4
		return null;
5
6
	for (var i = 0; i < this.segs.length; i++) {
7
		var s = this.segs[i];
8
		var inter = s.getIntersection(seg);
9
		if (inter) {
10
			var o = {};
11
			return s;
12
		}
13
	}
14
15
	for (var i = 0; i < this.quads.length; i++) {
16
		var inter = this.quads[i].getIntersection(seg);
17
		if (inter)
18
			return inter;
19
	}
20
21
	return null;
22
};

Una vez que pasamos un objeto QuadTree a nuestro sistema de partículas como el "colisionador", obtenemos una búsqueda rápida. Echa un vistazo a la demostración interactiva a continuación - utilizar el ratón para ver qué segmentos de línea el quadtree necesitaría probar en contra!


Coloque el cursor sobre un (sub-)cuadrante para ver qué segmentos de línea contiene.

Alimento para el pensamiento

El sistema de partículas y el árbol cuaternario presentados en este artículo son sistemas de enseñanza rudimentarios. Algunas otras ideas que quizás quiera considerar al implementarlas usted mismo:

  • Es posible que desee mantener objetos además de segmentos de línea en el quadtree. ¿Cómo lo expandirías para incluir círculos? ¿Cuadrícula?
  • Es posible que desee una forma de recuperar objetos individuales (para notificarles que han sido golpeados por una partícula), mientras sigue recuperando segmentos reflectantes.
  • Las ecuaciones físicas sufren de discrepancias que las ecuaciones de Euler se acumulan con el tiempo con tasas de cuadros inestables. Si bien esto no suele importar para un sistema de partículas, ¿por qué no leer sobre las ecuaciones de movimiento más avanzadas? (Eche un vistazo a este tutorial, por ejemplo.)
  • Hay muchas maneras de almacenar la lista de partículas en la memoria. Un arreglo es el más simple pero puede no ser la mejor opción dado que las partículas se quitan a menudo del sistema y otras nuevas se insertan a menudo. Una lista enlazada puede encajar mejor, pero tiene mala localidad de caché. La mejor representación de las partículas puede depender del marco o el lenguaje que está utilizando.