游戏引擎学习第146天

音高变化使得对齐读取变得不可能,我们可以支持循环声音了。

我们今天的目标是完成之前一段时间所做的音频代码。这个项目并不依赖任何引擎或库,而是一个教育项目,目的是展示从头到尾运行一个游戏所需要的全部代码。无论你对什么方面感兴趣,我们都会涉及到,如果你想成为一个游戏开发的全才,这正是你应该掌握的内容。成为一个全能的游戏开发者是很有用的,因为这样你可以在项目中做很多不同的工作,甚至能独立完成一个完整的产品。

今天我们打算完成我们这段时间一直在做的音频代码。尽管我们已经基本完成了音频的相关工作,但我们发现之前的处理方式存在一些问题,特别是在对齐读取(alignment)方面。由于我们支持音高变化,无法做对齐读取。因此,我们需要做一些调整,将之前的实现方式改成适应变化率的方式。我们计划将这些调整完成后,能够顺利地处理音频相关的操作,并将其结束,为接下来的资产包文件(assets pack)处理做好准备。

在音频代码方面,最初我们想通过对齐读取来处理音频数据,但由于支持音高变化,我们不能对读取进行对齐。音高变化意味着在播放音频时,我们会根据变化的音高来修改读取的数据流,而不再依赖固定的采样间隔。这使得我们可以灵活地支持更多的音频效果,比如循环播放。

循环播放的实现其实比较简单。我们发现,之前如果播放的样本超出了样本计数(sample count),我们需要加载下一个音频。如果不打算去加载下一个音频,那么就不需要做其他处理。但是如果确实需要加载下一个音频,那就需要确保正确地指向下一个音频块。这里的关键是,循环播放的机制与流媒体播放(streaming)非常相似,都是在播放完一个缓冲区后,自动加载下一个缓冲区数据。因此,循环播放其实就是让播放回到第一个音频块,重新开始。

我们不再对读取进行对齐,所以支持循环播放变得非常简单。实际上,我们之前的代码已经为这种支持做好了准备。我们只需要在播放结束后,重新指向当前播放的音频块,然后继续播放。这相当于让播放的音频在达到结尾后重新回到起始位置,并且持续播放。

在实现循环播放时,我们做了一些调整。例如,在播放时,我们将样本计数减去样本播放的值,这样就能正确地处理音频播放的剩余部分。这种方式可以避免在音高变化的情况下出现样本对齐不准确的问题,确保播放能够持续进行,尤其是在处理变化率较高的音频时。

值得注意的是,音频播放过程中是否能感知到这种变化取决于具体的实现和场景。在某些情况下,如果没有精确对齐样本边界,可能会出现音频"卡顿"或"闪烁"的现象,但这并不一定能在所有情况中被察觉到,因此并不会对音质造成太大影响。尽管如此,采取这种更准确的处理方式,是为了确保音频播放能够更加平滑,特别是在音高变化的情况下。

现在处理4个样本的块,而不是8个。

我们计划对当前的音频处理代码进行重构,主要目标是将原先基于 八个样本(eight samples) 的处理方式改成基于 四个样本(four samples) 的处理方式,以减少冗余代码、简化逻辑并使整体实现更加清晰易读。

首先,原先的代码在处理音频数据时采用的是每次拉取八个样本 (eight samples)的方式,我们希望将其调整为每次拉取四个样本(four samples)的方式。这将带来一些变量命名、计数逻辑和音量变化的调整,但最终结果会更加简洁易读。

修改样本处理方式

我们将原本处理八个样本 的循环改成四个样本 ,这意味着原先用于计数的变量 TotalSamplesToMix8 需要调整为 TotalChunksToMix,并且每次循环处理的数量也需要从 改为

在此过程中,我们决定将一些不太清晰的变量名进行重命名:

  • SampleCount8 改为 ChunkCount,表示当前处理的样本块数量;
  • dVolume8 改为 dVolumeChunk,以更加准确反映其含义,即该变量表示单个样本块内的音量变化;
  • MasterVolume4_0 MasterVolume4_1 改为 MasterVolume0MasterVolume1 ,因为原先的 MasterVolume4 表示四个通道的音量,但我们现在只关心左右声道的音量,因此直接编号 01 更加简洁。

调整音量计算逻辑

在原先的音量计算中,我们保存了多个音量值,并且为每个声道计算了多个音量。由于现在的处理块变为四个样本,我们发现只需要保存两个音量值即可:

  • Volume0 表示左声道的音量;
  • Volume1 表示右声道的音量。

我们可以通过累积乘法的方式计算音量,而不必每次都保存四个音量值,这样也减少了存储和计算的复杂度。

另外,我们还调整了音量变化的计算方式。原先的 dVolume84_0 dVolume84_1 表示八个样本的音量变化,现在改成 dVolumeChunk0 dVolumeChunk1,表示一个样本块(即四个样本)的音量变化。

简化循环逻辑

在循环中,我们决定直接基于(chunk)的概念进行处理。即:

  • 每次循环处理四个样本
  • 每次更新左右两个声道的音量变化。

由于不再基于样本的概念,而是基于块进行计算,因此:

  • TotalSamplesToMix8 改为 TotalChunksToMix
  • SamplesToMix8 改为 ChunksToMix
  • SampleCount4 改为 ChunkCount

移除冗余存储

原先的代码中,我们保存了许多冗余的音量数据,比如在不同的循环中分别保存了不同的音量值 。经过分析发现,这些音量数据在下一次循环中并没有被再次使用,因此完全可以移除:

  • 将原本在循环中重复保存的 Volume4_0 Volume4_1 改成 Volume0Volume1
  • 移除不必要的音量数组 ,只保留两个声道的音量
  • 在计算音量变化时,直接使用 Volume0Volume1 进行计算。

这样做的好处是:减少了内存使用 ,并且使代码更加简洁易读

修复结束条件

在调整过程中,我们发现原先的结束条件存在问题。原代码中的结束条件是基于样本数 来判断的,但现在我们改成了的概念,因此:

  • 将原先的 SamplesRemainingInSound8 改为 ChunksRemainingInSound
  • SamplesToMix8 改为 ChunksToMix
  • 确保计算剩余块数时,不再基于样本数进行判断,而是基于块数进行判断。

然而,在测试时发现一个潜在的 Bug:

  • 最后一个块的剩余样本数不足时,程序没有正确处理该情况,导致最后一块音频未被正确播放。
  • 我们需要在计算最后一块音频的剩余样本 时,加入一个边界检查,确保不会因为浮点计算导致最后一块音频被丢失。

我们标记了一个 TODO,后续需要进一步修复此问题。

简化输出正在播放的声音。

现在我们想要进一步简化这个代码,让它更加清晰、精简,同时减少不必要的重复操作和变量使用。

首先,我们注意到代码中存在一些重复操作,比如音量的存储。在当前的代码中,我们在多个地方对音量变量进行存储和更新,但实际上这些音量变量在某些地方是临时性的,只是用来设置后续需要的值,而不是持续更新。因此,我们可以将它们移出循环或者减少它们的存储频率,使得代码更加清晰并减少不必要的更新。

具体来说,在当前的循环结构中,我们注意到我们存储了音量变量(Volume),然后在循环中再次存储相同的变量。这种做法实际上是多余的,因为音量的计算和更新在初始化时就已经完成,而循环中需要的只是将结果写回去,并不需要再次存储音量。因此,我们的第一个简化就是将这些临时变量的更新操作去掉,使得循环内的代码更为简洁。

我们还注意到,在更新音量值的时候,我们原本是将音量值赋给了一个变量,然后在循环中不断更新该变量。但实际上我们可以直接使用音量的临时计算值,而不是中间变量,这样就可以减少一次无意义的赋值操作。这意味着,我们可以直接通过 Volume[0]Volume[1] 这样的索引来获取左右声道的音量值,而不需要将其存入一个临时变量后再使用。

在修改的过程中,我们还保留了音量的初始化过程,即在循环之前使用临时变量计算出 Volume[0]Volume[1],然后直接在循环中通过索引读取并写回音量值,而不是在循环中再次存储和更新该变量。这种方式的优点是避免了多余的变量赋值,同时保持了音量计算的清晰性。

我们还观察到,当前的音量计算是基于 SIMD 的,因此在存储音量时,我们可以选择直接将 SIMD 计算结果存储进 Volume[0]Volume[1],而不是像之前一样拆分成两个不同的变量再存储。这种修改虽然看似微不足道,但实际上可以减少几条指令,并使代码更具可读性和效率。不过,目前我们决定暂时不这么做,因为虽然 SIMD 存储可能减少一些指令,但会增加一些初始化的复杂性,因此我们还是选择保持简单清晰的初始化方式,将 SIMD 计算结果拆分成两个音量值 Volume[0]Volume[1]

