闪电在游戏中有很多用途,从暴风雨中的背景氛围到巫师的毁灭性闪电攻击。在本教程中,我将解释如何以编程方式生成出色的 2D 闪电效果:螺栓、分支,甚至是文本。
注意:虽然本教程是使用 C# 和 XNA 编写的,但您应该能够在几乎任何游戏开发环境中使用相同的技术和概念。
第 1 步:画一条发光线
我们制作闪电所需的基本构建块是线段。首先打开您最喜欢的图像编辑软件并绘制一条直线闪电。这是我的样子:
我们想画不同长度的线,所以我们要把线段剪成三段,如下图所示。这将允许我们将中间部分拉伸到我们喜欢的任何长度。由于我们要拉伸中间部分,我们可以将其保存为只有一个像素厚。另外,由于左右两片互为镜像,我们只需要保存其中一个即可。我们可以在代码中翻转它。
现在,让我们声明一个新类来处理绘制线段:
public class Line { public Vector2 A; public Vector2 B; public float Thickness; public Line() { } public Line(Vector2 a, Vector2 b, float thickness = 1) { A = a; B = b; Thickness = thickness; } }
A 和 B 是线的端点。通过缩放和旋转线段,我们可以绘制任意粗细、长度和方向的线。将以下Draw()方法添加到Line类中:
public void Draw(SpriteBatch spriteBatch, Color color) { Vector2 tangent = B - A; float rotation = (float)Math.Atan2(tangent.Y, tangent.X); const float ImageThickness = 8; float thicknessScale = Thickness / ImageThickness; Vector2 capOrigin = new Vector2(Art.HalfCircle.Width, Art.HalfCircle.Height / 2f); Vector2 middleOrigin = new Vector2(0, Art.LightningSegment.Height / 2f); Vector2 middleScale = new Vector2(tangent.Length(), thicknessScale); spriteBatch.Draw(Art.LightningSegment, A, null, color, rotation, middleOrigin, middleScale, SpriteEffects.None, 0f); spriteBatch.Draw(Art.HalfCircle, A, null, color, rotation, capOrigin, thicknessScale, SpriteEffects.None, 0f); spriteBatch.Draw(Art.HalfCircle, B, null, color, rotation + MathHelper.Pi, capOrigin, thicknessScale, SpriteEffects.None, 0f); }
在这里,Art.LightningSegment和是保存线段图像的Art.HalfCircle静态变量。设置为没有发光的线条的粗细。在我的图像中,它是 8 像素。我们将帽子的原点设置在右侧,将中间部分的原点设置在左侧。当我们在 A 点绘制它们时,这将使它们无缝连接。中间部分被拉伸到所需的宽度,在 B 点绘制另一个帽,旋转 180°。Texture2DImageThickness
XNA 的SpriteBatch类允许你在它的构造函数中传递一个a SpriteSortMode,它表示它应该绘制精灵的顺序。绘制线时,请确保将其传递 aSpriteBatch并将其SpriteSortMode设置为SpriteSortMode.Texture。这是为了提高性能。
显卡非常擅长多次绘制相同的纹理。但是,每次他们切换纹理时,都会产生开销。如果我们在没有排序的情况下绘制一堆线条,我们将按以下顺序绘制纹理:
LightningSegment,HalfCircle,HalfCircle,LightningSegment,HalfCircle,HalfCircle,...
这意味着我们将为我们绘制的每条线切换两次纹理。SpriteSortMode.Texture告诉按纹理SpriteBatch对调用进行排序,Draw()以便所有的LightningSegments将被绘制在一起并且所有的HalfCircles将被绘制在一起。此外,当我们使用这些线条制作闪电时,我们希望使用加法混合来使来自重叠闪电的光线相加。
SpriteBatch.Begin(SpriteSortMode.Texture, BlendState.Additive); // draw lines SpriteBatch.End();
第 2 步:锯齿状线条
闪电往往会形成锯齿状线条,因此我们需要一种算法来生成这些线条。我们将通过沿一条线随机选取点,并将它们从该线随机移动一个距离来做到这一点。使用完全随机的位移会使线条过于参差不齐,因此我们将通过限制彼此相邻点可以位移的距离来平滑结果。
通过将点放置在与前一点相似的偏移处来平滑线;这允许整个生产线上下浮动,同时防止它的任何部分过于参差不齐。这是代码:
protected static List<Line> CreateBolt(Vector2 source, Vector2 dest, float thickness) { var results = new List<Line>(); Vector2 tangent = dest - source; Vector2 normal = Vector2.Normalize(new Vector2(tangent.Y, -tangent.X)); float length = tangent.Length(); List<float> positions = new List<float>(); positions.Add(0); for (int i = 0; i < length / 4; i++) positions.Add(Rand(0, 1)); positions.Sort(); const float Sway = 80; const float Jaggedness = 1 / Sway; Vector2 prevPoint = source; float prevDisplacement = 0; for (int i = 1; i < positions.Count; i++) { float pos = positions[i]; // used to prevent sharp angles by ensuring very close positions also have small perpendicular variation. float scale = (length * Jaggedness) * (pos - positions[i - 1]); // defines an envelope. Points near the middle of the bolt can be further from the central line. float envelope = pos > 0.95f ? 20 * (1 - pos) : 1; float displacement = Rand(-Sway, Sway); displacement -= (displacement - prevDisplacement) * (1 - scale); displacement *= envelope; Vector2 point = source + pos * tangent + displacement * normal; results.Add(new Line(prevPoint, point, thickness)); prevPoint = point; prevDisplacement = displacement; } results.Add(new Line(prevPoint, dest, thickness)); return results; }
代码可能看起来有点吓人,但一旦你理解了逻辑,它就不会那么糟糕了。我们首先计算直线的法线向量和切线向量以及长度。然后我们随机选择沿线的一些位置并将它们存储在我们的位置列表中。位置在 和 之间进行缩放0,1从而0代表线的起点和1终点。然后对这些位置进行排序,以便我们可以轻松地在它们之间添加线段。
循环通过随机选择的点并将它们沿法线随机位移。比例因子的存在是为了避免过于锐利的角度,并且当我们接近终点时,包络通过限制位移来确保闪电实际上到达目的地点。
第 3 步:动画
闪电应该明亮地闪烁然后消失。为了解决这个问题,让我们创建一个LightningBolt类。
class LightningBolt { public List<Line> Segments = new List<Line>(); public float Alpha { get; set; } public float FadeOutRate { get; set; } public Color Tint { get; set; } public bool IsComplete { get { return Alpha <= 0; } } public LightningBolt(Vector2 source, Vector2 dest) : this(source, dest, new Color(0.9f, 0.8f, 1f)) { } public LightningBolt(Vector2 source, Vector2 dest, Color color) { Segments = CreateBolt(source, dest, 2); Tint = color; Alpha = 1f; FadeOutRate = 0.03f; } public void Draw(SpriteBatch spriteBatch) { if (Alpha <= 0) return; foreach (var segment in Segments) segment.Draw(spriteBatch, Tint * (Alpha * 0.6f)); } public virtual void Update() { Alpha -= FadeOutRate; } protected static List<Line> CreateBolt(Vector2 source, Vector2 dest, float thickness) { // ... } // ... }
要使用它,只需创建一个新的并LightningBolt调用每个框架。呼唤让它褪色。当螺栓完全消失时会告诉你。Update()Draw()Update()IsComplete
您现在可以在 Game 类中使用以下代码绘制螺栓:
protected override void Update(GameTime gameTime) { lastMouseState = mouseState; mouseState = Mouse.GetState(); var screenSize = new Vector2(GraphicsDevice.Viewport.Width, GraphicsDevice.Viewport.Height); var mousePosition = new Vector2(mouseState.X, mouseState.Y); if (MouseWasClicked()) bolt = new LightningBolt(screenSize / 2, mousePosition); if (bolt != null) bolt.Update(); } private bool MouseWasClicked() { return mouseState.LeftButton == ButtonState.Pressed && lastMouseState.LeftButton == ButtonState.Released; } protected override void Draw(GameTime gameTime) { GraphicsDevice.Clear(Color.Black); spriteBatch.Begin(SpriteSortMode.Texture, BlendState.Additive); if (bolt != null) bolt.Draw(spriteBatch); spriteBatch.End(); }
第 4 步:分支闪电
您可以使用LightningBolt该类作为构建块来创建更有趣的闪电效果。例如,您可以使螺栓分叉,如下所示:
为了制作闪电分支,我们沿着闪电选取随机点并添加从这些点分支出来的新闪电。在下面的代码中,我们创建了三到六个分支,它们与主螺栓成 30° 角分开。
class BranchLightning { List<LightningBolt> bolts = new List<LightningBolt>(); public bool IsComplete { get { return bolts.Count == 0; } } public Vector2 End { get; private set; } private Vector2 direction; static Random rand = new Random(); public BranchLightning(Vector2 start, Vector2 end) { End = end; direction = Vector2.Normalize(end - start); Create(start, end); } public void Update() { bolts = bolts.Where(x => !x.IsComplete).ToList(); foreach (var bolt in bolts) bolt.Update(); } public void Draw(SpriteBatch spriteBatch) { foreach (var bolt in bolts) bolt.Draw(spriteBatch); } private void Create(Vector2 start, Vector2 end) { var mainBolt = new LightningBolt(start, end); bolts.Add(mainBolt); int numBranches = rand.Next(3, 6); Vector2 diff = end - start; // pick a bunch of random points between 0 and 1 and sort them float[] branchPoints = Enumerable.Range(0, numBranches) .Select(x => Rand(0, 1f)) .OrderBy(x => x).ToArray(); for (int i = 0; i < branchPoints.Length; i++) { // Bolt.GetPoint() gets the position of the lightning bolt at specified fraction (0 = start of bolt, 1 = end) Vector2 boltStart = mainBolt.GetPoint(branchPoints[i]); // rotate 30 degrees. Alternate between rotating left and right. Quaternion rot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathHelper.ToRadians(30 * ((i & 1) == 0 ? 1 : -1))); Vector2 boltEnd = Vector2.transform(diff * (1 - branchPoints[i]), rot) + boltStart; bolts.Add(new LightningBolt(boltStart, boltEnd)); } } static float Rand(float min, float max) { return (float)rand.NextDouble() * (max - min) + min; } }
第 5 步:闪电文本
首先,我们需要获取我们想要绘制的文本中的像素。我们通过将文本绘制到 a并使用 读回像素数据来做到这一点。如果你想了解更多关于制作文本粒子效果的内容,我这里有更详细的教程。Rendertarget2DRenderTarget2D.Getdata<T>()
我们将文本中像素的坐标存储为List<Vector2>. 然后,每一帧,我们随机选择一对这些点,并在它们之间创建一个闪电。我们希望将其设计为使两点彼此越接近,我们在它们之间创建螺栓的机会就越大。我们可以使用一种简单的技术来完成此操作:我们将随机选择第一个点,然后我们将随机选择固定数量的其他点并选择最近的点。
我们测试的候选点的数量会影响闪电文本的外观;检查更多的点将使我们能够找到非常接近的点来绘制螺栓,这将使文本非常整洁易读,但字母之间的长闪电更少。较小的数字会使闪电文本看起来更疯狂但更难辨认。
public void Update() { foreach (var particle in textParticles) { float x = particle.X / 500f; if (rand.Next(50) == 0) { Vector2 nearestParticle = Vector2.Zero; float nearestDist = float.MaxValue; for (int i = 0; i < 50; i++) { var other = textParticles[rand.Next(textParticles.Count)]; var dist = Vector2.DistanceSquared(particle, other); if (dist < nearestDist && dist > 10 * 10) { nearestDist = dist; nearestParticle = other; } } if (nearestDist < 200 * 200 && nearestDist > 10 * 10) bolts.Add(new LightningBolt(particle, nearestParticle, Color.White)); } } for (int i = bolts.Count - 1; i >= 0; i--) { bolts[i].Update(); if (bolts[i].IsComplete) bolts.RemoveAt(i); } }
第 6 步:优化
如上图所示的闪电文本,如果您拥有一台顶级计算机,可能会顺利运行,但它肯定非常费力。每个螺栓持续超过 30 帧,我们每帧创建数十个新螺栓。由于每个闪电可能有多达几百个线段,而每个线段有三段,我们最终会绘制很多精灵。例如,我的演示在关闭优化的情况下每帧绘制超过 25,000 张图像。我们可以做得更好。
我们可以将每个新螺栓绘制到渲染目标并在每一帧淡出渲染目标,而不是绘制每个螺栓直到它淡出。这意味着,我们不必为每个螺栓绘制 30 帧或更多帧,而只需绘制一次。这也意味着没有额外的性能成本来使我们的闪电消失得更慢并持续更长时间。
首先,我们将修改LightningText类以仅绘制一帧的每个螺栓。在你的Game类中,声明两个RenderTarget2D变量:currentFrame和lastFrame. 在LoadContent()中,像这样初始化它们:
lastFrame = new RenderTarget2D(GraphicsDevice, screenSize.X, screenSize.Y, false, SurfaceFormat.HdrBlendable, DepthFormat.None); currentFrame = new RenderTarget2D(GraphicsDevice, screenSize.X, screenSize.Y, false, SurfaceFormat.HdrBlendable, DepthFormat.None);
请注意,表面格式设置为HdrBlendable. HDR 代表High Dynamic Range,它表明我们的 HDR 表面可以表示更大范围的颜色。这是必需的,因为它允许渲染目标具有比白色更亮的颜色。当多个闪电重叠时,我们需要渲染目标来存储它们颜色的全部总和,这可能会超出标准颜色范围。虽然这些比白色更亮的颜色仍会在屏幕上显示为白色,但重要的是要存储它们的全部亮度以使它们正确淡出。
XNA 提示:另请注意,要使 HDR 混合正常工作,您必须将 XNA 项目配置文件设置为 Hi-Def。您可以通过右键单击解决方案资源管理器中的项目,选择属性,然后选择 XNA Game Studio 选项卡下的高清配置文件来执行此操作。
每一帧,我们先将上一帧的内容绘制到当前帧,只是稍微变暗。然后我们将任何新创建的螺栓添加到当前帧。最后,我们将当前帧渲染到屏幕上,然后交换两个渲染目标,以便下一帧lastFrame将引用我们刚刚渲染的帧。
void DrawLightningText() { GraphicsDevice.SetRenderTarget(currentFrame); GraphicsDevice.Clear(Color.Black); // draw the last frame at 96% brightness spriteBatch.Begin(0, BlendState.Opaque, SamplerState.PointClamp, null, null); spriteBatch.Draw(lastFrame, Vector2.Zero, Color.White * 0.96f); spriteBatch.End(); // draw new bolts with additive blending spriteBatch.Begin(SpriteSortMode.Texture, BlendState.Additive); lightningText.Draw(); spriteBatch.End(); // draw the whole thing to the backbuffer GraphicsDevice.SetRenderTarget(null); spriteBatch.Begin(0, BlendState.Opaque, SamplerState.PointClamp, null, null); spriteBatch.Draw(currentFrame, Vector2.Zero, Color.White); spriteBatch.End(); Swap(ref currentFrame, ref lastFrame); } void Swap<T>(ref T a, ref T b) { T temp = a; a = b; b = temp; }
第 7 步:其他变化
我们已经讨论过制作分支闪电和闪电文本,但这些肯定不是您可以制作的唯一效果。让我们看一下您可能会使用的闪电的其他几种变体。
移动闪电
通常你可能想要制作一个移动的闪电。您可以通过在前一帧螺栓的端点每帧添加一个新的短螺栓来做到这一点。
Vector2 lightningEnd = new Vector2(100, 100); Vector2 lightningVelocity = new Vector2(50, 0); void Update(GameTime gameTime) { Bolts.Add(new LightningBolt(lightningEnd, lightningEnd + lightningVelocity)); lightningEnd += lightningVelocity; // ... }
平滑闪电
您可能已经注意到,闪电在关节处发出更亮的光。这是由于添加剂混合。您可能需要更平滑、更均匀的闪电外观。这可以通过更改混合状态函数来选择源颜色和目标颜色的最大值来完成,如下所示。
private static readonly BlendState maxBlend = new BlendState() { AlphaBlendFunction = BlendFunction.Max, ColorBlendFunction = BlendFunction.Max, AlphaDestinationBlend = Blend.One, AlphaSourceBlend = Blend.One, ColorDestinationBlend = Blend.One, ColorSourceBlend = Blend.One };
然后,在您的Draw()函数中,调用SpriteBatch.Begin()with maxBlendasBlendState而不是BlendState.Additive。下图显示了闪电上的加法混合和最大混合之间的区别。
当然,最大混合不会让来自多个螺栓或背景的光很好地叠加起来。如果您希望螺栓本身看起来平滑,而且还希望与其他螺栓进行叠加混合,您可以先使用最大混合将螺栓渲染到渲染目标,然后使用叠加混合将渲染目标绘制到屏幕上。注意不要使用太多的大型渲染目标,因为这会损害性能。
另一种对大量螺栓效果更好的替代方法是消除线段图像中内置的辉光,并使用后处理辉光效果将其添加回来。使用着色器和制作发光效果的细节超出了本教程的范围,但您可以使用XNA Bloom Sample开始。当您添加更多螺栓时,此技术不需要更多渲染目标。
结论
闪电是美化游戏的绝佳特效。本教程中描述的效果是一个很好的起点,但它肯定不是你可以用闪电做的全部。稍加想象就可以做出各种令人惊叹的闪电效果!下载源代码并自己尝试。
- 移动闪电
- 平滑闪电