回顾并为今天定下基调
今天的主要任务是让我们的性能分析工具正常工作,因为昨天已经完成了结构性工作。现在,剩下的工作大部分应该是调试和美化。性能分析工具现在应该已经基本可用了。昨天我们在这个方面取得了很大的进展。
接下来,我们将开始调试,并展示目前的成果。首先加载了代码库,并准备好进行编译。
运行游戏并查看当前的性能分析状态
当前,我们遇到了性能分析工具的一个问题,具体表现为数据记录不正确。在查看分析结果时,图表显示的数据完全没有意义,存在明显的错误。我们推测,问题可能出在数据收集的过程中,尤其是在使用绝对时间钟时可能出现了问题。
这种错误似乎具有周期性,可能是在记录每一帧的数据时出现了问题,导致某些操作使用的绝对时钟值有误。具体来说,在处理64位时钟时,某些地方的转换可能发生了错误,导致了不正确的结果。最终,这种错误影响了数据的准确性,甚至影响了整个图表的显示。
为了解决这个问题,首先需要回顾一下之前写的代码。因为我们并没有仔细检查代码,只是简单地写完后运行了一次,所以现在需要分析一下我们是如何记录这些信息的。重点是要检查与记录过程相关的代码,找到问题的根源。首先,我们将从分析涉及数据记录的代码开始,了解其具体实现,进一步分析可能的错误原因。
game_debug.cpp:回顾性能分析代码
在性能分析工具中,记录时间块的时机是当代码遇到打开语句时。例如,当我们进入一个时间块时,函数调用会打开一个时间块,记录开始事件。当代码执行到这一行时,会记录一个开始块(begin block)。当时间块离开作用域时,会通过一个构造函数和析构函数自动结束块,记录结束事件。
当看到开始块时,我们会检查当前线程是否已经有一个正在打开的时间块。这个检查是按线程进行的,因为可能会有多个线程同时执行,而每个线程都有自己的时间块。为了避免使用全局变量或共享状态来跟踪当前线程的时间块,每个线程都有自己的记录。通过在编译期间检查当前线程的状态,可以确保每个线程的时间块是独立的。
如果发现当前线程已有打开的时间块,我们会选择这个时间块作为父事件,继承它的时钟基准。也就是说,所有后续的时间块都会以该时钟基准为参考。时钟基准是打开时间块时的时钟值,它必须不断增加,因为每个新打开的时间块必须有一个大于上一个时间块的时钟值。如果新时间块的时钟值小于上一个时间块的时钟值,这意味着出现了严重的错误。为了避免这种情况,应该对时钟值进行断言检查,确保时钟值始终是递增的。
在理论上,时钟的递增速度非常快,大约每秒增加四十亿次,因此即使机器运行了很长时间,时钟也不会回绕。实际上,这意味着我们不太可能遇到时钟回绕的情况,因为它需要非常长的时间才会发生。
如果当前线程没有打开的时间块,这意味着我们没有根时间块,即整个帧的根时间块。此时,我们需要为这一帧创建一个根时间块来存储数据。这时候,我们会创建一个虚拟事件,初始化一个根时间块,它基本上没有任何信息,只有一个持续时间值,表示这一帧的时长。
在这个过程中,发现了一个bug,可能是根时间块的记录存在问题,导致性能数据无法正确收集。
game_debug.cpp:注意我们在写入之前使用了 EndClock 值
发现了一个bug,这个问题很简单:在计算持续时间时,使用了一个尚未写入的结束时钟值。具体来说,当当前使用的合并帧(collation frame)时,结束时钟值并没有被写入。由于在此时,根本没有写入结束时钟的操作,因此无法正确计算持续时间。这是一个必须修复的bug,因为没有结束时钟值,计算出来的持续时间肯定是错误的。
game_debug.cpp:在 CollateDebugRecords 中计算时长并正确设置 ClockBasis
问题的解决方法是将持续时间的设置推迟到另一个时机。假设当前的持续时间为零,可能还存在其他问题,但我们主要的修复是,将持续时间的计算放在"合并帧"最终确定并作为一帧添加时进行。此时,帧边界标记已经被记录,结束时钟也已经被记录,这就是进行计算的合适时机。
具体来说,当合并帧有根节点时,应该在此时计算持续时间,并更新相应的数值。此外,还有一个问题是时钟基准值(clock basis)没有被设置,这是另一个bug。时钟基准值应该设置为打开事件的时钟,但如果没有打开事件,则应该设置为帧的初始时钟值,因为我们知道这个值。
运行游戏并查看更合适的性能分析结果
问题的根本原因就是之前没有正确地设置持续时间和时钟基准值,导致了明显的bug。这两个问题的修复非常直接,经过修复后,得到的性能数据看起来更加合理了。
在修复后,虽然我们能看到更准确的分析数据,但仍然存在一些额外的功能需求。例如,虽然可以暂停分析数据的收集,但是目前并没有实现暂停功能。我们需要在某个时刻实现暂停功能,这样就能停止收集性能数据,避免不必要的数据干扰。
同时,当前我们还想实现一个功能,即能够在界面上悬停在某个分析区域上时,看到这个区域的具体内容。之前我们已经有类似的功能,但由于缺少相关的输出功能,暂时没能完全实现。因此,接下来需要重新启用之前的代码,这样就能够显示这些信息了。
game_debug.cpp:重新启用并重写悬停时的文本
目前的问题是,虽然我们有一些关于分析区域的信息,比如周期计数和网格信息等,但由于缺少必要的输出功能,之前的输出方式无法再使用。为了初步解决这个问题,计划直接将已知的信息打印出来。可以简单地打印出该区域的网格信息、持续时间以及相关的周期数据。虽然这只是一个临时解决方案,未来可能会进行一些更复杂的优化和美化,但作为起步,当前的做法已经足够实用。


运行游戏并查看悬停时的文本
当前的问题是无法一致地为每个分析区域分配不同的颜色,这使得不同的区域可能会显示相同的颜色,从而难以区分。为了改善这一点,需要找到一种方法,确保每个区域能够有独特的颜色。现在所做的做法存在问题,因为使用了指针并且通过计数值来生成颜色,但由于指针的底部位不会存储信息,这种方法是不合适的。指针底部位没有存储数据的特点使得这种做法变得不可靠。因此,需要重新考虑如何生成唯一且合理的颜色分配方式,这对于进行低级编程时尤其重要。
Blackboard:指针对齐和从数组中随机挑选
为了随机选择每个区域的颜色,采取了一种方法,即通过指针的数值来生成颜色。指针指向的内存地址唯一,因此可以通过指针的数值来确保每个区域的颜色不相同。这种方法的基本原理是将指针的数值与颜色数相除,得到一个索引,然后通过这个索引从颜色数组中选择颜色,从而实现随机颜色分配。然而,问题在于指针通常是对齐到四字节的边界,也就是说,指针的数值在低位部分的变化非常有限。例如,指针值通常会是0、4、8、12等,极少出现1、2、3等数值。这就导致了颜色分配时,实际可用的颜色非常少,最多只能用到三种颜色。
此外,颜色数组的大小恰好是四的倍数,这意味着每个颜色只能分配给一小部分区域,而不会有更多的颜色可供选择。为了更均匀和多样化的颜色分配,理想情况下应该使用一个质数大小的颜色数组,这样可以避免出现颜色重复和分配不均的情况。
game_debug.cpp:让 Colors 数组不是四的倍数
为了避免刚才提到的问题,我决定采取一种简单且有趣的方式来解决它。我决定将颜色数组的大小设置为非 4 的倍数,以便让颜色分配有更多的变频效果。实际上,如果我们能选择一个质数作为数组的大小,那就更好了。
目前数组的大小是 12,我考虑将其调整为 11,这样它就是质数了。因为质数的特性,可以避免指针值由于内存对齐问题而集中分配相同的颜色,这样可以让每个颜色在分配时更加均匀。为了避免使用到最后一个颜色,我决定去掉这个最后的颜色值,保持 11 个颜色的数组。
这样做可以有效地让颜色分配更加均匀,避免了之前由于对齐问题导致的颜色重复使用的情况。
假设我们有一个颜色数组,最初定义为:
c
int colorArray[12] = {color0, color1, color2, color3, color4, color5, color6, color7, color8, color9, color10, color11};
在此情况下,数组大小为 12,它是 4 的倍数。假设我们的指针地址(例如,内存中的地址)在内存对齐后是 4 字节对齐的,那么我们只会看到其中的某些颜色。例如:
- 地址 0x1000 对应 color0
- 地址 0x1004 对应 color4
- 地址 0x1008 对应 color8
由于内存的对齐特性,指针地址在 4 字节的边界上,只会影响到颜色数组中的部分颜色,导致有些颜色会被重复使用。
为了避免这种问题,可以将颜色数组的大小改为 11,选择一个质数大小,这样可以避免指针地址的对齐影响颜色的选择。我们改成:
c
int colorArray[11] = {color0, color1, color2, color3, color4, color5, color6, color7, color8, color9, color10};
这样,当指针通过每个元素时,颜色分配会更加均匀,不会因为对齐问题而集中选择某些特定颜色。通过这种方式,可以获得更好的颜色分配,避免颜色重复使用的问题。

