Cómo generar efectos de relámpagos 2D sorprendentemente buenos
Spanish (Español) translation by Elías Nicolás (you can also view the original English article)
Lightning tiene muchos usos en los juegos, desde el ambiente de fondo durante una tormenta hasta los devastadores ataques de un hechicero. En este tutorial, explicaré cómo generar genialmente impresionantes efectos de relámpago 2D: pernos, ramas e incluso texto.
Nota: Aunque este tutorial está escrito usando C # y XNA, debería ser capaz de usar las mismas técnicas y conceptos en casi cualquier entorno de desarrollo de juegos.Vista previa del video final
Vista previa del video
Paso 1: Dibuje una línea brillante
El bloque de construcción básico que necesitamos para hacer un rayo es un segmento de línea. Empiece abriendo su software de edición de imágenes favorito y dibujando una línea recta de rayos. Aquí está como se ve el mío:

Queremos dibujar líneas de diferentes longitudes, por lo que vamos a cortar el segmento de línea en tres piezas como se muestra a continuación. Esto nos permitirá estirar el segmento medio a cualquier longitud que nos guste. Puesto que vamos a estar estirando el segmento medio, podemos guardarlo como sólo un píxel de espesor. Además, como las piezas izquierda y derecha son imágenes especulares entre sí, sólo necesitamos guardar una de ellas. Podemos voltearlo en el código.

Ahora, vamos a declarar una nueva clase para manejar segmentos de línea de dibujo:
1 |
public class Line |
2 |
{
|
3 |
public Vector2 A; |
4 |
public Vector2 B; |
5 |
public float Thickness; |
6 |
|
7 |
public Line() { } |
8 |
public Line(Vector2 a, Vector2 b, float thickness = 1) |
9 |
{
|
10 |
A = a; |
11 |
B = b; |
12 |
Thickness = thickness; |
13 |
}
|
14 |
}
|
A y B son puntos finales de la línea. Mediante
el escalado y la rotación de las piezas de la línea, podemos dibujar
una línea de cualquier grosor, longitud y orientación. Agregue el siguiente método Draw() a la clase de línea Line :
1 |
public void Draw(SpriteBatch spriteBatch, Color color) |
2 |
{
|
3 |
Vector2 tangent = B - A; |
4 |
float rotation = (float)Math.Atan2(tangent.Y, tangent.X); |
5 |
|
6 |
const float ImageThickness = 8; |
7 |
float thicknessScale = Thickness / ImageThickness; |
8 |
|
9 |
Vector2 capOrigin = new Vector2(Art.HalfCircle.Width, Art.HalfCircle.Height / 2f); |
10 |
Vector2 middleOrigin = new Vector2(0, Art.LightningSegment.Height / 2f); |
11 |
Vector2 middleScale = new Vector2(tangent.Length(), thicknessScale); |
12 |
|
13 |
spriteBatch.Draw(Art.LightningSegment, A, null, color, rotation, middleOrigin, middleScale, SpriteEffects.None, 0f); |
14 |
spriteBatch.Draw(Art.HalfCircle, A, null, color, rotation, capOrigin, thicknessScale, SpriteEffects.None, 0f); |
15 |
spriteBatch.Draw(Art.HalfCircle, B, null, color, rotation + MathHelper.Pi, capOrigin, thicknessScale, SpriteEffects.None, 0f); |
16 |
}
|
Aquí,
Art.LightningSegment y Art.HalfCircle son variables estáticas Texture2D
que contienen las imágenes de las piezas del segmento de línea. ImageThickness se establece en el grosor de la línea sin el resplandor. En mi imagen, son 8 píxeles. Se establece el origen de la tapa a la
derecha, y el origen del segmento medio a su lado izquierdo. Esto hará
que se unan perfectamente cuando los dibujamos en el punto
A. El segmento medio se estira al ancho deseado, y otro tapón se dibuja
en el punto B, girado 180 °.
La
clase SpriteBatch de XNA le permite pasarle un SpriteSortMode en su
constructor, que indica el orden en el que debe dibujar los sprites. Cuando dibuja la línea, asegúrese de pasarle un SpriteBatch con su
SpriteSortMode establecido en SpriteSortMode.Texture. Esto es para
mejorar el rendimiento.
Las tarjetas gráficas son excelentes para dibujar la misma textura muchas veces. Sin embargo, cada vez que cambiar texturas, hay gastos generales. Si dibujamos un montón de líneas sin clasificar, estaríamos dibujando nuestras texturas en este orden:
LightningSegment, HalfCircle, HalfCircle, LightningSegment, HalfCircle, HalfCircle, ...
Esto significa que estaríamos cambiando las
texturas dos veces por cada línea que dibujemos. SpriteSortMode.Texture
le dice a SpriteBatch que ordene las llamadas de Draw() por textura
para que todos los LightningSegments se dibujen juntos y todos los
HalfCircles se dibujen juntos. Además,
cuando usamos estas líneas para hacer rayos, nos gustaría usar la
mezcla aditiva para hacer que la luz de las piezas superpuestas de
relámpagos se suman.
1 |
SpriteBatch.Begin(SpriteSortMode.Texture, BlendState.Additive); |
2 |
// draw lines
|
3 |
SpriteBatch.End(); |
Paso 2: Líneas dentadas
El rayo tiende a formar líneas dentadas, así que necesitaremos un algoritmo para generar estas. Haremos esto escogiendo puntos al azar a lo largo de una línea, y desplazándolos a una distancia aleatoria de la línea. Usando un desplazamiento completamente aleatorio tiende a hacer la línea demasiado irregular, por lo que vamos a suavizar los resultados mediante la limitación de la distancia entre sí puntos vecinos pueden ser desplazados.

