Blitz.Engine:资产系统



在我们了解Blitz.Engine引擎的资产系统如何工作之前,我们需要确定资产是什么,以及资产系统究竟意味着什么。根据维基百科,游戏资产是一种数字对象,主要由相同的数据组成,它是代表游戏内容一部分并具有某些属性的不可分割的实体。从程序模型的角度来看,资产可以显示为在某些数据集上创建的对象。资产可以存储为单独的文件。反过来,资产系统是许多程序代码,负责加载和操作各种类型的资产。

实际上,资产系统是游戏引擎的重要组成部分,可以成为游戏开发人员的忠实助手或将他们的生活变成地狱。我认为,合乎逻辑的决定是将这个“地狱”集中在一个地方,并谨慎地保护其他团队开发人员免受其害。我们将告诉您我们在本系列文章中所做的事情-走吧!

有关该主题的计划文章:

  • 需求说明和架构概述
  • 资产生命周期
  • 详细的AssetManager类概述
  • 集成到ECS中
  • GlobalAssetCache

要求和原因


资产加载系统的要求源于艰辛和艰辛。砧座是希望自己做一些封闭的事情,这样它就可以在不编写外部代码的情况下工作。好吧,或者几乎不需要编写外部代码。锤子变成了现实。这就是我们最终得到的结果:

  1. 自动内存管理,这意味着无需调用资产的释放功能。即,一旦破坏了使用该资产的所有外部对象,该资产便被破坏。这里的动机很简单-编写更少的代码。更少的代码意味着更少的错误。
  2. , ( AssetManager’a). , . — . , «» .
    , , (). — , . , , . , , . , , .
  3. . : . , .
  4. (shared) . , . , . «» , .
  5. 优先考虑资产装载只有3个优先级:高,中,低。在相同的优先级内,按请求的顺序加载资产。想象一个情况:玩家单击“战斗”,然后开始加载关卡。与此相关的是,准备加载屏幕的精灵的任务将落入下载队列中。但是由于某些关卡资产在Sprite之前进入队列,因此播放器在黑屏上看了相当长时间。

此外,我们为自己制定了一条简单的规则:“必须在AssetManager线程上完成的所有工作都必须在AssetManager线程上完成。” 例如,根据高度图准备法线的风景和纹理的分区,链接GPU程序等。

一些实施细节


在开始了解资产加载系统的工作方式之前,我们需要熟悉Blitz.Engine引擎中广泛使用的两个类:

  • Type:有关某种类型的运行时信息。此类型与TypeC#语言中的类型相似,不同之处在于它不提供对类型的字段和方法的访问。包含:类型名称,许多符号,例如is_floating, is_pointer, is_const,等等。该方法Type::instance<T>在一个应用程序启动时返回一个常量const Type*,使您可以检查表单if (type == Type::instance<T>())
  • Any:允许您打包任何可移动或可复制类型的值。打包哪种类型的知识Any存储为const Type*Any知道如何根据哈希的内容计算哈希,还知道如何比较内容是否相等。在此过程中,它允许您将当前类型转换为另一种类型。这是对标准库或boost库中任何类的重新思考。

所有资产清单加载系统基于以下三个类别:AssetManager, AssetBase, IAssetSerializer但是,在进行这些类的描述之前,必须说外部代码使用Asset<T>这样声明的别名

Asset = std::shared_ptr<T>

其中T是AssetBase或特定类型的资产。随处使用shared_ptr,我们可以满足要求1(自动内存管理)的要求。

AssetManager-这是没有继承人的有限类。此类定义资产的生命周期,并发出有关资产状态变化的消息。它还AssetManager在资产和资产绑定到磁盘上的文件之间存储依赖关系树,侦听FileWatcher并实现资产重新加载。最重要的是,它AssetManager启动了一个单独的线程,实现了用于准备资产的任务队列,并封装了与其他应用程序线程的所有同步(可以从任何应用程序线程(包括下载流)执行资产请求)。

同时AssetManager使用抽象资产AssetBase,将创建和加载特定类型资产的任务委派给继承人IAssetSerializer在后续文章中,我将详细介绍这种情况。

作为要求4(资产共享)的一部分,最热门的问题之一是“用什么作为资产标识符?” 最简单,看似显而易见的解决方案是使用要下载文件的路径。但是,此决定施加了许多严重限制:

  1. 要创建资产,后者必须以磁盘上的文件形式表示,这消除了基于其他资产创建运行时资产的能力。
  2. . , GPUProgram (defines). , .
  3. , .
  4. .

我们从一开始就没有将第3和第4款作为论据,因为甚至没有人认为这可能会派上用场。但是,这些功能随后极大地促进了编辑器的开发。

因此,我们决定使用资产密钥作为标识符,其级别AssetManager由type表示AnyAny继承人会解释IAssetSerializer。自己AssetManager只知道密钥类型和继承人之间的关系IAssetSerializer。请求资产的代码通常知道其所需的资产类型,并使用特定类型的键进行操作。一切都是这样的:


class Texture: public AssetBase
{
public:
    struct PathKey
    {
        FilePath path;
        size_t hash() const;
        bool operator==(const PathKey& other);
    };

    struct MemoryKey
    {
        u32 width = 1;
        u32 height = 1;
        u32 level_count = 1;
        TextureFormat format = RBGA8;
        TextureType type = TEX_2D;
        Vector<Vector<u8*>> data; // Face<MipLevels<Image>>

        size_t hash() const;
        bool operator==(const MemoryKey& other);
    };
};

class TextureSerializer: public IAssetSerializer
{
};