运行游戏并查看性能分析器中的不同区域
现在,通过调整颜色的分配方式,能够清晰地看到不同的区域了。我们成功地使用了比之前更多的颜色值,这使得配置更加稳定,帮助我们更清楚地观察到性能分析结果。从现在的视角来看,已经得到了更为直观的信息。
通过这些信息,可以大致看到每一帧的时间是如何划分的。其中,最大的一段亮眼的荧光条显示的是帧显示时间,也就是等待垂直刷新时的时间。此时,代码中正进行着大量工作,这些工作就在图表的某个区域中显示出来。
接下来,输入处理的部分代码被分配到了一部分区域。虽然我们对这段时间的长短有些疑问,但这可能是正确的,值得继续观察和验证。
进一步查看时,可以看到实际的游戏代码的执行时间。然后,调试聚合的时间显示得非常明显,因为调试过程中需要处理大量的信息,这部分显得尤其昂贵。
此时,我们可以通过继续分析和观察,进一步验证这些数据,发现哪些部分的性能开销过大,并找出可能存在的优化点。
game_render_group.cpp:向 object_transform 中添加 SortBias,使 SortKey 自动考虑它
在继续之前,需要先处理一个问题,那就是要确保我们的性能分析显示层位于最前面。之前处理精灵时,已经使用了层级排序的方式来确保调试代码能够显示在最前面,现在同样需要在性能分析显示中应用这一层级偏移(z-bias)。如果不处理好这一点,性能分析的数据就无法正确显示。
回想一下,渲染是从前到后排序的。如果我们不确保调试信息的渲染顺序将其放到最前面,就会遇到这个问题。为了解决这个问题,需要在渲染过程中正确设置调试信息的显示顺序,确保其位于最前。
接下来,需要找到渲染代码中的偏移设置。具体来说,是要将层级偏移(sort bias)与物体变换(object transform)相关联。物体变换已经在渲染过程中使用了,将偏移的概念放入物体变换中会更合适,这样就不需要在每次调用渲染时都传递偏移参数,只需要使用物体的变换信息即可。
这将使代码更加简洁,确保调试信息始终位于最前面,方便查看性能分析结果。


我们在 object_transform
中添加了一个新的字段:sort_bias
,用于指定图形对象在渲染排序中的优先级。这样做的主要目的,是为了解决当前存在的调试信息无法正确渲染在最前端的问题。
目前的渲染系统是基于从前到后的顺序进行绘制的,即所谓的 z 轴排序。如果不显式地设置调试信息的排序偏移,它们很容易被其他图形遮挡,导致分析图形不可见。因此,为了让调试图层始终处于前方,我们将排序偏移值整合到 transform(变换)信息中。
为了实现这一目标,我们在计算 render 实体的基础信息时做了以下改动:
- 在
object_transform
中添加了sort_bias
字段,这个偏移值用于控制当前对象在渲染列表中的排序位置。 - 在调用
get_render_entity_basis_p()
函数时,通过传入object_transform
,我们可以从中获取到当前对象的sort_bias
。 - 在这个函数中我们原本的做法是基于 z 值和 y 值来计算排序键(sort key),通过减去 y 值来让物体有个基于垂直方向的深度变化,从而模拟一个 2.5D 的效果。
- 我们现在在计算排序键之前,先用
sort_bias
作为初始值,然后再在这个基础上附加 z 和 y 的信息,这样我们可以灵活地控制对象的渲染顺序。 - 这样处理之后,无需在各个调用点都显式传递
sort_bias
,只要设置好 transform 结构,就能自动完成偏移应用,渲染逻辑更加清晰统一。
总的来说,这种处理方式让渲染排序更加可控,也避免了原本那种"穿透式传参"的繁琐过程,从结构上简化了渲染系统,并确保调试图层在性能分析过程中始终可见。
game_debug.cpp:在 DEBUGTextOp 中引入 Transforms
我们将之前用于处理 sort_bias
的零散逻辑,整合进了 transform
中,并开始定义几个特定用途的标准 transform,用来区分调试信息的渲染层级。原本在渲染过程中使用 sort_bias
的方式比较零散、不规范,因此我们希望通过结构化处理,把这种偏移信息规范地附加到 transform 对象上,以便统一使用。
我们当前做了以下调整和规划:
-
将 Sort Bias 纳入 Transform:
之前
sort_bias
是以某种"临时参数"的形式出现,现在我们将其明确为transform
结构中的一个字段,这样每个渲染实体都能在自身定义中明确其渲染层级偏移。 -
定义两个特殊 Transform:
text_transform
:用于调试文字的渲染。shadow_transform
:用于调试阴影或辅助图形的渲染。
这两个 transform 被赋予了非常大的sort_bias
数值,确保它们总是排在渲染顺序的最前面,从而始终可见,不会被其它图形遮挡。
-
未来集成到 Debug 系统:
目前这两个 transform 是临时写死在代码中的,但计划将它们作为全局常量或配置项,正式集成进调试系统(debug state)中,便于全局调用和维护。
-
分层渲染机制构建:
通过定义这些具有明确用途的 transform,我们可以将不同类型的调试信息(比如文本、阴影、轮廓、背景块等)安排在不同的"视觉层"中。只需在渲染调用时选择合适的 transform,就能自动实现合理的前后遮挡关系,无需手动控制顺序。
-
结果与预期:
这样一来,所有调试信息都能始终被正确渲染在最前方,便于观察。渲染系统结构也更加清晰、可维护。
总结来说,这种做法通过结构化设计和合理分层,把原本混乱的调试信息渲染逻辑规范化,为后续调试系统扩展和可视化调优打下了坚实基础。
运行游戏并注意到我们的问题还没有解决
我们目前的问题还未解决,是因为还没有将相关设置应用到另一个关键的渲染例程中。虽然我们在一处进行了调整,但在切换到另一部分代码时,效果依然不正确。因此接下来的目标是将这些 transform(变换信息)彻底变成调试系统的"一级公民"(first-class citizens),以便在所有调试例程中统一使用。
为此我们进行了如下处理:
-
将标准 transform 整合进调试系统:
把之前用于文字显示、阴影、调试标识等内容的 transform,不再每次手动设置,而是将它们直接内嵌到调试系统状态(debug state)中。这样这些 transform 成为常驻资源,可以在任何调试绘制过程中直接使用,无需重复定义或传参。
-
提升系统一致性和易用性:
把这些 transform 定义为调试系统的一部分,意味着任何时候进行调试图形绘制,都能一致性地使用这些预设的渲染层级。这解决了过去手动设置带来的不便,也避免了重复代码。
-
目标是默认可用:
一旦被集成,它们将在整个渲染生命周期中保持可用状态,任何调试渲染函数都能立即使用,无需额外配置。
-
后续扩展空间:
一旦这些标准 transform 成为调试系统的内建元素,就可以更方便地添加更多类别的调试层(如背景层、网格层、标签层等),为调试信息提供更清晰的视觉分离。
总之,此举使调试渲染变得更系统化、更自动化,也为后续功能扩展提供了结构上的支持。



