先看看 OSG 3.6.4 线程模型枚举变量的定义:
enum ThreadingModel
{
SingleThreaded,
CullDrawThreadPerContext,
ThreadPerContext = CullDrawThreadPerContext,
DrawThreadPerContext,
CullThreadPerCameraDrawThreadPerContext,
ThreadPerCamera = CullThreadPerCameraDrawThreadPerContext,
AutomaticSelection
};
你会发现出现了两次等值枚举变量名称: ThreadPerContext = CullDrawThreadPerContext 和 ThreadPerCamera = CullThreadPerCameraDrawThreadPerContext, 之前OSG 线程模型的定义有些让人费解,出现了“词不达意”的问题,我们在写程序“造词”的时候,一般要求看到变量或宏名或类名就知道其功能,这样便于其他人理解和阅读,ThreadPerContex 这类枚举变量不去认真分析下,还真不知道什么意思。 CullDrawThreadPerContext 表达的就很清楚了,场景对象裁剪和绘制都在设备线程中进行。CullThreadPerCameraDrawThreadPerContext 表明 相机线程负责场景裁剪,设备线程负责绘制。可以看出OSG官方也对不好理解的枚举变量定义相当于做了注解,加以修正。
从枚举变量可以看出,OSG主要有四种线程模型来适应不同的硬件或用户需求。
SingleThreaded : 单线程模型最好理解了,不做任何优化,按最常规方式渲染整个场景,OSG 没有开启另外的线程来加速场景绘制。SingleThreaded 模型并没有最大化的利用多处理器、多任务的硬件, 系统的渲染效率较低。在这里我们先看一下OSG 渲染一帧,要经历哪几个过程:
上图是一般渲染引擎渲染一帧所经历的主要过程,各种渲染引擎大同小异,多线程也得走这个过程,只是为了对渲染效率做优化,在一定的条件下(比如Update, Draw阶段要求节点数据不存在不一致的情况)允许各阶段有所重叠。
那么针对多窗口的情况(也就是多上下文的情况, OSG 把一个窗口看成一个context, 记住Context 就是窗口,窗口就是Context ),OSG 如何优化来提升效率呢?这就是 CullDrawThreadPerContex 。CullDrawThreadPerContex : 通俗的来说,我们为了多屏阵列显示创建了四个窗口(4 contexts)来渲染整个场景,总不能做个大的渲染循环逐个窗口去绘制吧?!这样做效率很低。OSG 为每个窗口各自创建了线程,每个窗口线程负责自己的场景裁剪和绘制工作,主线程负责更新,最后再来一步帧同步操作,不就very good 了。
这种线程模型也有它的缺点,由于主线程负责更新操作,当场景更新比较耗时时,同样会使帧率变慢。而且设备线程负责Cull和Draw两个阶段, 一般都是Draw 耗时最大,有没有可能把将当前帧的Draw 和 下一帧的场景Cull 同时进行能呢? 这就是DrawThreadPerContext 线程模型。DrawThreadPerContext :该线程模型将设备线程中的Cull 与 Draw 分离,让引擎主线程负责Cull , Cull 完之后交给设备线程去绘制,这无疑又加重了主线程的负担,但在场景节点数据更新不频繁或几乎没有更新的情况下,效率提升的还不错。
如果让其它线程干Cull的事是不是会更高效,也减轻了主线程的负担? 于是OSG 就有了 CullThreadPerCameraDrawThreadPerContext...
CullThreadPerCameraDrawThreadPerContext :
很明显最后一种线程模型效率最高,相机线程负责裁剪操作,设备线程负责绘制,两个线程主要(Update 工作还是由引擎主线程干)负责完成场景的绘制,也更符合现在计算机硬件条件,让多个核心不闲着。
ViewerBase::ThreadingModel ViewerBase::suggestBestThreadingModel()
{
std::string str;
if (osg::getEnvVar("OSG_THREADING", str))
{
if (str=="SingleThreaded") return SingleThreaded;
else if (str=="CullDrawThreadPerContext") return CullDrawThreadPerContext;
else if (str=="DrawThreadPerContext") return DrawThreadPerContext;
else if (str=="CullThreadPerCameraDrawThreadPerContext") return CullThreadPerCameraDrawThreadPerContext;
}
Contexts contexts;
getContexts(contexts);
if (contexts.empty()) return SingleThreaded;
#if 0
// temporary hack to disable multi-threading under Windows till we find good solutions for
// crashes that users are seeing.
return SingleThreaded;
#endif
Cameras cameras;
getCameras(cameras);
if (cameras.empty()) return SingleThreaded;
int numProcessors = OpenThreads::GetNumberOfProcessors();
if (contexts.size()==1)
{
if (numProcessors==1) return SingleThreaded;
else return DrawThreadPerContext;
}
#if 1
if (numProcessors >= static_cast<int>(cameras.size()+contexts.size()))
{
return CullThreadPerCameraDrawThreadPerContext;
}
#endif
return DrawThreadPerContext;
}
OSG 选择使用哪种线程模型是以窗口和相机数为准则,再考虑CPU物理核心数给出结果。 如果是单窗口应用 CPU 是单核, 返回 SingleThreaded,这没啥好说的。 如果CPU 是多核,主线程负责场景对象裁剪, 返回 DrawThreadPerContext。也就是说 单窗口应用的情况下,绘制的活要不就主线程干,CPU 核心多的情况下,要不就另开一个设备线程,让设备自己干,多核情况下,设备线程加上主线程共两个线程干活,那要是多相机的情况呢(这种情况在CAD多视口应用中很常见)?岂不是没有最大化利用CPU多核的优势? 所以对于CPU多核,单窗口多视口的应用,suggestBestThreadingModel 返回的线程模型不是最优的,CPU物理核心多的情况下,最好自己配置OSG线程模型。如果窗口数+相机数小于等于 CPU物理核心数,则 相机线程负责裁剪, 窗口线程负责绘制,返回 CullThreadPerCameraDrawThreadPerContext。其它多窗口,CPU多核的情况,OSG 为每个窗口创建一个设备线程,负责场景绘制, 返回 DrawThreadPerContext。这种情况下,我认为如果相机数量和闲着的CPU核心数相差不大的话,应该还是用CullThreadPerCameraDrawThreadPerContext比较合适。
多线程渲染带来的一个突出问题就是数据同步问题,如果场景正在绘制,节点数据或场景关系突然被改变了,系统就会崩溃或绘制错误的场景。由于 OSG 场景图使用的智能指针管理节点,对节点的增删不会影响系统的稳定性。 OSG 主要采用了两种策略来应对数据更新问题。
首先,对场景组织来说,使用了两个SceneView 来维持场景节点关系的稳定性,在裁剪阶段生成的 状态树 和 渲染树 的结果只保存在当前SceneView对象中,下一帧对场景节点做预裁剪,OSG使用另一个 SceneView 来保存下一帧的预裁剪结果,这两个SceneView 交替使用也使渲染更加平滑。
对节点数据的更新,OSG 将数据变动的节点标记为 dynamic 类型,只有当前帧渲染完全部被标记为动态的节点,才允许下一帧的更新,从而杜绝了数据的不一致性导致系统崩溃的发生。
从以上OSG的线程模型可以看出,这种渲染线程的划分粒度只在Camera 和 Context(窗口)的级别,对多窗口和视口有要求的应用能明显的提升渲染效率,而我们经常用到的单窗口和视口的应用,这种线程模型显示加速有限,不能尽可能多的使用CPU多个物理核心。使用多线程技术对场景渲染进行加速也有其局限性, 比如在GPU 端, 由于OpenGL 是状态机 ,GPU 端资源的管理是跟OpenGL上下文捆绑在一起的,如果多个场景节点绘制过程有多个GPU 资源交叉绑定必然会引起渲染错误。Vulkan 图形API 已经废弃了OpenGL 状态机这套机制,而且不用在硬件层面上做错误检查,在GPU端以CommandBuffer 的形式绘制实体,为GPU端并行渲染提供了可能,Vulkan 的API更贴近硬件调用,所以Vulkan 效率要好于OpenGL。
既然在GPU端,绘制图形时我们不能采用多线程加速的方式,那么我们只有在应用级别对节点的资源,位置更新,裁剪、排序,渲染状态分组合并来减少OpenGL状态切换上多下功夫。 那么怎样对我们常见的单窗口、单视口应用最大化的利用多线程加速呢?那就是。。。对场景划分更小的粒度,便于使用多线程并行渲染技术。
场景拓扑扁平化(一维数组) VS 层状(一般是树状层次结构)
一般我们将场景节点组成树状分层结构,一是更加直观更符合思维模式,且方便节点的遍历与查询。比如查找parent 和 sibling,接口非常容易实现。但这种数据结构它也有自己的局限性,关联性太强,不方便施加并行计算,需要“解耦”。以OSG 为例,比如一个节点对象的最终矩阵必须通过自上而下的遍历父子关系才能获得,当然了我们可以把节点的变换抽象成属性,挂载在对象上,而变换关系之间再去维护一套关联机制。OSG 节点设计的过于灵活,实际上也不是什么好事,给使用者太自由了,对不同格式的三维文件表达都成问题,乱了。。。,比如对Mesh 来讲, 一般我们认为 mesh 是带有材质\几何信息的节点,但OSG Drawable 可以设置材质,Geode 也可以设置材质,Drawable 是Mesh ,还是Geode 是mesh? 很容易混乱。没有规范去约束是不中的,你看看OSG 乱糟糟的 读取第三方格式的解析库就明白了。当然对于纯粹的显示场景,而对场景交互要求不高,不需要对实体进行查找,这种组织方式也无妨。
如果将场景节点组织扁平化,也就是用一维数组的形式,场景节点的关联关系、空间变换关系、材质关系以属性的方式链接到对象上,给节点"解耦",这种数据结构就能够很方便的进行并行计算了,只是查询\组织父子关系相对树状层次组织方式麻烦了一些,底层实现更为复杂,需要调用其它接口来完成,如果上层API封装好的话,对用户来讲完全是透明的。filament 渲染引擎就是以一维数组的形式组织场景节点,渲染过程中采用"分而治之"的并行算法执行场景节点的更新、裁剪、渲染命令的生成等工作。
很明显,Filament 的架构更灵活,渲染效率更高。OSG 多线程渲染架构突出的亮点就是它将场景绘制(Draw)、用户交互,裁剪(Cull)等过程都抽象成了一个操作,既可以长驻在线程事件循环队列中不断执行,也可以执行一次就自动销毁了,这种设计模式极大的方便了用户的扩展,用户可以将想要执行的交互事件,请求等都封装成一个操作,让引擎去执行。
OSG 其它方面也有不少优点:
系统比较稳定,完全可以商业化使用;提供的工具丰富,尤其对虚拟仿真,有许多现成的工具可用,比如阵列显示,立体显示,基于多窗口\视口的选择交互组件等;场景裁剪策略比较完善,功能比较弱的引擎一般只提供视锥体裁剪, 包括这个尚未完善的filament ;最大的缺点: 固定管线废除后,可以说是没有材质模块,对渲染真实性要求较高而又不想写材质系统的,还是放弃OSG吧。不过Sketchfab 是用OSG 的 web 版本开发的,实现了物理材质系统,渲染的很逼真。小弟也用了些时间实现了OSG的物理渲染,效果还过得去,也遇到了一些问题,主要是框架的局限性导致延迟渲染效率不高,这点主要体现在对场景对象的排序与剔除上,除非要对OSG框架做一些改动,才能根治。比如透明对象的拣选,在OSG cull 阶段是无法拣出来的,你或许会说StateSet 已经设置了binNumber标识透明对象, 但场景对象的透明情况复杂多样,有的是上层节点覆盖了子节点的材质,有的是对象自身材质透明属性动态变化,这种依据binNumber 而不是 材质属性去判断最终绘制对象是否透明是不合适的,且系统也不会根据材质属性自动拣选半透明对象。
原文链接:https://blog.csdn.net/fatcat123/article/details/102775600