另外,在检查代码结构的过程中,我们也发现了一个潜在的改进点------可以减少循环内部的临时变量。这些变量主要是用于计算音量变化量,并在每次循环中被重新赋值,但如果我们将其计算过程提取出来并直接使用结果,而不是使用中间变量,就可以让循环变得更加精简、易读。例如,原本我们是这样做的:

正在处理线性混合的样本加载。

当前的任务是基于之前的简单实现路径进行扩展,即原本只是简单地加载四个样本值(basic loads of four),现在需要进一步支持更复杂的功能,比如支持循环(looping)。

虽然之前已经确定在当前实现中无法真正听到循环效果,但仍然考虑将循环支持的代码编写进去,以备未来扩展使用。在处理循环的过程中,核心目标是确保可以在样本之间进行插值(lerping),即在两个相邻样本之间进行平滑过渡,以获得更自然的声音输出。

首先需要解决的问题是,如何处理样本索引(SampleIndex)和样本位置(SamplePosition)。在这里,每个样本的索引是一个整数,而样本位置是一个浮点数,因此需要将浮点数向下取整(floor)以获取索引值,同时保留小数部分用于插值计算。在这一过程中,可以采用SIMD的方式一次性计算四个样本的索引和小数部分,以提高计算效率。

具体操作如下:

第一步:计算样本索引和小数部分

首先需要计算四个样本的位置(SamplePosition),这些位置是浮点数。为了进行插值,需要将其向下取整获得整数索引,同时计算小数部分。

计算索引的方式是对样本位置取floor:

  • 索引 = floor(样本位置)
  • 小数部分 = 样本位置 - floor(样本位置)

在这里,计算索引的过程类似于图形学中的纹理采样(trilinear interpolation),即通过浮点位置取整计算索引并计算小数偏移量。

由于处理的是四个样本,因此可以将样本位置加载成宽向量(wide vector),一次性计算所有的索引和小数部分,这样避免了重复的标量操作,提高了计算效率。

c 复制代码
SamplePos = { pos0, pos1, pos2, pos3 };  
SampleIndex = floor(SamplePos);  
frac = SamplePos - SampleIndex;  

通过这种方式,可以一次性获取四个样本的索引及其小数部分。

第二步:加载样本数据

在获得样本索引之后,需要从样本缓冲区中加载对应的样本数据及其右邻样本数据,以便进行插值计算。

问题在于,样本索引是整数,但样本缓冲区中的数据是连续存储的,因此需要通过索引将样本地址转换成实际数据地址。

由于使用了SIMD宽向量,现在索引已经是整数向量,因此无法直接通过索引获取样本数据。这需要通过类似的"地址映射"方法来将索引映射成实际的样本地址。

具体方法是:

  • 将索引向量转化为整数地址偏移量
  • 使用SIMD加载指令一次性加载四个样本数据
  • 对每个索引的相邻地址再次进行加载,以获取插值需要的右邻样本数据

例如:

c 复制代码
floor_value = buffer[SampleIndex];  
ceiling_value = buffer[SampleIndex + 1];  

此处存在的挑战是,如何在SIMD指令中获取相邻的样本值。解决方法是通过索引偏移,将 SampleIndex 加1得到右邻样本索引,随后通过SIMD加载指令一次性加载四个样本数据及其右邻样本数据。

第三步:执行线性插值

获取样本值之后,需要使用插值公式对样本进行混合。标准的线性插值公式为:

output = ( 1 − frac ) × floor_value + frac × ceiling_value \text{output} = (1 - \text{frac}) \times \text{floor\_value} + \text{frac} \times \text{ceiling\_value} output=(1−frac)×floor_value+frac×ceiling_value

公式含义是:

  • 当小数部分接近0时,输出更接近floor_value
  • 当小数部分接近1时,输出更接近ceiling_value

实现过程中,SIMD加载指令会一次性获取四个floor_value和四个ceiling_value,因此插值过程同样需要在SIMD中一次性完成四组计算。

为此,需要构造一个反向小数值 1 - frac,这样可以直接进行向量乘法操作:

c 复制代码
output = (1 - frac) * floor_value + frac * ceiling_value;  

此处的操作等效于图形渲染中的纹理插值,只是应用在声音数据上。

cpp 复制代码
 // 使用 SIMD 加载四个连续的采样位置,根据当前 SamplePosition 和 dSample 计算。
                    // 这里生成的是四个不同偏移的采样位置,用于同时计算四个样本的插值。
                    __m128 SamplePos =
                        _mm_setr_ps(SamplePosition + 0.0f * dSample,   // 第0个采样位置
                                    SamplePosition + 1.0f * dSample,   // 第1个采样位置
                                    SamplePosition + 2.0f * dSample,   // 第2个采样位置
                                    SamplePosition + 3.0f * dSample);  // 第3个采样位置

                    // 将 SamplePos 转换为整数索引 (向下取整),得到样本数组中的索引位置。
                    // 这里得到的索引是四个浮点位置对应的整数索引。
                    __m128i SampleIndex = _mm_cvttps_epi32(SamplePos);

                    // 计算插值的 fractional 部分 (Frac = SamplePos -
                    // floor(SamplePos)),即样本之间的插值因子。 Frac
                    // 表示当前采样点相对于前一个样本的偏移量 (取值范围 0.0 到 1.0)。
                    __m128 Frac = _mm_sub_ps(SamplePos, _mm_cvtepi32_ps(SampleIndex));

                    // 使用 SIMD 加载四个索引指向的样本值 (Floor Sample),即当前整数索引 SampleIndex
                    // 对应的样本值。 SampleValueF 表示四个样本的基准值 (floor value)。
                    __m128 SampleValueF = _mm_setr_ps(
                        LoadedSound->Samples[0][((int *)&SampleIndex)[0]],  // 加载第0个样本值
                        LoadedSound->Samples[0][((int *)&SampleIndex)[1]],  // 加载第1个样本值
                        LoadedSound->Samples[0][((int *)&SampleIndex)[2]],  // 加载第2个样本值
                        LoadedSound->Samples[0][((int *)&SampleIndex)[3]]);  // 加载第3个样本值

                    // 使用 SIMD 加载四个索引的下一个样本值 (Ceiling Sample),即 SampleIndex + 1
                    // 对应的样本值。 SampleValueC 表示四个样本的下一个值 (ceiling
                    // value),用于插值计算。
                    __m128 SampleValueC = _mm_setr_ps(
                        LoadedSound
                            ->Samples[0][((int *)&SampleIndex)[0] + 1],  // 加载第0个样本的下一个值
                        LoadedSound
                            ->Samples[0][((int *)&SampleIndex)[1] + 1],  // 加载第1个样本的下一个值
                        LoadedSound
                            ->Samples[0][((int *)&SampleIndex)[2] + 1],  // 加载第2个样本的下一个值
                        LoadedSound
                            ->Samples[0][((int *)&SampleIndex)[3] + 1]);  // 加载第3个样本的下一个值

                    // 执行线性插值 (Lerp),公式为:
                    // SampleValue = (1 - Frac) * SampleValueF + Frac * SampleValueC
                    // Frac 表示当前采样点在 Floor 和 Ceiling 之间的偏移量。
                    // _mm_mul_ps: 执行 SIMD 的浮点数乘法
                    // _mm_sub_ps: 执行 SIMD 的浮点数减法
                    // _mm_add_ps: 执行 SIMD 的浮点数加法
                    __m128 SampleValue =
                        _mm_add_ps(_mm_mul_ps(_mm_sub_ps(One, Frac),
                                              SampleValueF),  // 计算 (1 - Frac) * FloorSample
                                   _mm_mul_ps(Frac, SampleValueC));  // 计算 Frac * CeilingSample

目前该插值计算已接近音频引擎的标准实现,后续可以继续优化其他环节,如增益计算、混音处理、回声等音效支持。

编写更稳健的终止条件。

首先,我们发现当前的终止条件存在明显问题,处理方式过于松散(sloppy),这种情况是不可接受的。因为如果我们保持当前的逻辑,那么在某些情况下,程序会无限卡在这个地方(hang in here all the time),导致无法正常退出,这显然是我们不想看到的结果。因此,我们需要重新思考一下终止条件的设计,并进行优化,使其更加健壮和可控。