game_debug.h:向 debug_state 中添加 Transforms
我们将一些常用的 transform 直接放入了调试状态(debug state)中,使它们始终可用,无需在使用前手动设置。比如,文字用的 transform、阴影用的 transform 现在都作为系统内建的一部分了。
此外,我们意识到可能还需要更多层次的 transform,比如专用于背景的 backing transform。这样我们可以按需选择不同的层级,用于渲染不同的调试元素,从而构建出一个更清晰的分层调试视图。
这些 transform 的整合目的是建立一个可复用、清晰分层的系统,我们可以为每种调试信息指定一个独立的显示层,比如:
- 文字层(Text)
- 阴影层(Shadow)
- 背景层(Backing)
未来可能还会添加更多,比如网格层、UI 层等等。
在进行这些调整的同时,也注意到自己的编辑环境需要优化。目前使用的是 FourCoder 编辑器,启用了模态编辑模式(类似 Vim 的操作方式),但因为一开始设置粗糙,加上一些 bug,导致键位绑定和操作效率还没完全调整好。现在因为已经切换到用 FourCoder 做主要开发环境,所以也希望在最近抽出时间对它进行全面优化,调整快捷键,使自己的开发效率能够回到从前非模态操作时的水平。
这是一个很自然的过渡过程,从熟悉的传统编辑方式转向模态编辑,需要一段适应期,同时也要求我们投入时间去配置和调教工具,才能真正释放出模态编辑带来的优势。希望能尽快完成这些设置,让开发流程更流畅、高效。
game_debug.cpp:在 DEBUGStart 中初始化 Transforms
我们开始对调试系统中用于图形层级显示的 transform 进行初始化设置。目标是为调试渲染(尤其是性能分析的可视化)指定一套专门的 transform,使调试信息总是处于最上层,不会被其他游戏内容遮挡。
为此,我们将这些 transform 加入初始化流程中,并分配给它们一些特定的、足够大的值,确保它们的排序优先级始终高于游戏中的其他实体。具体来说,我们设置了一个 tracking transform,用于显示性能分析的图形,它的排序值必须比游戏中的任何其他 transform 都要大,这样才能保证它始终显示在最前面。
虽然 transform 的排序值分配没有复杂算法,只需要满足比游戏中可能使用的值更大即可,但这是一个关键的系统设计点,能显著提升调试信息的可视性和稳定性。
最后,我们将这些新的 transform 应用于性能分析的绘制代码中,特别是 draw_profile
相关的地方。通过替换原有的 transform,确保调试图形在渲染中使用我们刚刚设定的更高优先级,从而实现信息图层的正确显示顺序。这样在查看性能分析时,就不会被遮挡,也更容易阅读和理解。
game_debug.cpp:将 BackingTransform 传递给 DrawProfileIn 中的 PushRect 调用
我们接下来要做的工作是让我们绘制的所有矩形都使用新的 transform,以便统一地控制其在渲染中的显示顺序。好在我们之前设计得比较合理,push_rect
接口本身就已经支持传入 transform,这使得改动比较顺利。
我们只需将调试状态中的 backing_transform
传入这些绘制调用中即可。这样一来,所有绘制的调试矩形(例如性能分析可视化框)都会自动使用这个 transform,从而在渲染中保持统一的层级排序。这种结构清晰而易于维护。
我们也检查了一些现有代码,发现还有其他绘制调用,比如绘制角标等界面元素的地方,也需要使用正确的 transform。这些元素也必须被正确地显示在前方,所以我们将这些绘制调用统一修改为使用 debug_state.backing_transform
。
例如,原先绘制角落或者高亮的逻辑,现在也需要加上对应的 transform 参数。为简化修改过程,我们采用了较直接的方式,把传入的 transform 设为 debug_state.backing_transform
,这样就能确保所有调试相关图形都能正确地显示在其他游戏内容之上。
整体上,我们通过系统地接入统一的 transform,确保了所有调试绘图的图层一致性、渲染可视性,并为后续的调试可视化打下了清晰可控的基础。
运行游戏并查看我们的性能分析器
现在我们查看最新的性能分析视图,观察刚才的调整是否生效。启动调试后,按下空格键,确实可以看到性能视图正确显示了,这非常理想。
在当前视图中,不同颜色的区块分别代表游戏主循环中各个阶段的耗时情况:
- 某一段明显的时间块表示游戏逻辑更新阶段所耗费的时间。
- 另一段表示输入处理过程的耗时。
- 还有一段时间花费在一些可能与执行环境有关的过程(如可执行文件的启动、指令调度等),目前无法具体查看。
- 接下来是调试信息的整理时间,这一段在完整游戏运行时会显著增加,因为调试系统需要处理的实体和数据更多。
- 最后一段颜色非常鲜艳、明显,是帧显示阶段的等待时间,也就是等待垂直同步(VSync)或帧缓冲交换的过程。
需要注意的是,这一阶段的时间并不纯粹代表显卡正在忙于绘制内容,其中一部分也可能只是等待垂直刷新间隔(v-blank)的空转时间。由于缺乏硬件级的深度信息,我们无法确定此段时间中,显卡到底是在执行任务还是处于空等状态。
此外,图中还显示出这一帧时间线中的不同阶段可能存在重叠,尤其是帧显示阶段,它与 CPU 逻辑处理之间不是严格顺序发生,而是存在一定程度的并行和交错。
通过这样的分析,我们已经能够较为直观地理解一帧时间中各个系统模块的工作分布和瓶颈位置,这为后续的性能优化提供了有力的数据支持。
Blackboard:性能分析器显示的内容可能发生在 SwapBuffers 调用之后
我们进一步深入分析帧时间的表现和机制,尝试理解当前帧等待和渲染背后的本质运作。
每一帧的时间线可以被想象成多个不同阶段的堆叠过程。例如:
- 一段是游戏逻辑的处理;
- 一段是输入的处理;
- 一段是调试信息的整理;
- 还有一段是帧显示阶段(也就是等待垂直同步的部分)。
这些部分按顺序组成一帧的完整生命周期。帧显示阶段通常是图形系统在等待垂直同步(v-blank),以确保画面更新不会发生撕裂。但重要的是,这一过程并不像看起来那么线性或单一。
现代硬件上的帧渲染流程是异步的,CPU 和 GPU 并不是一对一、逐帧地完全同步协作。可以想象如下的帧流水线:
- 当前显示的是第 4 帧;
- 我们在处理的是第 5 帧;
- 图形卡可能正准备绘制第 6 帧;
- 而我们调用
SwapBuffers
或类似函数时,其实并不是立刻渲染或等待当前帧,而是在触发一次交换的过程,并且启动了后续帧的准备。
换句话说:
- 我们等待的其实可能是前一帧的垂直同步信号;
- 当前帧在
SwapBuffers
时可能已经完成绘制,等待显示; - 同时新一帧的绘制准备也可能已经开始。
这种行为是典型的帧管线重叠优化,能够提高 GPU 和 CPU 的资源利用率。虽然会引入一帧的输入延迟(Latency),但这比降低帧率带来的响应滞后更具优势。
例如:
- 在 60FPS 下,每帧有 1/60 秒的时间可以进行工作;
- 如果允许一帧延迟,那么 CPU 和 GPU 都可以各自独立地利用这段时间;
- 相比于锁步运行或降至 30FPS,这种方式总体响应更好、画面更流畅。
需要注意的是,这种行为在现代操作系统和驱动中非常常见,尤其是当启用了垂直同步时,硬件和驱动往往会插入这类缓冲策略来保障帧率稳定性和系统流畅性。
总结来说:
- 帧显示阶段不是简单的"现在开始绘制然后等待显示",而是复杂的异步重叠;
- CPU 和 GPU 可能各自领先或滞后一帧;
- 这样安排能更好地利用资源,但会增加一帧的延迟;
- 了解这种机制,有助于分析一些帧率抖动、输入延迟或性能瓶颈问题;
- 虽然游戏中通常无需过于深入理解细节,但对系统表现有个基本认知,会对调试优化非常有帮助。
指出性能分析的有用性
我们在这里强调的是性能分析(profiling)系统的价值,也解释了为什么需要构建一个调试系统,并把它当成开发流程中不可或缺的一部分。
到目前为止,我们所做的只是展示了顶层的调试信息。我们甚至还没有实现点击进入某个区域、查看其内部子项的功能。只是简单地渲染出了最外层的分析视图。即使是这样基础的功能,也已经让我们发现了有意义的性能数据。
例如:
- 当前能看到一个很显眼的区域是"输入处理";
- 这个阶段消耗了大约两百万个周期(CPU cycles);
- 这个数字非常大,引起了我们的关注。
回顾一下,我们之前讨论过 CPU 周期的含义:
- 一次缓存未命中(cache miss)可能花费大约 100~200 个周期;
- 这是非常昂贵的操作,我们一直强调要避免;
- 所以两百万个周期的耗时,就显得非常不合理。
这让我们马上就产生疑问:输入处理怎么可能消耗如此大的时间?
进一步分析:
- 当前程序正在处理大约两千个调试事件;
- 更新了成百上千个实体;
- 所有这些逻辑都是调试系统代码;
- 而且尚未做过任何优化,完全是初始实现状态;
- 然而,输入处理的耗时依然占据了整体时间中一个明显比例,这在视觉化图表中非常清楚地反映了出来。
这意味着我们需要深入排查这个区域,因为这很可能是性能瓶颈之一。这个结果让我们立刻警觉:"这不合理"。
在有经验的程序员直觉驱动下,我们大概猜到了可能的原因。因为做程序久了,就会逐渐建立起对某些现象背后原因的直觉。有些人可能在过去我们某次讲解中听到过这个线索,但因为当时只是顺带提到,可能已经忘了。
如果是使用 Windows 平台开发的老手,可能一看这个现象就已经知道我们在想什么了------我们可能已经猜测到了真正的问题所在,尽管还未验证。
因此这部分内容的核心意义是:
- 性能可视化是发现瓶颈的关键工具;
- 即使调试系统还没完全做完,基础框架就已经能暴露出一些重要线索;
- 通过周期数与经验对比,可以判断某些行为是否异常;
- 哪怕只是顶层信息,也能引导我们深入挖掘问题源头;
- 构建一个强大调试系统的重要性由此体现出来。
win32_game.cpp:调查输入处理发生了什么
我们现在开始着手排查性能问题,并不打算一开始就假设自己知道该注释掉哪部分代码,而是希望借助已有的工具,逐步定位问题。
首先,我们关注的是"输入处理"部分的性能问题。即使不深入思考具体原因,也可以采用逐步缩小范围的策略,使用性能分析(profiling)工具来定位问题。
具体操作如下:
- 我们清楚所有造成性能消耗的代码,一定在某个
begin
和end
的时间测量块之间; - 所以只要在该区域内部继续划分子模块的时间块,就能快速排除哪些代码不是问题源;
- 即使完全不知道是哪部分代码的问题,也可以用这种"分而治之"的方法来调查;
- 接下来我们在不同的逻辑段落中嵌套了多个性能分析块(profiling block),给它们起名,以便后续观察数据时可以知道哪部分耗时最多。
划分的逻辑块包括:
-
控制器清除逻辑(controller clearing)
- 这段代码是我们自己的,独立执行;
-
Win32 消息处理逻辑(win32 message processing)
-
我们将其进一步拆分为:
- 键盘消息处理;
- 鼠标同步处理;
- 其他键盘相关消息;
- Xbox 控制器输入处理;
- 新增的键盘控制器。
-
其中一些模块被显式包裹在测量块中,也有一些没有嵌套,只是放置在结构上稍微抬高一点的位置。但整体目标是相同的:通过这些测量块,我们就能在之后运行时看到每一小段逻辑到底花了多少时间。
特别说明:
- 某些我们主观上不认为会出问题的部分(例如最后那个新键盘控制器逻辑),我们暂时不加测量块;
- 但是如果后续观察到它也包含在外层大耗时块中,那说明我们判断可能有误,还得继续深入;
- 这种方式允许我们以很小的代价快速排查、验证假设,逐步定位真实的性能瓶颈。
总结:
- 我们在没有完全确认问题源的前提下,采用了非常实用的"划分测量区域 + 观察"的方法;
- 通过在输入处理函数内嵌套多段 profiling 代码块,我们可以精确地知道哪个步骤耗时最多;
- 这是一种高效、可重复的定位性能问题的方法;
- 同时也体现了调试工具(profiling blocks)的重要性,它能够让我们在复杂系统中迅速缩小问题范围。
运行游戏并注意到我们需要能够缩小到某个性能区域
接下来我们希望实现的新功能,是在已有调试系统的基础上,进一步支持深入查看某个性能区块的内部结构。
当前的情况是:
虽然我们已经在代码中添加了多个计时块(profiling blocks),这些计时块在运行时确实会被统计,但界面上只显示了最顶层的区块 。比如我们可以看到"输入处理"这整个大块,但无法看到它内部更细致的子结构。
为了解决这个问题,我们准备做以下改进:
-
实现一种机制,可以选中一个较大的性能区块,然后**"展开"查看其内部被嵌套的子区块**;
-
例如,如果我们发现"输入处理"这块的性能开销很高,我们就可以点击它,然后查看它里面具体的组成部分,比如"清除控制器""鼠标同步""键盘消息处理"等分别花了多少时间;
-
这样做的好处在于:
- 一旦我们发现某个区域很耗时,我们就能马上细化观测;
- 不需要通过"猜"或者逐步注释代码的方式排查问题;
- 可以很快定位到底是哪一段代码产生了性能瓶颈;
- 整个调试流程变得更加直观、数据化、结构清晰。
总之,这是在调试系统中非常重要的一步改进:
从只能看到总览,进化到可以层层深入查看细节。这不仅提升了我们定位问题的效率,也让整个性能分析工具更加实用和智能化。我们希望通过这次优化,让调试体验更加流畅,为后续的系统优化打下坚实基础。
win32_game.cpp:注释掉"输入处理"部分
现在我们也可以采用一种简单方式来查看内部结构,那就是直接去掉外层的计时块,这样所有子计时块就会直接出现在顶层视图中。
比如说,如果我们不包裹"输入处理"这个大块,而是单独显示内部的"控制器清除""鼠标同步""键盘消息处理"等,那么这些内部步骤就都会显示在主视图中,能直接看到每个步骤的耗时。
这种做法确实可以临时让我们看到具体细节,但也暴露出一个问题:
- 如果我们所有的子块都在顶层展示,整体视图会变得非常混乱;
- 一旦嵌套变多,根本没办法快速分组和分析;
- 缺少了结构性,调试视图就会失控。
这也正是我们不想把所有内容都放在顶层的原因。
我们真正需要的是一种支持嵌套结构展开与收起的调试机制。这样既能保持整体逻辑清晰,又能在需要时快速下钻查看内部细节。每个顶层块相当于一个容器,当我们发现其中有问题时,只需点击它就能展开,看到其中每一步的具体耗时。
因此,当前的临时方案虽然能起到展示作用,但从长远看,还是要回归到结构化的设计思路,实现按层级展开性能数据的功能。这将让调试流程更高效、逻辑更清晰,也更适合长期维护与优化。

