摄像机与视口

到目前为止,我们笔记中的示例游戏内容都局限在一个屏幕之内,角色一旦跑出屏幕边界就看不见了。但真实游戏的世界往往比屏幕大得多,横版卷轴、俯视角大地图,这些内容都需要一个「摄像机」来决定把世界中的哪一部分呈现给玩家。此外,玩家的窗口尺寸、屏幕分辨率千差万别,如何让游戏画面在各种分辨率下表现一致,也是必须解决的问题。这篇笔记我们先从原理出发手写一个2D摄像机,实现摄像机跟随和世界边界钳制,然后讨论分辨率适配的经典方案,最后介绍MonoGame.Extended中开箱即用的摄像机和视口适配器。

2D摄像机的本质

在第4章介绍SpriteBatch.Begin时我们其实已经提到过了,Begin方法的transformMatrix参数会对本批所有Sprite应用统一的坐标变换,当时我们说过,这正是摄像机的实现原理。使用摄像机前,先要区分两个坐标系:

世界坐标系:游戏世界自身的坐标系,角色位置、地图尺寸都在这个坐标系下描述,它的大小由游戏内容决定,比如一张4000×3000的大地图。

屏幕坐标系:玩家实际看到的窗口像素坐标,原点在窗口左上角,大小就是视口(Viewport)尺寸,比如1280×720。

没有摄像机时,SpriteBatch默认把世界坐标直接当作屏幕坐标,也就是屏幕上看到的世界永远是左上角那一小块。摄像机的职责就是在两个坐标系之间建立一座桥梁:它本质上并不真实存在,只是世界坐标到屏幕坐标的变换矩阵,即视图矩阵(View Matrix)。

一个2D摄像机通常包含三个属性:

  • Position:摄像机注视的世界坐标位置
  • Zoom:缩放倍率,大于1为拉近(画面放大),小于1为拉远(画面缩小)
  • Rotation:旋转角度(弧度),普通游戏用得少,屏幕震动、特殊演出时会用到

由这三个属性构造视图矩阵的过程,就是前面我们介绍过的三步变换:先把世界反向平移使注视点对齐原点,再以原点为中心旋转缩放,最后把原点平移到屏幕中心。

$$V_{screen} = V_{world} \times T(-\text{position}) \times R(\text{rotation}) \times S(\text{zoom}) \times T(\text{screenCenter})$$

手写一个2D摄像机

理解了原理,手写一个摄像机类就是把上面的公式封装起来。下面例子代码中,我们实现了一个Camera2D类,并在Game1中使用它浏览一个大于屏幕的网格世界。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;

namespace Gacfox.Demo.DemoGame;

public class Camera2D
{
    private readonly GraphicsDevice _graphicsDevice;

    /// <summary>摄像机注视的世界坐标</summary>
    public Vector2 Position { get; set; }

    /// <summary>缩放倍率,1为原始大小</summary>
    public float Zoom { get; set; } = 1f;

    /// <summary>旋转角度(弧度)</summary>
    public float Rotation { get; set; }

    public Camera2D(GraphicsDevice graphicsDevice)
    {
        _graphicsDevice = graphicsDevice;
    }

    public Matrix GetViewMatrix()
    {
        // 屏幕中心随视口动态计算,窗口大小改变后依然正确
        Viewport viewport = _graphicsDevice.Viewport;
        Vector2 screenCenter = new Vector2(viewport.Width / 2f, viewport.Height / 2f);

        return
            Matrix.CreateTranslation(new Vector3(-Position, 0f)) *   // 反向平移,注视点对齐原点
            Matrix.CreateRotationZ(Rotation) *                        // 以原点为中心旋转
            Matrix.CreateScale(Zoom) *                                // 以原点为中心缩放
            Matrix.CreateTranslation(new Vector3(screenCenter, 0f));  // 原点平移到屏幕中心
    }
}

摄像机类本身非常简短,所有工作都交给了矩阵,使用时只需要在绘制场景的Begin中传入视图矩阵即可。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace Gacfox.Demo.DemoGame;

