内容管道与资源管理

游戏程序运行时需要各种各样的资源文件,常见的包括纹理图片、音频、字体、着色器等,这些原始文件格式各异,游戏不可能在运行时挨个解析它们,XNA给出的解决方案是使用内容管道(Content Pipeline)统一管理。使用内容管道,游戏程序在构建阶段就会把原始资源统一编译成高效的.xnb二进制格式(XNB是XNA Framework Content Pipeline Binary Data的缩写),运行时则通过ContentManager统一加载,MonoGame也继承了这套机制。

内容管道运行流程

内容管道的工作分为构建期和运行时两个阶段:

构建期:原始资源被Importer(导入器)解析为中间对象,由Processor(处理器)加工转换为运行时类型,最后序列化为.xnb文件。

运行时:反序列化.xnb文件,得到可用对象。

举例来说,一张hero.png经过TextureImporter导入、TextureProcessor处理后变成了hero.xnb,原始图片被转为了一个经过预处理、GPU友好的二进制图形资产结构,游戏里调用Content.Load<Texture2D>("hero"),拿到的就是直接可以绘制的纹理对象,相比直接加载PNG图片能够省去PNG解码开销,图片、音频等各种资源都是如此,大量资源处理为.xnb后,理论上能够大幅节省资源加载时间。这种构建期预处理的思路在游戏开发中其实非常普遍,Unity的AssetBundle也是类似的哲学。

MGCB

前面搭建环境时我们曾介绍过MGCB Editor,MGCB全称MonoGame Content Builder,MGCB Editor是内容管道配置文件的图形编辑工具,工程里的Content.mgcb文件就是内容管道的配置文件,它本质其实是MGCB命令行工具的参数列表。在Visual Studio中,我们双击Content.mgcb就会自动运行MGCB Editor并打开当前配置文件,如果用命令独立启动MGCB Editor,则需要使用命令dotnet mgcb-editor Content.mgcb,或在图形界面中手动打开Content.mgcb。

MGCB Editor界面如下图所示,它分为左右两栏,左侧是资源列表和属性面板,右侧是构建输出。

想要添加资源时,以添加PNG图片为例,可以按如下步骤操作:

  1. 右键点击工程面板中的Content,点击Add -> Existing Item...
  2. 选择一张图片,例如Content目录下的hero.png,此时可以在工程面板看到图片被添加进来,点击图片后左下角属性面板显示对应信息,包括自动匹配的Importer和Processor
  3. 点击Build -> Build,可以看到图片被构建为了hero.xnb,不过工程模板的.csproj里已经配置好了MSBuild任务,启动游戏或打包时会自动构建.xnb并拷贝到输出目录,因此这一步通常不需要手动操作

实际上.mgcb文件就是个纯文本,打开看内容大致如下:

#----------------------------- Global Properties ----------------------------#

/outputDir:bin/$(Platform)
/intermediateDir:obj/$(Platform)
/platform:Windows
/config:
/profile:Reach
/compress:False

#-------------------------------- References --------------------------------#


#---------------------------------- Content ---------------------------------#

#begin hero.png
/importer:TextureImporter
/processor:TextureProcessor
/processorParam:ColorKeyColor=255,0,255,255
/processorParam:ColorKeyEnabled=True
/processorParam:GenerateMipmaps=False
/processorParam:PremultiplyAlpha=True
/processorParam:ResizeToPowerOfTwo=False
/processorParam:MakeSquare=False
/processorParam:TextureFormat=Color
/processorParam:Version=3.8.5.1
/build:hero.png

该文件内容其实很简单,我们完全可以不使用MGCB Editor手动编辑,相关的参数用法可以参考MonoGame官方文档,这里就不多介绍了。

ContentManager

游戏运行时,加载.xnb资源需要统一使用ContentManager类,代码中,它可以通过Game.Content访问。下面例子中,我们加载之前的图片资源,绘制到屏幕上。

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);
    }
}