La línea se suaviza colocando puntos en un desplazamiento similar al punto anterior; esto permite que la línea en su conjunto pasee hacia arriba y hacia abajo, evitando que cualquier parte de ella sea demasiado dentada. Aquí está el código:
1 |
protected static List<Line> CreateBolt(Vector2 source, Vector2 dest, float thickness) |
2 |
{
|
3 |
var results = new List<Line>(); |
4 |
Vector2 tangent = dest - source; |
5 |
Vector2 normal = Vector2.Normalize(new Vector2(tangent.Y, -tangent.X)); |
6 |
float length = tangent.Length(); |
7 |
|
8 |
List<float> positions = new List<float>(); |
9 |
positions.Add(0); |
10 |
|
11 |
for (int i = 0; i < length / 4; i++) |
12 |
positions.Add(Rand(0, 1)); |
13 |
|
14 |
positions.Sort(); |
15 |
|
16 |
const float Sway = 80; |
17 |
const float Jaggedness = 1 / Sway; |
18 |
|
19 |
Vector2 prevPoint = source; |
20 |
float prevDisplacement = 0; |
21 |
for (int i = 1; i < positions.Count; i++) |
22 |
{
|
23 |
float pos = positions[i]; |
24 |
|
25 |
// used to prevent sharp angles by ensuring very close positions also have small perpendicular variation.
|
26 |
float scale = (length * Jaggedness) * (pos - positions[i - 1]); |
27 |
|
28 |
// defines an envelope. Points near the middle of the bolt can be further from the central line.
|
29 |
float envelope = pos > 0.95f ? 20 * (1 - pos) : 1; |
30 |
|
31 |
float displacement = Rand(-Sway, Sway); |
32 |
displacement -= (displacement - prevDisplacement) * (1 - scale); |
33 |
displacement *= envelope; |
34 |
|
35 |
Vector2 point = source + pos * tangent + displacement * normal; |
36 |
results.Add(new Line(prevPoint, point, thickness)); |
37 |
prevPoint = point; |
38 |
prevDisplacement = displacement; |
39 |
}
|
40 |
|
41 |
results.Add(new Line(prevPoint, dest, thickness)); |
42 |
|
43 |
return results; |
44 |
}
|
El código puede parecer un poco intimidante, pero no es tan malo una vez que entienda la lógica. Comenzamos calculando los vectores normales y tangentes de la línea, junto con la longitud. Entonces elegimos aleatoriamente un número de posiciones a lo largo de
la línea y las guardamos en nuestra lista de posiciones. Las posiciones
se escalan entre 0 y 1 tal que 0 representa el comienzo de la línea y 1
representa el punto final. Estas posiciones se clasifican entonces para
permitirnos agregar fácilmente segmentos de línea entre ellos.
El bucle pasa a través de los puntos elegidos al azar y los desplaza a lo largo de la normal por una cantidad aleatoria. El factor de escala está ahí para evitar ángulos demasiado agudos, y el sobre asegura que el rayo realmente va al punto de destino limitando el desplazamiento cuando estamos cerca del final.


