2D渲染基础
MonoGame的2D渲染几乎全部围绕一个类展开,那就是SpriteBatch。Sprite是2D游戏的基本元素,屏幕上的所有图片,包括角色、子弹、UI等本质都是Sprite,而SpriteBatch的职责就是高效地把成百上千个Sprite画到屏幕上,它封装了GPU绘制的复杂性,只暴露给我们简单的Draw方法。
屏幕坐标系和基础类型
MonoGame的2D屏幕坐标系和数学课本不一样,它的原点在屏幕左上角,X轴向右为正,Y轴向下为正。也就是说,一个1280×720的窗口,左上角是(0, 0),右下角是(1279, 719)。这套约定源自早期显示器的扫描线方向,几乎被所有2D图形库沿用。
和2D绘图相关的基础类型常用的有以下几种。
Vector2 position = new Vector2(100, 50); // 二维点或向量,float精度
Rectangle rect = new Rectangle(100, 50, 32, 32); // 矩形区域,int精度
Color color = new Color(255, 0, 0); // RGBA颜色,也有Color.Red等预置颜色
其中Vector2是2D游戏使用频率最高的类型,位置、速度、尺寸等一般都用它表示,Vector2支持加减乘除等各种运算符重载,例如+两个向量就是位置加位移,使用起来非常方便。
Begin Draw End
SpriteBatch的使用有一套固定的三段流程,即Begin -> Draw -> End。下面例子代码中,我们将图片绘制在屏幕坐标(100, 100)的位置,注意这个(100, 100)代表的是图片的左上角。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
namespace Gacfox.Demo.DemoGame;
public class Game1 : Game
{
private GraphicsDeviceManager _graphics;
private SpriteBatch _spriteBatch;
private Texture2D _hero;
public Game1()
{
_graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
IsMouseVisible = true;
}
protected override void Initialize()
{
base.Initialize();
}
protected override void LoadContent()
{
_spriteBatch = new SpriteBatch(GraphicsDevice);
_hero = Content.Load<Texture2D>("hero");
}
protected override void Update(GameTime gameTime)
{
if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
{
Exit();
}
base.Update(gameTime);
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
_spriteBatch.Begin();
_spriteBatch.Draw(_hero, new Vector2(100, 100), Color.White);
_spriteBatch.End();
base.Draw(gameTime);
}
}
Begin和End必须成对出现,中间夹着的所有Draw调用构成一个批次(Batch)。SpriteBatch会把这批Sprite的顶点数据在End时一次性提交给GPU,这正是Batch的含义。为什么要批处理?因为每次和GPU打交道(DrawCall)都有固定开销,一千个Sprite发一千次DrawCall会被通信开销拖垮,攒成批次发送就快得多了。
此外,Begin、Draw有大量重载,我们后续会介绍其中的重要方法。
运行后,绘图位置如下(其中红框和文字是手动标注了,方便观察屏幕坐标和图片位置的关系,非代码渲染)。

