演示资产系统中的一个 bug
我们留下了个问题,你现在可以看到,移动时它没有选择正确的资产。我们知道问题的原因,就在之前我就预见到这个问题会出现。问题是我们的标签系统没有处理周期性边界的匹配问题。当处理像角度这种周期性的标签时,系统不知道如何匹配最接近的值,这就是问题的根源。因此,今天我们需要花一点时间在黑板上讲解这个问题,看看该如何修复它。
黑板:周期性标签匹配
我们面临的问题是如何处理周期性的标签匹配。昨天,有人提出了一个很好的建议,即为了统一处理所有标签,或许我们可以引入“所有标签都是周期性”的概念,对于那些不想作为周期性处理的标签,我们可以为它们设置一个非常大的周期。这是一个非常不错的思路。接下来,我将详细解释一下我们遇到的问题,并探讨如何实施这个建议,即让所有标签都有一个周期,但将其设置为非常大,以免影响那些不需要周期性处理的标签。
问题的根源
我们的核心问题在于使用了一个叫做 atan2 的函数。这个函数的作用是将一个方向向量转换为一个角度,表示物体朝向的方向。举个例子,当我们使用方向向量(如速度向量)时,atan2 会将其转化为一个角度,这样就能得到一个标量值,用作标签值。我们可以根据这个标签值来从资产库中匹配对应的精灵(比如玩家移动的方向,或者其他物体的移动方向)。
问题出现在 周期性 上。atan2 返回的角度是周期性的,意味着当角度转动一圈(360度)时,它会回到初始位置。比如,如果我们转动了360度,就会回到0度,而不是转到一个新的角度。为了匹配资产,我们不能简单地比较两个角度的标量值,像0度和360度,虽然它们看起来差距很大,但它们其实是相同的,都是代表同一个方向。因此,我们需要一种方式来判断这些看似不同的值其实是接近的,这就是周期性带来的问题。
atan2 的输出范围
atan2 返回的结果是围绕0对称的,它给出的角度范围是 -180 到 +180,而不是从0到360。换句话说,如果角度是0,atan2 会返回 -180 到 +180 之间的值,分别表示逆时针和顺时针的180度。如果用弧度来表示,它的范围是 -π 到 +π。因此,我们的目标是让这个匹配机制适应这样的角度范围。
解决方案
我们可以将角度范围编码成 -π 到 +π,这样的匹配会比较容易。比如,某个标签值可能对应 0、±π/2、π 和 -π/2,这样就能够很自然地进行匹配。而且,负π和π实际上是一样的,应该匹配到同一个标签。
对于那些不需要周期性处理的标签,我们可以简单地为它们设置一个非常大的范围,比如从 -100000 到 100000,这样就避免了它们与周期性标签冲突,因为这个范围远远超过了我们需要处理的角度范围。只要这个范围足够大,不会与周期性标签的范围交错,就可以避免错误匹配。
总结来说,通过将周期性标签的范围设置为 -π 到 +π,对于不需要周期性匹配的标签设置一个很大的范围,这样就能够高效且准确地处理标签匹配问题。
黑板:“邻域运算符”
问题的核心是如何在匹配过程中处理周期性角度。在周期性标签的匹配中,当我们有一个角度 A 和一个角度 B,假设我们想要将这两个角度与一个已设置为 π 的角度进行匹配时,我们需要避免计算出 A 和 B 与 π 的差异非常大,导致不正确的匹配。换句话说,我们希望能够处理这种情况,使得无论角度如何旋转,能够正确地认为它们是接近的。
问题的背景
对于周期性角度,我们希望当角度 A 和角度 B 相差很大时,能够将它们视为接近的,而不是认为它们在圆的两侧,相差接近 360 度。例如,当 A 是 350 度,B 是 10 度时,它们实际上应该是非常接近的,因为在 360 度的周期内,它们实际上只相差 20 度。这个问题的解决方案是引入一个“邻域操作符”(neighborhood operator)。
邻域操作符的作用
邻域操作符的作用是计算两个角度的最近距离,而不是简单地通过角度值来比较它们的大小。邻域操作符将两个值映射到它们之间的最小距离,而这个距离是相对的,考虑了周期性的因素。
假设我们有两个角度 A 和 B,我们希望计算 A 在 B 的邻域中时应该如何调整。我们不关心得到的邻域值本身,而是关心调整后的角度之间的差异。具体地,如果我们有一个角度 A,当前角度值为正值且距离 B 很远,我们希望通过邻域操作符将 A 调整成负值,使其更加接近 B。这样,两个角度之间的差异将变得更小,而不是通过直接计算它们的差值(可能得到一个很大的正数)来产生错误的匹配。
举例说明
假设 A 的值是 350 度,B 的值是 10 度。直接计算它们的差值,会得到 350 度和 10 度之间的差值是 340 度,这会被认为它们距离很远。通过邻域操作符处理后,A 的角度会变为 -10 度(即顺时针旋转 10 度),而 B 的角度依然是 10 度,这样它们之间的差值就变成了 0 度,也就是完全匹配。这个调整是通过邻域操作符来实现的。
实现思路
在实现时,我们可以将邻域操作符视作一个函数,接受两个角度值作为输入,输出它们之间最小的差异。这意味着,如果两个角度值相差超过 180 度(例如 A 是 350 度,B 是 10 度),我们会通过周期性调整将其“拉近”到较小的范围,从而获得更精确的匹配结果。
简单来说,邻域操作符的功能就是根据周期性调整角度的差值,使得它们始终保持在最小的范围内,从而确保匹配过程的正确性。
总结
为了处理周期性角度的匹配问题,我们需要使用邻域操作符来计算角度之间的最小差距,而不是直接计算它们的差异。这使得我们可以在角度的周期性范围内,避免因大范围的旋转而导致错误的匹配,确保角度在匹配时总是能够准确地找到最接近的值。
黑板:“邻域距离”
在这里,我们实际上并不需要一个“邻域算子”(Neighborhood Operator),因为我们并不关心生成这个“a”,我们真正关心的是计算“邻域距离”。所以,我们的目标是做一个类似于邻域操作的过程,但我们只关心计算两个值之间的距离,而不需要生成具体的邻域值。
我们不需要实际生成邻域值,只需要计算它们的距离就可以了,这样可以让整个过程变得更简单。虽然在某些情况下,确实需要进行邻域操作(例如插值时),但在这种情况下,我们只关心两个角度之间的最短距离,而不需要真正生成邻域。因此,我们只需要计算距离即可,而不需要实际执行复杂的邻域操作。
接下来,假设我们已经知道所有值都在一个特定的范围内(比如在-π到π之间),我们就可以根据这个范围来判断角度之间的关系。具体来说,正数和负数总是有方向上的差异,但这并不影响它们之间的距离计算,因为无论如何,我们都能够在一个周期范围内进行计算。
在这种情况下,最简单的做法是检查这两个角度的符号(即它们的正负),如果它们的符号不同,那么它们就可能不在同一个邻域内。否则,它们就可能在相同的邻域内。
然而,在进一步思考后,我们发现其实并不需要专门进行符号检查。我们可以简化流程,直接跳过这一步。总的来说,我们只需要关注如何处理这两个角度之间的最短距离,而不需要关心它们是否在同一个邻域内。
黑板:将一个负范围的点的符号改变,计算它到正范围点的距离
我们有两个数,一个是正数,一个是负数,目标是计算它们之间的距离。首先,我们发现它们的符号不同,因此我们想要改变其中一个数的符号,使其变成与另一个数相同。具体来说,我们希望将负数变成正数。
为了做到这一点,我们可以通过以下方式来操作:负数的旋转(负旋转)其实就是从零开始,绕一圈的正向距离加上多出来的部分。因此,我们可以从负π开始,向正方向进行旋转,直到到达目标数B的位置,这样就变成了正旋转。
例如,如果B是负数,我们可以通过从负π开始,顺时针旋转得到B,实际上就是等于B加上整个范围(2π)。所以,B变成正数的操作就是将B加上这个周期范围,即加上2π。
这个过程说明,负数通过加上整个周期(2π)就可以转化为对应的正数。这样我们就可以用这种方式来处理符号不同的数值,使它们符合我们需要的范围和计算要求。
黑板:将一个正数移动到负数
如果我们有一个正数,想要将其转化为负数,过程和之前的情况类似,但这次是进行负旋转。
-
负数转正数:
如果我们有一个负数,我们可以通过加上整个周期(2π)使其变成正数。这样,通过顺时针旋转的方式,可以将负数转变为正数。 -
正数转负数:
如果我们想要把一个正数变成负数,步骤就有所不同。具体来说,我们需要从正数的位置(例如π的位置)开始,进行逆时针的旋转。以正数A为例,假设A位于[0, π]范围内,要将其转变为负数,可以按照以下步骤:
- 从π的地方开始逆时针旋转到A的位置。
- 这时候,我们需要从π位置开始,逆时针旋转到A,这相当于减去一个π,即我们需要将A减去π(因为π是最大正数)。
例如,如果A是一个正数,位于(0, π)之间,我们可以将其转换为负数,方法是将这个数减去π,使其变成负数。
-
总结:
- 对于负数转正数,我们通过加上2π来实现。
- 对于正数转负数,我们通过减去π来实现。
这个过程就相当于在单位圆上进行旋转,改变角度的符号,通过加或者减掉2π或π来实现符号的转换。
黑板:做这个邻域计算的完整公式
我们讨论的是如何计算两个数之间的“邻域”(neighborhood),这个操作实际上并不是特别复杂,我们可以通过计算它们之间的差距来完成。
1. 正数和负数之间的转化:
- 如果我们有一个正数和一个负数,并且想要计算它们之间的距离,我们首先需要确定它们的符号。如果这两个数符号不同(一个是负数,一个是正数),我们可以通过调整其中一个数的符号,使得两个数具有相同的符号。
- 具体来说,如果我们想把一个负数变为正数,方法是加上一个周期 2 π 2\pi 2π。反之,如果我们想把一个正数变为负数,方法是减去 π \pi π。
2. 计算两个数之间的距离:
- 如果两个数符号相同,我们可以直接计算它们的距离,即它们的差值。
- 如果两个数符号不同,则我们需要做一些额外的操作。我们可以将负数转化为正数,或者正数转化为负数,来让它们具有相同的符号,然后再计算它们之间的差距。
- 在计算时,我们可以用一种简化的方式,避免进行符号判断。我们可以分别计算两个数之间的差距,然后选择较小的距离作为最终结果。
3. 具体操作:
- 假设我们有两个角度值,分别是 θ \theta θ 和 ϕ \phi ϕ。
- 如果 θ \theta θ 是负数,而 ϕ \phi ϕ 是正数,我们可以通过加上周期来将负数转为正数。
- 如果 θ \theta θ 是正数,而 ϕ \phi ϕ 是负数,我们可以通过减去 π \pi π 来将正数转为负数。
- 无论是正负数之间的转换,核心操作是计算它们之间的“差距”。我们可以通过加减周期的方式,将符号统一,然后计算差值。
4. 简化计算:
- 不需要显式判断符号,我们可以直接计算两者之间的距离:
- 首先计算 θ − ϕ \theta - \phi θ−ϕ。
- 然后计算 θ − (range) × sin ( θ ) \theta - \text{(range)} \times \sin(\theta) θ−(range)×sin(θ),其中 range 是整个范围的大小(例如 2 π 2\pi 2π)。
- 然后,取这两个差值的最小值,即可得到两个数之间的实际距离。
5. 结论:
- 通过简化的操作,我们可以避免使用条件分支来判断符号,而是直接计算两种情况的差值,最终选择较小的距离。这样既避免了分支操作,也提高了计算效率。
game_asset.h:将 HalfTagRange 添加到游戏资产
在进行资产匹配时,我们希望将周期性(periodicity)引入到资产中,以确保匹配的准确性和一致性。为了实现这一点,我们需要对周期的理解和管理。
1. 理解周期性:
- 我们并不需要为每个标签(tag)都单独设置一个周期。实际上,周期通常是由标签的类型决定的,因此只需要为每种标签类型定义一个周期即可。
- 这样,周期性实际上是和标签类型紧密相关的,而不是每个标签实例都需要一个单独的周期。
2. 周期的存储和管理:
- 在资产中,每个标签(tag)通常会有一个周期字段,这个周期字段可以命名为
tag period
或者half tag range
,它表示标签的周期范围。 - 这个“半标签范围”(HalfTagRange)可以理解为每种标签类型对应的周期的一半。例如,如果一个标签的周期是 2 π 2\pi 2π,那么半周期就是 π \pi π。
- 这样,我们在管理标签时,只需要在资产的标签中为每个标签类型存储一次周期值,而不需要为每个标签实例都单独存储周期。这样可以减少存储空间并提高效率。
3. 实际操作:
- 每次在处理资产标签时,只需要知道每种标签的周期范围就可以进行相应的操作。例如,对于每种标签类型,我们可以在标签结构中定义一个字段来存储周期信息(如
tag period
),并根据该值来进行计算。 - 通过这种方式,标签的周期性可以高效地管理和应用,从而简化了资产匹配的过程。
4. 总结:
- 不需要为每个标签单独定义周期,而是可以通过定义标签类型的周期(或者半周期)来管理周期性。这样,我们只需存储每种标签类型的周期范围,而在实际处理时直接使用这些周期信息。
- 这种方法不仅简化了周期的管理,也提高了资产匹配过程的效率和准确性。
game_asset.cpp:为每个 TagType 设置 HalfTagRange
大多数情况下,我们并不关心周期性问题,周期性标签的出现频率远低于非周期性标签。因此,我们可以采取一种简化的方式来处理标签的周期性。以下是处理流程的详细步骤:
1. 初始化资产时的周期性处理:
- 在初始化游戏资产时,我们可以遍历所有的标签类型,并为每种标签类型设置一个周期范围。对于大多数标签类型,我们不需要关心周期性,所以可以将它们的周期设置为一个非常大的数字。
- 这个大数字的作用是标识这些标签类型没有周期性(即它们的周期范围几乎是无穷大的)。这个值可以是一个极大值,只要它是我们永远不会使用的值就行,因为它只是占位符,并不会实际影响计算。
2. 处理周期性标签:
- 对于需要周期性处理的标签类型,我们不需要事先知道所有这些标签,只需要关注那些确实具有周期性的标签。
- 目前已经知道的一个周期性标签是“朝向方向”(facing direction),它的周期是 π \pi π,即无论朝哪个方向,最大旋转角度是 π \pi π(180度)。对于这种情况,我们可以直接将其周期设置为 π \pi π,而其他不需要周期性处理的标签则保持为一个非常大的值。
3. 具体实现:
- 在初始化过程中,对于所有标签类型,我们先给它们设置一个默认的大周期值(如 1 0 10 10^{10} 1010 或其他极大值)。然后,对于确实需要周期性处理的标签(如朝向方向),我们单独为它们设置实际的周期值(如 π \pi π)。
- 这样,周期性标签的处理就能被单独标识出来,并在后续的计算中正确处理,而其他标签类型则无需进行周期性计算。
4. 总结:
- 大部分标签都不涉及周期性,所以我们为这些标签设置一个非常大的周期值,表示它们不需要周期性处理。
- 只有少数几个标签会涉及周期性处理(如朝向方向),这些标签的周期值设置为实际的周期值(如 π \pi π)。
- 通过这种方法,可以在初始化时简化标签的周期性处理,并在需要时进行特定的周期性计算。
在 BestMatchAsset 中使用 HalfTagRange
在进行标签匹配时,目的是确保匹配结果是合理的,并且能够正确计算标签之间的差异。以下是实现这一过程的详细步骤:
1. 计算差异:
- 在进行标签匹配时,我们首先计算两个值之间的差异,假设这两个值分别是 A A A 和 B B B,那么它们之间的差异就是 A − B A - B A−B。
- 但为了确保差异计算时考虑周期性,我们需要引入“邻域差异”这个概念。也就是说,计算时不仅仅是简单地减去 A A A 和 B B B 的差值,还要考虑它们之间的周期性差异,特别是在涉及周期性标签时。
2. 处理符号问题:
- 在计算差异时,需要检查两个值的符号(正负)。如果两个值的符号不同,那么我们需要进行符号转换,以确保差异的计算符合周期性要求。
- 为了实现这个功能,可以使用正弦函数(sine function)来辅助判断。如果符号不同,可以根据需要调整差异,确保计算结果在期望的周期范围内。
3. 符号判断和调整:
- 如果没有现成的正弦函数实现,我们可以通过简单的条件语句(如
if
语句)来判断 A A A 和 B B B 的符号,并根据符号的不同做出适当的调整。 - 判断符号的方式可以通过比较 A A A 和 B B B 的大小来实现,如果 A A A 和 B B B 符号不同,则需要调整差异值。
4. 最终实现:
- 可以在标签匹配时,通过判断标签的符号来决定是否需要调整差异值。
- 如果符号相同,直接计算差值。如果符号不同,则需要调整差值,确保结果正确地反映周期性影响。
5. 总结:
- 在进行标签匹配时,我们首先计算两个标签的差异,并根据需要调整周期性差异。
- 通过符号判断和正弦函数,确保计算结果符合周期性要求。
- 最终,我们通过条件语句(如
if
)来判断符号并调整差异,从而确保匹配过程合理且准确。
game_intrinsics.h:编写一个 real32 版本的 SignOf
在进行操作时,遇到一些问题,可能会觉得有些麻烦。为了简化工作,可以假设将来会实现一个更好的版本来处理这些问题。经过一定的调整,现在已经实现了所需的功能,可以有效地进行操作。
game_asset.cpp:继续将 (Half)TagRange 添加到 BestMatchAsset
为了处理某些标签类型的范围,我们需要首先计算标签的半范围,然后根据标签类型的范围调整。具体操作是,我们将标签的范围(TagRange)乘以正弦值,并进行一些运算,来确定最终的范围。
接着,我们通过计算两个数的差异(比如 a
和 b
)来得到两种不同的距离值,然后通过取最小值来找到最佳匹配。这意味着我们会选择这两个差异值中较小的那个,以便获得更准确的结果。
此外,为了优化工作,我们可以使用一个新的变量 TagRange
来替代半范围计算,这样可以简化操作。还可以使用 tau
(即 2π)替代 π,以便让数学计算更加一致和简洁。总之,通过这些优化操作,最终能得到更好的匹配结果。
调试器:进入 BestMatchAsset
首先,进入到相关代码部分,查看资产组的内容。查看当前的匹配逻辑。当前的匹配是对两个值进行比较,初步结果是将 0
和 0
进行匹配,显然这两个数的差异为 0
,因此结果也会是 0
,并且没有特别的变化。
接下来,我们看到程序在进行计算时,计算了两个值之间的差异,同时还会计算另一种可能的差异,即完整的一个周期(tau
)。这种计算是正确的,因为对于周期性的匹配问题,考虑整个周期是很重要的。这样处理后,程序能够正确地处理周期性差异,确保匹配的准确性。
玩游戏并查看我们新功能
接下来,我们对计算出的两个差异值取最小值,这样结果依然是 0
,然后继续进行下一步操作。之后,通过加权计算,得到最终的差异值(DIF)。这种方法在标签匹配上看起来更为准确。
通过这种方式,我们能够实现更精确的方向匹配,并且成功地加载正确的周期性资产。同时,系统也没有任何问题,能够顺利处理周期性资产的使用。因此,现在的实现已经能够很好地处理这些需求,确保一切正常运行。
我们差不多完成了
今天的基本完成了,但还剩下半小时的时间。接下来,想要处理一些遗留的问题。一个问题是目前系统没有正确地处理位移,导致一些效果(比如阴影)显示不正常。原因是在代码中,位移数据被硬编码了,但是我们没有真正地回溯这些位移数据。
我打算先看看标准位移的值,看是否可以统一使用一个固定值。通过检查,发现对于所有对象,位移值似乎都是一致的,具体数值是 72
和 182
,适用于所有的对象。因此,我想先快速验证一下,看看是否可以为所有对象使用相同的位移值。
我计划先在代码中做一个临时的修改,看看效果如何。为了调试,我将检查加载位图的部分,确认一下是否有任何问题。
game_asset.cpp:添加 *FileName[] 以便遍历位图并设置 TopDownAlign
为了处理位移问题,我计划通过以下方式进行调试。首先,我将所有的文件名放入一个数组中,然后使用一个循环遍历每个文件名。接下来,我将调用 DebugLoadBMP
函数,加载每个位图。
在这个过程中,我决定通过修改代码来测试加载的效果。首先,创建一个 FileName
数组,然后将所有相关的文件名添加到该数组中。接着,使用一个简单的方式加载位图数据。为了确保一切正常,我还会对加载后的数据进行一些调试,确保按预期进行处理。
通过使用 TopDownAlign
来调整这些位图,我能够对这些位图进行检查,确保它们符合需求,正确加载并且显示。最终,这个操作只是一个调试步骤,用来确保没有其他问题干扰,确保数据处理准确无误。
调试器:查看对齐方式
尝试让调试器以正确的方式显示数据,并逐步获取正确的位图数据。这次,数据看起来正常了,显示为 0.5
和 1.56
,并且这些值是一致的,没有发现任何异常。
接下来,我需要做的就是整理这些数据并返回正确的值,确保它们可以用于接下来的处理。至此,调试过程中的问题已经得到了解决,位图数据也加载成功了。
game_asset.cpp:将计算出的对齐方式烘焙到英雄位图中
为了确保数据正确,现在需要创建一个 V2
的变量,这个变量用于表示“英雄对齐”(HeroAlign)。这个英雄对齐的值已经通过之前的调试读取出来,分别是 0.5
和 0.156
等。
接下来,每次执行添加位图资产(AddBitmapAsset)时,都可以指定这个对齐值(alignment value)。这意味着,只要设置了这个值,就能确保位图的正确对齐方式。
至此,这部分的工作就完成了。
运行游戏并检查正确的对齐方式
现在,所有的资产都已经正确对齐,系统也按照需求运行了。对此,我感到满意。虽然目前不确定是否已经完全完成所有工作,但至少目前的进展是令人满意的,资产系统已经达到了预期目标。
开始讨论资产文件格式
目前,所有的资产匹配工作已经完成,系统运行得也很平稳,感觉一切都在预期之内。因此,可能是时候开始考虑资产文件格式了。虽然这可能有些早,但从当前的进展来看,已经有了合理的基础,可以开始讨论资产文件的格式和它的工作方式。我觉得我们不需要在资产文件格式中使用描述符,而是希望所有的描述信息都来自代码那一侧。因此,感觉现在的状态已经接近完成,所有的工作都顺利进行,应该可以进入下一阶段了。
game.h:向资产流式传输 TODO 中添加两项,并优先处理音频
目前,资产流式加载系统已经基本完成,剩下的工作就是对文件格式的设计。为了完成这部分,首先需要确保能够从磁盘加载资产,并进行内存管理,避免数据无序地加载。接下来,需要检查是否还有其他方面需要优化或改进。如果以后需要进行优化,那时再处理即可,因此现在可以将资产流式加载部分标记为完成。
考虑到接下来要进行文件格式的处理,可能需要在这之前先处理音频加载部分。如果没有音频加载功能的支持,资产加载系统就无法完全运作,因此音频部分是必须解决的问题。接下来的20分钟里,可以先完成音频加载的实现,确保音频功能能正常工作,然后再着手文件格式的设计,以确保文件格式能够同时支持音频和视频内容。
game_asset.cpp:引入 LoadSound
当前的任务是为音频资源的加载系统做准备。之前已经为音效准备好了资产槽和标签,但目前还没有实现加载音频的功能。为了完成这个目标,需要实现一个与加载位图类似的音频加载系统。换句话说,需要创建一个加载音频的流程,功能上与加载位图非常相似。
首先,可以借鉴LoadBitmap
的概念,创建一个LoadSound
的功能。这个功能将负责加载音频资源,具体来说,当音频文件的ID被传入时,加载任务会开始,然后执行实际的音频加载操作。这个过程会涉及到类似于位图的工作,但操作的对象会是音频文件。
为了实现这个目标,需要实现以下步骤:
- 音频信息结构:需要定义一个类似于位图的
音频信息
结构,其中包括音频的文件名、样本数等数据。 - 加载音频文件:创建一个类似
DebugLoadBitmap
的函数DebugLoadWAV
,用于加载音频文件。这将确保音频文件能够被正确加载到内存中,并提供音频所需的信息。 - 音频ID和槽管理:为音频资源分配ID并将其加载到适当的资产槽中,这样就能够方便地访问和管理音频资源。
同时,创建一个LoadedSound
结构体,这个结构将存储加载的音频信息,例如样本数和音频文件的内存数据。这个结构与位图的LoadedBitmap
结构非常相似,主要的区别在于它是针对音频数据的。
在功能上,音频的加载与位图加载非常相似,因此可以考虑将这些功能进行整合,减少冗余代码。如果这两个过程在实现过程中越来越相似,最终可能会将它们合并为一个通用的加载函数,这样可以减少代码量,使系统更加简洁高效。
目前还没有实现具体的LoadedSound
结构体,但可以先假设它的存在,并着手开发音频加载的相关功能,之后再根据需要进一步完善音频结构体的实现。总的来说,音频加载系统的基础功能框架已经初步构建,接下来的工作是实现具体的音频加载和管理逻辑。
引入 DEBUGLoadWAV
当前的任务是实现DEBUGLoadWAV
函数,该函数用于加载WAV格式的音频文件,并将其数据解析并存储到适当的结构中,以便后续的音频处理。这个过程与DebugLoadBitmap
类似,都是读取文件并解析内容,但WAV文件的解析稍微复杂一些。
实现步骤
-
读取整个文件
- 使用
DEBUGReadEntireFile
函数将WAV文件的全部内容读取到内存中。 - 检查读取是否成功,如果失败,则终止加载。
- 使用
-
解析WAV文件头部信息
- WAV文件的格式比BMP文件稍复杂,但仍然是标准的RIFF格式。
- 解析WAV文件的
RIFF
头部,确认文件类型是否正确(应包含RIFF
标识和WAVE
格式标签)。 - 解析
fmt
子块,提取音频格式信息(采样率、通道数、位深度等)。 - 解析
data
子块,找到实际的音频数据部分,并记录其大小和位置。
-
存储解析结果
- 提取出音频的关键信息,如:
- 采样率(Sample Rate)
- 通道数(Channels)
- 采样深度(Bit Depth)
- 总样本数(Sample Count)
- 音频数据指针(Data Pointer)
- 创建
LoadSound
结构体,将这些数据存入其中,以便后续音频播放或处理。
- 提取出音频的关键信息,如:
-
返回加载结果
- 确保所有数据正确解析,并返回
LoadSound
结构的指针,使其可以用于后续的音频处理流程。
- 确保所有数据正确解析,并返回
额外优化
- 由于WAV格式支持不同的编码方式(如PCM、ADPCM等),当前可以先实现最常见的PCM(线性脉冲编码调制)格式解析。
- 可以考虑与位图加载流程进行结构化对比,以便未来优化代码复用,例如抽取通用的“加载资源”方法,减少重复代码。
下一步
- 继续完善
DEBUGLoadWAV
函数的实现,并进行基本的测试,确保能够正确解析WAV文件并提取音频数据。 - 检查是否需要调整
LoadSound
结构的设计,以适应不同格式的音频资源管理。
网络:展示 WAVE 规格
WAV 文件的格式基于 RIFF(资源交换文件格式),它由多个数据块(Chunk)组成,每个数据块包含不同的信息,如文件头、音频格式和音频数据。
WAV 文件解析流程
-
读取 RIFF 头部
- WAV 文件以
RIFF
作为文件头标识(4 字节)。 - 之后是文件大小信息(4 字节),用于指示整个文件的大小。
- 紧接着是
WAVE
标识(4 字节),表明该文件是 WAV 格式。
- WAV 文件以
-
解析
fmt
块(音频格式信息)fmt
块(子块 ID 为"fmt "
)包含音频数据的格式信息:- 音频编码格式(Audio Format)(2 字节),常见值:
1
代表 PCM(未压缩的脉冲编码调制)- 其他值可能代表 ADPCM 或其他压缩格式
- 通道数(Number of Channels)(2 字节),如:
1
表示单声道2
表示立体声
- 采样率(Sample Rate)(4 字节),如
44100
Hz 表示 44.1kHz。 - 字节率(Byte Rate)(4 字节),用于计算数据流的速率。
- 块对齐(Block Align)(2 字节),用于存储每个采样块的大小。
- 位深度(Bits per Sample)(2 字节),常见值:
8
(8 位 PCM)16
(16 位 PCM)
- 音频编码格式(Audio Format)(2 字节),常见值:
-
解析
data
块(音频数据)data
块(子块 ID 为"data"
)包含音频的原始 PCM 数据:- 数据大小(Data Size)(4 字节),表示音频数据的总字节数。
- 音频数据(Data),实际的 PCM 采样值,格式取决于
fmt
块中定义的Bits per Sample
。
加载 WAV 文件的实现思路
- 读取整个文件并确认
RIFF
头是否正确:- 解析前 12 个字节,检查是否以
"RIFF"
开头,是否包含"WAVE"
标识。
- 解析前 12 个字节,检查是否以
- 查找
fmt
块并解析音频格式信息:- 提取音频格式、通道数、采样率、位深度等信息。
- 查找
data
块并读取音频数据:- 提取数据大小,并存储音频 PCM 数据。
- 将解析的音频数据存入
loaded sound
结构:- 存储采样率、通道数、样本数及数据指针,以便后续音频播放。
后续优化
- 目前仅解析 PCM 格式的 WAV 文件,可后续扩展支持 ADPCM 等其他编码格式。
- 结合已有的
bitmap
资源管理方式,优化代码结构,实现更通用的资源加载机制。
https://www.mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html
黑板:“IFF” 文件格式
块状文件格式(Chunked File Format) 是一种层次化数据编码方式,广泛用于早期计算机文件格式中。例如,IFF(Interchange File Format) 由 Electronic Arts 在 Amiga 计算机 上引入,是最早采用此方法的格式之一。
块状文件格式的基本结构
-
数据按块(Chunk)存储:
- 每个块都有一个 ID(通常是 4 个 ASCII 字符)。
- 之后是 大小字段,表示该块的数据长度。
- 紧随其后的是实际的 数据内容。
-
块的解析方式:
- 读取 ID 确定块的类型。
- 读取 大小字段,确定数据长度。
- 如果程序支持该块类型,则解析其数据;否则,跳过该块。
- 部分块可以包含 子块(Sub-chunk),形成层次结构。
WAV 文件的块结构
WAV 文件采用 RIFF(Resource Interchange File Format),其结构与 IFF 类似:
RIFF
块(主块)- 包含
WAVE
标识 - 内部包含多个子块:
fmt
块(存储音频格式)data
块(存储 PCM 音频数据)- 其他可选块(如
LIST
、fact
等)
- 包含
解析 WAV 文件的流程
- 读取
RIFF
头部,确认文件类型是否为WAVE
。 - 遍历文件中的块:
- 识别
fmt
块,提取音频格式信息(采样率、通道数、位深度等)。 - 识别
data
块,读取 PCM 数据。 - 遇到未知块则跳过,确保兼容扩展格式。
- 识别
优缺点分析
优点
- 结构清晰,可扩展(可添加新块类型而不影响旧软件)。
- 易于解析,支持跳过未知数据块。
- 适用于存储层次化数据。
缺点
- 额外的 ID 和 大小字段 增加了文件体积。
- 解析逻辑较复杂,需处理不同块类型。
- 现代格式(如 JSON、Protocol Buffers)提供更高效的替代方案。
总结
块状文件格式是一种早期的数据存储方法,在 WAV、AVI、3DS 等格式中广泛应用。尽管 结构灵活,但在现代开发中通常使用 更紧凑、高效 的数据格式来代替。
game_asset.cpp:引入 WAVE_header
在解析 WAV 文件时,我们主要关注 文件头(Header) 和 数据块(Chunk) 的基本结构。由于这是调试加载器(Debug Loader),我们不需要支持所有 WAV 格式,只需解析出基本的 PCM 音频数据,以便后续处理资产。
WAV 文件基本结构
WAV 文件使用 RIFF(Resource Interchange File Format) 作为容器格式,其结构包括多个 块(Chunk):
- RIFF 头部(文件标识)
- fmt 块(音频格式)
- data 块(PCM 数据)
- 其他可选块(如 LIST, fact 等)
解析 WAV 文件的初始步骤
1. 读取 WAV 头部
- WAV 文件的前 12 字节 定义为 RIFF 头部:
- 4 字节:
"RIFF"
(ASCII 编码) - 4 字节:整个文件的大小(不包括前 8 字节)
- 4 字节:
"WAVE"
(ASCII 编码)
- 4 字节:
2. 读取 fmt 块(音频格式信息)
- 4 字节:块 ID,通常为
"fmt "
(包括空格) - 4 字节:块大小(通常为
16
,表示 PCM 格式) - 2 字节:音频编码格式(
1
表示 PCM) - 2 字节:通道数(
1
= 单声道,2
= 立体声) - 4 字节:采样率(如
44100
Hz) - 4 字节:字节率(= 采样率 × 通道数 × 每个样本的字节数)
- 2 字节:块对齐(= 通道数 × 每个样本的字节数)
- 2 字节:每个样本的位数(如
16
表示 16 位 PCM)
3. 读取 data 块(PCM 音频数据)
- 4 字节:块 ID,通常为
"data"
- 4 字节:数据大小
- 接下来的字节:音频 PCM 数据
代码解析 WAV 头部
我们可以用 C 语言的 结构体(struct) 来定义 WAV 文件头:
typedef struct {uint32_t riff_id; // "RIFF"uint32_t file_size; // 文件总大小 - 8 字节uint32_t wave_id; // "WAVE"
} WaveHeader;
然后是 fmt 块:
typedef struct {uint32_t fmt_id; // "fmt "uint32_t fmt_size; // 块大小uint16_t audio_format; // 编码格式(1 = PCM)uint16_t num_channels; // 通道数(1 = 单声道,2 = 立体声)uint32_t sample_rate; // 采样率uint32_t byte_rate; // 字节率uint16_t block_align; // 块对齐uint16_t bits_per_sample; // 采样位数
} FmtChunk;
最后是 data 块:
typedef struct {uint32_t data_id; // "data"uint32_t data_size; // PCM 数据大小
} DataChunk;
调试加载 WAV 文件
在调试加载器中,我们可以按照以下步骤解析 WAV 文件:
- 读取
WaveHeader
,确保是"RIFF"
和"WAVE"
文件。 - 查找
"fmt "
块,解析音频格式信息。 - 查找
"data"
块,提取 PCM 数据。 - 如果遇到未知块,使用
chunk_size
跳过该块。 - 将音频数据存入
LoadedSound
结构体,供后续播放。
总结
- WAV 文件采用 块状结构,解析时按 ID + 大小 + 数据 方式读取。
- 只需解析 “RIFF”, “WAVE”, "fmt ", “data” 块,即可提取 PCM 数据。
- 解析时可跳过 未知块,确保兼容不同 WAV 变种格式。
- 由于本次目标是 调试加载器,不需要支持所有 WAV 格式,只需确保能加载调试所需的 WAV 资源。
引入 WAVE_fmt
在解析 WAV 文件时,fmt
块是关键部分之一,它存储了音频格式信息。我们需要解析其中的多个字段,以确保能够正确读取 PCM 数据。以下是详细解析步骤:
解析 fmt
块(Format Chunk)
fmt
块用于定义音频数据的格式,包括采样率、通道数、比特深度等。其基本结构如下:
偏移量 | 大小 (字节) | 字段名称 | 说明 |
---|---|---|---|
0 | 4 | fmt (块 ID) | 该块的标识符,固定为 "fmt " |
4 | 4 | chunk_size | 该块的大小(不包括前 8 字节) |
8 | 2 | wFormatTag | 音频格式(1 表示 PCM) |
10 | 2 | nChannels | 声道数(1 = 单声道,2 = 立体声) |
12 | 4 | nSamplesPerSec | 采样率(Hz) |
16 | 4 | nAvgBytesPerSec | 平均字节率(= 采样率 × 通道数 × 每样本字节数) |
20 | 2 | nBlockAlign | 块对齐大小(= 通道数 × 每样本字节数) |
22 | 2 | wBitsPerSample | 每个样本的比特数(如 16 表示 16 位 PCM) |
24 | 2 | cbSize (可选) | 扩展数据的大小(仅对非 PCM 格式有效) |
26+ | 可变 | 扩展数据 | 例如 dwChannelMask 和 SubFormat GUID |
字段解析
-
wFormatTag
(音频格式)- 该字段决定了 WAV 文件的编码方式:
1
= PCM(标准无压缩格式,最常见)- 其他值(如
3
= IEEE 浮点格式、6
= A-law、7
= μ-law)不作处理
- 由于我们只支持 PCM,因此遇到
wFormatTag != 1
的 WAV 文件可以直接忽略。
- 该字段决定了 WAV 文件的编码方式:
-
nChannels
(通道数)1
表示单声道2
表示立体声- 其他通道数(如 5.1、7.1)不在调试加载器的支持范围内。
-
nSamplesPerSec
(采样率)- 例如:
44100 Hz
(CD 音质)48000 Hz
(专业音频)96000 Hz
(高分辨率音频)
- 例如:
-
nAvgBytesPerSec
(字节率)- 计算方式:
nAvgBytesPerSec = nSamplesPerSec * nChannels * (wBitsPerSample / 8)
- 该字段可以用来验证数据完整性,但不直接影响 PCM 解析。
- 计算方式:
-
nBlockAlign
(块对齐)- 计算方式:
nBlockAlign = nChannels * (wBitsPerSample / 8)
- 该字段决定了每个采样帧的大小,例如:
16-bit 立体声
:nBlockAlign = 2 × (16 / 8) = 4
8-bit 单声道
:nBlockAlign = 1 × (8 / 8) = 1
- 解析 PCM 数据时,每次读取
nBlockAlign
个字节,即可得到完整的采样帧。
- 计算方式:
-
wBitsPerSample
(采样位数)- 常见值:
8
= 每个样本 8 位(无符号)16
= 每个样本 16 位(有符号)24
或32
= 高精度音频
- 仅支持
16
位 PCM,因为它是最广泛使用的格式。
- 常见值:
-
cbSize
(扩展数据大小,可选)- 仅当
wFormatTag
不是1
时才存在,如cbSize = 22
可能用于 IEEE 浮点格式。 - 由于我们只支持 PCM,因此可以忽略该字段。
- 仅当
-
dwChannelMask
(通道掩码,可选)- 该字段用于多通道音频,如 5.1 或 7.1 声道,但调试加载器无需解析。
-
SubFormat GUID
(可选)- 该字段用于非 PCM 格式(如 WAVE_FORMAT_EXTENSIBLE),但对于 PCM,我们可以直接跳过。
WAV 解析结构体
我们可以用 C 语言定义 fmt
块的数据结构:
typedef struct {uint32_t fmt_id; // "fmt "uint32_t fmt_size; // fmt 块大小uint16_t wFormatTag; // 音频格式(1 = PCM)uint16_t nChannels; // 通道数uint32_t nSamplesPerSec; // 采样率(Hz)uint32_t nAvgBytesPerSec;// 字节率uint16_t nBlockAlign; // 块对齐uint16_t wBitsPerSample; // 采样位数// 如果需要支持 WAVE_FORMAT_EXTENSIBLE,可在此扩展结构体
} FmtChunk;
解析 WAV 文件的 fmt
块
在调试加载 WAV 文件时,我们按照以下步骤解析 fmt
块:
- 读取
fmt
头部(前 8 字节),确保 ID 为"fmt "
。 - 读取
fmt_size
,确定该块的大小。 - 解析 PCM 相关字段,如
nChannels
、nSamplesPerSec
、wBitsPerSample
。 - 若
wFormatTag != 1
,则跳过该块,不解析。 - 跳过可能的扩展字段,直到找到
data
块。
总结
fmt
块是 WAV 文件解析的核心部分,决定了音频数据格式。- 只支持
PCM
(wFormatTag = 1),忽略cbSize
及扩展字段。 - 关键字段包括
nChannels
(通道数)、nSamplesPerSec
(采样率)、wBitsPerSample
(采样位数)。 - 解析时需检查
nBlockAlign
和nAvgBytesPerSec
,确保数据正确性。 - 如果遇到非 PCM 格式或不支持的位深度,直接跳过该文件。
接下来,我们可以继续解析 data
块,以获取实际的 PCM 音频数据。
引入 WAVE_chunk
在解析 WAV 文件时,我们还需要处理 WAV 文件中的其他数据块(chunk)。WAV 文件采用 分块(chunked)格式,其中每个数据块都包含 块 ID 和 块大小。我们需要解析这些数据块,以正确地读取 WAV 文件的结构。
WAV 文件中的通用块格式
WAV 文件的结构基于 RIFF(Resource Interchange File Format),每个块的基本格式如下:
偏移量 | 大小 (字节) | 字段名称 | 说明 |
---|---|---|---|
0 | 4 | ChunkID | 数据块的 ID(例如 "fmt " 、"data" 等) |
4 | 4 | ChunkSize | 该块的数据大小(不包括 ChunkID 和 ChunkSize ) |
8 | ChunkSize | 数据 | 该块的具体内容 |
WAV 文件主要包含的块
一个标准的 WAV 文件通常包括以下几个关键块:
1. RIFF 头部
ChunkID
:"RIFF"
(4 字节)ChunkSize
:文件的总大小(4 字节)Format
:"WAVE"
(4 字节)
该部分定义了整个文件的类型,WAV 文件必须以 "RIFF"
开头,并且其格式必须是 "WAVE"
。
2. fmt
块(格式块)
ChunkID
:"fmt "
(4 字节)ChunkSize
:格式块大小(通常为16
或18
字节,PCM 格式时为16
)- 数据内容:
wFormatTag
(音频格式,1 = PCM)nChannels
(通道数)nSamplesPerSec
(采样率)nAvgBytesPerSec
(平均字节率)nBlockAlign
(块对齐大小)wBitsPerSample
(每个样本的位数)
3. data
块(音频数据块)
ChunkID
:"data"
(4 字节)ChunkSize
:音频数据的大小(4 字节)- 数据内容:实际的 PCM 音频数据
这是 WAV 文件中最重要的数据块,它包含了音频的 PCM 采样数据。我们需要找到 data
块,并解析其中的音频数据。
解析 WAV 文件的通用方法
为了正确解析 WAV 文件,我们需要按照以下步骤处理数据块:
-
读取文件头部:
- 确保
ChunkID
是"RIFF"
。 - 读取
ChunkSize
(可忽略)。 - 确保
Format
是"WAVE"
。
- 确保
-
遍历文件中的数据块:
- 读取
ChunkID
和ChunkSize
。 - 根据
ChunkID
进行不同的解析:- 如果
ChunkID
是"fmt "
,则解析格式信息。 - 如果
ChunkID
是"data"
,则读取音频数据。 - 如果
ChunkID
是其他未知值,则跳过该块(通过ChunkSize
跳过相应字节)。
- 如果
- 读取
-
读取
data
块的数据:- 解析 PCM 采样数据(支持 8 位或 16 位 PCM)。
示例代码
我们可以用 C 语言实现 WAV 解析的基本逻辑:
typedef struct {uint32_t ChunkID; // "RIFF"uint32_t ChunkSize; // 文件总大小uint32_t Format; // "WAVE"
} RIFFHeader;typedef struct {uint32_t ChunkID; // "fmt "uint32_t ChunkSize; // 块大小uint16_t wFormatTag;uint16_t nChannels;uint32_t nSamplesPerSec;uint32_t nAvgBytesPerSec;uint16_t nBlockAlign;uint16_t wBitsPerSample;
} FmtChunk;typedef struct {uint32_t ChunkID; // "data"uint32_t ChunkSize; // 音频数据大小
} DataChunk;
然后,我们遍历 WAV 文件的块:
FILE *file = fopen("example.wav", "rb");
RIFFHeader riff;
fread(&riff, sizeof(RIFFHeader), 1, file);// 确保是 WAV 文件
if (riff.ChunkID != 0x46464952 || riff.Format != 0x45564157) {printf("错误:文件不是 WAV 格式!\n");fclose(file);return;
}// 遍历数据块
while (!feof(file)) {uint32_t chunkID;uint32_t chunkSize;fread(&chunkID, sizeof(uint32_t), 1, file);fread(&chunkSize, sizeof(uint32_t), 1, file);if (chunkID == 0x20746D66) { // "fmt "FmtChunk fmt;fread(&fmt, sizeof(FmtChunk), 1, file);printf("音频格式: %d, 采样率: %d Hz, 通道数: %d\n",fmt.wFormatTag, fmt.nSamplesPerSec, fmt.nChannels);} else if (chunkID == 0x61746164) { // "data"printf("找到数据块,大小: %d 字节\n", chunkSize);break; // 读取音频数据} else {fseek(file, chunkSize, SEEK_CUR); // 跳过未知块}
}fclose(file);
总结
- WAV 文件由多个块组成,每个块都有
ChunkID
和ChunkSize
。 - 关键块包括
RIFF
头部、fmt
格式块、data
音频数据块。 - 解析 WAV 文件时,我们需要遍历所有块,并根据
ChunkID
进行不同的处理:fmt
块:存储音频格式信息(采样率、位深、通道数)。data
块:存储 PCM 音频数据。- 未知块:跳过处理。
- 只支持 PCM (
wFormatTag == 1
),遇到其他格式可以忽略。
这样,我们就可以正确解析 WAV 文件,并提取其中的 PCM 音频数据!
#define RIFF_CODE
在解析 WAV 文件时,我们需要能够识别文件中的不同数据块(chunk),而这些数据块是通过 4 字节的 ID 进行标识的,例如 "fmt "
(格式块)、"WAVE"
(数据块)和 "RIFF"
(文件头)。由于 C 语言不允许直接用字符串字面量作为 uint32_t
类型的值,因此我们需要一种方法来将 4 个字符转换成一个 32 位整数,以便进行高效的比对。
目标:将 4 字节字符转换为 32 位整数
为了便于 WAV 解析,我们希望能够定义这些块 ID,例如:
enum {WAVE_ChunkID_fmt = RIFF_CODE('f', 'm', 't', ' '),WAVE_ChunkID_RIFF = RIFF_CODE('R', 'I', 'F', 'F'),WAVE_ChunkID_WAVE = RIFF_CODE('W', 'A', 'V', 'E'),
};
然而,C 语言并不支持直接写 'fmt '
这样的多字符字面量并让它变成 uint32_t
类型,因此我们需要定义一个 宏 来自动转换字符串。
实现思路
由于 WAV 文件使用 小端字节序(Little Endian) 存储数据,我们需要确保字符的存储顺序正确。例如:
"RIFF"
在内存中的存储可能是0x46464952
(即R I F F
)。"fmt "
在内存中的存储可能是0x20746D66
(即f m t
)。
为了确保正确的字节顺序,我们可以定义一个 宏 来将 4 个字符转换为 uint32_t
值,确保它们按 小端字节序 排列。
宏实现
我们定义一个宏 RIFF_CODE()
,用于将 4 个字符转换为 32 位整数:
#define RIFF_CODE(a, b, c, d) \((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24))
这个宏的工作原理如下:
a
是最低字节(最右边),不需要位移。b
左移 8 位,占据第二个字节。c
左移 16 位,占据第三个字节。d
左移 24 位,占据最高字节。
使用示例
通过这个宏,我们可以定义 WAV 解析所需的块 ID:
const uint32_t WAVE_CHUNK_ID_FMT = RIFF_CODE('f', 'm', 't', ' ');
const uint32_t WAVE_CHUNK_ID_RIFF = RIFF_CODE('R', 'I', 'F', 'F');
const uint32_t WAVE_CHUNK_ID_WAVE = RIFF_CODE('W', 'A', 'V', 'E');
这样,我们就可以用这些值与 WAV 文件中的 ChunkID
进行比对,确保正确解析数据。
如何验证字节顺序
由于 小端存储 可能导致字符顺序错误,我们可以通过读取实际 WAV 文件并打印 ChunkID
来验证:
uint32_t chunk_id;
fread(&chunk_id, sizeof(uint32_t), 1, file);
printf("Chunk ID: 0x%X\n", chunk_id);
如果打印出的 Chunk ID
和 RIFF_CODE()
计算出的值不匹配,则可能需要调整 字节顺序(例如更改 RIFF_CODE()
中的移位顺序)。
总结
- WAV 文件使用 4 字节的
ChunkID
标识数据块,例如"fmt "
、"data"
和"RIFF"
。 - 由于 C 语言不支持直接使用字符串字面量作为
uint32_t
值,我们定义了RIFF_CODE()
宏,用来将 4 个字符转换成 32 位整数。 RIFF_CODE()
确保字符按小端字节序排列,符合 WAV 文件格式要求。- 通过
RIFF_CODE()
生成的ChunkID
可以用于比对文件中的数据块,确保 WAV 解析正确。
这使得 WAV 解析代码更加清晰易读,并且方便扩展其他块类型的处理!
解析 WAVE_header 的 ID
在解析 WAV 文件时,我们首先需要解析 WAV 头部(Wave Header)。我们期望 WAV 头部包含 RIFF
块,并且 RIFF
块的 ID 字段应包含 "RIFF"
,同时 WAVE
ID 也应该存在。如果这两个条件都满足,那么文件的基本结构就是符合预期的,我们可以继续解析其中的具体数据块(chunks)。
Interchange(立体交叉道, 互换 vt. 交换, 互换 vi. 交替发生)
解析 WAV 头部
-
WAV 头部的主要结构
RIFF
块(Resource Interchange File Format):- 4 字节的 Chunk ID(必须是
"RIFF"
)。 - 4 字节的 文件大小。
- 4 字节的 格式(必须是
"WAVE"
)。
- 4 字节的 Chunk ID(必须是
- 只有在
Chunk ID
是"RIFF"
且Format
是"WAVE"
时,我们才会继续解析文件。
-
数据结构示例
typedef struct {uint32_t ChunkID; // 必须是 "RIFF"uint32_t ChunkSize; // 整个文件大小 - 8uint32_t Format; // 必须是 "WAVE" } WaveHeader;
使用断言(assert)验证 WAV 文件
由于这只是用于调试的 WAV 解析器,而不是最终的资产加载器,因此我们可以使用 assert
来验证 WAV 文件是否符合基本格式:
WaveHeader* header = (WaveHeader*)fileData;// 断言检查是否是有效的 WAV 头部Assert(Header->RIFFID == WAVE_ChunkID_RIFF);Assert(Header->WAVEID == WAVE_ChunkID_WAVE);
这里使用 RIFF_CODE()
宏(之前定义过)将 4 字节字符串转换为 uint32_t
,确保 ChunkID
和 Format
是 "RIFF"
和 "WAVE"
,否则程序会在调试阶段崩溃,提醒开发者该文件格式错误。
到这里,稍微展望一下未来
目前正在进行 WAV 文件加载的编写,预计明天能够实现完整的 WAV 文件解析。这不仅有助于完成资产加载的最后阶段,同时也能推进音频部分的开发。这样一来,整体的开发任务会减少,使进度更加顺利。
开发目标
- 计划在第 200 天左右开始编写正式的游戏逻辑代码,因此希望在此之前完成所有核心引擎工作。
- 目前正在清理待办事项,希望尽快完成所有和游戏无关的引擎开发工作,使后续能够专注于游戏代码的编写。
当前进度
-
渲染部分已经基本完成,但仍有两个主要任务需要解决:
- 调试工具(Debug Code):这部分相对简单,应该不会花费太多时间。
- 字体渲染(Fonts):这需要较多的工作量,仍然是一个待办事项。
-
WAV 文件解析即将完成:
- 目前已经建立了对
RIFF
结构的解析框架。 - 计划明天完成具体的数据加载,以便 WAV 文件能够被正确解析和使用。
- 这一部分完成后,音频子系统的主要功能也将基本完善。
- 目前已经建立了对
后续规划
- 继续推进 资产加载和音频系统,确保它们可以稳定运行。
- 完成调试工具和字体渲染,进一步减少待办事项。
- 优化代码,减少引擎层面的开发工作,以便在第 200 天左右切换到游戏逻辑的开发。
目前整体进度良好,正在有条不紊地完成各项核心引擎任务,为正式的游戏开发做最后的准备。
对于获取角色精灵,能不能只做以下操作? int sprite_to_load = (angle + (180 / sprites)) / (360 / sprites) % sprites
当前讨论的是角色精灵(sprite)的加载和角度映射问题。有人提出了一种计算方式:
int sprite_to_load = (angle + 180) / (sprites / 360) % sprites;
但这种方式存在多个问题,不能直接适用于当前的资产系统。
主要问题
-
缺乏角度分布的信息
- 当前的资产系统并没有存储精灵在特定角度上的数量信息。
- 角度的分布可能并不均匀,例如某些角度的精灵较多,而另一些角度的精灵较少。
- 例如,一个 Boss 角色 可能大部分时间是面向前方的,因此前方视角的精灵较多,而背后视角的精灵较少。
-
现有系统无需量化(Quantization)
- 当前的实现方式已经能够正确处理不同角度的精灵加载,而不需要进行额外的量化计算。
- 直接量化角度可能会导致问题,例如 某些角度下根本没有可用的精灵,导致错误的映射或加载失败。
结论
- 直接使用这种计算方式不可行,原因是它假设了 精灵角度均匀分布,但实际情况可能并非如此。
- 现有系统已经能够正确加载精灵,并适应 不均匀的角度分布,因此不需要进行额外的量化计算。
我有一个关于音频资产结构的联合问题。它需要 SoundLoopable,或者 LoopStart / LoopEnd 吗?(LoopEnd == -1 表示一次性播放的声音)
关于音频资产结构体的问题,是否需要添加如 循环起始点、循环结束点 以及将 循环结束点设置为 -1 以表示单次播放的声音,目前还不确定。虽然这种功能可能会需要,但由于音频系统还没有完全构建好,因此目前还不清楚具体需求。
大概的做法是,先开发混音器(mixer),然后根据混音器的需求,回过头来调整音频资产系统,加入所需的功能和字段。
实现其他音频格式如 MP3 的计划?
关于实现其他音频格式,如 MP3,无法实现,因为 MP3 格式有专利,受到法律保护,不允许实现。即使在 2016 年之后专利可能会过期,但在当前仍然不允许实施 MP3 解码。美国的专利系统特别复杂和限制,尤其是软件专利,给开发带来很多麻烦。
因此,虽然理论上可以讲解如何实现 MP3 解码,但实际的代码实现是违法的。如果源代码被发布并包含 MP3 播放功能,那将是违法的行为。虽然通过直播或说明可能不违法,但还是不鼓励这样做。对于其他格式,如 FLAC,虽然可以用于压缩,但由于其压缩率较低(通常不到两倍),对空间的节省效果并不显著,使用起来的意义不大。对于真正需要压缩音频的场景,OGG Vorbis 可能是一个更合理的选择,但这类格式实现起来复杂,且消耗的时间和资源较多,因此不太可能在当前实现。
MP3 到底怎么被专利化的?
MP3 格式受到了多个专利的保护,可以在互联网上找到有关 MP3 专利的详细信息,包括专利的持有者和专利过期的时间。根据这些资料,MP3 的专利并非在全球范围内统一到期。在一些地区,比如美国,MP3 专利仍然有效,而在其他地方,可能早在 2012 年就已经过期。因此,在某些地区,可能是合法的来使用 MP3 格式的相关代码,但在美国,使用 MP3 解码或编码相关的技术仍然是违法的。
根据不同的专利信息,有些专利可能在 2015 年或 2017 年才会过期,具体取决于不同的专利和地区的法律规定。因此,尽管在一些地区 MP3 可能不再受专利保护,但在美国,专利依然存在并限制相关技术的使用。
是的,但为什么有人会专利文件扩展名呢?
MP3 文件的专利并不是针对文件扩展名,而是针对创建音频、以及编码和解码音频所使用的技术。因此,仅仅解析 MP3 文件的内容就可能会违反这些专利,因为专利保护的就是这些解析技术。虽然这种做法令人难以理解,但遗憾的是,现行的法律要求遵守这些专利,因此在技术上,我们不能解析 MP3 文件。
FLAC 是 WAV 的 50% 大小,所以它对无损压缩非常有用
FLAC(无损压缩)并不会像很多人认为的那样将文件压缩到原始 WAV 文件的 50% 大小,实际上最好的情况下,压缩率大约是 60%。在许多情况下,FLAC 的压缩效果要比这个更差,甚至接近于无压缩。因此,若只是为了节省一些存储空间,使用 FLAC 并不值得,因为它无法提供显著的节省。
假设一个游戏的总大小为 2GB,其中音频占据 500MB,如果通过 FLAC 压缩能够节省大约 25%的空间,那么最终仅节省 100MB。相对于整个游戏文件大小,这种节省是微不足道的。再加上为了实现这种压缩,游戏代码将变得更加复杂,这样的压缩就没有太大的意义。
如果真需要大幅压缩,应该考虑使用像 OGG Vorbis 这样的格式,它能够提供 8:1 或更高的压缩比,这样的压缩效果才更有实际意义。因此,如果目标是有效地减小音频文件的体积,选择 FLAC 并不会带来太多好处,反而可能增加不必要的复杂性。
https://xiph.org/flac/
https://z-issue.com/wp/flac-compression-level-comparison/
所以这就是 RAD 为什么不专利他们的东西的原因吗?
某些技术并未申请专利,是因为认为这类技术的专利制度非常愚蠢,因此选择不进行专利申请。