public class Game1 : Game
{
    private static readonly Rectangle WorldBounds = new Rectangle(0, 0, 2000, 1400);

    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;
    private Texture2D _pixel;
    private Camera2D _camera;

    public Game1()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
    }

    protected override void Initialize()
    {
        _camera = new Camera2D(GraphicsDevice);
        _camera.Position = new Vector2(1000, 700); // 注视世界中心
        base.Initialize();
    }

    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);

        _pixel = new Texture2D(GraphicsDevice, 1, 1);
        _pixel.SetData(new[] { Color.White });
    }

    protected override void Update(GameTime gameTime)
    {
        if (Keyboard.GetState().IsKeyDown(Keys.Escape))
        {
            Exit();
        }

        float dt = (float)gameTime.ElapsedGameTime.TotalSeconds;
        KeyboardState keyboardState = Keyboard.GetState();

        // WASD移动摄像机
        Vector2 move = Vector2.Zero;
        if (keyboardState.IsKeyDown(Keys.W)) move.Y -= 1;
        if (keyboardState.IsKeyDown(Keys.S)) move.Y += 1;
        if (keyboardState.IsKeyDown(Keys.A)) move.X -= 1;
        if (keyboardState.IsKeyDown(Keys.D)) move.X += 1;
        _camera.Position += move * 400f * dt;

        // Q/E缩放摄像机
        if (keyboardState.IsKeyDown(Keys.Q)) _camera.Zoom += 1f * dt;
        if (keyboardState.IsKeyDown(Keys.E)) _camera.Zoom -= 1f * dt;
        _camera.Zoom = MathHelper.Clamp(_camera.Zoom, 0.25f, 4f);

        Window.Title = $"Camera Pos: {_camera.Position}, Zoom: {_camera.Zoom:F2}";
        base.Update(gameTime);
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.Black);

        // 场景绘制:传入摄像机视图矩阵,此后Draw的坐标都是世界坐标
        _spriteBatch.Begin(transformMatrix: _camera.GetViewMatrix());
        _spriteBatch.Draw(_pixel, WorldBounds, Color.DarkOliveGreen);
        for (int x = WorldBounds.Left; x <= WorldBounds.Right; x += 100)
        {
            _spriteBatch.Draw(_pixel, new Rectangle(x, WorldBounds.Top, 1, WorldBounds.Height), Color.Black * 0.3f);
        }
        for (int y = WorldBounds.Top; y <= WorldBounds.Bottom; y += 100)
        {
            _spriteBatch.Draw(_pixel, new Rectangle(WorldBounds.Left, y, WorldBounds.Width, 1), Color.Black * 0.3f);
        }
        _spriteBatch.Draw(_pixel, new Rectangle(1000 - 25, 700 - 25, 50, 50), Color.OrangeRed);
        _spriteBatch.End();

        base.Draw(gameTime);
    }
}

运行后我们可以用WASD移动视角、Q/E缩放,窗口标题栏会实时显示摄像机状态。可以看到,世界中的网格、标记物全部使用世界坐标绘制,摄像机移动时我们一行绘制代码都不用改,这正是视图矩阵带来的解耦。

一个实践中几乎必然用到的惯例是场景和UI要分开绘制。血条、背包、暂停菜单这类UI应该固定在屏幕上,不随摄像机移动,一般做法是在场景批次结束后再起一个不传transformMatrix的批次绘制UI。

// 场景:应用摄像机变换
_spriteBatch.Begin(transformMatrix: _camera.GetViewMatrix());
// ... 绘制世界内容
_spriteBatch.End();

// UI:不应用变换,直接使用屏幕坐标
_spriteBatch.Begin();
// ... 绘制血条、菜单等
_spriteBatch.End();

摄像机跟随

大多数游戏中摄像机不是由玩家直接控制的,而是自动跟随主角。摄像机跟随有三个递进的层次,我们逐一介绍。

硬跟随

最简单的方案,每帧把摄像机位置设为目标位置。

_camera.Position = _playerPosition;

硬跟随功能上没问题,但玩家每一次移动、每一帧的抖动都会原样反映到画面上,视觉感受非常生硬,实际游戏中很少这样使用。