运行游戏并发现 XBox 控制器轮询需要 200 万周期
现在来看一下到底是哪部分代码真正消耗了时间。
果不其然,猜测是正确的:轮询 Xbox 控制器 的操作竟然消耗了约 两百万个 CPU 周期。这是非常高的数值。
这个现象引发了疑问:为什么只是检测 Xbox 控制器是否存在,就要花掉两百万个周期?
从直觉来看,这种轮询操作本应非常轻量,只需要快速检查一下设备状态就好。可是现实却是,这段驱动层的代码执行极其缓慢。更令人疑惑的是,这种查询的核心目标------"控制器是否连接"------本就是个经常不确定的事情:有时控制器在、有时不在,而这本应是驱动设计时重点考虑的部分。
然而,驱动的实现方式却完全不像是对效率有所关注,反而给人一种完全不清楚硬件状态管理流程的感觉。如果有谁真正了解系统底层设备状态轮询机制的话,大概根本不会以这种方式写代码。
总结来看:
- Xbox 控制器的轮询操作存在明显的性能瓶颈;
- 实现方式可能非常低效;
- 造成了大量不必要的周期浪费;
- 这可能并非程序本身的性能问题,而是调用了一个系统层面本身就效率极差的接口。
这种问题也说明了为什么在调试过程中,构建精确的性能分析工具非常重要 。只有准确定位耗时,我们才能判断性能瓶颈是来自自身逻辑,还是底层外部库的实现问题。