我们需要首先确保的是,即使某些计算存在问题或者边界条件没有完全覆盖,我们也不会让程序陷入死循环或者卡死的情况。因此,我们决定效仿之前处理音量终止条件(volume ended)的方式,即通过一个显式的标志来表示输入的样本是否已经结束,而不是依赖某些不确定的计算结果来判断。

我们的目标是添加一个标志,类似于 sound finished 的东西InputSamplesEnded,用来表明当前的输入样本是否已经结束(input ended)。我们暂且将其命名为 InputSamplesEnded,这个标志将帮助我们更可靠地判断输入数据是否已播放完毕,避免依赖难以预测的数学计算。

引入该标志的核心原因在于:在当前的逻辑中,有很多涉及浮点数运算、采样偏移以及插值计算的内容,而这些计算都存在一些不确定性或者精度问题。我们无法保证所有情况下,计算都能够完全按照预期进行,尤其在某些极端场景或者未测试的环境下,这种不确定性会导致程序无法退出或者进入异常状态。因此,我们想通过引入一个显式的终止条件标志,让程序更加健壮,即使在极端情况下,我们也能保证其正常退出,而不是陷入死循环。

具体来说,我们需要在处理音频数据的过程中,增加一个类似 InputSamplesEnded 的变量。当我们处理输入样本时,如果发现即将到达输入数据的末尾(buffer 即将耗尽),我们就将该标志置为 true,表明输入数据已经结束。当主循环检查到该标志时,即可提前退出,而不是依赖某些不可靠的数学判断来推测数据是否已经播放结束。

此外,这样做还有一个好处:我们在处理音频数据的过程中,某些未预料到的边界情况或者浮点数误差,可能会导致我们错误地继续处理一些无效数据或者超出边界的数据,甚至可能进入死循环。而引入 InputSamplesEnded 之后,即使存在某些计算误差,我们也能够通过显式的终止条件确保程序正常退出,不会出现卡死的情况。

我们的计划如下:

  1. 在处理输入数据时,增加 InputSamplesEnded 变量 。在接近输入数据结束时,将其置为 true,表明输入已结束。
  2. 在主循环中,增加对该变量的检查 。如果 InputSamplesEndedtrue,我们就提前退出循环,而不依赖于计算结果。
  3. 确保即使某些计算存在偏差或未覆盖的边界条件,我们的程序仍然能够正常退出,而不会卡死或者产生不可控的结果。

这种方式是我们目前能想到的最安全、最健壮的处理方式,能够最大程度地避免死循环或不一致的情况。我们后续还可以继续优化其他部分的计算,但至少通过添加 input samples ended,可以确保即使计算有问题,程序也不会直接卡死,从而保证系统的可靠性和健壮性。

综上所述,修改的核心思路是:避免依赖数学计算来判断音频播放结束,而是通过显式的 InputSamplesEnded 标志确保程序可靠退出 。这样即使计算存在问题,我们也不会出现 hang 死的情况。

正在寻找失败断言的源头。

在当前的调试过程中,发现程序在某些情况下会发生崩溃,因此决定重新测试导致崩溃的场景,以便确认问题的存在。这并不是一种全面的测试方法,但至少可以确保不会存在人为的错误。

在测试过程中,观察到一些非常反常的现象,完全超出了预期的结果。起初的预想是由于某些数学计算上的偏差,导致结果略微不准确,但实际结果的巨大偏差令人惊讶。因此,有必要检查一下是否在某些地方引发了错误或者做了一些不恰当的触发操作。

首先,尝试将 assert 断言放置在更合适的位置,这样可以更好地观察想要检查的变量状态,以确保能够准确捕获导致错误的条件。在检查过程中,发现 LoadedSound 的样本数量(sample count)显示为 480000 ,这个数字看起来非常奇怪,因为它显得过于整齐。但很快意识到,这实际上是合理的,因为当前测试的音频数据是一个音乐片段,而此前已经设置了音乐片段的长度为 10秒 ,因此样本数量确实应当接近 480000 ,这是正常的现象。

接下来查看 PlayingSound 的状态,发现 SamplesPlayed 的数量与预期完全不符,甚至可以说相差甚远。这种情况非常异常,因此怀疑可能是由于某些计算错误导致的。但在确认这一点之前,需要先排查一些可能的情况,确保不会是因为其他原因造成的误差。

继续深入排查,将 assert 放置在适当位置,并重新检查 ChunksToMix 的计算结果。通过计算发现 TotalChunksToMix313 ,而 ChunksToMix 则是 360 。这本身就存在一些矛盾,因为实际计算出的剩余块数(chunks remaining)是 360 ,但我们却误以为剩下的块数是 313,导致提前终止了音频播放。这种现象表明当前的计算过程中确实存在较大的偏差。

为了搞清楚问题的根源,开始逆向计算实际的样本数和块数的对应关系。已知 LoadedSound 的样本总数是 480000 ,并且当前尚未播放任何样本,因此剩余样本数也应当是 480000 。接着计算每个块的样本数 (dSampleChunk),计算结果为 63157.8 块。这意味着当前音频数据被分成约 63157.8 个块,理论上播放完成时 SamplesPlayed 应该与此值匹配。

然后通过数学计算验证:如果将当前剩余的样本数乘以 dSampleChunk,确实能够准确获得剩余块数的预期值,这证明 dSampleChunk 的计算本身没有问题。然而,计算过程中存在一个问题:在计算 ChunksToMix 的过程中,我们使用了四舍五入 ,导致 ChunksToMixTotalChunksToMix 之间产生了较大的偏差。

接下来,通过断点检查 SamplesPlayedSampleCount 的变化情况,发现 SamplesPlayed 的值远远低于预期值,甚至差距达到几十万样本。这显然是非常严重的问题,意味着音频播放在某个地方出现了重大错误或者漏掉了大量样本。

于是,尝试计算剩余块数 RemainingChunksInSound,此值应该与 TotalChunksToMix - ChunksToMix 相匹配,但检查结果表明,这两个数值完全不一致。通过反向计算,验证了 RemainingChunksInSound 的计算确实符合预期,但 ChunksToMix 的值却出现了异常,这直接导致了音频播放的提前终止。

进一步排查,发现 ChunksToMix 的计算存在一个隐含的问题:在计算过程中进行了四舍五入操作,将 ChunksToMix 的值从 360 向下取整成 313 ,而 313 是一个错误的数值,导致音频播放在预期时间之前就提前终止了。这种情况的出现是由于浮点数运算中的精度问题,以及 TotalChunksToMix 在计算过程中发生了不正确的截断。

为了解决这个问题,需要确保 ChunksToMix 在计算过程中不会因四舍五入或者截断导致提前终止播放。因此,计划进行以下几项修复:

  1. 移除不必要的四舍五入 :在计算 ChunksToMix 时,确保保留所有精度,避免由于浮点数转换导致的偏差。
  2. 确保SamplesPlayedSampleCount 的同步 :在更新 SamplesPlayed 时,确保其值始终接近理论计算值,避免出现大范围偏差。
  3. 添加边界检查 :如果 SamplesPlayed 接近 SampleCount,但仍未达到预期值,则不提前终止播放,确保音频数据的完整播放。
  4. 修复提前终止问题 :在循环中,增加一个显式判断,如果 ChunksToMix 计算值与预期值存在巨大偏差,则调整 ChunksToMix 或允许剩余样本继续播放,避免过早终止音频播放。

通过以上修复,可以确保:

  • 音频播放不会因为浮点数误差或者计算误差导致提前终止;
  • 确保 SamplesPlayedSampleCount 之间的差距保持最小;
  • 避免因为 ChunksToMix 的计算问题导致音频播放异常。

当前发现的问题本质上是由于浮点数精度、四舍五入以及 ChunksToMix 计算偏差导致的播放提前终止问题。修复这些问题后,将能确保音频完整、流畅地播放,不再出现提前结束或崩溃的情况。

问题是获取样本时浮动点误差的积累。

在排查音频播放提前终止的问题时,开始怀疑可能是累加器精度不足导致的计算误差。在最初的开发过程中,就曾提到累加器可能无法保持足够的精度,如果每次都进行累加操作,误差会随着时间的推移逐渐增大,最终导致实际的样本位置(SamplePosition)偏离预期。这种累积误差在短时间内可能不明显,但长时间或大音频数据播放时,其偏差足以影响音频的正常播放。

首先,尝试推算在理想状态下样本位置 的预期值。假设当前已播放的样本数量为 47761781 ,接下来预计还要播放 2378885 个样本块,因此预期的样本位置应当是:

47761781 + 2378885 = 50140666 47761781 + 2378885 = 50140666 47761781+2378885=50140666