平滑跟随

平滑跟随的思路是让摄像机位置每帧向目标位置插值靠近一小段,而不是瞬间到达,画面就会产生柔和的拖拽感。下面代码中,我们使用Vector2.Lerp实现平滑跟随。

float dt = (float)gameTime.ElapsedGameTime.TotalSeconds;
float t = 1f - MathF.Exp(-5f * dt); // 帧率无关的插值系数,5是跟随速度,越大跟得越紧
_camera.Position = Vector2.Lerp(_camera.Position, _playerPosition, t);

注意这里插值系数没有直接写成5f * dt,而是用了1f - MathF.Exp(-5f * dt)。线性写法5f * dt在不同帧率下收敛速度不一致,高帧率时跟随会明显变快;指数写法保证了任意帧率下手感一致,这也是游戏开发中做阻尼插值的惯用法。

边界钳制

摄像机跟随还有一个必须处理的问题,当主角走到地图边缘时,摄像机会照出世界之外的空白区域,这通常不是我们希望看到的。解决办法是每帧把摄像机位置钳制(Clamp)在一个范围内,这个范围由世界边界和视野大小共同决定,视野半宽半高还要除以Zoom,因为缩放会改变一屏能看到的世界范围。

Viewport viewport = GraphicsDevice.Viewport;
float halfViewWidth = viewport.Width / 2f / _camera.Zoom;
float halfViewHeight = viewport.Height / 2f / _camera.Zoom;

_camera.Position = new Vector2(
    MathHelper.Clamp(_camera.Position.X, WorldBounds.Left + halfViewWidth, WorldBounds.Right - halfViewWidth),
    MathHelper.Clamp(_camera.Position.Y, WorldBounds.Top + halfViewHeight, WorldBounds.Bottom - halfViewHeight)
);

把三个技巧组合起来,便得出下面这个完整的可运行示例,方向键控制橙色方块在2000×1400的世界中移动,摄像机平滑跟随且不会照出世界之外。

using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace Gacfox.Demo.DemoGame;

public class Game1 : Game
{
    private static readonly Rectangle WorldBounds = new Rectangle(0, 0, 2000, 1400);

    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;
    private Texture2D _pixel;
    private Camera2D _camera;

    private Vector2 _playerPosition = new Vector2(1000, 700);

    public Game1()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
    }

    protected override void Initialize()
    {
        _camera = new Camera2D(GraphicsDevice);
        _camera.Position = _playerPosition;
        base.Initialize();
    }

    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);

        _pixel = new Texture2D(GraphicsDevice, 1, 1);
        _pixel.SetData(new[] { Color.White });
    }

    protected override void Update(GameTime gameTime)
    {
        if (Keyboard.GetState().IsKeyDown(Keys.Escape))
        {
            Exit();
        }

        float dt = (float)gameTime.ElapsedGameTime.TotalSeconds;
        KeyboardState keyboardState = Keyboard.GetState();

        // 玩家移动
        Vector2 direction = Vector2.Zero;
        if (keyboardState.IsKeyDown(Keys.W)) direction.Y -= 1;
        if (keyboardState.IsKeyDown(Keys.S)) direction.Y += 1;
        if (keyboardState.IsKeyDown(Keys.A)) direction.X -= 1;
        if (keyboardState.IsKeyDown(Keys.D)) direction.X += 1;
        if (direction != Vector2.Zero)
        {
            direction.Normalize();
            _playerPosition += direction * 300f * dt;
        }

        // 摄像机平滑跟随
        float t = 1f - MathF.Exp(-5f * dt);
        _camera.Position = Vector2.Lerp(_camera.Position, _playerPosition, t);

        // 摄像机边界钳制
        Viewport viewport = GraphicsDevice.Viewport;
        float halfViewWidth = viewport.Width / 2f / _camera.Zoom;
        float halfViewHeight = viewport.Height / 2f / _camera.Zoom;
        _camera.Position = new Vector2(
            MathHelper.Clamp(_camera.Position.X, WorldBounds.Left + halfViewWidth, WorldBounds.Right - halfViewWidth),
            MathHelper.Clamp(_camera.Position.Y, WorldBounds.Top + halfViewHeight, WorldBounds.Bottom - halfViewHeight)
        );

        base.Update(gameTime);
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.Black);

        _spriteBatch.Begin(transformMatrix: _camera.GetViewMatrix());
        _spriteBatch.Draw(_pixel, WorldBounds, Color.DarkOliveGreen);
        for (int x = WorldBounds.Left; x <= WorldBounds.Right; x += 100)
        {
            _spriteBatch.Draw(_pixel, new Rectangle(x, WorldBounds.Top, 1, WorldBounds.Height), Color.Black * 0.3f);
        }
        for (int y = WorldBounds.Top; y <= WorldBounds.Bottom; y += 100)
        {
            _spriteBatch.Draw(_pixel, new Rectangle(WorldBounds.Left, y, WorldBounds.Width, 1), Color.Black * 0.3f);
        }
        _spriteBatch.Draw(_pixel, new Rectangle((int)_playerPosition.X - 16, (int)_playerPosition.Y - 16, 32, 32), Color.OrangeRed);
        _spriteBatch.End();

        base.Draw(gameTime);
    }
}