这里要注意,Content.Load中使用的路径不带扩展名,且如果有多层路径,要用正斜杠,例如Content.Load<Texture2D>("audio/jump")加载的是Content/audio/jump.xnb,即使在Windows上也应保持统一,别用反斜杠。

此外我们还要知道,Content.Load具有缓存语义。同名资源重复Load,返回的是同一个对象引用,不会重复占用显存。这一设计很贴心,但也意味着如果你改了这张纹理的像素数据,所有引用者都会看到变化。

如果想要卸载资源,可以调用Content.Unload(),它会卸载该ContentManager加载过的所有资源,注意这里卸载的不是单个资源,MonoGame的资源释放粒度是整个ContentManager。这一功能主要用于分场景使用的独立ContentManager,当切换场景时,我们可以卸载之前场景的资源,以节省内存。

下面代码为某个场景创建独立的ContentManager。

ContentManager sceneContent = new ContentManager(Content.ServiceProvider);
sceneContent.RootDirectory = "Content";

下面代码卸载ContentManager中的所有资源。

sceneContent.Unload();

内置资源类型

不同的资源对应不同的泛型参数和Importer、Processor组合,内置支持的几种常用资源搭配如下表。

资源 原始格式 运行时类型 说明
纹理 png/jpg/bmp/tga Texture2D 各种图片,最常用
音效 wav SoundEffect 短促音效,低延迟播放
音乐 mp3/ogg/wma Song 背景音乐,流式播放
字体 .spritefont(XML) SpriteFont MonoGame中文字需要光栅化并生成.spritefont,见后续字体绘制章节
着色器 .fx(HLSL) Effect 自定义着色器常用于实现特效、特殊渲染效果等

自定义内容管道

内置Importer、Processor只能应付常规资源,如果我们想让游戏直接加载自定义资源就需要自定义内容管道。不幸的是MonoGame中自定义资源管道相当复杂,这是因为内容管道是在构建阶段运行的,我们需要独立创建一个类库工程来开发对应的Importer、Processor,MGCB则需要配置这个类库工程编译输出的DLL,所以我们的解决方案划分为如下两个工程。

Gacfox.Demo.DemoGame # 主游戏工程
Gacfox.Demo.DemoGame.ContentPipeline # 自定义内容管道工程

新建的类库工程Gacfox.Demo.DemoGame.ContentPipeline中,由于代码需要引入MonoGame内容管线相关的API,因此需要在.csproj中添加以下依赖。

<ItemGroup>
  <PackageReference Include="MonoGame.Framework.Content.Pipeline" Version="3.8.5.1" PrivateAssets="all" />
  <PackageReference Include="MonoGame.Framework.WindowsDX" Version="3.8.5.1" PrivateAssets="all" />
</ItemGroup>

其中MonoGame.Framework.Content.Pipeline是管道的核心库,至于引入MonoGame.Framework.DesktopGL这个依赖则是因为向.xnb写数据时会用到ContentWriter,它的大量重载中会用到Vector2、Vector3、Color、Matrix、Quaternion等类,C#做重载决议时必须能解析所有重载的参数类型,而这些类型又定义在MonoGame.Framework里,因此需要引入它。

此外,由于引入MonoGame.Framework.WindowsDX,这个框架本身的目标框架是net8.0-windows,我们的类库工程也要兼容这一设置,否则无法编译。如果你用的是MonoGame.Framework.DesktopGL,它的目标框架是net8.0而非仅限Windows,此时类库工程的目标框架默认就行了。

<TargetFramework>net8.0-windows</TargetFramework>

这里以JSON为例进行介绍,我们打算将一个hero.json加入内容管道,我们期望经过MGCB处理后能在主游戏工程里直接用ContentManager获取Hero实例,这里直接给出完整代码。

Hero.cs

namespace Gacfox.Demo.DemoGame.ContentPipeline;

/// <summary>
/// 玩家角色数据。
/// </summary>
public class Hero
{
    public string Name { get; set; }
    public int Level { get; set; }
    public int Hp { get; set; }
    public int Attack { get; set; }
    public int Defense { get; set; }
}
using System.Text.Json;
using Microsoft.Xna.Framework.Content.Pipeline;

