调试及开发工具

日志及跟踪

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中的显存的分配对开发者是隐藏的。
  • 分配器的不同形式。有的引擎会有多个分配器,这样需要追踪到分配器内部的内存分配情况,才能了解到实际的内存情况。

好的内存追踪工具:

  • 提供准确的信息
  • 把数据以方便及令问题显而易见的方式呈现
  • 提供上下文信息以协助团队追踪问题根源