随着多核CPU的普及,多线程设计对3D引擎已经变得越来越重要,很难想象一两年后推出的3D引擎还在使用单线程方式。但是多线程的引入使引擎变得更加复杂,不良的设计带来的性能提升非常有限,甚至在单核环境下还会出现明显的性能下降。所以找到一种清晰、简洁、高效的设计方式就变得至关重要。目前多线程3D引擎设计的资料还很少,多数只讨论一些原则性的概念,缺少具体实例的分析。
本文提出一种具体的3D引擎多线程实现方案,这种方案只是初步实现,还缺乏大量的测试和长期运行的考验,但其中的思路也许可以给大家提供一些参考。目前流行的线程功能划分一般会把资源加载和图像渲染分别独立到一个线程中,这也是非常直观的划分方式,本文也不例外,也是采用这种线程功能的划分。
一种传统的线程模块设计方式是将一组内聚性很强的工作分配给独立线程去做,如资源加载或图像显示,再为它设计一组命令消息,主线程向工作线程提交命令消息,工作线程在后台完成命令请求,再通过消息将结果传递给主线程。这种设计存在两个问题,一是需要实现的命令消息多且复杂,同步困难;二是异步的命令执行和结果回传打破了主线程自然的运行逻辑,主线程不得不保留命令提交时的相关上下文,通过轮询或着回调函数的方式获得命令的执行结果,并结合保留的上下文最终完成命令的处理。本文介绍的设计力图回避上面提到的两点问题,简化命令消息设计,最好只通过一种方式提交命令请求,同时希望做到请求提交后不管,不需要轮询和回调,即使有也封闭在线程模块内部,对主线程透明。天下没有免费的午餐,这种设计必然有它的局限,一是减少了工作线程的功能,只完成单一、清晰的任务,将复杂的任务留给主线程,主线程的工作量比较大。二是资源加载线程必须配合一种资源加载预测算法,通过预读的方式以实现后台加载对主线程透明,对于希望不通过预读就能实现资源后台加载的情况还需要主线程做轮询或者响应回调。
下面具体谈谈资源加载线程和绘制线程的设计与特点。
资源加载线程为避免复杂性,不做游戏对象的后台创建,只做游戏资源对象的创建。举例说就是不去创建代表主角或者NPC的模型对象,只创建模型对象所用到的mesh对象和贴图对象。并且资源对象的创建分为两步,先是资源文件到内存数据块的加载,再是内存数据块的解析,创建最后的资源对象(包括分配显存资源)。加载线程只完成资源创建的第一步,就是加载资源文件到内存,资源的解析和创建保留在主线程完成,但也属于加载模块的内部实现,对主线程的其他逻辑透明。这样做的目的就是最大化的简化加载线程的工作,虽然将数据解析和对象创建留给了主线程,但加载线程已经完成了开销最大的文件IO工作,因此加载线程还是分担了相当大的工作量,避免了主线程因为文件IO而阻塞。加载线程不做资源对象的创建还有另一个目的,就是资源对象创建往往要分配显存资源,要和3D设备打交道,这就需要与图像显示线程通信、同步,上面的设计加载线程不需要知道显示线程的存在,把与显示线程的同步问题交给主线程控制。下面让我们来看看加载线程的工作情况吧。主线程可以在任何时候提出加载请求(注意,这里指预读请求),其主要参数就是待加载文件的文件名。加载模块将请求放入一个待完成请求的队列。加载线程不断从请求队列中取出命令消息,完成文件的打开及读取,将加载后的内存指针保存到命令消息结构中,并把完成后的命令消息增加到一个已完成命令的队列中去。主线程在每一帧的末尾会调用加载模块的一个后处理函数,这个函数从已完成命令的队列中取出每一个命令消息,完成最终的资源解析和资源对象创建,并把创建的对象加入一个以资源文件名为所引的哈希表中,以便后面查找。要注意的是这一步是在主线程中完成,所以不需要与主线程的其它部分做任何同步,因此可以轻松完成很复杂的操作。至于资源创建与显示线程的同步将在后面的图表中看到。当主线程真正需要创建游戏对象的时候,游戏对象所依赖的资源对象已经完成了加载,所以游戏对象可以快速的创建出来。
再来看看绘制线程。同样为了简化绘制线程的复杂性,绘制线程不做显存资源的创建工作,而把这个工作留给主线程。这与许多文章中建议的把对3d设备的所有操作都交给一个线程去做不一致。绘制线程与主线程的通信靠一组绘制上下文完成。在主线程的每一个逻辑帧完成后,主线程为每一个需要绘制的对象向绘制线程提交一份绘制上下文,这个绘制上下文包括完成绘制任务所需的一切信息,如VB、IB、顶点声明、Shader以及所有Shader参数。有了这个上下文绘制线程就可以独立完成工作,不需要再和其它对象通信了。在每一帧的开始,主线程进行当前帧的逻辑运算,绘制线程同时对前一帧提交的绘制上下文做处理,绘制一帧画面。当主线程完成了当前帧的逻辑运算,而绘制线程还没有完成前一帧的绘制,或者绘制线程完成了绘制而主线程还没有完成逻辑计算时它们要相互等待,直到两个线程都完成了工作。也就是说在每一帧的末尾,主线程会和绘制线程进行一次同步,在这个同步点后,绘制线程处于等待状态(阻塞),而主线程开始调用加载模块的后处理函数,完成资源对象的创建(包括显存资源的创建,注意这里绘制线程已经阻塞,不用再对3d设备的操作做同步了),然后主线程为当前帧提交一组绘制上下文。走到这里主线程通知绘制线程开始运作,绘制已经提交的上下文,同时自己也开始新的一帧的逻辑处理。
千言万语不如一副图片来得明白,最后贴上三个线程工作的时序图作为结尾。