class AssetManager final
{
public:
    template<typename T>
    Asset<T> get_asset(const Any& key, ...);
    Asset<AssetBase> get_asset(const Any& key, ...);
};

int main()
{
   ...
   Texture::PathKey key("/path_to_asset");
   Asset<Texture> asset = asset_manager->get_asset<Texture>(key);
   ...

   Texture::MemoryKey mem_key;
   mem_key.width = 128;
   mem_key.format = 128;
   mem_key.level_count = 1;
   mem_key.format = A8;
   mem_key.type = TEX_2D;
   Vector<u8*>& mip_chain = mem_key.data.emplace_back();
   mip_chain.push_back(generage_sdf_font());
   
   Asset<Texture> sdf_font_texture = asset_manager->get_asset<Texture>(mem_key);
};

hash内部 的方法和比较运算符PathKey是实现相应的类操作所必需的Any,但我们将不对其进行详细介绍。

因此,上面的代码中发生了什么:在调用时,get_asset(key)键将被复制到该类型的临时对象中,该对象Any随后将被传递给method get_asset。接下来,AssetManager从参数中获取键的类型。在我们的情况下,它将是:

Type::instance<MyAsset::PathKey>

通过这种类型,他将找到序列化器对象,并将所有后续操作(创建和加载)委托给序列化器。

AssetBase-这是引擎中所有资产类型的基类。此类存储资产密钥,资产的当前状态(已加载,已排队等)以及资产加载失败时的错误文本。实际上,内部结构要复杂一些,但是我们将与资产生命周期一起考虑。

IAssetSerializer顾名思义,它是准备资产的实体的基类。实际上,此类的继承人不仅在加载资产:

  • 特定类型资产对象的分配和解除分配。
  • 加载特定类型的资产。
  • 根据构建资产的文件路径列表进行编译。文件更改时,资产重新加载机制需要此列表。出现了一个问题:为什么要列出路径而不是一个路径?诸如纹理之类的简单资源实际上可以在单个文件的基础上构建。但是,如果我们查看着色器,则不仅会看到着色器文本发生更改,而且通过include指令更改了连接到着色器的文件时,也会重新启动。
  • 将资产保存到磁盘。在编辑资产和为游戏准备资产时,都会积极使用它。
  • 报告它支持的密钥类型。

在本文的框架中,我想强调的最后一个问题是:为什么在一个序列化器/资产上可能需要几种类型的密钥?让我们依次解决。

一个串行器-几种类型的密钥


让我们以资产GPUProgram(即着色器)为例为了在我们的引擎中加载着色器,需要以下信息:

  1. 着色器文件的路径。
  2. 预处理程序定义列表。
  3. 着色器的组装和编译阶段(顶点,片段,计算)。
  4. 入口点的名称。

一起收集这些信息,我们获得了着色器键,该键在游戏中使用。但是,在开发游戏或引擎时,通常需要在屏幕上显示某些调试信息,有时需要使用特定的着色器。在这种情况下,直接在代码中编写着色器的文本会很方便。为此,我们可以获取第二种键,它代替文件的路径,而预处理程序定义的列表将包含着色器的文本。

考虑另一个示例:纹理。创建纹理的最简单方法是从磁盘加载纹理。为此,我们需要文件(PathKey的路径。但是我们还可以通过算法生成纹理的内容,并从字节数组(MemoryKey创建纹理。第三种类型的键可以是用于创建RenderTarget纹理(RTKey的键

根据密钥的类型,可以使用各种字形栅格化引擎:stb(StbFontKey),FreeType(FTFontKet)或自签名的距离场字体生成器(SDFFontKey)。

关键帧动画可以加载(PathKey)或由代码(MemoryKey生成

一种资产-几种类型的密钥


想象一下,我们拥有ParticleEffect描述粒子生成规则资产。此外,我们为该资产提供了一个方便的编辑器。同时,关卡编辑器和粒子编辑器是一个多窗口应用程序。这很方便,因为您可以在编辑效果本身的同时打开一个关卡,在其中放置粒子源,并在关卡环境中查看效果。如果我们只有一种类型的键,那么在效果编辑世界和关卡世界中使用的效果对象是相同的。在效果编辑器中所做的所有更改都将立即在关卡中可见。乍一看,这似乎是一个不错的主意,但让我们看一下以下情况:

  1. , , , . , .
  2. - , . , .

另外,有一种情况,我们可能会使用两种不同类型的密钥从磁盘上的一个文件中创建两种不同类型的资产。使用“游戏”类型的键,我们创建了针对游戏中的快速工作而优化的数据结构。使用“编辑”类型的键,我们创建了一个便于编辑的数据结构。大约以这种方式,我们的编辑器实现BlendTree了骨骼动画的编辑根据一种类型的键,资产系统使用内部的诚实树和一堆有关更改拓扑的信号来构建我们的资产,这在编辑时非常方便,但在游戏中却很慢。根据不同类型的密钥,序列化程序将创建另一种资产:该资产没有更改树的方法,并且树本身变成了一个节点数组,其中到该节点的链接是该数组中的索引。

结语


总结一下,我想将您的注意力集中在影响引擎进一步发展的解决方案上:

  1. 使用自定义结构作为资产键,而不是文件路径。
  2. 仅在异步模式下加载资产。
  3. 用于管理资产共享(一种资产-几种类型的密钥)的灵活方案。
  4. 能够使用不同的数据源接收相同类型的资产(在一个串行器中支持几种类型的密钥)。

在下一个系列中,您将了解这些决定如何精确地影响内部代码和外部代码的实施。

作者:混音

All Articles