我的20万
描述并考虑如何解决 XInputGetState 在轮询 XBox 控制器时的已知 bug
从分析结果可以非常直观地看出,整个输入处理阶段的大部分时间几乎全部都被 Xbox 控制器的轮询操作耗尽了 。简直像"桶里捞鱼"一样容易定位问题。每帧我们都在消耗 两百万个 CPU 周期,但实际并没有获取到任何 Xbox 控制器的输入。
更严重的是,根本就没有插着任何 Xbox 控制器。
这个问题实际上是一个早就广为人知的系统层级 Bug。具体表现是这样的:
- 当调用
XInputGetState
并且对应编号的控制器确实存在时 ,这个调用是非常快速的,几乎不耗费任何时间; - 但如果调用的是一个并不存在控制器的编号 ,这个函数就会严重阻塞,消耗大约 50 万个 CPU 周期(两百万除以轮询四个控制器);
- 即使只是为了确认"控制器不存在",这个函数也会让 CPU 空转极长时间。
这显然是非常严重的效率问题。调用本应是轻量的存在判断操作,结果却带来了巨大的性能开销。
面对这个 Bug,实际的操作系统或底层库并没有修复。虽然这个问题早在 2008 年就被报告过,但至今依然存在。因此,只能在应用层做规避处理。
我们可以采取一些权宜之计来降低性能影响:
临时优化方案:
-
仅轮询已知存在的控制器:对于真正插着的控制器,每帧都正常轮询;
-
未插入的控制器采用轮询间隔策略:比如一共有 3 个控制器未连接,那么可以:
- 第一帧轮询编号为 1 的;
- 第二帧轮询编号为 2 的;
- 第三帧轮询编号为 3 的;
- 然后循环回第一帧;
- 这样每帧最多只损失约 50 万个周期,而不是 200 万。
存在的问题:
- 无法完全避免性能浪费:即使优化,也还是每帧损失几十万周期;
- 带来新的不稳定性:某些帧性能会突然下降,形成"性能尖刺";
- 延迟感知控制器插入:在控制器插入时,检测可能会有帧级延迟。
更理想的解决方式:
为了从根本上解决问题,需要使用系统级的设备状态通知机制,比如:
- 注册设备变更通知回调;
- 监听 USB/HID 输入设备变动;
- 当真正检测到控制器插入时,再开始对其调用
XInputGetState
。
这样就避免了持续调用阻塞的函数,也杜绝了在控制器未插入时的高性能浪费。
总结:
- 问题出在
XInputGetState
调用; - 控制器未连接时调用它会严重浪费 CPU;
- 可以用间隔轮询缓解,但仍不是彻底方案;
- 最终需要通过设备变更事件检测来规避这类调用;
- 这个问题不是开发逻辑错误,而是系统 API 设计/实现层面的严重性能缺陷。
这也再次强调了性能分析工具的重要性:没有精细的时间分析,根本不可能定位这种"看似正常的 API 调用"其实暗藏巨大性能黑洞的问题。
win32_game.cpp:引入 XBoxControllerPresent,假设它们在启动时都已连接,并在意识到它们没有时停止轮询
当前我们已经准备好进入优化下一步了。接下来要做的是构建一个基础框架,为将来更彻底解决 Xbox 控制器轮询问题打下基础 。现在我们先做一个临时性的绕过方案,目的是避免每帧调用那些会造成 CPU 阻塞的控制器轮询逻辑。
当前优化步骤:
-
引入一个持久状态数组,放在主循环开始之前的位置:
- 数组名如
xbox_controller_present[]
; - 初始化时默认假设所有控制器都未连接 ,除了第一个控制器假设是连接的;
- 未来计划让这个数组实时更新控制器的连接状态。
- 数组名如
-
在主循环里判断控制器是否"存在"再决定是否调用
XInputGetState
:- 只有在
xbox_controller_present[i] == true
的时候,才会对对应编号的控制器执行输入轮询; - 如果某个控制器在调用时发现并未连接,就将对应标志设置为
false
,从此不再轮询它; - 这样可以有效避免大量无意义的调用导致 CPU 阻塞。
- 只有在
-
一次性初始化所有控制器状态为"已连接":
- 在进入主循环前,通过循环将
xbox_controller_present
全部设置为true
; - 实际这是一种简化方案,在逻辑上假设控制器在程序启动时都插好了;
- 但这也意味着:如果控制器是启动后插入的,就不会被识别和轮询到。
- 在进入主循环前,通过循环将
存在的问题与后续工作:
-
当前方案不能动态检测控制器插入/拔出:
- 一旦某个控制器在启动后才插入,将永远不会被识别为"可用";
- 这并不是理想做法,仅仅是权宜之计;
-
必须引入控制器连接状态监控机制:
- 后续需要通过某种"设备变更通知"或系统消息监听机制,实现动态检测 Xbox 控制器的连接与断开;
- 例如注册系统消息,当插入或拔出设备时接收到事件通知;
- 一旦检测到变化,就更新
xbox_controller_present
状态,并允许再次尝试读取。
总结:
- 通过引入一个数组来跳过不必要的轮询操作,显著减少了 CPU 浪费;
- 当前代码在程序启动后,只允许识别已插入的控制器;
- 一旦发现某个控制器未插入,将永久停止对它的轮询;
- 这是临时措施,后续必须配合系统级设备监控机制来动态更新状态;
- 现在可以通过运行程序并查看性能分析,观察优化措施带来的性能提升情况。
这个方案虽然不完整,但可以立刻获得明显的性能改进,并为将来的更完善机制做准备。

