游戏引擎学习第20天
视频参考:https://www.bilibili.com/video/BV1VkBCYmExt
解释 off-by-one 错误
从演讲者的视角:对代码问题的剖析与修复过程
-
问题的起因
- 演讲者提到,他可能无意中在代码中造成了一个错误,这与“调试时间标记索引”有关。
- 他发现了一个逻辑问题,即在检查数组边界时,使用了“调试时间标记索引是否大于数组计数”的条件。
- 这个问题的核心在于,数组是零基索引的,所以边界检查需要更加严谨。
-
数组索引的解释
- 在C语言中,数组的索引从零开始。
- 例如,如果一个数组包含N个元素,第一个元素的索引是0,而最后一个元素的索引是N-1。
- 如果尝试访问索引等于数组计数的元素,会指向数组分配空间之外的位置,这是非法的,也是潜在的错误来源。
- 在C语言中,数组的索引从零开始。
-
代码中的实际逻辑
- 演讲者解释说,代码逻辑中涉及的操作是获取数组的起始地址(指针),然后根据索引值计算访问地址。
- 如果索引值超出合法范围,比如大于或等于数组大小,就会越界访问。
- 他提到,“当索引等于数组计数时,实际指向的是数组分配空间的最后一个字节之后的一个字节”。
-
修复方案与改进
- 演讲者意识到,不需要用“索引大于数组计数”的条件来检查边界。
- 他认为更合理的方式是通过“索引等于数组计数”来判断是否到达数组的末尾,这样可以避免多余的检查逻辑。
- 为了确保代码健壮性,他建议添加断言(
assert
),用于验证程序永远不会尝试访问数组范围之外的地址。
-
代码错误的反思
- 演讲者坦承这是一个“愚蠢的错误”,可能是由于编写代码时的疏忽或思维不够严谨所致。
- 他提到,这种错误虽然看似简单,但在特定情况下可能导致程序的不稳定甚至崩溃,因此需要格外注意。
-
修复后的状态
- 通过修复,问题已经解决,程序现在可以正确地处理数组边界情况。
- 演讲者计划进一步检查代码,确保没有其他类似问题。
-
调试过程的价值
- 演讲者强调,尽管这是一次调试和修复的过程,但它对程序员,特别是初学者和中级程序员来说,是一个很好的学习机会。
- 通过这个过程,可以更好地理解数组操作和边界检查的重要性,以及如何编写更安全的代码。
音频代码状态概览
上面内容涉及到游戏开发中音频和视频同步的问题,以及作者在编程时遇到的一些挑战和解决方案的可能性。以下是对上述内容的详细复述和解析:
1. 问题背景
作者尝试以每秒30帧(30 FPS)的速率运行游戏,这意味着:
- 每帧需要处理的时间是 1000毫秒 / 30 ≈ 33.3 毫秒。
- 目标是在这33.3毫秒的时间窗口内,完成当前帧的计算和渲染,同时生成对应的音频,并保证音频和视频同步。
然而,他们发现实际实现过程中存在音频延迟问题,这使得音频与视频无法完美同步。
2. 音频延迟的根本问题
音频延迟源于硬件(如声卡)和软件(如DirectSound API)的限制:
- 在测量播放光标(Play Cursor)和写入光标(Write Cursor)时,作者发现声卡本身的音频延迟约为 30毫秒。
- 这正好与一帧的时间相当(33.3 毫秒),意味着即使当前帧的音频被正确计算,它也会延迟一个帧时间才被播放。
因此,当前帧的音频无法立即与该帧同步显示,而是会出现在稍后的帧中。
3. 理想状态 vs. 现实情况
理想状态:
- 在每帧时间窗口内,游戏计算并渲染当前帧,同时生成音频。
- 计算和生成的音频可以在显示该帧时立即播放,与画面同步。
现实情况:
- 由于音频延迟,即使在当前帧生成的音频数据,也要等到下一帧甚至更晚才会被播放。
- 这导致音频和画面的不同步。
4. 延迟导致的音频与视频不同步问题
作者通过微软的DirectSound API测试了音频延迟,并总结了以下结论:
- 音频延迟约为 30毫秒,这与每帧时间基本相等。
- 音频的播放不会立即发生,而是有一个延迟,这种延迟与音频被写入声卡缓冲区的位置有关。
例如:
- 当前帧计算音频的时间点是第 T T T 毫秒。
- 由于延迟,该音频最早可能在 T + 30 T + 30 T+30毫秒时播放,而此时画面已经进入下一帧或之后的帧。
5. 潜在解决方案
作者提出了几种可能的解决方案,但都存在局限性:
方案1:接受延迟
- 将音频和视频的不同步视为不可避免。
- 在代码中实现一个基础的音频循环,允许延迟存在。
- 这种方法简单,但会导致游戏体验下降。
方案2:优化音频延迟
- 尝试使用其他音频API(如XAudio2)或更新硬件。
- 某些声卡可能支持更低的延迟,从而减少不同步的程度。
方案3:重新设计音频处理
- 修改音频处理的逻辑,使其与游戏帧的渲染更独立:
- 在计算帧的同时,预测未来的音频需求。
- 提前计算并写入后续几帧的音频数据。
方案4:依赖更高性能的机器
- 在延迟更低的机器上测试,确保在特定硬件上能够实现同步效果。
- 这种方法不可广泛应用于所有设备。
6. 深入探讨同步问题
作者还详细讨论了帧内的音频计算流程:
-
帧内事件:
- 当前帧显示时,下一帧的图形和音频开始计算。
- 图形部分可以实时显示,但音频部分会因为延迟问题滞后。
-
音频的延迟播放:
- 当前帧计算的音频数据,即使被立即写入缓冲区,也要延迟约30毫秒才能播放。
- 这导致音频总是落后于画面。
-
解决难点:
- 要同时解决音频延迟和实时生成的需求几乎不可能。
- 除非使用特定的硬件或优化方法,否则同步问题难以彻底解决。
总结
作者在讨论中明确了音频与视频同步的技术难点,并提出了多个方向的解决方案,同时也表达了这些方案的局限性。他们可能会在之后尝试优化音频处理逻辑,或者接受延迟作为当前技术条件下的妥协。这是一个典型的游戏开发中的技术挑战,需要在性能、延迟和开发复杂性之间找到平衡。
分析每一帧的时间分配
上面的内容详细讲述了游戏开发中的帧处理逻辑,包括每帧时间的分配和实现方式。以下是内容的详细复述和解析:
1. 帧时间的分解
在每秒30帧的情况下,每帧时间为 33毫秒。作者试图进一步分解这33毫秒,解释帧内具体执行的任务,以及它们在时间上的分布。这些任务包括:
- 输入采集(Gather Input)
- 游戏逻辑更新(Update Game)
- 渲染准备(Render Prep)
- 渲染图像(Rendering)
- 等待与显示(Wait & Display)
2. 输入采集阶段
- 时间占用:这一阶段通常耗时较短。
- 任务内容:
- 从键盘、鼠标或游戏手柄收集输入信息。
- 在当前项目中主要使用键盘(可能是为了模拟游戏手柄的输入)。
- 扩展用途:虽然当前游戏中并未使用鼠标,但作者提到可能会在其他场景中需要。
这一阶段的目的是为游戏提供输入数据,用于下一阶段的逻辑更新。
3. 游戏逻辑更新与渲染准备
- 时间占用:比输入采集耗时稍长,但整体依然较短。
- 任务内容:
- 根据输入数据更新游戏逻辑,例如物理计算、状态变化等。
- 准备渲染所需的数据(渲染列表),这可能包括将场景对象、纹理等整理好,以供渲染器使用。
在二维游戏中,这部分通常不会太复杂。作者提到如果游戏涉及流体效果或其他复杂物理模拟,这部分可能会变得更耗时。
4. 渲染阶段
- 时间占用:这是最耗时的阶段,占据帧时间的大部分。
- 任务内容:
- 将渲染列表中的数据绘制到屏幕上。
- 在软件渲染器的情况下,这一过程尤其耗时,因为所有渲染都由CPU完成。
- 如果切换到GPU渲染,特别是高性能GPU,渲染时间可能会大幅减少。
二维游戏与三维游戏的差异:
- 对于三维游戏,复杂的物理计算和碰撞检测可能占用更多时间。
- 对于当前的二维游戏,渲染可能是性能的主要瓶颈。
作者还提到,如果启用了更高分辨率(如1920×1080)或多重采样(multi-sample),即使是GPU渲染,性能开销也会增加。
5. 等待与显示阶段
- 时间占用:只占用剩余的时间。
- 任务内容:
- 在所有渲染工作完成后,等待帧时间结束。
- 将当前帧的内容显示在屏幕上(称为“翻转”或Flip)。
- 管道化的可能性:
- 现代硬件允许将多个帧的处理管道化。例如,在一帧渲染的同时,下一帧的输入采集和逻辑更新可以开始。
- 如果是多线程环境,任务可以进一步重叠,例如在主线程渲染时,子线程采集输入或更新逻辑。
6. 同步执行的情况
- 如果整个过程在单核CPU上以同步方式运行(无管道化、无多线程),帧处理流程会依次执行:输入采集 → 逻辑更新 → 渲染 → 等待 → 显示。
- 这种方式较为原始,但在多核CPU和GPU出现之前,这种模式是主流。
7. 关键挑战与解决思路
- 瓶颈识别:
- 渲染阶段在当前帧中占用时间最多,因此可能成为性能优化的重点。
- 未来优化:
- 切换到GPU渲染以减少渲染时间。
- 实现管道化或多线程,以提高帧处理的效率。
- 通过代码优化,降低逻辑更新和物理模拟的复杂度。
8. 总结
帧时间的分解帮助作者明确了性能瓶颈和优化方向。通过合理分配帧内任务、利用硬件特性(如GPU渲染和多线程),可以有效减少延迟,提升游戏流畅度。尽管同步执行是较为传统的方式,但现代技术提供了更多提升效率的可能性,例如管道化处理和多线程任务分配。
一种可能的但不理想的解决音频延迟的方法
上面的内容讨论了如何在游戏的帧处理中插入声音输出的问题,特别是如何尽量减少音频延迟,同时协调声音和画面更新的时序。以下是内容的详细复述和解析:
1. 插入声音的挑战
- 在帧处理流程中,声音的插入需要保证与画面同步,同时尽量降低音频延迟。
- 如果存在 33毫秒的音频延迟,那么声音的输出实际上需要提前一个帧时间(即在当前帧的输入采集之前)进行。
然而,这种方式存在实际困难:
- 在当前帧的输入采集之前就输出声音意味着我们还不知道这帧的具体输入内容。
- 因此,提前输出声音可能是“不可能”实现的,因为它需要预知尚未发生的事件。
2. 帧处理的时间分布
为了更好地理解声音插入的可能性,作者提出通过分解帧时间来看声音输出的时间点。
单帧时间分布
假设一帧的总时间是33毫秒,其中:
- 输入采集和逻辑更新:占用 10毫秒。
- 渲染:占用 23毫秒。
这表明:
- 输入采集和逻辑更新在帧的前10毫秒完成。
- 剩下的时间主要用于渲染和等待。
跨帧的时间线
如果我们把时间线扩展到跨两帧的场景:
- 当前帧的渲染:占用了后半部分时间。
- 下一帧的输入采集和更新:可以与当前帧的渲染 重叠 进行。
3. 如何减少声音延迟
通过分析,作者提出了一种 音频输出延迟最小化方案:
- 在上一帧的渲染过程中,就可以开始为下一帧生成声音数据。
- 这种方式使得声音输出只会比画面延迟 一帧,而不会产生更大的延迟。
时间轴示意
- 在帧时间的后期(渲染阶段),我们已经有足够的信息来确定下一帧的声音内容。
- 如果在当前帧渲染时输出下一帧的声音,则:
- 声音数据可以与画面同步,只落后于画面 一帧时间(33毫秒)。
- 这是可以接受的延迟范围。
4. 利用多核处理优化
为了进一步优化,作者提到可以利用多核处理能力,将帧处理的任务分配到不同的核上,以实现更高效的并行处理。
多核优化示例
- 当前帧的渲染任务在一个核心上进行。
- 下一帧的输入采集和逻辑更新任务同时在另一个核心上启动。
- 这样,两者可以 重叠,进一步减少帧时间的浪费,并提升整体性能。
5. 总结
- 声音输出的插入是一个复杂的问题,必须考虑音频延迟和画面同步的要求。
- 理想情况下,声音数据的生成应尽可能早地开始,利用当前帧的渲染时间来完成下一帧的声音准备。
- 如果设计合理,声音输出的延迟可以控制在 一帧时间(33毫秒)以内,这是一个可接受的范围。
- 多核处理和任务并行化为进一步优化提供了可能性,通过重叠任务可以最大限度地利用帧时间。
这种分析为游戏开发中的声音系统设计提供了有价值的参考,同时强调了多线程和管道化的重要性。
音频延迟与输入延迟之间的权衡
以下是对上述内容的详细复述:
使用两核心处理音频与输入同步的权衡
-
优化音频同步的潜在收益:
- 通过合理利用两核心,渲染和输入处理可以并行运行:
- 一个核心负责渲染当前帧;
- 另一个核心提前处理下一帧的输入采集和更新。
- 结果:我们可以在音频输出上提前约 40 毫秒完成,确保音频和画面同步输出。
- 这种优化能够在减少音频延迟的同时,提升听觉体验,特别是在需要精确声音反馈的场景中。
- 通过合理利用两核心,渲染和输入处理可以并行运行:
-
成本:引入输入滞后:
- 为了实现上述优化,输入采集的时间会被推迟,因为渲染任务占用了较多的处理资源。
- 这将导致额外的输入滞后,大约 23 毫秒,这是渲染过程所需的最大时间。
- 换言之,玩家的输入会在更晚的时间点反映到游戏中,影响即时性和流畅性。
针对本游戏的适用性分析
-
音频同步的重要性:
- 本游戏的核心玩法并不依赖精准的音频反馈。
- 游戏音效的作用主要是为玩家提供辅助信息,例如:
- 提示敌人攻击的时机;
- 技能冷却的完成;
- 场景氛围的营造。
- 虽然音频在游戏体验中很重要,但其同步性对于本游戏来说并不是最高优先级,因为没有操作需要与声音反馈直接对齐。
-
输入滞后的影响:
- 与音频同步相比,输入同步对本游戏更为关键。
- 游戏体验的核心在于玩家的即时操作响应,因此输入滞后会显著降低手感:
- 玩家动作的延迟响应会影响操作流畅性;
- 游戏的实时性和操控感将因此下降,尤其是在需要快速反应的场景中。
- 引入额外的 23 毫秒输入滞后,将使得玩家的操作与游戏反馈之间出现明显脱节,得不偿失。
结论:优先级选择
-
优先优化输入同步:
- 尽可能提前进行输入采集,将输入滞后降到最低。
- 输入同步对玩家的操作体验至关重要,必须优先保证。
-
音频延迟可以接受:
- 在音频与输入的平衡中,音频的延迟可作为妥协点。
- 音频滞后对整体体验的影响相对较小,只要延迟在合理范围内即可。
最终,我们的策略应该是:尽量优化输入与音频的同步,但绝不以输入同步为代价来追求音频的完全同步。
回归稍微笨拙的实现
以下是对上述内容的详细复述:
背景:音频与输入同步的问题
在当前开发阶段,团队面临的挑战是如何在确保音频与画面同步的同时,尽量减少输入延迟。这需要在系统性能、玩家体验以及代码复杂度之间进行权衡。
核心问题及解决方案的变化
-
现状评估:选择有限,优化空间有限
- 开发者首先认识到,在硬件性能或其他设计限制下,没有理想的解决方案。
- 音频同步的目标:
- 希望音频与当前帧完全同步,但实现这一点的成本是巨大的。
- 实际权衡:
- 由于同步音频会引入更多输入延迟,开发者不得不调整策略。
-
调整后的方案:
- 开发者决定采取更简单但“粗糙”的方法,不再强求音频完全对齐当前帧边界,而是接受音频滞后可能落在下一帧的中间位置。
- 音频滞后的接受:
- 预计音频会滞后 15 毫秒左右;
- 游戏代码将被调整以掩盖这种滞后,对游戏逻辑“谎报”音频输出为当前帧的音频,以避免增加代码复杂度。
- 目标优先级:
- 优先减少输入延迟,而非追求精确的音频同步。
- 这一选择被认为比延迟整整一帧(可能多达 16 毫秒)或引入显著输入滞后更为合理。
详细实现策略
-
调整音频代码:
- 非同步音频处理:
- 开发者将更新音频代码,使其适应当前硬件条件下的滞后情况;
- 如果检测到音频滞后超过可接受的阈值(例如 16 毫秒),系统将尽可能快速地处理音频输出,避免进一步延迟。
- 同步优化:
- 当音频滞后低于阈值时,开发者会尝试通过调整缓冲区,将音频尽量与帧同步。
- 非同步音频处理:
-
两种音频处理路径:
- 系统会根据音频滞后的不同情况选择不同的处理方法:
- 高滞后路径:音频直接输出,但滞后落在下一帧中;
- 低滞后路径:音频输出与当前帧同步。
- 灵活调整策略:
- 系统会测量当前的平均更新时间,并动态调整音频的输出节奏,以实现尽可能平滑的体验。
- 系统会根据音频滞后的不同情况选择不同的处理方法:
-
代码复杂性的权衡:
- 开发者承认这种音频代码的编写过程较为复杂且易出错,需要格外谨慎:
- 在过程中已经发现并修正了多处错误,预计后续还会遇到新的问题。
- 尽管如此,开发者认为这是一条可行的优化方向,并将继续努力完善。
- 开发者承认这种音频代码的编写过程较为复杂且易出错,需要格外谨慎:
开始修改音频输出方法的代码
找到最小的期望音频延迟
这段内容讲解了一个与音频延迟处理相关的技术实现,具体是通过计算两个光标之间的位置差来估算音频的延迟。这些光标包括播放光标(play cursor)和写光标(write cursor)。以下是详细的复述:
核心概念和问题
-
播放光标和写光标:
- 播放光标表示音频正在播放的位置。
- 写光标表示当前写入数据的安全位置。
- 两者之间的差值反映了最低的音频延迟,这是我们期望在理想情况下可以达到的延迟。
-
延迟的计算挑战:
- 环形缓冲区问题:
- 使用环形缓冲区时,写光标可能会绕过缓冲区的末端并从头开始写入,而播放光标可能仍在缓冲区的较前位置。
- 这种情况下,简单地计算写光标和播放光标的差值会得到一个错误的负值,需要对这一问题进行处理。
- 环形缓冲区问题:
解决方案的实现
-
未环绕(unwrapped)的写光标:
- 定义一个未环绕的写光标:
- 如果写光标的位置小于播放光标,说明它已经环绕到了缓冲区的开头。
- 在这种情况下,未环绕的写光标值等于写光标加上缓冲区的总大小。
- 如果写光标未绕回开头,则未环绕的写光标等于写光标本身。
- 定义一个未环绕的写光标:
-
计算光标差值:
- 使用未环绕的写光标与播放光标计算差值,这样可以正确反映环形缓冲区中的相对位置。
- 差值公式:
delta = unwrapped_right_cursor - play_cursor
delta
表示光标之间的字节数,也就是音频延迟。
-
优化代码:
- 使用更简洁的代码实现:
- 通过判断写光标是否小于播放光标来决定是否需要添加缓冲区大小。
- 这样可以避免复杂的条件判断,使代码更易读。
- 使用更简洁的代码实现:
验证和调试
-
验证环形缓冲区的场景:
- 作者特别验证了写光标位于播放光标之前的场景,以确保在这种情况下,计算的延迟值仍然正确。
- 测试结果表明,使用未环绕的写光标计算得出的延迟值是稳定且正确的。
-
实时延迟展示:
- 输出的延迟值(以字节为单位)清晰地反映了音频缓冲的当前状态。
- 该值可以转化为秒或者其他单位以供进一步分析。
方法总结
- 这种方法计算出的延迟值能够反映实际的音频延迟状态,同时对环形缓冲区的特殊情况进行了处理。
- 尽管有多种方法可以实现类似的功能,作者选择了最易于理解和调试的方式。
- 最终,代码实现被验证为高效且可靠,能够用于实时音频处理系统。
这段讲解清晰地描述了如何通过光标的位置差计算音频延迟,并解决了环形缓冲区带来的潜在问题,同时还展示了如何写出简洁、清晰、可维护的代码。
使用量纲分析转换为秒
下面的内容解释了如何通过计算音频延迟样本来推导音频延迟时间,涉及了基本的量纲分析和计算方法。以下是逐步的详细复述:
-
从字节到样本的转换
- 计算光标之间的字节数:
首先计算两个光标之间的字节差值。这表示在音频缓冲区中未播放的音频数据量,以字节为单位。 - 将字节转换为样本:
通过将光标之间的字节差除以每个音频样本所占的字节数(bytes per sample
),可以得到未播放的音频样本数量。这是因为每个样本由固定数量的字节表示,除以字节数后结果是样本数量。
- 计算光标之间的字节数:
-
从样本到时间的转换
- 计算样本对应的时间:
将样本数量再除以音频的每秒采样率(samples per second
),可以得到音频延迟的时间。
这一步利用了量纲分析:- 样本数 ÷ 每秒样本数 = 时间(秒)。
这是一种简单的方法来确保单位的转换是正确的。
- 样本数 ÷ 每秒样本数 = 时间(秒)。
- 计算样本对应的时间:
-
维度简化与优化
- 合并公式简化计算:
为了减少计算步骤,可以将“每秒样本数”和“每样本字节数”合并,直接计算“每秒字节数”(bytes per second
)。这样,光标之间的字节数直接除以每秒字节数即可得到延迟时间。
这可以通过添加一个“每秒字节数”的字段或变量来优化代码逻辑,避免多次计算和转换。
- 合并公式简化计算:
-
量纲分析的重要性
- 量纲分析(Dimensional Analysis)是确保单位正确的核心工具。在计算中:
- 字节除以“每样本字节数”会得到样本;
- 样本除以“每秒样本数”会得到秒数。
- 若希望在代码中直接跳到秒数的结果,可以通过简化公式,减少显式转换操作。
- 量纲分析(Dimensional Analysis)是确保单位正确的核心工具。在计算中:
-
数据类型转换的注意事项
- 整数除法与浮点数:
如果变量均为整数,直接执行除法会导致舍入误差。因此,在计算中建议将变量显式转换为浮点数,以获得更精确的结果。
例如:float latencyInSeconds = (float)BytesBetweenCursors / (float)BytesPerSecond;
- 整数除法与浮点数:
-
计算结果的验证
-
打印结果进行验证:
输出计算结果以秒为单位的延迟时间。通过观察,计算出的延迟约为 33 毫秒,这符合音频帧时间的预期范围(略低于 30 毫秒)。 -
重要性:
程序中可能出现许多意料之外的问题,因此验证结果是确保计算正确的关键步骤。
-
-
进一步优化与清理
- 代码优化方向:
在代码清理阶段,可以添加字段如“每秒字节数”,减少冗余计算,使代码更简洁、更易维护。 - 计算和浮点数转换:
整体逻辑应该在计算前显式地进行类型转换,以确保计算精度,特别是处理音频时间的计算时。
- 代码优化方向:
总结
这段逻辑通过计算光标间的字节差值,结合音频格式(每样本字节数与采样率)来推导音频延迟时间。借助量纲分析,可以清晰地验证公式正确性并确保单位转换无误。最终结果(33 毫秒)表明延迟在合理范围内,这种验证是音频开发的重要组成部分。
根据音频延迟写入声音
音频延迟的计算与保存:
-
音频延迟秒值的作用
- “音频延迟秒”(audio latency seconds)是一个关键的计算值,反映了音频处理中的时间延迟。
- 它被认为是音频系统中能够达到的最佳性能。计算音频延迟秒的目的是在系统中合理化这些延迟并进行后续优化。
-
保存音频延迟值
- 这些音频延迟值被暂时保存下来,作为后续处理的一部分,尤其是在验证声音是否有效时(“sound is valid”)。
- 保存的值可能包括音频延迟秒和字节形式的值(audio latency bytes)。这些值为后续操作提供了一个可靠的基准。
-
计算的背景与使用
- 延迟值(秒和字节)会在音频的输出阶段使用。具体来说,当我们计算“写入位置”时(where to write to),延迟值会成为一个重要参考。
样本索引的处理与光标的关联:
-
运行样本索引
- “运行样本索引”(running sample index)是一个核心变量,初始值被设置为当前写光标的位置(write cursor position)。这确保了索引与实际音频数据的位置同步。
- 运行样本索引的单位可以是字节,也可以是样本。设计时需要考虑是否直接以字节为单位进行计算,以减少单位转换中的错误。
-
光标的作用
- 光标的位置用于确定音频缓冲区中写入和读取的区域:
- “写光标”(write cursor):指示当前写入的起点。
- “播放光标”(play cursor):记录上次播放的位置。
- 计算时,运行样本索引通常与写光标同步,这样在下一次操作时可以轻松找到目标位置。
- 光标的位置用于确定音频缓冲区中写入和读取的区域:
计算写入位置的逻辑:
-
目标光标的锁定
- 系统需要根据上一次的写光标和播放光标计算下一次写入的目标光标。通过这种方式,可以确保写入操作不会覆盖未被播放的数据,从而避免音频问题。
- 新设计中提到可能需要保留“最后一个写光标”和“最后一个播放光标”以提高对写入位置的控制精度。
-
减少误差的优化
- 在设计中,建议尽量使用字节单位直接计算目标位置。这可能会避免因单位转换而导致的错误,同时简化数学操作。
确定音频写入的位置
这段内容描述了一种计算音频写入时机的逻辑方法,尤其是在低延迟的音频处理场景中。以下是更详细的复述:
背景与问题
在低延迟音频处理中,程序需要在特定时间写入音频数据,以确保音频播放的流畅性并避免延迟导致的中断。
目标是确定:
- 何时应该写入音频数据。
- 写入多少音频数据以覆盖预期的延迟时间。
为了实现这一点,系统会查询音频硬件的播放和写入光标的位置,并利用这些位置计算写入时机和数据量。
关键概念
-
播放光标 (Play Cursor)
- 表示音频硬件当前正在播放的位置。
- 该位置会随着音频播放不断向前推进。
-
写光标 (Write Cursor)
- 表示音频缓冲区中可以安全写入而不会覆盖播放数据的位置。
- 通常位于播放光标之后。
-
音频延迟 (Audio Latency)
- 表示从音频数据写入缓冲区到实际播放之间的时间差。
- 延迟通常包括硬件处理时间和软件计算时间。
-
帧时间 (Frame Time)
- 表示一帧渲染的时间(如 33 毫秒对应 30 FPS 的场景)。
- 在这种场景下,音频写入的目标是覆盖至少一帧的音频。
处理逻辑
-
初始翻转 (Flip)
- 第一次翻转(显示图像帧)时,音频缓冲区为空,程序需要写入初始数据。
- 第一次写入时,播放光标和写光标的位置未知,但程序可以假设播放光标接近初始位置,写光标稍微超前。
-
查询当前光标位置
- 在翻转帧之后,程序会查询播放光标和写光标的位置,以了解缓冲区状态。
- 查询结果可能显示写光标已经超前于播放光标一定距离。这个距离决定了可用的缓冲空间。
-
计算需要写入的音频量
- 程序根据帧时间和音频延迟计算出需要写入的数据量。目标是确保在下一帧到来之前,音频缓冲区的内容不会被播放光标消耗殆尽。
-
判断是否需要额外推迟写入
- 如果发现写光标距离播放光标太近(意味着缓冲区几乎满了),可能需要“推迟写入”以避免覆盖未播放的数据。
- 如果缓冲区有足够空间,程序会直接写入一帧时间所需的音频数据。
具体计算方法
步骤 1:计算音频写入的起始位置
- 确定当前的写光标位置:
- 查询写光标与播放光标之间的距离,确保在安全范围内写入数据。
- 如果写光标的位置足够靠前,直接开始写入。否则,可能需要延迟写入。
步骤 2:计算音频写入的量
- 基本写入量:至少覆盖一帧(33 毫秒)的音频数据。
- 额外考虑音频延迟:
- 如果当前音频延迟较大(大于一帧时间),则需要额外增加写入量以弥补延迟。
- 写入量 = 帧时间(33 毫秒)+ 音频延迟 - 已使用的缓冲区空间。
步骤 3:动态调整
- 如果硬件和软件之间的时钟不同步,可能需要动态调整写入时机。
- 例如,如果查询显示写光标超前太多,则可以减少写入量,避免产生更多延迟。
特殊情况
-
第一次写入
- 第一次翻转时,播放光标和写光标可能都在缓冲区的起始位置。程序需要写入足够的数据来初始化播放。
- 在这种情况下,直接写入一帧的音频数据通常是安全的。
-
高效硬件的低延迟问题
- 如果硬件处理速度非常快,可能会导致写光标超前很多。此时,程序需要“拉回”音频写入的时机,减少初始延迟。
-
动态调整机制
- 如果缓冲区使用情况不均衡,可能需要动态调整写入策略。例如,增加音频延迟的占比,确保播放光标有足够数据可供播放。
总结
核心目标是确保音频播放的连续性和低延迟:
- 确保每次写入的音频量足以覆盖一帧时间,同时兼顾硬件延迟。
- 根据当前播放和写入光标的位置,动态调整写入时机和数据量。
这种方法虽然复杂,但它有效地平衡了实时性和稳定性,适用于高性能音频应用。
如何处理低延迟场景
我们需要实现一个系统,以确保在音频处理中,每个动作都能够以最优化的方式完成。为了确保实现这个目标,我们需要将动作拆分为两帧来执行。
首要目标
我们首先明确目标:
- 确保音频的延迟尽可能低。
- 确定“写光标”位置并调整音频的写入逻辑。
初步规划
案例分析
首先,我们从一些简单的案例入手,计算实际情况以帮助解释整个过程。这么做不仅有助于更清晰地理解,同时还能为后续的实施提供指导。这里的关键是:音频低延迟处理。
低延迟音频处理
- 音频延迟需要非常低,以便声音信号能够迅速传递到声卡。
- 我们需要仔细跟踪写光标的移动情况,确保写入的音频与实际播放保持一致。
操作步骤
-
初始帧:
- 确定第一帧时的写光标位置。
- 写入一帧音频,使其与光标对齐。
-
后续帧:
- 根据写光标的移动调整写入内容。
- 每次写入平均帧数,确保音频延迟保持最低。
问题分析
音频时钟与系统时钟的差异
一个常见的问题是,音频时钟可能与系统时钟存在微小偏差。例如,音频时钟可能运行得稍慢,或者稍快,这可能导致缓冲区的填充不一致。如果始终按照固定时间写入数据(比如固定的33毫秒),可能会导致缓冲区的溢出或数据不足。
因此,我们需要一种方法:
- 动态调整写入时长:根据音频时钟的实际消耗情况,动态调整写入的时长。
- 跟踪光标移动:通过观察写光标的移动情况,计算实际需要写入的音频长度。
具体实现方法
平均光标移动量
我们将观察写光标在两次操作之间的平均移动量。这可以确保:
- 写入的音频与写光标的实际移动保持同步。
- 避免因系统时间和音频时钟的偏差导致的问题。
每次写入时,我们都会使用这样的逻辑:
- 假设33毫秒的音频数据是基准。
- 实际写入的音频长度以写光标的移动量为参考。
实现逻辑
-
初次写入:
- 观察写光标的位置,将其与播放光标对齐。
- 写入一帧音频(对应33毫秒)。
-
后续写入:
- 检查写光标的移动距离。
- 按照光标移动的平均值,动态调整写入的帧数。
优化方向
在现有方法的基础上,还可以进一步优化计算:
- 直接通过写光标和播放光标的差值,计算需要写入的帧数,而不是依赖平均值。
- 如果可以精确地从音频时钟中推算出偏差值,则可以避免长期追踪平均值的问题。
挑战和潜在改进
-
复杂计算:
当前方法需要不断追踪光标的移动,并计算平均值。这在实时音频处理中可能会带来额外开销。 -
简单替代方案:
如果能找到一种直接利用写光标和播放光标的方法,则可能简化计算过程。 -
计算框架边界:
确保样本索引总是落在帧的边界上,从而避免复杂的跨帧计算。
总结
以上方法旨在实现音频低延迟处理,核心思想是:
- 动态调整写入的音频时长,使其与音频时钟保持一致。
- 通过追踪写光标的移动,确保音频写入的精确性。
- 如果可能,进一步简化计算过程以提高效率。
这种设计能够在实际使用中大幅降低音频延迟,同时保证播放的稳定性和准确性。
一个令人困惑的情况
下面是上述内容的详细复述:
背景与问题概述
在音频处理过程中,我们需要解决如何精确地估算并写入音频样本的问题,同时应对音频时钟和墙钟(系统时钟)之间的可能差异。
主要思路与挑战
样本数的估算
我们知道每帧音频对应的估算样本数,也能推测需要向前推进的距离。但是,这种估算并不全面,因为我们并不真正了解所有可能的细节,也无法直接确定准确的写入方式。
墙钟与音频时钟的同步问题
墙钟(系统时钟)是我们计算时间的主要依据,但音频时钟并不总是与墙钟同步。它可能稍微偏快或偏慢,具体表现可能与音频采样器的行为相关。这种同步问题增加了估算的复杂性,因为我们不能仅依赖墙钟来决定需要写入多少音频样本。
方法探索
为了应对这些问题,我们需要找到一种既能处理墙钟与音频时钟差异,又能动态调整写入样本数的方法。以下是一些核心思路:
追踪平均值
目前最直接的方法是追踪写光标的平均移动量。这种方法通过多次采样统计写光标的移动情况,计算出平均值,并以此为基准决定写入多少样本。这种方法较为稳定,但计算量可能较大。
光标位置估算
-
写光标与播放光标的关系:
- 如果写光标在一个位置,而播放光标在另一个位置,我们可以利用二者的差值,估算出写光标的目标位置。
- 当时间流逝后,播放光标会前进,我们可以推测出播放器将处于什么位置。
-
确保写入的音频超前于光标:
- 在写入音频时,我们需要确保数据至少超前于写光标的位置,以避免播放过程中出现断裂或延迟。
- 具体而言,我们需要写入超过写光标至少一帧的位置,确保音频缓冲区中有足够的数据供播放使用。
墙钟与音频时钟的动态调整
尽管墙钟是一个参考,但因为音频时钟可能偏离墙钟,我们需要一个动态调整机制:
- 使用墙钟来估算写光标的目标位置。
- 根据音频时钟的实际表现,修正墙钟的估算结果。
这种调整方法结合了墙钟的稳定性和音频时钟的实时性,有助于提高估算的精确度。
实现过程
以下是一个可能的操作步骤:
-
初始化估算:
- 查询墙钟时间,确定当前写光标和播放光标的位置。
- 根据墙钟时间和写光标位置,推算写光标未来的位置。
-
动态写入:
- 确保写入的数据覆盖写光标的位置,并至少超前一帧。
- 根据墙钟与音频时钟的同步情况,动态调整写入的样本数。
-
迭代更新:
- 每次写入后,重新查询墙钟和光标位置,更新估算模型。
- 如果发现墙钟与音频时钟之间存在明显的偏差,调整估算方法以适应新的情况。
方法的局限与改进空间
-
复杂性问题:
当前方法依赖多次采样和平均值计算,这可能在某些实时场景下造成额外的性能开销。 -
同步精度问题:
墙钟与音频时钟的偏差可能因设备或环境的不同而表现各异,需要更多的实验数据来优化同步算法。 -
改进方向:
- 开发更高效的算法,直接利用写光标和播放光标的位置关系进行估算,减少对平均值的依赖。
- 如果硬件支持,直接访问音频时钟数据,以提高同步的准确性。
结论
以上方法提供了一种解决音频低延迟处理问题的思路,通过动态调整写入样本数和光标位置估算,可以有效地缓解墙钟与音频时钟不同步带来的影响。尽管仍有优化空间,但这一框架能够在大多数场景下提供较为稳定和可靠的性能。
恍然大悟的瞬间
问题背景与解决提议
在音频系统开发中,如何确保高效、低延迟且精准的音频输出是一个核心挑战。这里提出了一个新思路,用以解决光标位置估算和写入同步的问题。
提议的核心思路
-
动态估算写光标的位置:
- 通过墙钟(系统时钟)获取当前时间,估算写光标未来的位置。我们称这一过程为“预测”或“向前推算”。
- 基于预测结果,决定写入音频的目标位置。
-
对齐至帧边界:
- 为了简化处理和提高效率,将写入的样本索引向上对齐到下一帧的边界。
- 这样,系统总是填充到下一个完整帧的位置,从而保证数据的稳定性和连贯性。
-
条件判断:
- 只有当写光标落后于当前时间时才会进行上述操作。这种判断避免了不必要的重复写入,也确保数据流的实时性。
具体实现步骤
1. 初始估算
- 每次唤醒时:
- 查询写光标的当前位置。
- 使用墙钟预测写光标未来的位置。
2. 确定写入目标
- 将估算出的写光标目标位置向上舍入到下一个帧边界。
- 计算需要写入的样本索引,该索引通常略超出写光标的位置以确保完整帧覆盖。
3. 写入操作
- 根据计算结果填充音频数据,确保写光标始终有足够的数据进行播放。
4. 循环迭代
- 持续更新光标位置,重复上述操作以维持系统的流畅性和低延迟。
提议的优势
-
同步改进:
- 这种方法通过墙钟估算写光标位置,并根据帧边界对齐,显著提高了音频输出的同步性。
-
高效填充:
- 写入操作始终基于帧边界,既减少了不必要的计算,又确保了数据流的完整性。
-
动态响应:
- 系统能够根据光标位置的实时变化,动态调整写入逻辑,增强了灵活性。
个人感受与反思
开发者表达了对这一方法的认可,认为它解决了困扰已久的难题:
-
渐进突破的成就感:
- 解决这个问题的过程类似于玩一款复杂的拼图游戏。尽管一开始让人困惑,但逐步找出解决方案带来的成就感非常强烈。
-
系统性改进的重要性:
- 过去的音频系统可能忽略了声卡的潜力,导致音频输出过早或过晚,无法充分利用低延迟硬件。而通过专注于优化写入逻辑,这一方法不仅提升了当前项目的质量,还为未来的音频开发奠定了基础。
未来展望
-
理论总结:
- 该方法有潜力成为音频输出优化的标准解决方案。开发者希望未来能撰写一篇详尽的文章,分享这一经验,让更多人受益。
-
长远价值:
- 通过一次性解决这个复杂问题,未来的音频层将具备动态适应的能力,从而提升整个系统的健壮性和可靠性。
总结
通过结合墙钟估算、帧边界对齐和动态响应,这一提议为音频输出提供了一个创新的解决思路。尽管开发过程充满挑战,但其成果令人满意,不仅为当前项目提供了解决方案,也为行业带来了潜在的价值提升。这种渐进式突破的过程体现了编程的魅力和意义。
我们将采取的低延迟处理方法
音频输出的两种延迟情况
在处理音频输出时,系统可能面临两种延迟情况:
-
低延迟场景:
- 系统延迟很小,例如 5 到 10 毫秒,大部分延迟主要来源于音频处理本身。
- 这种场景适用于专业级音频设备、控制台或低延迟驱动程序(如 ASIO 驱动程序)。
-
高延迟场景:
- 延迟相对较大,需要单独处理以确保音频和视频的同步。
基本操作流程
1. 唤醒与光标获取
- 系统唤醒:音频系统定期唤醒以检查当前状态。
- 获取写光标位置:查询当前写光标(写光标)的位置。这是判断系统当前音频状态的关键数据。
2. 映射写光标位置
- 基于写光标的当前位置,预测其在未来的某个时间点的位置:
- 通过将当前写光标位置加上系统帧样本数进行计算。
- 例如,若每帧包含 N 个样本,则写光标的新位置为:
写光标预测位置 = 当前写光标位置 + 帧样本数。
3. 确定目标帧边界
- 对齐到下一帧边界:
- 映射出的写光标位置可能落在一个帧的中间,因此需要将其向上舍入到下一个完整帧的边界。
- 这种对齐确保写入操作总是以完整帧为单位进行。
4. 写入音频数据
- 根据计算出的帧边界,决定写入的样本范围。
- 填充数据直至目标帧边界,确保光标总是提前有足够的数据可供播放。
示例说明
假设场景
- 当前写光标位置:位于某帧的中部。
- 系统计划每次写入一帧的样本数。
操作流程
-
预测光标位置:
- 系统计算写光标未来的位置(基于当前光标位置加帧样本数)。
- 如果预测的光标位置仍位于帧的范围内,则进一步向上舍入。
-
确定写入目标:
- 根据预测的写光标位置,找到下一个完整帧的边界。
- 目标是确保填充到下一帧的开始位置,或稍微超出一帧以避免音频数据不足。
-
写入数据:
- 从当前光标位置填充音频数据,直至计算出的目标帧边界。
- 确保写入操作覆盖下一个播放周期所需的数据量。
实现要点
-
同步策略:
- 在低延迟场景中,这种方法通过精确的预测和对齐,确保音频和系统的同步。
- 在高延迟场景中,需要进一步调整以适应较大的延迟。
-
灵活性:
- 该方法适配于多种硬件环境,例如专业级音频设备或游戏主机等。
-
效率提升:
- 通过向上舍入到帧边界,优化了音频数据的填充流程,减少了不必要的计算和数据丢失的风险。
开发者的感受与总结
开发者认为这一方案提供了一个清晰的方向,用以解决音频输出的核心问题。以下是其反思:
-
低延迟设备的潜力:
- 在专业级硬件(如 ASIO 驱动程序或控制台设备)上,音频延迟可以非常小(5-10 毫秒)。如果能够充分利用这些设备的特性,音频系统的性能会显著提升。
-
预测与调整的重要性:
- 通过映射光标位置并动态调整写入范围,可以有效避免音频失真或不同步的问题。
-
逐步完善的成就感:
- 尽管这个问题复杂且涉及许多细节,但逐步攻克难点的过程令人振奋,带来了强烈的成就感。
高延迟方法
高延迟情况分析
在处理高延迟音频卡时,与低延迟情境的处理逻辑类似,但有一些显著区别。主要体现在以下几点:
-
延迟影响:
- 音频系统唤醒时,写光标的位置可能已经超过了当前帧的边界。
- 需要针对这种延迟情况,调整写入数据的范围和策略。
-
计算目标写入位置的调整:
- 在高延迟场景中,目标不再简单地对齐到下一帧边界,而是可以引入“安全系数”(Safety Margin),以减少延迟的不确定性。
基本操作流程
1. 系统唤醒与光标位置
-
系统唤醒:
- 音频系统按照固定的时间间隔唤醒,用于处理音频数据。
- 唤醒时间与声卡延迟无关,因此音频和游戏更新的时间间隔保持一致。
-
获取写光标位置:
- 通过查询写光标的位置,判断它是否已经超出了当前帧边界。
- 高延迟情况下,写光标的位置通常会超出当前帧,甚至接近下一帧的范围。
2. 确定写入目标位置
-
目标位置的计算:
- 通过计算,决定写入数据的范围是从当前光标到下一帧边界,或者仅添加一定的“安全系数”。
- 如果写光标位置已经超出当前帧边界,则目标可以选择:
- 对齐到下一帧边界:直接舍入到完整帧的边界。
- 加入安全系数:在写光标预测位置的基础上,额外添加一小段数据,避免数据不足导致的播放问题。
-
安全系数的使用:
- 安全系数可以是一个非常小的值(如 1 毫秒或几个样本)。
- 它的作用是减少高延迟下由于变量变化可能引入的偏差。
- 与直接舍入到下一帧相比,使用安全系数能够提供更灵活的写入范围控制。
3. 写入数据
- 从写光标的当前位置开始填充数据,根据目标位置决定填充的范围。
- 在高延迟场景中,目标位置通常考虑到安全系数,而不是直接舍入到下一帧。
启动时的特殊处理
1. 光标位置的未知性
-
初次启动时的挑战:
- 在系统刚启动时,写光标的位置可能不明确。
- 需要通过查询播放光标和写光标的位置差(Delta),推断当前的帧同步状态。
-
光标角色分配:
- 播放光标(Play Cursor):表示音频系统当前正在播放的位置。
- 写光标(Write Cursor):表示音频系统准备写入的目标位置。
- 初次启动时,通过计算两者的距离(Delta),确定写光标的位置相对于当前帧的偏移。
2. 映射复杂性
- 映射写光标的位置:
- 需要将写光标的位置映射到帧的范围内,以确保计算的目标写入位置合理。
- 这增加了额外的复杂性,但对系统启动后的正常运行至关重要。
示例说明
假设场景
- 当前系统唤醒,写光标位置位于下一帧范围之外。
- 系统计划填充的数据量需要足够覆盖高延迟带来的额外播放时间。
操作流程
-
获取光标位置:
- 查询当前写光标的位置。
- 如果写光标超出当前帧的边界,则目标位置调整到下一帧或加入安全系数。
-
计算目标位置:
- 如果选择对齐到帧边界:
- 写入范围直接延伸到下一帧的边界。
- 如果选择加入安全系数:
- 写入范围为写光标位置加上安全系数。
- 如果选择对齐到帧边界:
-
写入数据:
- 填充从当前光标位置到目标位置范围内的音频数据。
总结与思考
1. 高延迟场景的关键点
-
安全系数的作用:
- 在高延迟情况下,安全系数提供了一种灵活的控制方法,减少了系统过度填充的风险。
- 安全系数的大小可以根据实际延迟的波动范围动态调整。
-
对齐策略的选择:
- 根据延迟的严重程度,可以选择直接对齐到帧边界或采用安全系数策略。
2. 启动阶段的处理
- 在初次启动时,写光标的位置可能不明确,需要通过计算与播放光标的差值来推断其位置。
- 这一过程增加了映射计算的复杂性,但为后续的正常运行奠定了基础。
3. 实现目标
- 确保音频数据的连续性,避免播放中断或失真。
- 平衡写入的范围,减少延迟对整体音频输出质量的影响。
4. 未来改进方向
- 开发动态调整安全系数的算法,使系统能够根据实时延迟状况优化填充策略。
- 简化初次启动时的光标映射过程,进一步提升系统的鲁棒性和效率。
通过上述方法,可以有效应对高延迟音频卡的挑战,同时确保音频输出的同步和稳定。
又遇到一个障碍
关于播放计数器的延迟更新
一个关键点是播放计数器的更新频率较低,这使得它在时间上的位置并不总是准确。具体来说,播放计数器每10毫秒更新一次,这意味着在任意时刻,计数器的报告位置可能存在正负5毫秒的误差。
举例来说,我们查看音频数据缓冲区并记录播放计数器位置时,这个位置可能已经过时了,或即将更新。这种不确定性增加了处理音频输出的复杂性。
用代码分析粒度误差
通过实际计算,播放计数器更新的粒度是每480个样本更新一次(基于常见的采样率计算得出约10毫秒)。这意味着每个更新周期可能引入正负5毫秒的误差。
这种误差对于音频输出的准确性可能是显著的,尤其在处理低延迟音频时,它可能导致音频不同步的现象。
延迟对同步的影响
设想以下情形:
- 在音频缓冲区中,计算出播放计数器的位置。
- 播放计数器的位置用于确定当前音频播放到了哪里。
- 与“写光标”的比较(写光标指代音频缓冲区中预计要播放的位置)。
- 如果写光标的位置在播放计数器的后面,表明我们有足够的时间去计算并处理下一个音频帧。
- 如果写光标超前过多,可能说明时间估算出现偏差。
然而,由于播放计数器的更新粒度较低,我们无法精确定位播放的位置。这种情况类似于用模糊的地图导航,难以精确判断。
应对策略
为了解决这个问题,我们可以:
-
以播放计数器为参考,估算音频帧的边界。
- 播放计数器是当前播放位置的近似值,通过它可以粗略计算需要播放音频数据的区域。
- 在这个区域内,我们基于写光标的位置,进一步确定实际要写入的音频数据。
-
引入“安全边际”来处理潜在的误差。
- 由于写光标的位置可能并不完全准确,我们可以在其基础上增加一个小的安全边际(如几毫秒)。
- 这种方法确保即使在延迟存在的情况下,我们仍能覆盖可能需要播放的音频数据。
-
动态调整安全边际的大小。
- 安全边际可以根据实际硬件的表现来调整。例如,如果观察到系统延迟更高,可以增加边际;如果延迟较低,可以适当减少。
实现的细节
-
初始同步问题:
- 刚启动时,由于无法准确知道写光标的具体位置,我们可以保守地假设播放计数器提供的是最小边界值。
- 使用这种方法,即使写光标的报告稍有误差,我们仍然可以确保音频的流畅性。
-
长期收敛:
- 随着音频系统的运行,硬件可能逐渐趋向于更准确的计时。这时,音频的帧同步可以精确地达到翻转点,从而实现低延迟、高准确度的音频输出。
总结和反思
-
理论上的可行性:
- 当前的分析和逻辑表明,这种方法可以应对粒度误差带来的问题。
- 使用保守的估算和合理的安全边际,可以最大程度地降低潜在问题。
-
实践中的验证:
- 还需要通过实际运行和测试来验证理论的可靠性。
- 尽管逻辑上看起来是合理的,但可能存在一些尚未发现的漏洞。
-
反馈机制:
- 一种建议是通过用户社区或论坛收集反馈,讨论如何进一步优化解决方案,例如选择更高效的方法或改进安全边际的动态调整策略。
用文字描述我们的解决方案
在音频输出的代码部分,首先要说明的是这个过程是如何运作的。我们会定义一个“安全值”,这个值是我们认为游戏更新循环可能会变化的样本数量。假设这个变化范围是2毫秒(或者其他合理值)。
当我们准备写音频数据时,我们会查看播放光标的位置,并预测它在下一帧边界会处于哪里。然后,我们会检查写入光,标的位置,判断它是否处于目标位置之前,至少在安全值的范围内。如果是这样,目标填充位置会是该帧的边界加上一帧,我们会将音频数据写入到下一个帧边界,再加上一帧,以保证音频的完美同步。
如果写入光标的位置已经超出了这个安全值的范围,我们就认为无法完美同步音频。在这种情况下,我们会写入一个帧的音频数据,再加上一些额外的保护样本。保护样本的数量会根据我们定义的安全值来确定,这个安全值可以是以毫秒为单位的延迟,或者是样本的数量。我们假设游戏的更新可能有最多2毫秒的变化。
这个安全值是用来确保即使游戏更新的时间发生变化,音频同步仍然可以尽可能准确。在大多数情况下,如果硬件的延迟足够低,音频可以完美同步。但如果硬件的延迟较高,音频同步就会受到影响,这时我们会通过额外的保护样本来避免音频“掉帧”。
如果我们判断右边的光标处于目标位置之前,说明音频同步是可行的,我们会将数据写到下一个帧边界,再加上一帧;如果光标已经超出目标边界,就会写入当前帧的数据,再加上一些保护样本,以确保音频的平稳播放。
这段话的核心是通过预测和检查播放光标与写入光标的相对位置,结合一个定义好的安全值,来决定如何写入音频数据,从而在不同的硬件环境下尽可能实现音频的精确同步。
这个代码片段看起来是音频处理系统的一部分,其中 SafetyBytes
是根据音频输出的采样率、每个采样的字节数和游戏更新率来计算的。
公式解析:
SoundOutput.SafetyBytes = (SoundOutput.SamplesPerSecond * SoundOutput.BytesPerSample / GameUpdateHz) / 3;
各部分的含义:
-
SoundOutput.SamplesPerSecond:
这个值表示音频的采样率,即每秒钟采样的次数(例如,44,100 采样/秒是CD音质的音频采样率)。这是确定音频数据缓冲区大小的关键因素。 -
SoundOutput.BytesPerSample:
这个值表示每个音频采样使用的字节数。比如,16位音频通常每个采样为2字节(因为16位 = 2字节)。 -
GameUpdateHz:
这个值表示游戏的更新频率,通常是游戏的帧率或更新频率,单位是Hz(每秒更新次数)。例如,如果游戏以60帧每秒(FPS)更新,则GameUpdateHz
为60。 -
SafetyBytes:
这个值表示一个“安全”字节数,用来确保每次游戏更新之间有足够的音频数据处理。通过保证有足够的音频数据在处理过程中,避免音频播放时出现延迟或卡顿。
公式的作用:
-
SamplesPerSecond × BytesPerSample:
这个操作计算出每秒音频数据的总字节数。 -
除以 GameUpdateHz:
将音频数据按游戏更新频率进行调整,计算出每个游戏更新周期(每帧)需要的音频数据量。实际上,计算的是每帧需要多少字节的音频数据,以便在每次游戏更新时能平稳处理。 -
除以 3:
这部分的作用是为缓冲区设置一个“安全余量”。数字3可能是为了在处理音频时留出一定的缓冲时间,确保音频处理不会由于延迟或数据不足而导致问题。
示例计算:
假设以下值:
- SamplesPerSecond = 44,100(标准CD音质音频采样率)
- BytesPerSample = 2(16位音频,每个采样2字节)
- GameUpdateHz = 60(游戏更新频率为60帧每秒)
那么计算过程如下:
SoundOutput.SafetyBytes = (44,100 * 2 / 60) / 3
= (88,200 / 60) / 3
= 1,470 / 3
= 490
因此,SoundOutput.SafetyBytes
的值将被设置为 490 字节。这意味着,每个游戏更新周期内,应该有 490 字节的音频数据准备好,以保持音频的平稳播放。
总结:
这个公式的核心目的是确保每个游戏更新周期之间有足够的音频数据进行处理,从而避免出现音频播放延迟或卡顿的情况。SafetyBytes
用来调整音频缓冲区的大小,以便在后台处理时避免音频数据的耗尽,同时也不会造成过多的内存消耗或延迟。
/*
这是声音输出计算的工作原理:
-
我们定义一个安全值(`SafetyBytes`),表示游戏更新循环可能会变化的样本数量(假设最多2毫秒)。
- 写入音频时,我们根据播放光标的位置,预测下一个帧边界时播放光标的位置。
- 判断写入光标是否在预测目标位置之前(加上安全范围)。
- 如果是,则目标填充位置是预测的帧边界加上一个完整的帧长度。
-
如果写入光标已经超过目标位置,则假设无法完美同步音频,这种情况下会写入一帧的音频数据,并加上安全值保护样本。
- 目标是低延迟情况下实现音频同步,但在高延迟情况下保证不会出现声音中断。
*/
// 准备绘制缓冲区,传递到游戏更新和渲染函数中
game_offscreen_buffer Buffer = {};
Buffer.Memory = GlobalBackbuffer.Memory;
Buffer.Width = GlobalBackbuffer.Width;
Buffer.Height = GlobalBackbuffer.Height;
Buffer.Pitch = GlobalBackbuffer.Pitch;
// 调用游戏的更新和渲染逻辑,填充缓冲区
GameUpdateAndRender(&GameMemory, NewInput, &Buffer);
// 声音处理部分
// 声明两个变量,分别表示音频缓冲区的播放光标和写入光标
DWORD PlayCursor; // 播放光标:当前音频硬件正在播放的位置
DWORD WriteCursor; // 写入光标:硬件允许写入新音频数据的位置
// 获取音频缓冲区的当前播放位置和写入位置
if (GlobalSecondaryBuffer->GetCurrentPosition(&PlayCursor, &WriteCursor) ==
DS_OK) {
// 如果成功获取了音频缓冲区的当前位置
if (!SoundIsValid) {
/*
如果声音状态无效(例如程序刚启动或是首次运行音频逻辑):
- 使用写入光标的当前位置作为基准,初始化运行样本索引。
- 将写入光标的位置除以每个样本的字节数,以确定对应的样本索引。
*/
SoundOutput.RunningSampleIndex =
WriteCursor / SoundOutput.BytesPerSample;
SoundIsValid = true; // 设置声音状态为有效
}
DWORD TargetCursor = 0; // 目标写入位置
DWORD BytesToWrite = 0; // 需要写入的字节数
// 计算需要锁定的字节位置,基于当前运行的样本索引
DWORD ByteToLock =
((SoundOutput.RunningSampleIndex * SoundOutput.BytesPerSample) %
SoundOutput.SecondaryBufferSize);
// 计算每帧需要的字节数(基于采样率和帧率)
DWORD ExpectedSoundBytesPerFrame =
(SoundOutput.SamplesPerSecond * SoundOutput.BytesPerSample) /
GameUpdateHz;
// 预测当前帧边界时的播放光标位置
DWORD ExpectedFrameBoundaryByte = PlayCursor + ExpectedSoundBytesPerFrame;
// 确保写入光标位置是安全的(考虑缓冲区环绕)
DWORD SafeWriteCursor = WriteCursor;
if (SafeWriteCursor < PlayCursor) {
SafeWriteCursor +=
SoundOutput.SecondaryBufferSize; // 修正光标位置以防止缓冲区回绕
}
Assert(SafeWriteCursor >= PlayCursor);
SafeWriteCursor += SoundOutput.SafetyBytes; // 加入安全保护字节范围
// 判断音频卡的延迟是否足够低
bool32 AudioCardIsLowLatency =
(SafeWriteCursor < ExpectedFrameBoundaryByte);
if (AudioCardIsLowLatency) {
/*
如果音频卡延迟较低:
- 将目标写入光标设置为下一帧边界加上一个完整的帧长度。
*/
TargetCursor = ExpectedFrameBoundaryByte + ExpectedSoundBytesPerFrame;
} else {
/*
如果音频卡延迟较高:
- 将目标写入光标设置为写入光标位置,加上一个帧长度和安全字节数。
*/
TargetCursor =
WriteCursor + ExpectedSoundBytesPerFrame + SoundOutput.SafetyBytes;
}
// 确保目标光标位置在环绕缓冲区内
TargetCursor = TargetCursor % SoundOutput.SecondaryBufferSize;
// 计算需要写入的字节数
if (ByteToLock > TargetCursor) {
/*
如果锁定位置在目标位置之后:
-
写入从锁定位置到缓冲区末尾的字节数,再加上从缓冲区开头到目标位置的字节数。
*/
BytesToWrite =
(SoundOutput.SecondaryBufferSize - ByteToLock) + TargetCursor;
} else {
/*
如果锁定位置在目标位置之前:
- 写入从锁定位置到目标位置之间的字节数。
*/
BytesToWrite = TargetCursor - ByteToLock;
}
// 设置音频缓冲区结构
game_sound_output_buffer SoundBuffer = {};
SoundBuffer.SamplesPerSecond = SoundOutput.SamplesPerSecond; // 每秒采样数
SoundBuffer.SampleCount =
BytesToWrite / SoundOutput.BytesPerSample; // 需要写入的样本数
SoundBuffer.Samples = Samples; // 指向样本数据的指针
// 调用游戏逻辑获取需要填充的声音样本数据
GameGetSoundSamples(&GameMemory, &SoundBuffer);
记录一下这次改动
接下来修改界面显示调试输出的代码
添加按下P键界面暂停
初次查看调试绘图输出并添加全局暂停键
当然,以下是对上述内容的详细复述,用于梳理逻辑并确保更容易理解:
我们当前正在调试一个程序,并查看其工作情况。首先,我们开始检查一些显示内容,这些内容主要是音频缓冲区内不同指针的状态。
检查内容
-
添加标签和辅助信息:
- 虽然添加标签和辅助显示内容可能会使调试更清晰,但当前我们先看现有的输出效果。
-
查看第一个标记:
- 我们主要观察第一个标记的状态,它包含:
- 播放光标 (Play Cursor):这是当音频缓冲区正在播放时指针所在的位置。
- 写入光标 (Write Cursor):这是当我们向缓冲区写入数据时指针所在的位置。
- 我们主要观察第一个标记的状态,它包含:
-
调试器中的展示:
- 在调试器中,我们可以清楚地看到:
- 白线表示播放光标的位置。
- 红线表示写入光标的位置。
- 翻转位置 (Flip Location) 显示为底部的一条白线。
- 在调试器中,我们可以清楚地看到:
-
检查逻辑正确性:
- 通过调试结果可以确认:
- 各个光标的位置符合我们的预期,显示逻辑和程序的实际行为一致。
- 翻转位置(底部的白线)是在翻转时播放光标的位置。
- 通过调试结果可以确认:
-
改进调试:
- 由于调试内容会动态更新,当前没有一个“暂停”功能,导致鼠标无法自由移动或指针难以准确查看。所以我们决定为显示添加暂停功能。
添加全局暂停功能
-
设计暂停功能:
- 我们引入一个全局变量
GlobalPause
来控制程序是否进入暂停状态。 - 通过按下某个键(例如
P
键)切换GlobalPause
的状态。 - 当程序暂停时,主要实现以下目标:
- 停止更新(Update)逻辑。
- 暂停所有绘制(Blit)和输入处理。
- 我们引入一个全局变量
-
实现暂停逻辑:
- 在主循环中添加检查逻辑:
- 如果
GlobalPause
被激活,则不再执行主要的更新和绘制操作。 - 保留处理输入的功能,以便用户可以通过再次按键恢复程序。
- 如果
- 在主循环中添加检查逻辑:
-
处理潜在问题:
- 当前实现的暂停功能存在一些潜在问题,例如:
- 如果主循环完全暂停,那么无法响应输入恢复状态。
- 调试显示可能会卡顿。
- 所以我们需要保留某些核心功能(例如消息处理
Win32ProcessMessages
),以便程序可以正确地取消暂停。
- 当前实现的暂停功能存在一些潜在问题,例如:
-
改进方案:
- 将暂停的实现改进为更细粒度的控制,确保可以逐步扩展功能,例如:
- 仅暂停特定部分(如音频渲染、动画更新等)。
- 保留用户交互功能。
- 将暂停的实现改进为更细粒度的控制,确保可以逐步扩展功能,例如:
暂停功能的逻辑代码
以下是实现暂停功能的伪代码:
// 定义全局变量
bool GlobalPause = false;
// 在主循环中添加逻辑
if (IsKeyDown(P)) { // 按下 'P' 键切换暂停状态
GlobalPause = !GlobalPause; // 切换暂停状态
}
// 更新和绘制逻辑
if (!GlobalPause) {
// 如果未暂停,执行更新和绘制
UpdateGame();
RenderGame();
} else {
// 如果暂停,不执行主要逻辑
// 保留消息处理以支持取消暂停
Win32ProcessMessages();
}
主要调试目标
-
展示音频缓冲区状态:
- 白线表示播放光标。
- 红线表示写入光标。
- 底部白线表示翻转光标。
-
暂停功能:
- 实现可以动态暂停和恢复的全局控制。
-
后续优化:
- 改进暂停功能的实现逻辑,避免程序在暂停状态下出现其他问题。
总结
通过调试器和程序的改进,我们确认了各个光标状态的显示逻辑正确性,并引入了一个简单的全局暂停功能,方便更好地观察调试输出。这是一个基础的实现,将在后续优化过程中不断改进暂停功能的细节和可靠性。
仔细检查调试绘图输出
以下是上述内容的详细复述,用于理清逻辑和内容细节:
我们刚刚实现了一种暂停功能,现在可以暂停程序运行,这使我们更容易观察和验证程序的行为。
调试状态验证
-
当前显示的内容:
- 我们绘制了一个基线 (Baseline),用于显示音频缓冲区状态。
- 现在,让我们逐一验证绘制的内容。
-
第一个绘制的内容:
- 基线以下的第一个绘制内容是:
- 输出播放光标位置 (Output Play Cursor Position)。
- 输出写入光标位置 (Output Write Cursor Position)。
- 这表示当程序开始运行时,我们询问系统当前缓冲区的状态(即光标的位置),并得到了这些值。
- 基线以下的第一个绘制内容是:
-
计算写入位置:
- 接着,我们计算了将要写入缓冲区的位置。
- 具体而言:
- 输出位置 (Output Location) 表示我们将要写入的位置。
- 根据计算结果,我们决定要写入的字节数,并将其显示在图形上。
-
翻页(Page Flip)的时机:
- 当到达翻页时刻时,我们在图形上记录了翻页时的状态:
- 翻页时写入光标的位置 (Right Cursor Position during Page Flip)。
- 这个位置虽然不直接影响当前调试逻辑,但我们仍然记录了它以备分析。
- 当到达翻页时刻时,我们在图形上记录了翻页时的状态:
-
音频延迟分析:
- 音频延迟 (Audio Latency) 是播放光标和写入光标之间的时间差。
- 在我们的图形中,这可以通过两条线之间的距离直观地观察。
- 当前的音频延迟已经小于一帧的时间(Frame Time),这是一个理想的状态。
延迟性能评估
-
延迟低于一帧:
- 这是我们调试过程中希望达到的效果,即尽量减少音频延迟。
- 延迟的减少有助于提升音频与视频的同步性,从而提供更流畅的用户体验。
-
潜在的更低延迟:
- 在某些情况下,延迟甚至可能会进一步降低。
- 具体结果取决于程序运行时的调度、音频缓冲区的大小等因素。
-
结果满意度:
- 当前结果已经令人满意。
- 虽然有些方面可能需要进一步优化,但从现有状态来看,延迟表现符合预期。
总结
-
我们实现了以下功能:
- 绘制了音频缓冲区状态,包括播放光标、写入光标和翻页光标。
- 验证了缓冲区的状态是否符合预期,并对音频延迟进行了测量和分析。
-
当前的音频延迟:
- 音频延迟已经小于一帧时间,这是一个令人满意的结果。
-
整体感受:
- 对现有的实现感到满意,尤其是在延迟优化方面取得了理想的效果。
-
下一步可能的优化方向:
- 进一步降低音频延迟。
- 增强程序的稳定性,以应对更多极端情况。
通过这次调试,我们不仅验证了程序的行为,同时确认了系统在当前实现下的延迟表现符合预期。这种分析方法对后续优化提供了有价值的参考数据。
绘制另一个调试标记
以下是上述内容的详细复述,尽量还原细节和逻辑:
新增功能:绘制预期的帧翻转时间
-
目标:
- 增加一个新的可视化标记,用于显示 “预期的帧翻转时间”。
- 该标记是根据程序计算的预期值,用以验证实际值与计算值是否一致。
-
绘制的依据:
- 绘制一个 “预期的帧边界字节”(Expected Frame Boundary Byte)。
- 该边界字节基于当前 播放光标位置(Play Cursor Position),加上每帧的 预期音频字节(Expected Sound Bytes per Frame) 计算得出。
实现中的问题与调整
-
问题识别:
- 在最初的实现中,计算逻辑存在偏差:
- 忘记扣除 当前已流逝的时间。
- 翻转时间并不是整帧的时间值,而应该考虑当前时间的进度,从而减少不必要的延迟。
- 在最初的实现中,计算逻辑存在偏差:
-
问题后果:
- 虽然偏差并未显现为明显的 bug,但它会导致音频延迟比预期稍高。
-
修复计划:
- 纠正计算逻辑,确保 帧翻转的预期时间 更加准确。
- 通过修复,进一步降低音频延迟。
绘制逻辑的实现
-
绘制颜色选择:
- 新增标记颜色为黄色(Yellow),因为黄色清晰且容易与其他标记区分。
- 在颜色选择过程中,也考虑过青色(Cyan)和紫色(Purple),但最终选择黄色。
-
绘制代码插入:
- 在现有代码中插入绘制逻辑,使其能够绘制 “预期的帧翻转光标”(Expected Flip Cursor)。
- 关键步骤:
- 计算
Expected Flip Play Cursor
。 - 在图形界面上绘制该光标。
- 计算
-
初步结果:
- 修复后绘制的预期光标位置,能够很好地靠近实际光标位置。
- 尽管有时光标可能不完全重合,但整体偏差已经非常小,属于合理范围。
程序验证和结果分析
-
光标位置对比:
- 绘制后,可以直观地观察实际光标位置和预期光标位置的差异。
- 在某些情况下,预期光标与实际光标非常接近,但偶尔可能有轻微偏差。
-
误差原因分析:
- 游戏更新的毫秒级延迟非常低,因此估算的翻转时间已经非常接近实际值。
- 偏差偶发于一些运气较差的场景,但属于可接受范围内。
-
验证逻辑的补充:
- 为确保计算和绘制的准确性,新增了对
Expected Flip Cursor
的断言(Assertion)。 - 断言帮助验证计算值是否在正常范围内,进一步保证了程序的稳健性。
- 为确保计算和绘制的准确性,新增了对
最终总结
-
新增功能的意义:
- 绘制预期帧翻转时间的标记,能够帮助开发者直观验证程序的音频同步和延迟控制效果。
- 提供了更好的调试工具,有助于优化和校准程序性能。
-
修复带来的改进:
- 纠正计算逻辑后,音频延迟进一步降低。
- 实现了更准确的帧翻转时间预测,使得程序表现更加可靠。
-
当前表现评价:
- 初步结果令人满意,尽管在某些场景下光标位置有轻微偏差,但整体误差已控制在合理范围。
- 程序的实时性和延迟优化表现良好,基本达到了预期目标。
-
后续优化方向:
- 进一步完善测试,验证在复杂场景下的稳定性。
- 优化其他可能的潜在延迟因素,以求达到更加精准和流畅的音频与视频同步效果。
通过这次调整和新增功能的实现,程序的延迟控制和可视化能力得到了显著提升。这为后续的优化和功能扩展打下了坚实基础,同时也进一步提高了开发效率和代码质量。
改进对 PlayCursor 位置的估算以减少延迟
详细复述与解释:
背景:
这段内容描述了在游戏或多媒体应用中,分析和修正“帧翻转”(frame flip)同步的问题。目标是将计算出的“翻转时间”(frame flip timing,与视觉或音频帧的更新点相关)与系统计算的期望时间对齐,从而提高时间精度并减少延迟。
-
绘制期望的翻转标记:
讲解者提到需要增加一个可视化的标记,用来表示期望的帧翻转时间:- 该标记显示的是预测的帧边界字节,对应于预估的帧翻转点。
- 最初的设想是,这个标记应该与实际的帧翻转时间点紧密相关。
但在检查过程中发现,这两个点之间存在一定偏差,说明计算可能存在问题。
-
识别问题:
- 期望的翻转时间计算存在错误。
- 讲解者意识到,在计算时没有正确考虑当前帧中已过去的时间。计算假设整个帧的持续时间,但实际只需计算帧内剩余时间(离下一次翻转的时间)。
-
修正计算方法:
- 找到了问题的根源:未将当前帧开始以来的已用时间减去,导致预测的翻转时间点提前或与实际翻转点不符。
- 决定重新绘制标记,直观地检查偏差,同时修正计算以纳入已用时间。
-
音频延迟的影响:
- 尽管这种偏差对画面表现的影响很小(只有微弱的延迟),但修正后可以进一步优化时间同步。
- 讲解者指出,这种偏差是由于期望的帧边界字节数与实际字节数之间的差异引起的。
-
引入挂钟时间(Wall Clock Time):
为了更好地分析问题,他们决定记录挂钟时间:- 挂钟时间是翻转发生时的精确时间点,由系统在翻转触发后立即捕获。
- 通过比较挂钟时间与预测时间,能够更好地识别期望和实际翻转事件之间的不一致。
-
记录挂钟时间的步骤:
- 引入一个新变量
FlipWallClock
,用于存储翻转后立即捕获的挂钟时间。 - 每一帧都通过系统查询记录该值,用于测量上一帧翻转后经过的时间。
- 引入一个新变量
-
使预测与实际对齐:
- 为计算离下一次翻转的剩余时间,从每帧目标持续时间中减去已用时间:
SecondsLeftUntilFlip = TargetSecondsPerFrame - ElapsedSeconds
- 将剩余时间占帧持续时间的比例乘以每帧的总字节数,以计算翻转前应该处理的字节数:
ExpectedBytesUntilFlip = BytesPerFrame * (SecondsLeftUntilFlip / TargetSecondsPerFrame)
- 为计算离下一次翻转的剩余时间,从每帧目标持续时间中减去已用时间:
-
可视化的相关性:
- 将新的期望标记与实际翻转标记绘制在一起,目标是黄色标记(表示预测的翻转时间)能够与观察到的帧翻转事件紧密对齐。
- 讲解者注意到,这些标记虽然接近,但在某些情况下未完全对齐,这可能是因为系统中帧更新时间非常短。
-
确保正确的理解:
- 讲解者发现自己之前比较了错误的标记,导致了混淆。实际上,黄色标记应该直接与实际翻转时间相关,因为它代表修正后的预测值。
-
改进可视化方式:
- 为避免未来的误读,讲解者决定将绘制的标记延伸到相关的轴上,以便更清晰地对比期望和实际的翻转事件是否对齐。
- 这样可以更加直观地发现问题并进行调整。
-
下一步计划:
- 通过优化可视化和改进计算方法,他们计划迭代调整逻辑,直到预测的翻转事件与实际翻转事件更一致。
这段过程展示了如何通过详细调试、精确的时间计算以及可视化手段,优化多媒体系统的同步。每一步修正都在减少延迟、提高时间精度,从而改进用户体验。
查看估算与实际的对齐情况
详细复述与解释:
-
问题描述:
讲解者仍然未达到预期的结果,特别是在黄色值和白色值之间的关联性上:- 黄色线表示预期的播放光标位置,而白色线则是实际的翻转光标位置。
- 讲解者希望这两个值能够紧密对齐,但目前并没有完全如预期那样同步。
-
底部点值的解释:
讲解者指出,底部的点值是指当翻转发生时,播放光标的实际位置。这个位置被称为翻转光标,而它对应的是白色线的实际位置。- 讲解者曾经认为黄色线和底部的白色线应对齐,实际上它们并不总是精确对齐。
-
黄色线的含义:
- 黄色线代表的是预期的播放光标位置,这个位置应该与系统预估的翻转时间相匹配。
- 讲解者现在确认,黄色线和白色线应该尽可能紧密对齐,这样才能反映系统正确同步的状态。
-
理想情况:
- 在理想情况下,**黄色线(预期播放光标)与白色线(实际翻转光标)**应该紧密对齐,这样可以确保翻转和播放时间的完美同步。
- 讲解者观察到,实际上这两个线条大部分时间内是对齐的,但有时会出现些许偏差。
-
问题出现的情况:
- 尽管大多数时间两条线能对齐,但偶尔会有一些微小的偏差,这可能是由于系统同步或时间计算的误差引起的。
总结:
在调试过程中发现,黄色线(表示预期的播放光标位置)和白色线(表示实际的翻转光标位置)应该在理论上紧密对齐。尽管大部分时间它们确实对齐,但有时候由于系统同步问题,这两条线之间会出现轻微的偏差。因此,在调整中不断确认这些线的相关性,以确保系统的准确同步。
绘制我们的 480 样本抖动窗口
详细复述与解释:
-
问题描述:
注意到,在某些情况下,黄色光标(预期播放光标)和白色光标(实际翻转光标)之间的距离比预期的要远。这怀疑这种情况是否正常。- 测量了在翻转过程中的400和80样本的抖动,这可能会导致差异。
- 为了进一步了解情况,考虑在图形上绘制一个400和80样本窗口,并使用“漂亮的紫色”来表示这个播放窗口的颜色。
-
绘制窗口和观察:
- 绘制了一个包含400和80样本窗口的视图,希望能够清楚地看到播放窗口的变化。
- 声音缓冲区的输出字节每个样本会根据这个窗口大小产生影响。
- 通过这些图形化的表示,能看到预期的光标位置与实际的翻转光标位置如何对齐。
-
分析观察结果:
- 从图形中可以看出,黄色线(预期的播放光标)和白色线(实际翻转光标)大多数时候是对齐的。
- 黄色线显示的是预期的播放光标位置,而白色线则表示翻转发生时的实际播放光标位置。
- 这两个光标应该总是尽可能接近,尽管大部分时间它们确实如此,但有时会出现不一致。
- 当这两个光标之间的距离过远时,这像是一个bug,即程序中存在某些问题。
-
疑虑与反思:
- 偶尔的这种偏差可能是由于更新频率的变化或其他可变因素造成的,这可能是正常现象,也可能表明系统在某些地方存在缺陷。
- 尽管这不是最终发布的代码,但仍希望能够看到更为精确的光标同步。
-
决定与后续行动:
- 虽然讲解者对当前结果有些不满意,但他决定先暂停对音频输出的完美调整,避免过多纠结于小细节。
- 他认为,等到有更多时间或更强大的渲染器时,再回头解决这些问题,可能会有更好的办法。
- 他决定暂时结束这个问题的调试,并把注意力转向其他任务。
-
总结:
- 虽然目前的音频输出同步存在一定的问题,但在时间和资源允许的情况下,他会回过头来进一步优化代码,确保光标和音频输出更精确对齐。
- 最后,总结:现在的代码质量仍有提升空间,但他暂时决定放弃进一步优化,并继续处理其他任务。
game.h
#pragma once
#include <iostream>
#include <malloc.h>
#include <cmath>
#include <cstdint>
#define internal static // 用于定义内翻译单元内部函数
#define local_persist static // 局部静态变量
#define global_variable static // 全局变量
#define Pi32 3.14159265359
typedef uint8_t uint8;
typedef uint16_t uint16;
typedef uint32_t uint32;
typedef uint64_t uint64;
typedef int8_t int8;
typedef int16_t int16;
typedef int32_t int32;
typedef int64_t int64;
typedef int32 bool32;
typedef float real32;
typedef double real64;
/*
GAME_INTERNAL:
0 - 构建用于公开发布,代码将被用于正式发布版本,可能会进行优化和移除调试代码。
1 - 构建仅供开发者使用,代码包含调试信息和开发时需要的额外功能。
GAME_SLOW:
0 - 不允许慢代码,代码需要在生产环境中保持高效。
1 -
允许慢代码(调试),在开发和调试阶段允许一些效率较低的代码,例如用于调试的断言。
*/
#if GAME_SLOW // 如果允许慢代码 (即在调试阶段)
#define Assert(Expression) \
if (!(Expression)) { \
(*(volatile int *)0 = 0); \
}
#else
#define Assert(Expression)
#endif
// 将给定的值转换为千字节(1024 字节)
#define Kilobytes(Value) ((Value) * 1024LL)
// 将给定的值转换为兆字节(1024 * 1024 字节)
#define Megabytes(Value) (Kilobytes(Value) * 1024LL)
// 将给定的值转换为吉字节(1024 * 1024 * 1024 字节)
#define Gibabytes(Value) (Megabytes(Value) * 1024LL)
// 将给定的值转换为太字节(1024 * 1024 * 1024 * 1024 字节)
#define Terabytes(Value) (Gibabytes(Value) * 1024LL)
#define ArrayCount(Array) (sizeof(Array) / sizeof((Array)[0]))
inline uint32 SafeTruncateUInt64(uint64 Value) {
// 确保传入的 64 位值不会超出 32 位的最大值
Assert(Value <= 0xFFFFFFFF);
uint32 Result = (uint32)Value; // 安全地将 64 位值截断为 32 位
return Result;
}
#if GAME_INTERNAL
/*
以下结构体和操作仅用于游戏的内部版本,
在正式发布的版本中不应使用!
这些操作是阻塞的,并且写入过程不防止数据丢失。
主要用于开发和调试阶段。
*/
struct debug_read_file_result {
uint32 ContentsSize; // 文件内容的大小(以字节为单位)
void *Contents; // 指向文件内容的内存指针
};
// 读取整个文件内容到内存
internal debug_read_file_result DEBUGPlatformReadEntireFile(const char *Filename);
// 释放文件读取的内存
internal void DEBUGPlatformFreeFileMemory(void *Memory);
// 将内存内容写入文件
internal void *DEBUGPlatformWriteFileEntireFile(char *Filename, uint64 MemorySize, void *Memory);
#endif
// NOTE: 平台层为游戏提供的服务
// NOTE: 游戏为平台玩家提供的服务
// (这个部分未来可能扩展——例如声音处理可能在单独的线程中)
// 四个主要功能 - 时间管理,控制器/键盘输入,位图缓冲区,声音缓冲区
struct game_offscreen_buffer {
// TODO(casey):未来,渲染将特别变成一个三层抽象!!!
void *Memory;
// 后备缓冲区的宽度和高度
int Width;
int Height;
int Pitch;
int BytesPerPixel;
};
struct game_sound_output_buffer {
int SamplesPerSecond; // 采样率:每秒采样48000次
int SampleCount;
int16 *Samples;
};
// 游戏按钮状态结构体
struct game_button_state {
int HalfTransitionCount; // 按钮状态变化的次数(用于处理按钮的按下和释放事件)
bool32 EndedDown; // 按钮是否处于按下状态(true 表示按下,false 表示释放)
};
// 游戏控制器输入结构体
struct game_controller_input {
bool32 IsConnected; // 控制器是否已连接
bool32 IsAnalog; // 控制器是否为模拟输入(例如操控杆,判断是否为连续输入)
real32 StickAverageX; // 左摇杆 X 轴的平均值
real32 StickAverageY; // 左摇杆 Y 轴的平均值
// 按钮状态的联合体,用于存储按钮输入信息
union {
game_button_state Buttons[12]; // 按钮状态数组,最多支持 12 个按钮
// 通过命名字段表示常见的控制按钮(用于便捷访问)
struct {
game_button_state MoveUp; // 上移按钮
game_button_state MoveDown; // 下移按钮
game_button_state MoveLeft; // 左移按钮
game_button_state MoveRight; // 右移按钮
game_button_state ActionUp; // 动作上按钮
game_button_state ActionDown; // 动作下按钮
game_button_state ActionLeft; // 动作左按钮
game_button_state ActionRight; // 动作右按钮
game_button_state LeftShoulder; // 左肩键状态
game_button_state RightShoulder; // 右肩键状态
game_button_state Back; // 返回按钮
game_button_state Start; // 启动按钮
添加按钮再之前添加
game_button_state Terminator; // 占位符号
};
};
};
// 游戏输入结构体(包含多个控制器的输入信息)
struct game_input {
game_controller_input Controllers[4]; // 最多支持 4 个控制器
};
// 获取指定索引的控制器输入
inline game_controller_input *GetController(game_input *Input, int ControllerIndex) {
// 确保传入的控制器索引在有效范围内
Assert(ControllerIndex < ArrayCount(Input->Controllers));
// 获取指定索引的控制器输入,并将其返回
game_controller_input *Result = &Input->Controllers[ControllerIndex];
return Result; // 返回对应的控制器输入结构体指针
}
struct game_state {
int ToneHz;
int GreenOffset;
int BlueOffset;
};
struct game_memory {
bool32 Isinitialized; // 记录内存是否已经初始化。类型 `bool32` 是一个 32
// 位布尔值类型(通常是定义为 `typedef bool bool32`
// 或类似类型别名)。
uint64 PermanentStorageSize; // 永久存储的大小,使用 `uint64`(64
// 位无符号整数)来表示,允许存储较大的数值。
uint64 TransientStorageSize; // 临时存储的大小,也是用 `uint64` 表示。
void *PermanentStorage;
// 指向永久存储的指针,通常用于存储游戏的长期数据,如保存文件、配置文件等。
void *TransientStorage;
// 指向临时存储的指针,用于存储短期的数据,如帧缓冲、临时计算等。
};
// 游戏更新和渲染的主函数
// 参数包含图像缓冲区和音频缓冲区
internal void GameUpdateAndRender(game_memory *Memory, //
game_input *Input, //
game_offscreen_buffer *Buffer);
internal void GameGetSoundSamples(game_memory *Memory, //
game_sound_output_buffer *Bufferr);
// 三个主要功能:
// 1. 时间管理(Timing)
// 2. 控制器/键盘输入(Controller/Keyboard Input)
// 3. 位图输出(Bitmap Output)和声音(Sound)
// 使用的缓冲区(Buffer)
game.cpp
#include "game.h"
internal void GameOutputSound(game_sound_output_buffer *SoundBuffer, int ToneHz) {
local_persist real32 tSine;
int16 ToneVolume = 3000;
int16 *SampleOut = SoundBuffer->Samples;
int WavePeriod = SoundBuffer->SamplesPerSecond / ToneHz;
// 循环写入样本到第一段区域
for (int SampleIndex = 0; SampleIndex < SoundBuffer->SampleCount; ++SampleIndex) {
real32 SineValue = sinf(tSine);
int16 SampleValue = (int16)(SineValue * ToneVolume);
*SampleOut++ = SampleValue; // 左声道
*SampleOut++ = SampleValue; // 右声道
tSine += 2.0f * (real32)Pi32 * 1.0f / (real32)WavePeriod;
}
}
// 渲染一个奇异的渐变图案
internal void RenderWeirdGradient(game_offscreen_buffer *Buffer, int BlueOffset,
int GreenOffset) { // TODO:让我们看看优化器是怎么做的
uint8 *Row = (uint8 *)Buffer->Memory; // 指向位图数据的起始位置
for (int Y = 0; Y < Buffer->Height; ++Y) { // 遍历每一行
uint32 *Pixel = (uint32 *)Row; // 指向每一行的起始像素
for (int X = 0; X < Buffer->Width; ++X) { // 遍历每一列
uint8 Blue = (uint8)(X + BlueOffset); // 计算蓝色分量
uint8 Green = (uint8)(Y + GreenOffset); // 计算绿色分量
*Pixel++ = ((Green << 8) | Blue); // 设置当前像素的颜色
}
Row += Buffer->Pitch; // 移动到下一行
}
}
[[maybe_unused]] internal game_state *GameStartup(void) {
game_state *GameState = new game_state;
if (GameState) {
GameState->BlueOffset = 0;
GameState->GreenOffset = 0;
GameState->ToneHz = 256;
}
return GameState;
}
[[maybe_unused]] internal void GameShutDown(game_state *GameState) { delete GameState; }
// 游戏更新和渲染函数
internal void GameUpdateAndRender(game_memory *Memory, //
game_input *Input, //
game_offscreen_buffer *Buffer) {
Assert((&(Input->Controllers[0].Terminator) - &(Input->Controllers[0].Buttons[0])) ==
(ArrayCount(Input->Controllers[0].Buttons)));
Assert(sizeof(game_state) <= Memory->PermanentStorageSize);
game_state *GameState = (game_state *)Memory->PermanentStorage;
if (!Memory->Isinitialized) {
// 定义一个常量字符指针,指向当前源文件的路径(__FILE__
// 是一个预定义宏,表示当前源代码文件的文件名)
const char *Filename = __FILE__;
// 调用 DEBUGPlatformReadEntireFile 函数读取当前源文件的内容到内存
debug_read_file_result File = DEBUGPlatformReadEntireFile(Filename);
// 如果文件内容成功读取
if (File.Contents) {
// 将读取的文件内容写入到名为 "test.out" 的文件中
// 传入参数:文件路径、内容大小、文件内容
DEBUGPlatformWriteFileEntireFile("./test.out", // 输出文件的路径
File.ContentsSize, // 文件内容的大小
File.Contents); // 文件的内容
// 写入文件后,释放分配的内存
DEBUGPlatformFreeFileMemory(File.Contents); // 释放读取的文件内容的内存
}
GameState->ToneHz = 256;
GameState->GreenOffset = 0;
GameState->BlueOffset = 0;
// TODO: 这可能更适合在平台层中执行
Memory->Isinitialized = true;
}
for (int ControllerIndex = 0; ControllerIndex < ArrayCount(Input->Controllers);
++ControllerIndex) {
// 获取第一个控制器的输入
game_controller_input *Controller = GetController(Input, ControllerIndex);
if (Controller->IsAnalog) {
// 注释:使用模拟运动调节
// 如果是模拟输入,根据控制器的 EndX 值调节音调频率(X轴的偏移量决定音调)
GameState->ToneHz = 256 + (int)(128.0f * (Controller->StickAverageX));
// 音调频率与输入的 EndX 值成比例变化
GameState->BlueOffset += (int)4.0f * (int)(Controller->StickAverageY);
// 根据 EndY 值调整蓝色分量偏移
} else {
// 注释:使用数字运动调节
// 如果是数字输入,处理数字输入的按钮事件(目前不做调整)
// 检查是否按下 "Down" 按钮,如果按下则增加绿色偏移量
if (Controller->MoveLeft.EndedDown) {
GameState->BlueOffset -= 1; // 增加绿色偏移量
}
if (Controller->MoveRight.EndedDown) {
GameState->BlueOffset += 1;
}
}
}
// 渲染渐变效果,根据蓝色和绿色偏移量调整颜色
RenderWeirdGradient(Buffer, GameState->BlueOffset, GameState->GreenOffset);
}
internal void GameGetSoundSamples(game_memory *Memory, //
game_sound_output_buffer *SoundBuffer) {
game_state *GameState = (game_state *)Memory->PermanentStorage;
// 输出声音,根据 ToneHz 控制音调频率
GameOutputSound(SoundBuffer, GameState->ToneHz);
}
win32_game.h
#pragma once
#include "game.h"
#include <dsound.h>
#include <minwindef.h>
#include <windows.h>
#include <winnt.h>
#include <xinput.h>
// 添加这个去掉重复的冗余代码
struct win32_window_dimension {
int Width;
int Height;
};
struct win32_offscreen_buffer {
BITMAPINFO Info;
void *Memory;
// 后备缓冲区的宽度和高度
int Width;
int Height;
int Pitch;
int BytesPerPixel;
};
struct win32_sound_output {
// 音频测试
uint32 RunningSampleIndex; // 样本索引
int SamplesPerSecond; // 采样率:每秒采样48000次
int BytesPerSample; // 一个样本的大小
DWORD SecondaryBufferSize; // 缓冲区大小
real32 tSine; // 保存当前的相位
int LatencySampleCount;
DWORD SafetyBytes; // 安全值表示游戏更新循环可能会变化的样本数量
};
struct win32_debug_time_marker {
// 当前音频输出设备播放指针的位置,用于标识正在播放的音频缓冲区位置
DWORD OutputPlayCursor;
// 当前音频输出设备写入指针的位置,用于标识下一个需要填充音频数据的位置
DWORD OutputWriteCursor;
// 音频输出时的当前位置,用于跟踪音频数据的具体写入点(可能与 OutputWriteCursor
// 类似,但用途更具体)
DWORD OutputLocation;
// 已写入到音频缓冲区的字节数,用于统计音频数据量或调试
DWORD OutputByteCount;
// 预期的翻转指针位置(下一帧或下一块缓冲区的播放起始位置)
DWORD ExpectedFlipCursor;
// 在屏幕翻转(图形帧切换)时,音频播放指针的位置,用于同步音频和图形的关系
DWORD FlipPlayCursor;
// 在屏幕翻转时,音频写入指针的位置,用于调试音频数据的写入延迟
DWORD FlipWriteCursor;
};
win32_game.cpp
// game.cpp : Defines the entry point for the application.
//
/**
T这不是最终版本的平台层
1. 存档位置
2. 获取自己可执行文件的句柄
3. 资源加载路径
4. 线程(启动线程)
5. 原始输入(支持多个键盘)
6. Sleep/TimeBeginPeriod
7. ClipCursor()(多显示器支持)
8. 全屏支持
9. WM_SETCURSOR(控制光标可见性)
10. QueryCancelAutoplay
11. WM_ACTIVATEAPP(当我们不是活动应用程序时)
12. Blit速度优化(BitBlt)
13. 硬件加速(OpenGL或Direct3D或两者?)
14. GetKeyboardLayout(支持法语键盘、国际化WASD键支持)
只是一个部分清单
*/
#include "win32_game.h"
#include "game.h"
#include <basetsd.h>
#include <fileapi.h>
#include <handleapi.h>
#include <memoryapi.h>
#include <minwindef.h>
#include <winnt.h>
#include <winuser.h>
#include <xinput.h>
// 释放文件读取的内存
internal void DEBUGPlatformFreeFileMemory(void *Memory) {
if (Memory) {
// 调用 VirtualFree 释放内存
VirtualFree(Memory, 0, MEM_RELEASE);
}
}
// 读取整个文件内容到内存
internal debug_read_file_result DEBUGPlatformReadEntireFile(const char *Filename) {
// 打开文件以进行读取
HANDLE FileHandle = CreateFileA(Filename, // 创建文件的文件名
GENERIC_READ, // 只读访问权限
FILE_SHARE_READ, // 允许其他进程读取文件
0, // 默认安全性,NULL使用默认设置
OPEN_EXISTING, // 如果文件存在,则打开
0, // 文件属性
0); // 模板文件句柄
debug_read_file_result Result = {}; // 初始化返回结果
if (FileHandle != INVALID_HANDLE_VALUE) { // 如果文件成功打开
LARGE_INTEGER FileSize;
if (GetFileSizeEx(FileHandle, &FileSize)) { // 获取文件大小
// TODO:为最大值定义宏,例如 Uin32Max
Assert(FileSize.QuadPart <= 0xFFFFFFFF); // 确保文件大小不超过 32 位
uint32 FileSize32 = SafeTruncateUInt64(FileSize.QuadPart); // 将文件大小转换为 32 位
// 分配内存用于存储文件内容
Result.Contents = VirtualAlloc(0, //
FileSize32, // 文件大小
MEM_RESERVE | MEM_COMMIT, // 保留并提交内存
PAGE_READWRITE); // 可读写内存
if (Result.Contents) { // 如果内存分配成功
DWORD BytesRead;
if (ReadFile(FileHandle, //
Result.Contents, //
FileSize32, // 要读取的字节数
&BytesRead, // 读取的字节数
0) &&
(FileSize32 == BytesRead)) { // 如果读取的字节数与文件大小相符
// NOTE: 文件读取成功
Result.ContentsSize = FileSize32; // 设置读取内容的大小
} else {
// TODO: 记录日志
DEBUGPlatformFreeFileMemory(Result.Contents); // 释放内存
Result.Contents = 0; // 清空指针
}
} else {
// TODO: 记录日志
}
} else {
// TODO: 记录日志
}
CloseHandle(FileHandle); // 关闭文件句柄
} else {
// TODO: 记录日志
}
return Result; // 返回结果,包含文件内容和大小
}
// 将内存内容写入文件
internal void *DEBUGPlatformWriteFileEntireFile(const char *Filename, uint32 MemorySize,
void *Memory) {
void *Result = 0; // 默认结果为空
// 创建或打开文件以进行写入
HANDLE FileHandle = CreateFileA(Filename, //
GENERIC_WRITE, // 写入权限
0, // 不共享文件
0, // 默认安全性
CREATE_ALWAYS, // 如果文件存在,则覆盖
0, // 文件属性
0); // 模板文件句柄
if (FileHandle != INVALID_HANDLE_VALUE) { // 如果文件成功打开
bool32 WriteResult = false;
DWORD BytesToWritten;
if (WriteFile(FileHandle, //
Memory, // 要写入的内存内容
MemorySize, // 写入的字节数
&BytesToWritten, // 实际写入的字节数
0)) { // 如果写入成功
// NOTE: 文件写入成功
WriteResult = (BytesToWritten == MemorySize); // 确保写入字节数与预期一致
} else {
// TODO: 记录日志
}
} else {
// TODO: 记录日志
}
return Result; // 返回结果,写入成功则返回文件句柄,否则返回空
}
#include "game.cpp"
// TODO: 全局变量
// 用于控制程序运行的全局布尔变量,通常用于循环条件
global_variable bool GlobalRunning;
global_variable bool GlobalPause;
// 用于存储屏幕缓冲区的全局变量
global_variable win32_offscreen_buffer GlobalBackbuffer;
global_variable LPDIRECTSOUNDBUFFER GlobalSecondaryBuffer;
global_variable int64 GlobalPerfCountFrequency;
/**
* @param dwUserIndex // 与设备关联的玩家索引
* @param pState // 接收当前状态的结构体
*/
#define X_INPUT_GET_STATE(name) \
DWORD WINAPI name([[maybe_unused]] DWORD dwUserIndex, \
[[maybe_unused]] XINPUT_STATE *pState) // 定义一个宏,将指定名称设置为
// XInputGetState 函数的类型定义
/**
* @param dwUserIndex // 与设备关联的玩家索引
* @param pVibration // 要发送到控制器的震动信息
*/
#define X_INPUT_SET_STATE(name) \
DWORD WINAPI name( \
[[maybe_unused]] DWORD dwUserIndex, \
[[maybe_unused]] XINPUT_VIBRATION *pVibration) // 定义一个宏,将指定名称设置为
// XInputSetState 函数的类型定义
typedef X_INPUT_GET_STATE(x_input_get_state);
// 定义了 x_input_get_state 类型,为 `XInputGetState` 函数的类型
typedef X_INPUT_SET_STATE(x_input_set_state);
// 定义了 x_input_set_state 类型,为 `XInputSetState` 函数的类型
// 定义一个 XInputGetState 的打桩函数,返回值为
// ERROR_DEVICE_NOT_CONNECTED,表示设备未连接
X_INPUT_GET_STATE(XInputGetStateStub) { //
return (ERROR_DEVICE_NOT_CONNECTED);
}
// 定义一个 XInputSetState 的打桩函数,返回值为
// ERROR_DEVICE_NOT_CONNECTED,表示设备未连接
X_INPUT_SET_STATE(XInputSetStateStub) { //
return (ERROR_DEVICE_NOT_CONNECTED);
}
// 设置全局变量 XInputGetState_ 和 XInputSetState_ 的初始值为打桩函数
global_variable x_input_get_state *XInputGetState_ = XInputGetStateStub;
global_variable x_input_set_state *XInputSetState_ = XInputSetStateStub;
// 定义宏将 XInputGetState 和 XInputSetState 重新指向 XInputGetState_ 和
// XInputSetState_
#define XInputGetState XInputGetState_
#define XInputSetState XInputSetState_
// 加载 XInput DLL 并获取函数地址
internal void Win32LoadXInput(void) { //
HMODULE XInputLibrary = LoadLibrary("xinput1_4.dll");
if (!XInputLibrary) {
// 如果无法加载 xinput1_4.dll,则回退到 xinput1_3.dll
XInputLibrary = LoadLibrary("xinput1_3.dll");
} else {
// TODO:Diagnostic
}
if (XInputLibrary) { // 检查库是否加载成功
XInputGetState = (x_input_get_state *)GetProcAddress(
XInputLibrary, "XInputGetState"); // 获取 XInputGetState 函数地址
if (!XInputGetState) { // 如果获取失败,使用打桩函数
XInputGetState = XInputGetStateStub;
}
XInputSetState = (x_input_set_state *)GetProcAddress(
XInputLibrary, "XInputSetState"); // 获取 XInputSetState 函数地址
if (!XInputSetState) { // 如果获取失败,使用打桩函数
XInputSetState = XInputSetStateStub;
}
} else {
// TODO:Diagnostic
}
}
#define DIRECT_SOUND_CREATE(name) \
HRESULT WINAPI name(LPCGUID pcGuidDevice, LPDIRECTSOUND *ppDS, LPUNKNOWN pUnkOuter);
// 定义一个宏,用于声明 DirectSound 创建函数的原型
typedef DIRECT_SOUND_CREATE(direct_sound_create);
// 定义一个类型别名 direct_sound_create,代表
// DirectSound 创建函数
internal void Win32InitDSound(HWND window, int32 SamplesPerSecond, int32 BufferSize) {
// 注意: 加载 dsound.dll 动态链接库
HMODULE DSoundLibrary = LoadLibraryA("dsound.dll");
if (DSoundLibrary) {
// 注意: 获取 DirectSound 创建函数的地址
// 通过 GetProcAddress 函数查找 "DirectSoundCreate" 函数在 dsound.dll
// 中的地址,并将其转换为 direct_sound_create 类型的函数指针
direct_sound_create *DirectSoundCreate =
(direct_sound_create *)GetProcAddress(DSoundLibrary, "DirectSoundCreate");
// 定义一个指向 IDirectSound 接口的指针,并初始化为 NULL
IDirectSound *DirectSound = NULL;
if (DirectSoundCreate &&
SUCCEEDED(DirectSoundCreate(0,
// 传入 0 作为设备 GUID,表示使用默认音频设备
&DirectSound,
// 将创建的 DirectSound 对象的指针存储到
// DirectSound 变量中
0
// 传入 0 作为外部未知接口指针,通常为 NULL
))) //
{
// clang-format off
WAVEFORMATEX WaveFormat = {};
WaveFormat.wFormatTag = WAVE_FORMAT_PCM; // 设置格式标签为 WAVE_FORMAT_PCM,表示使用未压缩的 PCM 格式
WaveFormat.nChannels = 2; // 设置声道数为 2,表示立体声(两个声道:左声道和右声道)
WaveFormat.nSamplesPerSec = SamplesPerSecond; // 采样率 表示每秒钟的样本数,常见值为 44100 或 48000 等
WaveFormat.wBitsPerSample = 16; // 16位音频 设置每个样本的位深为 16 位
WaveFormat.nBlockAlign = (WaveFormat.nChannels * WaveFormat.wBitsPerSample) / 8;
// 计算数据块对齐大小,公式为:nBlockAlign = nChannels * (wBitsPerSample / 8)
// 这里除以 8 是因为每个样本的大小是按字节来计算的,nChannels 是声道数
// wBitsPerSample 是每个样本的位数,除以 8 转换为字节
WaveFormat.nAvgBytesPerSec = WaveFormat.nSamplesPerSec * WaveFormat.nBlockAlign;
// 计算每秒的平均字节数,公式为:nAvgBytesPerSec = nSamplesPerSec * nBlockAlign
// 这表示每秒音频数据流的字节数,它帮助估算缓冲区大小
// clang-format on
// 函数用于设置 DirectSound 的协作等级
if (SUCCEEDED(DirectSound->SetCooperativeLevel(window, DSSCL_PRIORITY))) {
// 注意: 创建一个主缓冲区
// 使用 DirectSoundCreate 函数创建一个 DirectSound
// 对象,并初始化主缓冲区 具体的实现步骤可以根据实际需求补充
DSBUFFERDESC BufferDescription = {};
BufferDescription.dwSize = sizeof(BufferDescription); // 结构的大小
// dwFlags:设置为
// DSBCAPS_PRIMARYBUFFER,指定我们要创建的是主缓冲区,而不是次缓冲区。
BufferDescription.dwFlags = DSBCAPS_PRIMARYBUFFER;
LPDIRECTSOUNDBUFFER PrimaryBuffer = NULL;
if (SUCCEEDED(DirectSound->CreateSoundBuffer(
&BufferDescription, // 指向缓冲区描述结构体的指针
&PrimaryBuffer, // 指向创建的缓冲区对象的指针
NULL // 外部未知接口,通常传入 NULL
))) {
if (SUCCEEDED(PrimaryBuffer->SetFormat(&WaveFormat))) {
// NOTE:we have finally set the format
OutputDebugString("SetFormat success");
} else {
// NOTE:
OutputDebugString("SetFormat failure");
}
} else {
}
} else {
}
// 注意: 创建第二个缓冲区
// 创建次缓冲区来承载音频数据,并在播放时使用
// 对象,并初始化主缓冲区 具体的实现步骤可以根据实际需求补充
DSBUFFERDESC BufferDescription = {};
BufferDescription.dwSize = sizeof(BufferDescription); // 结构的大小
// dwFlags:设置为
// DSBCAPS_GETCURRENTPOSITION2 |
// DSBCAPS_GLOBALFOCUS两个标志会使次缓冲区在播放时更加精确,同时在应用失去焦点时保持音频输出
BufferDescription.dwFlags = DSBCAPS_GETCURRENTPOSITION2 | DSBCAPS_GLOBALFOCUS;
BufferDescription.dwBufferBytes = BufferSize; // 缓冲区大小
BufferDescription.lpwfxFormat = &WaveFormat; // 指向音频格式的指针
if (SUCCEEDED(DirectSound->CreateSoundBuffer(
&BufferDescription, // 指向缓冲区描述结构体的指针
&GlobalSecondaryBuffer, // 指向创建的缓冲区对象的指针
NULL // 外部未知接口,通常传入 NULL
))) {
OutputDebugString("SetFormat success");
} else {
OutputDebugString("SetFormat failure");
}
// 注意: 开始播放!
// 调用相应的 DirectSound API 开始播放音频
} else {
}
} else {
}
}
// 处理并映射摇杆的输入值
internal real32 Win32ProcessXinputStickValue(SHORT Value, SHORT DeadZoneThreshold) {
real32 Result = 0;
// 检查当前摇杆值是否小于负的死区阈值
if (Value < -DeadZoneThreshold) {
// 如果摇杆值在负方向超过死区,将值映射到 -1.0 到 0 的范围
// (摇杆值 + 死区阈值) 以死区边界为起点进行线性映射
Result = (real32)(Value + DeadZoneThreshold) / (32768.0f - DeadZoneThreshold);
}
// 检查当前摇杆值是否大于正的死区阈值
else if (Value > DeadZoneThreshold) {
// 如果摇杆值在正方向超过死区,将值映射到 0 到 1.0 的范围
// (摇杆值 - 死区阈值) 以死区边界为起点进行线性映射
Result = (real32)(Value - DeadZoneThreshold) / (32767.0f - DeadZoneThreshold);
}
// 返回处理后的映射值,范围为 -1.0 到 1.0
return Result;
}
// 处理单个按键的状态更新
internal void Win32ProcessKeyboardMessage(game_button_state *NewState, bool32 IsDown) {
Assert(NewState->EndedDown != IsDown);
// 更新按钮的状态(是否按下)
NewState->EndedDown = IsDown; // 将按钮的状态设置为按下(IsDown 为
// true)或松开(IsDown 为 false)
// 增加按键状态变化的计数
++NewState->HalfTransitionCount; // 每次按键状态变化时,半次状态转换计数增加 1
}
// 处理 XInput 数字按钮状态的函数
// XInputButtonState: 当前帧的按钮状态(位掩码表示多个按钮状态)
// OldState: 上一帧的按钮状态
// ButtonBit: 要检测的具体按钮位
// NewState: 当前帧更新后的按钮状态
internal void Win32ProcessXInputDigitalButton(DWORD XInputButtonState, game_button_state *OldState,
DWORD ButtonBit, game_button_state *NewState) {
// 判断当前按钮是否处于按下状态
NewState->EndedDown = ((XInputButtonState & ButtonBit) == ButtonBit);
// 如果按钮的按下状态发生变化,增加过渡计数
NewState->HalfTransitionCount = (OldState->EndedDown != NewState->EndedDown) ? 1 : 0;
}
internal win32_window_dimension Win32GetWindowDimension(HWND Window) {
win32_window_dimension Result;
RECT ClientRect;
GetClientRect(Window, &ClientRect);
// 计算绘制区域的宽度和高度
Result.Height = ClientRect.bottom - ClientRect.top;
Result.Width = ClientRect.right - ClientRect.left;
return Result;
}
// 这个函数用于重新调整 DIB(设备独立位图)大小
internal void Win32ResizeDIBSection(win32_offscreen_buffer *Buffer, int width, int height) {
// device independent bitmap(设备独立位图)
// TODO: 进一步优化代码的健壮性
// 可能的改进:先不释放,先尝试其他方法,再如果失败再释放。
if (Buffer->Memory) {
VirtualFree(Buffer->Memory, // 指定要释放的内存块起始地址
0, // 要释放的大小(字节),对部分释放有效,整体释放则设为 0
MEM_RELEASE); // MEM_RELEASE:释放整个内存块,将内存和地址空间都归还给操作系统
}
// 赋值后备缓冲的宽度和高度
Buffer->Width = width;
Buffer->Height = height;
Buffer->BytesPerPixel = 4;
// 设置位图信息头(BITMAPINFOHEADER)
Buffer->Info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); // 位图头大小
Buffer->Info.bmiHeader.biWidth = Buffer->Width; // 设置位图的宽度
Buffer->Info.bmiHeader.biHeight = -Buffer->Height; // 设置位图的高度(负号表示自上而下的方向)
Buffer->Info.bmiHeader.biPlanes = 1; // 设置颜色平面数,通常为 1
Buffer->Info.bmiHeader.biBitCount = 32; // 每像素的位数,这里为 32 位(即 RGBA)
Buffer->Info.bmiHeader.biCompression = BI_RGB; // 无压缩,直接使用 RGB 颜色模式
// 创建 DIBSection(设备独立位图)并返回句柄
// TODO:我们可以自己分配?
int BitmapMemorySize = (Buffer->Width * Buffer->Height) * Buffer->BytesPerPixel;
Buffer->Memory = VirtualAlloc(
0, // lpAddress:指定内存块的起始地址。
// 通常设为 NULL,由系统自动选择一个合适的地址。
BitmapMemorySize, // 要分配的内存大小,单位是字节。
MEM_COMMIT, // 分配物理内存并映射到虚拟地址。已提交的内存可以被进程实际访问和操作。
PAGE_READWRITE // 内存可读写
);
Buffer->Pitch = width * Buffer->BytesPerPixel; // 每一行的字节数
// TODO:可能会把它清除成黑色
}
// 这个函数用于将 DIBSection 绘制到窗口设备上下文
internal void Win32DisplayBufferInWindow(HDC DeviceContext, int WindowWidth, int WindowHeight,
win32_offscreen_buffer Buffer) {
// 使用 StretchDIBits 将 DIBSection 绘制到设备上下文中
StretchDIBits(DeviceContext, // 目标设备上下文(窗口或屏幕的设备上下文)
/*
X, Y, Width, Height, // 目标区域的 x, y 坐标及宽高
X, Y, Width, Height,
*/
0, 0, WindowWidth, WindowHeight, //
0, 0, Buffer.Width, Buffer.Height, //
// 源区域的 x, y 坐标及宽高(此处源区域与目标区域相同)
Buffer.Memory, // 位图内存指针,指向 DIBSection 数据
&Buffer.Info, // 位图信息,包含位图的大小、颜色等信息
DIB_RGB_COLORS, // 颜色类型,使用 RGB 颜色
SRCCOPY); // 使用 SRCCOPY 操作符进行拷贝(即源图像直接拷贝到目标区域)
}
LRESULT CALLBACK Win32MainWindowCallback(HWND hwnd, // 窗口句柄,表示消息来源的窗口
UINT Message, // 消息标识符,表示当前接收到的消息类型
WPARAM wParam, // 与消息相关的附加信息,取决于消息类型
LPARAM LParam) { // 与消息相关的附加信息,取决于消息类型
LRESULT Result = 0; // 定义一个变量来存储消息处理的结果
switch (Message) { // 根据消息类型进行不同的处理
case WM_CREATE: {
OutputDebugStringA("WM_CREATE\n");
};
case WM_SIZE: { // 窗口大小发生变化时的消息
} break;
case WM_DESTROY: { // 窗口销毁时的消息
// TODO: 处理错误,用重建窗口
GlobalRunning = false;
} break;
case WM_SYSKEYDOWN: // 系统按键按下消息,例如 Alt 键组合。
case WM_SYSKEYUP: // 系统按键释放消息。
case WM_KEYDOWN: // 普通按键按下消息。
case WM_KEYUP: { // 普通按键释放消息。
Assert(!"键盘输入通过非分发消息到达!!");
} break;
case WM_CLOSE: { // 窗口关闭时的消息
// TODO: 像用户发送消息进行处理
GlobalRunning = false;
} break;
case WM_ACTIVATEAPP: { // 应用程序激活或失去焦点时的消息
OutputDebugStringA("WM_ACTIVATEAPP\n"); // 输出调试信息,表示应用程序激活或失去焦点
} break;
case WM_PAINT: { // 处理 WM_PAINT 消息,通常在窗口需要重新绘制时触发
PAINTSTRUCT Paint; // 定义一个 PAINTSTRUCT 结构体,保存绘制的信息
// 调用 BeginPaint 开始绘制,并获取设备上下文 (HDC),同时填充 Paint 结构体
HDC DeviceContext = BeginPaint(hwnd, &Paint);
// 获取当前绘制区域的左上角坐标
win32_window_dimension Dimension = Win32GetWindowDimension(hwnd);
Win32DisplayBufferInWindow(DeviceContext, Dimension.Width, Dimension.Height,
GlobalBackbuffer);
// 调用 EndPaint 结束绘制,并释放设备上下文
EndPaint(hwnd, &Paint);
} break;
default: { // 对于不处理的消息,调用默认的窗口过程
Result = DefWindowProc(hwnd, Message, wParam, LParam);
// 调用默认窗口过程处理消息
} break;
}
return Result; // 返回处理结果
}
internal void Win32ClearBuffer(win32_sound_output *SoundOutput) {
VOID *Region1; // 第一段区域指针,用于存放锁定后的首部分缓冲区地址
DWORD Region1Size; // 第一段区域的大小(字节数)
VOID *Region2; // 第二段区域指针,用于存放锁定后的剩余部分缓冲区地址
DWORD Region2Size; // 第二段区域的大小(字节数)
if (SUCCEEDED(GlobalSecondaryBuffer->Lock(
0, // 缓冲区偏移量,指定开始锁定的字节位置
SoundOutput->SecondaryBufferSize, // 锁定的字节数,指定要锁定的区域大小
&Region1, // 输出,返回锁定区域的内存指针(第一个区域)
&Region1Size, // 输出,返回第一个锁定区域的实际字节数
&Region2, // 输出,返回第二个锁定区域的内存指针(可选,双缓冲或环形缓冲时使用)
&Region2Size, // 输出,返回第二个锁定区域的实际字节数
0 // 标志,控制锁定行为(如从光标位置锁定等)
))) {
int8 *DestSample = (int8 *)Region1; // 将第一段区域指针转换为 16
// 位整型指针,准备写入样本数据
// 循环写入样本到第一段区域
for (DWORD ByteIndex = 0; ByteIndex < Region1Size; ++ByteIndex) {
*DestSample++ = 0;
}
for (DWORD ByteIndex = 0; ByteIndex < Region2Size; ++ByteIndex) {
*DestSample++ = 0;
}
GlobalSecondaryBuffer->Unlock(Region1, Region1Size, //
Region2, Region2Size);
}
}
internal void Win32FillSoundBuffer(win32_sound_output *SoundOutput, DWORD ByteToLock,
DWORD BytesToWrite, game_sound_output_buffer *SourceBuffer) {
VOID *Region1; // 第一段区域指针,用于存放锁定后的首部分缓冲区地址
DWORD Region1Size; // 第一段区域的大小(字节数)
VOID *Region2; // 第二段区域指针,用于存放锁定后的剩余部分缓冲区地址
DWORD Region2Size; // 第二段区域的大小(字节数)
if (SUCCEEDED(GlobalSecondaryBuffer->Lock(
ByteToLock, // 缓冲区偏移量,指定开始锁定的字节位置
BytesToWrite, // 锁定的字节数,指定要锁定的区域大小
&Region1, // 输出,返回锁定区域的内存指针(第一个区域)
&Region1Size, // 输出,返回第一个锁定区域的实际字节数
&Region2, // 输出,返回第二个锁定区域的内存指针(可选,双缓冲或环形缓冲时使用)
&Region2Size, // 输出,返回第二个锁定区域的实际字节数
0 // 标志,控制锁定行为(如从光标位置锁定等)
))) {
// int16 int16 int16
// 左 右 左 右 左 右 左 右 左 右
DWORD Region1SampleCount =
Region1Size / SoundOutput->BytesPerSample; // 计算第一段区域中的样本数量
int16 *DestSample = (int16 *)Region1; // 将第一段区域指针转换为 16
// 位整型指针,准备写入样本数据
int16 *SourceSample = SourceBuffer->Samples;
// 循环写入样本到第一段区域
for (DWORD SampleIndex = 0; SampleIndex < Region1SampleCount; ++SampleIndex) {
*DestSample++ = *SourceSample++; // 左声道
*DestSample++ = *SourceSample++; // 右声道
SoundOutput->RunningSampleIndex++;
}
DWORD Region2SampleCount =
Region2Size / SoundOutput->BytesPerSample; // 计算第二段区域中的样本数量
DestSample = (int16 *)Region2; // 将第二段区域指针转换为 16
// 位整型指针,准备写入样本数据
// 循环写入样本到第二段区域
for (DWORD SampleIndex = 0; SampleIndex < Region2SampleCount; ++SampleIndex) {
// 使用相同逻辑生成方波样本数据
*DestSample++ = *SourceSample++; // 左声道
*DestSample++ = *SourceSample++; // 右声道
SoundOutput->RunningSampleIndex++;
}
// 解锁音频缓冲区,将数据提交给音频设备
GlobalSecondaryBuffer->Unlock(Region1, Region1Size, Region2, Region2Size);
}
}
// 处理并分派待处理的消息
internal void Win32ProcessPendingMessages(game_controller_input *KeyboardController) {
MSG Message; // 声明一个 MSG 结构体,用于接收消息
// 使用 PeekMessage 从消息队列中获取消息
while (PeekMessage(
&Message, // 指向一个 `MSG` 结构的指针。`PeekMessage` 将在 `lpMsg`
// 中填入符合条件的消息内容。
0, // `hWnd`
// 为`NULL`,则检查当前线程中所有窗口的消息;如果设置为特定的窗口句柄,则只检查该窗口的消息。
0, // 用于设定消息类型的范围
0, // 用于设定消息类型的范围
PM_REMOVE // 将消息从消息队列中移除,类似于 `GetMessage` 的行为。
)) {
// 根据消息的不同类型进行处理
switch (Message.message) {
case WM_QUIT: { // 退出消息
GlobalRunning = false; // 设置全局标志为 false,停止程序
} break;
// 处理键盘相关的消息
case WM_SYSKEYDOWN:
case WM_SYSKEYUP:
case WM_KEYDOWN:
case WM_KEYUP: {
uint64 VKCode = Message.wParam; // 获取虚拟键码
// 判断键盘按键在前一状态时是否已按下
bool32 WasDown = ((Message.lParam & (1 << 30)) != 0);
// 判断键盘按键当前是否被按下
bool32 IsDown = ((Message.lParam & (1 << 31)) == 0);
// 如果按键的状态发生变化(即按下/松开),则处理键盘输入
if (IsDown != WasDown) {
// 针对不同的按键处理对应操作
if (VKCode == 'W') { // 按下 'W' 键时的处理
Win32ProcessKeyboardMessage(&KeyboardController->MoveUp, IsDown);
} else if (VKCode == 'A') { // 按下 'A' 键时的处理
Win32ProcessKeyboardMessage(&KeyboardController->MoveLeft, IsDown);
} else if (VKCode == 'S') { // 按下 'S' 键时的处理
Win32ProcessKeyboardMessage(&KeyboardController->MoveDown, IsDown);
} else if (VKCode == 'D') { // 按下 'D' 键时的处理
Win32ProcessKeyboardMessage(&KeyboardController->MoveRight, IsDown);
} else if (VKCode == 'Q') { // 按下 'Q' 键时,处理左肩按钮
Win32ProcessKeyboardMessage(&KeyboardController->LeftShoulder, IsDown);
} else if (VKCode == 'E') { // 按下 'E' 键时,处理右肩按钮
Win32ProcessKeyboardMessage(&KeyboardController->RightShoulder, IsDown);
} else if (VKCode == VK_UP) { // 按下上箭头时,处理上键
Win32ProcessKeyboardMessage(&KeyboardController->ActionUp, IsDown);
} else if (VKCode == VK_DOWN) { // 按下下箭头时,处理下键
Win32ProcessKeyboardMessage(&KeyboardController->ActionDown, IsDown);
} else if (VKCode == VK_LEFT) { // 按下左箭头时,处理左键
Win32ProcessKeyboardMessage(&KeyboardController->ActionLeft, IsDown);
} else if (VKCode == VK_RIGHT) { // 按下右箭头时,处理右键
Win32ProcessKeyboardMessage(&KeyboardController->ActionRight, IsDown);
} else if (VKCode == VK_ESCAPE) { // 按下 'ESC' 键时,退出程序
GlobalRunning = false;
} else if (VKCode == VK_SPACE) {
}
#if GAME_INTERNAL
else if (VKCode == 'P') {
if (IsDown) {
// 按下P键时的处理(此处暂无特定处理)
GlobalPause = !GlobalPause;
}
}
#endif
}
// 如果按下了 Alt + F4,退出程序
bool32 AltKeyWasDown = (Message.lParam & (1 << 29));
// 判断 Alt 键是否被按下
if ((VKCode == VK_F4) && AltKeyWasDown) {
// 如果按下的是 F4 键并且 Alt 键按下,退出程序
GlobalRunning = false;
}
break;
}
// 其他消息的处理(如鼠标、窗口消息等)
default: {
TranslateMessage(&Message); // 翻译消息,如果是键盘消息需要翻译
DispatchMessage(&Message); // 分派消息,调用窗口过程处理消息
break;
}
}
}
}
// 获取当前的高精度计时器值
inline LARGE_INTEGER Win32GetWallClock(void) {
LARGE_INTEGER Result;
// 调用 Windows API QueryPerformanceCounter 来获取当前的计时器值
QueryPerformanceCounter(&Result);
return Result;
}
// 计算两个时间点之间经过的秒数
inline real32 Win32GetSecondsElapsed(LARGE_INTEGER Start, LARGE_INTEGER End) {
// 计算经过的时钟周期差,转换为秒
real32 Result = ((real32)(End.QuadPart - Start.QuadPart) / (real32)GlobalPerfCountFrequency);
return Result;
}
// 这个函数用于在屏幕缓冲区上绘制一条垂直线
// 它通过直接操作像素内存来实现绘图,因此非常高效
internal void Win32DebugDrawVertical(win32_offscreen_buffer *Buffer, //
int X, // 垂直线的X坐标
int Top, // 垂直线的起始Y坐标
int Bottom, // 垂直线的结束Y坐标
uint32 Color) { // 垂直线的颜色
// 确保Top的值不超出屏幕缓冲区的顶部边界
if (Top < 0) {
Top = 0; // 若Top小于0,则将其限制为0
}
// 确保Bottom的值不超出屏幕缓冲区的底部边界
if (Bottom > Buffer->Height) {
Bottom = Buffer->Height; // 若Bottom超出高度,则将其限制为屏幕的最大高度
}
// 确保X坐标在屏幕缓冲区的宽度范围内
if ((X >= 0) && (X < Buffer->Width)) { // 注意条件为 X < Buffer->Width,而非 <=
// 计算起始像素的位置,位于屏幕缓冲区内存的 (X, Top)
uint8 *Pixel = ((uint8 *)Buffer->Memory + // 缓冲区内存的起始地址
X * Buffer->BytesPerPixel + // 加上X列的偏移量
Top * Buffer->Pitch); // 加上Y行的偏移量
// 遍历从Top到Bottom的每个像素行,绘制垂直线
for (int Y = Top; Y < Bottom; ++Y) {
// 设置当前像素的颜色,颜色格式假设为0xAARRGGBB(32位颜色)
*(uint32 *)Pixel = Color;
// 移动指针到下一行的同一列
Pixel += Buffer->Pitch;
}
}
}
// 这个函数用于绘制声音缓冲区的标记
inline void Win32DrawSoundBufferMarker(win32_offscreen_buffer *Buffer, //
[[maybe_unused]] win32_sound_output *SoundOutput, //
real32 C, int PadX, int Top, int Bottom, DWORD Value,
uint32 Color) {
real32 XReal32 = (C * (real32)Value); // 根据声音输出值计算X坐标
int X = PadX + (int)XReal32; // 加上边距,得到最终X坐标
// 绘制指定位置的垂直线
Win32DebugDrawVertical(Buffer, X, Top, Bottom, Color);
}
#if GAME_INTERNAL
// 这个函数用于调试时在屏幕上同步显示音频缓冲区的状态和标记
internal void Win32DebugSyncDisplay(
win32_offscreen_buffer *Buffer, // 用于绘制的屏幕缓冲区
int MarkerCount, // 标记数量
win32_debug_time_marker *Markers, // 包含所有标记的数组
int CurrentMarkerIndex, // 当前正在处理的标记索引
win32_sound_output *SoundOutput, // 声音输出信息(例如缓冲区大小)
[[maybe_unused]] real32 TargetSecondsPerFrame) { // 每帧目标时间,未使用
// 定义绘制区域的边距和高度
int PadX = 16; // 左边距
int PadY = 16; // 上边距
int lineHeight = 64; // 每条显示线的高度
// 计算每个音频缓冲字节对应的像素宽度
real32 C = (real32)Buffer->Width / (real32)SoundOutput->SecondaryBufferSize;
// 遍历所有标记
for (int MarkerIndex = 0; //
MarkerIndex < MarkerCount; // 根据标记数量进行循环
++MarkerIndex) {
win32_debug_time_marker *ThisMarker = &Markers[MarkerIndex]; // 当前标记指针
// 确保每个光标值都在音频缓冲区范围内,避免无效数据或越界
Assert(ThisMarker->OutputPlayCursor < SoundOutput->SecondaryBufferSize);
Assert(ThisMarker->OutputWriteCursor < SoundOutput->SecondaryBufferSize);
Assert(ThisMarker->OutputLocation < SoundOutput->SecondaryBufferSize);
Assert(ThisMarker->OutputByteCount < SoundOutput->SecondaryBufferSize);
Assert(ThisMarker->FlipPlayCursor < SoundOutput->SecondaryBufferSize);
Assert(ThisMarker->FlipWriteCursor < SoundOutput->SecondaryBufferSize);
// 初始化当前标记的绘制区域
int Top = PadY; // 区域顶部
int Bottom = PadY + lineHeight; // 区域底部
// 定义各种状态指针的颜色
DWORD PlayColor = 0xFFFFFFFF; // 白色 - 播放光标
DWORD WriteColor = 0xFFFF0000; // 红色 - 写入光标
DWORD ExpectedFlipColor = 0xFF00FF00; // 绿色 - 预期翻转光标
DWORD PlayWindowColor = 0xFFFF00FF; // 紫色 - 播放窗口光标
// 如果当前标记是正在处理的标记,绘制额外信息
if (CurrentMarkerIndex == MarkerIndex) {
Top += lineHeight + PadY; // 向下偏移绘制区域
Bottom += lineHeight + PadY;
int FirstTop = Top; // 记录顶部位置以用于多条线绘制
// 绘制 OutputPlayCursor
Win32DrawSoundBufferMarker(Buffer, SoundOutput, C, PadX, Top, Bottom,
ThisMarker->OutputPlayCursor,
PlayColor); // 白色表示播放光标
// 绘制 OutputWriteCursor
Win32DrawSoundBufferMarker(Buffer, SoundOutput, C, PadX, Top, Bottom,
ThisMarker->OutputWriteCursor,
WriteColor); // 红色表示写入光标
Top += lineHeight + PadY; // 向下偏移
Bottom += lineHeight + PadY;
// 绘制 OutputLocation
Win32DrawSoundBufferMarker(Buffer, SoundOutput, C, PadX, Top, Bottom,
ThisMarker->OutputLocation,
PlayColor); // 白色表示当前写入位置
// 绘制 OutputLocation + OutputByteCount(写入范围结束位置)
Win32DrawSoundBufferMarker(Buffer, SoundOutput, C, PadX, Top, Bottom,
ThisMarker->OutputLocation + ThisMarker->OutputByteCount,
WriteColor); // 红色表示写入范围结束位置
Top += lineHeight + PadY; // 再次向下偏移
Bottom += lineHeight + PadY;
// 绘制 ExpectedFlipCursor
Win32DrawSoundBufferMarker(Buffer, SoundOutput, C, PadX, FirstTop, Bottom,
ThisMarker->ExpectedFlipCursor,
ExpectedFlipColor); // 绿色表示预期翻转位置
}
// 绘制 FlipPlayCursor
Win32DrawSoundBufferMarker(Buffer, SoundOutput, C, PadX, Top, Bottom,
ThisMarker->FlipPlayCursor, PlayColor); // 白色表示播放光标
// 绘制 FlipPlayCursor + 播放窗口偏移
Win32DrawSoundBufferMarker(Buffer, SoundOutput, C, PadX, Top, Bottom,
ThisMarker->FlipPlayCursor + 480 * SoundOutput->BytesPerSample,
PlayWindowColor); // 紫色表示播放窗口
// 绘制 FlipWriteCursor
Win32DrawSoundBufferMarker(Buffer, SoundOutput, C, PadX, Top, Bottom,
ThisMarker->FlipWriteCursor,
WriteColor); // 红色表示写入光标
}
}
#endif
int CALLBACK WinMain(HINSTANCE hInst, [[maybe_unused]] HINSTANCE hInstPrev, //
[[maybe_unused]] PSTR cmdline, [[maybe_unused]] int cmdshow) {
LARGE_INTEGER PerfCountFrequencyResult;
QueryPerformanceFrequency(&PerfCountFrequencyResult);
GlobalPerfCountFrequency = PerfCountFrequencyResult.QuadPart;
// NOTE: 将Windows调度器的粒度设置为1毫秒,以使我们的 `sleep()` 函数更加精细化。
UINT DesiredSchedulerMS = 1;
bool32 SleepIsGranular = (timeBeginPeriod(DesiredSchedulerMS) == TIMERR_NOERROR);
Win32LoadXInput(); // 加载 XInput 库,用于处理 Xbox 控制器输入
WNDCLASS WindowClass = {}; // 初始化窗口类结构,默认值为零
// 使用大括号初始化,所有成员都被初始化为零(0)或 nullptr
Win32ResizeDIBSection(&GlobalBackbuffer, 1280,
720); // 调整 DIB(设备独立位图)大小
// WindowClass.style:表示窗口类的样式。通常设置为一些 Windows
// 窗口样式标志(例如 CS_HREDRAW, CS_VREDRAW)。
WindowClass.style = CS_OWNDC | CS_HREDRAW | CS_VREDRAW;
// CS_HREDRAW 当窗口的宽度发生变化时,窗口会被重绘。
// CS_VREDRAW 当窗口的高度发生变化时,窗口会被重绘
// WindowClass.lpfnWndProc:指向窗口过程函数的指针,窗口过程用于处理与窗口相关的消息。
WindowClass.lpfnWndProc = Win32MainWindowCallback;
// WindowClass.hInstance:指定当前应用程序的实例句柄,Windows
// 应用程序必须有一个实例句柄。
WindowClass.hInstance = hInst;
// WindowClass.lpszClassName:指定窗口类的名称,通常用于创建窗口时注册该类。
WindowClass.lpszClassName = "gameWindowClass"; // 类名
// TODO:我们如何在 Windows 上可靠地查询这个?
// 定义显示器的刷新率(每秒钟刷新次数)
#define MonitorRefreshHz 60 // 显示器刷新率为 60Hz(每秒钟刷新 60 次)
// 定义游戏更新的频率(每秒钟更新次数),它是显示器刷新率的一半
#define GameUpdateHz ((MonitorRefreshHz) / 2) // 游戏更新频率为显示器刷新率的一半,即 30Hz
real32 TargetSecondsPerFrame = 1.0f / (real32)GameUpdateHz;
if (RegisterClass(&WindowClass)) { //
// 如果窗口类注册成功
HWND Window = CreateWindowEx(
0, // 创建窗口,使用扩展窗口风格
WindowClass.lpszClassName, // 窗口类的名称,指向已注册的窗口类
"game", // 窗口标题(窗口的名称)
WS_OVERLAPPEDWINDOW | WS_VISIBLE, // 窗口样式:重叠窗口(带有菜单、边框等)并且可见
CW_USEDEFAULT, // 窗口的初始位置:使用默认位置(X坐标)
CW_USEDEFAULT, // 窗口的初始位置:使用默认位置(Y坐标)
CW_USEDEFAULT, // 窗口的初始宽度:使用默认宽度
CW_USEDEFAULT, // 窗口的初始高度:使用默认高度
0, // 父窗口句柄(此处无父窗口,传0)
0, // 菜单句柄(此处没有菜单,传0)
hInst, // 当前应用程序的实例句柄
0 // 额外的创建参数(此处没有传递额外参数)
);
// 如果窗口创建成功,Window 将保存窗口的句柄
if (Window) { // 检查窗口句柄是否有效,若有效则进入消息循环
// 图像测试
win32_sound_output SoundOutput = {}; // 初始化声音输出结构体
// 音频测试
SoundOutput.RunningSampleIndex = 0; // 样本索引
SoundOutput.SamplesPerSecond = 48000; // 采样率:每秒采样48000次
SoundOutput.BytesPerSample = sizeof(int16) * 2; // 一个样本的大小
SoundOutput.SecondaryBufferSize =
SoundOutput.SamplesPerSecond * SoundOutput.BytesPerSample; // 缓冲区大小
// 计算音频输出的延迟样本数
// TODO:去掉 LatencySampleCount
SoundOutput.LatencySampleCount = 3 * (SoundOutput.SamplesPerSecond / GameUpdateHz);
SoundOutput.SafetyBytes =
(SoundOutput.SamplesPerSecond * SoundOutput.BytesPerSample / GameUpdateHz) / 3;
int16 *Samples =
(int16 *)VirtualAlloc(0, 48000 * 2 * sizeof(int16), MEM_RESERVE | MEM_COMMIT,
PAGE_READWRITE); //[48000 * 2];
Win32InitDSound(Window, SoundOutput.SamplesPerSecond,
SoundOutput.SecondaryBufferSize); // 初始化 DirectSound
Win32ClearBuffer(&SoundOutput);
GlobalSecondaryBuffer->Play(0, 0, DSBPLAY_LOOPING);
GlobalRunning = true;
LARGE_INTEGER LastCounter = Win32GetWallClock(); // 保留上次计数器的值
#if GAME_INTERNAL
// 设置基地址为 2
// TB(此基地址通常用于模拟大规模内存空间或防止与操作系统其他内存地址重叠)
LPVOID BaseAddress = 0;
// LPVOID BaseAddress =Terabytes((uint64)2);
#else
// 设置基地址为 0
LPVOID BaseAddress = 0;
#endif
// 初始化游戏内存结构体
game_memory GameMemory = {};
// 设置持久存储区的大小为 64 MB
GameMemory.PermanentStorageSize = Megabytes(64);
// 设置临时存储区的大小为 4 GB
GameMemory.TransientStorageSize = Gibabytes((uint64)4);
// 计算总内存需求
uint64 TotalSize = GameMemory.PermanentStorageSize + GameMemory.TransientStorageSize;
// 使用 VirtualAlloc 来分配内存,基地址是 BaseAddress,内存大小为
// TotalSize
// 分配的内存具有 'MEM_RESERVE | MEM_COMMIT' 属性,并且设置为可读写的内存
GameMemory.PermanentStorage =
(int16 *)VirtualAlloc(BaseAddress, TotalSize, MEM_RESERVE | MEM_COMMIT, //
PAGE_READWRITE);
// 设置临时存储区的起始位置在持久存储之后
GameMemory.TransientStorage =
(uint8 *)GameMemory.PermanentStorage + GameMemory.PermanentStorageSize;
// 获取当前 CPU 时钟周期数,通常用于性能测量或时间标记
int64 LastCycleCount = __rdtsc();
LARGE_INTEGER FlipWallClock = Win32GetWallClock();
// 如果有输入样本且成功分配了内存
if (Samples && GameMemory.PermanentStorage && GameMemory.TransientStorage) {
// 初始化输入结构体
game_input Input[2] = {};
// 将 NewInput 指向当前帧的输入
game_input *NewInput = &Input[0];
// 将 OldInput 指向上一帧的输入
game_input *OldInput = &Input[1];
#if GAME_INTERNAL
// 调试时间标记索引初始化为 0
int DebugTimeMarkerIndex = 0;
// 初始化一个大小为 GameUpdateHz / 2 的调试时间标记数组,
// 用于存储音频播放和写入光标的时间标记,数组大小是基于游戏更新频率的一半。
win32_debug_time_marker DebugTimeMarkers[GameUpdateHz / 2] = {0};
// TODO: 特别处理启动时的初始化逻辑
// 这里的 TODO
// 表示在游戏启动时可能需要做一些特殊的初始化操作,可能是与音频或其他系统组件相关。
DWORD AudioLatencyBytes = 0;
real32 AudioLatencySeconds = 0;
bool32 SoundIsValid = false;
#endif
while (GlobalRunning) { // 启动一个无限循环,等待和处理消息
game_controller_input *OldKeyboardController = GetController(OldInput, 0);
game_controller_input *NewKeyboardController = GetController(NewInput, 0);
// TODO: 我们不能把所有东西都置零,因为上下状态会不正确!!!
game_controller_input ZeroController = {};
NewKeyboardController->IsConnected = true;
*NewKeyboardController = ZeroController;
for (int ButtonIndex = 0;
ButtonIndex < ArrayCount(NewKeyboardController->Buttons); ++ButtonIndex) {
NewKeyboardController->Buttons[ButtonIndex].EndedDown =
OldKeyboardController->Buttons[ButtonIndex].EndedDown;
}
Win32ProcessPendingMessages(NewKeyboardController);
if (!GlobalPause) {
// TODO: 我们应该频繁的轮询吗
// 需要避免轮询已断开连接的控制器,以避免在旧版库中造成 XInput
// 帧延迟。
uint64 MaxControllerCount = XUSER_INDEX_ANY;
if (MaxControllerCount > ArrayCount(NewInput->Controllers) - 1) {
MaxControllerCount = ArrayCount(NewInput->Controllers) - 1;
}
for (DWORD ControllerIndex = 0; ControllerIndex < MaxControllerCount;
ControllerIndex++) {
DWORD OurControllerIndex = ControllerIndex + 1;
game_controller_input *OldController =
GetController(OldInput, OurControllerIndex);
game_controller_input *NewController =
GetController(NewInput, OurControllerIndex);
// 定义一个 XINPUT_STATE 结构体,用来存储控制器的状态
XINPUT_STATE ControllerState;
// 调用 XInputGetState 获取控制器的状态
if (XInputGetState(ControllerIndex, &ControllerState) ==
ERROR_SUCCESS) {
NewController->IsConnected = true;
// 如果获取控制器状态成功,提取 Gamepad 的数据
// NOTE:
// 获取方向键的按键状态
// 获取当前控制器的 Gamepad 状态
XINPUT_GAMEPAD *Pad = &ControllerState.Gamepad;
// 判断方向键的按键状态
bool Up = (Pad->wButtons & XINPUT_GAMEPAD_DPAD_UP);
bool Down = (Pad->wButtons & XINPUT_GAMEPAD_DPAD_DOWN);
bool Left = (Pad->wButtons & XINPUT_GAMEPAD_DPAD_LEFT);
bool Right = (Pad->wButtons & XINPUT_GAMEPAD_DPAD_RIGHT);
// 输出方向键状态
std::cout << " Up = " << Up << " Down = " << Down
<< " Right = " << Right << " Left = " << Left
<< std::endl;
// 将新控制器的摇杆位置设置为基于旧控制器的状态
NewController->StickAverageX = Win32ProcessXinputStickValue(
Pad->sThumbLX, //
XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE);
NewController->StickAverageY = Win32ProcessXinputStickValue(
Pad->sThumbLY, //
XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE);
// 输出新控制器的摇杆平均值
std::cout << NewController->StickAverageX
<< NewController->StickAverageY << std::endl;
if ((NewController->StickAverageX != 0.0f) ||
(NewController->StickAverageY != 0.0f)) {
// 设置新控制器为模拟模式
NewController->IsAnalog = true;
}
// 如果按下方向键,则根据方向修改摇杆的 X 或 Y 坐标值
// 检测 D-Pad(方向键)向上按钮是否被按下
if (Pad->wButtons & XINPUT_GAMEPAD_DPAD_UP) {
NewController->StickAverageY =
1.0f; // 将摇杆的 Y 轴设为最大值(向上)
NewController->IsAnalog = false; // 设置控制器为非模拟模式
}
// 检测 D-Pad(方向键)向下按钮是否被按下
if (Pad->wButtons & XINPUT_GAMEPAD_DPAD_DOWN) {
NewController->StickAverageY =
-1.0f; // 将摇杆的 Y 轴设为最小值(向下)
NewController->IsAnalog = false; // 设置控制器为非模拟模式
}
// 检测 D-Pad(方向键)向左按钮是否被按下
if (Pad->wButtons & XINPUT_GAMEPAD_DPAD_LEFT) {
NewController->StickAverageX =
1.0f; // 将摇杆的 X 轴设为最大值(向左)
NewController->IsAnalog = false; // 设置控制器为非模拟模式
}
// 检测 D-Pad(方向键)向右按钮是否被按下
if (Pad->wButtons & XINPUT_GAMEPAD_DPAD_RIGHT) {
NewController->StickAverageX =
-1.0f; // 将摇杆的 X 轴设为最小值(向右)
NewController->IsAnalog = false; // 设置控制器为非模拟模式
}
// 设置阈值用于处理摇杆偏移
real32 Threshold = 0.5f;
// 根据摇杆的 X 坐标值触发左右移动
Win32ProcessXInputDigitalButton(
(NewController->StickAverageX < -Threshold) ? 1 : 0, //
&OldController->MoveLeft, // 左移
1, //
&NewController->MoveLeft);
Win32ProcessXInputDigitalButton(
(NewController->StickAverageX > Threshold) ? 1 : 0, //
&OldController->MoveRight, // 右移
1, //
&NewController->MoveRight);
// 根据摇杆的 Y 坐标值触发上下移动
Win32ProcessXInputDigitalButton(
(NewController->StickAverageY < -Threshold) ? 1 : 0, //
&OldController->MoveDown, // 下移
1, //
&NewController->MoveDown);
Win32ProcessXInputDigitalButton(
(NewController->StickAverageY > Threshold) ? 1 : 0, //
&OldController->MoveUp, // 上移
1, //
&NewController->MoveUp);
// 处理 A 按钮的数字输入
Win32ProcessXInputDigitalButton(
Pad->wButtons, // 获取当前帧的按钮状态
&OldController->ActionDown, // 传入上一帧的 A 按钮状态
XINPUT_GAMEPAD_A, // A 按钮的位掩码,用于判断 A 按钮是否被按下
&NewController->ActionDown); // 更新新控制器中的 A 按钮状态
// 处理 B 按钮的数字输入
Win32ProcessXInputDigitalButton(
Pad->wButtons, // 获取当前帧的按钮状态
&OldController->ActionRight, // 传入上一帧的 B 按钮状态
XINPUT_GAMEPAD_B, // B 按钮的位掩码,用于判断 B 按钮是否被按下
&NewController->ActionRight); // 更新新控制器中的 B 按钮状态
// 处理 X 按钮的数字输入
Win32ProcessXInputDigitalButton(
Pad->wButtons, // 获取当前帧的按钮状态
&OldController->ActionLeft, // 传入上一帧的 X 按钮状态
XINPUT_GAMEPAD_X, // X 按钮的位掩码,用于判断 X 按钮是否被按下
&NewController->ActionLeft); // 更新新控制器中的 X 按钮状态
// 处理左肩按钮的数字输入
Win32ProcessXInputDigitalButton(
Pad->wButtons, // 获取当前帧的按钮状态
&OldController->LeftShoulder, // 传入上一帧的左肩按钮状态
XINPUT_GAMEPAD_LEFT_SHOULDER, // 左肩按钮的位掩码,用于判断左肩按钮是否被按下
&NewController->LeftShoulder); // 更新新控制器中的左肩按钮状态
// 处理右肩按钮的数字输入
Win32ProcessXInputDigitalButton(
Pad->wButtons, // 获取当前帧的按钮状态
&OldController->RightShoulder, // 传入上一帧的右肩按钮状态
XINPUT_GAMEPAD_RIGHT_SHOULDER, // 右肩按钮的位掩码,用于判断右肩按钮是否被按下
&NewController->RightShoulder); // 更新新控制器中的右肩按钮状态
// 处理 Start 按钮的状态
Win32ProcessXInputDigitalButton(
Pad->wButtons, // 获取当前控制器的按钮状态
&OldController->Start, // 传入旧控制器的状态
XINPUT_GAMEPAD_START, // 按钮类型:Start 按钮
&NewController->Start); // 更新新控制器的状态
// 处理 Back 按钮的状态
Win32ProcessXInputDigitalButton(
Pad->wButtons, // 获取当前控制器的按钮状态
&OldController->Back, // 传入旧控制器的状态
XINPUT_GAMEPAD_BACK, // 按钮类型:Back 按钮
&NewController->Back); // 更新新控制器的状态
} else {
NewKeyboardController->IsConnected = false;
}
}
/*
这是声音输出计算的工作原理:
-
我们定义一个安全值(`SafetyBytes`),表示游戏更新循环可能会变化的样本数量(假设最多2毫秒)。
- 写入音频时,我们根据播放光标的位置,预测下一个帧边界时播放光标的位置。
- 判断写入光标是否在预测目标位置之前(加上安全范围)。
- 如果是,则目标填充位置是预测的帧边界加上一个完整的帧长度。
-
如果写入光标已经超过目标位置,则假设无法完美同步音频,这种情况下会写入一帧的音频数据,并加上安全值保护样本。
- 目标是低延迟情况下实现音频同步,但在高延迟情况下保证不会出现声音中断。
*/
// 准备绘制缓冲区,传递到游戏更新和渲染函数中
game_offscreen_buffer Buffer = {};
Buffer.Memory = GlobalBackbuffer.Memory;
Buffer.Width = GlobalBackbuffer.Width;
Buffer.Height = GlobalBackbuffer.Height;
Buffer.Pitch = GlobalBackbuffer.Pitch;
// 调用游戏的更新和渲染逻辑,填充缓冲区
GameUpdateAndRender(&GameMemory, NewInput, &Buffer);
// 声音处理部分
// 声明两个变量,分别表示音频缓冲区的播放光标和写入光标
DWORD PlayCursor; // 播放光标:当前音频硬件正在播放的位置
DWORD WriteCursor; // 写入光标:硬件允许写入新音频数据的位置
// 获取当前时间点,作为当前帧结束的时间
LARGE_INTEGER AudioWallClock = Win32GetWallClock();
// 计算当前帧的时长(以毫秒为单位)
real32 FromBeginToAudioSeconds =
Win32GetSecondsElapsed(FlipWallClock, AudioWallClock);
// 获取音频缓冲区的当前播放位置和写入位置
if (GlobalSecondaryBuffer->GetCurrentPosition(&PlayCursor, &WriteCursor) ==
DS_OK) {
// 如果成功获取了音频缓冲区的当前位置
if (!SoundIsValid) {
/*
如果声音状态无效(例如程序刚启动或是首次运行音频逻辑):
- 使用写入光标的当前位置作为基准,初始化运行样本索引。
- 将写入光标的位置除以每个样本的字节数,以确定对应的样本索引。
*/
SoundOutput.RunningSampleIndex =
WriteCursor / SoundOutput.BytesPerSample;
SoundIsValid = true; // 设置声音状态为有效
}
DWORD TargetCursor = 0; // 目标写入位置
DWORD BytesToWrite = 0; // 需要写入的字节数
// 计算需要锁定的字节位置,基于当前运行的样本索引
DWORD ByteToLock =
((SoundOutput.RunningSampleIndex * SoundOutput.BytesPerSample) %
SoundOutput.SecondaryBufferSize);
// 计算每帧需要的字节数(基于采样率和帧率)
DWORD ExpectedSoundBytesPerFrame =
(SoundOutput.SamplesPerSecond * SoundOutput.BytesPerSample) /
GameUpdateHz;
// 计算距离下一个翻转(flip)操作剩余的时间,以秒为单位
real32 SecondsLeftUntilFlip =
(TargetSecondsPerFrame - FromBeginToAudioSeconds);
// 计算从当前时刻到下一个翻转操作之间,预计需要处理的音频字节数
// TargetSecondsPerFrame 表示每帧的目标时间,
// FromBeginToAudioSeconds 表示从开始到当前时刻的音频时间,
// ExpectedSoundBytesPerFrame 是每帧预期的音频字节数。
// SecondsLeftUntilFlip 是剩余的时间,通过比例计算剩余字节数。
[[maybe_unused]] DWORD ExpectedBytesUntilFlip =
(DWORD)((SecondsLeftUntilFlip / TargetSecondsPerFrame) *
(real32)ExpectedSoundBytesPerFrame);
// 预测当前帧边界时的播放光标位置
DWORD ExpectedFrameBoundaryByte =
PlayCursor + ExpectedSoundBytesPerFrame;
// 确保写入光标位置是安全的(考虑缓冲区环绕)
DWORD SafeWriteCursor = WriteCursor;
if (SafeWriteCursor < PlayCursor) {
SafeWriteCursor +=
SoundOutput
.SecondaryBufferSize; // 修正光标位置以防止缓冲区回绕
}
Assert(SafeWriteCursor >= PlayCursor);
SafeWriteCursor += SoundOutput.SafetyBytes; // 加入安全保护字节范围
// 判断音频卡的延迟是否足够低
bool32 AudioCardIsLowLatency =
(SafeWriteCursor < ExpectedFrameBoundaryByte);
if (AudioCardIsLowLatency) {
/*
如果音频卡延迟较低:
- 将目标写入光标设置为下一帧边界加上一个完整的帧长度。
*/
TargetCursor =
ExpectedFrameBoundaryByte + ExpectedSoundBytesPerFrame;
} else {
/*
如果音频卡延迟较高:
- 将目标写入光标设置为写入光标位置,加上一个帧长度和安全字节数。
*/
TargetCursor = WriteCursor + ExpectedSoundBytesPerFrame +
SoundOutput.SafetyBytes;
}
// 确保目标光标位置在环绕缓冲区内
TargetCursor = TargetCursor % SoundOutput.SecondaryBufferSize;
// 计算需要写入的字节数
if (ByteToLock > TargetCursor) {
/*
如果锁定位置在目标位置之后:
-
写入从锁定位置到缓冲区末尾的字节数,再加上从缓冲区开头到目标位置的字节数。
*/
BytesToWrite =
(SoundOutput.SecondaryBufferSize - ByteToLock) + TargetCursor;
} else {
/*
如果锁定位置在目标位置之前:
- 写入从锁定位置到目标位置之间的字节数。
*/
BytesToWrite = TargetCursor - ByteToLock;
}
// 设置音频缓冲区结构
game_sound_output_buffer SoundBuffer = {};
SoundBuffer.SamplesPerSecond =
SoundOutput.SamplesPerSecond; // 每秒采样数
SoundBuffer.SampleCount =
BytesToWrite / SoundOutput.BytesPerSample; // 需要写入的样本数
SoundBuffer.Samples = Samples; // 指向样本数据的指针
// 调用游戏逻辑获取需要填充的声音样本数据
GameGetSoundSamples(&GameMemory, &SoundBuffer);
#if GAME_INTERNAL
// DWORD TestPlayCursor;
// DWORD TestWriteCursor;
// GlobalSecondaryBuffer->GetCurrentPosition(&TestPlayCursor, //
// &TestWriteCursor);
win32_debug_time_marker *Marker =
&DebugTimeMarkers[DebugTimeMarkerIndex];
Marker->OutputPlayCursor = PlayCursor;
Marker->OutputWriteCursor = WriteCursor;
Marker->OutputByteCount = BytesToWrite;
Marker->OutputLocation = ByteToLock;
Marker->ExpectedFlipCursor = ExpectedFrameBoundaryByte;
// 定义未封装的写指针,用于计算逻辑上的写指针位置
DWORD UnwrappedWriteCursor = WriteCursor;
// 如果写指针在播放指针之前(循环缓冲区重绕的情况),
// 则将写指针逻辑上移到缓冲区的后面,以便计算差值
if (UnwrappedWriteCursor < PlayCursor) {
UnwrappedWriteCursor += SoundOutput.SecondaryBufferSize;
}
// 计算写指针和播放指针之间的字节数,
// 这表示写指针和播放指针的距离,可以用来确定可写区域大小。
// 注意:由于WriteCursor可能已经逻辑上被"展开",直接相减是安全的
AudioLatencyBytes = UnwrappedWriteCursor - PlayCursor;
AudioLatencySeconds =
((real32)AudioLatencyBytes / (real32)SoundOutput.BytesPerSample) /
(real32)SoundOutput.SamplesPerSecond;
char TextBuffer[255];
_snprintf_s(TextBuffer, sizeof(TextBuffer), //
"BTL:%u,TC%u,BTW:%u - PC:%u WC:%u DELTA:%u (%fs)\n", //
ByteToLock, //
TargetCursor, //
BytesToWrite, //
PlayCursor, //
WriteCursor, //
AudioLatencyBytes, //
AudioLatencySeconds);
OutputDebugStringA(TextBuffer);
#endif
Win32FillSoundBuffer(&SoundOutput, ByteToLock, BytesToWrite,
&SoundBuffer);
// 计算需要锁定的字节位置,基于当前样本索引和每样本字节数
}
// 获取当前时间作为工作结束的时间点
LARGE_INTEGER WorkCounter = Win32GetWallClock();
// 计算从上一帧到当前帧实际经过的时间
real32 WorkSecondsElapsed =
Win32GetSecondsElapsed(LastCounter, WorkCounter);
real32 SecondsElapsedForFrame = WorkSecondsElapsed;
// 如果当前帧消耗的时间少于目标帧时间(目标帧时间由目标帧率决定),则需要等待
if (SecondsElapsedForFrame < TargetSecondsPerFrame) {
// 如果系统的睡眠粒度已被设置为足够小(如 1 毫秒)
if (SleepIsGranular) {
// 计算还需要等待的时间,并将其转换为毫秒
DWORD SleepMS = (DWORD)(1000.0f * (TargetSecondsPerFrame -
SecondsElapsedForFrame));
// 调用 Sleep 函数让线程休眠,以节省 CPU 资源
if (SleepMS > 0) {
Sleep(SleepMS);
}
}
while (SecondsElapsedForFrame < TargetSecondsPerFrame) {
// 重新计算从上一帧到当前帧实际经过的时间
SecondsElapsedForFrame =
Win32GetSecondsElapsed(LastCounter, Win32GetWallClock());
}
} else {
// TODO:
// 丢失帧率(当前帧消耗的时间超过了目标帧时间,可能需要记录或调整以优化性能)
// TODO: 写日志(可以将帧率丢失的原因记录到日志文件中,便于后续分析)
}
// 这个地方需要渲染一下不然是黑屏a
{
HDC DeviceContext = GetDC(Window);
win32_window_dimension Dimension = Win32GetWindowDimension(Window);
RECT WindowRect;
GetClientRect(Window, &WindowRect);
#if GAME_INTERNAL
// 在调试模式下同步显示音频缓冲区状态
Win32DebugSyncDisplay(
&GlobalBackbuffer, // 传入全局后备缓冲区,用于显示
ArrayCount(DebugTimeMarkers), // 传入调试时间标记的数量
DebugTimeMarkers, // 传入调试时间标记数组
DebugTimeMarkerIndex + 1,
&SoundOutput, // 传入声音输出结构,包含当前音频信息
TargetSecondsPerFrame); // 目标每帧的秒数,用于同步帧率
#endif
// 在窗口中显示当前的缓冲区内容
Win32DisplayBufferInWindow(
DeviceContext, // 设备上下文,用于渲染到窗口
Dimension.Width, // 窗口的宽度
Dimension.Height, // 窗口的高度
GlobalBackbuffer); // 全局后备缓冲区,包含要显示的图像
ReleaseDC(Window, DeviceContext);
}
#if GAME_INTERNAL
{
if (GlobalSecondaryBuffer->GetCurrentPosition(&PlayCursor, //
&WriteCursor) //
== DS_OK) { // 如果获取位置成功
Assert(DebugTimeMarkerIndex < ArrayCount(DebugTimeMarkers));
// 在调试模式下记录音频缓冲区的状态
win32_debug_time_marker *Marker =
&DebugTimeMarkers[DebugTimeMarkerIndex];
// 记录当前的播放光标和写入光标
Marker->FlipPlayCursor = PlayCursor;
Marker->FlipWriteCursor = WriteCursor;
}
}
#endif
int64 EndCycleCount = __rdtsc();
int64 CyclesElapsed = EndCycleCount - LastCycleCount;
// 获取当前时间点,作为当前帧结束的时间
LARGE_INTEGER EndCounter = Win32GetWallClock();
// 计算当前帧的时长(以毫秒为单位)
real32 MillisecondPerFrame =
1000.0f * Win32GetSecondsElapsed(LastCounter, EndCounter);
// 计算帧率(Frames Per Second, FPS)
// GlobalPerfCountFrequency 表示计时器的频率,CyclesElapsed
// 表示当前帧的时钟周期数
real32 FPS = (real32)GlobalPerfCountFrequency / (real32)CyclesElapsed;
// 计算每帧的百万时钟周期数(MegaCycles Per Frame, MCPF)
// 即每帧的时钟周期数除以 1,000,000,用于观察 CPU 消耗
real32 MCPF = (real32)CyclesElapsed / (1000.0f * 1000.0f);
// 将计算出的帧时间、帧率和百万时钟周期数格式化为字符串
char PrintBuffer[256];
sprintf_s(PrintBuffer, "%fms/f, %ff/s, %fmc/f\n", //
MillisecondPerFrame, FPS, MCPF);
// OutputDebugString(PrintBuffer);
game_input *Temp = NewInput;
NewInput = OldInput;
OldInput = Temp;
LastCounter = EndCounter;
LastCycleCount = EndCycleCount;
#if GAME_INTERNAL
++DebugTimeMarkerIndex;
// 如果标记索引超出了数组范围,重新从头开始
if (DebugTimeMarkerIndex == ArrayCount(DebugTimeMarkers)) {
DebugTimeMarkerIndex = 0;
}
#endif
}
}
} else {
}
} else { // 如果窗口创建失败
// 这里可以处理窗口创建失败的逻辑
// 比如输出错误信息,或退出程序等
// TODO:
}
} else { // 如果窗口类注册失败
// 这里可以处理注册失败的逻辑
// 比如输出错误信息,或退出程序等
// TODO:
}
return 0;
}
额外的VLC 方便再播放对应时间知道对应的内容
介绍
在脚本文件的目录添加time_display.lua 文件
命令行打开敲入下面命令
vlc --extraintf=luaintf{intf="time_display"} -vv
time_display代表文件名
怎么调试
相关API 参考https://code.videolan.org/videolan/vlc/-/tree/master/share/lua
下面是相关的Lua脚本
time_display.lua
-- 将 "looper_custom_time" 脚本文件复制到 VideoLAN\VLC\lua\intf 文件夹中
-- 激活它:
-- vlc --extraintf=luaintf{intf="time_display"} -vv
-- -vv 方便调试 vlc菜单 Tools(工具)->Messages(消息)打开能看到调试消息
-- 读取的文件
-- 00:21:58 开始修改音频输出方法的代码
-- 00:22:48 找到最小的期望音频延迟
-- 00:27:31 使用量纲分析转换为秒
-- 00:31:34 根据音频延迟写入声音
-- 00:34:14 关于在早期阶段保持代码灵活且稍微混乱的精彩旁白
-- 00:37:00 确定音频写入的位置
-- 00:46:27 如何处理低延迟场景
-- 读到的内容
-- time = 00:21:58, message = 开始修改音频输出方法的代码
-- time = 00:22:48, message = 找到最小的期望音频延迟
-- time = 00:27:31, message = 使用量纲分析转换为秒
-- time = 00:31:34, message = 根据音频延迟写入声音
-- time = 00:34:14, message = 关于在早期阶段保持代码灵活且稍微混乱的精彩旁白
-- time = 00:37:00, message = 确定音频写入的位置
-- time = 00:46:27, message = 如何处理低延迟场景
-- -- 定义目标时间点(hh:mm:ss 格式)及对应消息
-- local targetMessages = {
-- time = 00:21:58, message = 开始修改音频输出方法的代码
-- time = 00:22:48, message = 找到最小的期望音频延迟
-- time = 00:27:31, message = 使用量纲分析转换为秒
-- time = 00:31:34, message = 根据音频延迟写入声音
-- time = 00:34:14, message = 关于在早期阶段保持代码灵活且稍微混乱的精彩旁白
-- time = 00:37:00, message = 确定音频写入的位置
-- time = 00:46:27, message = 如何处理低延迟场景
-- }
-- 单独的函数,打印 targetMessages 表的内容
function printTargetMessages(targetMessages)
Log("Loaded target messages:")
for _, target in ipairs(targetMessages) do
Log("time = " .. target.time .. ", message = " .. target.message)
end
end
-- 读取文件并解析目标时间和操作
-- 调试输出路径和文件打开情况
function loadTargetMessages(filePath)
-- 里面不能打印log 不然会有问题
local targetMessages = {}
local file = io.open(filePath, "r")
if file then
-- Log("File opened successfully")
for line in file:lines() do
local time, message = line:match("^(%d+:%d+:%d+)%s*(.*)$")
if time and message then
-- 按照时间字符串解析操作
table.insert(targetMessages, { time = time, message = message })
else
end
end
file:close()
else
end
return targetMessages
end
-- 用于记录已触发的目标时间
-- 文件路径修改成之间的路径
local targetMessages = loadTargetMessages("C:/Users/16956/Documents/game/day20/game/Q&A.md")
local triggeredTargets = {}
function Looper()
local loops = 0 -- counter of loops
-- 加载目标时间和操作
while true do
-- 调试打印读取的文件的内容 打开后台打印的数据太多
-- printTargetMessages(targetMessages)
if vlc.volume.get() == -256 then break end -- inspired by syncplay.lua; kills vlc.exe process in Task Manager
if vlc.playlist.status() == "stopped" then -- no input or stopped input
loops = loops + 1
Log(loops)
Sleep(1)
else -- playing, paused
if vlc.playlist.status() == "playing" then
-- showFinalTime()
checkTargetMessages()
Sleep(1)
elseif vlc.playlist.status() == "paused" then
showFinalTime()
Sleep(0.3)
else -- unknown status
Log("unknown")
Sleep(1)
end
end
end
end
-- 将 hh:mm:ss 格式的时间字符串转换为秒数
function parseTime(timeStr)
local hours, minutes, seconds = timeStr:match("^(%d+):(%d+):(%d+)$")
hours = tonumber(hours) or 0
minutes = tonumber(minutes) or 0
seconds = tonumber(seconds) or 0
return hours * 3600 + minutes * 60 + seconds
end
local lastTarget = nil
local lastTimePassed = nil
local currentMessage = nil
-- 检查目标时间并显示相关消息
function checkTargetMessages()
local timePassed = math.floor(getTimePassed()) -- 获取当前时间(秒)
-- Log("checkTargetMessages")
local closestTarget = nil
local closestDifference = math.huge -- 初始化一个非常大的值作为最接近的时间差
for _, target in ipairs(targetMessages) do
local targetSeconds = parseTime(target.time) * 1000 * 1000 -- 将 hh:mm:ss 转为秒
-- 确保目标时间小于或等于当前时间
if targetSeconds <= timePassed then
local difference = timePassed - targetSeconds -- 计算当前时间与目标时间的差值
-- 找到最接近的目标时间
if difference < closestDifference then
closestTarget = target
closestDifference = difference
currentMessage = closestTarget.message
end
end
end
if closestTarget == lastTarget and closestTarget ~= nil and absoluteDifference(timePassed, lastTimePassed) > 5 * 1000 * 1000 then
Log("diff" .. absoluteDifference(timePassed, lastTimePassed))
triggeredTargets[closestTarget.time] = false
end
-- 如果找到了最接近的目标时间,显示消息
if closestTarget and not triggeredTargets[closestTarget.time] then
-- 显示最接近的消息
vlc.osd.message(closestTarget.message, vlc.osd.channel_register(), "top-left", 5000000)
-- 标记该时间点已触发
triggeredTargets[closestTarget.time] = true
Log("lastTarget ~= nil before")
if lastTarget ~= nil and lastTarget ~= closestTarget then
Log("lastTarget ~= nil == true")
triggeredTargets[lastTarget.time] = false
end
lastTarget = closestTarget;
end
lastTimePassed = timePassed;
end
function absoluteDifference(x, y)
if x > y then
return x - y
else
return y - x
end
end
function Log(lm)
vlc.msg.info("[looper_intf] " .. lm)
end
function showFinalTime()
Log("showFinalTime")
local timePassed = getTimePassed()
local formattedTime = formatTime(timePassed)
Log(formattedTime)
vlc.osd.message("Current Time: " .. formattedTime, vlc.osd.channel_register(), "top-right", 1200000)
vlc.osd.message((currentMessage ~= nil) and currentMessage or "", vlc.osd.channel_register(), "top-left", 1200000)
end
-- 将相对时间(秒)转换为时:分:秒格式
function formatTime(microseconds)
local seconds = math.floor(microseconds / (1000 * 1000))
local hours = math.floor(seconds / 3600)
local minutes = math.floor((seconds % 3600) / 60)
local secs = seconds % 60
return string.format("%02d:%02d:%02d", hours, minutes, secs)
end
function getTimePassed()
local input = vlc.object.input()
if input then
local time = vlc.var.get(input, "time") or 0
Log("Raw Time Passed: " .. time)
return time -- 此处保留毫秒
else
return 0
end
end
function Sleep(st) -- seconds
vlc.misc.mwait(vlc.misc.mdate() + st * 1000000)
end
Looper()