分辨率适配

虚拟分辨率

到目前为止,我们所有绘制坐标都是窗口像素。这会带来一个问题,游戏在1920×1080的窗口下调试好后,换到1366×768的笔记本上,画面内容就会显示不全,UI布局也会乱掉。

业界解决这个问题的主流思路是引入虚拟分辨率(Virtual Resolution),游戏内部的一切逻辑和绘制都只面向一个固定的内部分辨率(例如1280×720),开发者完全不用关心玩家的实际窗口有多大,最后一帧时再把这份固定尺寸的画面统一映射到真实窗口上。这样无论窗口大小、宽高比如何变化,游戏内的布局都恒定不变。

将虚拟画面映射到真实窗口有三种常见策略:

直接拉伸:画面非等比拉伸铺满窗口,实现最简单,但宽高比不一致时画面会变形,角色被拉扁或拉长。

等比缩放留黑边:等比缩放到窗口能容纳的最大尺寸并居中,多余的部分填充黑边。画面不变形、内容不缺失,代价是两侧或上下可能出现黑边,这是最常见的方案,左右黑边叫Pillarbox,上下黑边叫Letterbox。

等比缩放裁剪:等比缩放到完全覆盖窗口,超出部分裁掉。没有黑边,但玩家可能看不到画面边缘的内容,UI需要做安全区避让。

手写RenderTarget2D适配方案

留黑边策略用第4章介绍过的RenderTarget2D就能实现,先把整个游戏以虚拟分辨率画到一张渲染目标画布上,再把画布等比缩放、居中绘制到屏幕,下面例子代码给出了完整的实现。

using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace Gacfox.Demo.DemoGame;

public class Game1 : Game
{
    // 虚拟分辨率:游戏内部只面向这个尺寸开发
    private const int VirtualWidth = 1280;
    private const int VirtualHeight = 720;

    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;
    private RenderTarget2D _canvas;
    private Texture2D _pixel;