运行游戏并看到不再有控制器轮询的问题
我们现在来看,经过前面对控制器轮询逻辑的优化之后,性能瓶颈已经被彻底移除。程序不再陷入那种每帧无意义地调用控制器输入获取函数所造成的"卡顿"------因为我们设置了判断机制,如果控制器不存在,就不会再去轮询它。
当前状态的好处:
- 程序运行效率大幅提升;
- 控制器未连接的情况下不再消耗高达 两百万 CPU 周期 去"确认"这个事实;
- 整体输入处理流程干净、顺畅,不再出现令人费解的性能波动;
- 这正是我们想要达到的初步目标。
仍需注意的事项:
我们在代码中已经明确写下了"待办项 ",提醒自己未来要补全这部分功能,具体包括:
- 要在平台层中查阅资料,研究如何在操作系统层面接收设备连接变化的通知;
- 目标是让程序能动态识别控制器的插入与移除,及时更新那个状态数组;
- 目前的实现,只能在程序启动时识别已连接的控制器,无法响应运行期间的控制器变更;
- 所以这不是完整的解决方案,只是为后续开发打下良好基础。
接下来要做的:
既然我们已经解决了控制器带来的性能问题,现在就可以**回到 profiler(性能分析器)**的工作上了。
- 接下来要继续推进输入处理子模块(input processing sub-loop)的细化分析;
- 会在 profiler 中深入展开分析子块,定位更多潜在的性能问题;
- 目的是进一步拆解分析,提升程序中其他部分的运行效率;
- 从顶层时间块往下挖掘,明确每一段逻辑所消耗的 CPU 时间;
- 利用这些数据指导后续优化决策。
总结:
- 成功规避了 Xbox 控制器轮询导致的严重性能浪费;
- 利用了状态标志数组防止反复执行高开销的输入函数;
- 明确了后续需通过操作系统通知机制实现控制器热插拔识别;
- 现在可以将注意力转回性能分析器,继续深入优化输入处理子系统。
这标志着从"修复性能大坑"阶段,过渡到了"深入微调性能细节"的阶段。
发现软件渲染器没有正确绘制性能分析器
我们现在查看性能分析器的输出界面时,发现了一些异常现象,引发了我们的好奇和进一步排查的欲望:
当前遇到的问题:
- 在切换到软件渲染模式(software render)之后,性能分析器的可视化数据显示似乎不太对劲;
- 整个区域呈现出异常的全粉色块,没有像之前那样正确渲染时间分布结构;
- 明明我们已经切换了渲染器,而且也能观察到一些线程活动的迹象,比如 lane count 增加,说明后台确实有更多线程在工作;
- 说明性能分析器仍然在记录线程行为、数据也在更新,只是渲染结果异常。
当前判断与疑点:
- 性能数据本身是被正确记录的,因为 lane 数量发生了变化;
- 问题可能出在 渲染逻辑或者可视化显示层面;
- 怀疑可能与 排序算法、渲染顺序或布局逻辑 有关,某个处理步骤出现了 bug;
- 有可能是图形绘制部分没有正确处理软件渲染下的数据格式或线程分布,导致全部被覆盖成同一颜色区域;
- 还不确定是否是新加入的渲染模式与旧代码兼容性的问题。
后续可能需要的动作:
-
深入调试性能可视化模块,看看是否存在处理多线程数据排序、绘图坐标计算错误;
-
检查软件渲染模式是否使用了某些和 GPU 渲染不同的线程逻辑,导致图形标记失效;
-
确保分析器在多个渲染后端下都能正确解释和绘制线程数据;
-
可以尝试对这部分绘制进行"降级验证",例如:
- 先只显示一个线程的数据看看是否能恢复正常;
- 关闭排序、颜色分类等辅助渲染,验证是否是其中某个开关引起的问题;
- 对比切换前后的数据结构,看是否有缺失字段或误差;
小结:
- 虽然我们成功记录了线程行为并获得了数据,但渲染结果异常;
- 目前推测是排序或显示逻辑方面的 bug;
- 接下来需要专注调试渲染器输出模块,确保所有线程和渲染模式下都能正确绘制 profiler 图形;
- 这是性能分析工具本身的关键稳定性问题,必须修复,以便更好地服务后续优化流程。
这个阶段属于性能工具自身的健壮性完善,而不仅是优化游戏逻辑了。
game_opengl.cpp:使 glTexImage2D 使用 GL_SRGB8_ALPHA8
我们遇到的一个问题让我们想起了另一个潜在的渲染相关 bug,具体涉及 OpenGL 在使用软件渲染输出位图时出现的图像偏色或发白现象。以下是对问题的详细思考和技术排查分析:
问题现象:
- 在使用软件渲染并通过 OpenGL 显示图像时,画面表现异常,呈现出偏白的色调;
- 而如果是直接使用硬件渲染(比如标准 OpenGL 路径),这个问题则不会出现;
- 所以怀疑问题可能出现在渲染图像传输到 GPU 的阶段,而不是软件渲染本身。
技术怀疑点:
- 怀疑焦点在颜色空间处理上,尤其是有关 sRGB 和线性 RGB 的设置是否一致;
- 在使用
glTexImage2D
上传纹理时,纹理的内部格式指定为线性 RGB ,也就是默认的GL_RGB8
或GL_RGBA8
; - 但根据当前帧缓冲(framebuffer)的设定,帧缓冲可能被配置为
sRGB
格式(例如GL_FRAMEBUFFER_SRGB
打开); - 若输入纹理是线性 RGB,而帧缓冲自动执行了 gamma 变换(sRGB 解码),就会导致画面亮度被提升,出现明显的偏白效果。
当前代码路径分析:
- 图像上传使用了
glTexImage2D
; - 上传时指定的纹理格式未必是
GL_SRGB8_ALPHA8
或其他 sRGB 格式; - 如果帧缓冲启用了
GL_FRAMEBUFFER_SRGB
,OpenGL 会在写入帧缓冲时自动将颜色从线性空间转换到 sRGB 空间; - 这在上传线性 RGB 图像时,会导致颜色被二次 gamma 校正,结果是图像变亮。
可能的修复方向:
-
确保纹理格式和帧缓冲格式匹配:
- 如果启用了
GL_FRAMEBUFFER_SRGB
,则应上传GL_SRGB8_ALPHA8
等 sRGB 格式纹理; - 或者如果上传的是线性 RGB 纹理,应禁用
GL_FRAMEBUFFER_SRGB
,防止额外的 gamma 转换。
- 如果启用了
-
修改纹理上传格式:
cglTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
-
禁用帧缓冲 sRGB:
cglDisable(GL_FRAMEBUFFER_SRGB);
-
调试验证:
- 打印当前 framebuffer 配置,确认是否开启了
GL_FRAMEBUFFER_SRGB
; - 尝试在软件渲染路径下启用与硬件渲染一致的颜色空间设置,观察是否能还原正确图像色彩。
- 打印当前 framebuffer 配置,确认是否开启了
小结:
- 当前画面发白的核心原因极可能是 颜色空间不匹配导致的重复 gamma 校正;
- 图像上传和帧缓冲输出必须在颜色空间设定上保持一致性;
- 为避免偏色,必须确认上传的是 sRGB 格式纹理,或者关闭帧缓冲的 sRGB 转换;
- 这是一个非常典型的图形管线中颜色空间未对齐导致的视觉 bug,调整配置后应该能修复。
此问题处理好之后,软件渲染和显示逻辑才算真正收敛与一致。
GL_SRGB8_ALPHA8
和 GL_RGBA8
是 OpenGL 中两个不同的纹理内部格式(internal format),它们的主要区别在于 颜色空间(Color Space)处理方式不同 ,尤其是在和 GL_FRAMEBUFFER_SRGB
搭配使用时会产生视觉和性能差异。
核心区别总结:
格式名 | 颜色空间 | 含义 | 用途 |
---|---|---|---|
GL_RGBA8 |
线性 RGB | 每个通道(R、G、B、A)都是 8 位整数,线性空间 | 常规用途,不自动进行 gamma 处理 |
GL_SRGB8_ALPHA8 |
sRGB | RGB 在存储时采用 sRGB 曲线(非线性),A 是线性 | 常用于 UI、贴图、图片等已在 sRGB 空间下制作的资源 |
详细对比:
1. GL_RGBA8
(线性颜色空间)
- 使用线性 RGB 存储颜色;
- 没有 gamma 校正;
- 写入到启用了
GL_FRAMEBUFFER_SRGB
的 framebuffer 时,会自动进行 gamma 校正(线性 → sRGB); - 常用于:线性光照计算、G-buffer、计算后再展示的中间图像。
2. GL_SRGB8_ALPHA8
(sRGB 颜色空间)
- RGB 通道使用 sRGB gamma 编码,A 通道仍是线性的;
- 告诉 OpenGL:"这张纹理是 gamma 编码的",采样时会自动解码为线性空间;
- 写入启用
GL_FRAMEBUFFER_SRGB
的 framebuffer 时,不会再次 gamma 校正(避免重复); - 常用于:UI、原始贴图(摄影图像、UI元素、PNG资源等),它们本身就已经是 sRGB 空间的。
举个例子:
场景:你有一张 .png
图像,已经是 sRGB 空间。
- 推荐格式 :使用
GL_SRGB8_ALPHA8
,OpenGL 在采样时会帮你解码为线性空间进行光照计算; - 如果你用了
GL_RGBA8
,则它会被当作线性空间来处理 → 渲染结果颜色会偏暗或不准确。
场景:你从计算中生成了一张颜色 buffer,存储了光照值。
- 推荐格式 :使用
GL_RGBA8
,因为是线性空间; - 如果你误用了
GL_SRGB8_ALPHA8
,OpenGL 会以为它是 gamma 编码的,会在采样时错误地"解码"。
搭配 GL_FRAMEBUFFER_SRGB
时的注意:
c
glEnable(GL_FRAMEBUFFER_SRGB);
- 会在**写入 framebuffer(不是纹理上传)**时自动执行 gamma 编码;
- 所以你上传的纹理必须是线性格式 (如
GL_RGBA8
),否则会"重复 gamma",导致图像偏白。
简洁建议:
如果你是上传贴图 | 如果你是上传计算结果(线性光照图) |
---|---|
用 GL_SRGB8_ALPHA8 |
用 GL_RGBA8 |
开启 GL_FRAMEBUFFER_SRGB |
开启或关闭都可(视需求) |
配图理解(颜色空间流程图):
|--[sRGB Texture: GL_SRGB8_ALPHA8]--(auto decode)--> Linear RGB --> Shader Work
Resource --|
|--[Linear Texture: GL_RGBA8]-----------------------> Linear RGB --> Shader Work
Shader Output --> Linear RGB --(GL_FRAMEBUFFER_SRGB on)--> sRGB --> Screen

