游戏引擎学习第151天

仓库:https://gitee.com/mrxiao_com/2d_game_3

回顾与今天的计划

今天的任务是继续推进游戏开发的进程,主要目标是完成资产文件合并的工作。之前,我们在资产系统上取得了一些不错的进展,所以我们打算利用今天的时间将其做得更完善,以便下周能够顺利进入新的开发阶段。

我们已经为接下来的任务做了一些准备工作,接下来就是解决目前存在的问题,并为下周的工作打下良好的基础。这就像篮球比赛中的进攻配合一样------我们现在是准备好了,下一步就是通过这些努力让整个项目进入一个新的高度。

不过,有一个小插曲需要先处理,就是清理一下之前为了帮助理解指针传递和按值传递的代码。之前有人提出过关于指针传递的疑问,为了帮助理解,我们编写了相关的示例代码。但现在这个代码已经不再需要,因此我们首先删除这些不再使用的部分。

今天的目标主要是:

  1. 清理不再需要的示例代码。
  2. 继续完善资产文件的合并流程,为下周的开发工作做好准备。

回顾之前的代码

昨天的工作主要集中在开发一个可以打开多个资产文件并将它们的数据合并在一起的系统。目的是模拟最终游戏启动时加载和处理资产文件的过程。这一步是为了帮助设计一个平台层的API,即最终游戏发布时我们希望在所有平台上支持的文件API,而这个API需要能够加载和处理资产文件。

在这个过程中,首先设计了一个名为"平台文件句柄"的系统。这个文件句柄的作用是保持对游戏中资产文件的引用,使得在游戏运行过程中,可以持续操作这些文件。在启动时,我们会检查目录中有哪些资产文件,然后逐个打开这些文件,并保持对它们的文件句柄,从而可以在后续操作中读取和处理这些文件的内容。

此外,平台文件句柄还需要具备错误检测功能。当文件发生错误时,文件句柄会记录该错误,这样在后续的操作中,开发者只需做一次错误检查就能知道是否出现了问题。如果检测到错误,程序可以优雅地关闭相关文件,否则继续进行正常操作。

为了进一步完善这个系统,计划在错误发生时为游戏用户提供反馈,例如弹出对话框来通知用户发生了错误。考虑到如果资产文件未能加载,我们可能无法显示通知内容(因为可能没有可用的字体),因此可能需要在程序中嵌入一个调试字体,仅用于这种紧急情况。这种方案在实际操作中需要进一步细化和测试。

在确保文件句柄功能完善后,我们会分配内存来存储所有要加载的资产的元数据。这里加载的只是资产的元数据,而不是整个资产文件本身,因为资产文件通常非常大,而且会通过流式加载来优化内存使用。元数据存储可以帮助我们快速查找资产并决定何时加载相应的内容,尤其是当游戏中有大量资产时。

接下来,我们还计划编写代码来实际加载这些资产。具体方法是遍历所有文件,检查每个文件是否包含特定类型的资产。如果发现符合条件的资产,就会从文件中读取相应的资产数据,并将其存储在预分配的内存区域中。每读取完一部分数据,就会更新相应资产类型的计数器。这一过程是为了确保所有需要的资产都被加载到内存中,虽然这种方法可能效率不高,但目前来看它的效率足够满足需求。

总体来说,昨天的工作主要集中在资产文件的加载和处理机制上,确保能够有效地读取并合并多个文件的数据,并为后续的工作做出合理的规划。

加载资产数组

首先,我们需要做的第一件事是计算每种类型的资产数量。计算方法是使用上一次传递时记录的最后一个资产索引,并减去当前数组中指定的第一个资产索引,这样就可以得到实际的资产数量,也就是我们需要加载的资产数量。

接下来,我们需要将这些资产加载到正确的位置,确保它们按预期进行排列和使用。这部分我们稍后会处理,但在计算出需要加载的资产数量之后,我们要做的就是按照该数量进行推进,也就是跳过这些已经计算并加载的资产,继续处理下一个资产类型。

因此,整体流程就是:首先计算当前类型的资产数量,然后确保将这些资产加载到正确的位置,最后通过增加该数量的步长继续处理后续资产类型。这是当前我们所需要完成的主要任务。

标签索引重定基机制概述

首先,我们需要处理的一个重要问题是重新基准化(rebase)标签索引,这一点非常关键,所以我们先解决这个问题,然后再讨论其他内容。

我们现在拥有一组标签数组(Tags arrays),也就是一组资产标签(asset tags),这些标签是存储在文件格式中的。在文件格式中,我们使用 HHA 标签进行标识,而 HHA 标签中包含两个重要的字段:ID 和 value。这两个字段用于唯一标识和描述每个资产。

cpp 复制代码
struct hha_tag {
    uint32 ID;
    real32 Value;
};

然而,资产引用标签的方式是通过索引进行的。也就是说,HHA 资产中会记录两个索引:第一个标签的索引(FirstAssetIndex)以及超出最后一个标签的索引 (OnePastLastAssetIndex)。这些索引是绝对索引,意味着它们直接对应标签表中的位置。例如,如果某个资产的标签索引是 5 到 6,那么它表示该资产关联了标签表中的第 5 和第 6 个标签。

在只有一个文件的情况下,这种映射关系是完全正确的。但如果我们有多个文件需要合并(concatenate),那么问题就出现了。

假设我们有两个文件:

  • 第一个文件包含 100 个标签。
  • 第二个文件包含 50 个标签。

在合并时,标签数组会直接拼接在一起,即第一个文件的 100 个标签在前,第二个文件的 50 个标签紧随其后。此时:

  • 第一个文件的标签索引是完全正确的,因为它们从 0 开始,直接对应它们的标签位置。
  • 但第二个文件中的标签索引就会完全错误,因为它的标签索引仍然是从 0 开始计算的,而实际合并后的标签位置已经发生了变化。

举个例子:

  • 假设第二个文件中某个资产引用标签索引为 0 和 1,但在合并后的数组中,这两个标签的实际位置已经变成了 100 和 101。
  • 因此,我们需要将第二个文件中的所有标签索引都加上前一个文件的标签总数,这样才能确保它们引用的是正确的标签位置。

我们需要做的事情就是:

  1. 遍历第二个文件中的所有资产。
  2. 将它们的 第一个标签索引oOnePastLastAssetIndex 标签索引 加上前一个文件的标签总数,使它们的索引基准与合并后的数组对齐。
  3. 如果有第三个、第四个文件,继续重复上述操作,使所有文件的标签索引都基于完整合并后的标签表进行重新基准化(rebase)。

这个过程本身是非常简单的,我们只需要按照前面文件的标签数量进行偏移修正即可。所以我们接下来要做的,就是优先解决这个索引重基准化的问题,确保所有资产在合并后引用的标签是正确的。

设置文件的 TagBase

在这里我们需要处理的主要任务是加载多个文件中的标签数组(tag arrays),并将它们合并成一个连续的大标签数组,同时确保每个文件中的标签索引保持正确。

我们目前的文件格式中,每个文件都包含一个标签数组(tag array),而每个标签数组中包含若干个标签(tag)。每个标签本质上是一个 HHA 标签,里面包含 ID 和 value,用于描述资产的属性。然而,多个文件的标签数组在合并时会带来一些索引错位的问题,因此我们需要通过设置**标签基准(TagBase)**来解决这个问题。

1. 设置文件的标签基准(tagbase)

我们首先要做的,是确保在加载每个文件时,记录该文件的标签基准

  • 第一个文件的标签基准起始位置为 0,因为还没有加载任何标签。
  • 第二个文件的标签基准起始位置应该是第一个文件中标签总数的下一位 ,即one-past-last标签的位置。
  • 第三个文件的标签基准起始位置则是前两个文件标签总数的下一位,以此类推。

通过设置这个标签基准(TagBase),我们确保了每个文件在加载标签时,能够将它们放置到正确的位置,并且不会覆盖已经加载的标签数据。

2. 循环加载所有文件的标签数组

我们需要遍历所有文件,将它们的标签数组连续加载到一块大的标签数组中。具体过程如下:

  • 遍历文件,确保文件没有错误的情况下,开始加载标签数组。
  • 计算标签数组的大小 ,即:
    KaTeX parse error: Expected 'EOF', got '_' at position 42: ...text{sizeof(hha_̲tag)} \times \t...
    这里的 sizeof(hha_tag) 是标签结构的大小,而 TagCount 是当前文件的标签数量。
  • 将标签数据加载到当前文件的标签基准偏移量 位置:
    • 第一个文件从索引 0 开始加载。
    • 第二个文件从第一个文件的标签总数索引处开始加载。
    • 第三个文件从前两个文件标签总数索引处开始加载。
  • 每加载完一个文件的标签数组后,更新标签基准,使其指向下一个文件标签数组的起始位置。

3. 保证标签数组连续排列

通过上述操作,我们确保了所有文件的标签数组都被加载到一个连续的内存区域中。具体示例如下:

文件序号 标签数量 标签基准 (TagBase) 标签在内存中的位置
文件0 100 0 [0 - 99]
文件1 50 100 [100 - 149]
文件2 30 150 [150 - 179]

这种做法确保了我们最终得到的标签数组是连续且无重叠的,并且每个文件中的标签都按照正确的索引排列在大数组中。

4. 加载时的核心操作