    public Game1()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
        Window.AllowUserResizing = true; // 允许调整窗口大小,观察适配效果
    }

    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);

        // 虚拟分辨率画布,尺寸固定,不随窗口变化
        _canvas = new RenderTarget2D(GraphicsDevice, VirtualWidth, VirtualHeight);

        _pixel = new Texture2D(GraphicsDevice, 1, 1);
        _pixel.SetData(new[] { Color.White });
    }

    protected override void Update(GameTime gameTime)
    {
        if (Keyboard.GetState().IsKeyDown(Keys.Escape))
        {
            Exit();
        }
        base.Update(gameTime);
    }

    /// <summary>
    /// 计算画布在屏幕上的目标绘制矩形:等比缩放并居中,多余部分留黑边
    /// </summary>
    private Rectangle ComputeDestinationRectangle()
    {
        int backBufferWidth = GraphicsDevice.PresentationParameters.BackBufferWidth;
        int backBufferHeight = GraphicsDevice.PresentationParameters.BackBufferHeight;

        float scale = MathF.Min(backBufferWidth / (float)VirtualWidth, backBufferHeight / (float)VirtualHeight);
        int width = (int)(VirtualWidth * scale);
        int height = (int)(VirtualHeight * scale);
        int x = (backBufferWidth - width) / 2;
        int y = (backBufferHeight - height) / 2;
        return new Rectangle(x, y, width, height);
    }

    protected override void Draw(GameTime gameTime)
    {
        // 第一步:把整个游戏画到虚拟分辨率画布上
        GraphicsDevice.SetRenderTarget(_canvas);
        GraphicsDevice.Clear(Color.CornflowerBlue);

        _spriteBatch.Begin();
        _spriteBatch.Draw(_pixel, new Rectangle(0, 0, VirtualWidth, 60), Color.DarkSlateBlue);      // 顶部栏
        _spriteBatch.Draw(_pixel, new Rectangle(0, VirtualHeight - 60, VirtualWidth, 60), Color.DarkSlateBlue); // 底部栏
        _spriteBatch.Draw(_pixel, new Rectangle(VirtualWidth / 2 - 50, VirtualHeight / 2 - 50, 100, 100), Color.OrangeRed); // 中央方块
        _spriteBatch.End();

        // 第二步:切回屏幕,把画布等比缩放、居中绘制
        GraphicsDevice.SetRenderTarget(null);
        GraphicsDevice.Clear(Color.Black); // 黑边颜色

        _spriteBatch.Begin(samplerState: SamplerState.PointClamp); // 像素风游戏用PointClamp防止模糊
        _spriteBatch.Draw(_canvas, ComputeDestinationRectangle(), Color.White);
        _spriteBatch.End();

        base.Draw(gameTime);
    }
}

运行后随意拖动窗口边缘改变窗口大小和比例,可以看到游戏画面始终保持16:9居中显示,多余部分是黑边,内容既不变形也不缺失。

不过这套方案有一个容易踩的坑,鼠标坐标不再等于游戏内坐标!Mouse.GetState()返回的是真实窗口坐标,而游戏逻辑运行在虚拟分辨率下,坐标必须先减去黑边偏移、再除以缩放比例换算回虚拟坐标,否则按钮点击判定会整体错位。

Vector2 ScreenToVirtual(Point screenPoint)
{
    Rectangle dest = ComputeDestinationRectangle();
    return new Vector2(
        (screenPoint.X - dest.X) * VirtualWidth / (float)dest.Width,
        (screenPoint.Y - dest.Y) * VirtualHeight / (float)dest.Height
    );
}

如果游戏还叠加了摄像机,那还要再经过一次摄像机逆变换才能得到世界坐标,换算链条是屏幕坐标 -> 虚拟坐标 -> 世界坐标。

MonoGame.Extended中的摄像机与视口适配

前面我们手写了摄像机和分辨率适配,目的是把原理讲清楚,实际开发中不必自己维护这些代码。MonoGame.Extended(第6章已介绍安装方式)提供了成熟的ViewportAdapter视口适配器和OrthographicCamera摄像机,二者可以无缝组合,下面分别介绍。

ViewportAdapter视口适配器

ViewportAdapter封装了虚拟分辨率到真实窗口的映射,它在命名空间MonoGame.Extended.ViewportAdapters下,常用实现有三个。

类型 说明
DefaultViewportAdapter 虚拟分辨率等于设备实际视口,不做任何缩放,GetScaleMatrix()返回单位矩阵
ScalingViewportAdapter 面向固定虚拟分辨率绘制,通过GetScaleMatrix()提供的缩放矩阵把画面拉伸铺满窗口
BoxingViewportAdapter 在ScalingViewportAdapter基础上增加留黑边策略,等比缩放居中,多余部分为黑边,窗口尺寸变化时自动重算视口

最常用的是BoxingViewportAdapter,它就是我们上一节手写方案的成品实现,但比我们手写的更巧妙,它不需要RenderTarget2D画布,而是直接把GraphicsDevice.Viewport重设为窗口中那个等比居中的矩形区域,后续所有绘制天然落在虚拟坐标系内。