画面上没看出什么区别
运行游戏并看到问题已经解决
我们之前怀疑渲染异常可能是因为没有使用 sRGB 格式读取纹理,结果确实如此------问题正是出在这里。现在我们修复了它,渲染效果就恢复正常了,非常好。这样我们现在可以在软件渲染和硬件渲染之间自由切换,并且最终的视觉效果几乎完全一致。
这其实还挺有意思的,虽然是相同的内容,在两个渲染路径下的默认渲染效果却略有不同。我们猜测可能是由于像素中心坐标不够精确导致的渲染差异,这是目前最有可能的原因。虽然不确定,但除此之外也想不到别的解释了。
不过这项修复本身还是很酷的,现在切换渲染路径不会再带来明显的视觉差别,这是一个非常理想的状态。
另外还有一个小插曲:我们看到问题的时候突然想到这个可能的原因,当时就有点冲动地想去验证,结果还真找到了问题的根源。这种偶然之间发现问题然后迅速定位修复的感觉还是挺令人满意的。
解释渲染器中的伽玛处理
在进行伽马校正时,我们渲染的过程是先加载图像并进行伽马空间到线性空间的映射,接着进行所有的混合操作,最后将结果从线性空间映射回伽马编码空间。这样做是为了确保颜色的正确显示,不会因为计算中的颜色空间不一致而导致问题。
问题发生在我们使用 OpenGL 显示软件渲染结果时。具体来说,当我们将软件渲染的结果(这些结果是伽马编码的)提交给 OpenGL 时,我们没有告诉 OpenGL 这些纹理是伽马编码的。因此,当 OpenGL 渲染图像并写入帧缓冲时,它错误地认为这些纹理是线性空间的值。然后它将再次应用伽马曲线,而实际上它应该先将纹理从伽马编码空间转换为线性空间。
这就导致了一个问题:OpenGL 在写入帧缓冲时应用了错误的伽马曲线,结果是颜色变得异常亮,因为它错误地将已经经过伽马编码的颜色再次进行了伽马映射。简而言之,我们只做了伽马映射的一半过程,导致了图像看起来过于明亮。
通过解决这个问题,我们确保了伽马校正的过程正确执行,避免了颜色渲染上的异常。
注意到我们需要在启动时保留一个纹理,以防以后需要做 OpenGL 位图显示
在切换到硬件渲染时,之前我们已经为游戏分配了一些纹理。但现在由于我们切换到 OpenGL 渲染,不能再假设我们能够继续使用纹理1。为了避免这个问题,需要做一些调整。具体来说,我们应该在程序启动时预留一个纹理,专门用于 OpenGL 位图显示。
为了实现这个目标,我们计划创建一个全局的纹理句柄,用于这次特殊的渲染操作。这样,当我们切换渲染模式时,可以确保我们有一个专用的纹理用于显示,不会与其他纹理发生冲突。
此外,还注意到了一些与纹理相关的 bug,可能是因为在切换时未能正确处理纹理的分配和切换,导致了渲染错误。解决这一问题的方式是确保在渲染过程中纹理的分配和切换得到适当的管理。
演示在渲染器之间切换时的 bug
在切换渲染模式时,出现了一个问题。当从硬件渲染切换到软件渲染时,背景图层显示异常。具体来说,在软件渲染时,会看到一个奇怪的背景,这个背景实际上是软件渲染覆盖了原本为硬件渲染准备的纹理。这个问题发生的原因是,软件渲染修改了之前用于硬件渲染的纹理,导致背景图层出现了异常图像。
为了解决这个问题,需要确保为背景渲染使用一个单独的纹理。这就意味着,在切换渲染模式时,不能修改同一个纹理,而是要为软件渲染和硬件渲染分别分配不同的纹理。这样,就能够避免在切换渲染模式时相互覆盖,保证渲染的正确性。
win32_game.cpp:引入 OpenGLReservedBlitTexture,并在 Win32InitOpenGL 中设置其中一个
为了避免硬件渲染和软件渲染之间的纹理冲突,需要在运行时从图形硬件中分配一个新的纹理,并为其分配一个句柄。这使得在切换渲染模式时,不会覆盖或修改现有的纹理,从而保证渲染的正确性。
具体步骤包括:
- 为纹理分配一个句柄,并确保在进行 OpenGL 渲染时,使用这个专门分配的纹理。
- 在 OpenGL 初始化完成后,调用
genTextures
来生成一个新的纹理,并将其传递给 OpenGL 的显示函数。 - 确保在进行
openGL display bitmap
时,绑定这个纹理,以确保不会覆盖已经存在的纹理。
这样,通过保证在渲染时使用独立的纹理,可以有效避免在切换渲染模式时出现图像覆盖或异常的问题。



