调试及开发工具
日志及跟踪
printf调试法有时是很好的调试方法。因为有些bug很难用断点和监视窗口跟踪;有些bug有时间依赖性,只有全速运行时才会出现等等。这事打印信息就是很好的调试方法。
win32窗口应用程序没有控制台显示输出的函数,但是visual studio中有函数OutputDebugString()打印信息。但是它不支持格式化输出,所以Windows游戏引擎以自定义函数包装此函数
#include<stdio.h> #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN 1 #endif #include<windows.h> int VDebugPrintF(const char* format, va_list argList){ const U32 MAX_CHARS = 1023; static char s_buffer[MAX_CHARS + 1]; int charsWritten = vsnprintf(s_buffer, MAX_CHARS, format, argList); s_buffer[MAX_CHARS] = '\0';//字符串以'\0'结尾 OutputDebugStringA(s_buffer); //OutputDebugString(s_buffer);//VS2012不兼容 return charsWritten; } int DebugPrintF(const char *format, ...){ va_list argList;//处理变参(...)的一组宏,具体参考:http://blog.csdn.net/aihao1984/article/details/5953668 _crt_va_start(argList, format);//VS2008定义方式:#define va_start _crt_va_start int charsWritten = VDebugPrintF(format, argList); _crt_va_end(argList); return charsWritten; }
冗长级别
当你在代码中添加了适当的打印语句后,最好能保留它们,以便以后需要时使用。为此,引擎通常提供一些机制来控制冗长级别。根据全局的冗长级别,打印出对应的信息。简单的实现方式:把当前系统的冗长级别存储在一个全局整数变量中,或可命名为g_verbosity。然后提供一个函数VerboseDebugPrintF()函数,其首个参数是冗长级别。
int g_verbosity = 0; void VerboseDebugPrintF(int verbosity, const char *format, ...){ if (g_verbosity >= verbosity){ va_list argList; _crt_va_start(format, argList); VDebugPrintF(format, argList); _crt_va_end(argList); } }
频道
将调试输出分类位频道是很有用的功能。例如PlayStation3,可以把调试输出到14个TTY窗口之一,而且每条消息还会抄送至一个特别的TTY窗口(它包含所有的输出)。
就算是一些只有单个输出窗口的系统中,也可以通过把每个频道显示不同颜色来划分输出信息。而且还可以实现过滤器(filter),开关相应的过滤器,能够只显示对应频道的调试输出。
实现方法是在调试打印函数中加入频道参数来实现该功能,频道可以用数字标识,使用enum表示更好,也可以使用字符串或字符串散列标识符命名频道。如果少于32或64个频道,直接用32位或64位掩码过滤频道。
把输出同时抄写到日志文件中
把所有调试信息同时抄写到一个或多个日志文件中,可以方便事后诊断问题。应当不管当前的冗长级别和频道,而把所有调试信息都写入日志文件。
每次调用调试输出函数后都对日志文件清空缓冲,以确保万一游戏崩溃时日志文件仍会包含最后的输出。通常最后的输出对确定崩溃的原因很关键,但是清空缓冲成本很高。因此,以下情况下才应该清空缓冲:
- 程序输出的日志量不多;
- 某平台有这样做的必要。
崩溃报告
有些游戏引擎会在崩溃时放出特别的文本或日志。大多数系统都有一个顶层的异常处理函数,它可以捕获大部分的崩溃情形。你可以在此函数中打印各种有用信息。
崩溃报告可包含的信息:
- 崩溃时玩家在玩的关卡,玩家角色所在的世界空间位置,玩家角色的动画/动作状态;
- 崩溃时正在运行的一个或多个游戏脚本;
- 堆栈跟踪:系统通常提供获取调用堆栈的机制。通过这些机制可以获得崩溃时,堆栈中所有非内联函数的符号。
- 引擎中所有内存分配器的状态。崩溃和内存相关,这个信息会很有用。
- 其他和崩溃相关的信息。
调试用的绘图功能
大部分游戏引擎会提供一组API,去绘画有颜色的线条、简单图形及三维文本。这些API称为调试绘图。因为这些绘画仅为了在开发及调试期间做可视化,在游戏的发布版中会移除。相对于看代码中的数学公式,直接通过图形看绘图结果能更快的知道逻辑和数学错误。
调试绘图API
满足的要求:
- API应简单且易用;
- API支持一组有用的图元:直线、球体、点、坐标轴、包围盒、格式化文本等。
- API应能弹性控制图元如何绘画:颜色、线的宽度、球体半径、点的大小、坐标轴的长度及其他图元的尺寸等。
- API应可以把图元绘画至世界空间或屏幕空间。
- API应选择是否使用深度测试来绘画图元:
- 当开启深度测试,图元会被场景中的真实物体所遮挡。这样能显示图元的前后关系。
- 当关闭深度测试,图元会“漂浮”在场景中所有真实物体之前。
- 应该可以在代码的任何地方调用此API。
- 每个图元应该包含生命期,它控制图元提交后维持在屏幕上的时间。例如,若某个图元每帧都会显示在屏幕上,生命期应该设置为1帧,这样每帧刷新时,它都存在;如果只是间歇存在,则可以以秒为单位设置它的生命期。
- 调试绘图系统应能高效处理大量调试图元。
游戏内置菜单
在游戏运行期间,开发人员能直接配置各个子系统的配置选项,这样会很方便。因为,它不需要重新编辑代码,编译连接。游戏中配置菜单选项,最简单有效的方法是提供游戏内置菜单:
- 切换全局布尔设定
- 调校全局整数及浮点数值
- 调用一些引擎函数,执行任务
- 开启副菜单,是菜单按阶层式管理,方便浏览
游戏内置主控台
有些引擎提供游戏内置主控台,他提供命令式的接口让用户使用引擎功能;相对游戏内置菜单,游戏内置主控台虽然不太方便,但是他提供更丰富的接口,使用户几乎能使用所有引擎功能。
调用摄像机和游戏暂停
游戏内置主控台或游戏内置菜单最好附有两个功能:
- 把摄像机从游戏角色分离出来,控制其观察游戏世界的所有细致场景的细节;
- 暂停、恢复暂停、单步执行游戏
暂停游戏时,仍需控制摄像机;可以通过停止逻辑时钟,保持渲染引擎和摄像机控制系统来实现。
慢动作模式也很有用,可以通过游戏时钟和真实时钟的更新速率不同来实现。
作弊
作弊是调试游戏的重要方法。如果为了调试游戏还要死命玩到某关,在调试,效率上太差了。因此,需要作弊。像是不死身、给玩家武器、无尽弹药、选择角色网络等。
屏幕截图及录像
获取屏幕截图是有用的工具。通常这些截图会放到某个预设的文件夹中,并以日期来命名保证文件的唯一性。
有些引擎也提供全面的录像功能。系统是以目标帧率来获取屏幕截图,然后存成视频格式文件。
获取屏幕截图很慢,因为从显存传送帧缓冲至内存的时间开销(图形硬件通常不会优化此操作)和图像存盘。
游戏内置性能剖析
前面提到过需到第三方的剖析工具,但是不一定能在该游戏机上运行,因此,游戏通常会内置性能剖析工具。
层阶式剖析:层阶式的函数调用;C/C++中根函数一般是main()或WinMain(),但从技术上说真正的根是C标准运行时库中的启动函数。
两个方面度量函数的耗时:函数的执行时间和函数的调用次数;
游戏内性能剖析工具通常手动在程序中添加测控,来得到函数的执行时间:
//一个典型的游戏循环如下: while (!quitGame){ PollJoypad(); UpdateGameObjects(); UpdateAllAnimateions(); PostProcessJoints(); DetectCollisions(); RunPhysics(); GenerateFinalAnimationPoses(); UpdateCameras(); RenderScene(); UpdateAudio(); }
如果要剖析上面代码的性能,可能这样插入测控:
while (!quitGame){ { PROFILE("Poll Joypad"); PollJoypad(); } { PROFILE("Game Objects Update"); UpdateGameObjects(); } { PROFILE("Animateions"); UpdateAllAnimateions(); } { PROFILE("Joint Post-Processing"); PostProcessJoints(); } { PROFILE("Collisions"); DetectCollisions(); } { PROFILE("Physics"); RunPhysics(); } { PROFILE("Animation Finaling"); GenerateFinalAnimationPoses(); } { PROFILE("Cameras"); UpdateCameras(); } { PROFILE("Rendering"); RenderScene(); } { PROFILE("Audio"); UpdateAudio(); } }
上面代码的PROFILE()宏会以一个类实现,该类的构造函数负责计时,析构函数停止计时,并以指定的名字记录执行时间。它只会为块作用域内代码计时
struct AutoProfile { AutoProfile(const char* name){ m_name = name; m_startTime = QueryPerformanceCounter(); } ~AutoProfile(){ __int64 endtime = QueryPerformanceCounter(); __int64 elapsedTime = endtime - m_startTime; g_profileManager.storeSample(m_name, elapsedTime); } const char *m_name; __int64 m_startTime; }; #define PROFILE(name) AutoProfile p(name)
通过加入一些代码去描述剖析采样的层阶关系。
//此代码声明多个剖析样本箱,指明样本箱的名字,以及父样本箱的名字(若有) ProfilerDeclareSampleBin("Rendering", NULL); ProfilerDeclareSampleBin("Visibility", "Rendering"); ProfilerDeclareSampleBin("ShaderSetUp", "Rendering"); ProfilerDeclareSampleBin("Materials", "ShaderSetUp"); ProfilerDeclareSampleBin("SubmitGeo", "Rendering"); ProfilerDeclareSampleBin("Audio", NULL); //......
游戏内置的内存统计和泄漏检测
很多游戏引擎会实现自定义的内存追踪工具。该工具的难点:
- 不能控制他人代码的分配行为。游戏中调用的第三方库,好的库会提供内存分配钩子,但有的库没有,这样就没办法控制它的内存分配了。
- 内存的不同形式。通常有主存和显存,PC中的显存的分配对开发者是隐藏的。
- 分配器的不同形式。有的引擎会有多个分配器,这样需要追踪到分配器内部的内存分配情况,才能了解到实际的内存情况。
好的内存追踪工具:
- 提供准确的信息
- 把数据以方便及令问题显而易见的方式呈现
- 提供上下文信息以协助团队追踪问题根源