_viewportAdapter = new BoxingViewportAdapter(Window, GraphicsDevice, 1280, 720);

BoxingViewportAdapter还有两个进阶特性,构造函数可传入horizontalBleed和verticalBleed出血参数,允许画面向黑边方向适当溢出,避免某些分辨率下出现极细的黑边;BoxingMode属性可以查询当前的留边模式,取值为BoxingMode.None、BoxingMode.Letterbox(上下黑边)或BoxingMode.Pillarbox(左右黑边)。

OrthographicCamera摄像机

OrthographicCamera是MonoGame.Extended提供的2D正交摄像机,在命名空间MonoGame.Extended下。它可以直接用GraphicsDevice构造,但我们更推荐传入ViewportAdapter构造,这样摄像机的视野中心、视野尺寸都基于虚拟分辨率计算,与视口适配器自动协同。

_camera = new OrthographicCamera(_viewportAdapter);
_camera.LookAt(Vector2.Zero); // 初始注视世界原点

绘制时同样只需把视图矩阵传给Begin。

_spriteBatch.Begin(transformMatrix: _camera.GetViewMatrix());

OrthographicCamera的常用API如下。

成员 说明
Position 摄像机视野的左上角世界坐标(注意不是中心)
Center 视野中心的世界坐标,只读,等价于Position + Origin
Rotation 旋转角度(弧度)
Zoom 缩放倍率,赋值时自动钳制到[MinimumZoom, MaximumZoom]
MinimumZoom / MaximumZoom 缩放范围,默认为0到float.MaxValue,赋值为负数会抛出ArgumentOutOfRangeException
BoundingRectangle 当前视野在世界中的包围矩形,可用于视野剔除
Move(Vector2) 按方向移动摄像机(摄像机局部坐标系)
Rotate(float) 旋转指定弧度
ZoomIn(float) / ZoomOut(float) 拉近 / 拉远
LookAt(Vector2) 注视某个世界坐标,使其位于视野中心
GetViewMatrix() 获取视图矩阵,传给SpriteBatch.Begin
GetViewMatrix(Vector2 parallaxFactor) 带视差系数的视图矩阵,1为完全跟随,值越小跟随越弱,用于视差背景层
WorldToScreen(Vector2) / ScreenToWorld(Vector2) 世界坐标与屏幕坐标的互相转换
EnableWorldBounds(Rectangle) / DisableWorldBounds() 启用 / 关闭世界边界,启用后摄像机位置和缩放自动被钳制在世界范围内
Contains(Vector2) / Contains(Rectangle) 判断点或矩形是否在视野内,返回ContainmentType

可以看到,我们前面手写的跟随(LookAt)、边界钳制(EnableWorldBounds)、鼠标坐标换算(ScreenToWorld)都被封装好了。其中几个特别值得说明:

视差滚动:GetViewMatrix(Vector2 parallaxFactor)是实现多层背景视差的关键。近处图层用1完全跟随摄像机,远处山脉用0.5半速跟随,天空用0固定不动,不同图层各起一个Begin批次传入不同的视差矩阵即可。

鼠标点选世界物体:ScreenToWorld一行解决我们上一节提到的屏幕坐标 -> 虚拟坐标 -> 世界坐标换算链,配合BoxingViewportAdapter使用时黑边偏移也已自动处理。

MouseState mouseState = Mouse.GetState();
Vector2 worldMousePosition = _camera.ScreenToWorld(mouseState.Position.ToVector2());

视野剔除:大地图中可能有成千上万个物件,全部Draw会浪费性能,绘制前用Contains判断物件包围盒是否在视野内,不在就跳过。

if (_camera.Contains(itemBounds) != ContainmentType.Disjoint)
{
    _spriteBatch.Draw(itemTexture, itemBounds, Color.White);
}

2D摄像机完整示例

最后我们把BoxingViewportAdapter和OrthographicCamera组合起来,用很少的代码实现一个具备虚拟分辨率、黑边适配、平滑跟随、边界钳制的完整方案,这正是前面几节手写内容的成品版本。