在读取数据时,我们会使用平台提供的 PlatformReadDataFromFile 接口进行数据加载。加载操作如下:

  • 将标签数组数据读取到内存中,读取的起始位置为当前文件的标签基准(TagBase)。
  • 将标签数组的大小设置为当前文件标签数量 * HHA 标签大小
  • 每加载完一个文件的数据后,更新标签基准,使其指向下一块空闲内存区域。

5. 为什么标签数据本身不需要修改

值得注意的是,标签数据本身并不需要修改。因为标签本身(ID 和 value)没有引用其他数据,所以无需进行任何调整。我们所需要做的仅仅是保证标签索引在合并后的大数组中保持正确

例如:

  • 文件 0 的第一个标签,索引为 0,合并后索引仍然是 0。
  • 文件 1 的第一个标签,原索引为 0,但合并后索引应该变成 100(假设文件 0 有 100 个标签)。
  • 文件 2 的第一个标签,原索引为 0,但合并后索引应该变成 150(假设前两个文件共 150 个标签)。

这个过程中,我们不修改标签内容本身,只需要通过标签基准偏移量确保它们加载到正确的位置即可。

重定基资产数组

在这里,我们需要做的是对资产(assets)进行重定位(rebase),以确保它们的标签索引正确地指向合并后的标签数组中的相应位置。

1. 资产重定位的目标

由于每个文件在合并标签数组时,标签的起始索引会发生偏移,因此每个文件中的资产引用的标签索引也需要调整。具体来说,每个资产的标签索引需要加上该文件的标签基准(tag base),以确保它指向合并后的标签数组中的正确位置。

2. 重定位的具体步骤

我们需要按照以下步骤来完成资产的重定位:

  • 循环遍历当前文件中的资产
    • 资产的遍历起始索引是当前已加载的资产数量 AssetCount
    • 结束索引是 AssetCount + 当前文件的资产数量,即确保覆盖当前文件的所有资产。
  • 获取当前资产的指针
    • 资产指针的计算方式是 assets数组 + 当前资产索引,这可以快速找到内存中该资产的位置。
  • 调整资产的标签索引
    • 每个资产都有一个指向其标签的索引字段。
    • 该索引需要加上该文件的标签基准(TagBase),以将其从原本的局部索引 转换为全局索引

3. 示例

假设我们加载了两个文件,文件 0 中有 100 个标签,文件 1 中有 50 个标签。

  • 在标签数组中,文件 1 的标签基准(TagBase)应为 100。
  • 假设文件 1 的某个资产原本指向标签索引 5,则该资产在合并后的标签数组中应指向索引 100 + 5 = 105

以下是资产重定位的过程示意:

文件序号 资产原始索引 标签原始索引 标签基准 (TagBase) 标签修正后索引
文件0 0 0 0 0
文件0 1 10 0 10
文件1 0 0 100 100
文件1 1 5 100 105

读取 hha_asset 数据

现在我们需要做的最后一件事就是确保我们实际将数据读取进来,并进行相应的计算,使这些资产数据能够正确地填充到内存中的合适位置。

1. 读取数据的总体思路

我们的主要目标是:

  • 从文件中读取资产数据
  • 确保数据被放置到内存中的正确位置,即当前资产数组的末尾。
  • 确保读取的数据大小正确,即需要读取的资产数量乘以每个资产的大小。
  • 确保从文件中正确的偏移位置开始读取,即跳过文件头,然后找到指定的资产类型数组,再跳到该类型数组中的正确位置。
  • 在读取过程中进行断言,确保不会超出预期的资产数量。

2. 计算数据读取的大小

我们首先需要确定本次要读取的数据大小。这是一个非常简单的计算:

读取大小 = 资产数量 × 单个资产的大小 \text{读取大小} = \text{资产数量} \times \text{单个资产的大小} 读取大小=资产数量×单个资产的大小

  • 资产数量是当前文件中该资产类型的数量AssetCountForType。
  • 单个资产的大小是 hha_asset 的大小。
  • 读取的数据总大小就是这两者的乘积。

3. 确定文件的读取偏移位置

接下来我们需要计算从文件中读取数据的起始位置。这个计算相对复杂一些,因为我们不能直接从文件头读取,而是需要跳过某些数据,直接定位到我们需要的数据位置。

计算步骤

  • 首先跳过文件头 ,直接定位到资产数组偏移地址。这个偏移地址存储在文件头中,因此我们需要通过文件头获取它。
  • 根据资产类型再做偏移 。由于文件中不同资产类型的数据是分块存储的,所以我们需要根据当前资产类型的起始索引 向前跳一定的偏移量。这个偏移量是:
    偏移量 = 资产数组偏移地址 + ( 资产起始索引 × 单个资产大小 ) \text{偏移量} = \text{资产数组偏移地址} + (\text{资产起始索引} \times \text{单个资产大小}) 偏移量=资产数组偏移地址+(资产起始索引×单个资产大小)
  • 这样我们就成功跳到了该资产类型数据在文件中的起始位置。

4. 将数据读取到内存中

计算完偏移位置后,我们需要确保数据正确加载到内存中的合适位置。

我们选择将这些资产数据直接加载到当前资产数组的末尾 ,也就是 AssetCount 的位置。

假设:

  • assets 数组是我们存储所有资产的地方。
  • AssetCount 表示当前已经加载的资产数量。

那么我们需要将文件中的数据读取到:
内存加载位置 = Assets + AssetCount \text{内存加载位置} = \text{Assets} + \text{AssetCount} 内存加载位置=Assets+AssetCount

这样可以保证:

  • 文件中的数据会连续追加到内存数组中。
  • 不会覆盖之前的资产数据。

5. 断言以确保数据正确

在数据加载的过程中,我们需要做几个断言,以确保:

  • 不会超出预期的资产数量。我们在开始加载之前就知道该文件内的资产数量,因此可以直接断言,防止数据溢出。
  • 最终加载的资产数量要匹配 。在文件完全加载之后,asset_count 的总量必须等于我们在文件头中声明的资产总量,否则说明加载过程中存在问题。

具体断言包括:

  • 加载完成后:
    AssetCount = Assets->AssetCount \text{AssetCount} = \text{Assets->AssetCount} AssetCount=Assets->AssetCount

这样我们就可以确保整个资产加载过程没有任何溢出或数据错误的情况发生。


定义平台层的文件函数

现在我们需要开始真正定义这些平台函数 ,也就是之前我们在开发过程中预设的那些函数。之前我们在编写代码时,只是假设 这些平台函数已经存在,并直接调用它们,但实际上它们还没有被真正实现,因此接下来我们需要将它们落地,使其成为现实。

我们面临的主要问题是:

  • 我们需要为平台库创建实际的 API 函数,使其真正支持我们之前定义的那些平台操作。
  • 我们需要考虑如何确保这些平台 API 兼容 C 语言,以便其他开发者在不使用 C++ 的情况下,也可以顺利链接我们的平台层或进行其他操作。
  • 我们要考虑是否采用分步实现,比如先通过原始的文件 IO 例程加载数据,再逐步过渡到新的平台 API,但目前看来这样做的工作量可能更大,所以决定直接一步到位。

1. 确定平台函数的目标和路径

我们首先明确一点:

  • 这些平台 API 主要提供文件操作、内存管理等功能。
  • 我们希望尽量使这些 API 保持简单易用,同时兼容 C 语言。
  • 为了保证可读性和一致性,我们在命名时使用 platform_ 作为前缀,这样就能确保平台特定的 API 有明确的标识。

定义 platform_file_handle 和 platform_file_group 结构体

我们现在需要定义平台文件句柄 (platform_file_handle)平台文件组 (platform_file_group) ,以支持我们在平台层中操作文件。我们之前已经定义了 platform_file_handle,但现在我们需要进一步调整它,并创建 platform_file_group,以便我们能够通过文件组来访问一系列的文件。

我们面临的几个主要问题是:

  1. platform_file_handle 的数据结构设计 :我们希望平台句柄是不透明的 (opaque),即我们不希望调用方知道文件句柄内部的具体内容,这样可以确保不同平台的文件句柄结构可以自由变化。
  2. platform_file_group 的数据结构设计:平台文件组将表示某个目录下的文件集合,它允许我们通过索引访问文件并打开它们,但我们不想暴露文件组的内部结构。
  3. 保持平台 API 的一致性:我们需要确保所有文件操作都通过平台 API 完成,而不会直接操作 HHA 文件。

一、调整 platform_file_handle 的定义

我们最初的 platform_file_handle 是这样定义的:

c 复制代码
struct platform_file_handle
{
    void* handle;
};

但是现在我们希望它完全不透明 ,即调用方只能通过 API 操作文件,而无法直接访问 handle,因此我们将它改成:

c 复制代码
typedef struct platform_file_handle platform_file_handle;

然后我们不再公开 platform_file_handle 的结构体内容,只提供一个指针:

c 复制代码
platform_file_handle* platform_open_file(const char* file_name);
size_t platform_read_file(platform_file_handle* file, void* buffer, size_t size);
void platform_close_file(platform_file_handle* file);

这样:

  • platform_file_handle 是一个完全不透明的类型,调用方无法访问其内部结构。
  • platform_open_file 会返回一个文件句柄指针,但该指针指向的结构体内容对调用方是隐藏的。

二、定义 platform_file_group