运行游戏并看到现在没有那个 bug,但字体的像素中心出现了问题
现在,使用了一个保留的句柄,这样就确保了这个句柄不会被其他部分的代码使用,从而避免了纹理覆盖的问题。通过这种方式,软件渲染和硬件渲染之间的切换不再出现之前的错误。现在切换渲染模式时,一切看起来正常了。
虽然软件渲染的像素中心(pixel centers)可能还存在一些瑕疵,但这部分的优化可能会等到游戏开发的后期进行,因为目前软件渲染主要是为了教学和演示其原理,实际游戏中对其要求并不高。为了进一步提升软件渲染的质量,未来可以回去解决像素中心的问题,这实际上并不困难,只需要更多的数学计算和处理。
目前,大部分的开发任务已经完成,所有的主要问题都得到了解决。接下来,计划继续改进性能分析工具,让它更强大,可以支持更方便的层次结构导航和显示。整体来说,项目进展顺利,接下来的任务已经明确,团队已经准备好继续推进。
在处理完这些后,可以稍作休息,准备迎接下一个开发阶段。
Q&A
问:不记得 OpenGL 认为像素的中心在哪里了...?
在讨论OpenGL的像素中心时,OpenGL选择了一种特殊的方式来处理像素坐标。OpenGL定义像素中心的方式是,当指定纹理的左下角为(0, 0)和右上角为(1, 1)时,纹理坐标与像素坐标是严格对齐的。这意味着,如果你用0,0和1,1的纹理坐标来绘制图像,OpenGL会将纹理精确地映射到屏幕上,以保证每个像素的中心对齐,而不会产生模糊或错位。
这与早期的3D图形标准有所不同,3D图形最初的像素定义方式并没有考虑到这种对齐问题,但后来做了修正。这种做法被认为非常智能,因为它确保了双线性过滤和纹理映射中的像素覆盖能够完美匹配,避免了可能的显示问题。通过这种方式,OpenGL能够实现更加准确的像素渲染和纹理过滤。虽然这种做法可能不是每种渲染引擎的最佳选择,但对于OpenGL来说,这是一个非常合理的定义方式。
能否进一步阐述性能分析器中等待 VSync 的问题?
在讨论"等待垂直同步"(v-sync)时,实际上指的是GPU告诉你是否可以继续提交新的渲染帧。具体来说,等待v-sync并不一定意味着与垂直同步(即显示器刷新率)的实际时刻完全对齐,它更多的是指GPU已经完成了当前的渲染工作,并且需要等待合适的时机来显示下一帧。
当调用"swap buffers"时,GPU可能会立即返回,表示可以提交新的渲染工作,即便当前帧还没有完全显示完毕。但如果GPU还没有完成上一帧的渲染,它就会告诉你等待,直到它完成当前的显示任务,才能继续提交新的内容。这种机制确保不会提交过多的渲染任务,从而防止GPU过载。
因此,调用"swap buffers"时并不意味着立即等待垂直同步的发生,而是告诉GPU"这是当前的渲染命令,请在适当的时机展示它"。如果GPU进度太快,可能会把当前线程挂起,直到它准备好接收下一帧。这就导致了"等待垂直同步"并不总是立即发生,可能会存在一定的延迟,而这种延迟并不是直接与垂直同步时间对齐的。
总的来说,等待v-sync的过程涉及到GPU的内部调度,它确保不会过度提交渲染任务,避免了帧率过高而导致的性能浪费或画面撕裂。
我其实更喜欢软件渲染器产生的字体。我们能做点什么让它们在 GL 中看起来一样吗?
对于软件渲染所产生的错误效果,可能的原因之一是软件渲染使用的伽玛曲线是近似的,而硬件渲染使用的是更加精确的伽玛曲线。特别是透明度(alpha)的处理,可能也会对渲染结果产生影响。软件渲染的近似伽玛曲线可能导致某些颜色或亮度效果不同。
要解决这个问题,可能需要重新调整资产打包器中的处理方式,确保采用正确的sRGB伽玛曲线。这将有助于让硬件渲染的效果更接近软件渲染的效果。因此,可能需要进一步检查和调整这些参数,以便在硬件渲染中获得更理想的结果。
推荐给有志于成为游戏开发者的入门级编程语言?
对于编程语言的选择,认为入门编程时使用像JavaScript这样的语言并不会让人成为糟糕的程序员,实际上是可以接受的。编程的关键在于大量的实践,而不在于使用什么语言。很多人一开始学习编程时,并不会直接学习像C或汇编这样的底层语言,而是会从简单的语言入手,比如BASIC或者Logo等,重要的是开始动手做,而不是选择某个特定的语言。
然而,问题出在如今的技术环境中,很多人开始学习编程时,会停留在像JavaScript这样的高层语言上,而不再有动力去学习底层的编程知识。这就意味着,他们可能会继续在这些高级语言中编写性能不高、效率低下的程序,尽管这些程序仍然可以发布和使用,且没有比一些大型公司(例如Google)所发布的程序差。这种现象虽然可以让开发者在短期内通过高层语言做出竞争力的应用,但如果不学习更底层的控制,如内存管理和指针操作,程序的质量可能会受到影响。
长期来看,这种情况并不利于技术的发展,因为它减少了人们向更高效编程语言转变的动力,导致一些开发者可能永远不会接触更底层的语言,如C语言。尽管目前的高级语言很强大,但仍然无法完全抽象出所有低级操作。若不学习底层语言,最终可能会让程序变得不够精确和高效。
尽管如此,入门时使用任何语言都是合适的,只要它能让学习者感兴趣并且迅速上手。但如果要认真做编程,特别是想成为一名优秀的软件工程师,就应该逐步过渡到更底层的语言,学习如何控制内存、使用指针等。这是一个重要的成长步骤,而不单单依赖于高级语言的便利性。
有没有什么可以帮助我们更容易悬停/点击调试可视化的办法?
为了简化在剖析器(profiler)中悬停点击(hover click)操作,解决方案是通过设置一个"暂停"状态来实现。当系统处于暂停状态时,它将停止收集新的时间信息,这样就可以方便地在不同的帧之间进行滚动,并准确找到需要查看的帧进行悬停操作。
实现的方法是通过在调试状态中设置"暂停"标志,使得在调试过程中,程序不会继续执行新的操作或收集数据,这样就可以在查看每一帧时,轻松暂停并进行精确操作。
通过这种方式,调试和分析过程中的交互将更加简便,不会因为数据持续更新而使得悬停操作变得困难。这也解决了在查看和比较不同帧时可能遇到的问题。
每帧都会传输所有纹理吗?
并非每一帧都转移所有纹理。只有那些刚刚从磁盘加载的纹理会被转移。实际上,OpenGL 并不会在每一帧都概念性地传输所有纹理。纹理的传输是在需要时进行的,通常是在第一次加载纹理时进行。
在纹理加载系统中,每当从磁盘加载完纹理后,程序会立即要求平台层将其下载到内存。这一过程是由后台线程处理的,因此纹理一旦下载完成,它就会保存在内存中,不会重复加载。
例如,在渲染一个复杂场景时,可能会涉及大量的纹理数据。一些场景中,可能有五六个甚至更多的纹理需要加载。最初,在纹理加载的第一帧中,可能会出现卡顿现象,因为所有纹理都是在这一帧被下载的。但目前,所有的纹理下载工作都是在后台完成的,因此可以实现更平滑的渲染体验。
当场景播放时,程序会提前加载并下载下一批需要的纹理,这样它们可以在合适的时机被使用,实现了按需流式加载的效果,从而避免了明显的性能问题或卡顿现象。
在调试 UI 中,我们能点击某一帧并将其复制到屏幕上吗?
不能实现点击帧并将其显示出来的功能,因为要做到这一点,我们需要每一帧都从帧缓冲区读取并保存。这个操作显然是不可行的,这相当于我们需要编写自己的帧记录器,这虽然是一个有趣的练习,但并不适合在这个环境中使用。实际上,是否真的需要这个功能也是值得考虑的。
能否解释一下动态分辨率是如何工作的?是纹理重缩放、视口重缩放吗?
目前我们并没有处理动态分辨率的功能。现在我们只是一直以 1080p 的分辨率渲染并显示到屏幕上。动态分辨率的调整将会在接近发布时进一步考虑和优化。
我们会有自动暂停在长帧时的功能吗?
不会自动暂停长帧。虽然可能会有一些功能,帮助我们更容易地看到长帧的存在,但不会自动暂停或跳转到长帧的处理。
检查内存泄漏怎么办?
关于内存泄漏的检查,虽然最终我们会进行一些检查,但目前并不认为它会成为我们游戏中的一个大问题。我们并不认为内存泄漏会在当前的上下文中引发严重问题,因此它不是我们优先考虑的事项。虽然我们可能会做一些基本的检查,但这不是当前的重点。
什么时候应该从像 JavaScript 这样的基础语言转向像 C 这样的语言?
在学习编程的过程中,应该在掌握了基础的编程概念之后,逐步过渡到更复杂的语言,如C语言。首先,当你已经能够理解JavaScript中如if
语句、while
循环等基本结构,并且能够编写结构清晰的程序时,就可以考虑转向C语言。这是因为C语言相比于JavaScript,需要你自己管理内存并理解程序的内存布局,增加了编程的复杂性。
从简单语言开始学习编程的目的是为了让你能够掌握编程的基本构建块,比如控制流、函数、返回值等,而不用担心复杂的底层细节。这样做就像是骑自行车时戴上了辅助轮,能让你更专注于理解编程的核心概念,而不用一开始就面对所有的技术难题。
一旦你熟悉了这些基本的编程概念,就可以转向更复杂的语言,并开始学习如何控制内存、如何与系统交互等更底层的内容。重要的是,不必成为JavaScript的专家,只要通过它掌握编程的基本原理,然后继续向更深层次的语言过渡。
然而,JavaScript有一个缺点,即它是动态类型的,这意味着程序中的类型错误可能不会被及时捕捉到,而是在运行时才暴露出来。而像C语言这样的静态类型语言,会在编译阶段就发现类型错误,这有助于更早地捕获潜在的bug。
总结来说,学习编程的过程中可以从简单的语言入手,掌握基础的编程技能,再逐步转向更复杂的语言。JavaScript虽然适合入门,但它的动态类型机制可能会给编程初学者带来一些困扰,因此在深入学习后,学习一门静态类型的语言,如C语言,是非常有帮助的。
抱歉,有个故障保护来判断什么时候我们卡顿并暂停系统来调试
在调试过程中,如果遇到卡顿(stutter),不能立即暂停并回到卡顿的帧进行调试。因为一旦发生卡顿,程序的状态已经向前推进,所以无法直接跳回到卡顿的帧进行调试。为了查看卡顿的原因,唯一能做的就是查看调试输出和调试流数据。由于这些数据会持续记录,所以可以在稍后暂停并回溯查看。
虽然可以手动暂停,回溯并检查卡顿帧,但不建议将这个过程自动化,因为这样会影响正在进行的其他操作,造成干扰。自动暂停会导致卡顿事件频繁发生,影响调试流程。因此,除非进行大量的额外工作,无法直接回溯到卡顿帧进行调试,也不建议自动暂停。