游戏引擎学习第150天
回顾与当天计划
我们在这里完全不使用任何库,所以我们完全是引擎和库免疫的, 正如大家所知道的,我们正在编写自己的资源处理系统,准确来说,是一个资源加载系统。过去一周我们已经完成了很多工作,现在只剩下最后几步,就可以将我们的资源文件系统正式替代之前从硬盘上随机加载常规文件的方式。
我们已经写好了一个测试文件,其中包含了所有打包的资源。现在我们需要做的就是开始使用这些资源。如你所见,如果现在运行游戏,由于我们注释掉了之前为了填充资源表而写的调试代码,游戏没有加载任何资源。所以我们之前游戏中的角色、树木、草地等元素都无法显示,因为它们没有加载任何资源。幸运的是,游戏并不会崩溃,这证明我们的系统在这一点上做得相当不错。不过,我们还是希望能够重新加载一些位图资源,当然,音乐也没有播放,因为它也是资源的一部分。
加载资源数组
接下来我们要做的事情是,继续朝着让所有资源从资源文件中加载的方向迈进。昨天我们完成了标签数组的处理,现在我们要处理另外两个数组:一个是资源本身,另一个是资源类型。
首先,我们通过遍历资源数组,处理每个资源的加载。就像我们之前处理标签数组那样,我们需要从HHA资源数组中提取出资源的数据,并且根据文件头中给出的偏移量来定位这些资源。因为我们已经将文件加载到内存中,所以我们可以直接使用内存地址并根据文件头告诉我们的偏移量来跳转到相关数据的位置。
我们还要处理资源类型数组。在这里,我们需要跟资源数组一样进行遍历,确保能够加载和处理所有的资源类型。我们决定使用文件头中的信息来计算数组的长度,这样可以确保在后续的处理中,我们能够清晰地了解资源的具体内容和结构。
在遍历这些数组时,我们需要同时处理源资源和目标资源。源资源是从资产定义中读取的,而目标资源则是我们实际需要处理和存储的内容。我们通过索引值来处理每一个资源项,确保每次都能正确地从源数组中提取数据并将其存储到目标数组中。
在编译代码并进行调试时,发现有一些小问题,比如标签索引需要重新命名。我们通过更改代码中的变量名来解决这些问题,确保代码能够顺利编译。
接下来,我们需要根据不同的资源类型来处理每个资源。由于资源类型可能是位图或音效,我们需要根据资源类型来做出不同的处理。每个资源都有一个类型ID,这个ID能够帮助我们判断该资源属于哪种类型。根据类型,我们将执行不同的操作:如果是位图资源,我们将按照位图处理,如果是音效资源,则需要进行音效处理。
通过这些步骤,我们逐步完成了资源加载的核心功能,确保能够正确地加载和处理每种类型的资源,同时为后续的处理打下基础。
简化与资源相关的结构体
在处理资源加载时,发现一个问题:我们并没有在文件中存储一些信息,这些信息其实并不需要在加载时使用,这使得我们考虑是否有必要将这些信息存储到文件中。经过思考,决定不必将这些信息进行存储,而是直接从资源文件中提取相关数据,避免增加不必要的复杂性。
具体来说,我们有一个HHA
结构,它包含了资源的所有信息,比如位图和声音的相关数据。原本我们有一些额外的结构体(如位图信息和声音信息),但这些结构并不代表我们当前要存储的真实数据。因此,考虑将资源直接映射到HHA
结构上,而不是额外维护多个不必要的结构体。
为了实现这个目标,我们做了以下操作:
- 通过修改代码,直接从
HHA
资源结构中提取数据,而不是使用之前定义的位图信息和声音信息。 - 更改了代码中的相关部分,比如不再返回旧的结构,而是返回
HHA
中的声音或位图数据。 - 继续调整代码,确保在提取资源时,能够正确地处理这些数据,而不会遗漏或出错。
在此过程中,还发现了一些需要注意的小细节。例如,FileName
字段不再需要,因为我们不再通过旧的方式去调用这些数据。为了适应这种变化,我们需要重新处理与文件名相关的部分。
另一个重要的修改是,原本通过FirstTagIndex
等字段来操作标签,现在这些字段也需要直接从HHA
资源结构中获取。为了避免错误,我们直接将这些数据拷贝到新的结构中,确保数据的一致性。
通过这些修改,我们开始简化代码,并且将资源加载过程中的一些复杂操作减少到了最小。接下来的任务是,确保所有资源加载和处理流程都能顺利进行,同时继续保持代码的清晰性和可维护性。
回到加载资源数组
在加载资源的过程中,实际操作是将源数据直接复制到我们需要使用的目标结构中。这一过程中,我们暂时需要做一些步骤来确保一切能够顺利进行,尽管这些步骤在最终版本中并不需要。
具体来说,我们将资源数据直接复制到目标结构体中,而不是通过复杂的转换过程。这种做法简化了代码,并使得资源加载变得更为直接。然而,这只是一种临时方案,最终我们并不打算继续使用这种方法。
在这个过程中,有些字段或数据处理步骤是暂时性的,用来保证当前的系统能够正常工作。虽然这些操作最终会被替换或去除,但它们是为了当前阶段能够顺利进行加载和调试。
存储文件的基址
在处理资源时,需要有一个指向文件内容的指针(如 uint8 *
类型),这个指针用于存储文件的基础地址。因为在初始化阶段之后,未来还需要从这个地址提取数据。通过存储文件的基础地址,可以方便地确定数据的存放位置,并在之后的步骤中根据这个位置来提取资源。这个地址对后续的资源加载和处理至关重要,因此必须在初始化时妥善保存。
加载资源类型数组
在处理资源时,asset_type
数组与其他数组有所不同,因为它只是存储了一个表格,指示了当前资源的类型以及其在文件中的范围。因此,在加载时,需要根据资源的 typeID
来查找对应的条目,并根据该条目来获取相关数据。
由于可能加载的文件包含未知的资源类型,所以在处理时需要检查资源的 typeID
是否小于已知的 Asset_Count
,以确保不会访问超出已知范围的数组。为了确保这一点,可以在代码中添加相应的检查条件,避免出现数组越界的情况。
暂时的做法是将资源类型的数据覆盖到目标位置,并假设之前没有任何数据。可以使用断言来验证这个假设,确保 FirstAssetIndex
和 OnePastLastAssetIndex
都是零,这样就能确保数组中没有数据。
移除DEBUGLoadBMP的调用
在加载资产时,目标是从已经加载的资源包文件中提取数据,而不是直接加载单独的资源文件。为了实现这一目标,需要依赖事先加载好的资源数据,这样就可以避免实际的文件读取操作。接下来,主要的任务是从已加载的资源数据中提取相关信息,并将其转换为适合使用的格式。
首先,需要通过 hha_bitmap
结构体从资源包文件中提取与位图相关的信息。比如:
- 对齐百分比:可以直接从文件中读取,因为在保存时已经考虑到了这一点。
- 宽度和高度:这些信息存储在位图数据的第一个维度和第二个维度中,因此可以直接获取。
- 像素大小:在这里,宽度和高度的像素大小需要计算,通常是宽度乘以像素大小。
- 宽高比:通过将宽度和高度分别转换为浮点数进行除法运算,得到实际的宽高比。
- 内存数据:位图的实际数据位于资源包文件中的某个偏移位置,这个位置由资源文件中的
DataOffset
提供。通过将DataOffset
加到已加载文件的基址上,就能定位到位图数据的位置。
通过这些步骤,不需要重新加载位图数据,而是直接使用资源包文件中已加载的数据。最终,我们只需要将这些信息填充到目标结构体中,完成位图的加载操作。
这些步骤能确保所有位图数据能够正确加载并且使用,虽然实际上没有执行实际的文件 I/O 操作。
移除DEBUGLoadWAV的调用
为了修复声音加载的过程,使其与位图加载的方式类似,需要进行一系列的调整。首先,我们通过对比之前处理位图数据的方式,采用类似的方法来处理声音数据。
主要步骤:
-
初始化声道数据:
- 需要根据
Sound->SampleCount
设置音频样本的数量。这些值存储在音频信息结构体中,因此直接使用这些信息。 - 还需要获取
ChannelCount
(声道数量),并且为了确保不会超出数组的范围,应该对其进行检查,确保声道数量小于预定的最大值。
- 需要根据
-
复制声音数据:
- 由于不再真正加载声音数据文件,而是直接引用已经加载的资源,关键是找到声音样本数据的位置。每个音频样本的偏移量存储在
asset
中,就像我们之前处理位图数据一样。 - 通过读取
HHAAsset
中的DataOffset
(数据偏移量),确定音频样本数据在资源包中的位置。 - 从文件基址开始,偏移到正确的音频样本数据位置,并根据
Sound->SampleCount
(样本数量)和每个样本的大小,逐步加载每个声道的数据。
- 由于不再真正加载声音数据文件,而是直接引用已经加载的资源,关键是找到声音样本数据的位置。每个音频样本的偏移量存储在
-
指向音频数据:
- 每个音频样本的指针都需要指向正确的内存地址,这个地址是根据上面计算出的偏移量得出的。
- 通过遍历每个声道并逐一填充音频数据,确保每个声道的数据都正确地加载到内存中。
-
完成数据加载:
- 最终,将加载好的音频数据填充到目标结构体中,替代之前使用的
debug load
函数。 - 这样一来,不需要再通过实际加载音频文件来处理音频数据,只需将数据指针指向已经加载的资源文件中的相应位置即可。
- 最终,将加载好的音频数据填充到目标结构体中,替代之前使用的
-
其他注意事项:
- 需要确保在加载过程中处理的音频样本数据不会超出预设的数组范围。
- 有必要调整数据类型,特别是将音频数据结构中的某些字段类型从
int
调整为int16
以适应实际的数据类型。
通过这些步骤,声音数据的加载就与位图数据的加载一致,避免了重复的文件加载操作,从而实现了更高效的数据管理和加载方式。
测试更改
在进行调试和修改过程中,发现了一些错误需要修复,但大体上已经完成了从资产文件而非位图文件加载资源的工作。这是为了更好地测试这个新的资产文件系统,而不再依赖单独的位图文件或其他文件。这种修改可能会成功,也可能会出现问题,游戏开发的过程中有时就会这样。
幸运的是,在进行这些调整之后,游戏似乎能够正常运行。声音能够正常播放,音效和音乐都没有问题,整体运行状况良好。这种情况通常很难预测,有时一切都能顺利进行,游戏似乎也恢复了正常。这次的成功为之前的失败做了补偿,让整个开发过程中的起伏有所平衡。
接下来需要做的就是继续完善代码,确保所有功能都能稳定运行。虽然目前一切看起来都很顺利,但依然需要对之前的错误进行修复,并为未来的开发做好准备。
位图显示不对修改 生成hha文件的代码
恢复速度
只加载所需内容,不加载整个资源文件
目前,我们的资产文件(asset file)加载方式存在一个非常明显的问题:我们在启动时一次性加载了整个资产文件,而这并不是我们想要的方式。
我们更希望的是流式加载(streaming)资产文件中的数据,也就是说按需加载我们需要的部分,而不是一次性将所有数据全部加载到内存中。这种方式不仅更高效,还能避免浪费大量内存,并且有助于加快游戏的启动速度。
实际上,我们已经提前编写了所有流式加载的代码,包括:
多线程支持(threaded),确保不会阻塞主线程。
流式加载机制,使我们能够根据需要读取资产文件中的指定数据。
但是目前的问题是,我们在平台层(platform layer)中,只提供了一次性加载整个文件的功能,而没有实现按需加载的能力。这显然不符合我们的目标,因此我们需要对平台层的文件加载进行改造,使其支持部分加载。
扁平化加载资源标签和资源
我们正在对代码进行一些简化和优化,主要目的是减少不必要的数据处理步骤,直接利用文件中加载的数据,从而提高加载效率并减少代码复杂度。
首先,我们注意到在代码中存在一个 asset_tag
,原本的处理方式是将文件中的 HHA_tag
加载进来后,再进行一遍转换,存储成 asset_tag
。但我们发现实际上没有必要进行这个转换,因为文件中的 HHA_tag
本身就可以直接使用,没有其他需要在加载时进行的注解或转换操作。因此,我们决定直接使用 HHA_tag
,将原本的 asset_tag
替换成 HHA_tag
,并删除 asset_tag
的定义,使所有引用 asset_tag
的地方直接使用 HHA_tag
,以此减少数据加载后的转换工作。
在实施这一改动时,我们面临一个选择:要么保留 asset_tag
的名称,使其指向 HHA_tag
的数据,要么直接使用 HHA_tag
的名称。考虑到便于区分文件数据和内存数据,我们最终选择直接使用 HHA_tag
的名称,这样可以更直观地知道这些数据直接源自文件,未经过额外处理。同时,这种方式也方便后续跟踪和管理文件数据。
完成这一修改后,我们进一步优化了加载过程。之前的逻辑是在加载数据后,为 asset_tag
分配内存空间,并将 HHA_tag
中的数据逐个拷贝到新的内存区域。而现在,我们完全跳过了这一步,直接将内存指针指向加载的数据,无需再进行内存分配和数据拷贝。这样就避免了额外的循环处理,大大简化了加载过程。
接着,我们开始思考是否可以对 HHA_asset
进行同样的处理。我们检查了当前的代码结构,发现 HHA_asset
被拆分成了 asset
和 asset slot
,但本质上 HHA_asset
已经具备了我们需要的数据,因此也完全没有必要进行额外的转换或复制。于是,我们决定直接将 asset
替换成 HHA_asset
,并移除原有的 asset
结构及其内存分配。这样加载数据时,只需将指针指向加载的数据,无需再遍历和转换,从而进一步减少了不必要的处理步骤。
具体来说,我们将原本的内存分配和数据拷贝操作删除,使 HHA_asset
直接指向文件中的数据,并将所有引用 asset
的地方改成引用 HHA_asset
,同时也移除了对应的内存分配和遍历循环。这意味着在加载完成后,内存中存储的数据就是文件中的原始数据,无需任何中间转换或处理,从而大幅减少了加载时间和内存开销。
通过这一系列改动,我们成功地去除了两块不必要的内存分配和数据转换工作:
- asset_tag:原本需要加载
HHA_tag
后转换成asset_tag
,现在直接使用HHA_tag
。 - asset:原本需要加载
HHA_asset
后转换成asset
,现在直接使用HHA_asset
。
最终效果是,数据加载过程中不再需要进行循环遍历和内存分配,直接将内存指针指向文件中的数据,从而极大地减少了加载时间和内存使用。整体逻辑变得更加清晰简洁,也避免了不必要的重复处理,提升了程序的执行效率。
资源类型不能扁平化加载
在对数据加载进行优化的过程中,我们注意到有一部分数据无法直接进行扁平化加载(flat load),需要进行额外的处理。这是因为该部分数据的存储方式不同,它不是固定的,而是动态变化的,因此不能像之前的 HHA_tag
或 HHA_asset
一样直接将内存指向文件中的数据。我们需要对这部分数据进行额外的处理,以确保数据加载和使用的正确性。
我们之所以无法对其进行扁平化加载,主要是因为该数据结构在文件中的存储形式决定了它的用途。它被设计成允许我们动态指定需要添加的数据内容,因此无法简单地将其映射到内存中直接使用。在加载该数据时,我们需要进行一定的解析和转换,以便正确地将其应用到内存中的数据结构中,而不是直接指向文件中的数据。
同时需要注意的是,即便我们目前成功地对 HHA_tag
和 HHA_asset
进行了扁平化加载,但在未来,随着需求的变化,该方式可能也会发生变化。例如,我们可能会加载多个文件,而不是像现在这样只加载一个文件。在那种情况下,如果我们继续沿用当前的做法,直接将内存指向文件数据,就可能出现问题。因为加载多个文件时,我们可能会创建一个更大的内存数组来容纳所有数据,而不是直接映射到文件中的数据地址。因此,未来我们可能需要对这部分逻辑进行调整,以适应多文件加载的场景。
接下来我们计划继续优化加载过程,目前的加载方式是一次性将整个文件的内容加载到内存中,但这种方式并不理想。我们希望实现的目标是避免不必要的内存消耗和数据加载,以便在加载过程中更加高效。因此,我们将着手调整加载逻辑,使其更加灵活和高效,而不仅仅是简单地将整个文件一次性加载到内存中。
定义文件API
接下来我们开始考虑如何进一步优化数据加载流程,使其更加灵活和高效。目前的加载方式是直接将整个文件一次性加载到内存中,但这并不是我们最终想要的方式。我们希望的是能够支持按需加载,即根据需求动态加载文件中的部分数据,而不是全部加载,从而减少内存占用并提高加载效率。
我们设想的目标是,假设我们拥有一个包含所有资源的超大文件,我们希望在需要时只加载其中的一小部分,并仅处理这一部分数据,而不是把整个文件一次性加载到内存中。这种按需加载的方式将使内存消耗最小化,同时提升加载速度,并为后续的文件管理提供更高的灵活性。
现在我们已经到了需要定义一个更加现实的文件 API的阶段,这个 API 将是我们真正用于加载资源的核心接口。在早期开发阶段,我们刻意没有去定义这个文件 API,因为当时的开发重心在于搭建基础的加载框架,我们并不清楚最终的使用场景,因此没有急于定义 API,而是优先开发了加载代码。但现在不同了,我们已经基本完成了基础加载流程并确定了资源使用的模式,因此是时候正式定义这个文件 API,使其支持按需加载模式。
在此之前,我们采用的加载方式是直接将整个文件加载到内存,然后遍历解析数据。这种方式的弊端非常明显:
- 内存占用巨大:如果文件非常庞大,例如包含成千上万个资源,将整个文件加载到内存显然是不现实的。
- 加载时间长:即便我们只需要使用文件中的一小部分资源,但我们仍然需要等待整个文件加载完毕才能使用,导致加载时间过长。
- 数据解析复杂:整个文件加载后,我们还需要遍历所有数据进行解析,即便部分数据并不需要,也必须解析所有内容。
因此,我们计划改为增量加载(piecemeal loading)的方式,即根据需要加载文件中的一小块数据,处理完毕后再根据需要加载下一块数据。这种方式具有以下优势:
- 最小化内存消耗:只加载当前需要的数据,避免占用大量内存。
- 快速响应:无需等待整个文件加载完成,能够更快地提供所需资源。
- 灵活性更强:可以动态控制加载哪些数据,使资源加载更具针对性。
我们在设计这个文件 API 时,需要特别关注以下几点:
- 文件指针的控制:确保我们能够指定文件中某个位置的数据进行加载,而不是从头到尾一次性加载。
- 数据块大小:定义合理的数据块大小,确保既不浪费内存又能有效加载资源。
- 异步加载机制:允许加载和处理同时进行,以进一步减少加载时间。
这也是我们之前刻意推迟设计文件 API 的原因。在加载框架不成熟时,定义文件 API 很容易导致设计错误或不适配的情况。而现在,我们对资源加载的需求更加清晰,因此可以更有针对性地设计一个可用于实际产品的文件 API,使资源加载更符合实际需求并确保性能最优。
接下来我们将重点放在设计文件 API上,使其能够支持按需加载资源,并确保加载过程高效且灵活。这将成为我们资源加载系统的重要组成部分,决定了最终产品在加载资源时的性能和可扩展性。
良好API的编写方法:先写用法代码
在设计文件 API 之前,我们首先需要明确一个非常重要的开发原则:始终先编写使用代码(usage code),再设计 API。这是确保 API 设计优雅、实用且符合实际需求的关键步骤。通过先编写使用代码,我们能够直观地了解 API 在真实场景下的需求,避免设计出冗余、复杂或难以使用的 API。然而,很多开发者在设计 API 时总是直接从 API 本身入手,最终导致 API 难以使用且不符合实际需求,因此我们必须遵循先写使用代码的原则,从需求出发来反推 API 的结构和功能。
目前,我们的目标是设计一个可用于实际产品的文件 API,该 API 需要满足的核心需求包括:
-
支持加载多个资源文件:我们需要支持加载多个资产文件(asset files),而不是仅仅加载单个文件。这是因为:
- 文件系统限制:在某些文件系统(如 FAT32)中,单个文件的大小上限为 4GB,因此我们不得不将超大资源文件拆分成多个小文件。
- 补丁文件(Patch Files):在游戏开发中,我们可能需要对已发布的游戏内容进行更新或修复,因此需要加载一个补丁文件(Patch File)来替换或覆盖原始文件中的某些资源。
- 可选内容(DLC):如果我们发布了游戏的 DLC(可下载内容),该内容会作为一个独立的文件存在,并且用户可能未下载该内容。因此,API 必须能够动态检查和加载这些可选文件。
-
按需加载:我们不希望一次性将所有文件的数据全部加载到内存中,而是希望根据需求加载数据,以减少内存消耗并加速加载过程。
-
动态合并资源:由于我们可能存在多个文件,因此需要 API 支持动态合并这些文件的数据。例如,如果某个补丁文件(Patch File)提供了某个资源的新版本,我们需要确保加载文件时优先使用该补丁文件的数据,而不是原始数据。
-
跨平台兼容性:由于该 API 最终会集成到游戏引擎中,我们需要确保该 API 能够在不同操作系统(Windows、Linux、MacOS 等)中运行,并能够读取操作系统文件系统中的资源文件。
假设我们正在开发游戏的最终版本
为了更好地理解该 API 的需求,我们假设此时正在开发游戏的最终版本,并需要加载多个资源文件。这些资源文件包括:
- 主资源文件(Main Asset File):包含游戏的主要内容,如贴图、声音、模型等。该文件非常庞大,超过了 FAT32 的单文件大小限制,因此被拆分成多个文件。
- 补丁文件(Patch File):包含对主资源文件的修复或更新。例如,如果我们在发布游戏后发现某些资源有问题,我们可以通过补丁文件修复这些内容。
- DLC 文件:如果玩家购买了 DLC,则需要加载该文件,否则不加载。因此,API 需要检测该文件是否存在,并决定是否加载。
设计文件 API 的初步思路
我们希望该 API 的使用方式尽可能简单直观,因此我们设想 API 的使用代码可能是这样:
- 请求加载所有资源文件:通过 API 获取文件系统中所有的 HHA 文件(资产文件)。
- 合并资源:根据文件的优先级或时间戳,自动合并文件中的资源,以确保最新的资源覆盖旧版本资源。
- 按需加载:只有在使用某个资源时才真正加载该资源的数据。
例如,API 的使用代码可能如下:
PlatformFileGroup group = PlatformGetAllFilesOfType("HHA");
for (int i = 0; i < group.fileCount; ++i) {
PlatformFile file = group.files[i];
AssetFileHandle handle = OpenAssetFile(file);
LoadAssets(handle);
}
在这里:
- PlatformGetAllFilesOfType:用于获取指定类型(如 HHA)的所有文件。
- OpenAssetFile:打开指定的资产文件并返回文件句柄。
- LoadAssets:从文件句柄中按需加载资源数据,并将其合并到内存中。
动态合并文件
为了确保加载的文件数据是最新的,我们需要实现一种动态合并机制。例如:
- 如果补丁文件存在,则优先加载补丁文件中的资源,并替换主文件中的旧资源。
- 如果 DLC 文件存在,则加载 DLC 文件中的资源,并将其追加到资源列表中。
- 如果文件缺失,则跳过该文件,不影响其他资源的加载。
下一步工作
接下来,我们将根据上述使用代码的需求,正式设计该文件 API 的核心结构。该 API 将包含以下几个核心功能:
- 文件组(File Group):表示一组文件的集合,用于管理所有资源文件。
- 文件句柄(File->Handle):表示打开的文件句柄,用于访问文件内容。
- 资源加载(Load Asset):按需加载文件中的资源内容,并将其合并到内存中。
- 资源优先级(Asset Priority):在加载多个文件时,根据优先级(如时间戳、文件顺序等)决定使用哪个版本的资源。
最终,该 API 将成为我们游戏资源加载系统的核心组件,使其具备高效、灵活、动态合并资源的能力,从而适应各种复杂的加载场景。
平台层负责知道资源文件的位置
为了实现一个更有效的文件加载系统,我们需要进一步优化并具体化文件加载的 API 设计。具体来说,这个系统旨在处理多个资源文件,特别是在游戏中可能有多个来源的文件,比如硬盘和光盘(在控制台平台上)。这里的目标是让游戏能够自动从不同的位置获取所需的文件,而不需要游戏代码本身去关心平台的具体细节。这些平台细节,比如文件存放的位置或从哪个设备读取文件,应该由平台层(Platform Layer)来处理。
设计目标
-
文件类型获取:我们首先需要通过某个 API 获取指定类型的文件。例如,如果我们需要获取所有扩展名为
.hha
的文件,平台层应该能够处理文件路径和加载所有相关的文件。我们希望 API 只需要简单地通过文件类型(如 “HHA”)来获取文件,而不关心这些文件是否分布在硬盘、DVD 或其他媒介上。 -
文件组(File Group):一旦文件类型被指定,平台层会返回所有符合条件的文件。为了进一步处理这些文件,我们将把它们组织成一个文件组(File Group)。这个文件组包含一个文件数组和文件的数量,方便我们后续逐一处理每个文件。
-
打开和读取文件:每个文件将有一个对应的“文件句柄”,用于访问文件内容。通过文件句柄,我们能够对文件进行操作,比如读取数据。在这里,我们也需要考虑文件的有效性(是否成功打开)和是否能正确读取。
操作流程
-
获取文件组:首先,我们会从平台层获取一个文件组,包含所有需要加载的文件。假设我们传入的文件类型是
.hha
,平台层返回的可能是硬盘中的主资源文件、补丁文件或 DLC 文件的集合。 -
遍历文件:接下来,我们遍历文件组中的每个文件,尝试打开它们。每次打开一个文件时,我们会检查文件是否成功打开。
-
读取文件头信息:如果文件成功打开,接下来我们就读取文件的头信息。这个头信息包含文件的偏移量、大小等数据,我们将使用这些信息来加载文件中的具体内容。
-
错误处理:在文件打开和读取过程中,可能会遇到各种错误,比如文件损坏、读取失败等。因此,我们需要处理这些错误,确保每个步骤都能正确执行。具体来说,当文件句柄无效时,我们应该通过适当的方式检查文件是否能成功打开,并尝试重新加载或跳过文件。
-
文件合并:如果文件打开并读取成功,接下来我们需要将这些文件合并在一起,构建出一个完整的资源集。这可能涉及到合并多个文件的内容,特别是在多个文件之间存在版本冲突时,确保最新的文件覆盖旧版本数据。
代码实现
假设我们已经从平台层获取了一个文件组,接下来的操作将是打开这些文件并读取它们的数据。例如,代码可以是这样的:
platform_file_group group = PlatformGetAllFilesOfType("HHA");
Assets->FileCount = FileGroup.FileCount;
Assets->Files = PushArray(Arena, Assets->FileCount, asset_file);
for (int FileIndex = 0; FileIndex < group.fileCount; ++FileIndex) {
asset_file *File = Assets->Files + FileIndex;
File->Handle = PlatformOpenFile(FileGroup, FileIndex);
PlatformReadDataFromFile(File->Handle, 0, sizeof(File->Handle), &File->Header);
if (PlatformFileErrors(File->Handle)) {
} else {
InvalidCodePath;
}
}
文件句柄与错误处理
在文件操作过程中,我们需要通过文件句柄来确保每个文件的操作都是有效的。我们将通过检查文件句柄的有效性,并确保我们能够成功读取文件的数据。如果某个文件无法打开或读取,我们将记录错误,并决定是跳过该文件还是采取其他补救措施。
后续考虑
在设计中还需要考虑一些细节问题:
-
文件头信息的存储:有时候,文件的头信息非常重要(例如,文件偏移、大小等),这些信息可能在文件读取过程中被多次使用。因此,是否将这些信息保存在内存中或每次读取时都获取,都需要进一步评估。
-
性能优化:目前的设计假设我们一次性读取文件的所有数据。在实际应用中,可能需要根据实际需求来优化,比如只按需加载数据块,避免加载过多不必要的数据。
-
文件合并与优先级:如果我们有多个文件,其中一些可能是补丁文件或 DLC 文件,需要根据优先级处理合并。我们可能需要定义一种策略,决定在多个文件之间如何合并资源,例如补丁文件优先于原始文件。
总结
总体来说,这种设计方法通过清晰地分离平台层和游戏逻辑层,确保游戏能够在不同平台和文件系统中灵活处理资源文件。通过定义合理的文件 API,我们可以有效地加载和管理游戏中的所有资源,同时也具备扩展性,支持未来可能的优化或新需求。
允许所有文件操作并仅检查一次错误
为了进一步完善文件加载 API,我们需要优化错误处理机制,使得文件加载过程更加简洁高效,同时确保在出现错误时能够妥善处理。我们希望文件操作过程尽量保持简单,而不必过度关注错误发生的具体位置,只要确保最终结果是可用的即可。
错误处理的核心理念
在文件加载过程中,可能会遇到各种错误,比如:
- 文件未找到:某个需要加载的文件不存在。
- 文件损坏:文件存在但无法正常读取数据。
- 文件句柄无效:文件打开后未能获取有效的句柄。
- 数据读取失败:文件打开后,读取数据时发生错误。
在这些情况下,我们不打算详细区分错误发生的具体步骤(如文件打开失败还是数据读取失败),因为最终结果是相同的——我们无法获得该文件的内容。因此,我们决定采用更简化的错误检查机制,即:
- 只需检查最终结果:在操作完成后,通过一个统一的函数
PlatformNoFileErrors()
来检测整个操作过程中是否发生了错误。 - 不关心错误位置:无论是打开文件失败、读取头失败还是读取数据失败,只要最终结果无效,我们都认为文件读取失败即可。
- 无需记录详细错误信息:因为即使知道是在哪个步骤出错,我们也无法采取其他的补救措施(比如如果文件打不开,我们无法强行打开它),所以无需关注错误细节。
简化的错误处理流程
我们的流程将会是:
- 尝试打开文件:对于文件组中的每个文件,我们逐个尝试打开它。
- 读取文件头:如果文件成功打开,则尝试读取文件头数据。
- 检查错误:在所有文件操作完成后,通过
PlatformNoFileErrors()
检查是否发生了任何错误。 - 处理错误:如果发现错误,则立即中断该文件的加载,并记录错误信息(如向用户提示该文件可能已损坏)。
- 继续处理其他文件:即使某个文件加载失败,也不影响我们继续加载其他文件。
错误处理的代码设计
我们将文件加载代码修改为类似下面的形式:
platform_file_group FileGroup = PlatformGetAllFilesOfType("hha");
Assets->FileCount = FileGroup.FileCount;
Assets->Files = PushArray(Arena, Assets->FileCount, asset_file);
for (uint32 FileIndex = 0; FileIndex < FileGroup.FileCount; ++FileIndex) {
asset_file *File = Assets->Files + FileIndex;
File->Handle = PlatformOpenFile(FileGroup, FileIndex);
PlatformReadDataFromFile(File->Handle, 0, sizeof(File->Handle), &File->Header);
if (PlatformNoFileError(File->Handle)) {
} else {
InvalidCodePath
}
}
为什么采用这种错误处理方式
-
简化代码逻辑:我们不需要在每个操作步骤都进行错误检查,只需要在所有操作结束后统一检查错误即可。这样可以让代码更简洁。
-
避免不必要的错误信息:在实际运行中,如果文件出现问题,我们只需要知道“该文件无法使用”,而不需要关心“为什么无法使用”。因此,不记录详细错误信息可以避免不必要的复杂性。
-
减少无意义的恢复尝试:如果文件无法打开或无法读取,我们也无法采取补救措施(比如修复文件或重新下载)。因此,与其在代码中处理各种情况,不如直接跳过有问题的文件。
-
保持代码一致性:所有的文件操作都使用同样的错误检查方式——在操作结束后统一检查,确保 API 接口一致性。
用户通知机制
虽然我们在底层代码中不会关心错误的具体类型,但在用户层面,我们需要提供一种方式通知用户文件加载失败。例如:
- 如果是主资源文件(如
main.hha
)损坏,可能导致游戏无法启动,我们需要提示用户重新安装游戏。 - 如果是补丁文件(如
patch.hha
)损坏,可能导致部分资源无法显示,我们需要提示用户修复文件。 - 如果是 DLC 文件(如
dlc.hha
)损坏,我们需要提示用户重新下载 DLC。
因此,在 UI 层面,我们应该添加一个错误提示功能,例如:
if (PlatformFileHasErrors(fileHandle)) {
ShowErrorMessage("The file %s is corrupted. Please check your installation.", file.name);
}
未来当我们有更完整的 UI 系统时,可以直接通过弹窗或状态栏提示用户,而不是仅仅通过日志记录错误。
进一步优化
在未来的迭代中,我们还可以进一步优化:
- 自动修复文件:如果检测到文件损坏,可以自动触发文件验证流程(如通过 Steam 验证文件完整性)。
- 部分加载:如果文件损坏但部分数据可用,可以尝试仅加载可用数据,而不是完全放弃该文件。
- 错误恢复机制:在文件加载失败时,记录该文件的信息,并提供恢复选项(如重新下载或重建索引)。
合并资源文件的内容
在加载所有资源文件的过程中,我们不仅需要检查文件是否存在、是否损坏或是否可读取,还需要在文件读取成功时,统计文件中的一些关键信息,比如标签数量(TagCount)和资源数量(AssetCount),以便后续资源的整体布局和加载工作能够顺利进行。此外,我们还需要在检测到版本不兼容或者其他错误时,及时进行标记,以确保不会继续加载错误数据,并且能够适当地提示用户。
一、统计资源和标签的数量
在所有资源文件中,每个文件都会包含一些标签(Tags)和资源(Assets),我们希望在成功加载每个文件时,能够统计这些标签和资源的总数。这样在加载过程中,我们就能提前知道:
- 总共有多少个标签(Tag Count)。
- 总共有多少个资源(Asset Count)。
- 根据这些数据,预分配内存空间,以便将所有的标签和资源整合到一起。
为此,我们需要在文件加载过程中,对每个文件的Header进行解析,并获取标签数量和资源数量。我们设计的流程如下:
- 初始化计数器:在开始加载文件之前,将
TotalTagCount
和TotalAssetCount
设为0。 - 遍历所有文件:逐个打开资源文件并读取文件头。
- 累加数量:如果文件有效(无错误、版本兼容),则提取该文件的
TagCount
和AssetCount
,并将其加到总计数器中。 - 继续下一个文件:无论当前文件是否加载成功,都继续处理下一个文件,以保证最大程度加载可用数据。
- 最终统计结果:所有文件处理完成后,
TotalTagCount
和TotalAssetCount
就是所有资源文件中总共有的标签和资源数量。
二、处理文件版本不兼容的情况
在加载资源文件时,不同版本的资源文件结构可能会发生变化,因此我们必须检查文件版本号。如果文件的版本号与当前引擎所支持的版本不兼容,则:
- 立即标记文件错误,避免加载该文件的数据。
- 继续处理下一个文件,保证最大程度加载可用资源。
- 记录错误日志,方便开发者或用户排查问题。
- 未来可以考虑增加修复机制,例如通过联网更新、重新下载等方式修复损坏的资源。
我们决定在加载过程中增加以下验证:
- 文件格式版本检查:检查文件头中的
Version
是否与当前版本匹配。 - 文件结构验证:检查文件头中
HeaderSize
或其他关键信息是否合法。 - 文件内容完整性检查:在读取数据时,检测数据长度是否匹配预期值。
如果发现文件版本不兼容,我们可以直接调用 PlatformFileError()
来标记该文件为不可用,避免继续使用该文件的数据。
三、将错误状态绑定到文件句柄
在加载文件的过程中,如果发现文件存在问题(如版本不兼容、数据读取失败等),我们需要将错误状态直接绑定到文件句柄上。这样可以确保:
- 在任何地方使用该文件句柄时,都可以检测到该文件是否存在错误。
- 通过统一的错误检查函数,避免重复检查同一文件的错误状态。
- 简化错误处理流程,使代码更清晰易维护。
为此,我们计划增加以下操作:
- 在文件读取失败时,立即标记文件句柄为错误状态:
- 在检测到版本不兼容时,立即标记文件句柄错误:
if(Header->Version == HHA_VERSION){ Assets->TagCount += Header->TagCount; Assets->AssetCount += Header->AssetCount; } else { InvalidCodePath; PlatformFileError(File->Handle); }
- 通过统一函数检测文件是否有错误:
PlatformFileError();
这样,即使文件在不同阶段发生错误,我们也不需要关心错误发生的具体位置,而只需在最终阶段进行统一错误检查。
四、汇总资源数据
在确保文件没有错误并且版本兼容后,我们就可以开始汇总资源数据了。我们主要需要统计:
- 标签数量:文件中的
TagCount
。 - 资源数量:文件中的
AssetCount
。
并将这些数量加到全局计数器中:
Assets->TagCount += Header->TagCount;
Assets->AssetCount += Header->AssetCount;
五、优化文件合并(未来可优化)
在所有文件的数据汇总后,我们可能还需要将所有资源数据合并,以便统一存储和加载。这涉及到:
- 合并标签:将所有文件中的标签合并为一个大的标签数组。
- 合并资源:将所有文件中的资源信息合并为一个大的资源数组。
- 合并资源类型:将所有文件中的资源类型合并,避免重复加载相同资源。
目前,我们计划使用最简单的线性合并方式:
- 直接预分配内存:根据
TotalTagCount
和TotalAssetCount
分配内存。 - 依次拷贝数据:遍历每个文件,将标签和资源拷贝到预分配的内存中。
- 建立全局索引:为每个资源分配唯一的索引,以便快速访问。
在未来,如果资源文件数量变多或合并速度成为瓶颈,我们可以优化合并策略:
- 采用多线程合并:多个线程同时合并不同文件的数据。
- 跳表/哈希表优化:对资源进行哈希索引,提升查找效率。
- 基于内存映射文件:将所有文件映射到内存中,避免多次读取磁盘。
另一个集中错误处理的机会
在处理资源文件加载的过程中,我们希望优化错误处理机制,使其更加简单和统一,同时确保加载的资源数据能够高效地分配和存储。我们意识到在文件解析过程中,错误处理的方式可以进一步简化,并且数据的分配和加载流程也需要做一些调整,以保证内存使用的高效性和数据完整性的最大化。
一、优化错误处理机制
在处理资源文件的过程中,可能会遇到各种错误,比如:
- 文件头的 Magic Number 无效:说明文件可能已经损坏或者格式错误。
- 文件版本号不兼容:当前引擎版本无法解析该文件。
- 文件读取失败:由于某些原因(如磁盘损坏、文件权限等)导致文件无法正常读取。
我们决定将所有的错误都通过统一的错误处理系统进行处理,而不是在每个出错点都单独处理错误。这种方式的好处包括:
- 错误处理更加集中和清晰,减少了重复代码。
- 便于后期扩展错误提示,如果未来我们想添加 UI 弹窗或者日志系统,只需要在错误处理系统中添加即可。
- 调试时更容易定位错误,因为所有的错误都会触发断言(Assert),并指向具体的错误位置。
具体实现
- 在读取文件头时进行统一错误检测:
if (Header->MagicValue != HHA_MAGIC_VALUE) { PlatformFileError(File->Handle, "hha file has an valid magic value"); } if (Header->Version > HHA_VERSION) { PlatformFileError(File->Header, "hha file is of a later version"); }
- 在错误出现时统一调用 PlatformFileError():
- 所有的错误在最终检查时统一处理:
if (PlatformNoFileError(File->Handle)) { Assets->TagCount += Header->TagCount; Assets->AssetCount += Header->AssetCount; } else { InvalidCodePath }
这样做的最大优势是:
- 文件错误统一记录,不需要在每个地方单独处理。
- 调试环境中自动触发断言,方便我们快速发现问题。
- 最终版本中用户不会看到断言,但仍然可以通过错误日志获知问题。
二、确保内存分配足够并进行数据加载
在文件解析过程中,我们需要先统计资源数据的总量,然后一次性分配内存,最后将所有资源数据加载到内存中。我们这样设计的主要原因是:
- 避免频繁分配内存:如果在读取每个文件时都分配内存,可能会导致大量内存碎片,影响性能。
- 最大限度减少内存占用:通过预先统计所有数据量,精确分配内存,避免浪费。
- 便于后续数据合并:所有数据分配在连续内存块中,后续合并操作更简单。
三、分配内存
统计完资源总量后,我们可以通过**PushArray()**一次性分配所有数据所需的内存:
Assets->Assets = PushArray(Arena, Assets->AssetCount, hha_asset);
Assets->Slots = PushArray(Arena, Assets->AssetCount, asset_slot);
Assets->Tags = PushArray(Arena, Assets->TagCount, hha_tag);
这里的 PushArray()
是一个内存分配器,它可以根据指定的数量和类型一次性分配所需的内存,从而避免大量的小块内存分配,提升内存管理效率。
告诉操作系统我们已完成对hha文件列表的使用,以便它可以释放任何相关资源
在处理资源文件加载时,我们意识到在文件扫描和加载过程中,可以通过一种更加优雅的方式与操作系统交互,从而避免占用不必要的系统资源,同时确保数据的完整性和一致性。我们希望在加载文件时,能够最大限度地减少内存消耗,并且在出现错误或异常情况下,能够优雅地进行资源释放和错误检测。
一、优化文件扫描流程
在加载资源文件之前,我们需要先获取所有符合特定类型的文件(例如所有 HHA 文件),但在获取文件列表时,操作系统可能需要消耗一定的系统资源(如文件句柄、内存缓冲区等)。因此,我们决定将文件获取的操作封装成一个获取文件的生命周期操作,确保:
- 在获取文件列表时,申请操作系统资源。
- 在文件加载完成后,释放操作系统资源。
文件获取生命周期
我们将文件获取的操作封装成以下形式:
platform_file_group FileGroup = PlatformGetAllFilesOfTypeBegin("hha");
PlatformGetAllFilesOfTypeEnd(FileGroup);
这里的 PlatformGetAllFilesOfTypeBegin()
和 PlatformGetAllFilesOfTypeEnd()
具有以下作用:
- Begin: 向操作系统申请文件列表,同时锁定相应资源。
- End: 加载完所有文件后,释放文件句柄和相关内存资源,防止资源泄露。
这样可以保证:
- 减少内存消耗:只在文件扫描时占用资源,加载完成后立即释放。
- 提高加载效率:在文件扫描过程中可以获取所有文件并快速遍历加载。
- 避免资源泄露:如果不调用
PlatformGetAllFilesOfTypeEnd()
,文件句柄将持续占用,导致内存泄漏。
二、分配内存存储资源数据
在获取所有文件句柄后,我们需要为所有资源分配内存,以便将数据存储到一个连续的内存块中。我们采取的做法是:
- 先统计所有资源总量:遍历所有文件头,计算资源数量。
- 一次性分配所有内存:避免频繁内存分配,减少内存碎片。
- 保证内存布局连续:有助于后续快速加载和访问。
✅ 统计资源总量
uint32 AssetCount = 0;
uint32 TagCount = 0;
for (uint32 FileIndex = 0; FileIndex < Assets->FileCount; ++FileIndex) {
asset_file *File = Assets->Files + FileIndex;
}
这里我们统计了:
- 资源数量(totalAssetCount)
- 标签数量(totalTagCount)
通过多次遍历所有文件,将资源按类型进行组织
在处理资源数据加载时,我们考虑到需要根据**资源类型(Asset Type)**对数据进行分类整理,以便在加载后能够快速访问和使用这些数据。因此,我们决定采用一种相对低效但直观的方式来进行资源分类,即:
for (uint32 AssetTypeID = 0; AssetTypeID < AssetCount; ++AssetTypeID) {
for (uint32 FileIndex = 0; FileIndex < Assets->FileCount; ++FileIndex) {
asset_file *File = Assets->Files + FileIndex;
}
}
一、遍历资源类型ID进行数据分类
我们首先确定所有可能存在的资源类型ID,然后逐个遍历资源类型ID,在遍历每个资源类型ID时,再遍历所有文件,查找是否存在该资源类型的数据,并将其归类。这种方式的特点是:
- 按照资源类型进行归类:确保同类型的资源存放在一起。
- 避免多次遍历同一文件:但可能导致一些重复操作,效率稍低。
- 保证最终数据按类型排序:便于后续访问和渲染。
在这里,我们的核心思路是:
- 第一层循环:遍历所有资源类型ID。
- 第二层循环:遍历所有文件。
以一种超级简陋的方式来做这个
以上内容主要描述了一种处理资产文件(asset files)的方法,重点是将不同类型的资产(asset type)进行合并,并将它们连续地存放在一个数组中,以便后续使用。
首先,处理的核心目标是:针对每一种资产类型(asset type ID),将所有文件中属于该类型的资产合并在一起,连续存放。这样做的目的是方便后续操作,使访问同一类型的资产时更加高效。同时提到目前的实现方式比较粗糙(janky),但由于文件数量较少,因此即使效率不高,整体执行速度仍然会非常快,因此短期内不会专门去优化,但未来如果文件数量激增,可能需要考虑一种更加优雅、可扩展的解决方案。
实现思路
- 遍历所有资产文件,针对每个文件中的资产类型(asset type),提取所有该类型的资产数据,然后将其连续存放在目标数组中。
- 每个资产文件中都包含一个文件头(File->Header),其中记录了该文件中所有资产类型的数量 (
AssetTypeCount
) 以及对应的资产类型数据 (AssetTypeArray
)。 - 在处理时,需要首先读取文件头以获取
AssetTypeCount
,然后分配内存存储这些资产类型的数据 (AssetTypeArray
)。 - 在内存分配方面,提到应该将这些数据放置在临时内存(transient arena)中,因为这些数据只在加载过程中使用,后续不需要持久保留,所以不应该占用长期内存。
具体实现步骤
1. 读取文件头
- 对于每个文件,首先需要读取文件头 (
File->Header
),获取该文件中包含多少种资产类型 (AssetTypeCount
)。 - 文件头的数据结构 (
File->Header
) 包含了资产类型数组 (AssetTypeArray
) 的大小 (AssetTypeCount
),因此可以计算出所需内存大小:uint32 AssetTypeArraySize = File->Header.AssetTypeCount * sizeof(hha_asset_type);
- 在读取文件头时,可以使用
PlatformReadDataFromFile
这一函数,从文件句柄 (File->Handle
) 中读取数据,并将数据存入内存中。
2. 分配内存存储资产类型数组
- 在读取文件头后,需要动态分配内存存储
AssetTypeArray
,其大小取决于AssetTypeCount
乘以单个资产类型结构体大小 (HHAAssetType
)。 - 分配内存时,采用临时分配 (
transient arena
),因为这些数据在加载完成后就不再需要,减少长期内存占用。 - 使用
push_array
或push_size
函数分配内存:File->AssetTypeArray = (hha_asset_type *)PushSize(Arena, AssetTypeArraySize);
- 然后将文件头中记录的
asset type
数据读入内存。
3. 将同一类型的资产数据合并
- 在读入文件头和
AssetTypeArray
后,接下来的工作就是合并同一类型的资产数据。 - 假设有多个文件,每个文件都有不同的
AssetTypeArray
,需要遍历所有文件,找到属于目标AssetTypeID
的资产数据,将其拷贝到最终的目标数组中。 - 合并时使用的是块加载(block load),将连续的数据块直接读取到内存中,以提高加载效率。
在asset_file结构体中包含TagBase,以重新定位其标签
以上内容主要描述了在合并多个资产文件(asset files)的过程中,需要解决的一个标签索引重定位 (tag rebasing) 问题,以及在加载资产时的具体操作流程和一些边界处理。
核心问题在于:每个资产文件的标签数组 (tag array) 的索引都是从 0 开始,但在合并多个资产文件时,这些标签数组需要拼接在一起,因此标签的索引必须重新定位 (rebase),否则合并后的标签索引会出现错误。这带来了额外的复杂性,即必须在加载过程中修改资产数据结构中的标签索引,而不是直接做一次性块加载 (flat load)。
1. 为什么需要标签重定位 (tag rebasing)?
在每个资产文件 (asset file
) 中,都会包含一个标签数组 (tag array),用于描述该文件内各个资产的标签信息。
在单个资产文件中,所有的标签索引都是从 0 开始,假设:
- 第一个文件有 10 个标签,则索引是 [0, 9]
- 第二个文件有 5 个标签,则索引是 [0, 4]
- 第三个文件有 7 个标签,则索引是 [0, 6]
如果直接合并这三个文件,最终标签数组的索引应该是:
- 第一个文件的标签 -> [0, 9]
- 第二个文件的标签 -> [10, 14]
- 第三个文件的标签 -> [15, 21]
但目前的实现中,每个文件的标签索引都是从 0 开始,如果直接合并,索引会出现重复冲突,导致访问错误。
因此在合并文件时,必须对标签索引重新定位 (rebase),让它们在合并后的标签数组中保持连续。
2. 为什么修改标签索引会比较麻烦?
理想情况下,我们希望直接加载资产数据 (flat load),即:
- 将文件中所有资产数据直接读入内存,避免额外操作;
- 不修改内存中的数据结构,保持原始文件结构;
但是,由于标签索引必须重新定位,这意味着:
- 必须修改内存中资产数据结构的标签索引;
- 必须在加载过程中遍历所有资产,将标签索引调整为正确的位置。
这就打破了我们希望的一次性加载 (flat load),引入了加载时数据修改,使得加载过程变得复杂。
3. 标签索引重定位的解决思路
为了解决标签索引重定位 (tag rebasing) 问题,我们需要在加载资产数据时,将其标签索引进行偏移调整,使其适应合并后的标签数组。
你通常是如何决定一个变量是指针还是普通变量的?
在编程中,决定一个变量是使用指针传递还是按值传递,通常取决于两个主要原因。
第一个原因:如果担心拷贝的开销太大
当我们定义一个结构体 (struct) 时,如果这个结构体非常小,比如只有一个或两个成员变量,那么按值传递 (by value) 是完全可以接受的,因为它的拷贝开销非常低。但是,如果结构体很大,比如有 32 个、1024 个或更多的成员变量时,按值传递就会非常昂贵,因为在函数调用时,整个结构体的数据都会被拷贝一份。
举个简单的例子:
struct MyStruct {
int X;
};
void Foo(MyStruct S) {
S.X = 5;
}
void Bar(MyStruct S) {
S.X += 5;
}
假设 Foo
函数设置 X
为 5,然后 Bar
将其增加 5。在没有优化的情况下,编译器在调用 Bar
时会将 MyStruct
的所有内容全部拷贝一份,这意味着如果 MyStruct
非常大,拷贝开销会非常巨大。
如果我们将 MyStruct
修改为一个非常大的结构体,比如包含 1024 个整数:
struct MyStruct {
int Data[1024];
};
此时,编译器在调用 Bar
时需要拷贝 4096 字节(1024 * 4 字节)的数据,显然非常耗费性能。在汇编层面,可以看到 rep movs
指令的出现,该指令专门用于大量内存拷贝操作,说明编译器正在执行大量内存复制工作。
如果我们改成使用指针传递:
void Bar(MyStruct* S) {
S->X += 5;
}
此时,函数调用时只需要传递一个指针 (地址),也就是 4 字节或 8 字节(取决于 32 位或 64 位平台),而不需要拷贝整个结构体的数据。我们观察汇编指令,会发现 rep movs
已经消失,取而代之的是直接传递一个指针,从而极大地减少了内存拷贝的开销。
因此,第一个使用指针的原因是:
当结构体足够大时,按值传递开销过高,此时应该使用指针传递以减少内存拷贝的开销。
第二个原因:如果需要修改原始数据
假设我们有如下代码:
struct MyStruct {
int X;
};
void Foo(MyStruct S) {
S.X = 5;
}
void Bar(MyStruct S) {
S.X += 5;
}
如果我们在 main
函数中调用:
MyStruct S;
S.X = 0;
Foo(S);
Bar(S);
我们希望 Foo
将 X
设为 5,然后 Bar
将其增加 5,但实际上 Bar
不会修改 S
的值,因为 Bar
接收到的是 S
的拷贝版本,而不是原始版本。因此,Bar
修改的只是 S
的副本,退出 Bar
后,原始 S
的值并没有发生变化。
如果想要 Bar
修改 S
的值,就必须传递指针:
void Bar(MyStruct* S) {
S->X += 5;
}
这样 Bar
操作的是 S
的原始数据地址,因此 X
的值会真正增加 5,而不是修改拷贝的数据。
这是使用指针的第二个原因:
如果我们需要在函数中修改原始数据,而不是修改数据的副本,就必须使用指针传递。
为什么不总是使用指针?
虽然指针传递可以避免大量的拷贝开销,但也存在一些缺点:
- 指针访问存在间接寻址开销:
当我们通过指针访问数据时,CPU 需要先读取指针地址,然后通过指针地址访问数据本身,相比于直接传递数据,存在额外的内存访问开销。 - 指针传递增加了内存安全风险:
如果传入的指针为空 (nullptr
) 或指向无效地址,则访问该指针会导致程序崩溃。
因此,在结构体非常小或不需要修改原始数据时,使用按值传递会更加简单和高效。而在结构体非常大或需要修改原始数据时,使用指针传递会更加节省内存和拷贝开销。
总结
情况 | 适合使用按值传递 | 适合使用指针传递 |
---|---|---|
结构体数据非常小 | ✅ 直接传值,拷贝开销低 | ❌ 没必要增加指针寻址开销 |
结构体数据非常大 | ❌ 拷贝开销非常大 | ✅ 只传递指针,减少拷贝 |
不需要修改原始数据 | ✅ 按值传递更直观 | ❌ 不需要使用指针 |
需要修改原始数据 | ❌ 按值传递不会修改原始数据 | ✅ 通过指针直接修改原数据 |
如果结构体非常小,拷贝开销可以忽略,同时不需要修改原始数据,则直接使用按值传递更简单高效。
如果结构体非常大,拷贝开销巨大,或者需要在函数中修改原始数据时,使用指针传递可以显著减少内存复制,并且可以直接操作原始数据,避免不必要的拷贝。
补充:传递指针 VS 引用传递
在 C++ 中,还可以使用引用传递 (&
) 来代替指针:
void Bar(MyStruct& S) {
S.X += 5;
}
引用传递的本质与指针传递几乎相同,只是语法更简洁,调用时不需要显式传入地址。但是在 C 语言中,引用传递并不存在,因此我们只能使用指针传递。
无论是使用指针还是引用,我们都需要在结构体较大或者需要修改原始数据时,优先选择传递指针。而在结构体较小时,传值反而是最优的选择。
额外说明:为什么编译器不能优化所有拷贝?
在某些情况下,即使结构体非常大,编译器理论上可以通过优化避免拷贝,但实际上编译器并不能总是做到这一点。
原因有两个:
- ABI (应用二进制接口) 的限制:大多数编译器会遵循平台 ABI 的约定,当传入结构体时必须拷贝一份,这样不同语言、不同模块之间可以兼容工作。
- 编译器无法预知后续用途:如果传入的结构体在函数内部会被保存或传递出去,编译器无法确定不会出现数据冲突,因此必须进行拷贝操作。
这也是为什么在实际开发中,我们不能依赖编译器优化,而是应该根据结构体的大小和用途,主动决定使用指针或按值传递。
✅ 总结核心原则
- 结构体小且无需修改原始数据 → 直接按值传递。
- 结构体大或需要修改原始数据 → 使用指针传递。
- 如果结构体非常庞大(如 4KB 以上),指针传递几乎是唯一合理的选择。
- 如果需要确保函数修改原始数据,使用指针或引用传递。
只要遵循这两个原则,大多数情况下我们都可以做出最优选择。
我不完全理解你为什么避免使用C标准库……这是只在HMH教学中这么做吗?还是你真的不使用标准库?
我们在开发过程中基本上不使用C标准库,这主要有以下几个原因:
第一:不信任C标准库
我们不使用C标准库最主要的原因之一是不信任它。在实际开发过程中,我们发现C标准库中的很多函数存在着大量不必要的额外开销,这些开销会导致性能下降。
举个简单的例子,当我们调用数学库中的某个函数,比如sine()
或者atan()
时,C标准库会在底层进行一些额外的计算或者调用一些复杂的底层函数,而这些操作并非我们真正想要的。我们希望函数的行为是完全可控的,比如我们只需要计算一个简单的反正切函数atan2()
,但标准库中却可能执行一大堆无关的检查或者复杂运算,导致整体运行效率降低。
此外,不同平台上的C标准库实现可能也不一致。这导致了我们在不同平台上运行同一份代码时,标准库的某些行为可能会有所不同,而我们更希望自己写的代码在所有平台上行为完全一致,而不是依赖于标准库维护者或者C标准委员会的决定。所以,我们更倾向于自己实现所有函数,比如atan2()
我们会自己编写,从而保证其行为在任何平台上都一致,且不会产生额外开销。
第二:标准库的API设计糟糕且不完整
C标准库中的许多API设计得非常糟糕,比如文件操作API,设计非常不直观、不方便且功能不完整。
举个例子,C标准库的文件操作API根本没有提供遍历目录下所有文件的方法(类似于glob
功能),我们想要在一个目录中查找特定文件名称的文件时,标准库无法直接支持。这种情况下,我们还是需要自己去实现相关功能,或者依赖平台特定的API。
既然C标准库无法满足我们的需求,并且功能还不完整,那我们为什么还要依赖它呢?最终我们发现:无论如何我们都需要自己实现一些功能,而标准库又无法提供我们想要的API,干脆直接放弃标准库自己实现更符合我们需求的功能即可。
第三:减少外部依赖,避免部署问题
这是一个非常现实且重要的原因。
在Windows平台上,如果我们使用了C标准库,那么打包发布的程序可能会因为没有安装对应的C运行时库而无法运行。这种问题在Windows上非常常见,例如开发者将游戏打包发布,用户下载后却无法运行,原因是用户电脑上没有安装正确版本的C运行时库(MSVC Redistributable)。
这个问题在Windows平台尤为严重,因为微软的C运行时库是动态链接的,导致只要用户电脑上没有安装对应的VC++运行时库,程序就无法正常运行。
但是,如果我们完全不使用C标准库,就意味着我们的代码不会依赖任何第三方运行时库(比如msvcrt.dll
等),这样无论用户是否安装了C运行时库,我们的程序都可以直接运行,不会因为缺少库文件而崩溃。
具体的例子:
在我们最近的项目中,我们完全不依赖任何C标准库或第三方库,唯一链接的库就是Windows的kernel32.dll,而且我们直接把kernel32.lib
文件拷贝到源代码目录中,避免了所有的环境配置问题。
这样,我们将项目拷贝到任何一台新开发机器上,直接编译即可,不需要设置任何环境变量、安装任何SDK或运行时库,甚至不需要配置路径,只要有编译器就可以直接编译和运行。这大大简化了开发和部署的复杂度,也完全避免了运行时依赖问题。
第四:提高可控性
我们希望对自己项目中的所有代码拥有绝对的掌控权,不依赖第三方库,不依赖标准库,这样我们就能确保:
- 所有代码的行为完全一致,无论在哪个平台上编译和运行,都不会因为标准库的底层实现不同而导致行为不一致。
- 不会出现任何隐藏的性能损耗,因为所有的底层代码我们都可以自己掌控,不需要担心某个标准库函数偷偷做了一些额外的操作。
- 代码体积最小化,因为我们不需要链接任何额外的库,从而减少了可执行文件的体积,也不会携带额外的动态链接库。
第五:避免链接问题
在Windows平台上使用C标准库还会带来链接方面的灾难。
比如:
- 如果开发者链接了动态运行时库(DLL),用户没有安装对应版本的C运行时库,程序就无法运行。
- 如果开发者链接了静态运行时库(Lib),每次更新编译器版本或者SDK版本时,都可能导致重新编译、库路径不一致等问题。
而我们完全不使用C标准库之后,所有这些链接问题都消失了。
我们只链接kernel32.dll,然后将其lib文件直接拷贝到项目目录中,从此再也不会遇到任何库路径错误或者缺少运行时库的问题。
第六:编译和部署完全可控
我们最新的项目完全没有任何外部依赖:
- 不需要安装平台SDK;
- 不需要安装VC++运行时库;
- 不需要配置环境变量;
- 不需要设置编译器路径;
只需要有一个裸机操作系统和一个C编译器,就可以直接编译出可执行文件,无需依赖任何第三方环境。这极大简化了开发流程和部署流程,彻底避免了所有外部依赖导致的问题。
第七:避免标准库的不一致性
C标准库的不同平台实现差异较大,同一份代码在不同平台的行为可能完全不同。例如:
- 不同平台上
malloc()
和free()
的实现不同,内存分配策略也不同; - 文件操作API在Windows和Linux上的行为不同;
- 不同平台上的浮点运算(例如
sin()
、atan()
等数学函数)结果可能存在细微差异。
我们不希望这些差异存在,所以我们完全放弃C标准库,自己实现所有基础功能。这样保证我们的代码在所有平台上的行为是完全一致的。
总结
综上所述,我们放弃C标准库的主要原因有:
- 不信任标准库:标准库经常做多余的操作,导致性能下降。
- API设计糟糕:标准库的API设计不符合实际需求,功能不完整。
- 减少外部依赖:避免由于缺少运行时库而导致的部署失败。
- 完全可控性:确保所有底层代码行为一致,无额外开销。
- 避免链接问题:消除由于动态链接导致的各种问题。
- 精简编译流程:在新机器上无需配置任何环境,直接编译和运行。
- 行为一致性:确保所有平台上代码的行为完全一致。
所以,我们在开发过程中选择完全放弃C标准库,所有底层功能全部自己实现。这不仅让我们的代码更加可控、更加稳定,还极大简化了开发和部署流程,确保了在所有平台上的一致性和最优性能。
用户保存的数据将存储在哪里?
关于用户数据的存储,我们计划将其实现推迟到后期,并且将完全依赖于平台的特性。这意味着,用户数据存储的方式将根据不同的平台而有所不同。因此,我们打算在需要存储用户数据时,再定义一个适配平台的API。
存储位置的依赖性
用户数据的存储位置与平台密切相关,不同的平台有不同的存储方案:
- 如果程序运行在Steam上,用户数据可能会使用Steam云存储(Steam Cloud)功能。
- 如果是在游戏主机上运行,那么就会根据各个主机的要求使用对应的存储方式。
- 如果是在Windows平台上运行,存储位置通常会使用Windows应用数据目录(如AppData)或者其他Windows推荐的路径。
存储API的设计
在我们的设计中,主程序代码只负责处理用户数据的存取操作,它会通过调用API来保存或更新用户数据。具体的存储方式和位置会被完全推迟到平台实现时定义。这样,主程序的代码无需关注具体的存储实现,而是通过平台特定的API来处理数据存储任务。
总之,主程序只会关心用户数据的保存和更新指令,而具体存储的地方和方式将由平台决定。每个平台都有不同的存储要求和机制,因此将这个细节推迟到平台实现时统一处理,以确保代码的灵活性和可移植性。
为什么选择茶轴而不是蓝轴?
我选择了茶轴而不是蓝轴,因为我不喜欢蓝轴那种很重的点击感。我从小最喜欢的电脑键盘是Amiga电脑的键盘,它的手感比较柔和。我至今还没找到比那款键盘更舒服的键盘。而蓝轴的键盘手感对我来说有点太硬了,茶轴的感觉更接近我喜欢的触感。
你怎么看待Intel INDE?
关于Intel INDE(跨架构生产力套件),它是一个为开发者提供工具支持和IDE集成的套件,旨在帮助开发高性能的C++和Java应用程序,特别是用于Windows平台。虽然我没有试过这个工具,但我不确定它对我会有什么帮助。就我个人而言,唯一用MS开发工具的原因是调试器。如果它有一个非常好的调试器,可能会有用,但除此之外,我不太确定我能用它做什么。
从头开始构建引擎的乐趣
这段话总结了一个关于开发工作和时间管理的观点。在开发过程中,虽然每个任务的时间投入相对较短,但仍然能够实现很多强大的功能。例如,开发了一个流媒体系统,包含了独立线程处理、音量平移、音高调整等复杂功能,所有的资源都通过资产系统进行后台加载和流式传输。这些功能虽然开发时间较短,但结果却足够强大,展示了高效的开发能力,甚至比一些商业游戏引擎还要有优势。
这种经验验证了一个观点,即通过脚本或从零开始开发并不困难,一旦掌握了技能,就能在未来继续构建和优化功能,并确保它们按预期运行。此外,接下来的工作计划是继续优化资产文件的加载和管理,确保在Win32平台上平稳运行,并能够加载多个资产文件并合并它们。