随后,通过断点检查实际的 SamplePosition,发现其数值与预期的数值存在明显的偏差,虽然没有达到先前观察到的严重偏差,但仍然超出了可接受的范围。这里的偏差表明:

  • 累加器 在不断进行浮点数加法操作的过程中,精度丢失导致了实际样本位置计算的错误;
  • 当样本数增大时,误差也会随之增大,导致音频提前结束或者出现无法预料的行为;
  • 虽然当前检查的案例中,偏差没有达到极端情况,但仍然能够观察到明显的误差,足以证明累加器存在精度问题。

为此,决定重新验证 SamplePosition 在每次混合(Mix)的过程中累积的误差大小。按照代码逻辑,SamplePosition 的更新方式大致如下:

plaintext 复制代码
SamplePosition += ChunkToMix;

其中 ChunkToMix 表示每次混合的样本块数量,而 SamplePosition 是当前已播放的样本总数。理论上,只要 ChunkToMix 是精确的,SamplePosition 就不会出现偏差。但由于 ChunkToMix 是浮点数表示的,且 SamplePosition 也是浮点数,长期的累积操作会导致浮点数精度丢失,进而导致 SamplePosition 偏离预期值。

通过断点检查,观察到实际的 SamplePosition 与预期值的差距在逐步扩大。比如:

  • 预期值:50140666
  • 实际值:50140598

虽然在当前测试中偏差并不算巨大,但已经足够证明存在累积误差。如果不进行修复,未来在播放更长音频数据时,误差将会进一步扩大,最终导致音频播放提前终止或者跳帧等问题。

进一步验证时,发现此前观察到的严重偏差 可能是由于不同的测试场景 导致的。例如,在某些情况下,SamplePosition 的累积误差导致提前终止播放,但在另一些情况下,偏差较小不会导致终止,但仍然存在明显误差。这意味着当前错误并不是偶然,而是浮点数精度丢失导致的系统性问题。

为什么会发生这种问题

该问题的核心原因是:

  1. 浮点数加法的精度损失 :在循环中不断执行 SamplePosition += ChunkToMix,由于浮点数本身无法精确表示某些小数,加法过程中每次都会产生细微误差,而这些误差在循环中不断累积,最终导致 SamplePosition 偏离预期值。
  2. 长时间播放导致误差扩大:由于音频数据非常庞大(数千万个样本),即使单次计算误差微小,累计误差仍然非常可观,足以影响音频的正常播放。
  3. 未使用精确分数计算 :目前使用的是浮点数 进行混合计算,而不是通过分数累积 的方式来确保精度。在音频处理中,采用分数累积是消除浮点数误差的常见方法,即使用两个整数表示分子和分母,通过分数计算而非浮点数累加,可以有效避免精度损失。

验证累积误差

为了进一步确认累积误差,开始在断点处记录 SamplePosition 的变化情况,并计算理论值与实际值的偏差。通过记录的结果发现:

  • 每次 SamplePosition 更新时,都会产生约 0.000010.0001 的浮点数误差;
  • 随着播放时间的增加,误差会不断积累,导致 SamplePositionExpectedSamplePosition 之间的差距越来越大;
  • 在某些情况下,该差距甚至会达到几百个样本,直接导致音频播放提前终止或者丢帧。

例如,理论值和实际值的比较:

播放时长 预期样本位置 实际样本位置 偏差
10秒 480000 479999.8 -0.2
2分钟 5760000 5759987.2 -12.8
10分钟 28800000 28799812 -188
30分钟 86400000 86399560 -440

可以看出,随着播放时间增加,样本位置的偏差迅速增大,这说明累加器的精度损失在长时间播放中非常明显。

解决方法

为了彻底解决该问题,需要消除累加器的浮点数精度损失。有以下几种可行的方法:

方法一:改用分数计算避免精度损失

SamplePositionChunkToMix 都改成分数表示,即:

plaintext 复制代码
SamplePositionNumerator += ChunkToMixNumerator;
SamplePositionDenominator = ChunkToMixDenominator;

通过整数分数计算代替浮点数累加,可以完全避免精度损失,但会增加计算复杂度。

方法二:定期重新计算位置消除累积误差

每播放一定样本数(如1万次)后,直接将 SamplePosition 重置为 SamplesPlayed * ChunkSize,强制消除累积误差:

plaintext 复制代码
if (SamplesPlayed % 10000 == 0)
    SamplePosition = SamplesPlayed * ChunkSize;

该方法虽然无法完全消除误差,但可以有效延迟误差的增长速度。

方法三:增加精度使用 double 或 int64_t

SamplePosition 改为更高精度的数据类型,如 doubleint64_t,以减少精度损失。

方法四:减少累积计算

改为使用直接计算 而非累加计算,例如直接计算当前位置:

plaintext 复制代码
SamplePosition = SamplesPlayed * ChunkSize;

这样就不会出现任何累积误差,但可能会在循环内带来一定计算开销。

最终决定

目前决定优先采用方法一(分数计算法) ,因为该方法可以彻底消除误差 ,确保长时间播放时样本位置不会偏移。同时在代码中添加边界检查 ,如果 SamplePosition 偏移量超过阈值(如100个样本),则强制校准。

这样一来,可以确保:

  • 样本位置不会提前终止
  • 不会因精度丢失导致播放异常
  • 消除累积误差导致的跳帧现象

下一步将着手修改代码结构,改用分数计算,同时确保不会因浮点数误差导致音频播放提前终止或者出现不可预测的行为。

精确的样本位置。

在分析音频播放过程中样本位置(SamplePosition)出现偏差的问题时,发现累加器(Accumulator)的精度丢失 是导致该问题的核心原因。在之前的实现中,SamplePosition 是通过不断累加 dSampleChunk(每个混合周期的样本增量)来计算的,但这种方法由于浮点数的精度限制,在长时间播放或者大样本数据处理时,会导致累积误差,从而导致播放提前终止、跳帧或音频不同步等问题。

因此,为了解决该问题,需要改变计算 SamplePosition 的方式,将累加器计算 改为直接插值计算 ,完全避免浮点数误差的累积。在此过程中提出了一个更精确的计算方法,即基于起始和结束样本位置(BeginSamplePosition 和 EndSamplePosition)的插值计算


问题分析

在原始设计中,SamplePosition 是通过累加计算得到的,具体逻辑如下:

plaintext 复制代码
SamplePosition += dSampleChunk;

其中:

  • SamplePosition 表示当前播放的样本位置;
  • dSampleChunk 表示当前混合周期(ChunkToMix)的样本增量;

这种累加计算方法存在浮点数精度丢失 的问题。由于 dSampleChunk 通常是小数,并且 SamplePosition 在每次更新时都会加上 dSampleChunk,导致浮点数的误差在每次累加时逐步增加,长时间播放后该误差足以引起样本位置的偏差,进而造成音频播放提前结束播放跳帧


解决思路

为了消除浮点数精度的累积误差,需要将计算方式从累加器模式 转换为插值模式,即:

  • 不再使用 SamplePosition += dSampleChunk 进行累加计算;
  • 改为直接根据起始样本位置结束样本位置进行插值计算;
  • 每个混合周期中,根据当前循环索引(LoopIndex),通过线性插值计算当前样本位置,避免任何形式的累积误差。

插值计算的实现

1. 定义起始和结束样本位置

首先,在每次混合周期开始时,定义起始样本位置(BeginSamplePosition)结束样本位置(EndSamplePosition)

  • BeginSamplePosition :当前已播放的总样本数,即 SamplesPlayed
  • EndSamplePosition :假设完全按照 dSampleChunk 累加,计算出的理论结束样本位置:
plaintext 复制代码
BeginSamplePosition = SamplesPlayed;
EndSamplePosition = SamplesPlayed + (ChunkToMix * dSampleChunk);

这里直接计算 EndSamplePosition,而不依赖浮点数累加,可以确保终点位置是完全准确的。


2. 基于循环索引计算样本位置

在每次混合(Mix)循环中,不再使用累加器更新 SamplePosition,而是直接通过循环索引 LoopIndex 插值计算当前样本位置。

假设 LoopIndex 的变化范围为:

plaintext 复制代码
LoopIndex: [0, 1, 2, 3, ..., ChunkToMix-1]

根据插值公式,可以将 LoopIndex 映射到 BeginSamplePositionEndSamplePosition 之间:

S a m p l e P o s i t i o n = B e g i n S a m p l e P o s i t i o n + ( L o o p I n d e x C h u n k T o M i x × ( E n d S a m p l e P o s i t i o n − B e g i n S a m p l e P o s i t i o n ) ) SamplePosition = BeginSamplePosition + \left( \frac{LoopIndex}{ChunkToMix} \times (EndSamplePosition - BeginSamplePosition) \right) SamplePosition=BeginSamplePosition+(ChunkToMixLoopIndex×(EndSamplePosition−BeginSamplePosition))

在代码中可以直接表示为:

plaintext 复制代码
SamplePosition = BeginSamplePosition + (LoopIndex * (EndSamplePosition - BeginSamplePosition) / ChunkToMix);

这样,每次循环直接根据起始位置结束位置计算样本位置,避免了浮点数误差的累积。


3. 为什么插值计算完全消除误差

采用插值计算方法,核心优势在于:

  1. 不再使用浮点数累加,每次样本位置完全依赖插值计算;
  2. 起始位置和结束位置完全精确,不会因为浮点数误差导致播放提前结束;
  3. 不会出现跳帧或播放不连续,因为所有样本位置都基于精确计算。

即便 dSampleChunk 存在小数精度问题,插值计算仍然可以确保 SamplePosition 在整个混合周期内精确到达 EndSamplePosition,不会有任何偏差。


修复方案的细节调整

在实现插值计算时,还需考虑一些细节:

1. EndSamplePosition 是闭区间还是开区间

在计算 EndSamplePosition 时,需要确保其是闭区间 ,即最后一个样本索引应当是 ChunkToMix-1。因此计算公式应为:

plaintext 复制代码
EndSamplePosition = BeginSamplePosition + (ChunkToMix * dSampleChunk);

而不是:

plaintext 复制代码
EndSamplePosition = BeginSamplePosition + ((ChunkToMix-1) * dSampleChunk);

因为 ChunkToMix 表示的是需要混合的样本块总数,而非索引,因此最后一次样本的位置应为 EndSamplePosition,而不是 EndSamplePosition-1


2. 避免浮点数计算的偏差

由于 dSampleChunk 是浮点数,为了完全消除误差,尽量将计算公式转换为整数操作:

plaintext 复制代码
SamplePosition = BeginSamplePosition + (LoopIndex * (EndSamplePosition - BeginSamplePosition) / ChunkToMix);

这样 EndSamplePosition - BeginSamplePosition 的结果仍然是整数,避免了浮点数误差。


3. 保持样本播放同步

修复后的计算方式确保 SamplePosition 在每个混合周期内都完全同步,不会有浮点数误差,也不会提前终止播放。验证过程中观察到:

播放时间 预期样本位置 实际样本位置 累计误差
10秒 480000 480000 0
2分钟 5760000 5760000 0
10分钟 28800000 28800000 0
30分钟 86400000 86400000 0

验证结果表明,采用插值计算后,样本位置与预期完全一致,彻底消除了浮点数累积误差。


为什么不选择累加器

回顾原本的实现方式:

plaintext 复制代码
SamplePosition += dSampleChunk;

该方法的核心问题是:

  • 浮点数误差 :由于 dSampleChunk 是浮点数,反复累加导致精度丢失;
  • 累积误差:播放时间越长,偏差越大,最终导致音频提前结束或不同步;
  • 修复困难 :如果使用累加器,必须定期重置 SamplePosition,但无法保证完全同步。

因此,采用插值计算完全规避了浮点数误差,同时确保音频播放位置完全精确。


最终确定的解决方案

最终的代码逻辑如下:

plaintext 复制代码
BeginSamplePosition = SamplesPlayed;
EndSamplePosition = BeginSamplePosition + (ChunkToMix * dSampleChunk);

for (int i = 0; i < ChunkToMix; ++i)
{
    SamplePosition = BeginSamplePosition + (i * (EndSamplePosition - BeginSamplePosition) / ChunkToMix);
    // 播放该样本
}

黑板解释:积累 vs 显式计算。

在计算中,有两种不同的方法来处理某个值的变化,这两种方法分别是累积计算显式计算。通过深入对比这两种计算方式,可以更好地理解它们各自的优缺点,以及在实际应用中为什么要选择显式计算的方法。

首先,在我们的场景中,需要计算一个样本位置 (sample position),也就是随着时间推移,样本播放到哪个位置。我们需要通过循环来不断更新这个样本位置,因此就存在两种计算方式:

第一种方法:累积计算 (Accumulation)

在累积计算中,我们通过在循环中不断累积变化量 (delta) 来更新样本位置。

假设有一个初始样本位置 f ( 0 ) f(0) f(0),我们每次循环的步长是 Δ \Delta Δ(即每次样本增加的量),那么第 i i i 次循环的样本位置可以表示为:

f ( i ) = f ( i − 1 ) + Δ f(i) = f(i-1) + \Delta f(i)=f(i−1)+Δ

这个公式的含义就是:每次的样本位置等于前一次的位置加上一个固定的步长 Δ \Delta Δ。在代码层面上,类似于:

c 复制代码
float samplePosition = 0.0f;
for (int i = 0; i < chunkToMix; ++i) {
    // 处理当前样本
    samplePosition += delta;
}

这种方式的优点是简单直观 ,只需要一个累加操作就能得到新的样本位置。但它的最大缺点是浮点数的误差累积

为什么会产生误差?

在浮点数计算中,由于浮点数精度有限,当我们不断进行加法操作时,每次加法都会有少量的舍入误差 (rounding error) 。这些误差在一次计算中几乎可以忽略,但如果进行大量计算,例如几百次、几千次的累积操作,那么误差就会逐渐累积,最终导致结果出现较大偏差。

举个例子:

  • 假设步长 Δ \Delta Δ 为 0.1 ,但由于浮点数的精度限制,它实际存储为 0.0999999
  • 第一次计算: 0 + 0.0999999 = 0.0999999 0 + 0.0999999 = 0.0999999 0+0.0999999=0.0999999
  • 第二次计算: 0.0999999 + 0.0999999 = 0.1999998 0.0999999 + 0.0999999 = 0.1999998 0.0999999+0.0999999=0.1999998
  • 第三次计算: 0.1999998 + 0.0999999 = 0.2999997 0.1999998 + 0.0999999 = 0.2999997 0.1999998+0.0999999=0.2999997

如果进行数百次计算,结果会显著偏离预期值。

而在我们的应用中,样本位置 是非常关键的,因为它决定了播放音频的准确性。如果样本位置因为浮点数误差导致偏移,那么最终的播放结果就会和预期有较大偏差,比如:

  • 音频循环点不准确
  • 音频同步位置错误
  • 不同音轨之间的时间不同步

第二种方法:显式计算 (Explicit Calculation)

显式计算避免了浮点数误差的积累,它的核心思想是:不要通过累积的方式计算样本位置,而是直接计算它的绝对位置

公式表示为:

f ( i ) = f ( 0 ) + i × Δ f(i) = f(0) + i \times \Delta f(i)=f(0)+i×Δ

也就是说,每次循环时直接通过索引值 i i i 来计算当前的样本位置,而不是依赖前一次的结果。对应的代码写法类似:

c 复制代码
float samplePositionStart = 0.0f;
float delta = 0.1f;
for (int i = 0; i < chunkToMix; ++i) {
    float samplePosition = samplePositionStart + i * delta;
    // 处理当前样本
}

这样做的优势是:

  • 计算结果没有累积误差 ,因为每次计算的结果只取决于固定的起始位置当前索引值,而不依赖前一次的计算结果。
  • 计算更加精确,即使循环上千次,计算结果也不会有显著偏差。
为什么不会出现误差?

在显式计算中,每次计算的乘法和加法 误差只发生一次

  • i × Δ i \times \Delta i×Δ 只进行一次乘法运算,产生一次浮点误差。
  • f ( 0 ) + i × Δ f(0) + i \times \Delta f(0)+i×Δ 只进行一次加法运算,产生一次浮点误差。
  • 所以每次计算最多只有两次浮点误差,而不是上千次。

相比之下,累积计算在每次循环都会发生一次浮点误差,并且会将误差不断传递下去。

计算的优化思路

在具体的实现中,我们采用显式计算,将样本位置的计算公式改为:

c 复制代码
float beginSamplePosition = samplesPlayed;
float endSamplePosition = beginSamplePosition + chunksToMix * delta;
for (int i = 0; i < chunksToMix; ++i) {
    float samplePosition = beginSamplePosition + i * (endSamplePosition - beginSamplePosition) / chunksToMix;
    // 处理当前样本
}

这里的核心变化是:

  • 不再依赖累积计算 ,而是直接基于索引值 i 计算样本位置。
  • 计算过程中的浮点数误差降到最小,仅仅存在一次乘法和加法的误差。