我们需要创建一个新的 platform_file_group,它表示一组文件,比如某个目录下的所有文件集合。我们期望:

  • 通过 platform_file_group 获取文件数量。
  • 通过 platform_file_group 打开指定索引的文件。
  • 不公开 platform_file_group 的内部结构,只允许通过 API 操作它。

我们定义如下:

c 复制代码
typedef struct platform_file_group platform_file_group;

三、实现 platform_file_group

我们在实现 platform_file_group 时,遇到的最大问题是文件组的数据结构

1. 文件组的核心结构

在 Windows 平台上,文件组需要通过 FindFirstFileFindNextFile 遍历文件,而在 Linux/macOS 上,则需要使用 opendirreaddir

因此,我们需要定义一个通用的 platform_file_group,但不暴露其内部结构:

c 复制代码
struct platform_file_group
{
    uint32 FileCount;
    void* Data;
};
  • Data 是一个指向平台特定数据的指针。
  • FileCount 记录当前文件组内的文件数量。

与文件相关的特定平台函数

我们现在需要添加一个新的平台函数 platform_get_all_file_of_type,该函数的作用是获取指定类型的所有文件 ,例如获取所有 .hha 文件或者所有 .txt 文件等。这种操作在我们加载资源文件时非常有用,因为我们可以直接通过扩展名过滤出我们需要的文件。

接下来我们要做的是:

  1. 定义 platform_get_all_file_of_type 函数,确保其能够按文件类型获取文件组。
  2. 确保该函数的行为符合我们之前设计的 platform_file_group 机制 ,也就是说,该函数应该返回一个 platform_file_group,而不是直接返回文件句柄。
  3. 确保该函数跨平台工作,即在 Windows 和 Linux/macOS 上都能正常获取文件。

一、定义 platform_get_all_files_of_type

我们先在 game_platform.h 中添加新的函数声明:

c 复制代码
#define PLATFORM_GET_ALL_FILE_OF_TYPE_BEGIN(name) void name(const char *Type);
typedef PLATFORM_GET_ALL_FILE_OF_TYPE_BEGIN(platform_get_all_files_of_type_begin);

定义 PlatformNoFileErrors 宏

我们现在的目标是进一步完善文件操作 API,并对文件错误检测、文件读取等功能进行优化和抽象,使其更加高效且兼容 C 语言环境。为了实现这一目标,我们需要完成以下几项核心工作:


一、优化文件错误检测函数

目前我们有一个 PlatformNoFileErrors 函数用于检测文件是否发生错误,但是该函数每次调用都需要通过平台层 API (如 Windows 的 GetLastError) 或文件系统接口进行查询,这样会带来性能开销

我们决定将文件错误状态内联化 ,即直接通过文件句柄判断是否有错误,而不必调用平台 API,从而减少函数调用的开销。


设计新的文件错误检测机制

我们考虑将文件句柄 (platform_file_handle) 内部增加一个错误标志位 (HasErrors) ,这样我们在任何地方只需要检查该标志即可知道文件是否出错,无需频繁查询平台 API。

同时,为了保持 C 语言兼容性 ,我们不能使用 C++ 内联函数,因此决定采用宏定义来实现该功能。


定义错误检测宏

我们在 game_platform.h 中新增一个宏:

c 复制代码
#define PlatformNoFileErrors(Handle) (!(Handle)->HasError)

该宏的功能:

  • 检查 platform_file_handle 结构体中的 HasError 标志,如果为 false 表示没有错误;为 true 表示有错误。
  • 通过宏的方式内联化该函数,避免函数调用带来的开销。

修改 platform_file_handle

我们需要确保 platform_file_handle 结构体中增加一个 HasError 字段:

c 复制代码
typedef struct platform_file_handle {
    //
    bool32 HasErrors; // 是否存在错误
} platform_file_handle;

这样我们在任何地方都可以直接通过 PlatformNoFileErrors 宏快速判断文件状态。


二、定义统一的文件读取 API

我们还需要定义一个通用的文件读取函数 platform_read_data_from_file,该函数的作用是:

  • 从文件中指定偏移量 (offset) 读取指定大小的数据 (size) 到指定的内存地址 (dest) 中。
  • 避免直接暴露平台层文件 API,如 Windows 的 ReadFile 或 Linux 的 read
  • 确保该函数跨平台兼容且操作简洁。

定义 platform_read_data_from_file

我们在 platform.h 中添加如下函数原型:

c 复制代码
#define PLATFORM_READ_DATA_FROM_FILE(name) \
    void name(platform_file_handle *Source, uint64 Offset, uint64 Size, void *Dest);

该函数接收以下参数:

  • handle:文件句柄。
  • offset:读取的起始偏移量。
  • size:读取的数据大小(以字节为单位)。
  • dest:存储读取数据的目标地址。

、开放文件组的 API

接下来我们还需要提供文件组 (FileGroup) 相关的 API,用于批量打开和读取文件。

我们需要实现以下功能:

  1. 开始迭代文件组
  2. 通过索引打开文件
  3. 关闭文件组

将新的回调函数放入 game_memory

我们现在需要将新定义的文件 API (如 platform_get_all_files_of_type_beginplatform_open_fileplatform_read_data_from_file 等)完全集成 到现有的平台 API中,并替换掉之前用于调试的临时代码,从而使我们的文件系统模块更加规范、统一且高效。


一、调整平台 API 接口结构

由于我们刚刚定义了一套新的文件 API,意味着我们必须更新平台 API 结构体 (platform_api),将这些新的函数接口添加进去,使平台层能够提供完整的文件操作功能。

使 Platform API 全局可用

我们决定将平台 API 结构体(platformAPI)作为全局变量来简化整个平台接口的管理。之前的做法是每次调用平台 API 时,都需要通过传递回调函数来进行处理,这样会显得较为复杂和冗余。通过将平台 API 定义为全局变量,我们可以避免频繁传递回调,从而让代码变得更加简洁和易于维护。

1. 平台 API 结构调整

我们将 platform API 改为一个全局变量,称为 platform,这样便于随时调用该 API 的各种功能。原先在每个函数中调用 API 时传递的回调函数,现在直接通过 platform 进行访问,简化了结构和逻辑。

我们希望将 platform API 改为一个全局对象,并且通过该对象来直接访问各个平台的相关操作函数,如打开文件、读取文件、检查文件错误等。