namespace Gacfox.Demo.DemoGame.ContentPipeline;

/// <summary>
/// 导入器:把 hero.json 读取为 Hero 对象。
/// </summary>
[ContentImporter(".json", DisplayName = "Hero Importer", DefaultProcessor = nameof(HeroProcessor))]
public class HeroImporter : ContentImporter<Hero>
{
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNameCaseInsensitive = true
    };

    public override Hero Import(string filename, ContentImporterContext context)
    {
        context.Logger.Log($"Importing hero from: {filename}");
        var json = File.ReadAllText(filename);
        return JsonSerializer.Deserialize<Hero>(json, JsonOptions);
    }
}
using Microsoft.Xna.Framework.Content.Pipeline;

namespace Gacfox.Demo.DemoGame.ContentPipeline;

/// <summary>
/// 处理器:对导入结果做简单校验后原样传递给写入器。
/// </summary>
[ContentProcessor(DisplayName = "Hero Processor")]
public class HeroProcessor : ContentProcessor<Hero, Hero>
{
    public override Hero Process(Hero input, ContentProcessorContext context)
    {
        if (input == null)
            throw new PipelineException("hero.json 解析结果为空。");
        if (input.Hp <= 0)
            throw new PipelineException("Hero.Hp 必须大于 0。");

        return input;
    }
}
using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler;

namespace Gacfox.Demo.DemoGame.ContentPipeline;

/// <summary>
/// 写入器:编译期把 Hero 序列化为二进制,并声明运行期使用的读取器。
/// </summary>
[ContentTypeWriter]
public class HeroWriter : ContentTypeWriter<Hero>
{
    public override string GetRuntimeReader(TargetPlatform targetPlatform)
    {
        // 游戏运行期通过该程序集限定名反射创建 HeroReader
        return typeof(HeroReader).AssemblyQualifiedName;
    }

    protected override void Write(ContentWriter output, Hero value)
    {
        output.Write(value.Name);
        output.Write(value.Level);
        output.Write(value.Hp);
        output.Write(value.Attack);
        output.Write(value.Defense);
    }
}
using Microsoft.Xna.Framework.Content;

namespace Gacfox.Demo.DemoGame.ContentPipeline;

/// <summary>
/// 读取器:主游戏工程使用,用于反序列化 XNB 获得 Hero 对象。
/// </summary>
public class HeroReader : ContentTypeReader<Hero>
{
    protected override Hero Read(ContentReader input, Hero existingInstance)
    {
        return new Hero
        {
            Name = input.ReadString(),
            Level = input.ReadInt32(),
            Hp = input.ReadInt32(),
            Attack = input.ReadInt32(),
            Defense = input.ReadInt32(),
        };
    }
}

主游戏工程中,我们需要将自定义内容管道工程编译得到的DLL复制进来,放到Content/Pipeline/Gacfox.Demo.DemoGame.ContentPipeline.dll,Content.mgcb中添加以下配置,引入自定义内容管道DLL。

#-------------------------------- References --------------------------------#

/reference:Pipeline/Gacfox.Demo.DemoGame.ContentPipeline.dll

此外由于实体类和读取器都定义在了内容管道工程里,我们的游戏工程也得依赖该工程,以便在代码中使用Hero和HeroReader,它们是在游戏运行时用到的,我们可以直接在Visual Studio中右键点击工程,选择生成依赖项 -> 项目依赖项...编辑工程的依赖项,或者手动编辑.csproj。

如果你不喜欢手动复制,也可以在.csproj配置一个MSBuild自定义目标,在MGCB运行前触发依赖的目标编译并将DLL自动复制进来。