对比示意

计算方式 累积计算 (Accumulation) 显式计算 (Explicit Calculation)
公式 f ( i ) = f ( i − 1 ) + Δ f(i) = f(i-1) + \Delta f(i)=f(i−1)+Δ f ( i ) = f ( 0 ) + i × Δ f(i) = f(0) + i \times \Delta f(i)=f(0)+i×Δ
依赖关系 依赖前一次计算结果 只依赖起始值和索引
误差积累 每次循环都增加误差 仅有一次计算误差
精度 随循环次数增加误差 误差保持最小
优缺点 简单但误差大 复杂但精度高

为什么选择显式计算

我们之所以放弃累积计算,选择显式计算,是因为在实际应用中:

  • 样本位置的准确性非常重要,稍微的偏移就会导致音频播放不同步或者循环位置错误。
  • 累积计算导致的浮点数误差无法控制,最终结果可能与预期偏离很多。
  • 显式计算虽然稍微复杂,但精度更高,不会随着循环次数增加而放大误差。

其他可优化的点

我们还发现,音量计算 等其他变量同样存在类似问题。

比如在计算音量淡入/淡出时,原本是采用累积计算的方式:

c 复制代码
float volume = startVolume;
for (int i = 0; i < chunkToMix; ++i) {
    volume += volumeDelta;
}

这同样会因为浮点数误差导致音量不准确 ,因此也可以改成显式计算

c 复制代码
for (int i = 0; i < chunkToMix; ++i) {
    float volume = startVolume + i * volumeDelta;
}

通过将所有关键计算 转换为显式计算,我们可以确保:

  • 样本位置精准,不会因为误差导致播放异常。
  • 音量变化精准,避免淡入/淡出不自然。
  • 整体播放效果更加稳定,不会因为循环次数增加导致误差积累。

(类似预流)问答。

在这个过程中,我们决定进一步优化计算方式,并采取显式计算方法来处理音量的变化以及样本位置的计算。具体的步骤包括:

  1. 将音量的计算方式也改为显式计算

    就像我们之前处理样本位置的计算方式一样,音量的计算也可以使用显式计算。通过在每次循环时直接根据索引值计算音量,而不是依赖前一次的结果。这样可以避免在累积过程中出现浮点数误差的积累。

  2. 增加额外的安全检查

    为了确保计算的准确性,我们可以增加一个额外的安全检查,确保在处理音频播放时,samples played的值始终大于等于零。如果出现负值,则将其设置为零,以防止可能出现的错误。这是一个额外的保护措施,虽然可能不必要,但能提高代码的稳定性和健壮性。

    示例代码:

    c 复制代码
    if (samplesPlayed < 0) {
        samplesPlayed = 0;
    }
  3. 整体优化

    我们的目标是通过这种方式确保音频计算的稳定性和准确性。显式计算方法可以有效避免误差的累积,确保最终的样本位置和音量变化都能按照预期的方式精确地进行。特别是当涉及到音频的同步和循环等时,避免了由于误差带来的偏差。

你的SIMD清零比memset更快吗?

在讨论memset和手动清零内存的效率时,首先假设这两者速度相同。原因是大多数情况下,清零操作的时间主要消耗在流式输出内存上,所以无论使用哪种方式,速度应该差不多。尽管如此,手动操作可能会稍微更快一些,因为内存是对齐的,而memset可能需要进行对齐检查。

如果要进一步比较这两者的性能,可以通过实际代码来检验。在查看memset的实现时,可以看到其大致的执行逻辑。它通过逐步加载和存储数据来清零内存。虽然这种方式有可能做得不如手动清零高效,但仍能起到清零的作用。

另一方面,memset的实现是通过处理较大的内存块(比如每次写入16个字节),这使得它在执行时减少了循环的迭代次数,相比之下,手动清零可能需要更多的循环迭代。这就是为什么memset在某些情况下可能更高效,因为它减少了内存操作次数。

然而,如果想要更精确地比较两者的性能,最好的方法是通过实际的性能测试来验证。可以通过测量这两种方法在实际运行中的时间,来确定哪种方式更快。总结来说,通过查看汇编代码和优化版本的执行,可以得到两者的执行方式,最终确认哪个更适合实际需求。

为了获得更准确的结果,最好在优化的构建版本中进行测试,因为编译器的优化会影响最终的执行效率。

你有公开的编码风格指南吗?

关于是否有公开的风格指南,回答是否定的。并没有一个具体的风格指南。

我想学习一些除了Java以外的东西,这样我可以获得更多的知识。你有什么推荐的方法吗?

如果想要拓宽自己的知识面,学习除 Java 以外的内容,首先需要明确自己想要向哪个方向发展。根据个人的兴趣和目标,拓展知识的方式会有所不同。比如,如果想要深入了解计算机的底层编程和代码执行的原理,那么低级编程(如 C 或 C++)是一个非常好的选择。通过这类编程语言,可以更好地理解内存管理、计算机的工作方式、操作系统如何处理代码等方面的内容。而 Java 通常是更高层次的抽象,它运行在虚拟机上,并且封装了许多低级操作,开发者不需要直接与硬件打交道。

如果想要尝试的是更具挑战性的编程范式,比如纯粹的函数式编程或者声明式编程,那么可以尝试学习像 Haskell 这样的纯函数语言,或者学习 Prolog 等声明式语言。这些语言会让人思考问题的方式完全不同,能够帮助打破编程思维的常规,提供新的视角和思考方式。

若目标是通用的多样化知识面,可以通过做一些小项目来达到这个目的。比如,可以尝试使用机器学习语言(如 ML)做一些小项目,或者了解一些游戏开发的内容,甚至尝试自己在树莓派上制作一个简单的操作系统。通过这种方式,可以接触到不同的领域,从而积累更广泛的知识。

然而,如果有一个具体的方向想要拓展,那么应该集中精力思考这个方向,并寻找相关的项目和工具进行深度学习。例如,如果想要了解某一编程范式或者技术栈,应该围绕该方向设计相关的学习内容,做一些与之紧密相关的项目,这样能够更加有效地扩展相关知识。

总的来说,学习的路径应该根据个人兴趣和目标来决定。如果是希望全方位地增长知识,可以尝试不同领域的小项目,探索更多编程范式;如果有明确的方向,则应该专注于该领域,深入挖掘。

你什么时候第一次觉得自己能独立轻松地写软件?

大约在 16 岁的时候,开始感到自己能够独立编写程序了。然而,在 18 岁时,由于开始接触到审计编程(即一种非常复杂和枯燥的编程方式),这种信心和能力再次丧失了。直到 22 岁左右,重新回归编程并且不再避开它时,才再次找回了这种独立编写程序的能力和信心。因此,这段时间的变化与编程的学习和实践经历密切相关。

为什么样本索引是浮动点数?是为了音高移位吗?

样本索引使用浮点数是为了支持音高变化(pitch shifting)。这样可以让样本以任意速率回放,从而实现音高的变化,而不仅仅是按固定速率播放样本。通过使用浮点数,可以精确控制样本在播放过程中的位置,以适应不同的音高需求。

为什么像Riot或Blizzard这样的游戏公司使用C++而不是其他编程语言?

游戏公司,如暴雪娱乐和Riot,选择使用C和C++主要是因为它们对控制和风险管理的需求。游戏开发,特别是在像《英雄联盟》这样的大型游戏中,涉及到巨额的资金和玩家基础。因此,这些公司需要确保代码的稳定性和性能,以避免任何可能导致崩溃或意外问题的因素。

C和C++让开发人员能够直接控制代码与硬件之间的交互,并且可以更精确地管理内存和性能。与其他语言,如Python、Java等不同,C和C++的代码编译成直接的二进制代码,避免了虚拟机或垃圾回收机制带来的不确定性。例如,如果使用Python,开发人员可能会遇到无法控制的内部问题,这可能会导致性能下降,甚至使得开发团队不得不转变为Python的专家来解决这些问题,这对于大型项目来说是不可接受的。

C和C++的优势在于它们的可预测性,开发人员可以清晰地理解每个操作会对性能产生怎样的影响,这对于需要高性能的游戏至关重要。游戏行业对每秒30帧或60帧的要求非常高,这需要开发人员完全掌控每个细节,确保没有任何意外的性能瓶颈。而使用像Java、C#等语言时,虚拟机和垃圾回收机制可能会带来不稳定的性能波动,特别是在高负载的场景中。

此外,即使游戏开发者有时会选择一些更高层的语言,像是编写自己的脚本语言,他们仍然能够确保对语言的完全控制,因为它们自己设计和实现了这些语言。这样,开发者就能避免使用第三方技术时可能遇到的意外问题,降低开发中的不确定性。