Paso 3: Animación
El relámpago debe destellar brillantemente y luego apagarse. Para manejar esto, vamos a crear una clase LightningBolt.
1 |
class LightningBolt |
2 |
{
|
3 |
public List<Line> Segments = new List<Line>(); |
4 |
|
5 |
public float Alpha { get; set; } |
6 |
public float FadeOutRate { get; set; } |
7 |
public Color Tint { get; set; } |
8 |
|
9 |
public bool IsComplete { get { return Alpha <= 0; } } |
10 |
|
11 |
public LightningBolt(Vector2 source, Vector2 dest) : this(source, dest, new Color(0.9f, 0.8f, 1f)) { } |
12 |
|
13 |
public LightningBolt(Vector2 source, Vector2 dest, Color color) |
14 |
{
|
15 |
Segments = CreateBolt(source, dest, 2); |
16 |
|
17 |
Tint = color; |
18 |
Alpha = 1f; |
19 |
FadeOutRate = 0.03f; |
20 |
}
|
21 |
|
22 |
public void Draw(SpriteBatch spriteBatch) |
23 |
{
|
24 |
if (Alpha <= 0) |
25 |
return; |
26 |
|
27 |
foreach (var segment in Segments) |
28 |
segment.Draw(spriteBatch, Tint * (Alpha * 0.6f)); |
29 |
}
|
30 |
|
31 |
public virtual void Update() |
32 |
{
|
33 |
Alpha -= FadeOutRate; |
34 |
}
|
35 |
|
36 |
protected static List<Line> CreateBolt(Vector2 source, Vector2 dest, float thickness) |
37 |
{
|
38 |
// ...
|
39 |
}
|
40 |
|
41 |
// ...
|
42 |
}
|
Para usar esto, simplemente cree un nuevo LightningBolt y llame a Update() y Draw() cada frame. Llamar a Update() hace que se desvanezca. IsComplete le dirá cuándo el perno se ha desvanecido completamente.
Ahora puede dibujar sus pernos usando el siguiente código en su clase de juego:
1 |
LightningBolt bolt; |
2 |
MouseState mouseState, lastMouseState; |
3 |
|
4 |
protected override void Update(GameTime gameTime) |
5 |
{
|
6 |
lastMouseState = mouseState; |
7 |
mouseState = Mouse.GetState(); |
8 |
|
9 |
var screenSize = new Vector2(GraphicsDevice.Viewport.Width, GraphicsDevice.Viewport.Height); |
10 |
var mousePosition = new Vector2(mouseState.X, mouseState.Y); |
11 |
|
12 |
if (MouseWasClicked()) |
13 |
bolt = new LightningBolt(screenSize / 2, mousePosition); |
14 |
|
15 |
if (bolt != null) |
16 |
bolt.Update(); |
17 |
}
|
18 |
|
19 |
private bool MouseWasClicked() |
20 |
{
|
21 |
return mouseState.LeftButton == ButtonState.Pressed && lastMouseState.LeftButton == ButtonState.Released; |
22 |
}
|
23 |
|
24 |
protected override void Draw(GameTime gameTime) |
25 |
{
|
26 |
GraphicsDevice.Clear(Color.Black); |
27 |
|
28 |
spriteBatch.Begin(SpriteSortMode.Texture, BlendState.Additive); |
29 |
|
30 |
if (bolt != null) |
31 |
bolt.Draw(spriteBatch); |
32 |
|
33 |
spriteBatch.End(); |
34 |
}
|
Paso 4: Rayo de la rama
Puede utilizar la clase LightningBolt como un bloque de construcción
para crear efectos de relámpago más interesantes. Por ejemplo, puede
hacer que los pernos se ramifiquen como se muestra a continuación:

Para hacer la rama del rayo, elegimos puntos aleatorios a lo largo del rayo y agregamos nuevos tornillos que se ramifican fuera de estos puntos. En el siguiente código, creamos entre tres y seis ramas que se separan del perno principal en ángulos de 30 °.
1 |
class BranchLightning |
2 |
{
|
3 |
List<LightningBolt> bolts = new List<LightningBolt>(); |
4 |
|
5 |
public bool IsComplete { get { return bolts.Count == 0; } } |
6 |
public Vector2 End { get; private set; } |
7 |
private Vector2 direction; |
8 |
|
9 |
static Random rand = new Random(); |
10 |
|
11 |
public BranchLightning(Vector2 start, Vector2 end) |
12 |
{
|
13 |
End = end; |
14 |
direction = Vector2.Normalize(end - start); |
15 |
Create(start, end); |
16 |
}
|
17 |
|
18 |
public void Update() |
19 |
{
|
20 |
bolts = bolts.Where(x => !x.IsComplete).ToList(); |
21 |
foreach (var bolt in bolts) |
22 |
bolt.Update(); |
23 |
}
|
24 |
|
25 |
public void Draw(SpriteBatch spriteBatch) |
26 |
{
|
27 |
foreach (var bolt in bolts) |
28 |
bolt.Draw(spriteBatch); |
29 |
}
|
30 |
|
31 |
private void Create(Vector2 start, Vector2 end) |
32 |
{
|
33 |
var mainBolt = new LightningBolt(start, end); |
34 |
bolts.Add(mainBolt); |
35 |
|
36 |
int numBranches = rand.Next(3, 6); |
37 |
Vector2 diff = end - start; |
38 |
|
39 |
// pick a bunch of random points between 0 and 1 and sort them
|
40 |
float[] branchPoints = Enumerable.Range(0, numBranches) |
41 |
.Select(x => Rand(0, 1f)) |
42 |
.OrderBy(x => x).ToArray(); |
43 |
|
44 |
for (int i = 0; i < branchPoints.Length; i++) |
45 |
{
|
46 |
// Bolt.GetPoint() gets the position of the lightning bolt at specified fraction (0 = start of bolt, 1 = end)
|
47 |
Vector2 boltStart = mainBolt.GetPoint(branchPoints[i]); |
48 |
|
49 |
// rotate 30 degrees. Alternate between rotating left and right.
|
50 |
Quaternion rot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathHelper.ToRadians(30 * ((i & 1) == 0 ? 1 : -1))); |
51 |
Vector2 boltEnd = Vector2.Transform(diff * (1 - branchPoints[i]), rot) + boltStart; |
52 |
bolts.Add(new LightningBolt(boltStart, boltEnd)); |
53 |
}
|
54 |
}
|
55 |
|
56 |
static float Rand(float min, float max) |
57 |
{
|
58 |
return (float)rand.NextDouble() * (max - min) + min; |
59 |
}
|
60 |
}
|
Paso 5: Texto relámpago
A continuación se muestra un video de otro efecto que puedes hacer de los rayos:
Primero tenemos que obtener los píxeles en el texto que nos gustaría dibujar. Hacemos
esto dibujando nuestro texto a un RenderTarget2D y leyendo los datos de
píxeles con RenderTarget2D.GetData<T>(). Si desea leer más acerca de cómo hacer efectos de partículas de texto,
tengo un tutorial más detallado aquí.
Almacenamos las coordenadas de los
píxeles en el texto como una lista List<Vector2>. Entonces, cada marco,
elegimos al azar pares de estos puntos y creamos un relámpago entre
ellos. Queremos
diseñarlo de tal manera que cuanto más cerca estén los dos puntos entre
sí, mayor será la probabilidad de que creemos un cerrojo entre ellos. Hay una técnica simple que podemos utilizar para lograr esto:
seleccionaremos el primer punto al azar y luego seleccionaremos un
número fijo de otros puntos al azar y elegiremos el más cercano.
El número de puntos candidatos que probamos afectará la apariencia del texto del relámpago; comprobar un mayor número de puntos nos permitirá encontrar puntos muy cercanos para atraer a los pernos, lo que hará que el texto sea muy limpio y legible, pero con menos rayos largos entre las letras. Los números más pequeños harán que el texto del rayo parezca más loco pero menos legible.
1 |
public void Update() |
2 |
{
|
3 |
foreach (var particle in textParticles) |
4 |
{
|
5 |
float x = particle.X / 500f; |
6 |
if (rand.Next(50) == 0) |
7 |
{
|
8 |
Vector2 nearestParticle = Vector2.Zero; |
9 |
float nearestDist = float.MaxValue; |
10 |
for (int i = 0; i < 50; i++) |
11 |
{
|
12 |
var other = textParticles[rand.Next(textParticles.Count)]; |
13 |
var dist = Vector2.DistanceSquared(particle, other); |
14 |
|
15 |
if (dist < nearestDist && dist > 10 * 10) |
16 |
{
|
17 |
nearestDist = dist; |
18 |
nearestParticle = other; |
19 |
}
|
20 |
}
|
21 |
|
22 |
if (nearestDist < 200 * 200 && nearestDist > 10 * 10) |
23 |
bolts.Add(new LightningBolt(particle, nearestParticle, Color.White)); |
24 |
}
|
25 |
}
|
26 |
|
27 |
for (int i = bolts.Count - 1; i >= 0; i--) |
28 |
{
|
29 |
bolts[i].Update(); |
30 |
|
31 |
if (bolts[i].IsComplete) |
32 |
bolts.RemoveAt(i); |
33 |
}
|
34 |
}
|
Paso 6: Optimización
El texto relámpago, como se muestra arriba, puede funcionar sin problemas si usted tiene una computadora en la parte superior de la línea, pero sin duda es muy gravoso. Cada perno dura más de 30 cuadros, y creamos docenas de pernos nuevos cada marco. Dado que cada rayo puede tener hasta un par de cientos de segmentos de línea, y cada segmento de línea tiene tres piezas, terminamos dibujando un montón de sprites. Mi demo, por ejemplo, extrae más de 25.000 imágenes de cada fotograma con las optimizaciones desactivadas. Podemos hacerlo mejor.
En lugar de dibujar cada perno hasta que se desvanece, podemos dibujar cada perno nuevo a un objetivo de renderizado y atenuar el render target cada marco. Esto significa que, en vez de tener que dibujar cada perno para 30 o más marcos, sólo lo dibujamos una vez. También significa que no hay costo de rendimiento adicional para hacer que nuestros rayos se desvanezcan más lentamente y duren más.
Primero,
modificaremos la clase LightningText para dibujar solo cada perno para
un fotograma. En su clase de juego Game , declare dos variables
RenderTarget2D: currentFrame y lastFrame. En LoadContent(),
inicialízalos de la siguiente manera:
1 |
lastFrame = new RenderTarget2D(GraphicsDevice, screenSize.X, screenSize.Y, false, SurfaceFormat.HdrBlendable, DepthFormat.None); |
2 |
currentFrame = new RenderTarget2D(GraphicsDevice, screenSize.X, screenSize.Y, false, SurfaceFormat.HdrBlendable, DepthFormat.None); |
Observe que el formato de la superficie se establece
en HdrBlendable. HDR significa High Dynamic Range, e indica que nuestra
superficie HDR puede representar una gama más amplia de colores. Esto es
necesario porque permite que el destino de render tenga colores que son
más brillantes que blanco. Cuando
varios rayos se superponen, necesitamos que el objetivo de
procesamiento almacene la suma completa de sus colores, lo que puede
aumentar más allá del rango de colores estándar. Mientras que estos
colores brillantes-que-blancos seguirán siendo
mostrados como blanco en la pantalla, es importante para almacenar su
brillo completo con el fin de hacer que se funden correctamente.
Cada trama, dibujamos primero el contenido del último cuadro al
marco actual, pero ligeramente oscurecido. A continuación, agregamos los
tornillos recién creados al marco actual. Finalmente,
renderizamos nuestro marco actual a la pantalla y luego cambiamos los
dos objetivos de render para que para nuestro próximo fotograma,
lastFrame haga referencia al fotograma que acabamos de representar.
1 |
void DrawLightningText() |
2 |
{
|
3 |
GraphicsDevice.SetRenderTarget(currentFrame); |
4 |
GraphicsDevice.Clear(Color.Black); |
5 |
|
6 |
// draw the last frame at 96% brightness
|
7 |
spriteBatch.Begin(0, BlendState.Opaque, SamplerState.PointClamp, null, null); |
8 |
spriteBatch.Draw(lastFrame, Vector2.Zero, Color.White * 0.96f); |
9 |
spriteBatch.End(); |
10 |
|
11 |
// draw new bolts with additive blending
|
12 |
spriteBatch.Begin(SpriteSortMode.Texture, BlendState.Additive); |
13 |
lightningText.Draw(); |
14 |
spriteBatch.End(); |
15 |
|
16 |
// draw the whole thing to the backbuffer
|
17 |
GraphicsDevice.SetRenderTarget(null); |
18 |
spriteBatch.Begin(0, BlendState.Opaque, SamplerState.PointClamp, null, null); |
19 |
spriteBatch.Draw(currentFrame, Vector2.Zero, Color.White); |
20 |
spriteBatch.End(); |
21 |
|
22 |
Swap(ref currentFrame, ref lastFrame); |
23 |
}
|
24 |
|
25 |
void Swap<T>(ref T a, ref T b) |
26 |
{
|
27 |
T temp = a; |
28 |
a = b; |
29 |
b = temp; |
30 |
}
|
Paso 7: Otras Variaciones
Hemos discutido la posibilidad de hacer relámpagos de ramas y relámpagos, pero éstos no son los únicos efectos que puedes hacer. Echemos un vistazo a un par de otras variaciones sobre el relámpago que puede manera de utilizar.
Relámpagos en movimiento
A menudo, es posible que desee hacer un rayo de movimiento. Puede hacerlo añadiendo un nuevo perno corto cada marco en el punto final del rayo del frame anterior.
1 |
Vector2 lightningEnd = new Vector2(100, 100); |
2 |
Vector2 lightningVelocity = new Vector2(50, 0); |
3 |
|
4 |
void Update(GameTime gameTime) |
5 |
{
|
6 |
Bolts.Add(new LightningBolt(lightningEnd, lightningEnd + lightningVelocity)); |
7 |
lightningEnd += lightningVelocity; |
8 |
|
9 |
// ...
|
10 |
}
|
Relámpago liso
Usted puede haber notado que el relámpago brilla más brillante en las articulaciones. Esto se debe a la mezcla aditiva. Usted puede desear una mirada más lisa, más uniforme para su relámpago. Esto puede lograrse cambiando su función de estado de mezcla para elegir el valor máximo de los colores de origen y de destino, como se muestra a continuación.
1 |
private static readonly BlendState maxBlend = new BlendState() |
2 |
{
|
3 |
AlphaBlendFunction = BlendFunction.Max, |
4 |
ColorBlendFunction = BlendFunction.Max, |
5 |
AlphaDestinationBlend = Blend.One, |
6 |
AlphaSourceBlend = Blend.One, |
7 |
ColorDestinationBlend = Blend.One, |
8 |
ColorSourceBlend = Blend.One |
9 |
};
|
A
continuación, en su función Draw(), llame a SpriteBatch.Begin() con
maxBlend como BlendState en lugar de BlendState.Additive. Las imágenes de abajo muestran la diferencia entre mezcla aditiva y mezcla máxima en un rayo.