<Target Name="CopyContentPipelineAssembly"
        BeforeTargets="RunContentBuilder"
        DependsOnTargets="ResolveProjectReferences">
  <ItemGroup>
    <_ContentPipelineReference Include="@(_ResolvedProjectReferencePaths)"
                               Condition="'%(Filename)' == 'Gacfox.Demo.DemoGame.ContentPipeline'" />
  </ItemGroup>
  <MakeDir Directories="$(MSBuildProjectDirectory)\Content\Pipeline" />
  <Copy SourceFiles="@(_ContentPipelineReference)"
        DestinationFolder="$(MSBuildProjectDirectory)\Content\Pipeline"
        SkipUnchangedFiles="true" />
</Target>

主游戏工程中,我们这里准备一个测试用的JSON,放在Content文件夹中,然后使用MGCB Editor将其加入到Content.mgcb中,Importer和Processor都使用我们之前自定义的组件。

{
  "name": "Arthur",
  "level": 1,
  "hp": 100,
  "attack": 12,
  "defense": 5
}

此时,我们的Game1类中就可以使用ContentManager加载自定义JSON资源了!和之前的规则一样,我们调用Content.Load<Hero>("hero"),传入字符串是.xnb的名字,但后缀可以省略,如果一切正常,会看到资源被正确读取。

protected override void LoadContent()
{
    _spriteBatch = new SpriteBatch(GraphicsDevice);
    _hero = Content.Load<Hero>("hero");
    Debug.WriteLine($"Hero: {_hero.Name} Lv{_hero.Level} HP{_hero.Hp} ATK{_hero.Attack} DEF{_hero.Defense}");
}

这里可能有人会有疑问,之前的HeroReader类在我们的代码里没有地方显式调用,它是怎么生效的?其实,.xnb中会保存资源读取器的类信息,游戏运行时HeroReader会自动被MonoGame框架反射加载,并用于反序列化.xnb中保存的二进制数据,这些类信息是写入器在GetRuntimeReader方法中写入.xnb的。

public override string GetRuntimeReader(TargetPlatform targetPlatform)
{
    return typeof(HeroReader).AssemblyQualifiedName;
}

另外一点值得说明的是,我们这里将整个解决方案划分为了两个工程,游戏工程依赖自定义管道工程,资源的实体类、Reader这两个游戏运行时所需的类也写在了自定义管道工程里,如果你有强迫症,也可以尝试将其划分为更解耦的架构,例如:

Gacfox.Demo.DemoGame.Shared # 数据模型、读取器
Gacfox.Demo.DemoGame # 主游戏工程
Gacfox.Demo.DemoGame.ContentPipeline # 自定义内容管道工程

这里出于篇幅考虑就不展开介绍了。

绕过内容管道直接加载

.xnb管道虽好,但有些时候我们就是想直接读文件,MonoGame也支持这么做,下面是一些例子代码。

// 从文件流加载并读取纹理
using var stream = TitleContainer.OpenStream("Content/ball.png");
Texture2D ball = Texture2D.FromStream(GraphicsDevice, stream);

// 读取文本文件
string json = File.ReadAllText("Content/data/items.json");

TitleContainer.OpenStream是个跨平台的文件打开辅助方法,它会把路径解析到程序运行目录下,注意用这种方式加载文件时,需要将其在Visual Studio中设置为复制到输出目录,且不要再将资源文件加入.mgcb,否则一个文件构建两份浪费构建时间和存储空间。

实际开发中,MGCB和运行时直接加载混用是常见的。前面例子中我们费了很大劲才把JSON“塞”进内容管道,但对JSON来说,这么做其实对于性能提升效果有限,JSON文本文件加载本身就已经很快了,转为.xnb还要自己写二进制序列化和反序列化(或引入MessagePack等),没准还会引入新Bug。以RPG游戏为例,实际项目可能用到大量JSON来定义物品、NPC、任务,这些数据文件可能频繁修改且需要热重载,游戏还可能需要读取玩家自制MOD,而将数据JSON加入内容管线则反而变成了阻碍。

总而言之,对于大部分独立游戏开发者来说,直接读取原始JSON是性价比更高的选择,只有在追求极端的资产规范化时,才可能考虑将其加入自定义管道,而那些解析复杂耗时且存量较多的资源文件,加入.xnb才真正有意义。

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