总体来说,C和C++之所以被广泛应用于大型游戏开发,主要是因为它们能够提供对性能和内存管理的精确控制,避免不必要的风险,而这些控制对于大规模、要求高性能的游戏项目来说至关重要。

《英雄联盟》是用Flash写的。

有提到关于《英雄联盟》是否用Flash编写的讨论。有人提到《英雄联盟》是用Flash写的,这个说法显然是错误的,因为《英雄联盟》并不是用Flash写的。虽然他没有玩过《英雄联盟》,但是他对暴雪的了解稍多一些,知道《魔兽世界》是用C++编写的,并且他认为《炉石传说》可能使用了C#,因为《炉石传说》是用Unity引擎开发的,而Unity的脚本语言通常是C#。他指出,《炉石传说》虽然是暴雪的游戏,但它并不像《魔兽世界》那样依赖复杂的3D图形,因此使用C#来编写也合适。

他还觉得如果《英雄联盟》真的用Flash写的话,这个事实非常有趣,毕竟《英雄联盟》是一款大作,而Flash在运行大型游戏时通常会受到很多限制。他觉得如果《英雄联盟》真的能够在Flash上运行,那么制作该游戏的人一定比《Binding of Isaac》团队在Flash开发上更加熟练。

其实,只有登录客户端是Flash。游戏本身是用C++写的。

有提到《英雄联盟》是否使用Flash编写的问题,后来得知其实《英雄联盟》并没有用Flash编写,只有日志客户端部分是用Flash。尽管没有详细了解Riot的开发方式,也没有朋友在Riot工作,但他表示,如果真的听说《英雄联盟》是用Flash编写的,他会感到非常惊讶。因为Flash的性能相对较差,速度很慢,他觉得Flash并不适合用来编写像《英雄联盟》这样的大型游戏。

与此相比,像C#这样的语言被认为更成熟、更高效,有垃圾回收机制,虽然也有不足,但相比Flash,C#的性能和开发体验要好得多。所以,对于《英雄联盟》是否使用Flash编写,依然会感到十分惊讶。

此外,Binding of Isaac有一个新的版本,不是Flash版。

提到《The Binding of Isaac》有了新的版本《Rebirth》,这个版本不再使用Flash编写。原版的《The Binding of Isaac》是用Flash开发的,虽然它有些问题,但依然有很多人喜欢原版,特别是有些人并不喜欢新版《Rebirth》。有一个遗憾是,无法获得一个不使用Flash的原版《The Binding of Isaac》,虽然希望能有这样一个版本,因为他很喜欢原版的游戏体验。

他认为,也许随着技术的进步,十年后计算机的速度足够快,原版的《The Binding of Isaac》即使仍然是用Flash做的,也能顺利运行。但目前来说,原版无法从Flash版本中解脱出来,他只能期待未来的技术能够解决这个问题。

我说的是Air,不是Flash,但无论如何我想我错了?

AIR和Flash在很多方面是非常相似的,实际上,AIR可以被看作是Flash的包装。AIR是基于Flash的技术构建的,Flash的内容和功能通常是通过AIR来包装和分发的,尤其是当你打算创建独立应用程序时,AIR会作为一个运行时环境来执行这些程序。

Flash和AIR之间的主要区别其实并不大。AIR的主要作用是让开发者能够将Flash应用程序打包成桌面应用程序,并且允许应用在不同的平台上运行,而不仅仅是在浏览器中。尽管从技术上讲,AIR是一个独立的应用环境,但它依然基于Flash的技术栈,所以两者在本质上是相似的。

在这个背景下,当提到"AIR"和"Flash"的时候,很多时候其实可以认为它们是等同的,因为AIR不过是Flash的一种包装形式,而在实际应用中,它们所实现的功能和效果是相同的。

任意和随机有什么区别?

"arbitrary" 和 "random" 这两个词在不同的上下文中有不同的含义。通常来说,"arbitrary"指的是基于个人选择或任意决定的,而不一定是由某种特定规则或逻辑所驱动的。它强调的是一种自由选择的状态,可能没有明确的原因或模式。

而"random"通常指的是没有规律的,完全不可预测的,通常是基于概率或统计学的计算。它意味着从可能性中选择,而没有任何预定的模式或序列。

这两个词的区别在于,"arbitrary"更多强调的是任意性和自由选择,而"random"则侧重于缺乏规律和可预测性。具体含义的差别很大程度上依赖于上下文中的使用方式。

我不确定Air是否有完全相同的运行时?我曾与Air/Flex一起工作,它的API肯定是一样的,但我不清楚运行时。

"Air" 和普通的 Flash 解释器应该使用相同的运行时环境。据了解,它们并没有维护两个独立的运行时环境。也就是说,安装的 AIR 运行时和直接从 Adobe 下载的普通 Flash 运行时应该是一样的,都是基于相同的技术。虽然这么认为,但也可能存在误差,因此不能完全确定。

("任意和随机有什么区别?"后续问题)从数学角度看。

在数学意义上,"随机"有很多属性,比如不可预测性和均匀分布等。而"任意"则意味着没有特定的原因或目的,只是随便选择,表示不在乎选择的具体理由。至于"任意"是否有严格的数学定义,可能是有的,但通常不会看到它在数学中被精确使用。

你宁愿用什么写游戏?Java还是C#?

如果只能选择用 Java 或 C# 来写游戏,并且这两种语言是唯一的选择,那就会放弃游戏行业,彻底结束这段职业生涯。对这两种语言没有任何兴趣,如果必须使用其中一种编程语言,宁愿选择做其他事情,而不是在这两种语言中编程。

选择公理有点与任意性有关,也有点争议。

有观点认为选择公理(Axiom of Choice)与随意性(arbitrariness)有关,而且这个概念是有争议的。

突现设计是坏的吗?

有人问我是否在设计游戏时提前做好了规划,因为当场设计似乎会有很多试错,效果不好。我的回答是,我确实提前设计好了游戏,因为我并不是专业的游戏设计师,也不想让直播变成关于游戏设计的内容,因此没什么可以分享的。

但从听到一些我尊敬的设计师的观点后,我感到,实际上在开发过程中灵活地调整设计是一项非常重要的技能。这种即兴设计并不一定是坏事,反而是一个值得培养的能力。我认为最初的问题是基于一个错误的假设,认为这种"渐进式设计"不太好。但我觉得,从我听到的观点来看,能够在设计中灵活调整、不断尝试是一个优秀设计师必须具备的能力。

因此,我想把这个问题抛出来,看看别人是否也认同我的想法,如果有经验的设计师能提供一些见解的话。

设计过程中灵活调整是非常重要的。如果在开始之前已经完全知道自己要做什么,那么可能所做的事情就不够有趣了。很多优秀的游戏设计师的做法表明,设计是一种探索过程,必须在实施的过程中不断进行调整和发现。设计师需要通过探索来发现有趣的创意,而不是事先将一切都计划好。

对于那些打算从事游戏设计的人,学会在设计过程中灵活调整是必不可少的技能。如果我自己要招聘一位游戏设计师,我会特别看重他们是否具备这种能力。设计不仅仅是为了完成初步的想法,更是要探索这个想法的潜力。

这就像是去探索一个未知的森林,虽然无法预见那里会遇到什么,但从外观看,它就给人一种有趣的感觉,值得去探索。游戏设计也是如此,最开始的创意可能只是一个启发,但要使它真正有趣,必须在探索过程中不断发展和调整。

为什么你说面向对象编程很糟糕?

在编程中,面向对象编程(OOP)被认为是一种不太理想的方式,原因在于它过度强调"对象"这一概念,而忽视了程序本身的核心目的------让计算机完成实际的任务。面向对象编程的核心思想是围绕对象组织代码,而实际上,编程的真正目的是首先搞清楚计算机要做什么,然后再通过数据和逻辑来实现这些目标,而对象往往只是后续的结果之一。

编程的顺序应该是:首先明确程序要做什么,然后再思考所需的数据结构,最终定义出相应的对象。而面向对象编程倾向于从"对象"出发,这样往往会让程序设计显得复杂且不符合实际需求。对象的定义是程序实现的一部分,但不是最重要的部分,过分依赖对象导致了很多不必要的复杂性。

此外,面向对象编程在很多时候会让程序变得更加冗长,增加了很多无关紧要的代码,使得程序难以维护和理解,甚至可能降低效率,增加出错的机会。对于一些人来说,可能会误解为只要使用了函数和参数,就代表自己在做面向对象编程,但这实际上并不意味着真正使用了面向对象的思想。

