回顾和今天的计划
我对接下来的进展感到非常兴奋。虽然我们可能会遇到一些问题,但昨天我们差不多完成了将所有内容迁移到新的日志系统的工作,我们正在把一些内容整合进来,甚至是之前通过不同方式记录时间戳的旧平台层部分,现在也可以通过新的调试日志系统记录了。我对这一点感到很兴奋,因为一旦这一切正常运行,我们将能够制作一些关键的可视化,展示程序中时间的消耗情况,这对我来说是一个很有意思的过程。
所以我想尽快开始,因为我们昨天正好处于这个中间阶段,所以今天我想继续完成它。
如果你记得的话,我们之前已经把所有东西迁移到新的系统中并且运行得很好,实际上系统已经可以正常工作了。然而,我们还没有真正利用我们之前所做的迁移工作。事实上,如果你看看现在的情况,我们还没有将平台层的任何东西加入到当前系统中,我们目前只在使用来自游戏本身的部分内容。
今天我想做的是,把这些内容推送进去,这样我们就能看到平台层的部分也能正常显示出来。我们可以开始进入 game 项目的文件夹。我们需要做的是将相关内容移动到 game Platform 里。如果你记得的话,我们把它放到了文件的底部,这样两个系统都能访问这些内容。所以我们现在可以开始使用它了,比如在这里开始使用时间锁等功能。
移除过时的 debug_frame_end_info
我现在要做的是直接移除这个调试框架输入的概念,具体来说,就是移除那个包含调试帧时间戳等内容的部分。我决定把它去掉,取而代之的是,我将传递其他的内容来替代它。
取而代之的是,我们可以为平台层翻译单元创建一个计数器数组
我打算用平台层翻译单元的计数器数组来替代之前的一组信息。具体来说,在调试过程中,我们会记录一些调试记录,包括主计数、优化计数和平台计数等。这些记录会包含诸如文件名等信息,并存储在一个全局调试表的记录数组中。这些记录就是我要保存和记录的信息。
但我们也可以直接写入 GlobalDebugTable
经过进一步思考,平台层实际上可以访问调试表,因为我们能够加载它的地址并从平台独立代码中获取这个信息。因此,我不需要特别传递记录数据,而是可以直接写入这些记录中。这意味着,我可以完全删除之前的调试框架信息(debug frame info)。
为了实现这一点,我打算完全移除调试框架信息(debug frame info)。首先,我将删除相关代码并将其归零,虽然我们将来可能需要将其转换为某些图表,但目前不再绘制这些内容。接下来,原来用于记录时间戳的部分将不再需要,改为使用时间块(time block)来替代。
此外,我们还希望能够为这些时间块提供一个命名功能,以便更具体地标识它们,但目前我不打算对此过多关注,因为这部分暂时不太需要处理。
引入 manual_timed_block 来提供更灵活的事件记录
我们希望实现一种传统的 begin-end block 结构,但能够手动控制其执行,而不是完全依赖自动化的方式。例如,我们希望能够在代码的不同部分插入小片段的调试信息,以更灵活地记录程序执行的时间片段。
目前,在 game_platform 中,timed block(计时块)实际上只是一个 begin block event(开始事件)和一个 end block event(结束事件)配合使用,并访问相应的记录。因此,我们的目标是扩展现有的实现,使其可以手动控制,而不是仅依赖作用域自动执行。
为此,我们计划创建一个新的 manual_timed_block(手动计时块),它的主要区别是:
- 它不会在作用域结束时自动执行,而是允许我们手动触发 begin block 和 end block 事件。
- 它仍然需要存储一个计数器,以便正确地管理计时信息。
在具体实现方面,我们需要检查 UI 相关的代码,并移除旧的 begin block 和 end block 逻辑(这些代码已经被废弃,不再使用)。目前已经确认这些旧逻辑不再需要,因此可以直接删除。
此外,我们还注意到 debug global memory(全局调试内存)似乎并未真正被使用。尽管它在代码中被定义,但在实际运行中并未起作用。这可能是因为我们原本计划使用它,但后来没有实际应用。因此,需要进一步检查这一部分,看看是否应该真正使用它,或者完全移除。
总的来说,我们的主要任务是:
- 引入手动计时块,允许更灵活地管理 begin-end block 结构。
- 移除旧的无用代码,包括不再使用的 begin block 和 end block 相关逻辑。
- 检查 debug global memory,确认其用途,决定是否需要真正使用或删除。
实现 BEGIN_BLOCK 和 END_BLOCK
在 time block(计时块) 机制的实现上,我们可以让 begin block(开始块)和 end block(结束块) 互相配合,使其在逻辑上保持一致。
当前的计划是,当我们执行 begin block 时,它的行为将与 time block 机制保持一致,执行的操作与现有逻辑完全相同。也就是说,执行 begin block 时,它将按照已有的 time block 逻辑进行处理,记录所需的信息。
因此,我们可以直接利用已有的 time block 逻辑,让 begin block 和 end block 直接调用这些现有的操作。这意味着,它们的实现基本上就是对现有 time block 代码的封装,只是调用的时机可以手动控制。
在具体实现方面,我们可能会:
- 让 begin block 直接调用 time block 的开始逻辑,确保计时信息正确记录。
- 让 end block 直接调用 time block 的结束逻辑,保证计时能够正确结束。
- 将这两者封装成手动控制的结构,以便我们可以在代码的不同位置灵活使用,而不是完全依赖作用域自动触发。
目前,我们的思路是确保 begin block 和 end block 完全复用 time block 现有的逻辑,这样可以减少重复代码,同时保证行为的一致性。
为了能够配对 BEGIN_ 和 END_BLOCK,我们需要为区块命名
我们希望实现 手动时间块(manual time block),其中 begin block(开始块)和 end block(结束块) 可以配合使用,并且能够正确存储和恢复 计数器变量,以便 end block 自动使用 begin block 生成的信息。
目标:
- 支持手动时间块,即
manual_time_block = begin_block(...)
,随后end_block(manual_time_block)
进行匹配使用。 - begin block 需要存储计数器变量,确保
end block
能够自动引用到正确的计数器值。 - 允许在 begin block 处提供一个名称,确保可读性,并方便调试和记录。
- 自动化 end block 行为,避免手动传递过多信息。
可能的思路:
begin_block
执行时,应该返回一个结构,该结构存储 计数器变量,这样end_block
可以直接引用它。- 需要一个 合适的存储方式 来保证
begin_block
生成的计数器变量可以在end_block
中恢复,例如:- 在一个 局部变量 里存储结构,并手动传递它。
- 在一个 全局数据结构 里存储信息(可能需要基于线程存储,以防止不同线程干扰)。
- 使用 RAII 方式 让
end_block
自动调用,避免显式传递参数。
目前的挑战是如何让 begin_block
产生的计数器变量能够 自动关联 到 end_block
,以确保数据一致性,而不需要手动管理太多额外信息。
我们希望避免为了仅仅计时而创建作用域
希望实现的目标是:不需要在块作用域(scope)内定义变量,从而可以在 块外部访问这些变量。
关键需求:
- 避免作用域限制:希望
begin_block
生成的变量可以在作用域外部使用,而不是局限于块内部。 - 变量可在其他地方访问:如果
begin_block
生成了一些信息(如计数器变量),则这些信息应该能够在后续代码(如end_block
)中被访问,而不必局限在begin_block
的作用域内。 - 提高代码灵活性:如果
begin_block
需要存储某些信息,应该有一种方式让end_block
直接使用,而无需开发者手动传递太多额外参数。
可能的实现思路:
- 全局或线程本地存储(TLS):
begin_block
将信息存入一个全局或线程本地的结构,这样end_block
只需要从该存储结构中取出数据,而不依赖作用域。
- 返回结构体并存储在外部变量:
manual_time_block = begin_block("SomeName");
end_block(manual_time_block);
- 这样
begin_block
生成的信息不会局限在某个代码块中。
- 使用 RAII(资源获取即初始化)模式:
- 通过 构造函数 在
begin_block
记录信息,析构函数 在end_block
自动完成清理。 - 这样
begin_block
生成的变量即使离开作用域也不会丢失。
- 通过 构造函数 在
核心问题是找到 存储 begin_block
生成信息的合理方式,以便 end_block
能够正确使用,而不依赖局部作用域。
让我们先写使用代码
目标是 简化代码结构,使 begin_block
和 end_block
易于使用,并确保:
- 语法简洁:开发者可以直接写
begin_block("name")
和end_block()
,不需要额外管理变量或作用域。 - 嵌套清晰:能够按逻辑块划分程序,例如
"ExecutableReady"
,"InputProcessed"
,"GameUpdated"
,"AudioUpdated"
,"FrameDisplay"
等,每个部分有清晰的begin_block
和end_block
。 - 可编译移除:希望可以在 非调试模式下自动移除 这些代码,以减少运行时开销。
- 尽可能使用宏封装:避免额外的变量声明,使
begin_block
和end_block
的使用尽可能简单,并减少对开发者的干扰。
可能的实现方式:
- 使用线程本地存储(TLS)或全局栈:
begin_block("name")
会将"name"
及相关信息存入 全局或线程本地的栈 中。end_block()
直接从栈顶弹出最近的begin_block
,确保匹配。- 这样就不需要开发者手动管理变量,保证
end_block()
知道对应的begin_block
。
最终,核心思想是找到一种方法,使 end_block()
能够自动匹配 begin_block("name")
,避免手动管理,并且在非调试模式下可以轻松移除这些代码。
同样也要为 END_BLOCK 命名
当前目标是 优化手动时间块(manual blocks)的命名方式,并确保它们的结构合理、可读性强,同时 避免重复传递名称。
主要思考点:
-
减少名称重复传递
- 目前
begin_block("name")
和end_block("name")
都需要显式提供名称,不够优雅。 - 但如果存在嵌套的
begin_block
,为了 确保匹配正确,某种程度上确实需要重复名称。
- 目前
-
调整命名逻辑
- 之前的命名方式在转换为 block 结构后,不太符合逻辑,例如
"Frame Rate Complete"
其实更适合叫"Frame Wait"
,"Audio Updated"
可能应该是"Audio Update"
。 - 目标是 让名称更符合时间片(timing sections)的逻辑,使
begin_block
/end_block
结构清晰易读。
- 之前的命名方式在转换为 block 结构后,不太符合逻辑,例如
调整后的结构示例:
BEGIN_BLOCK(ExecutableRefresh);
// 代码...
END_BLOCK(ExecutableRefresh);BEGIN_BLOCK(InputProcessing);
// 代码...
END_BLOCK(InputProcessing);BEGIN_BLOCK(GameUpdate);
// 代码...
END_BLOCK(GameUpdate);BEGIN_BLOCK(AudioUpdate);
// 代码...
END_BLOCK(AudioUpdate);BEGIN_BLOCK(FramerateWait);
// 代码...
END_BLOCK(FramerateWait);BEGIN_BLOCK(FrameDisplay);
// 代码...
END_BLOCK(FrameDisplay);
总结
- 改进了命名方式,使其更加合理和清晰。
- 优化
begin_block
/end_block
结构,减少代码冗余,提升可读性。 - 引入自动管理机制(栈或 RAII),使
end_block
不再需要重复传递名称,并确保匹配正确。
实现命名的 BEGIN_BLOCK
当前目标是 优化时间块(time block)机制,通过宏和自动变量管理,使 begin_block
和 end_block
更加高效、可读性更强,并减少手动操作的负担。
主要优化思路:
-
自动管理计数器(counter)变量
- 通过
Counter_##Name
变量存储当前时间块的计数器值,使其可以在end_block
时正确访问。 - 采用
int Counter_##Name = __COUNTER__;"
这种方式确保唯一性,避免变量名冲突。
- 通过
-
改进
BEGIN_BLOCK_
的参数传递- 利用编译器提供的
__FILE__
、__LINE__
信息,自动填充文件名和行号,减少开发者手动输入的负担。 BlockName
由调用者提供,并在BEGIN_BLOCK
处理后存入Name
。
- 利用编译器提供的
-
优化
END_BLOCK
逻辑- 通过 全局存储
counter
,使END_BLOCK
只需传递Name
,而不需要手动管理计数器。 - 这样
END_BLOCK
就能 自动匹配最近的BEGIN_BLOCK
,防止嵌套错误。
- 通过 全局存储
优化后的 begin_block
/ end_block
设计:
(1)存储计数器并初始化时间块
#define BEGIN_BLOCK_(name, Counter, FileName, LineNumber, BlockName) \Counter = Counter; \debug_record *Record = GlobalDebugTable.Records[TRANSLATION_UNIT_INDEX] + Counter; \Record->FileName = FileName; \Record->LineNumber = LineNumber; \Record->FunctionName = BlockName; \RecordDebugEvent(Counter, DebugEvent_BeginBlock);#define BEGIN_BLOCK(Name) \int Counter_##Name = __COUNTER__; \BEGIN_BLOCK_(Counter_##Name, __FILE__, __LINE__, #Name);
Counter_##Name
确保每个BEGIN_BLOCK
都有独立的计数器变量。__FILE__
和__LINE__
由编译器自动提供。#Name
让BlockName
以字符串形式传入begin_block
。
(2)结束时间块
#define END_BLOCK(Name) RecordDebugEvent(Counter, DebugEvent_EndBlock);
额外优化
- 自动管理作用域(RAII 方式)
适用于 C++,使用 构造函数begin_block()
,析构函数end_block()
来自动管理时间块:
使用方式struct timed_block {timed_block(int Counter, const char *FileName, int LineNumber, const char *BlockName) {BEGIN_BLOCK_(name, Counter, FileName, LineNumber, BlockName);}~timed_block() { //END_BLOCK_(Counter);};}
优点:{timed_block block("GameUpdate");// 代码块... } // 作用域结束时自动调用 end_block()
- 完全自动管理,不需要写
END_BLOCK()
,减少出错概率。 - 代码更简洁,更符合 C++ 设计风格。
- 完全自动管理,不需要写
最终优化结果
✅ 简化 begin_block
和 end_block
逻辑,避免手动传递计数器。
✅ 利用宏自动获取文件名、行号,减少开发者输入。
✅ 提供 RAII 方式,进一步减少手动操作,提高代码可靠性。
实现命名的 END_BLOCK
当前的优化重点是 end_block
自动匹配 begin_block
的计数器值(counter),以便正确记录调试事件(debug event),从而简化调用方式,并提高代码的可维护性和可读性。
主要优化目标:
- 自动获取
begin_block
生成的计数器,避免手动传递。 - 确保
end_block
能正确匹配begin_block
,防止跨块错误。 - 优化
record_debug_event
的调用方式,确保end_block
只需要传递counter
即可完成记录。 - 减少重复代码,提高
begin_block
和end_block
之间的数据流通性。
2. end_block
自动匹配 begin_block
#define END_BLOCK(Name) RecordDebugEvent(Counter, DebugEvent_EndBlock);
将 TIMED_BLOCK 重命名为 TIMED_FUNCTION
当前的优化目标是将 时间块(time block)改进为时间函数(time function),使得可以更灵活地追踪不同函数的执行时间,同时提升代码的可读性和可维护性。
主要优化点
-
改进命名:
- 原先使用的
TIMED_BLOCK
改为TIMED_FUNCTION
,更符合其用途。 BlockName
统一作为标识,避免混淆。
- 原先使用的
-
减少手动管理时间块的工作:
- 之前需要手动管理
begin_block
/end_block
,现在通过time_function
自动管理。 - 使用 函数封装 使其更简洁。
- 之前需要手动管理
-
支持手动命名时间段:
- 允许指定 自定义名称 追踪特定区域。
- 提供默认名称,减少调用复杂度。
在所有修改调试系统后,让代码重新编译通过
更改内容:
-
时间块名称更改:
- 将所有原本的
TIMED_BLOCK()
改为TIMED_FUNCTION()
,确保后续所有相关功能都能一致使用时间函数。 - 例如,
PixelFill
被作为一个例子来展示如何更改和命名函数。这个函数被临时命名为PixelFill
,目前没有其他特别的原因不使用这个名字。
- 将所有原本的
-
函数类型的调整:
- 所有涉及
TIMED_BLOCK()
的地方都将改成TIMED_FUNCTION()
。 - 保留了一些可能的特殊用例,在这些用例中,仍然可以为时间函数指定特定名称。
- 所有涉及
-
标识符问题:
- 在更改后,一些标识符没有找到,出现了
timed block identifier not found
的错误。需要逐步修复这些标识符的问题,确保它们可以正确引用。
- 在更改后,一些标识符没有找到,出现了
重新引入 TIMED_BLOCK,并与 TIMED_FUNCTION 合并
现在要进行一些修改,使得时间块(TIMED_BLOCK
)和时间函数(TIMED_FUNCTION
)能够更简洁地调用彼此,避免太复杂的实现。具体来说:
-
宏调用简化:
- 目标是通过传递块名称来简化时间块和时间函数的宏调用。例如,传递一个块名称后,可以在宏内统一处理,避免重复的定义和代码冗余。
- 通过这种方式,时间块和时间函数可以被合并在一起,传递块名称后,系统可以自动处理它们的功能和行为。
-
时间函数的实现:
TIMED_FUNCTION
会调用TIMED_BLOCK
,这样做的目的是简化代码和统一逻辑。- 只需要传递函数名称和块名称,系统就会处理后续逻辑。这样就能避免手动拼接名称的麻烦。
-
块名称处理:
- 对于每个时间块,块名称需要被转换成字符串,这样在代码中能够正确引用。
TIMED_FUNCTION
被应用于所有需要时间块的地方,确保一致性。块名称会替代原来的function name
,从而减少命名上的混乱。
-
简化代码结构:
- 通过这种方式,能够简化代码中的命名和宏调用,使得时间块和时间函数的关系更加清晰。
- 例如,
TIMED_BLOCK
会被替换成TIMED_FUNCTION
,并通过相同的结构处理。
-
调整调试渲染组:
TIMED_FUNCTION
用于调试渲染组,确保一致性,块名称和函数名称也做了适当调整,确保代码的逻辑更加清晰。
-
传递参数:
- 对于时间块,需要传递合适的参数(如块名称、函数名称等)来保证它们能够正确运行。
-
后续步骤:
- 现在需要对宏进行一些修改,以确保时间块和时间函数能够正确工作。完成这些修改后,应该可以实现更加简洁的代码结构,并确保功能正常。
通过这些修改,代码结构变得更为简洁,减少了重复的定义,增强了代码的可维护性。
与 BEGIN_BLOCK 相关的编译问题
在这里,出现了一个语法错误,问题似乎出在传递字符串作为最终参数时。具体来说,BEGIN_BLOCK
后面的参数应该是字符串类型,但当前的实现似乎没有正确处理这一点。问题的根源是,BEGIN_BLOCK
中的最后一个参数(如ExecutableRefresh
)被认为是一个字符串,但代码的处理方式没有符合预期。
另外,Counter_##Name
是计数器变量,应该正确传递并展开,但这部分的宏扩展似乎没有正确处理。RecordDebugEvent
应该能够通过计数器来工作,然而在这里出现了一个语法错误,可能是因为宏中的字符串处理没有正常展开。
在 Visual Studio 中,调试信息没有给出有用的提示,导致了调试过程变得困难。相比之下,Linux 编译器的调试工具提供了更好的宏扩展信息,帮助更容易地追踪和调试类似的问题。
总体来看,这个问题出在宏展开的过程,字符串处理以及宏中的参数传递上,导致了错误的发生。
问题出在命名冲突
问题出在处理文件名、行号和块名称时,确保这些信息不会与结构体成员发生命名冲突。为了避免这种情况,需要对相关数据进行处理,确保它们不会干扰结构体的成员变量。此外,还需要解决宏展开时,何时决定展开以及何时不展开的问题。
为了修复这个问题,决定使用临时变量,但这也带来了潜在的问题。因此,最好的解决方案是将这些操作封装到一个块中,这样可以避免记录数据与其他成员变量发生命名冲突。通过这种方式,记录就不会干扰其他部分的工作。
经过这些调整后,主要的问题已经得到解决,代码也恢复到正常工作状态,不再需要传递不必要的参数(例如frame
)。
在 DLL 加载时抓取 GlobalDebugTable
问题在于当前无法访问全局的调试表(GlobalDebugTable),这导致出现未解决的外部引用错误。为了解决这个问题,需要确保在加载调试表时,能够正确地获取到全局调试表的指针。
具体来说,需要确保在加载调试信息时,可以访问全局调试表,并能够正确地指向它。为了实现这一点,可以在加载时,确保能够通过某种方式获得全局调试表的地址或指针,这样就能在需要时访问并使用它。
因此,关键是创建一个机制,能够在系统中获取和访问全局调试表的指针,从而避免外部引用未解决的错误。
将全局调试表的类型更改为指针
当前的问题是全局调试表并没有作为指针存在,这使得在访问时无法直接操作。若它是一个指针,则可以方便地指向任何需要的位置。然而,若它不是指针,处理起来就会有一些困难,因为没有简单的方式来确保其指向正确的内存地址。
因此,为了让系统能够正常工作,需要将调试表定义为一个指针,这样可以确保指向正确的位置。虽然这样做引入了一些间接性,但解决了无法访问全局调试表的问题。通过将调试表定义为全局变量并导出,外部代码和平台层也可以访问到它。
在修改后,调试表可以作为全局事件表的一部分使用,并通过合适的指针指向它。最终,平台层代码能够通过这种方式访问并使用调试表,从而完成了系统的调整。
在平台层引入占位符 GlobalDebugTable
如果只是为了编译通过而不在乎是否能真正正常工作,操作会简单一些。可以简单地定义一个全局调试表,并通过指针指向它。然后在平台层中再定义一个指向该调试表的指针。这两块代码就会分别写入内存的不同位置。
然而,当运行时,这种方式可能导致程序崩溃,因为尽管这些指针看起来是有效的,但它们实际上指向的是完全不同的内存位置,这会造成冲突或者无法预料的错误。
程序崩溃了
问题似乎出在指针没有正确初始化,导致访问时出现了未定义的行为。最初认为可能是代码中存在明显的错误,但通过检查后发现可能并没有明显的错误。指针应该在加载时初始化,但实际上并没有被正确初始化,导致无法访问。最初可能怀疑是因为使用了错误的语法或拼写错误,但在仔细检查后,发现问题可能在于time function
的实现,或者其他地方犯了不小心的错误。
随机未定义
我们没有在 timed_block 构造函数中初始化计数器
问题出在没有初始化计数器(counter)。这就是导致问题的原因。现在终于找到了问题所在,可以继续回到之前想要编写的内容了。
测试最后的更改。现在平台层写入了一个独立的调试内存
问题出现在平台层正在写入不同的调试内存区域。由于在 DLL 边界的两侧分别声明了一个调试内存区域,平台层写入的内存和实际需要写入的内存完全不同。因此,平台层所写入的数据与预期写入的数据不在同一内存区域,导致了这个问题。
在加载游戏 DLL 后,将调试表连接到游戏代码
现在需要做的是将调试表连接起来。具体来说,在平台层的代码中,当加载 DLL 时,可以通过引入一个函数来获取并切换调试表。也就是说,在加载游戏代码后,在第一次汇编处理完成后,可以通过某种方式获取调试表,并覆盖现有的调试表指针。这样,平台层就可以根据需要使用正确的调试表。
调试数组也可以存在于平台层那一边,但那样也会有其他问题
目前的思路是,每次卸载 DLL 时,恢复平台层中的全局调试表指针,指向原始的调试表。当加载新代码时,再重新设置调试表。这种方式存在一个问题,就是如果调试表中的字符串在 DLL 卸载时会变得无效,这样可能会带来麻烦。为了避免这种问题,可以在每次循环结束时,将调试表的事件索引数组重置为零,这样可以确保不会发生溢出。虽然这个方案可能不是最优的,但目前来看是一个可以解决问题的有效方法。
调试表的共享方式可以改进
目前的计划是,将调试框架中的全局调试表返回,以确保调试信息在正确的内存位置进行共享。尽管有更优的方法来优化这个共享机制,但考虑到时间限制,现在先使用当前方案,并在有更多时间时进一步完善。如果希望系统更加精细化,可能需要深入研究如何提高共享的效率。接下来的步骤是确保函数的定义和实现没有遗漏,确保全局调试表在程序中正确返回并正常工作。
平台计数器仍然没有显示在可视化中
当前还没有完全成功,因为平台相关的数据没有显示出来。虽然全局调试表的指针似乎指向了正确的地方,但我们还没有看到预期的结果。调试过程中,通过查看代码发现,全局调试表已经正确地指向了应该指向的内容,并且不使用的部分被清除掉了。接下来,继续检查“Win32”循环,看看是否能进一步解决问题。
我们还没有打印平台层的记录!
问题可能出在没有正确打印调试信息,因此需要完成之前的调试工作,确保正确输出相关内容。发现调试记录中的平台计数器始终为零,这显然是不对的。为了实现这一点,需要检查计数器部分,确保它们不会被清除,并且在正确的地方设置快照。
向调试表中添加每个翻译单元的 RecordCount
接下来需要完成的是计算调试记录的总数。首先,需要在调试表中加入记录计数的信息,因为目前调试表中并没有包含这一信息。为此,我们可以在调试表中加入一个字段来存储记录计数,并通过计算所有翻译单元的记录数来更新这一信息。
具体做法是,首先去掉现有的 DebugRecords_Platform_Count
,然后使用 TotalRecordCount
来表示每个翻译单元的记录数。通过遍历所有翻译单元,将它们的记录数加起来,从而得到总记录数。这样,调试状态的计数器数量就会等于总的记录数。
接下来,需要初始化这些计数器,方法是在程序中定义一个计数器数组,这个数组的大小等于翻译单元的数量。在处理过程中,每个翻译单元的计数器会根据其记录数进行更新。
在每次运行时,会确保记录计数是最新的。然后,平台层会负责更新其实际的调试信息。
弄清楚平台层记录计数
为了实现这个功能,在加载游戏时,程序需要在调用相应的函数之前,明确地设置每个翻译单元的记录计数。具体来说,每个翻译单元的记录计数将等于其总的计数器数量。
然而,这个方法有些麻烦,因为存在多个翻译单元时,需要为每个翻译单元做额外的工作,这让实现变得不如预期的简洁。如果没有动态加载代码,只使用一个翻译单元会使得实现变得更加简单,但由于当前的需求,必须对每个翻译单元做更多处理。
虽然这种方法可能会带来一些不便,但它仍然可行。每次采用新的技术时,可能并不清楚其效能如何,可能需要考虑更多因素。但值得注意的是,这些繁琐的小问题可能暗示着该方式并不是最佳选择。尽管如此,最终这种方法应该能够实现预期的功能。
出现段错误
赋值的地方打断点没有触发
反而直接触发了段错误
好像有的值也不对怎么对
选择一个跟踪进去看看
把TIMED_FUNCTION切换成BEGIN_BLOCK(EndRender)和END_BLOCK(EndRender) 就没问题
看来是TIMED_FUNCTION有问题
这就是很奇怪为什么构造函数的名字的参数名字和成员函数不能一样
再次测试
现在来看当前的情况,理论上应该能够看到所有的 Win32 相关内容。可以观察到,Win32 的主循环已经出现,可执行文件的刷新也在,预处理阶段也正常,游戏更新等部分也都在预期的位置。
尽管目前还没有进行深入测试,因此尚不清楚其工作是否完全正常,但整体来看,进展顺利,大部分功能似乎已经基本实现。然而,现在的数据显示的内容过多,显得有些杂乱。不过,这正是之前所做的基础工作的目的——为后续优化提供便利,使得后续能够以更智能的方式呈现和分析数据。
Win32Loop 在调用 DEBUGFrameEnd 后关闭,因此它的值不正确
现在观察到 Win32 主循环的数据有些异常,这可能是因为这个循环不会被关闭,它不会在当前时间周期内完成。这也是需要进一步调整的地方,因为当运行这些数据时,这个循环在时间范围内不会自然结束,而是在代码中的某个位置才真正关闭。
接下来,希望能完成最后一项工作。然而,这个过程比预期花费的时间更长,导致有些原本计划进行的优化可能无法在剩余时间内完成。不过,仍然可以尝试实现几个关键改进。
首先,希望在调试事件流中引入某种帧锁定机制,以便更清晰地标记帧边界的位置。这将有助于更直观地理解帧的起止点。然而,仔细考虑后,发现如果所有的调试事件都被组织在一个大块内,也许已经可以达到类似的效果。因此,暂时决定先不增加额外的帧边界标记,而是继续当前的优化路径,观察后续结果,再决定是否进一步调整。
我们需要一种方法来确定帧边界
需要在调试事件流中引入一个特殊的标记,用于指示帧边界。这不仅是为了组织数据,还能在可视化时提供明确的时间切割点。目前,所有事件都被视为一个连续的块,但实际上需要一个特殊的时间点来表示逻辑帧的分割。
这样做的目的是在调试工具中提供额外的信息,使得查看数据时能按照帧的逻辑单位进行拆分,而不仅仅依赖于事件块的自然排列。这种帧边界标记本身没有其他实际作用,仅仅是为了可视化分析提供清晰的分割点,确保数据展示时符合直观的帧结构。
引入 FRAME_MARKER
需要引入一个特殊的帧标记(Frame Marker),用于区分帧边界,使调试事件流更加清晰。这个帧标记并不是普通的计时事件,而是一个特殊的事件类型,专门用于表示逻辑帧的起始或结束点。
实现时,可以通过调用 record_debug_event
函数,并为其指定一个特殊的事件索引或事件类型,使其区别于普通的计数器事件。虽然它仍然可以保留计数器的特性,但在事件类型上需要标注为“帧标记”,以便在后续分析和可视化时能够正确解析和显示帧边界。
这样做的目的是在调试工具中提供明确的帧划分信息,确保数据在分析时能够按照预期的帧单位进行展示,而不会因所有事件混在一起而失去逻辑上的时间顺序。
新的 debug_event_type:DebugEvent_FrameMarker
我们正在实现一种特殊的标记事件,而不是普通的事件类型。这些特殊标记事件包括调试事件和帧标记等,我们可以在需要时插入这些事件,以便更好地跟踪和记录。
在实现过程中,我们可以在合适的位置填充相关信息。例如,我们可以定义一个计数器 int Counter
,然后使用 RecordDebugEvent
记录一个帧标记事件。接着,我们会执行相关操作,例如调用 debug record
以记录该事件。
除此之外,我们还需要添加标准信息,如文件名(file)、行号(line)以及块名称(block name)。块名称会被设置为 frame marker
,以明确其用途。
在实现过程中,我们还需要注意正确处理转义字符,并确保所有字符串格式符合预期。此外,我们可能需要修改打印输出的例程,以便暂时忽略这些事件,因为目前它们尚未被实际使用。
对于 DebugEvent_EndBlock
相关的断言检查,我们需要进行调整。如果事件类型等于 n blocks
,则执行相关逻辑,否则无需进行额外处理,这样就可以确保代码的正确性和合理性。
测试它
目前整体状态已经基本正确,不应该再出现那些强制打开块的无效内容,这部分看起来已经合理。
接下来的步骤是开始关注帧数据,并尝试将调试日志视为一个整体日志进行处理。考虑到这一点,需要探索如何更有效地查看和分析这些日志数据。
为了更直观地表达这个想法,可以借助图示来说明具体的思路,因此接下来会进行绘制,以便更清晰地阐述这一概念。随后会切换到 blacklist
以继续相关工作。
(黑板) 调试日志的结构和计划。我们希望保持比一帧更长的历史记录
当前的调试日志结构主要由帧标记和一系列成对的事件组成,这些事件大多是层次化的,但并不严格要求必须是层次化的。日志会按照帧的方式持续记录,每个帧都会有一个帧标记,并且这一模式会不断重复。
需要考虑的问题是,一些事件可能会跨越帧边界,比如异步操作,它们的持续时间可能超出单个帧。因此,需要将所有日志数据组织成一个滚动日志,这样即使事件跨帧,也可以正确地查看和分析它们。
同时,还需要解决日志存储空间的问题。如果不断写入日志,而不进行清理,日志数据会无限增长。因此,目前的方案使用了一种乒乓缓冲区(ping-pong buffer)机制,但可能需要更进一步的优化,使其能够存储多个帧的数据,而不是仅仅局限于单帧。
更理想的方案是采用**环形缓冲区(circular buffer)**的方式。这样可以在日志存储满时,移动“可读”与“可写”位置,而不是简单地覆盖旧数据。这样能够保证日志数据在一定范围内持续可用,同时不会无限增长占用存储空间。
我们需要分配更多的内存,也许应该从平台层获取
为了实现这一点,需要大量的内存来存储所有调试信息。不过,这并不是一个很大的问题,只要有足够的内存即可。然而,目前这些数据是以静态变量的形式存储的,这就带来了一个问题——最好能够将这些数据作为可传递的内存,而不是固定的静态存储。理想情况下,可以使用虚拟内存分配或者类似的机制来动态管理这些数据。
这也表明,或许应该调整数据的传递方式——而不是像目前这样向后传递,可能向前传递会更合适,至少在内存管理方面可能更有优势。不过,目前还很难判断哪种方式更优。
在当前的实现中,每当进入新的帧时,都会交换缓冲区。理论上,可以将调试信息复制到某个独立的内存区域,并在需要时将这些数据串联在一起。但这种方式可能会浪费大量时间在数据复制上,而实际上,数据本可以留在原本的位置,避免不必要的拷贝操作。因此,需要更合理的策略来优化这一流程,以确保既能高效存储多个帧的数据,又不会带来额外的性能损耗。
我们想在静态部分保留的数据量会不会成为问题?
在调整调试表(debug table)的大小时,需要谨慎考虑,如果将其尺寸调整得过大,可能会导致一些严重的问题。例如,可能会在静态区(static section)存储过多的数据,或者导致分配的内存超出可执行文件的限制。
其中一个潜在的问题是,如果直接在静态存储区分配过大的数组,可能会触及某些系统的限制。例如,在C语言中,如果尝试分配一个超过4GB的大型数组,可能会超出可执行文件的静态存储限制,这可能会引发未知的后果。因此,在调整调试表大小时,需要特别注意这个问题。
目前,测试的结果表明,在一定范围内增加存储的事件数量似乎不会导致严重问题,因此可能可以合理地增加一些存储空间。例如,如果将存储的帧数设定为63帧,并使用第64帧进行数据写入,就可以提供一个足够长的回溯窗口,以便观察事件随时间的变化。这种方式允许在一定时间范围内分析和回溯性能数据,使得调试信息更加丰富和直观。
不过,还需要进一步评估是否真的有必要存储如此多的帧数据,以及是否有更加高效的存储方式。例如,可以考虑是否有更智能的内存管理策略,以避免占用过多的静态存储空间,同时又能提供足够的历史数据进行分析。
现在我们有六十四个ping-pong数组
如果要实现这一点,实际上并不复杂,整体逻辑与当前的实现方式并没有太大区别。
所需要做的改动主要是在索引管理上。当前在获取 CurrentEventArrayIndex
时,通常会使用某种获取方式,而这里需要的改动是直接对索引进行递增操作,也就是执行 ++
操作,让索引指向下一个存储位置。
除此之外,还需要添加一个边界检查机制,确保索引不会超出数组范围。当索引值大于等于 ArrayCount(GlobalDebugTable->Events)
的容量时,就将其重置回 0
,实现循环覆盖存储的效果。
这个机制确保了 GlobalDebugTable.events
以循环队列的方式存储数据,使得事件数组不会无限增长,而是始终保持在固定大小内,以避免超出预期的内存占用,同时又能持续存储最新的事件信息供后续分析使用。
现在我们可以跨帧查看,不再需要快照
现在,使用循环缓冲区的方式,可以让我们跨帧查看事件数据,因为现在所有事件数据都已经被捕获,这意味着之前的快照(snapshot)功能不再必要了。
通过这种方式,我们可以实时计算和查看事件数据,而不需要依赖之前那种静态存储的快照。快照功能已经变得多余,因此不再需要特别处理这个部分。
考虑到这一点,选择采用一个较大的数据存储区域是合适的,因为这不会影响最终用户的机器,毕竟这是仅用于调试的功能。可以不必担心内存的消耗问题,毕竟这些数据仅在调试时使用。
因此,决策就是将这些大量的调试数据直接存储进去,不用过多顾虑内存的占用,只需在写入数据时直接将其插入进来。这种方式简化了整个调试过程,同时也保证了能够高效地处理和查看大量事件数据。
不像循环缓冲区,但可以满足需求
虽然现在的实现方式并不是最理想的,像是一个圆形缓冲区(circular buffer)那样,但目前选择这种方式的原因是避免让开发者自己去做圆形缓冲区的检查,这样会增加额外的复杂性。虽然理想情况下,应该使用圆形缓冲区来优化存储和处理,但在调试过程中,这种做法的缺点相对较小。
另外,调试功能的内存需求不应太过考虑,因为它只是为了调试使用,不会影响最终用户的使用体验。调试时可以尽可能使用更多的内存,这样可以更高效地捕捉和存储所需的信息,而不需要过多担心内存的节省。因此,当前的做法是可行的,至少在短期内应该没有问题。
现在,由于数据已被存储在循环缓冲区中,可以方便地回顾之前的多个帧的数据,并能够正确识别帧边界。这意味着不再需要担心调试数据的更新时机,事件跨越多个帧的情况也能正确处理,避免了因为事件跨帧而导致无法看到的边缘情况。
这种方式可以确保我们能够观察到跨越多个帧的事件,这对于调试非常重要。它解决了一个关键问题,即不再担心事件被拆分或遗漏,确保所有相关数据都能被正确捕获并分析。
你打算在开始游戏逻辑之前先开始硬件渲染器吗?还是等到软件无法满足需求再做?或者等 Vulkan 吗?
硬件渲染的引入时机取决于软件渲染是否能满足需求。如果软件渲染已经能够完成工作,并且不会遇到性能瓶颈,那么就没有必要引入硬件渲染。硬件渲染的使用通常是在需要在屏幕上处理大量内容时,软件渲染无法应对时才会考虑。
因此,在开发过程中,可能会先完成整个游戏的开发,所有逻辑和渲染都通过软件渲染来实现。只有在遇到性能问题,软件渲染无法满足需求时,才会考虑切换到硬件渲染。
不过,也可能会在开发过程中早些时候就决定切换到硬件渲染,尤其是当需要做一些复杂的操作,发现软件渲染无法满足时,可能就需要提前进行硬件渲染的实现。
你认为在什么时候应该使用别人已有的库/系统,而不是自己实现?(例如,你会使用 C 标准库中的任何东西吗?)
在决定是否使用他人的库系统时,最重要的考虑因素是它是否与自己实现的系统具有相同或更高的质量。如果某个库的质量和自己编写的代码相当,甚至更好,那么使用这个库是完全合适的。例如,标准库就被认为是高质量的,因为它经过广泛测试并且设计得当,可以信任其稳定性和与其他部分的集成性。
然而,对于大多数库,很多人可能不太信任,因为它们是由自己不信任的开发者编写的。因此,是否使用某个库往往取决于是否信任该库的作者。如果一个库的作者是自己愿意在团队中合作的人,那么这通常是一个使用该库的好标志。
使用别人编写的库并不是一件轻松的决定,尤其是对于经验较少的开发者来说,他们可能没有足够的经验来判断一个库会给项目带来哪些潜在问题。经验丰富的开发者能够识别哪些库值得信赖,哪些库可能会在未来导致问题。因此,对于初学者来说,需要通过实践积累经验,才能了解哪些库是合适的,哪些库可能会导致麻烦。
总的来说,使用他人库的决定是建立在对库质量的评估和对开发者经验的信任之上的,经验越丰富,判断能力越强,做出这样的决策就越容易。
你是把过去的 60 帧保存到缓冲区吗?
目前正在将所有的调试事件保存到缓冲区中,目的是希望能够查看过去的调试信息,特别是如何呈现这些数据。一直在努力优化系统,确保能够看到所有的调试事件,而不仅仅是那些恰好在一个帧内发生的事件。通过保存最近的60帧数据,确保可以捕获更广泛的事件信息,这样可以更全面地了解程序在不同时间点的状态和行为。
如果奇迹发生,C++ 开始正确地处理大多数特性中的问题,你会使用它们的特性(比如模板等)吗?还是继续使用元编程等方法?
讨论提到了一些关于编程语言特性的使用,尤其是模板和编程中的其他复杂功能。对于这些问题,认为目前可能难以解决,特别是当面对更严重的问题时,认为这些功能的使用可能不会带来实质性的改变。虽然这些技术在某些情况下可能有效,但目前的挑战和问题似乎超出了这些功能的影响范围。
在调试显示中,我们会看到类似 Brendan Gregg 的火焰图(函数调用深度在 Y 轴上表示)吗?
讨论中提到是否会实现类似于 Brendan Gregg 的火焰图(flame graphs)。对此表示不确定,因为自己从未深入研究过火焰图。虽然可能不打算做类似的图表,但更倾向于进行更高层次的目标区分析和深入探讨。不过,虽然没有具体看过火焰图,仍然对这类图表的效果保持开放态度。
https://www.brendangregg.com/blog/2014-11-09/differential-flame-graphs.html
我认为很多人都想看到如何编写硬件渲染器,包括我在内
许多人可能会想看到如何编写硬件渲染器,但需要明确的是,虽然有很多人希望看到很多内容,但必须控制所涉及的内容范围,确保能够在合理的时间内完成。因此,尽管很多人可能也希望看到如何编写一个三维引擎,但这个内容并不会被添加到当前的计划中。主要还是集中在如何从零开始编写一个完整的二维游戏,其他内容如果不是这个过程的必需部分,就不会覆盖。
硬件渲染并不特别吸引人,因为它在许多方面是非常短暂的。即便现在可能有这个技术,未来可能根本不存在硬件渲染这一概念。相比之下,所讨论的内容具有长期的实用性,虽然这些知识与特定编程语言挂钩,但如果能够将其抽象出来,它依然能够为编程提供更多普遍适用的技巧。
硬件渲染器部分,若不是因为现代机器的GPU拥有强大计算能力,实际上从一开始就不打算涉及。然而,考虑到游戏可能在某个点上面临屏幕上显示内容过多,软件渲染可能无法提供足够的优化,可能最终不得不实现硬件渲染。
写好游戏引擎的秘诀是:多做一些,还是在做少量大的引擎时不断提升?
要在游戏引擎开发上变得更出色,最重要的是要在少数几个引擎上不断努力,而不是一开始就尝试做很多不同的引擎。你不需要在一开始就做很多项目,而是要专注于不断完善和迭代已有的部分或架构。随着对编程理解的深入,应该不断重新评估和质疑最初的设计。
你完全可以花十年或二十年时间专注于一个引擎,在这个过程中学到很多东西,做出许多伟大的工作,只要你持续在工作中改变和改进,不断优化自己的设计。如果你让项目在完成初期后停滞不前,只是堆积一些杂乱的内容,而从不回头修正、改进和精炼,那么这种做法就不理想了。
在这种情况下,可能会需要多个游戏引擎的经验,以便能够更广泛地学习和成长。但如果你愿意不断调整和改进,保持自己的引擎灵活性和可塑性,集中精力不断在某一部分上做出改变,那么专注于一个引擎也是非常好的做法。你可以在不断改善和更新引擎的过程中,找到你想优化的部分,深入研究并逐步提升。这样,你就不会总是从头开始,而是通过持续的进步来提升引擎的质量。