回顾并规划今天的工作
没有使用引擎,也没有任何库支持,只有我们自己,编写游戏的所有代码,不仅仅是小小的部分,而是从头到尾。现在,我们正处于一个我一直想做的任务中,虽然一切都需要按部就班,不能急于求成,但现在终于到了可以开始的时刻,那就是摆脱 BMP 和 WAV 文件,转而使用一个真正的资源文件系统。
当我说“文件系统”时,并不是指像 FAT32 这样的文件系统,而是指一个资源文件系统,一个我们可以用来加载游戏所有资源的系统。它能够定义我们需要了解的所有资源信息,以便运行游戏。所以我们昨天开始了这项工作,而今天我认为我们可能会有机会写出一个资源文件。我不确定我们是否能读取这个文件,但我觉得今天和明天之间,我们应该能够完成一个大致的框架并让它正常运作。
记住,做程序员,不做拖延者。好了,放下这些话题,我们回到正题。
昨天,我稍微简化了一下资源系统。之前我们在其中添加了一些不必要的复杂性,后来我把这些复杂性去掉了,这样就节省了一些时间。不过昨天花了些时间来整理这些。现在我们目前的进展是,所有这些内容都从磁盘上作为独立文件加载过来,但看起来似乎有点问题。
确定声音播放完毕时的 bug
昨天简化的音频混音系统可能存在一些问题。原本测试过这个功能,但现在似乎出现了意料之外的错误。问题出现在音频样本结束时的估算上,音频播放的结束点并未如预期那样正确计算。
当我们检查音频播放时,发现音频播放的样本数并未按照预期结束。具体来说,音频的播放缓冲区应该在特定的样本数之后结束,但实际上并未达到该点。这个问题似乎与音量的变化有关。我们在做音频混音时,需要知道音频应混合的时间段,并计算是否会超出可混合的样本数。如果超出,则会截断混合的长度,音量变化也会作为终止条件之一。然而,问题出在我们设置了音频样本结束标记(InputSamplesEnded
)时,并没有考虑音量变化可能导致提前结束。
错误的根源在于,当音量变化时,我们可能提前结束了混音,这使得 InputSamplesEnded
标志并不总是准确。因此,音频混音系统依赖于多个条件来判断何时结束,但这些条件之间的关系并不清晰,导致了这个问题的出现。
具体来说,问题发生在以下几个方面:
- 我们设置了
ChunksToMix
(需要混合的块数),并且假设它与ChunksRemainingInSound
(剩余块数)相等。然而,音量变化可能导致提前终止,导致这两个值不同。 - 在音量变化导致提前停止的情况下,我们没有正确更新
InputSamplesEnded
,从而导致错误。 - 原来的混音结构和终止条件不够清晰,依赖于多个不相关的条件,这导致了多个潜在的bug。
为了解决这个问题,建议改进音频混音的终止条件:
- 将
InputSamplesEnded
标志的判定条件从“音量变化是否结束”改为“如果ChunksToMix
和ChunksRemainingInSound
相等,则认为音频混音结束”。 - 使用
ChunksToMix
作为音量变化是否结束的标志,避免由于提前结束导致的误判。
这样做的好处是,系统将更加清晰地判断音频的混音是否完成,避免了依赖不明确的标志。通过调整这一逻辑,可以确保音频混音的结束点更加准确,并且减少由于音量变化导致的错误。
这个方法也会段错误
重新设计混音循环以避免此类 bug。移除一个次要变量以避免需要保持同步
在音频混音系统中,最初的问题源于使用了辅助变量来判断音频是否结束,这些辅助变量与实际算法的运行状态没有直接关系。为了简化并避免错误,建议直接基于最核心的数据(即 ChunksToMix
)来判断音频混音是否结束,而不是依赖于额外的状态变量。
具体而言,ChunksToMix
是我们在音频混音过程中实际更新的数据,它表示我们需要混合多少数据。通过让终止条件直接基于这个核心数据,可以确保在算法正确运行的情况下,终止条件也能准确执行。这样做的好处是,避免了引入额外的状态变量,这些变量可能与实际算法逻辑脱节,从而容易导致同步错误或理解上的偏差。
此外,在进行调试时,我们意识到,如果没有放置适当的断言,我们可能永远不会发现这个问题。即便音频停止时出现一些小的错误,我们也可能没有意识到原本的 bug。通过在代码中加入断言,我们能够在出问题的地方捕获到错误,这使得调试过程变得更加有效和可控。
总之,通过简化代码结构,直接使用最重要的核心数据(如 ChunksToMix
)来判断音频结束,我们能够减少潜在的错误,并且通过断言帮助我们更早发现问题,从而提高代码的可维护性和可靠性。
关于断言的重要性
在编程中,断言(asserts)是一个非常有用的工具,用来捕捉潜在的错误。尽管编写代码时并不推荐加入过多的冗余内容,但断言是一种例外。断言能够帮助捕捉到一些难以发现的错误,特别是那些可能导致程序运行异常或表现不一致的问题。例如,在音频处理的情况下,某个音效的结束条件可能没有被正确触发,而如果没有断言,可能就会出现轻微的音频错乱或某些数据被截断的问题,开发者可能很难察觉这些错误。
断言会在代码执行时检查某些条件是否为真,如果条件不成立,它会立即报错,这样可以及时发现潜在的 bug。与 const
等其他代码检查不同,断言能够有效地捕捉到可能影响程序正确性的关键性错误,而这类错误往往不容易被其他方式发现。因此,建议在开发中合理使用断言,尤其是在编写可能出现难以察觉的错误的复杂代码时,断言能帮助开发者确保代码按预期运行。
在实际开发过程中,断言能够帮助我们在早期阶段发现和修正 bug,尤其是那些可能导致程序表现不稳定或难以复现的错误。通过在关键的地方插入断言,我们可以让程序更加健壮,并且能够及时反馈错误信息,避免错误在后期变得难以调试。
写断言来捕捉你容易犯的错误
断言(assert)是一个灵活且有效的工具,可以帮助开发者捕捉到特定类型的错误。随着编程经验的积累,开发者会逐渐意识到自己容易犯的错误类型。并不是每个开发者都会犯相同的错误,但有些错误是普遍存在的,例如复杂的逻辑问题。而断言的优势在于它可以针对开发者自己常犯的错误进行定制。通过在代码中放置适当的断言,开发者能够在发生错误时及时发现并修复,避免潜在问题的蔓延。
例如,有些开发者可能不会因为使用 const
而发现很多错误,而其他开发者则可能会频繁遇到这类问题。相比之下,断言能够根据个人的编程习惯和错误类型进行调整和优化。对于初学者来说,熟悉自己常犯的错误,并学会如何在代码中加入适当的断言,是一个非常重要的技能。可以把断言看作是一种“安全网”,就像登山者在攀登时放置保护钉一样,帮助开发者避免在编程过程中“摔落”——即避免因错误而导致程序不稳定。
每个程序员,无论经验多丰富,都不可避免地会犯错,而编程的高效性在于如何快速识别并修复这些错误。学习使用断言来捕捉这些错误,将极大提升程序的健壮性,减少难以发现的问题,确保程序运行的可靠性。通过这种方式,开发者不仅能够避免简单的错误,还能确保在开发过程中保持高质量的代码。
返回到测试资源构建器
我们正在进行的任务是构建一个手动资产处理器,目标是将游戏中的资产结构从一个个独立的文件,转换成一个单一的大文件,以便通过一个句柄进行访问。这个大文件将包含所有资产的元数据,能够快速访问所需的资源,并且这些资源的相关信息可以在文件中预先存储,而不再需要在游戏运行时动态构建。
首先,游戏中当前的做法是每次启动时构建一个虚拟的资产结构,使用文件名指定去实际磁盘目录中抓取资源。在这个过程中,每当需要某些资产时,程序就会查找相应的文件。而新的目标是将所有资源存储在一个文件中,便于直接打开该文件进行访问,而不必每次都从多个目录中寻找。这种方法可以大大简化资产管理,并加快资源加载速度。
为了实现这一目标,我们需要设计并实现一个读取和写入资产文件的系统,并为此写一个简单的资产构建器。我们已经准备好了所有所需的艺术资源,接下来将它们打包到一个文件中,而这些资源的构建和存储可以在开发过程中离线完成。
接下来的步骤是整理和优化之前的代码。由于我们之前已经简化了一部分代码,删除了一些不必要的调试信息和无用变量,如 bit map
和 sound count
,因此我们可以直接提取并修改现有的代码,将其集成到新的资产打包工具中。我们不再需要处理一些复杂的动态代码重用的问题,因此可以简化处理流程。接着,我们将继续重构和调整代码,确保所有功能正确整合并且能顺利编译。
在此过程中,需特别注意一些地方,比如如何正确处理不同类型的资源标识符(例如 soundID
和 bitmapID
),以及如何合理组织和存储这些资产信息。此外,还需要确保资产的标签和元数据能够正确地存储和读取,避免遗漏关键信息。
整体的目标是通过简化和优化代码,使得资产管理更加高效,减少手动管理多个文件的复杂性,并确保系统的稳定性和扩展性。
避免修改大量代码以反映变量从对象变为指针的技巧
我们目前正在继续开发手动资产处理器,并对现有的游戏资产结构进行整理和转换,以便将所有资源打包到一个单一的文件中。这种处理方式的核心目的是将原本零散分布的资源文件(如图像、音效等)集中到一个文件内,从而通过一个文件句柄进行访问,避免多次磁盘查找,提升加载速度和资源管理的便捷性。
在处理过程中,我们注意到当前的 game_assets
结构体中有很多字段是通过指针来引用的,如果直接将整个结构体传递给新函数,很多指针类型的字段都需要进行手动修改以适应新结构。因此,为了避免修改大量代码,我们采用了一种简便的“巧妙手法”:在原有结构体名称后添加下划线 _
,然后直接传递该结构体的地址,从而保持原有代码逻辑不变,同时减少不必要的修改。这种做法是非常实用的,可以快速适配新结构而不破坏现有代码。
接下来,我们开始整理并初始化 game_assets
结构体,删除了许多不必要的字段。例如,原有的 BitmapCount
和 SoundCount
等字段在新的数据结构中已经不再需要,因为所有信息都将集中打包到一个文件中进行管理,因此将这些字段移除是必要的。在处理过程中,我们还需要确保 AssetCount
的初始化正确,因为第一个资源通常是一个空资源(null asset),所以我们将 AssetCount
初始化为1。
在处理过程中,我们还发现了部分函数的安全性警告(如 fopen
提示使用更安全的 fopen_s
),但考虑到我们当前的需求仅仅是一个离线资产处理器,因此不需要做过多的安全防护,所以直接禁用了该警告,以确保代码可以正常编译和运行。
在接下来的任务中,我们决定将 test_asset_builder
分离成一个 .h
文件和 .c
文件,以便更方便地管理和查看代码逻辑,因此我们创建了一个 test_asset_builder.h
文件并将主要定义移入其中,同时在 .c
文件中通过 #include
进行引用。这种做法使得我们的代码结构更加清晰易维护。
接下来,我们需要处理的核心任务是:
- 初始化
game_assets
结构体:将所有未初始化的字段进行赋值,确保asset_count
从1开始,并将相关的资源信息进行整理。 - 编写资源写入功能:实现将所有资源信息打包并写入一个单一文件中,包括资源的元数据、文件偏移量、标识符等信息。
- 处理资源加载:确保在读取该大文件时,可以快速定位资源并加载到内存中。
- 确保兼容性:由于新的资源文件格式与旧的动态加载方式不同,因此需要确保游戏中所有资源加载逻辑能顺利迁移到新模式下。
目前,我们已经解决了大部分结构调整和初始化问题,接下来将重点放在资源文件的写入功能上,这将是资产打包流程中最核心的一步。我们计划通过直接写入二进制文件的方式,将所有资源的二进制数据、元数据、文件偏移信息等整合到一起,以便在游戏运行时快速定位并加载资源。这种设计可以极大提升游戏启动速度,并避免繁琐的磁盘访问。
我们希望包含在资源文件中的数据
目前我们需要解决的核心任务是:我们已经有了一个包含所有游戏资源的 game_assets
结构,现在我们的目标是将所有的资源处理成一个大文件,这个文件将包含:
- 目录信息 (Metadata):即所有资源的元数据,如资源的类型、大小、偏移量等信息,方便我们在游戏运行时快速定位和加载资源。
- 实际数据 (Raw Data):包括所有的位图数据 (Bitmap Data)、声音数据 (Sound Data) 等,所有这些数据都需要存储在一个大文件中,以便在游戏加载时直接读取和使用。
这个操作的核心思想就是将所有资源打包成一个文件,并且在文件开头存储一个目录结构 (Directory),里面包含了所有资源的元信息 (Metadata),以便加载器能快速定位和读取资源数据。
✅ 第一步:创建一个文件格式头文件
首先我们需要定义一个专门用于描述资源打包文件格式的头文件。开发者计划将其命名为:
game_file_format.h
这个文件的核心目的是:定义文件格式的结构体,即描述资源在文件中的排布方式。
✅ 第二步:使用内存对齐 (pragma pack)
在定义文件格式时,必须保证文件中的数据排列和结构体内存布局完全一致,因此开发者决定引入 #pragma pack
来确保结构体无填充,保证二进制文件和结构体映射一致。
我们可以看到开发者翻查了一下之前写的 bitmap
处理代码,因为在位图加载时曾经使用过 #pragma pack
保证二进制数据直接映射到结构体中。他计划直接复用这个方法。
对文件格式结构进行 pragma 打包
在定义结构体 (Structure) 时,编译器通常会自动插入内存填充 (Padding),以确保结构体内的成员变量按照内存对齐 (Alignment) 的规则进行布局。内存填充的作用是确保处理器能够高效读取和写入内存数据,但是这种填充会导致结构体的内存布局与实际想要写入磁盘的二进制数据存在差异。
而我们目前的需求是要将数据结构直接写入文件 (二进制文件),确保文件内容和结构体内存布局完全一致,因此我们需要显式关闭编译器的内存填充,保证写入磁盘的数据结构与内存中的数据结构一模一样。
✅ 为什么会有内存填充?
在 C/C++ 中,结构体的内存对齐通常遵循以下规则:
假设我们有一个结构体:
struct TestStruct {uint8_t A; // 1 字节uint32_t B; // 4 字节uint16_t C; // 2 字节
};
内存布局通常是:
成员变量 | 偏移量 | 填充 (Padding) | 占用字节 |
---|---|---|---|
A | 0 | 无 | 1 |
padding | 1 | 填充3个字节 | 3 |
B | 4 | 无 | 4 |
C | 8 | 填充2个字节 | 2 |
总大小 | - | - | 12字节 |
可以看到:
A
占 1 字节,但是B
是 4 字节,因此编译器自动填充 3 字节,使B
对齐到 4 字节边界。C
占 2 字节,但为了使结构体大小满足 4 字节对齐,编译器又填充了 2 字节。
这样虽然在内存中是高效的,但是如果直接写入磁盘,那么磁盘文件中的数据就会带有这些无意义的填充字节,这显然是我们不想要的。
✅ 解决方案:使用 #pragma pack
关闭内存填充
为了解决这个问题,我们必须使用:
#pragma pack(push, 1)
这条指令告诉编译器:
- 关闭内存填充 (Padding)。
- 确保结构体布局与磁盘文件的二进制布局完全一致。
hha 头部和其魔术值
在设计文件格式时,为了确保读取文件时能够快速判断该文件是否为我们期望的文件类型,通常会在文件开头添加一个魔数 (Magic Value)。这个魔数是一个固定的标识值,用于验证文件格式的合法性。在许多标准文件格式中,例如 WAV 文件,就存在类似的魔数机制。
✅ 为什么需要魔数 (Magic Value)
在文件格式设计中,如果没有魔数,读取文件时无法确认当前文件是否为我们定义的文件格式。例如:
- 假设我们尝试加载一个资源文件 (
.hha
),但是该文件实际上是一个损坏的文件或错误的文件格式,如果没有魔数,我们的加载器会继续尝试解析,最终导致程序崩溃或解析错误。 - 但如果我们有魔数,加载器在读取文件的前4个字节时,只需要判断魔数是否匹配,如果不匹配,直接报错退出,避免后续的崩溃或错误。
✅ 魔数的设计
常见文件格式的魔数
我们参考一些常见文件格式中的魔数:
文件格式 | 魔数 (Magic Value) | 说明 |
---|---|---|
WAV | RIFF + WAVE | 用于标识 WAV 文件格式 |
PNG | 0x89504E47 | PNG 文件标识 |
ZIP | 0x504B0304 | ZIP 文件标识 |
可以看出,这些魔数要么是ASCII字符串,要么是固定的十六进制数值,它们的作用就是确保文件格式正确。
✅ 我们的文件格式
我们当前设计的是一个资源包文件格式 (HHA 文件),用于存放游戏的位图、声音、动画等资源。因此,我们也需要定义自己的魔数,用于标识该文件是一个HHA 资源文件。
我们计划定义以下信息:
- 魔数 (Magic Value):用来标识文件是HHA 资源文件。
- 版本号 (Version):用来标识文件格式的版本,方便未来格式升级。
资源文件结构概述
在定义文件格式时,我们需要在文件头部放置一些关键信息,以便在加载该文件时能够快速获取文件结构信息,并保证加载过程的高效性和可控性。文件头部的主要目的是提供文件验证信息和文件数据的结构布局信息,方便我们在游戏运行时快速加载资源数据,而无需进行复杂的解析操作。
文件头部的结构设计
在文件加载时,我们会首先加载文件头部的一个块 (chunk),该块包含一些基础信息,包括:
- 魔数 (Magic Value):用于确认该文件是否为我们定义的格式。
- 版本号 (Version Number):确保加载器能够识别文件版本,避免加载不兼容的文件。
- 资源信息 (Asset Information):告诉我们文件中包含了多少资源、资源的具体位置以及资源的种类等。
魔数 (Magic Value)
我们在文件开头放置了魔数,该魔数是一个特定的字节序列,用于确认当前文件是否是我们定义的格式。
例如 WAV 文件在开头放置了 “RIFF” + “WAVE”,通过读取这段信息就可以确认该文件是 WAV 文件。
同样,我们也为自己的文件定义一个魔数,比如 "HHA"
或 "HHF"
,用于识别该文件是否为Handmade Hero 资源文件。
版本号 (Version Number)
接下来是版本号,版本号用于验证加载器是否支持当前文件版本。
假设未来我们修改了文件格式并发布了新版本,为了避免加载器读取错误的格式导致崩溃,我们在头部添加了版本号。
如果加载器遇到版本号不匹配的文件,就可以提前报错,避免文件加载过程中出现不可预期的问题。
数据索引 (Data Index)
读取完魔数和版本号后,我们需要在头部存储一些关于数据块位置和数量的信息:
- 标签数量 (Tag Count):文件中的标签数量,方便加载器分配内存。
- 资源数量 (Asset Count):文件中资源的数量,例如图像、声音等。
- 资源类型数量 (Asset Type Count):文件中包含的资源类型数量,比如只有图片和音效,或者包含字体、音乐等。
这些信息的意义:
- 标签数量:告诉加载器文件中有多少个标签,用于描述资源的属性,比如“环境音效”、“UI贴图”等。
- 资源数量:告诉加载器文件中有多少个资源(比如总共有100个图像、50个音效等),以便加载器申请内存空间。
- 资源类型数量:由于资源类型是可扩展的,未来可能会新增更多类型,因此需要一个可变的资源类型计数器。
文件偏移量 (Offset)
在文件头部还需要存储文件偏移量 (Offset),用于告诉加载器:
- 资源的起始位置(资源数据块的地址)。
- 标签的起始位置(标签数据块的地址)。
- 资源类型的起始位置(资源类型数据块的地址)。
由于文件的大小可能超过 4GB,因此我们在记录偏移量时,使用64位整数来保证偏移量足够大。
例如:
- 标签数据块:可能从第 1024 字节开始。
- 资源数据块:可能从第 4096 字节开始。
- 资源类型数据块:可能从第 8192 字节开始。
为什么使用 32 位和 64 位整数?
我们在存储数量时使用32 位无符号整数 (uint32),因为:
- 32 位整数的最大值是 4,294,967,295 (约 42 亿)。
- 即便我们有 42 亿个资源,4TB 文件容量也是非常庞大的,远超实际需求。
但是在存储文件偏移量时,我们使用64 位无符号整数 (uint64),因为:
- 文件偏移量表示的是文件中的地址。
- 文件总大小超过 4GB 时,32 位偏移量会溢出,因此必须使用 64 位。
文件头部结构总结
最终我们定义的文件头部结构大致如下:
字段名称 | 类型 | 说明 |
---|---|---|
魔数 | uint32 | 用于验证文件格式,比如 "HHF" |
版本号 | uint32 | 用于验证文件版本,比如 0 表示初始版本 |
标签数量 | uint32 | 文件中的标签总数 |
资源数量 | uint32 | 文件中的资源总数 |
资源类型数量 | uint32 | 文件中的资源类型总数 |
标签数据偏移 | uint64 | 标签数据在文件中的起始位置 |
资源数据偏移 | uint64 | 资源数据在文件中的起始位置 |
资源类型偏移 | uint64 | 资源类型数据在文件中的起始位置 |
文件头部的作用
加载器在读取文件时:
- 首先读取文件头部,验证魔数和版本号是否正确。
- 读取文件的结构信息,包括标签数量、资源数量、资源类型数量。
- 根据偏移量快速跳转到相应位置读取数据,避免顺序遍历文件,提高加载速度。
为什么要这样设计?
- 保证文件的可验证性:通过魔数验证文件类型,避免加载错误文件。
- 确保文件版本兼容性:通过版本号检查,避免加载器解析新格式失败。
- 快速定位资源:通过偏移量直接跳转到资源位置,避免顺序扫描,提高加载速度。
- 控制内存分配:通过标签数量和资源数量,预先分配内存,避免动态扩展导致性能下降。
✅ 为什么不采用固定结构?
如果我们在文件中直接按顺序写入资源,而不定义偏移量:
- 每次加载都必须遍历整个文件,导致加载时间非常长。
- 无法通过版本号兼容未来文件格式变化。
- 无法直接跳转到特定资源位置,导致效率极低。
而通过定义文件头部并存储偏移量,加载器只需要:
- 一次读取文件头部。
- 通过偏移量直接跳转到指定数据位置。
- 快速加载资源数据,避免不必要的遍历和解析。
✅ 未来扩展性
我们在设计文件格式时还考虑了:
- 版本号:允许未来扩展新的格式,而不会破坏已有的加载逻辑。
- 资源类型计数:允许未来添加新资源类型(如动画、粒子效果等)。
- 文件偏移量:保证文件超大时仍然可用。
如果未来我们想添加更多数据,只需扩展文件格式,而不必修改加载逻辑,非常方便。
🚀 总结
我们在文件头部存储:
- 魔数:确保文件是我们定义的格式。
- 版本号:确保加载器兼容当前版本。
- 资源信息:记录资源数量、标签数量、类型数量。
- 偏移量:允许快速跳转到资源位置加载数据。
通过这种设计,我们确保了:
- 文件验证:确保加载正确文件。
- 快速加载:通过偏移量直接定位资源。
- 内存预分配:通过计数提前分配内存。
- 未来扩展性:通过版本号保证兼容性。
最终的文件格式结构非常简洁且高效,确保游戏加载资源时最大限度节省时间。
使用偏移量和计数来访问资源内容
在文件的设计中,我们需要确保游戏在加载文件时能够高效获取所需数据,因此我们需要在文件的开头设置一个头部(header),用于描述文件的基本信息以及指示文件中的数据布局。
首先,当我们加载文件时,最开始读取的就是这个头部,在头部中我们会存储一个魔数值(Magic Value)和版本号(Version)。
- 魔数值的作用是验证该文件是否为我们定义的格式,即是否为我们游戏的文件,如果不是,我们就不加载。
- 版本号的作用是保证文件版本与加载器版本匹配,如果加载器只支持版本0,而文件是版本7的,那么我们需要拒绝加载,以防出现解析错误或崩溃。
接下来,由于头部仅仅是文件的开头,我们并不知道文件中其他数据的位置和数量,因此我们需要在头部中存储更多信息来指引加载器找到数据的位置和数量。
文件中包含的数据
我们已经确定文件中至少会包含标签(Tag)、**资源(Asset)以及资源类型(Asset Type)**这三类数据。
因此,头部需要提供以下信息:
- 标签数量(Tag Count):表示文件中总共有多少个标签。
- 资源数量(Asset Count):表示文件中总共有多少个资源。
- 资源类型数量(Asset Type Entry Count):表示文件中有多少个不同的资源类型。
由于我们可以确定资源和标签的数量不可能超过40亿个(即2^32),因此我们选择**32位无符号整数(uint32_t)**来存储它们的数量。
例如,如果每个资源占用1KB内存,40亿个资源将占用4TB内存,而这远远超过我们游戏的需求,因此32位整数完全足够。
但是文件中的偏移地址可能会超出4GB,因此我们需要为偏移地址使用**64位无符号整数(uint64_t)**来存储。
文件中的数据偏移信息
我们在头部中还需要提供数据在文件中的具体位置,因此需要存储:
- 标签数组偏移地址(Tag Array Offset):该地址指向文件中存储所有标签的区域。
- 资源数组偏移地址(Asset Array Offset):该地址指向文件中存储所有资源的区域。
- 资源类型数组偏移地址(Asset Type Array Offset):该地址指向文件中存储所有资源类型的区域。
这些偏移地址使用**64位无符号整数(uint64_t)**存储,确保文件体积超过4GB时仍能正常解析。
因此,当加载器读取头部时,就可以通过标签数量、资源数量、资源类型数量以及对应的偏移地址,快速定位文件中的所有数据,从而避免复杂的解析过程,最大程度提高加载效率。
文件中的数据结构设计
接下来我们需要定义文件中具体的数据结构,即:
- 标签(Tag):表示资源的附加信息,用于描述资源的特性,如音频的音调、位图的尺寸等。
- 资源(Asset):表示游戏中的实际资源,例如音效、图片、模型等。
- 资源类型(Asset Type):表示资源的分类,例如“音效”、“位图”等。
资源类型结构
资源类型结构主要用于描述一种资源类型,它包含:
- 类型ID(Type ID):表示该资源类型的唯一标识符。
- 第一个资源索引(First Asset Index):表示该资源类型中第一个资源在资源数组中的索引。
- 资源数量(Asset Count):表示该资源类型中的资源数量。
例如:
类型ID | 第一个资源索引 | 资源数量 |
---|---|---|
1 | 0 | 100 |
2 | 100 | 50 |
通过这个结构,我们可以快速定位某个资源类型的所有资源。
资源结构
资源结构用于描述文件中的每个资源,它包含:
- 首个标签索引(First Tag Index):该资源的第一个标签在标签数组中的索引。
- 最后一个标签索引(One Past Last Tag Index):该资源最后一个标签的索引+1,用于快速迭代标签。
- 数据偏移地址(Data Offset):该资源数据在文件中的具体偏移地址。
- 数据大小(Data Size):该资源数据的大小。
- 资源信息(Asset Info):该资源的特定信息,例如位图的宽高、音效的采样数等。
由于不同的资源类型拥有不同的资源信息,因此资源信息部分将根据资源类型存储不同的数据结构:
- 位图(Bitmap):存储宽度、高度、下一帧ID等信息。
- 音效(Sound):存储下一帧ID、采样数量等信息。
位图数据结构
如果资源是位图,我们需要存储:
- 宽度(Width)
- 高度(Height)
- 下一帧ID(Next ID To Play)
例如:
Bitmap:Width: 256Height: 256Next ID: 5
由于加载器不再加载独立的位图文件,而是直接加载数据块,因此必须手动存储宽度和高度,否则无法正确解析位图数据。
音效数据结构
如果资源是音效,我们需要存储:
- 下一帧ID(Next ID To Play)
- 采样数(Sample Count)
例如:
Sound:Next ID: 6Sample Count: 44100
采样数决定了音效的长度,而下一帧ID用于链接下一个音效(如果存在)。
标签结构
标签是描述资源的附加信息,例如:
- 颜色标签:描述资源的主色调。
- 环境标签:描述资源适用的环境(白天/夜晚)。
- 音效标签:描述音效的情绪或风格。
标签结构主要包含:
- 标签ID(Tag ID)
- 标签值(Tag Value)
标签在资源数组中是连续存储的,因此通过首个标签索引和最后一个标签索引,可以快速访问所有标签。
数据存储的方式
所有的数据都以连续的块存储在文件中,数据布局如下:
[ 文件头部 ]
[ 标签数组 ]
[ 资源数组 ]
[ 资源类型数组 ]
[ 资源数据 ]
- 文件头部中包含标签数量、资源数量、资源类型数量以及偏移地址。
- 标签数组存储所有标签的数据,每个标签大小固定。
- 资源数组存储所有资源的基本信息,包括数据偏移地址和数据大小。
- 资源类型数组存储所有资源类型的信息。
- 资源数据部分存储实际的音效、位图数据等。
总结
我们设计了一个简单高效的文件格式,通过头部存储数据偏移地址和数量信息,使得加载器在解析文件时无需复杂计算,直接跳转到目标数据块读取数据。
通过定义资源类型、资源和标签三种数据结构,可以实现灵活的资源管理,并支持未来的扩展需求。
从测试资源构建器写入头部
我们现在开始编写文件的写入逻辑,首先需要将文件头(Header)写入文件中,并确保该文件符合我们定义的格式。
引入文件格式头文件
在编写文件写入逻辑之前,需要先包含我们之前定义的文件格式头文件。该头文件中定义了文件的结构体,包括:
- 文件头结构体(Header)
- 资源结构体(Asset)
- 资源类型结构体(Asset Type)
- 标签结构体(Tag)
因此,我们首先在代码顶部添加:
#include "game_file_formats.h"
通过包含这个头文件,可以直接使用我们定义的各种结构体,并保证文件格式一致。
开始写入文件头
接下来我们开始写入文件头(Header),这部分数据位于文件的开头,包含文件的基本信息和偏移地址。
首先,我们需要创建一个文件头结构体,然后填充它的各个字段。
hha_header Header = {};
创建完成后,我们需要依次填充各个字段。
填写文件魔数和版本号
首先,我们需要在文件头中写入魔数值和版本号:
Header.MagicValue = HHA_MAGIC_VALUE;
Header.Version = HHA_VERSION;
- MagicValue用于标识该文件是否符合我们的文件格式。加载器读取文件时会首先检查该值,如果不匹配则拒绝加载。
- Version表示文件版本号,用于确保加载器和文件格式版本一致,避免因格式差异导致解析失败。
填写标签数量、资源数量
接下来,我们填写标签数量(Tag Count)和资源数量(Asset Count),这两个值是我们已经明确知道的:
Header.TagCount = Assets.TagCount;
Header.AssetCount = Assets.AssetCount;
- TagCount表示文件中标签的总数量,用于加载器在解析文件时确定标签数组的长度。
- AssetCount表示文件中资源的总数量,用于加载器定位资源数组。
填写资源类型数量
在填写**资源类型数量(Asset Type Count)**时,情况稍微复杂一些。
由于我们在资源打包过程中,并不确定哪些资源类型会被实际使用,因此无法直接确定资源类型的数量。
假设我们在开发过程中定义了10种资源类型,但当前文件中可能只使用了3种资源类型,因此不能直接写入10,而应写入实际使用的数量。
到目前为止,我们已经成功创建了文件头结构体,并填充了以下内容:
字段 | 内容 | 是否确定 |
---|---|---|
MagicValue | 魔数值,标识文件格式 | ✅确定 |
Version | 文件版本号 | ✅确定 |
TagCount | 文件中的标签数量 | ✅确定 |
AssetCount | 文件中的资源数量 | ✅确定 |
AssetTypeCount | 文件中的资源类型数量 | ❌不确定 |
TagArrayOffset | 标签数组在文件中的偏移地址 | ❌待确定 |
AssetArrayOffset | 资源数组在文件中的偏移地址 | ❌待确定 |
AssetTypeArrayOffset | 资源类型数组在文件中的偏移地址 | ❌待确定 |
下一步
写入文件头之后,接下来需要依次写入:
- 标签数据(Tag Array)
- 资源数据(Asset Array)
- 资源类型数据(Asset Type Array)
写入完成后,需要回填偏移地址,确保加载器能够正确读取数据。
接下来,我们将继续编写标签数据、资源数据和资源类型数据的写入逻辑,并确保文件格式符合设计需求。
计算标签、资源类型和资源偏移量的两种方式
我们目前正在处理文件格式的写入部分,目标是将所有的数据块按照固定的格式写入文件中,包括文件头、标签数据、资源类型数据和资源数据。但在实际写入过程中,我们面临一些关于**数据块偏移地址(Offset)**的不确定性,因此需要对数据布局进行一些计算。
我们当前面临的问题
在文件中,数据块的存储位置是连续的,但我们在写入文件头(Header)时并不知道:
- 标签数组(Tag Array)的起始地址是多少?
- 资源类型数组(Asset Type Array)的起始地址是多少?
- 资源数组(Asset Array)的起始地址是多少?
由于我们在写文件头时无法提前知道这些数据的偏移地址,因此必须先占位这些地址,等所有数据写入完成后,再回填这些偏移地址。这是我们面临的主要问题。
一种简单但不优雅的方式:手动计算偏移
一种直接的方式是我们手动计算各个数据块在文件中的偏移地址。这意味着:
- 标签数组的起始地址是:文件头的大小。
- 资源类型数组的起始地址是:标签数组的起始地址 + 标签数量 * 标签结构体大小。
- 资源数组的起始地址是:资源类型数组的起始地址 + 资源类型数量 * 资源类型结构体大小。
通过这种方式,我们可以手动计算出各个数据块的位置,但这种方法的缺点是:
- 容易出错,因为我们需要在心里进行各种偏移量计算。
- 不灵活,如果结构体大小变化或者文件格式变化,所有偏移量计算都要重写。
但是,为了更清晰地展示文件布局,我们先使用这种方式进行写入操作。
开始计算文件偏移地址
首先,我们写入文件头,并假设文件开头处是偏移量0:
fwrite(&Header, sizeof(Header), 1, File);
这条语句将文件头写入文件,并使文件指针(File Pointer)移动到文件头之后的位置。
fwrite
是 C 语言标准库 <stdio.h>
提供的二进制文件写入函数,专门用于将内存中的数据以二进制形式写入文件中。其函数签名如下:
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
fwrite
的四个参数如下:
参数 | 类型 | 作用 |
---|---|---|
ptr | const void* | 指向要写入数据的指针,数据可以是结构体、数组、基本数据类型等。 |
size | size_t | 每个元素的大小(字节数),通常使用 sizeof() 来获取结构体或数据的大小。 |
count | size_t | 要写入的元素数量,即要写入多少个 size 大小的数据块。 |
stream | FILE* | 文件指针,指向已打开的文件。 |
返回值:
- 返回成功写入的元素数量(count),不是字节数!
- 如果返回值小于
count
,说明写入过程出现了错误或者文件空间不足。
✅ 为什么 fwrite
需要两个尺寸参数:size 和 count?
这个设计很多人都会疑惑:为什么 fwrite
不直接写入固定大小的数据,而是需要 size
和 count
两个参数?
原因在于:
fwrite
是为通用性设计的,它不仅能写入一个结构体、字符串,还能写入大量数据(数组)。- 将数据分成 “单元”,让你定义每个单元的大小,以及需要写入多少个单元,从而支持批量写入。
✅ fwrite 的底层写入过程(本质)
fwrite
的核心逻辑如下:
for (size_t i = 0; i < count; ++i)
{写入 size 字节的数据;
}
所以如果你这样调用:
fwrite(&Header, sizeof(Header), 1, File);
表示:
- 写入 1个单元 (
count = 1
)。 - 每个单元大小是
sizeof(Header)
。 - 等效于直接把整个 Header 结构体写入文件中。
如果你这样调用:
fwrite(Tags, sizeof(Tag), Header.TagCount, File);
表示:
- 写入 Header.TagCount 个单元 (
count = Header.TagCount
)。 - 每个单元大小是
sizeof(Tag)
。 - 等效于将
Tags
数组中所有Tag
结构体连续写入文件中。
这就解决了批量写入的问题。
✅ 为什么返回的是元素数量,而不是字节数?
这是 fwrite
另一个让人迷惑的地方。为什么它返回的是成功写入的元素数量而不是字节数?
举例:
假设我们写入 10 个 int
类型的数组:
int numbers[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
fwrite(numbers, sizeof(int), 10, File);
如果写入成功,fwrite
的返回值是 10
,表示写入了 10 个 int 单元。
但如果磁盘空间不足或文件错误,可能只写入 5
个元素,则 fwrite
返回 5
。
如果我们想知道写入的字节数,需要手动计算:
size_t bytesWritten = fwrite(numbers, sizeof(int), 10, File);
printf("Bytes Written: %zu\n", bytesWritten * sizeof(int));
⚠️ fwrite 不会返回字节数,只会返回写入的元素数量。
如果想知道写入的字节数,需要用count * size
手动计算。
✅ fwrite 在文件中的效果
假设我们有一个结构体:
struct Header
{uint32_t MagicValue;uint32_t Version;uint32_t TagCount;
};
我们这样写入:
Header header = { 0x12345678, 1, 100 };
fwrite(&header, sizeof(Header), 1, File);
文件中的二进制布局(16进制)大致如下:
Offset | 内容 | 备注 |
---|---|---|
0x00 | 78 56 34 12 | MagicValue |
0x04 | 01 00 00 00 | Version |
0x08 | 64 00 00 00 | TagCount |
原因:
fwrite
会直接把内存中Header
结构体的二进制内存直接拷贝到文件中。- 没有任何格式转换。
✅ fwrite 写入数组
如果我们有一个 Tag
数组:
struct Tag
{uint32_t ID;float Weight;
};Tag Tags[5] = {{1, 0.1f},{2, 0.2f},{3, 0.3f},{4, 0.4f},{5, 0.5f}
};
我们写入数组:
fwrite(Tags, sizeof(Tag), 5, File);
fwrite 的含义是:
- size = 每个
Tag
结构体大小,即sizeof(Tag)
。 - count = 5,表示有 5 个
Tag
要写入。
写入文件中的数据类似于:
01 00 00 00 CD CC CC 3D
02 00 00 00 CD CC 4C 3E
03 00 00 00 9A 99 99 3E
04 00 00 00 CD CC CC 3E
05 00 00 00 00 00 00 3F
✅ 如果只想写入一个结构体怎么办?
直接传入 count = 1
即可:
fwrite(&Header, sizeof(Header), 1, File);
等效于:
write(File, &Header, sizeof(Header));
✅ 如果想写入字符串怎么办?
如果你想写入一个字符串:
char* Name = "Hello World";
fwrite(Name, strlen(Name), 1, File);
- size =
strlen(Name)
,即字符串长度。 - count = 1,表示写入一个字符串。
- 注意不会写入
\0
终止符。
如果想带终止符:
fwrite(Name, strlen(Name) + 1, 1, File);
✅ fwrite 的错误检测
写入文件失败时,fwrite
会返回写入成功的元素数量,我们可以用它检测错误:
size_t written = fwrite(&Header, sizeof(Header), 1, File);
if (written != 1)
{printf("写入失败\n");
}
✅ fwrite 和 fwrite_s 的区别
在现代 C 语言中,fwrite_s
是更安全版本:
fwrite_s(ptr, size, count, File);
它会检查:
- 文件是否为空。
- 指针是否为空。
- 写入数量是否超出限制。
建议在敏感数据写入时使用 fwrite_s
。
✅ 总结
使用场景 | size | count | 说明 |
---|---|---|---|
写入单个结构体 | sizeof(Header) | 1 | 写入一个结构体 |
写入数组 | sizeof(Tag) | 5 | 写入5个Tag |
写入字符串 | strlen(Name) | 1 | 写入不带\0 的字符串 |
写入缓冲区 | 1 | BufferSize | 写入内存缓冲区 |
所以,size 是单位大小,count 是单位数量,这就是 fwrite
的核心。 🚀
#include <stdio.h>
#include <stdlib.h>
#pragma warning(disable : 4996)
struct Person {char name[20];int age;
};int main() {// 打开文件进行写入FILE *file = fopen("test.dat", "wb");if (file == NULL) {printf("无法打开文件。\n");return 1;}// 创建一个结构体实例struct Person person1 = {"Alice", 25};struct Person person2 = {"Bob", 30};// 将结构体写入文件size_t result = fwrite(&person1, sizeof(struct Person), 1, file);if (result != 1) {printf("写入结构体失败。\n");fclose(file);return 1;}result = fwrite(&person2, sizeof(struct Person), 1, file);if (result != 1) {printf("写入结构体失败。\n");fclose(file);return 1;}// 创建一个数组struct Person people[3] = {{"Charlie", 35}, {"David", 40}, {"Eve", 45}};// 将数组写入文件result = fwrite(people, sizeof(struct Person), 3, file);if (result != 3) {printf("写入数组失败。\n");fclose(file);return 1;}printf("数据已成功写入文件。\n");// 关闭文件fclose(file);return 0;
}
流式文件的小抱怨(在问答的第一个问题中扩展)
在讨论文件操作时,有人指出了对“流式”文件操作模式的强烈反感,特别是文件句柄的当前位置概念。他认为文件操作中使用“流”模式是一种非常糟糕的设计,尤其是在需要使用文件句柄时,这种设计会导致许多问题。
具体来说,文件句柄在执行写入时会自动跟随文件当前位置移动,这意味着每次写入时,文件指针都会更新,而不需要明确指定文件的写入位置。这样的设计会导致一些潜在的错误,特别是在多线程环境下。如果两个线程同时操作同一个文件句柄,且文件指针的位置被自动更新,就会导致文件指针出现错乱,进而影响写入内容的顺序。为了避免这种情况,必须保存文件指针的当前状态,这样会增加额外的复杂性和出错的机会。
在多线程和并发环境下,这种自动更新文件指针的机制会导致更多的麻烦。每个线程都依赖于文件句柄的当前位置,而文件指针的位置可能在两个线程之间共享,这使得线程之间的文件操作变得不可预测。如果文件操作没有明确指定位置,则无法保证两个线程同时对文件进行操作时的行为是安全的。
他建议,理想的文件操作模式应该允许开发者在写入文件时明确指定位置和大小,这样可以避免文件指针的隐式更新,并且可以更好地控制文件的读写位置。这种设计可以避免由于线程间竞争或文件句柄共享所带来的错误,使得文件操作更加清晰和可控。
总之,尽管C语言的运行时库提供了这种“流式”文件操作API,允许文件写入时自动更新指针,但他认为这是一个非常糟糕的设计,特别是在需要高控制性和高可靠性的环境中。
写出数组
在这段代码的讨论中,我们主要关注如何构建并写出文件中的几个数组,包括标签数组、资产类型数组和资产数组。首先,明确了这些数组的大小,我们通过使用之前定义的变量来确定每个数组的大小。这些大小目前需要限定为 uint32
类型,因为系统使用的是32位模式。
具体来说,我们有三个数组:标签数组(tags array)、资产类型数组(asset type array)和资产数组(assets array)。对于这些数组的大小,程序已经通过之前的设置获取了相关的数值,并且这些值在程序中得到了合理的复用。
接下来,程序需要构建实际的数组并将其写入文件。首先,构建标签数组是直接的,因为它已经在内存中准备好了,只需要在文件中输出即可。而对于资产类型数组,也可以直接按照相同的方式构建和写入。程序通过改变数组类型来调整它们的格式,以确保它们符合文件格式要求。
然后,提到了资产数组的问题。资产数组比较特殊,因为它不仅仅存储一个简单的值,它存储的数据类型会根据不同情况发生变化。这意味着,除了存储资产类型外,还需要存储其他信息。为了适应这种变化,程序在构建资产数组时采取了相应的处理方式。
另外,程序的重点在于如何将这些数组准确地写入文件。标签数组和资产类型数组的构建较为简单,但资产数组需要根据不同条件存储不同的内容。因此,处理资产数组时,程序首先对其进行了必要的注释,以避免在时间紧张的情况下出现错误。
总结来说,这段代码的目的是构建和写入三个主要的数组,这些数组的内容已经通过之前的步骤确定好了。代码的实现过程中,重点是如何组织和存储这些数组,确保它们在文件中的格式和结构是正确的。
跟踪代码
在这段过程里,首先介绍了如何使用自定义的批处理文件来构建项目,并在其中执行测试。批处理文件主要用于简化命令行的输入,使得开发者可以通过更简单的命令进行构建操作。此时,出现了一个问题,即批处理文件未能正确传递需要打开的文件路径,因此无法成功启动调试。
接下来,进入了调试阶段。在此过程中,首先检查了编译时是否包含调试信息,并发现由于删除了 PDB 文件,导致没有生成调试信息。为了修复这个问题,开发者调整了编译设置,确保能够生成调试信息,这样才能在调试时查看代码的运行状态。
在调试过程中,开发者逐步运行了构建过程。构建的核心部分是生成资产文件,这些资产包含了标签、资产类型和资产本身。通过使用预设的数组和类型,程序能够按预期生成这些资产。程序在构建过程中会生成一个文件头,该头部包含了资产的数量、偏移量以及每个数据的大小等信息。
然后,程序通过准备头部并写入相关的资产数据,逐步生成一个完整的资产文件。在写入时,首先处理了文件头,接着是标签数组、资产类型数组,最后是资产数组。在完成这些操作后,程序关闭了文件并退出。这个文件就是最终的资产文件,虽然写入过程并没有完全完成,但已经完成了大部分的工作。
文件的输出包括了一个名为“test.out”的文件,最终目标是生成一个名为“test.HHA”的文件。在过程中,开发者想要通过十六进制编辑器查看生成的文件内容。虽然并未直接使用最优的十六进制编辑器,但开发者依然尝试通过一些工具查看生成的文件。通过这些步骤,开发者能够验证文件是否按预期正确生成。
总结而言,整个过程涉及了文件的构建、调试、调整编译设置、生成文件头和资产数据的写入等多个步骤。这些步骤确保了最终的资产文件正确构建,并且调试过程中通过逐步查看文件内容来确保其符合预期。
tags:
十六进制编辑生成的文件
在这个过程中,我们主要使用十六进制编辑器(HEX 编辑器)来查看我们生成的文件内容,以便验证文件格式和数据是否正确写入。我们将生成的二进制文件加载到十六进制编辑器中,并逐步分析文件结构,确认文件中的数据与内存中准备的数据是否一致。
首先,可以看到文件开头的 4 个字节是我们设置的文件标识符(Magic Number),在这里我们设置为 “HHF”。在十六进制编辑器中,文件前四个字节显示的就是 H、H、F 这三个字符的 ASCII 码。接着是 4 个字节的版本号,该版本号设置为 0,因此这里看到的 4 个字节全部是 0。这证明我们的文件头信息已经正确写入。
接下来,紧跟在版本号后面的是标签数量(Tag Count)。由于我们使用的是小端字节序(Little Endian),因此较低的字节会存储在前面。当查看十六进制数据时,可以观察到类似 “0D 00 00 00” 的数据,这就是标签数量。通过将其转换为十进制,可以看到其数值等于 13(0x0D),这与我们代码中定义的标签数量一致。这进一步验证了标签数量已经正确写入文件中。
随后,我们继续向下查看,可以发现紧随其后的就是标签数组(Tag Array)。在内存中,我们直接将标签数组的内存内容写入文件,因此在十六进制数据中,这些标签的数据会直接以二进制形式呈现。可以观察到不同标签的 ID、数据内容等直接以二进制形式出现,确保标签数组已经正确写入文件。
在标签数组之后,可以看到资产类型数组(Asset Type Array)的数据。这里的资产类型数组同样是以原始内存内容的形式直接写入,因此在文件中可以观察到资产类型数据以连续的二进制数据块形式出现,并且长度与我们定义的资产类型数量一致。通过比对这些数据块的长度和内容,可以确认资产类型数组已经正确写入文件中。
接下来,我们可以在文件中看到资产数据(Asset Data)的内容。这部分数据由我们之前在内存中构造的资产结构直接写入文件,因此它的格式与内存布局完全一致。在十六进制数据中,可以看到连续的数据块,这些数据块包含了资产的 ID、标签索引、资产类型等信息。观察这些数据的连续性和一致性,可以确认资产数据同样已正确写入文件。
值得注意的是,由于我们采用的是小端字节序(Little Endian),因此在查看数值数据时,低字节位总是排在前面。例如,一个 32 位无符号整数 “0D 00 00 00” 代表的数值是 13,而不是 218103811。这也是我们在解析数据时需要特别注意的地方。
在文件最后,我们可以看到文件已经按照预期的结构写入了头部、标签数组、资产类型数组和资产数组。文件中没有包含任何额外的填充或垃圾数据,确保了文件结构的紧凑性和可解析性。这表明我们的文件写入流程已经基本正确完成。
最后,在尝试退出十六进制编辑器时,遇到了一些小问题,但最终还是成功退出了编辑器,并确认文件已经按照预期正确生成。在接下来的工作中,我们将继续完善文件格式,进一步丰富资产数据,并最终确保我们的文件格式符合预期,可以被游戏引擎正确加载和解析。
0D 00 00 00 表示 13 是小端序 (Little Endian)。
✅ 为什么是小端序?
在计算机内存中,整数数据是以二进制形式存储的,而存储方式通常有两种:
- 大端序 (Big Endian):高位字节存储在低地址,低位字节存储在高地址。
- 小端序 (Little Endian):低位字节存储在低地址,高位字节存储在高地址。
我们这里看到的是 0D 00 00 00,这是一个**32位 (4字节)**的无符号整数,表示 13。
如果是小端序存储,则 13 (十进制) 的二进制表示如下:
13 的二进制表示 (32位无符号整数)
13 (十进制) = 0x0D (十六进制)
13 (二进制) = 00000000 00000000 00000000 00001101
✅ 小端序存储方式
小端序的存储方式是:
低字节先存储,即0D 存在最前面,00 00 00在后面。
内存地址 | 数据(十六进制) |
---|---|
0x00 | 0D |
0x01 | 00 |
0x02 | 00 |
0x03 | 00 |
从内存地址低位到高位依次存储:
0D 00 00 00
这就是小端序的存储方式。
✅ 如果是大端序存储呢?
大端序的存储方式是:
高字节先存储,即00 00 00 0D。
内存地址 | 数据(十六进制) |
---|---|
0x00 | 00 |
0x01 | 00 |
0x02 | 00 |
0x03 | 0D |
如果是大端序,我们看到的数据应该是:
00 00 00 0D
但实际上我们看到的是:
0D 00 00 00
这证明它采用的是小端序 (Little Endian)。
✅ 为什么 x86 平台一般使用小端序?
在x86 或 x64平台(Windows/Intel/AMD等架构)中,几乎所有的整数都采用小端序存储。这种存储方式的主要优点是:
- 通过地址递增的方式,可以快速访问低位字节,适合部分计算需求。
- 在读取 16 位、32 位、64 位等数据时,可以通过增加地址直接访问更高字节的数据。
✅ 如何验证它是小端序?
如果你在代码中直接写一个值:
uint32_t value = 13;
fwrite(&value, sizeof(value), 1, file);
在文件中看到的十六进制输出就是:
0D 00 00 00
如果你手动反转字节顺序,变成:
00 00 00 0D
那么它就是大端序。但由于我们使用的是x86/x64平台,默认就是小端序。
✅ 总结
存储方式 | 内存展示 (Hex) | 对应的十进制值 | 备注 |
---|---|---|---|
小端序 | 0D 00 00 00 | 13 | ✅ 正常的x86平台数据 |
大端序 | 00 00 00 0D | 13 | ❌ 不符合当前平台 |
所以,当你在 HEX 编辑器中看到:
0D 00 00 00
表示的整数就是:
13 (十进制)
并且确认它是小端序 (Little Endian)。🎉
要计算 DB 0F C9 3F
在浮点数中的值,我们需要了解它是 IEEE 754 单精度浮点数 (32位 float) 的表示格式。
✅ IEEE 754 单精度浮点数格式 (32-bit float)
IEEE 754 单精度浮点数使用 32位表示一个浮点数,结构如下:
符号位 (Sign) | 阶码 (Exponent) | 尾数 (Mantissa) |
---|---|---|
1位 | 8位 | 23位 |
✅ 将 DB 0F C9 3F
转换为二进制
首先,将 DB 0F C9 3F
转换为二进制:
原始16进制:
DB 0F C9 3F
转换成二进制
十六进制 | 二进制 |
---|---|
DB | 11011011 |
0F | 00001111 |
C9 | 11001001 |
3F | 00111111 |
拼接在一起:
00111111 11001001 00001111 11011011
✅ 解析 IEEE 754 浮点数
分解字段
按照 IEEE 754 格式分解:
名称 | 位数 | 二进制 |
---|---|---|
符号位 | 1位 | 0 |
指数位 | 8位 | 01111111 |
尾数位 | 23位 | 10010010000111111011011 |
✅ 计算符号位 (Sign bit)
- 符号位 =
0
表示正数 - 如果是
1
表示负数
结果:正数
✅ 计算指数位 (Exponent)
指数位采用偏移量(bias)存储:
公式:
e = E − B i a s e = E - Bias e=E−Bias
其中:
- E E E 是指数的真实值
- Bias (偏移量) 对于单精度浮点数是:
B i a s = 127 Bias = 127 Bias=127
指数位二进制:
01111111
转换成十进制:
E = 0 × 2 7 + 1 × 2 6 + 1 × 2 5 + 1 × 2 4 + 1 × 2 3 + 1 × 2 2 + 1 × 2 1 + 1 × 2 0 E = 0 \times 2^7 + 1 \times 2^6 + 1 \times 2^5 + 1 \times 2^4 + 1 \times 2^3 + 1 \times 2^2 + 1 \times 2^1 + 1 \times 2^0 E=0×27+1×26+1×25+1×24+1×23+1×22+1×21+1×20
即:
E = 127 E = 127 E=127
计算真实指数 (e):
e = E − B i a s = 127 − 127 = 0 e = E - Bias = 127 - 127 = 0 e=E−Bias=127−127=0
✅ 计算尾数 (Mantissa)
尾数部分是隐藏1的规范化表示:
IEEE 754 规范中,浮点数尾数前面默认为1,所以实际尾数是:
1 + Mantissa 1 + \text{Mantissa} 1+Mantissa
二进制尾数:
10010010000111111011011
转换成十进制:
1 + 2 − 1 + 2 − 4 + 2 − 7 + 2 − 8 + 2 − 10 + 2 − 11 + 2 − 13 + 2 − 14 + 2 − 15 + 2 − 17 + 2 − 18 + . . . 1 + 2^{-1} + 2^{-4} + 2^{-7} + 2^{-8} + 2^{-10} + 2^{-11} + 2^{-13} + 2^{-14} + 2^{-15} + 2^{-17} + 2^{-18} + ... 1+2−1+2−4+2−7+2−8+2−10+2−11+2−13+2−14+2−15+2−17+2−18+...
我们直接转换(较快速):
1.10010010000111111011011 (二进制)
转换成十进制:
1 + 0.5625 ≈ 1.570796 1 + 0.5625 \approx 1.570796 1+0.5625≈1.570796
✅ 计算最终浮点数的值
浮点数计算公式:
( − 1 ) sign × 2 e × ( 1 + Mantissa ) (-1)^{\text{sign}} \times 2^{e} \times (1 + \text{Mantissa}) (−1)sign×2e×(1+Mantissa)
代入:
( − 1 ) 0 × 2 0 × 1.570796 (-1)^0 \times 2^{0} \times 1.570796 (−1)0×20×1.570796
化简:
1 × 1 × 1.570796 = 1.570796 1 \times 1 \times 1.570796 = 1.570796 1×1×1.570796=1.570796
✅ 验证结果
通过浮点数计算器验证 DB 0F C9 3F
:
👉 结果:1.570796
这个值恰好是π/2 (π除以2)。
✅ 为什么是 π/2?
这个浮点数**DB 0F C9 3F
**,在一些3D引擎或数学库中,常常用于存储 π/2,因为:
π 2 ≈ 1.570796 \frac{\pi}{2} \approx 1.570796 2π≈1.570796
这个值在三角函数计算 (sin, cos, tan) 中非常常见,比如:
sin(π/2) = 1
cos(π/2) = 0
✅ 总结
表示 | 计算结果 |
---|---|
原始十六进制 | DB 0F C9 3F |
IEEE 754 二进制 | 0 01111111 10010010000111111011011 |
符号 | 正数 (0) |
指数 | 0 |
尾数 | 1.570796 |
最终值 | 1.570796 ≈ π/2 |
✅ 💯 推导小结
步骤 | 结果 |
---|---|
符号 | 正数 |
指数 | E = 127 E = 127 E=127,真实指数0 |
尾数 | 1.570796 |
最终结果 | 1.570796 ≈ π/2 |
💡 小技巧
如果你以后遇到类似的十六进制浮点数,可以直接使用 Python 快速验证:
import structhex_value = bytes.fromhex('DB0FC93F')
float_value = struct.unpack('f', hex_value)[0]
print(float_value)
输出:
1.5707963705062866
或者直接用 Windows 自带的计算器:
- 打开计算器 -> 编程模式
- 选择 浮点数 -> 输入
DB0FC93F
- 切换为 十进制
- 结果:
1.570796
为什么说流的位置是一个坏主意?
在流式写入 (stream writing) 中,如果我们采用基于流位置 (stream position) 的方式来写入数据,这种方法存在一些严重的问题,因此并不推荐使用。相反,更推荐使用显式指定写入位置 (write with explicit location) 的方式,因为它更加安全、灵活,并且避免了许多潜在的错误。以下是我们详细的分析和总结:
✅ 为什么基于流位置 (stream position) 的写入方式是错误的
📌 1. 基于流位置的写入方式
假设我们通过流式写入的方式进行文件写入:
write(a);
write(b);
这里,write(a)
表示将数据 a
写入文件,接着 write(b)
写入数据 b
。在流式写入模式下,文件的当前位置 (file pointer) 会自动向后移动,并保持隐藏状态,下一个数据会接着前一个数据写入的地方继续写入。
🚨 为什么这很糟糕?
这种基于流位置的写入方法存在非常多的隐患,主要原因是:
- 流位置是隐式的 (hidden state),即我们无法直接知道当前文件指针在哪里。
- 如果某些情况下写入的顺序变化或者写入中断,会导致文件内容完全混乱。
- 多线程 (multi-threading) 环境下更容易发生写入错位。
✅ 为什么基于指定位置的写入方式更好
📌 2. 基于指定位置 (explicit location) 的写入方式
正确的做法是采用显式指定位置的写入方式,即:
write(location_a, a);
write(location_b, b);
这里的 write(location_a, a)
表示:
- 明确指定写入位置,即
location_a
。 - 明确指定数据内容,即
a
。 - 无论发生任何情况,
a
一定会被写入location_a
。
这样有什么好处?
- 写入位置完全由我们指定,不会受到流位置的影响。
- 不依赖文件指针,所有的写入都是确定的。
- 即使是多线程情况下,也不会发生数据错位。
✅ 为什么流位置模式是灾难
接下来我们深入分析,为什么基于流位置的写入模式在不同情况下都会崩溃。
📌 情况一:队列 (Queue) 导致的错位
假设我们有一个任务队列,其中包含两个任务:
queue.push(write(a));
queue.push(write(b));
由于队列是无序处理的,可能会出现以下情况:
- 任务
write(b)
被提前执行。 - 任务
write(a)
在write(b)
之后执行。
文件内容可能会变成:
[b data][a data]
而不是:
[a data][b data]
这完全破坏了我们想要的文件内容结构!
📌 情况二:多线程 (multi-threading) 导致的错位
如果我们的程序是多线程的:
Thread 1:
write(a);Thread 2:
write(b);
线程1和线程2都想写入数据,而文件指针 (stream position) 是共享的:
- 线程1将文件指针移动到
a
的位置,但还未写入。 - 线程2将文件指针移动到
b
的位置,并成功写入b
。 - 线程1继续写入,但文件指针已经被改变,最终
a
写入到了错误的位置。
最终文件内容可能变成:
[b data][a data]
这是灾难性的错误!数据写入错位,内容混乱,文件无效。
📌 情况三:操作系统文件句柄限制
如果操作系统的文件 API 只能基于流位置进行写入 (seek+write
),那么:
- 每个线程都需要一个独立的文件句柄。
- 文件句柄的数量非常有限,而且管理成本非常高。
- 文件写入效率大大降低。
但是如果我们采用指定写入位置的方法:
write(location_a, a);
write(location_b, b);
我们只需要一个文件句柄,所有的线程都可以并发写入文件而不会发生任何冲突。
✅ 为什么流位置 (stream position) 是隐藏状态 (hidden state)
我们可以将流位置理解成文件指针:
- 文件指针 (file pointer) 本质上是一个隐藏状态,用户无法直接操作。
- 每次
write()
时,文件指针自动移动,但用户无法确定位置。 - 在多线程、多队列环境下,文件指针极易错位,导致数据混乱。
✅ 为什么显式写入位置是最优解
我们采用:
write(location_a, a);
write(location_b, b);
意味着:
- 写入位置完全由我们指定,不会受文件指针影响。
- 避免了多线程冲突,每个线程直接指定位置写入,不影响其他线程。
- 避免了队列错乱,即使写入顺序发生变化,数据依然是正确的。
- 无需多个文件句柄,所有线程共享同一个句柄即可。
✅ 如何在多线程环境下安全写入
我们可以设计一个结构体来管理写入:
struct MyStream {u64 position;FileHandle file;
};
然后通过:
stream.write(a);
stream.write(b);
它会自动:
- 记录当前的写入位置 (
position
)。 - 每次写入数据时,内部调用:
write(position, data);
position += data.size;
- 这样保证了即使在多线程环境下,也不会出现错位。
✅ 为什么基于流位置的写入设计如此糟糕
总结一下,基于流位置的写入存在以下致命问题:
问题类型 | 影响 |
---|---|
隐藏状态 | 无法直接知道当前文件指针 |
多线程问题 | 写入错位、数据混乱 |
队列问题 | 异步写入顺序错乱 |
文件句柄 | 需要多个文件句柄 |
而采用显式写入位置的方式,完全避免了以上问题。
✅ 最佳实践
❌ 不推荐:
write(a);
write(b);
✅ 推荐:
write(location_a, a);
write(location_b, b);
✅ 💡 总结
写入方式 | 是否推荐 | 存在问题 |
---|---|---|
基于流位置写入 | ❌ 不推荐 | 容易写入错位,隐藏状态 |
指定位置写入 | ✅ 强烈推荐 | 位置明确,线程安全 |
最终的核心思想是:
永远不要依赖流位置进行写入,始终显式指定写入位置。
这样不仅可以避免多线程冲突,还可以确保文件内容始终正确,并且减少文件句柄的消耗。
✅ 💎 额外提示
如果必须要在高层 API 中模拟流式写入,可以自己封装:
struct MyStream {u64 position;FileHandle file;void write(data) {write(position, data);position += data.size;}
};
这样就避免了文件指针被其他线程破坏,同时保留流式写入的便利性。
这就是为什么:
- 永远不要基于流位置进行写入。
- 始终显式指定写入位置才是正确的。
💯 这才是安全、高效的文件写入方式! 🚀
你是否担心“信任”文件格式会带来安全问题?例如,有人可能会说他们制作了一些修改过的资源,给出一个精心制作的 .hha 文件,运行任意代码?
我们对文件格式的信任不会带来安全问题的担忧,即使有人声称制作了一些经过修改的资源文件,并提供了一个特制的 HHH 文件,该文件可能会执行任意代码,我们对此也并不关心。原因在于,我们的代码并不是为安全性而设计的,也不会将其视为一个需要高度安全性的关键任务应用程序。因此,我们默认认为,如果有人以某种方式在自己的计算机上以提升的权限运行我们的应用程序,那么一切后果都与我们无关,我们不会为此负责。毕竟,这只是一个游戏,而不是一个面向安全需求的应用。
我们也不会将我们的代码提交给那些需要进行严格安全审查的流程,因此,我们没有必要专门为了防止这种情况而特意设计或者测试代码的安全性。我们对模糊测试 (fuzz testing) 等安全测试方面并不感兴趣,因为我们的主要目标是开发游戏,而不是确保它具备高度安全性。
从更广泛的角度来看,实际上,操作系统本身才应该承担起防止用户因游戏文件遭受安全威胁的责任。理论上来说,如果一个操作系统设计得足够好,那么用户运行游戏时就根本不需要考虑游戏文件是否会执行任意代码的问题。以 Windows 为例,如果它是一个合格的操作系统,它本可以非常轻松地通过限制游戏的权限,确保游戏只能访问某些特定资源,比如显卡、声音设备或游戏存储区域,而无法对系统的核心资源造成威胁。
但实际上,Windows 长期以来在这方面做得非常糟糕。用户安装游戏时经常需要授予管理员权限,甚至有些游戏必须以提升的权限才能运行,这本身就已经是一个极大的安全隐患。更荒谬的是,Windows 并没有在操作系统层面提供一种简单的方式,让用户可以在不信任游戏开发者的情况下,依然放心运行游戏。因此,用户不得不依赖开发者自己去保证游戏的安全性,这本来就是一种非常不合理的情况。
我们认为,用户支付了 Windows 高昂的授权费用,那么确保游戏不会对系统造成危害,本应该是操作系统的职责,而不是游戏开发者的职责。微软有能力将 Windows 打造成一个更加安全的操作系统,比如可以通过沙盒机制或权限管理系统,让游戏只能访问特定的资源区域,避免任意代码执行的可能性,但他们选择了不这么做,反而让用户承担了更多的安全风险。
此外,大部分游戏开发者并不具备深入的安全研究能力,也无法完全确保自己的游戏文件格式是绝对安全的。我们自己也不具备专业的安全审计能力,因此即使我们愿意尝试防止一些潜在的安全风险,我们也无法做到万无一失。因此,我们认为,要求游戏开发者具备专业的安全知识,并确保游戏文件完全不会被恶意利用,是一种不切实际的想法。
即便我们尝试做一些基本的安全措施,比如对文件格式进行一些检测或者限制,最终我们仍然无法确保这些措施能防止所有的漏洞。因为现代操作系统本应当在用户层面保护用户免受这些威胁,而不应该让开发者承担这部分责任。我们认为,开发者不应该被迫成为安全专家,否则将大大增加开发成本,并且也无法保证最终的产品是绝对安全的。
因此,我们不会为了防止文件格式被恶意利用而进行大量的安全工作,也不会把精力放在进行严格的安全审计上。我们的任务是开发游戏,而不是提供一个经过严格安全审计的企业级应用。我们愿意在合理的范围内采取一些基本的预防措施,但如果用户出于某种原因以提升权限运行游戏并因此造成了系统损害,这并不是我们的责任,而是操作系统的设计缺陷导致的结果。
总之,我们的游戏不考虑针对文件格式进行特殊的安全防护,因为操作系统本身就应该承担起保护用户免受游戏威胁的责任。而要求游戏开发者在开发过程中兼顾安全性,并确保文件格式不会被恶意利用,这本身就是一种不合理的要求。我们更希望将所有精力放在开发游戏内容和功能上,而不是去修补操作系统本应负责的安全漏洞。
使用断言和编写独立的测试函数/程序检查结果之间有什么主要区别?你何时选择使用其中之一?
在开发中,使用断言(asserts)和编写独立的测试函数(test functions)在本质上有很大的不同,它们各自适用于不同的场景。断言更适合在游戏开发中使用,而测试函数则主要用于特定场合以确保某些功能的正确性。
断言的主要作用是在运行时捕获错误,即当程序实际运行时,如果某些预期的条件未被满足,断言就会触发,从而提示存在潜在的错误。在游戏开发中,通常很难或者几乎不可能编写全面的测试函数来覆盖所有可能的情况。这是因为游戏中存在大量复杂的实时交互、动态行为以及难以预测的环境,导致无法通过编写固定的测试程序来验证所有功能。因此,断言成为一种非常有价值的工具,因为它可以直接嵌入到实际的代码路径中,当出现违反预期的情况时立即中断程序并提示错误,从而快速定位和修复问题。
断言的另一个优点是它们能够反映出真实的运行时环境,而不是人为构造的测试环境。在测试环境中,很多问题可能不会暴露出来,而在真实的运行环境中,由于玩家的操作、输入数据或游戏状态的变化,很容易触发潜在的漏洞和错误。而断言正是通过检测这些意外情况来捕获问题,因此在游戏开发中,使用断言通常是首选的方式。
虽然断言在游戏开发中非常有用,但有时仍然需要编写测试函数来验证一些特定的功能是否正常工作。这种情况主要出现在以下几种场合:
-
需要高度可靠的功能:当某些模块的错误极难被发现,或者错误发生后影响非常严重时,就需要编写测试函数。例如,音频混音器(mixer)的功能就非常重要,因为如果混音器存在问题,可能会导致整个游戏的声音异常甚至崩溃。此时可以编写测试函数,通过传入各种不同类型的音频缓冲区,测试混音器在不同情况下是否能正常工作,从而尽早发现潜在问题。
-
数学函数验证:在开发中,如果需要自己实现一些数学函数(例如三角函数、矩阵运算等),就需要通过测试函数来验证计算结果的正确性。例如,假设需要编写一个自定义的余弦(cosine)函数,那么就需要通过测试函数,将其计算结果与标准库的结果进行对比,确保它们的差异在可接受的误差范围内。
-
内存分配器验证:在开发自定义内存分配器时,也需要编写测试函数对其进行验证。通过不断向内存分配器请求、释放内存,并观察内存分配器的状态,确保它不会发生内存泄漏、内存碎片化或非法访问等问题。在这种情况下,测试函数的目标就是尽可能通过极端或异常的使用情况来触发错误,以验证分配器的健壮性。
-
数据操作验证:在开发中,如果有模块涉及到对数据的操作或查询(例如数据库查询、数据序列化/反序列化等),就非常适合通过测试函数进行验证。例如,假设存在一个数据库查询函数,需要确保它在处理边界数据、无效数据、空数据或大量数据时不会崩溃或产生错误结果,这种情况下就需要通过编写测试函数来验证其行为。
需要注意的是,即使在编写测试函数时,断言依然非常有用。测试函数的核心目标之一是尽可能促使代码暴露问题,而断言能够在问题出现的瞬间捕获错误并中断程序运行,从而帮助快速定位问题。因此,测试函数和断言在很多情况下是配合使用的,测试函数的作用是模拟各种极端或特殊的运行环境,而断言的作用是确保这些环境下的行为符合预期。
多线程是否会对读取资源文件造成问题?
在多线程环境下读取资源文件(assets file)通常不会带来任何问题,主要原因在于读取操作的设计方式确保了线程之间不会发生冲突。在多线程中读取资源文件时,每个线程都会向操作系统发送明确的读取请求,即指定需要读取的文件位置以及读取的数据大小。由于请求是精确的,因此多个线程可以同时读取同一个资源文件而不会互相干扰。
这种设计的核心在于所有的读取操作始终围绕固定的内存地址和大小进行,而不会存在多个线程同时尝试修改文件或写入数据的情况。这种情况下,即使多个线程同时读取同一个资源文件,操作系统也能够很好地处理这些请求并确保数据的完整性和一致性。
操作系统的文件读取接口本身就支持多线程并发访问。只要读取的操作不涉及写入,操作系统就不会在同一文件上设置排他锁(exclusive lock),而是允许多个线程同时进行只读操作。即使线程之间的读取请求发生了重叠,操作系统也会通过内部机制保证数据的有序性和一致性。因此,在读取资源文件的场景下,根本不需要担心线程之间的竞争或数据破坏问题。
此外,使用固定的读取位置和大小还有一个重要好处,即有助于提升文件读取的效率。当线程明确指定读取的位置和数据长度时,操作系统可以通过优化磁盘访问的方式减少文件读取的开销。例如,当文件内容已经被加载到磁盘缓存中时,操作系统可以直接从缓存中返回数据而不必再次访问磁盘,从而显著提升读取速度。这种方式对于游戏开发尤其重要,因为游戏通常需要频繁加载资源文件(如纹理、音效、模型等),而多线程读取配合操作系统的缓存机制可以有效减少读取延迟,提升游戏运行的流畅度。
还有一个非常重要的因素是:在设计文件读取逻辑时,避免了跨线程共享数据或者修改数据的情况。因为读取文件的线程仅仅是向操作系统请求指定位置的数据,并不会直接修改文件或向文件中写入内容,这就避免了由于数据竞争而导致的数据损坏或异常情况发生。
如果未来需要在多线程环境下进行资源文件的写入操作,那么问题的复杂度将会显著增加。因为文件写入涉及到修改文件内容,如果多个线程同时对同一文件进行写入操作,就可能出现数据竞争、文件内容损坏或数据不一致等问题。因此,为了确保多线程环境的稳定性,所有写入操作通常都会通过一个专门的线程或任务队列进行集中处理,而不会允许多个线程直接修改文件。
值得一提的是,即使是读取文件,如果不加以限制,让多个线程任意读取大量不同位置的数据,仍然有可能导致磁盘 I/O 瓶颈或者缓存失效等问题。因此在设计文件读取流程时,通常会尽量按需读取,避免在短时间内对磁盘发起大量的随机访问请求。例如,可以将资源文件的数据进行分块(chunk),每次只加载需要的数据块,从而减少不必要的磁盘读取操作,提高读取效率。
总结来说,多线程读取资源文件是完全可行且不会产生问题的,只要:
- 所有线程只进行读取操作,不涉及文件的写入或修改;
- 每个线程明确指定读取的位置和大小,避免随机或不确定的读取;
- 依赖操作系统的并发读取能力,确保数据读取的完整性和一致性;
- 尽量避免大量的随机读取,以提升磁盘读取的效率。
通过遵循这些原则,可以确保在多线程环境下高效、稳定地读取资源文件,而不必担心数据竞争或文件损坏的风险。
如果 C++ 有反射功能,这会是你使用它的地方吗?例如,用于反射你想要包含在资源包中的类型
如果 C++ 具备内省(Introspection)功能,在某些情况下确实可以用来生成资源包中的数据类型,但在当前的场景下使用内省的意义并不大。原因在于,当前的资源包结构设计非常简单,我们只需要处理**标签(tags)、资源(assets)和资源类型(asset types)**三种内容,因此手动编写这些数据的读写(file I/O)代码已经足够简单且高效,内省机制并不会为我们节省太多时间,反而可能引入不必要的复杂性和额外的代码负担。
通常情况下,内省机制最有价值的场景是处理大量结构化存储的复杂数据,例如在大型游戏中,如果游戏数据结构非常庞杂、动态且变化频繁,并且需要持续编写文件读写代码时,内省就可以极大地减少重复性工作。例如,如果游戏的关卡设计高度依赖于关卡编辑器,并且编辑器中有大量复杂的游戏元素(如陷阱、机关、脚本事件等),这些元素各自包含不同的数据结构,并且需要存储到文件中,那么通过内省生成代码就能大大减少工作量。
具体来说,如果游戏的数据结构是高度结构化的,例如:
- 有很多不同类型的对象(例如敌人、陷阱、机关、交互物品等);
- 每种对象都包含复杂的数据结构,并且需要定期进行存储和加载;
- 很多对象之间存在关联性,比如陷阱触发某个脚本、门钥匙与特定门绑定等。
在这种情况下,使用内省可以使我们自动生成文件读写代码,避免手动编写大量冗长的序列化和反序列化逻辑。例如,通过内省,代码可以自动遍历结构体中的所有字段,将其转换为二进制格式写入文件,并在读取时自动解析并恢复结构体。这样可以极大减少开发人员的工作量,并避免因为手动编码而导致的数据错误。
然而在当前的开发场景中,资源包的设计十分简单:
- 标签:仅仅是描述资源的一些附加信息(如声音的用途、贴图的用途等);
- 资源:游戏中实际使用的二进制数据(如纹理、音效、模型等);
- 资源类型:仅仅是区分资源类型的简单标识(如音效、纹理、模型等)。
由于数据结构非常简单,文件 I/O 代码所需的工作量非常少,因此即使 C++ 支持内省机制,使用它所带来的收益也非常有限。我们直接手动编写这些文件读写代码会更加直观、清晰,同时也不会引入额外的复杂性。因此,在当前的开发需求下,内省机制并没有显著的优势。
另一个值得注意的地方是,我们的游戏采用了程序化生成的设计理念,这意味着游戏中的大部分内容并不是依赖关卡编辑器或手工设计的,而是通过算法自动生成的。例如:
- 地形通过噪声函数生成;
- 敌人分布通过随机算法控制;
- 物品掉落通过概率表确定。
在这种模式下,我们几乎不需要复杂的数据结构,也不会存在大量手工配置的数据文件,因此也就没有大量的文件读写需求。这进一步减少了内省机制的使用价值。
如果将来我们需要开发一个更复杂的游戏,比如:
- 需要大量手工设计的关卡;
- 关卡中包含复杂的交互元素,如陷阱、门、脚本触发器等;
- 数据结构庞杂且需要频繁修改;
那么此时使用内省生成文件 I/O 代码就会非常有价值,可以减少开发者大量的工作量,并确保数据读写的准确性。但是在当前的开发需求下,我们的资源文件结构非常简单,直接手写文件读写代码是最优解,内省反而会增加开发和维护的复杂性,因此没有必要引入。
总结如下:
- 内省机制适用于数据结构复杂、数据类型繁多、数据存储需求庞大的场景,尤其是关卡编辑器、脚本系统、大型数据库等;
- 当前的资源包结构非常简单,仅包含标签、资源和资源类型三种基础内容,文件 I/O 代码极为简洁,因此使用内省机制没有明显的收益;
- 游戏采用程序化生成,减少了对手工数据的依赖,因此也不需要通过内省生成文件 I/O 代码;
- 在当前场景下,直接手写文件 I/O 代码更加直观、清晰且易于维护,而引入内省反而可能增加代码复杂度。
因此,在我们的项目中,内省机制没有实用价值,手写文件读写代码是最佳的选择。但如果未来需要开发一个高度依赖手工设计的大型游戏,或者需要频繁修改数据结构,那么内省机制将会非常有用。
什么是内省(Introspection)机制?
内省(Introspection)是一种程序在运行时获取自身数据结构和类型信息的能力。简单来说,内省允许程序在运行时动态地查看、访问和操作自己的代码、数据结构或者对象信息,而不需要提前手动编写很多重复性的代码。
在编程中,很多时候我们需要处理复杂的数据结构,比如:
- 保存和加载数据:将游戏中的对象保存到磁盘文件中(如将游戏存档保存为文件),或者将文件中的数据重新加载回内存中;
- 网络通信:将数据结构转换成字节流在网络中传输;
- 序列化和反序列化:将内存中的数据结构转换为文件或网络流的格式,再转换回来;
- 调试工具:在调试中直接查看对象的所有字段和值;
如果没有内省机制,开发者就必须手动编写大量重复的代码,告诉程序如何读写每一个数据结构的每一个字段,这样的工作既繁琐又容易出错。而内省机制可以在运行时动态获取数据结构的信息,从而自动完成这些繁琐的任务。
✅ 内省的核心能力
内省主要具备以下几种能力:
1. 获取数据结构的类型信息
通过内省机制,程序可以在运行时动态查看某个对象的数据类型。例如:
struct Player
{int health;float speed;std::string name;
};
如果 C++ 支持内省机制,我们可以在运行时直接获取 Player
结构体的字段信息:
TypeInfo type = Introspect<Player>();
for (Field field : type.fields)
{std::cout << "Field name: " << field.name << ", Field type: " << field.type << std::endl;
}
输出:
Field name: health, Field type: int
Field name: speed, Field type: float
Field name: name, Field type: std::string
也就是说,程序本身可以在运行时知道:
- 这个结构体有几个字段;
- 每个字段的名字是什么;
- 每个字段的类型是什么;
- 每个字段在内存中的偏移量是多少;
2. 自动化文件读写(序列化/反序列化)
假设我们需要将 Player
结构体保存到磁盘文件中,并在下次运行时加载回来:
传统做法(没有内省):
void SavePlayer(Player* p, FILE* file)
{fwrite(&p->health, sizeof(int), 1, file);fwrite(&p->speed, sizeof(float), 1, file);fwrite(p->name.c_str(), p->name.size(), 1, file);
}void LoadPlayer(Player* p, FILE* file)
{fread(&p->health, sizeof(int), 1, file);fread(&p->speed, sizeof(float), 1, file);fread(buffer, bufferSize, 1, file);p->name = std::string(buffer);
}
这种方式的问题是:
- 每次新增字段都需要手动修改保存和加载代码;
- 如果字段类型变化,读取和写入代码也要一起修改;
- 写的代码非常重复且容易出错。
如果有内省:
void SavePlayer(Player* p, FILE* file)
{TypeInfo type = Introspect<Player>();for (Field field : type.fields){fwrite(field.GetPointer(p), field.size, 1, file);}
}void LoadPlayer(Player* p, FILE* file)
{TypeInfo type = Introspect<Player>();for (Field field : type.fields){fread(field.GetPointer(p), field.size, 1, file);}
}
✅ 只需要写一次通用的文件读写函数,不管 Player
中增加多少字段,读写代码都不需要修改!
✅ 避免了大量的手动重复代码,减少了出错的可能性。
✅ 3. 自动生成编辑器UI
如果我们想给 Player
对象添加一个编辑器窗口,可以在游戏运行时直接修改角色的属性。
传统做法(没有内省):
ImGui::SliderInt("Health", &player.health, 0, 100);
ImGui::SliderFloat("Speed", &player.speed, 0, 10);
ImGui::InputText("Name", &player.name);
如果 Player
结构体添加了一个新字段:
struct Player
{int health;float speed;std::string name;int armor; // 新增的字段
};
那么我们必须手动修改 UI 代码,添加新的 ImGui::SliderInt("Armor", &player.armor);
。
如果有内省:
TypeInfo type = Introspect<Player>();
for (Field field : type.fields)
{if (field.type == Type::INT)ImGui::SliderInt(field.name, (int*)field.GetPointer(&player), 0, 100);else if (field.type == Type::FLOAT)ImGui::SliderFloat(field.name, (float*)field.GetPointer(&player), 0, 10);else if (field.type == Type::STRING)ImGui::InputText(field.name, (char*)field.GetPointer(&player));
}
✅ 只需要写一次通用的 UI 代码,新增字段时不需要修改 UI 代码!
✅ 节省大量重复的 UI 开发时间。
✅ 4. 自动生成网络通信/数据库同步代码
如果我们要将 Player
对象的数据发送到网络服务器,或者存储到数据库中:
传统做法(没有内省):
SerializeInt(player.health);
SerializeFloat(player.speed);
SerializeString(player.name);
如果有内省:
TypeInfo type = Introspect<Player>();
for (Field field : type.fields)
{Serialize(field.GetPointer(&player), field.size);
}
✅ 新增字段时,不需要修改任何网络传输代码!
✅ 节省开发时间,降低错误风险。
✅ 5. 支持反射(Reflection)
内省(Introspection)是反射(Reflection)的前置条件。
- 内省:获取结构体的信息(字段、名称、类型等);
- 反射:不仅能获取信息,还能动态操作对象的字段。
例如,如果 C++ 支持反射,我们甚至可以动态修改对象的字段值:
TypeInfo type = Introspect<Player>();
for (Field field : type.fields)
{if (field.name == "health")field.SetValue(&player, 100);
}
这个功能在脚本引擎、关卡编辑器中非常有用。
✅ 为什么 C++ 没有内置内省?
1. C++ 设计理念
C++ 是面向性能和效率的语言,它的设计理念是:
一切在编译期确定,不做运行时消耗。
内省需要在运行时动态获取结构信息,这会带来额外的开销,C++ 不愿意为了动态性牺牲性能。
2. 可选解决方案
如果需要内省功能,可以使用:
- 第三方库:如 RTTI、Reflective 等;
- 生成代码:通过脚本工具生成序列化代码;
- 宏+模板:利用 C++ 宏和模板模拟内省功能。
✅ 总结
功能 | 无内省 | 有内省 |
---|---|---|
文件读写 | 手动写读写代码 | 自动生成读写代码 |
UI 界面 | 手动添加UI字段 | 自动生成UI |
网络通信 | 手动编写传输代码 | 自动生成通信代码 |
数据库 | 手动同步数据库 | 自动生成数据库映射 |
如果 C++ 原生支持内省,将极大减少开发者的工作量,但也会牺牲一定的运行时性能。
因此,C++ 目前并未内置内省功能,而是交给开发者自行选择。
为什么不直接把整个文件读取为字符串并根据需要解析,然后写入时也做同样的操作?
在处理资产文件时,我们不考虑将整个文件作为字符串一次性读取并解析。原因是这些文件可能会非常大,甚至超过机器内存的容量。比如,可能会有数十GB甚至更大的文件,因此将整个文件加载为字符串是不可行的。为了支持更大的资产文件,我们的目标是能处理诸如《侠盗猎车手》那样的大型资产文件。
为了实现这一点,我们将文件设计为包含一些前置的二进制数据,这部分数据通常较小(最多三四兆),它包含了所有资产的相关信息,但是不包括实际的资产数据(比如图像、声音等)。这部分数据类似于文件的“头部”和数组,可以通过直接加载这一小段数据来快速获取关于所有资产的描述,而无需一次性读取所有内容。
接下来,我们使用一个资产流系统,这个系统已经能够处理游戏中的资产流媒体。当游戏需要某个资产时,流系统会根据从资产文件中加载的目录信息,直接跳到文件中相应的部分,提取出需要的数据并继续运行。通过这种方式,即使资产文件非常大,我们也能够保持即时加载,不需要等待,确保了资产的实时流式读取,没有加载延迟。
这种方法的优势是,无论资产文件多大,读取过程都能够保持流畅和高效。这种设计不仅支持了超大文件的管理,还保证了游戏能够平稳运行,不会因为文件过大而造成加载卡顿或性能问题。
另一种选择是内存映射文件,然后按照自己的方式操作。尽管在旧款主机上可能无法使用
不推荐使用内存映射文件的主要原因有两个:
-
32位系统的限制:内存映射文件的操作需要将整个文件映射到内存中,而在32位系统上,内存地址空间非常有限,通常无法映射超过一个GB的文件。这对于运行32位操作系统的机器(比如某些老旧的Windows XP系统或某些设备)来说,是个大问题。因此,如果目标平台是32位的机器,就不适合使用内存映射文件,尤其是考虑到许多旧系统仍然使用32位版本的操作系统。
-
操作系统的内存管理不确定性:内存映射文件的另一大问题是,操作系统何时将文件中的数据加载到内存中是不可预测的。操作系统可能在任何时间将文件的部分数据加载到内存中,这对于需要精确控制内存使用的应用来说,可能会带来不必要的开销。为了避免这个问题,必须使用后台线程来管理内存映射文件的加载,这使得程序的行为更加隐式,难以控制。
与其依赖操作系统在背后自动加载文件数据,不如直接向操作系统发出明确的请求,指定加载哪些文件块,并在数据准备好时通知程序。这种方式更加清晰,并且可以有效控制程序的内存使用,避免操作系统做出不符合需求的内存管理决策。例如,操作系统可能会将不必要的文件数据替换出内存,而优先保留其他程序需要的数据。
因此,除非对内存映射文件的使用有明确的计划和需求,否则不建议使用这种方式。它不适合大多数情况下的文件管理,特别是在需要精细控制内存使用和确保高效流式读取时。
艺术家是否与资源打包工具互动,还是完全自动化的?例如,如果他们立即想要预览他们的艺术作品在游戏中的效果
艺术家与资产打包器的互动通常是一个独立的过程。在某些情况下,艺术家们希望立即预览他们的艺术作品在游戏中的效果,这通常需要一个自动化的机制来加快这一过程。
在实际操作中,有一些工作正在进行,目的是通过优化流程,使得资产更新和反馈变得更加高效。具体来说,这包括通过一些工具和流程改进来加速资产的更新和预览,使得艺术家可以更快速地看到他们的作品在游戏中的表现。
我被教导使用异常处理,因为它不会使代码因错误检查而变得复杂
使用异常处理的做法通常被认为是一种不好的编程实践,尤其是在C++中。虽然一些人可能会认为异常可以避免代码中充斥着错误检查逻辑,但实际上,许多优秀的程序员并不支持这种做法。实际上,不知道有哪位优秀的程序员认为异常处理是一个好主意,因为大多数开发者认为它带来的是复杂性和不可控的风险。
异常处理,尤其是C++的异常处理,不仅会让程序逻辑变得混乱,而且它们的性能开销和引入的复杂性可能会严重影响代码的可维护性和稳定性。因此,推荐的做法是避免在任何情况下使用异常。简单来说,作为编程的经验法则,应该避免在代码中使用异常处理机制。
当我说“内存映射文件”时,我是指作为 fread() 的替代方案,而不是流式加载的替代方案。使用 fread() 实际上会将所有数据复制两次:先从缓冲区缓存到 FILE* 缓冲区,再从缓冲区复制到最终目标。通过内存映射文件复制时只会进行一次复制
在今天的讨论中,主要内容集中在资产文件的管理、调试和输入输出(I/O)操作上。以下是详细的总结:
-
关于内存映射文件: 讨论中提到内存映射文件是作为
fread
的替代方案。内存映射允许操作系统知道程序需要操作整个文件,从而可能提高效率,避免重复复制数据。然而,内存映射的使用存在一些问题,尤其是当涉及32位系统时,内存映射文件的大小受到限制,因为文件必须完全适配操作系统的内存空间。此外,内存映射的操作是隐式的,可能导致内存管理上的问题,因此通常不建议在游戏开发中使用。 -
处理音频和图像文件: 在编程中,需要处理和写入图像(bitmaps)和音频文件(wave files)作为资产。为了支持大量数据的流式加载,需要将这些资产的基本信息写入资产文件中,而实际的图像和声音文件会以某种流式方式被加载。这样可以避免一次性加载整个文件,提升游戏的性能和响应速度。
-
处理资产文件: 完成资产文件的编写后,下一步将是实现资产文件的读取过程。虽然读取过程并不复杂,但它需要一些时间来实现,因为涉及到如何在Windows平台上正确处理文件输入输出。对于Windows平台,涉及到“重叠I/O”的操作,即在后台同时进行多项输入输出操作,以提高效率和减少延迟。
-
调试和文件输入输出: 今天的讨论还涉及到调试音频和文件I/O操作。调试过程中通过解决一些小问题,程序员能更好地理解和优化资产文件的读取和写入操作。
-
未来的工作计划: 未来的工作将继续关注资产文件的处理,包括完成剩余的资产写入工作,并开始实现资产文件的读取。与此同时,计划展示如何在Windows上实现高效的重叠I/O操作,以提高性能。