Draw详解
Draw有七个重载,完整形态如下。
_spriteBatch.Draw(
texture: _hero, // 纹理
position: new Vector2(400, 300), // 绘制位置
sourceRectangle: frameRect, // 源矩形,用于截取纹理的一部分,常用于截取Sprite Sheet
color: Color.White, // 颜色叠加(着色)
rotation: MathHelper.PiOver4, // 旋转角度(弧度,绕origin)
origin: new Vector2(16, 16), // 原点,是旋转和缩放中心,也是position的锚点
scale: new Vector2(2f, 2f), // 缩放因数
effects: SpriteEffects.FlipHorizontally, // 翻转效果
layerDepth: 0.5f); // 层深度,前后排序用
源矩形 sourceRectangle:null表示绘制整张纹理,传入一个Rectangle则只绘制纹理的对应区域。这是Sprite Sheet的基础,例如把动画的每帧排在一张大图里,绘制时用源矩形框出当前帧,序列帧动画章节会用到。
颜色叠加 color:传入的颜色会和纹理像素相乘。具体来说,会如下计算RGBA结果:
$$R_{final} = R_{texture} \times \frac{R_{color}}{255}$$
$$G_{final} = G_{texture} \times \frac{G_{color}}{255}$$
$$B_{final} = B_{texture} \times \frac{B_{color}}{255}$$
$$A_{final} = A_{texture} \times \frac{A_{color}}{255}$$
注:虽然Color在C#中是由0到255的字节(Byte)组成的,但在底层着色器中,它们会被映射到0.0到1.0的浮点数范围参与计算。
这个color参数非常有用,例如:Color.White时,所有通道值都是255,因此结果就是纹理原样绘制;Color.Red则会把绿色和蓝色通道清零,图片只剩红色;new Color(255,255,255,128)则能实现半透明。给角色受击时闪红、UI置灰等效果,都可以基于这个参数实现。
原点 origin:该参数有两层含义,一是旋转和缩放的中心点,二是position锚定在纹理上的位置。原点是相对于纹理自身坐标的,例如new Vector2(0, 0)是纹理左上角,new Vector2(texture.Width / 2f, texture.Height / 2f)是纹理中心。又如果想让角色“站”在地面上某个点,那就把origin设为脚底中心,position传地面坐标即可。
下面例子代码实现以图片中心为原点,绘制在屏幕正中并随时间旋转。
Vector2 center = new Vector2(_hero.Width / 2f, _hero.Height / 2f);
_rotation += (float)gameTime.ElapsedGameTime.TotalSeconds;
_spriteBatch.Draw(_hero, new Vector2(640, 360), null, Color.White,
_rotation, center, 1f, SpriteEffects.None, 0f);
翻转 effects:可取值SpriteEffects.FlipHorizontally水平翻转,SpriteEffects.FlipVertically垂直翻转。有了翻转功能,角色朝左朝右不必画两套图,翻转一下就行了。
层深度 layerDepth:取值0~1,决定精灵的前后遮挡关系,0最前1最后。只有排序模式设为FrontToBack或BackToFront时才生效,后文会详细介绍。
Begin详解
混合状态 BlendState
混合状态决定新画的像素如何与屏幕上已有的像素结合,常用的有:
BlendState.AlphaBlend(默认):按Alpha通道透明混合,绝大多数2D游戏的选择。BlendState.Additive:叠加混合,颜色值直接相加,画面会“变亮”。火焰、魔法光效、激光这类发光特效可用该混合模式。BlendState.Opaque:不透明,忽略Alpha直接覆盖,性能最好但没有透明效果。BlendState.NonPremultiplied:非预乘Alpha混合,当纹理的Alpha是直通格式时使用。
如果画了半透明效果却发现颜色发暗发灰,多半是预乘Alpha(Premultiplied Alpha)的问题,MonoGame的纹理处理器默认会把颜色预乘Alpha值,此时必须配合AlphaBlend使用,细节不必深究,记住默认搭配就是正确答案即可。
采样状态 SamplerState
采样状态决定纹理被放大缩小时如何取色,这对像素风游戏至关重要:
SamplerState.LinearClamp(默认):线性插值,缩放时平滑模糊。SamplerState.PointClamp:最近邻采样,缩放时保持硬边像素块,像素风游戏必选项,否则像素画会糊成一团。LinearWrap/PointWrap:Wrap表示UV超出0~1范围时平铺重复,做无限滚动的背景平铺时有用。
排序模式 SpriteSortMode
SpriteSortMode是绘制顺序的控制策略,五种模式可分以下三档理解:
Deferred(默认):不排序,完全按照Draw调用的先后顺序绘制,先画的在后画的底下,延迟到End时统一提交。它是最直观也最常用的模式,即“谁后画谁在上”,和在纸上作画的顺序是一样的。
FrontToBack / BackToFront:按layerDepth排序后绘制。绘制大量分层Sprite(例如背景、场景、角色、前景)时,给每层一个固定的layerDepth,绘制顺序就可以随意书写了,SpriteBatch会帮我们自动排好。下面是一段例子代码。
_spriteBatch.Begin(sortMode: SpriteSortMode.BackToFront);
_spriteBatch.Draw(_background, Vector2.Zero, null, Color.White, 0, Vector2.Zero, 1f, SpriteEffects.None, 1.0f);
_spriteBatch.Draw(_hero, _pos, null, Color.White, 0, Vector2.Zero, 1f, SpriteEffects.None, 0.5f);
_spriteBatch.Draw(_ui, Vector2.Zero, null, Color.White, 0, Vector2.Zero, 1f, SpriteEffects.None, 0.0f);
_spriteBatch.End();
Texture / Immediate:Texture表示按纹理分组排序以减少纹理切换,这是一种性能优化手段;Immediate则跳过批处理,Draw调用立即提交GPU,只有在给单个Sprite应用不同Effect时才需要它,平时基本用不到。
变换矩阵 transformMatrix
最后一个参数transformMatrix接受一个Matrix,会对本批所有精灵应用统一的坐标变换,包括平移、缩放、旋转。这正是摄像机的实现原理:
Matrix cameraTransform =
Matrix.CreateTranslation(new Vector3(-_cameraPosition, 0)) *
Matrix.CreateScale(_cameraZoom) *
Matrix.CreateTranslation(new Vector3(screenCenter, 0));
_spriteBatch.Begin(transformMatrix: cameraTransform);
代码中三个操作拆开来看:
Matrix.CreateTranslation(new Vector3(-_cameraPosition, 0)):根据摄像机当前的位置_cameraPosition进行反向平移,让世界坐标相对相机归零,对准要看的物体。为什么是让世界反向平移?因为相对来看,摄像机移动也可以说是反向移动整个世界,摄像机往右走,等价于世界中的所有物体往左移。举例来说,如果相机要看(100, 100),那么世界坐标中(100, 100)的物体实际上应该显示在屏幕正中心,通过减去相机位置,我们就把相机的焦点“拉”到了世界坐标的原点(0, 0)。Matrix.CreateScale(_cameraZoom):根据_cameraZoom的值进行缩放。在第一步中,相机的焦点已经被对齐到了原点(0, 0),此时进行缩放,缩放的“锚点”自然就是原点,也就是相机的中心。Matrix.CreateTranslation(new Vector3(screenCenter, 0)):将世界坐标系的原点(0, 0)平移到屏幕的中心,屏幕中心其实就是(屏幕宽 / 2, 屏幕高 / 2)。MonoGame默认的屏幕绘图原点(0, 0)在左上角,经过前两步变换后,原本应该在屏幕正中央的物体,其坐标其实是在(0, 0),因此,最后一步需要加上屏幕中心的偏移量,把画面整体平移到玩家看到的屏幕中央。
$$V_{screen} = V_{world} \times T(-\text{cameraPosition}) \times S(\text{zoom}) \times T(\text{screenCenter})$$
这样应用该变换矩阵后,所有Draw的坐标都是“世界坐标”,而SpriteBatch会自动换算成屏幕坐标,绘制到屏幕上。
注意:MonoGame继承微软XNA,公式中表达的是采用行向量乘矩阵,因此乘矩阵的顺序即代表变换执行的先后顺序。
渲染目标 RenderTarget2D
Draw不止能画到屏幕,还能画到纹理上,这就是渲染目标(RenderTarget)。它能实现很多强大的功能,包括分辨率适配、屏幕截图、分屏、后处理特效、小地图等。下面例子代码我们实现了一个经典的分辨率适配功能。
// 创建800×450画布
RenderTarget2D canvas = new RenderTarget2D(GraphicsDevice, 800, 450);
// 把画面画到画布上
GraphicsDevice.SetRenderTarget(canvas);
GraphicsDevice.Clear(Color.Black);
_spriteBatch.Begin();
_spriteBatch.Draw(_scene, Vector2.Zero, Color.White);
_spriteBatch.End();
// 切回屏幕
GraphicsDevice.SetRenderTarget(null);
GraphicsDevice.Clear(Color.CornflowerBlue);
_spriteBatch.Begin();
_spriteBatch.Draw(canvas, new Rectangle(0, 0, 1280, 720), Color.White);
_spriteBatch.End();
代码中,SetRenderTarget(canvas)把后续的绘制输出重定向到纹理,画完再SetRenderTarget(null)切回屏幕,此时canvas本身已经是一张可以随便Draw的Texture2D了。先把整个游戏场景以固定内部分辨率画到RenderTarget,再整体拉伸绘制到屏幕,这也是最经典的分辨率适配方案。
绘制图形
如果你在寻找类似GDI+中的绘制线条、绘制矩形、绘制圆形的方法,那很遗憾,MonoGame框架本体中没有这些API。MonoGame的定位是一个底层、轻量级的游戏框架,它本质上是对底层图形API的一个薄封装,现代GPU在底层并不是为这类绘制任务设计的,GPU的核心工作是光栅化三角形并处理纹理。当然,这并不意味着我们就不能画线、画矩形了,MonoGame中,我们可以创建1x1的白色纹理,然后用缩放和颜色叠加画出一切。
万能白像素,游戏启动时创建一次即可。
_pixel = new Texture2D(GraphicsDevice, 1, 1);
_pixel.SetData(new[] { Color.White });
画一个血量条。
_spriteBatch.Draw(_pixel, new Rectangle(100, 20, 200, 16), Color.DarkRed);
_spriteBatch.Draw(_pixel, new Rectangle(100, 20, (int)(200 * hpPercent), 16), Color.LimeGreen);
巧妙运用旋转和缩放画一条线。
void DrawLine(Vector2 start, Vector2 end, Color color, float thickness = 1f)
{
Vector2 edge = end - start;
float angle = (float)Math.Atan2(edge.Y, edge.X);
_spriteBatch.Draw(_pixel, start, null, color, angle,
Vector2.Zero, new Vector2(edge.Length(), thickness), SpriteEffects.None, 0);
}
当然,如果你懒得自己写这些绘制算法,其实MonoGame.Extended扩展库提供了非常完整的Shape形状绘制扩展,有关MonoGame.Extended将在专门章节进行介绍。