平台 API 的定义
c 复制代码
typedef struct platform_api {
typedef struct platform_api {
    platform_add_entry *AddEntry;
    platform_complete_all_work *CompleteAllWork;

    platform_get_all_files_of_type_begin *GetAllFilesOfTypeBegin;
    platform_get_all_files_of_type_end *GetAllFilesOfTypeEnd;
    platform_open_file *OpenFile;
    platform_read_data_from_file *ReadDataFromFile;
    platform_file_error *FileError;

    // 指向临时存储的指针,用于存储短期的数据,如帧缓冲、临时计算等。
    debug_platform_read_entire_file *DEBUGPlatformReadEntireFile;
    debug_platform_free_file_memory *DEBUGPlatformFreeFileMemory;
    debug_platform_write_entire_file *DEBUGPlatformWriteEntireFile;
} platform_api;

这里的 platform_api 结构体包含了多种文件操作接口。通过全局变量 platform,我们可以直接访问这些接口。

2. 移除旧的回调机制

我们从代码中移除了之前通过回调函数传递的机制。所有原来需要传递的回调函数(如 platform_get_all_files_of_type)现在都被封装到了 platform_api 结构体中,统一通过 platform 全局变量进行访问。

3. 简化代码结构

我们不再需要通过复杂的回调传递来进行平台操作,而是直接使用全局 platform 变量访问所需的功能,代码结构变得更加清晰和直观。

例如,原本我们需要这样传递回调:

c 复制代码
get_all_files_of_type(".hha");

现在可以直接这样调用:

c 复制代码
platform.get_all_files_of_type(".hha");

启动 Win32 版本新文件函数的实现所需的前提工作

现在,我们已经完成了大部分的设置,但还需要在代码中加入一些函数来完善平台 API 的实现。特别是在 game_platform.h 中,我们需要为每个平台函数(如 platform_get_all_files_of_typeplatform_open_file 等)做相应的版本。由于我们正在支持多个平台(如 Windows 和 32-bit 系统),所以每个函数都需要根据不同的系统做相应的实现。

1. 平台 API 函数的实现

首先,除了我们已经实现的 platform_get_all_files_of_typeplatform_open_file 函数外,我们还需要实现一个额外的 platform_file_error 函数。这个函数的作用是记录文件错误信息。具体来说,它接收一个文件句柄和错误信息,处理完后记录错误。

2. 函数版本管理

每个函数都需要根据不同的系统(比如 Windows 或 32-bit 系统)来实现相应的版本。例如,我们有一个 platform_get_all_files_of_type 函数,它会根据平台的不同而有不同的实现。同样的处理也适用于其他函数,如 platform_open_fileplatform_read_data_from_file 等。

由于这些函数会根据不同的版本提供不同的实现,我们需要为每个平台提供特定的实现版本。例如:

c 复制代码
internal PLATFORM_GET_ALL_FILE_OF_TYPE_BEGIN(Win32GetAllFilesOfTypeBegin) {
    //
    (void)Type;
    platform_file_group Result{};
    return Result;
}

internal PLATFORM_GET_ALL_FILE_OF_TYPE_END(Win32GetAllFilesOfTypeEnd) {
    //
    (void)FileGroup;
}

internal PLATFORM_OPEN_FILE(Win32OpenFile) {  //
    (void)FileIndex;
    (void)FileGroup;
    platform_file_handle *Result{};
    return Result;
}
internal PLATFORM_READ_DATA_FROM_FILE(Win32ReadDataFromFile) {  //
    (void)Source;
    (void)Offset;
    (void)Size;
    (void)Dest;
}
internal PLATFORM_FILE_ERROR(Win32FileError) {
    //
    (void)Handle;
    (void)Message;
    bool Result = {};
    return Result;
}

这些函数会根据具体平台调用不同的实现,确保在正确的环境中执行。

3. 虚拟内存管理和文件加载

我们已经在构建一个动态加载和热加载的系统。为了避免在启动时加载所有的资源,而是按需加载资源,我们使用了内存预留的方法。

当加载位图(bitmap)时,我们希望分配一些空间,以便在加载图像时有足够的内存。具体来说,在 load_bitmap 函数中,当我们加载一个图像时,我们需要先预留出一些内存空间。这样,加载操作可以在需要时进行,而不会在程序启动时就占用大量内存。

c 复制代码
  Work->BitMap->Memory = PushSize(memory_arena * Arena, memory_index Size);

在这里,push_size 是我们用来分配内存的函数,它确保为位图预留出足够的内存空间。

现在线程工作者不再需要处理资产元数据

在这个过程中,原本需要执行的许多操作其实可以简化。特别是在资产加载的工作流中,之前我们需要计算位图的宽度和其他附加数据,原因是当时在加载位图文件时我们并不知道这些数据。为了实现多线程加载,我们将这些额外的数据计算推迟到实际加载位图文件之后进行。

但是,现在我们有了一个资产处理器,所有相关的信息已经被提取并存储到一个小型的元数据结构中,这个结构可以在启动时快速加载。因此,当前不再需要像之前那样推迟计算这些数据,因为元数据已经包含了位图的宽度和其他信息,只有实际的负载数据(位图内容)才需要从资产文件中提取。

这意味着我们不再需要通过多线程的方式去延迟计算这些额外的数据,而可以直接通过加载的元数据来获取这些信息,这大大简化了工作流程。

位图和声音的工作指令现在已简化为加载一块数据,因此它们可以合并为单一的工作指令类型

现在,由于已经有了元数据,我们可以将之前的处理逻辑移除,简化工作流程。我们不再需要在加载过程中计算位图的宽度等数据,因为这些信息已经被包含在启动时加载的元数据结构中。因此,我们可以将音频和位图的加载工作合并处理,因为它们现在没有太大区别,实际上它们的处理流程是相似的。唯一的区别可能是在内存中加载数据的方式,但这也可以简化为加载一小块内存。

为了继续简化流程,可以逐步优化代码,将一些不必要的步骤移除,恢复之前的资产加载方式------一次性加载完整的资产文件。这个过程非常简单,主要就是验证加载是否正常工作,只需要报告并检查数据是否加载正确。

通过这种方式,可以将加载过程简化到只需要加载完整数据而无需进行额外的复杂处理,这样能够提高效率并简化代码的复杂性。

使用新工作指令类型和旧版文件加载例程交换,以最小化调试量

现在我们的目标是对现有的加载位图的功能进行改进,使其更加通用化,同时减少重复代码并优化加载流程。之前的实现中,每种类型的资产(如位图、声音等)的加载工作是分开进行的,并且加载位图的任务有一个特定的load_bitmap_work来处理所有和位图相关的加载和初始化操作。但是现在我们计划将其简化,创建一个通用的load_asset_work,该任务不关心加载的是哪种资产,只负责从文件中读取指定大小的数据到指定的内存位置,并将该任务的所有完成状态回传。

我们的第一个目标是确保在功能切换前,现有的加载功能完全正常,这样我们在重构过程中就不会因为同时引入多种修改而导致难以排查的错误。我们计划分步进行这个修改,避免将新文件加载例程的开发和位图加载的重构混在一起,以减少调试难度。

接下来我们计划创建一个通用的load_asset_work任务,它只包含三个主要参数:

  • 偏移量(offset):表示从文件的哪个位置开始读取数据。
  • 加载大小(size):表示需要读取的数据大小。
  • 目标地址(destination):表示读取的数据应该存放在内存中的哪个地址。

这个任务的职责非常简单,只需要调用平台层的read_data_from_file函数,按照指定的偏移量和大小,将数据读取到指定的内存位置即可。它不会关心当前加载的是位图、声音还是其他资产,也不会做任何资产解析或数据填充的操作。它的核心任务就是加载文件数据,其余的一切由外部调用者决定。

在实现这个任务时,我们还需要提供一个文件句柄(file handle),确保我们在读取数据时没有文件错误。在数据读取完成后,我们也需要更新任务状态,确保任务结束后能够正确通知其他部分加载已完成。

接下来我们需要注意几个问题

  1. 文件句柄错误处理:我们需要检查文件句柄是否有效,如果文件句柄无效,我们不能错误地设置任务完成状态,而是需要有一种处理策略。例如,可以选择填充一块无意义的数据(如全零),或者让该任务保持未完成状态,等待后续加载。
  2. 任务状态更新:在任务完成后,需要明确设置任务状态,标志其已完成并可供后续使用。
  3. 保持接口通用性 :在load_asset_work任务中,不涉及任何与位图、声音等资产相关的操作,仅执行数据加载任务。

接下来我们将修改原有的load_bitmap_work任务,将其重构为使用新的load_asset_work任务。在重构过程中,我们需要将原本在load_bitmap_work中的所有初始化操作前置,在创建任务时直接处理。这样,load_asset_work任务将完全不需要接触资产相关信息,确保其通用性。

我们还需要处理一些原本特定于位图的状态更新和数据分配操作。这部分工作将移至任务创建前进行,而不再依赖任务内部处理。例如,原本在任务内部处理的位图尺寸计算内存分配等工作,现在都提前在创建任务时完成,这样任务内部只需关注数据的加载即可。

最后,我们将原本的load_bitmap_work任务完全替换为load_asset_work,并确保编译和运行正常。我们还需要做一些测试,确保新任务加载位图时不会出现任何问题,并验证在文件读取失败的情况下,是否会如预期填充默认数据或保持未加载状态。

通过这样的重构:

  • 代码复用性更高:不再需要为每种资产写一个特定的加载任务。
  • 错误隔离性更好:将数据加载和数据解析分离,减少了加载任务中的复杂逻辑。
  • 调试更方便:由于修改被拆分为两个阶段(先重构加载任务,后更换文件读取例程),调试过程将变得更容易。
  • 未来扩展性更强 :如果未来需要加载更多类型的资产(如模型、纹理等),只需调用load_asset_work即可,无需增加新的任务类型。

下一步就是进行加载位图的全面重构,并确保它完全依赖新的load_asset_work进行数据加载。

为位图预留空间

现在我们需要做的就是为即将加载的位图预留内存空间,以便在文件数据加载完成后能够直接将位图数据存储到内存中。因此,我们只需要在内存区域中进行内存分配,提前为位图的原始数据保留足够的空间。

在分配内存时,我们选择使用push操作,将指定大小的内存块从内存区域(arena)中分配出来。这里的内存区域通常是为加载资产(assets)预留的,所以我们直接在该区域中进行分配操作。分配的大小直接取决于位图的尺寸,即宽度、高度和每行像素占用的字节数。我们可以通过计算pitch * height的方式来得到需要分配的内存大小。

这里存在一个潜在的"先有鸡还是先有蛋"的问题。即:我们在分配内存之前,需要知道位图的大小,但在加载数据之前我们还没有这些信息。因此,我们需要先通过位图信息结构(info)来获取pitchheight,从而计算出内存大小。

具体计算方法如下:

  • pitch 表示位图数据中每一行像素占用的内存大小(以字节为单位)。
  • height 表示位图的总行数。
  • 二者相乘即可得到总的内存需求大小,即pitch * height

我们将计算结果赋值给一个名为memory_size的变量,该变量代表为该位图所需要分配的内存大小。随后我们在内存区域中调用push操作,预留这块内存空间,确保稍后文件数据加载完成后,能够直接将数据填充到这里。

在分配完内存后,我们接下来就可以填充其他一些基本信息,例如:

  • 将内存地址设置为位图的存储地址。
  • 将任务状态标记为"等待加载完成"。
  • 设置文件读取的偏移量和数据大小等。

目前我们还没有真正触碰到文件加载部分,所有的工作都是在为加载任务做前期准备。我们在这一阶段确保内存分配是正确的,并且任务结构中的内存地址已经准备就绪。这样等到文件加载完成时,数据可以无缝地填充到该内存中。

我们在这里还有一个考虑点,即如果文件读取失败怎么办?目前有两种策略:

  1. 保守策略:如果文件加载失败,则不改变任务状态,避免将无效数据暴露给其他模块。
  2. 宽容策略:如果文件加载失败,我们仍然填充这块内存,但用特定的标识数据(如全零或全粉色)表示该位图加载失败,确保游戏不会崩溃。

当前我们暂时保持灵活,先专注于确保内存分配的正确性,稍后再决定如何处理加载失败的情况。接下来我们就可以继续配置任务参数,让文件加载功能将数据填充到该内存中。

填充工作指令结构体

现在我们需要做的是填充工作任务(work order)的所有字段,并将该任务放入任务队列中,以便稍后执行加载操作。这些任务将被放置在**任务内存区域(task arena)**中,因为这是临时内存,因此在加载完成之后就不需要保留这些数据了。我们创建一个新的load_asset_work,并开始填充其所有必要的字段。

首先,我们要填充工作任务结构 中的Work->Slot,即对应的资产槽位(Assets->Slots)。该槽位将指向我们正在加载的位图,我们直接将这个槽位的指针赋值给Work->Slot字段。这么做的好处是,当加载完成后,数据会直接填充到该槽位中,而不需要额外的映射过程。

接下来,我们需要填充文件句柄(file handle) ,以便读取文件数据。我们之前已经有了一个平台文件句柄platform_file_handle,所以直接将其赋值即可。

然后,我们需要填写文件偏移量(offset) 。文件偏移量决定了从文件的哪个位置开始读取数据。在前面的流程中,我们已经通过HHAAsset->DataOffset获取了该信息,所以直接将其赋值给任务即可。

**数据大小(size)**也需要填充。这部分数据大小就是我们之前计算出来的MemorySize,即位图数据所占用的总字节数,我们直接将其填入任务的size字段中。

**内存地址(destination)**是数据最终需要存放的位置,我们之前已经通过push操作在内存中预留了一块空间,用来存储位图数据。因此,这里我们将那块内存地址赋值给任务的destination字段,这样当文件加载完成时,数据会直接填充到这块内存中。

cpp 复制代码
Bitmap->Memory = Assets->HHAContents + HHAAsset->DataOffset;

至此,我们的工作任务已经基本设置完成,包含了:

  • work slot:对应的资产槽位。
  • file handle:平台文件句柄,用于读取数据。
  • offset:文件中数据的起始偏移量。
  • size:要读取的数据大小。
  • Destination:数据的目标内存地址。

我们接着发现一个问题:在某些情况下,work slot的指针并没有提前设置,而是留在了加载过程的线程中设置的,这样就导致线程中不得不执行一些非必要的任务 。但实际上,这种设置完全可以提前在主线程中完成,避免在多线程中做一些不必要的工作,减少线程竞争的风险。因此,我们决定直接在主线程中预先设置work slot的指针,并避免在子线程中修改它,从而降低线程安全性问题。

我们同时还优化了返回位图的逻辑 ,通过判断slot state是否处于asset loaded状态来决定是否返回有效数据。我们直接将slot stateasset loaded进行比较,如果状态满足,就直接返回位图,否则返回空指针(null),这样就避免了子线程中频繁检查或更新指针的需求。

接下来我们注意到,在填充任务时,存在一些多余的工作,比如在工作任务中填充work bitmap的过程。实际上,这个过程也可以提前在主线程中完成,而不必放到子线程中去执行。我们认为这样做更加清晰,避免了在多线程中操作内存地址,从而减少潜在的竞争风险。

在调整这些内容之后,我们还修复了一些小问题:

  1. 移除了多余的状态检查 :原本在子线程中会检查work bitmap是否为空,现在直接在主线程中将其设置好,避免了重复检查。
  2. 统一了指针设置的时机 :我们在主线程中就直接设置好work slot的指针,确保子线程无需再次操作,减少线程竞争。
  3. 确保任务结构简洁:我们剔除了不必要的字段,仅保留了加载数据的必需信息。

接下来我们需要做的就是确保文件加载完成后,数据能顺利填充到内存中。因此,我们的下一步就是在文件加载完成的回调中,确认数据已成功加载,并将位图标记为已加载状态(asset loaded)。这样,当其他模块请求位图时,就可以直接从内存中获取而无需重复加载。

我们稍后还需要处理加载失败的情况。当前的策略是:

  • 如果文件加载失败,我们可以选择用特定颜色(如粉色或全零)填充内存。
  • 或者,我们可以保留该任务,但不标记为asset loaded,让其保持未加载状态,直到下次加载请求再次触发。

目前我们倾向于保留任务失败状态,避免使用错误数据,但最终方案可以根据实际情况调整。

现在我们的任务结构已经完成,下一步就是调整文件读取流程,将文件数据填充到内存中,并确保数据在加载完成后状态正确。

Win32 文件例程仍未完成,但我们的时间到了,所以让我们保持代码在运行状态

我们现在需要做的就是暂时禁用任务的实际操作 ,即让任务在执行时不做任何实际的数据加载 ,只保留完成任务的操作 (complete)。这样我们可以快速验证整个流程是否正常工作,而不需要等待文件加载的实际完成。我们的目标是通过跳过文件加载,观察内存指针是否正确设置,从而验证加载任务流程是否正确。

首先,我们进入任务处理的核心部分,将任务中的所有文件加载逻辑去除 ,保留任务完成 (complete) 的部分。在这里,我们不会真正执行platform.read_data_from_file,而是直接将内存指针指向正确的数据地址,以观察流程是否通畅。换句话说,我们暂时欺骗任务系统,让其看起来像是加载成功,但实际上并没有真正加载任何数据。

我们还遇到一个问题:如果文件句柄 (file handle) 不可用,那么任务无法继续推进。因此,我们需要在这里添加一个条件分支 ,如果句柄无效 (file handle == null),那么直接跳过任务执行,但仍然标记任务已完成 (complete)。这确保了任务队列不会卡死在无效句柄的任务上。

接下来,我们需要解决任务回调的指针设置 问题。在任务完成后,我们希望指针能够指向正确的数据内存,但由于目前任务没有真正加载数据,因此我们需要手动设置指针 ,确保任务完成后能访问数据。我们直接将work slot中的内存指针设置为我们预留的内存地址 (destination),以便在测试时可以直接访问数据内存。

接下来我们发现,由于没有真正加载数据,因此我们不能调用文件读取函数 (platform.read_data_from_file)。因此,我们需要将这一部分代码用#if 0宏屏蔽掉,避免调用无效文件句柄。将来当我们恢复文件加载逻辑时,只需要移除#if 0宏即可。

我们接着检查任务状态,确保任务在完成后能正常进入任务完成状态 (complete)。我们发现任务结构体中的final_state仍然需要填写,以确保任务在完成后能被识别为已完成状态。我们直接将final_state设置为asset_loaded,确保即使没有实际加载数据,也能将任务标记为已完成。

发现一个问题音频听起来不怎么对

得检查一下LoadSound 相关得函数有哪里设置得不对

少了一个括号

使用此系统,是否可以在不修改原始文件的情况下,在新的 .hha 文件中添加更新版的资产?在这种情况下,IDs 会怎么处理?

我们现在讨论的是在不修改原始HHA文件的情况下,添加一个更新版本的资源 到新的HHA文件中,并确保系统能够优先使用新的资源。这种情况在资源更新、补丁更新或MOD制作 时非常常见,而我们需要解决的核心问题是:如何处理资源ID,确保加载时优先使用新的资源而不是旧的资源。

当前资源查询的工作方式

目前,我们的资源查询系统基于**标签匹配 (tag matching)**工作。当我们调用GetBestMatchBitmapFrom时,内部会基于提供的标签向量 (tag vector) 执行查询,并返回最匹配的资源。查询过程大致如下:

  1. 遍历所有已加载的HHA文件
  2. 在每个HHA文件内遍历资源,根据标签匹配程度选出最佳匹配。
  3. 如果有多个匹配 ,目前的逻辑是选择第一个找到的资源

问题在于:

  • 如果我们添加了新的HHA文件 ,即使它包含了更新版本的资源,由于查询逻辑优先选择最早匹配到的资源,因此不会加载新资源。
  • 即使新HHA文件内存在ID相同的资源,系统也无法识别应该替换原始资源。

基于加载顺序优先替换的方案

我们可以在不修改原始HHA文件 的前提下,让新HHA文件中的资源优先级更高,方法是:

  • 调整加载顺序 :在加载HHA文件时,优先加载新文件,确保新文件内的资源排在前面。
  • 修改匹配逻辑 :将匹配到相同标签的多个资源时,始终选择最后加载的资源

目前的匹配代码类似于:

c 复制代码
if (current_match_score > best_match_score)
{
    best_match = current_asset;
}

我们只需要修改成:

c 复制代码
if (current_match_score > best_match_score ||
    (current_match_score == best_match_score && current_asset->load_time > best_match->load_time))
{
    best_match = current_asset;
}

通过引入load_time字段,确保后加载的资源优先 ,这样就可以在不修改原始HHA文件的前提下优先使用新HHA文件中的资源


但这个方法有局限性

上述方法只能在标签匹配 的情况下工作,但如果资源是通过直接ID引用的,比如:

c 复制代码
bitmap = get_bitmap_by_id(asset_id);

那么新的HHA文件中即使存在更新资源,系统仍然会通过ID直接加载旧资源 。因此,这种方式对于直接ID查询场景无法覆盖。


针对ID查询的资源替换方案

如果我们希望无论是标签匹配还是直接ID查询 都能优先加载新资源,我们需要添加一张资源替换表。流程如下:

1. 在新HHA文件内添加资源替换表

每个新的HHA文件可以内置一个资源替换表 (AssetRemovalTable),结构类似于:

plaintext 复制代码
Asset Replacement Table:
{
    Original File ID: 1234,  // 要替换的资源ID
    Replacement File ID: 5678 // 替换的新资源ID
}

如果资源ID1234存在于原HHA文件内,而新的HHA文件内包含5678作为替代资源,则加载时会优先加载5678

2. 在加载过程中处理替换表

在加载HHA文件时,检测是否存在资源替换表,如果有,则记录替换映射:

c 复制代码
if (new_hha->has_replacement_table)
{
    for (int i = 0; i < new_hha->replacement_count; i++)
    {
        asset_replacement_table[new_hha->replacements[i].original_id] = new_hha->replacements[i].replacement_id;
    }
}

此时,asset_replacement_table记录的是旧资源ID新资源ID的映射关系。

3. 在查询资源时优先检查替换表

每当通过ID获取资源时 (get_bitmap_by_id),先检查替换表:

c 复制代码
int real_asset_id = asset_id;
if (asset_replacement_table.contains(asset_id))
{
    real_asset_id = asset_replacement_table[asset_id];
}
return get_bitmap_by_id(real_asset_id);

这样,无论是通过ID 还是通过标签匹配 ,都会优先加载新资源,彻底解决资源替换问题。


额外优势:允许完全禁用旧资源

我们还可以允许在新HHA文件内完全禁用旧资源,即不提供替换资源,而是直接禁用旧资源。这种情况适用于:

  • 移除错误资源
  • 删除某些不再需要的资源

实现方式

  • Asset Replacement Table中,只提供Original File ID而不提供Replacement File ID,即可标记为禁用:
plaintext 复制代码
Asset Replacement Table:
{
    Original File ID: 1234,  // 要禁用的资源ID
    Replacement File ID: 0   // 无替代资源,直接禁用
}
  • 在加载过程中检测到Replacement File ID = 0,直接跳过该资源。

总结

方案 能解决的问题 缺点 适用场景
✅ 调整加载顺序 确保新HHA文件中的资源优先被加载 无法覆盖直接ID查询场景 适合标签匹配查询的资源
✅ 引入替换表 完全替代或禁用旧资源,无论是标签匹配还是ID查询 需要改动HHA文件格式 适合版本更新、补丁或MOD
✅ 组合两种方案 完全覆盖所有情况 增加一点加载复杂度 适合任何资源替换或移除

启动时,怎么去除角色的初始闪烁?

目前我们的资源加载系统 存在一个明显的问题:在游戏启动或遇到新资源时,资源的首次加载会导致屏幕闪烁 。这是由于资源加载系统的工作机制决定的:

当前资源加载的机制

  1. 资源不会提前加载 :当前系统不会在游戏启动时主动加载任何资源,而是等到资源第一次被使用时,才会通过线程加载。
  2. 线程加载有延迟 :在首次使用资源时,系统会将加载任务提交给线程异步加载 ,而此时资源尚未可用,因此会导致资源未加载完成时屏幕出现闪烁的情况。
  3. 遇到新资源就会闪烁 :每次角色进入一个新场景,遇到新怪物、新物品、新特效等时,如果这些资源之前没有被加载过,就会出现短暂的空白或错误显示,直到加载完成。

解决方案:引入资源预加载(Pre-Caching)

为了解决首次使用资源导致的闪烁问题 ,我们计划增加一个资源预加载机制 ,即在游戏启动或进入新场景时,提前将需要的资源加载到内存中,从而避免资源加载延迟带来的视觉闪烁。


预加载的基本策略

我们不打算采用非常复杂的全量预加载(因为这会导致长时间的加载过程和占用大量内存),而是通过基于视野的预加载 策略,以最小化加载延迟的方式保证资源可用性。

1. 基于屏幕视野的资源预加载
  • 当角色进入新的场景时,我们不仅加载当前屏幕内可见的资源 ,还会加载屏幕周围的一定范围内的资源,这样在角色移动时,新资源几乎已经加载完成,避免出现闪烁。
  • 比如:
    • 当前屏幕:加载所有可见资源。
    • 屏幕外边缘:提前加载一定范围内的资源。
    • 一定缓冲区:加载一些资源(如常用特效、角色动画等),确保在短时间内可用。
  • 这样,即使角色快速移动,也不会出现资源未加载完成的空白或错误贴图
伪代码示意
c 复制代码
void PrecacheAssets(WorldRegion *region)
{
    for (int i = 0; i < region->visibleEntityCount; ++i)
    {
        Entity *entity = region->entities[i];
        PrecacheEntityAssets(entity);
    }
}

void PrecacheEntityAssets(Entity *entity)
{
    LoadBitmap(entity->bitmap);
    LoadAnimation(entity->animation);
    LoadSound(entity->sound);
}

该方案通过在角色进入新区域时 提前加载可能需要的资源 ,从而大幅减少视觉闪烁的概率。


2. 基于场景上下文的资源预加载

除了基于视野的预加载外,还可以通过场景上下文进行资源预加载。例如:

  • 角色进入村庄时,提前加载村民的贴图、背景音乐、交互物品等。
  • 进入地下城时,提前加载怪物、战斗特效、地下城背景等。
  • 进入BOSS战时,提前加载BOSS模型、BOSS技能特效、战斗音乐等。

这种场景上下文预加载 可以极大减少重要场景的资源加载延迟,让玩家感受更加流畅。

伪代码示意
c 复制代码
void PrecacheAssetsForScene(Scene *scene)
{
    for (int i = 0; i < scene->entityCount; ++i)
    {
        PrecacheEntityAssets(scene->entities[i]);
    }

    PrecacheBackground(scene->background);
    PrecacheMusic(scene->music);
}

当我们加载一个新场景时,只需要提前加载所有的场景资源 ,就可以有效避免首次加载时的闪烁问题


3. 关键角色、关键资源的强制预加载

对于某些资源,比如:

  • 主角模型、主角动画
  • 主角技能特效
  • 主角攻击音效
  • UI界面资源

这些资源在游戏启动时几乎必定会使用 ,因此可以在游戏启动时直接预加载,而不是等待首次使用时加载。

伪代码示意
c 复制代码
void PrecacheCoreAssets()
{
    PrecacheBitmap("MainCharacter.id");
    PrecacheAnimation("AttackAnimation.id");
    PrecacheSound("SwordSwing.id");
    PrecacheBitmap("UserInterface.id");
}

通过这种方式,确保游戏启动时的核心资源不会出现任何加载延迟或闪烁问题。


4. 资源加载优先级机制

我们还可以设置资源加载优先级 ,确保高优先级资源 (如角色模型、UI等)优先加载,而低优先级资源(如装饰物、背景音乐等)可以稍后加载:

  • 高优先级:角色模型、动画、UI界面、主线任务物品。
  • 中优先级:场景背景、环境音效、次要NPC模型。
  • 低优先级:装饰物品、非关键场景资源、稀有出现的怪物模型等。

5. 异步加载和同步加载的结合

我们仍然保持异步加载 的方式,但在特定场合 下(如进入场景、加载主角等),可以采用同步加载的策略:

  • 同步加载:进入新场景时,强制加载场景资源,确保无闪烁。
  • 异步加载:其他非关键资源,继续保持线程加载,确保不卡住主线程。

预加载系统的内存消耗控制

预加载会增加内存占用,因此我们还需要设计缓存回收机制

  • 最近未使用的资源优先被释放。
  • 当前区域资源持续保留。
  • 核心资源不释放。

示例:

c 复制代码
void UnloadUnusedAssets()
{
    for (int i = 0; i < assetCount; ++i)
    {
        if (AssetNotUsedRecently(assets[i]))
        {
            UnloadAsset(assets[i]);
        }
    }
}

最终的加载机制示意

类型 加载方式 加载时机 是否影响流畅度
主角、UI等核心资源 同步加载 游戏启动时 不会
当前场景资源 异步+同步加载 场景切换或角色移动 基本不会
远处不可见资源 异步加载 背景预加载 可能有轻微延迟
最近未使用资源 自动卸载 内存占用过高时 不影响

预加载的最终效果

通过这一系列的资源预加载策略,我们可以:

  1. 消除角色、怪物、特效等资源的首次加载闪烁
  2. 在角色快速移动时,保证资源已经提前加载,避免延迟
  3. 进入BOSS战或重要场景时,确保资源无卡顿加载
  4. 确保内存使用合理,避免加载过多资源导致内存溢出

作为一个开发简单图形游戏的程序员,如果你不认识一个好的数字艺术家,有什么建议去寻找别人来委托艺术资产或类似的工作吗?

在开发一款具有相对简单图形风格的游戏时,如果需要委托他人绘制美术资源 ,但又不认识合适的数字艺术家,寻找合适的艺术家或美术资源的过程确实是一个难题。

目前来说,我们可以考虑以下几种方法来寻找适合的美术资源或艺术家


1. 使用专门的美术外包平台

目前有很多专门用于游戏美术外包 的平台或网站,可以直接找到适合自己项目的数字艺术家购买现成的美术资源

📌 常用的美术外包平台
  1. Fiverr :这是一个非常流行的自由职业平台,里面有大量的像素风、手绘风、卡通风 等艺术家可供选择,价格从几美元到几百美元不等
  2. Upwork :这是一个更偏向于专业人才的平台,可以找到更具专业能力的游戏美术设计师,适合预算稍微充足的项目。
  3. ArtStation :如果想直接找到专业的游戏原画设计师 ,ArtStation是非常理想的选择,这里聚集了大量专业游戏美术人员。可以直接联系他们进行私下合作或委托。
  4. DeviantArt :DeviantArt是一个全球知名的艺术家社区,里面有大量的独立艺术家和插画师。可以浏览作品并联系作者进行定制化合作
📜 寻找艺术家的策略
  • 如果预算有限:优先选择Fiverr或DeviantArt,成本较低且可控。
  • 如果追求专业美术 :优先选择ArtStation或Upwork,能够找到具备游戏美术设计经验的专业人才,但价格相对较高。
  • 如果需要现成资源 :可以在Unity Asset Storeitch.io等平台上直接购买美术资源,节省时间。

2. 使用素材市场直接购买资源

如果不想直接委托艺术家,也可以考虑直接购买现成的美术资源,例如角色、场景、UI等素材。

📌 常见的素材市场
  1. Unity Asset Store :如果使用Unity引擎开发,可以直接在Unity官方商店购买各种美术素材 ,包括2D/3D模型、UI资源、特效等
  2. itch.io Asset Store :itch.io是一个非常受欢迎的独立游戏开发者社区 ,同时提供大量的免费或收费游戏资源,价格非常便宜,甚至有很多免费资源可用。
  3. OpenGameArt :这是一个专注于开源游戏美术资源的网站,所有资源都可以免费使用,适合预算非常有限的开发者。
📜 优先购买哪些资源
  • 角色设计 :如果需要简单的角色,可以在这些平台找到2D像素角色、卡通风角色、RPG角色等资源。
  • UI界面:游戏的UI界面通常可以直接使用现成的UI Kit,节省大量设计时间。
  • 背景资源:如果是2D游戏,可以直接购买像素风背景或者平台背景,避免自己制作。

3. 在社交媒体或开发者社区寻找合作伙伴

如果希望找到长期合作的数字艺术家,可以尝试在以下平台直接发布招聘需求:

📌 适合寻找艺术家的社区
  1. Twitter/X :Twitter上有大量独立游戏开发者 以及游戏美术人员,可以通过特定的标签(如:#PixelArt #GameDev #IndieGame)来寻找合适的艺术家。
  2. Reddit :在Reddit上有一些专门的游戏开发社区,例如:
    • r/gamedev
    • r/PixelArt
    • r/IndieDev
      这些社区里有很多愿意接单的独立艺术家,可以直接私信联系合作。
  3. Discord社区 :有很多面向游戏开发者 的Discord服务器,例如:
    • GameDevNetwork
    • Pixel Art Community
    • Indie Game Developer
      在这些服务器中,可以直接发布委托需求,寻找合适的美术人员。
📜 如何发布需求

发布需求时,尽量写清楚:

  • 游戏的类型(如:2D像素风、卡通风等)。
  • 需要的美术资源(如:角色、场景、UI等)。
  • 预算范围。
  • 项目风格参考。

例如:

我们正在开发一款2D像素风RPG游戏,现需要1名美术设计师,负责:
- 制作主角和敌人的像素角色。
- 设计游戏内的UI界面。
- 制作背景场景。
预算:500美元左右,欢迎有经验的艺术家联系我们!

4. 通过开源资源快速搭建游戏原型

如果当前阶段只是验证游戏原型 ,那么完全可以使用免费开源资源来构建游戏雏形,等到游戏基本成型后再考虑委托艺术家设计最终资源。

📌 常用的免费资源平台
平台 资源类型 备注
OpenGameArt 2D/3D资源、音效 免费资源,无版权限制
Kenney.nl UI、角色、场景 提供大量免费素材
itch.io 美术资源、特效 部分资源免费
GameDevMarket 专业级美术资源 收费资源,但价格较低
📜 优点
  • 快速构建游戏:无需等待艺术家制作资源,直接使用现成素材。
  • 验证游戏玩法:游戏原型阶段最重要的是验证玩法,美术风格可以暂时不考虑。
  • 降低开发成本:减少前期投入资金,等游戏确定后再付费定制美术资源。

5. 注意授权和版权问题

无论是委托艺术家绘图,还是使用现成资源,都需要注意版权和授权协议,确保不会因为美术资源问题导致游戏无法发布。

📜 常见授权协议
授权类型 可商用 修改 备注
CC0(完全开源) 可自由使用
CC-BY 需要署名
CC-BY-SA 需要署名,保持相同授权
个人使用许可 禁止商业用途

因此,在使用任何资源前,务必确认是否具有商业使用授权,避免未来游戏发布时出现版权纠纷。


6. 总结

需求 推荐平台 备注
寻找艺术家 Fiverr、Upwork、ArtStation 定制化资源,费用较高
购买现成资源 Unity Asset Store、itch.io 快速获取资源,节省成本
使用开源资源 OpenGameArt、Kenney.nl 适合验证原型
长期合作 Twitter、Reddit、Discord 寻找长期合作伙伴
📜 建议的开发流程
  1. 前期原型阶段:直接使用开源资源或低成本素材,快速验证游戏可玩性。
  2. 中期开发阶段 :在游戏玩法稳定后,开始定制化美术资源或寻找长期合作伙伴。
  3. 最终发布阶段 :确保所有资源都具有商业授权,并最终优化游戏美术表现。

最终建议

如果预算有限,建议:

  • 前期直接使用开源资源,快速验证游戏可行性。
  • 中期开始寻找艺术家,定制核心美术资源。
  • 最终阶段确保所有资源都有合法授权,避免版权纠纷。

这样可以最大程度降低开发成本,同时确保游戏最终的美术质量达到要求。 🚀🎨

回顾剩余任务,完成资产系统

目前我们已经完成了大部分的资源系统(Asset System)的搭建工作,但仍有一些重要的工作 需要处理,主要集中在音频资源的加载统一 以及资源内存管理这两个方面。


一、将音频加载逻辑统一合并

目前在我们的资源系统中,音频(Audio) 位图(Bitmap)的加载是通过两个不同的回调函数进行的,即:

  • 位图资源 使用 LoadAsset 函数通过工作队列回调加载;
  • 音频资源 使用 LoadSound 函数通过另一个工作队列回调加载。

但是,这种分离的加载方式存在一定的问题:

  1. 代码冗余:两个回调函数的逻辑实际上非常相似,分别处理资源加载,但没有共用相同的逻辑。
  2. 资源系统的不一致性:所有的资源都应当通过同一个通道加载,这样可以确保资源加载逻辑更加一致,便于未来扩展。
  3. 维护成本增加:如果未来要修改资源加载逻辑,需要修改两个回调函数,增加了开发和维护成本。
解决方案

我们计划将音频资源加载逻辑 直接合并到**LoadAsset**回调中,取消LoadSound的专用回调。

  • 即:位图和音频资源统一通过同一个工作队列回调进行加载
  • 不再为音频资源单独设置回调函数 ,而是直接通过LoadAsset函数进行加载。

这样做的好处

  • 简化代码结构,只需维护一个资源加载回调;
  • 减少维护成本,未来调整资源加载逻辑时只需修改一处;
  • 提升加载一致性,避免资源加载过程中的差异性问题。

我们下一步要做的就是重构音频加载逻辑 ,让音频资源与位图资源共用同一条加载管道,确保资源加载流程的一致性。


二、实现平台层 Win32 的底层支持

在完成音频资源加载逻辑重构之后,我们还需要在Win32平台层完善一些底层支持,包括:

  1. 确保Win32平台层能够调用LoadAsset函数
  2. 确保音频和位图的加载回调都能顺利触发
  3. 提供一套通用的资源加载入口,让平台层可以无差别地加载资源。

目前我们已经具备了一些基础加载能力 ,但还没有实现完整的Win32回调支持,因此我们需要将其补全。

完成这一部分后,我们的资源系统 就将进入稳定状态,音频和位图的加载将完全统一。


三、解决超大资源占用导致的内存溢出问题

完成资源加载逻辑的统一后,我们需要开始解决一个更加棘手的问题

如果资源体积过大,超出内存限制时该如何处理?

📌 问题描述

在大型游戏开发中,游戏的资源文件 (如音频、位图、模型等)的总大小通常会远超设备内存

例如:

  • 假设玩家的设备物理内存只有 2GB
  • 而我们的游戏资源体积达到 4GB
  • 此时游戏加载过多资源,将会导致内存溢出,引发游戏崩溃。

目前我们还没有实现资源溢出后的内存管理机制,因此这将是接下来最难的工作


四、设计资源内存管理系统

我们需要设计一个类似虚拟内存的资源管理系统 ,确保在内存不足时,能够自动卸载不需要的资源,从而腾出内存空间加载新的资源。

📜 设计核心思路
  1. 内存占用限制 :我们需要设定一个内存占用阈值,例如:1.5GB,超过这个值后就主动卸载一部分资源。
  2. 资源优先级 :我们需要给资源分配优先级 ,优先加载当前屏幕内可见资源 ,延迟加载屏幕外资源
  3. 资源替换策略 :当内存即将耗尽时,主动卸载一段时间未使用的资源,为新资源腾出空间。
  4. 缓存策略 :尽量缓存高使用率资源,避免反复加载相同资源导致的性能消耗。

五、实现内存管理的技术方案

我们计划实现一个类似虚拟内存的资源管理机制,主要分为以下几个步骤:

📌 1. 资源加载缓存

我们需要维护一个资源缓存表 ,记录当前所有加载到内存中的资源

缓存表格式大致如下:

资源ID 资源类型 加载时间戳 访问次数 是否可丢弃
001 位图 12:30:25 56
002 音频 12:31:10 10
003 位图 12:35:00 4

我们通过以下策略管理内存:

  • 优先保留高访问率的资源
  • 优先卸载长时间未访问的资源
  • 设置内存阈值,保证不超出设备内存。

📌 2. 资源丢弃策略

当内存占用接近阈值 时,我们需要丢弃一些长时间未使用的资源。

丢弃策略:

  • 优先丢弃低优先级资源
  • 优先丢弃长时间未访问资源
  • 避免丢弃主角、UI等核心资源

例如:

  • 当内存占用达到1.5GB时,丢弃30分钟未使用的资源;
  • 当内存占用达到1.8GB时,强制丢弃低优先级资源;

📌 3. 自动内存回收

我们需要实现一个内存回收线程,定期检查内存占用:

  • 内存不足时,自动丢弃低优先级资源;
  • 内存充足时,允许保留更多资源;
  • 持续监控内存状态,动态调整资源缓存大小。

六、下一步的工作安排

工作内容 状态 预计完成时间
✅ 合并音频和位图加载 进行中 1天内
✅ 完善Win32平台支持 进行中 1天内
✅ 设计内存管理机制 即将开始 3-5天
✅ 完成内存自动回收 即将开始 5-7天

七、面临的挑战

最大难点是:

  • 如何保证内存溢出时游戏不会崩溃;
  • 如何确保加载资源时不卡顿,保持流畅运行;
  • 如何设计最优的资源回收策略,确保核心资源始终可用。

我们相当于在游戏内实现一套虚拟内存管理系统 ,这是整个资源系统中最困难的部分,但完成后将大大提升游戏运行的稳定性和性能。


最终目标

  • 统一音频和位图加载逻辑,减少冗余代码;
  • 实现资源内存回收机制,保证游戏在低内存设备上也能顺畅运行;
  • 确保资源系统具备超大资源支持能力,即使4GB资源也能顺畅加载;

这将是资源系统最艰难的部分

目前我们已经接近资源系统开发的终点 ,只需攻克内存管理难题,整个资源系统就将完全完成。 🚀🎮

回顾即将进行的任务

目前我们的开发进展非常顺利,整体的资源系统已经基本搭建完成,并且音频系统 也处于一个非常不错的状态。我们现在的主要任务已经非常清晰,就是将资源系统中的内存管理机制完善 ,并进行一些收尾工作,确保游戏资源加载系统稳定且高效。以下是我们当前的开发总结和接下来的工作计划。


一、目前的开发进度总结

从目前的情况来看,我们已经完成了相当大的一部分开发工作,主要包括:

📀 资源加载系统
  • 音频位图资源的加载功能已经基本完成;
  • 音频系统 目前支持环境音效背景音乐 以及其他声音,功能已经非常完善;
  • 资源流式加载系统 (Asset Streaming)也基本可用,当前只需要解决内存管理问题即可;
  • 回调系统已经完全打通,音频和位图资源可以通过同一通道加载,避免了重复代码和加载冲突。
🎶 音频系统
  • 我们目前的音频系统支持多音轨播放 ,包括背景音乐环境音效等;
  • 同时,我们还实现了动态音量调整淡入淡出 以及循环播放等核心功能;
  • 基本上音频系统已经具备了所有核心功能,我们接下来只需要稍作检查,确保没有遗漏任何功能即可。
💾 资源流式加载
  • 目前我们的资源系统 支持了流式加载 ,即:
    • 需要时才加载资源;
    • 避免一次性加载所有资源导致内存溢出;
    • 实现动态加载和释放,确保游戏流畅运行;
  • 唯一的难题 就是如何解决内存管理 ,即:
    • 当资源占用超过设备内存时,如何及时释放内存?
    • 如何确保核心资源不会被回收
    • 如何动态调整内存占用

目前的资源流式加载 是可用的,但还不够完美,我们需要通过内存管理解决最后的问题。


二、下一步的工作计划

目前,我们主要有两个大的工作内容需要完成:


📂 1. 完成内存管理机制

这是我们当前面临的最复杂 也是最困难 的开发任务。

我们要解决的问题是:

  • 当内存不足时,如何自动释放不常用资源
  • 如何确保核心资源(如主角贴图、UI、音效等)不会被释放?
  • 当内存恢复后,如何快速恢复被释放的资源

我们计划的解决方案包括:

内存占用监控
  • 在资源加载时,实时检测内存占用
  • 当内存占用达到某个阈值时,触发资源回收机制
资源回收策略
  • 优先回收长时间未使用的资源;
  • 优先回收低优先级资源;
  • 避免回收核心资源(如主角贴图、UI、背景音乐等)。
内存回收线程
  • 在后台运行一个内存管理线程,持续监测内存状态;
  • 一旦发现内存占用过高,立即触发资源回收;
  • 同时避免内存抖动,确保资源加载不会卡顿。
缓存机制
  • 保持一部分资源常驻内存(如主角贴图、UI、背景音乐等);
  • 对于次要资源,进行动态加载和回收;
  • 确保内存使用始终处于可控状态

我们预计内存管理机制 将需要一到两周 才能完成,因为这部分开发涉及到非常复杂的内存管理算法,且需要反复测试和调整,确保不会引入新的Bug。


🎵 2. 回顾和优化音频系统

虽然我们目前的音频系统 已经可以正常工作,但我们计划在内存管理机制完成后,对音频系统进行一次全面的回顾和优化,主要包括:

  • 检查是否存在内存泄漏
  • 确保音频资源回收正确
  • 优化音频播放流程,避免卡顿;
  • 检查所有音频回调是否正常

如果在回顾过程中发现任何Bug 或者未实现的功能 ,我们将一并修复,确保音频系统达到最终的稳定状态。


三、开发进度预估

根据当前的开发状态,我们对接下来的开发进度进行了如下估计:

工作内容 状态 预计完成时间
音频加载回调合并 已完成 已完成
音频系统核心功能 基本完成 已完成
资源流式加载 基本完成 已完成
内存管理机制 即将开始 约需1-2周
音频系统回顾 内存管理后进行 约需2-3天
资源系统最终检查 内存管理后进行 约需3天

预计两个星期内 ,我们将完全完成资源系统,包括:

  • ✅ 音频加载;
  • ✅ 位图加载;
  • ✅ 内存管理;
  • ✅ 动态加载与回收;

四、资源系统完成后的游戏引擎状态

资源系统完全完成之后,我们的游戏引擎将达到以下状态:

🎮 1. 支持完整的音频加载和回放
  • ✅ 支持背景音乐;
  • ✅ 支持环境音效;
  • ✅ 支持动态加载和回收音效;
  • ✅ 避免音频加载卡顿。
🖼 2. 支持流式加载位图资源
  • ✅ 支持动态加载场景贴图;
  • ✅ 支持动态加载角色贴图;
  • ✅ 支持大资源回收;
  • ✅ 确保主角贴图不被回收。
💽 3. 内存管理机制
  • ✅ 自动检测内存占用;
  • ✅ 内存不足时回收资源;
  • ✅ 内存恢复时快速恢复资源;
  • ✅ 避免内存溢出导致游戏崩溃。
💯 4. 游戏引擎稳定性
  • ✅ 避免资源加载卡顿;
  • ✅ 保证核心资源(如主角、UI、背景音乐等)常驻内存;
  • ✅ 确保游戏在低内存设备上也能流畅运行。

五、最后的开发目标

接下来,我们的目标非常明确:

  1. 优先解决内存管理问题,确保游戏不会因内存溢出而崩溃;
  2. 回顾并优化音频系统,确保音效播放流畅且无Bug;
  3. 对整个资源系统进行全面测试,确保加载、回收、恢复机制完全正常;
  4. 完成最终调试 ,确保游戏在低内存设备上也能流畅运行。

六、预计开发完成时间

根据当前的开发进度,我们预计:

  • 内存管理:1-2周内完成;
  • 音频系统回顾:2-3天内完成;
  • 资源系统最终调试:1周内完成;

最终,我们将在不到200小时的开发时间内 ,完成一套功能强大且高度优化的2D游戏引擎

这套引擎将具备:

  • ✅ 高效的资源加载;
  • ✅ 流畅的音频播放;
  • ✅ 高度优化的内存管理;
  • ✅ 良好的稳定性和性能。

这是非常令人兴奋的,接下来我们只需攻克最后的内存管理难关,游戏引擎就会趋于完美! 🚀🎮

相关推荐
瑶光守护者42 分钟前
并行计算编程模型的发展方向与RISC-V的机遇
人工智能·笔记·学习·架构·risc-v
半夏知半秋1 小时前
linux下的网络抓包(tcpdump)介绍
linux·运维·服务器·网络·笔记·学习·tcpdump
Cools06131 小时前
[从零开始学习JAVA] 新版本idea的数据库图形化界面
学习
xiyuping243 小时前
在VMware上创建虚拟机Ubuntu20.04(适合学习版,包含gparted vim git的下载)
git·学习·ubuntu·vim
zzzkk20094 小时前
BambuStudio学习笔记:MTUtils
笔记·学习
大宝剑1704 小时前
blender学习25.3.11
学习·blender
Aphelios3804 小时前
Java 学习记录:基础到进阶之路(一)
java·开发语言·学习·idea
小逗比r4 小时前
小白学习:提示工程(什么是prompt)
学习·prompt
爱学习的小王!4 小时前
有关MyBatis的动态SQL
java·笔记·sql·学习·mybatis