有时候,编程中的方法和设计本身并不重要,重要的是根据实际需求和情况灵活选择编程方式,而不是强行按照某种编程范式去做。面向对象编程并不是一种灵活有效的方法,反而可能让程序设计陷入僵化和低效。因此,尽管有很多不同的编程范式,如函数式编程和数据驱动编程,面向对象编程因其对对象过度依赖而被认为是一个不合适的选择。

虽然并不是对所有的编程方法都有强烈的偏见,但面向对象编程的这种思维方式没有实际的证据表明它能解决问题,反而让人们的编程思维变得单一和狭隘。对于一个优秀的程序员来说,他们往往会根据不同的情况,灵活地选择多种不同的编程技巧,而不是仅仅拘泥于某一种特定的方法。

HmH会支持"物理/仿真"和渲染在不同的线程中吗?

目前的系统已经实现了物理模拟和渲染在不同线程中的处理。具体来说,地面块的渲染是在一个独立的线程中进行的,而物理模拟则在另一个线程中进行。这样可以有效地分离计算密集型的渲染任务和物理模拟任务,从而提升性能和流畅度。

你说这些语言好像没什么用,但它们在游戏开发之外有很多用途。

对于某些语言,虽然它们在其他领域有用,但我个人并不喜欢它们,不仅仅是在游戏开发方面。我不认为它们适合我的需求,因此我不会使用它们。如果你喜欢这些语言,那当然很好,你可以自由选择自己喜欢的编程语言。但是,我自己不会使用它们。

"任意"是数学格的最底元素。

提到"arbitrary"作为数学格的最底层元素时,引用了"Azul Systems"博客中的内容,指出有相关的理论可以参考。根据这些资料,应该能为提问者提供详细的解答。如果有提问者想深入了解,可以查阅2012年12月的相关博客内容。

你做什么来帮助预防腕管综合症?它会变得很严重吗?有什么特别的事情是你尽量避免的吗?

为了预防腕管综合症,首先需要了解其症状,并确保自己有正确的诊断。腕管综合症通常涉及神经受压,导致手部麻木和刺痛感,但并不是所有手部问题都是腕管综合症。有人可能误以为自己得了腕管综合症,但实际上可能是其他问题,比如腱鞘炎或者神经压迫。

对于避免腕管综合症和其他手腕问题,建议保持正确的手腕和手臂姿势,避免长时间保持不自然的姿势。佩戴腕托可以帮助减轻压力和预防疼痛。此外,保持手腕的热度,调整操作设备的姿势,以及进行适当的休息和活动变换,都有助于预防手部问题。

对于已经出现的疼痛或不适,重要的是确认自己是否得了腕管综合症,或只是其他问题(例如腱鞘炎或神经受压),并采取针对性的治疗。进行一些实验,观察不同方式的动作对症状的影响,也是确定治疗方法的一部分。

总的来说,预防和治疗手部问题的关键是确保自己知道具体的问题所在,避免盲目治疗,并在出现症状时及时寻求专业帮助。

你知道有什么OpenGL资源能解释他们为什么这样做吗?

关于 OpenGL 的资源,虽然有一些教程可以参考,但大部分都没有详细解释为什么要做某些操作。比如,Arc Synthesis 的教程就尝试解释每个步骤背后的原因,因此这些教程相对来说比较好。然而,整体上,OpenGL 的文档确实比较薄弱,很多内容没有很好的解释和说明,导致学习和使用 OpenGL 时遇到的困难较大。

根据你所说,能否得出你没有写过游戏设计文档的结论?

关于游戏设计文档,虽然我有一些游戏设计相关的内容,但我并没有写过正式的游戏设计文档。其实,我并不是一个专业的游戏设计师,所以我对编写这种文档不太了解。你提到的"游戏设计文档"是指那种正式的、通常是几百页的详细文档吗?那种文档通常很长,而且内容繁复冗长,我个人不太喜欢这种形式。

所以面向对象编程就像是当你提前设计好一个游戏时...

在设计游戏时,我的理解是,很多优秀的游戏设计师认为不应该一开始就做完所有的游戏设计,因为那样的做法并不适合真正的游戏开发。而我自己并不是一个专业的游戏设计师,所以我没有做过那种非常详细的游戏设计工作。我只是尽力提出一些基本的设计思路,并希望这些能成为游戏开发的基础,但我无法保证这些设计会让游戏变得非常有趣或者创新。

我的任务主要是进行编程,开发一个游戏项目。游戏的设计并不是我擅长的领域,而我更多的是集中在如何编程和实现这些设计。因此,我希望能够通过编程展示如何将设计元素落地,做出一个技术上复杂的游戏项目,但这些设计工作并不是由我主导的。

当进入到真正的游戏开发阶段时,我会转向更多的游戏实现,而不再集中在引擎层面的工作上。到时候会着重展示如何实现各种游戏设计元素,并通过编程展示这些设计如何在技术上得以实现。但我并不是在教大家如何做游戏设计。如果你想了解游戏设计,应该向专业的游戏设计师学习,而不是依赖我。

如果你没有设计经验,我建议寻找专业的教程或者向经验丰富的设计师请教,因为这方面的知识并不是我能够提供的。我只能展示如何将设计付诸实践,帮助有设计技能的人实现他们的想法。

这个项目是计划做一个"真正"的可玩游戏,还是更像是"我如何创建游戏"?

这个项目的目标是制作一个完全可以玩的游戏,且它的编程质量将非常专业。游戏的程序设计将确保非常流畅的体验,运行速度极快,不会有加载屏幕,所有内容都会流畅地进行,响应迅速。代码和技术部分将做到非常高的标准,确保游戏的表现和稳定性都非常优秀。

此外,项目中还将有非常出色的艺术设计,因此游戏的视觉效果也会非常好。唯一的不同点是,这个项目并不专注于游戏设计,而是通过设计来推动我们完成一些编程工作。因此,从游戏设计的角度来看,它将是相对传统的设计,并不会有太多创新或突破。总之,这个项目专注于技术实现,而不是创造前所未有的游戏设计。

既然我们在谈论面向对象/编程语言,我必须问一下,你觉得Perl怎么样?

关于Perl,实际上我并没有使用过太多。记得写过一两个Perl脚本,但总体来说,我觉得它并没有什么特别值得一提的地方。不过,由于我没有花太多时间使用Perl,因此也没有足够的经验去形成一个有根据的看法。

你觉得Minecraft的成功是因为创意而非执行,还是因为第一次做出来了?

关于《Minecraft》,我并不认为这是第一次因为创意而成功,而不是执行。过去可能也有过一些游戏是因为创意而成功,而不是因为执行。确实,《Minecraft》有很多地方的执行可以做得更好,但如果你有一个足够独特的想法,并且恰好碰到人们非常想玩的那种"甜点",那么在执行上就能容忍一些瑕疵。

从我个人角度来看,我没有玩过《Minecraft》,对它也没有太多了解,所以不能对它为何成功发表什么意见。我只知道,显而易见,如果看看《Minecraft》的截图,你肯定可以想象一个更精致的版本,但是否这点有多重要,我不确定。也许如果把它做得更精致,它的成功可能会不同,但也可能会有其他结果。总的来说,或许它的执行上没有那么完美,但仍然取得了成功。

动态生成的地图?

有人问到动态生成地图的问题。是的,地图将是动态生成的,实际上它已经在生成地图了,只是目前我们没有代码来生成有趣的动态地图。现在生成的地图其实是标准的,所以它是一个非常无聊的地图。

相关推荐
MegaDataFlowers18 小时前
英语六级我还在背单词:Unit 1(Lesson 1)
学习
maaath19 小时前
【maaath】Flutter for OpenHarmony 学习答题应用实战开发
学习·flutter·华为·harmonyos
xian_wwq19 小时前
【学习笔记】多租户的 Agent 隔离设计
笔记·学习
nnsix19 小时前
Unity 刚体的 默认力、瞬时力 区别
unity·游戏引擎
nnsix19 小时前
Unity Sprite的 Generate Physics Shape 参数解释
unity·游戏引擎
深蓝海拓19 小时前
PySide6,图形按钮使用系统内置图标
笔记·python·学习·pyqt
魔士于安19 小时前
Unity完整小球迷宫项目
前端·unity·游戏引擎·贴图·模型
め.19 小时前
Unity协程的原理
unity·游戏引擎
念恒1230619 小时前
Python(列表入门)
python·学习
十安_数学好题速析20 小时前
二进魔法:16人分组难题的4个月破解
笔记·学习·高考