using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using MonoGame.Extended;
using MonoGame.Extended.ViewportAdapters;

namespace Gacfox.Demo.DemoGame;

public class Game1 : Game
{
    private const int VirtualWidth = 1280;
    private const int VirtualHeight = 720;
    private static readonly Rectangle WorldBounds = new Rectangle(0, 0, 2400, 1600);

    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;
    private Texture2D _pixel;

    private BoxingViewportAdapter _viewportAdapter;
    private OrthographicCamera _camera;

    private Vector2 _playerPosition = new Vector2(1200, 800);

    public Game1()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
        Window.AllowUserResizing = true;
    }

    protected override void Initialize()
    {
        _graphics.PreferredBackBufferWidth = VirtualWidth;
        _graphics.PreferredBackBufferHeight = VirtualHeight;
        _graphics.ApplyChanges();

        _viewportAdapter = new BoxingViewportAdapter(Window, GraphicsDevice, VirtualWidth, VirtualHeight);
        _camera = new OrthographicCamera(_viewportAdapter);
        _camera.LookAt(_playerPosition);
        _camera.EnableWorldBounds(WorldBounds); // 摄像机不会照出世界之外

        base.Initialize();
    }

    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);

        _pixel = new Texture2D(GraphicsDevice, 1, 1);
        _pixel.SetData(new[] { Color.White });
    }

    protected override void Update(GameTime gameTime)
    {
        if (Keyboard.GetState().IsKeyDown(Keys.Escape))
        {
            Exit();
        }

        float dt = (float)gameTime.ElapsedGameTime.TotalSeconds;
        KeyboardState keyboardState = Keyboard.GetState();

        // 玩家移动
        Vector2 direction = Vector2.Zero;
        if (keyboardState.IsKeyDown(Keys.W)) direction.Y -= 1;
        if (keyboardState.IsKeyDown(Keys.S)) direction.Y += 1;
        if (keyboardState.IsKeyDown(Keys.A)) direction.X -= 1;
        if (keyboardState.IsKeyDown(Keys.D)) direction.X += 1;
        if (direction != Vector2.Zero)
        {
            direction.Normalize();
            _playerPosition += direction * 300f * dt;
            Point worldMin = WorldBounds.Location;
            Point worldMax = WorldBounds.Location + WorldBounds.Size;
            _playerPosition = Vector2.Clamp(_playerPosition, worldMin.ToVector2(), worldMax.ToVector2());
        }

        // 摄像机平滑跟随:插值视野中心后重新注视
        float t = 1f - MathF.Exp(-5f * dt);
        _camera.LookAt(Vector2.Lerp(_camera.Center, _playerPosition, t));

        base.Update(gameTime);
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.Black); // 黑边区域颜色

        _spriteBatch.Begin(transformMatrix: _camera.GetViewMatrix());
        _spriteBatch.Draw(_pixel, WorldBounds, Color.DarkOliveGreen);
        for (int x = WorldBounds.Left; x <= WorldBounds.Right; x += 100)
        {
            _spriteBatch.Draw(_pixel, new Rectangle(x, WorldBounds.Top, 1, WorldBounds.Height), Color.Black * 0.3f);
        }
        for (int y = WorldBounds.Top; y <= WorldBounds.Bottom; y += 100)
        {
            _spriteBatch.Draw(_pixel, new Rectangle(WorldBounds.Left, y, WorldBounds.Width, 1), Color.Black * 0.3f);
        }
        _spriteBatch.Draw(_pixel, new Rectangle((int)_playerPosition.X - 16, (int)_playerPosition.Y - 16, 32, 32), Color.OrangeRed);
        _spriteBatch.End();

        base.Draw(gameTime);
    }
}

对比前面手写的版本可以看到,RenderTarget2D画布、目标矩形计算、钳制代码全部消失了,整个适配和摄像机逻辑收敛到Initialize中的三行设置,平滑跟随也只需LookAt配合一行插值。运行后拖动窗口边缘,画面始终保持16:9居中,黑边自动出现,这正是BoxingViewportAdapter在背后帮我们完成了视口重算。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。