Por supuesto, la mezcla máxima no permitirá que la luz de múltiples tornillos o desde el fondo para sumarse bien. Si desea que el perno se vea suave, pero también se mezcle de forma aditiva con otros tornillos, primero puede convertir el tornillo en un destino de renderizado utilizando la mezcla máxima y, a continuación, dibujar el destino de renderización en la pantalla utilizando mezcla aditiva. Tenga cuidado de no utilizar demasiados objetivos de render grandes, ya que esto dañará el rendimiento.
Otra alternativa, que funcionará mejor para un gran número de pernos, es eliminar el resplandor incorporado en las imágenes del segmento de línea y volver a añadirlo usando un efecto de resplandor post-procesamiento. Los detalles del uso de shaders y efectos de resplandor están más allá del alcance de este tutorial, pero puede utilizar el XNA Bloom Sample para empezar. Esta técnica no requerirá más objetivos de renderización a medida que añada más.
Conclusión
Relámpago es un gran efecto especial para arreglar sus juegos. Los efectos descritos en este tutorial son un buen punto de partida, pero ciertamente no es todo lo que puedes hacer con los rayos. ¡Con un poco de imaginación puedes hacer todo tipo de impresionantes efectos de relámpagos! Descargue el código fuente y experimente con el suyo propio.
Si disfrutó de este artículo, eche un vistazo a mi tutorial sobre los efectos de agua 2D, también.



