游戏引擎首先解决的任务就是渲染,N3的渲染架构是一个多线程渲染架构。渲染线程是主线程外的一个线程,主线程操作的是GraphicsEntity , 而渲染渲染线程操作的是InternalGraphicsEntity。这章节想尽可能详尽的解析N3的渲染框架的实做手法和设计理念。同时以编写一个简单的例子为目的,实现场景对象的添加和控制。让一个角色在场景中从A点移动到B点。
渲染系统概述
N3 的渲染系统主要分成如下的几个部分:帧管理,视域裁剪管理,动画系统,光照系统,character系统,粒子系统,渲染插件,调试渲染系统,输入输出管理,模型系统,和前面提到的资源系统。N3的多线程渲染框架,把场景对象的操作和场景对象的渲染分到两个线程。对象操作在主线程,渲染线程是子线程。
对于深入渲染系统的实做之前,先要对这两个线程划分,职责,以及线程中可操作数据要有一个比较清晰的理解。前面提到过一个多线程渲染的实现方案,是缓存渲染指令,然后再在独立线程执行指令批调用,这个方案不是N3的方案,而且这个方案存在一个比较困难的地方就是渲染数据,指令可以缓存,但数据本身会导致一些问题,比如是否需要用锁的机制等来保证渲染数据的安全。N3实现的是一个胖线程,渲染线程拥有渲染相关的所有数据,对于需要渲染线程和主线程访问的数据进行一个共享器的处理,在共享器中会保留两份数据。主线程操作主线程对应的场景对象,这个场景对象会在渲染线程存在一个与之一一对应的对象,主线程的操作通常是会通过消息发送给渲染线程。在代码的结果上来说,主线程可以操作的数据是render/graphics中定义的类,这些类在渲染线程也会存在与之一一对应的类,其中渲染线程由graphicsinterface进行创建和初始化,主线程操作的接口是graphicsserver。在解析渲染前,需要对渲染的数据,也就是所谓的游戏场景构成要个概念。
场景构成
在大多数的游戏引擎中,通常会有一个SceneManager 以及 Scene 的概念,是的,N3中也是有的,只不过是名字变了,而且对结构做了一些调整,以解耦sceneManager的各种功能。在N3中有一个架构来主导渲染框架,就是 Stage - View - GraphicsServer。这个框架会在主线程,以及渲染线程分别构建一套,这两个是一一对应的。先来看下主线程相关的框架,N3的文档中会把主线程当做client端,而渲染线程当做server端。
- Stage 是相当于场景,N3的设计者把这个概念形象的比喻为我舞台,Stage由各种GraphicsEntity组成,相当于舞台上的道具,灯光,音效,摄像机等,这些有些是可见,有些是不可见的
- View 相当于一个镜头,一个观察点,不过这个观察点会和一个摄像头绑定。所以,Vew 需要 和 一个 Stage,以及一个舞台上的 CameraEntity绑定。
graphicsserver负责 创建 Stage 和 View,也负责更新Stage和View。同时还负责管理RenderModel, 对RenderModel进行更新。
多线程渲染框架
这一套在主线程,在渲染线程同样会存在一套 internalStage - internalView - internalGraphicsServer,两套之间通过三个渠道进行交互:
- 一个是会在主线程这边的对象中放置一个ObjeRef引用渲染线程的对象,所有从对主线程执行的操作都可以非常快速的对应到渲染线程的对象;ObjRef 的实现依赖一个interlockExchange,并且ObjRef 最好只是用在从主线程发送消息到渲染线程的消息结构体中做一个句柄功能,从ObjRef这儿句柄进行解引用的操作还是放在渲染线程中。
- 第二个是一个FrameSync::FrameSyncSharedData,这个数据是一个线程的共享数据体,类似一个RingBuffer,一个线程写,另外一个线程读,不过这里的共享数据结构运行两个线程同时写,只是需要自己保证数据的有效性,如果是和RingBuffer一样,一个线程读一个线程写,就课可以保证共享数据的一致和有效;FrameSyncSharedData的实做方式前面有提到一点点,这里深入的看一下,FrameSysncShareData在N3中诛仙是用来干这么一件事情,主线程需要访问渲染线程的数据,这些数据由渲染线程更新,而主线程只是访问,FrameSysncShareData保证这两个线程对数据的写和读可以并行,而且不需要锁,这个实做依赖一个线程同步机制,FrameSyncHandlerThread, 这个会同步各个线程的帧,给出整个系统的一个帧号,而FrameSysncShareData依赖这个帧号,对两份数据进行分配,比如奇数帧的时候,把第一份数据分配给主线程进行访问,第二份数据给渲染线程进行写入,而偶数帧的时候,进行对调,第一份数据给渲染线程写入,第二份数据给主线程访问。这个机制依赖一个共享的数据,任意时刻渲染线程和主线程都是根据同一个帧号进行数据检索的,而且任意一个线程都不会保留一个对数据的指针或者引用。
- 第三个就是线程间的消息通讯了。
这其中出现了一个新的类 FrameSyncHandlerThread, 这个是从 public Messaging::HandlerThreadBase 继承来的,它就是真的渲染线程,这个线程定制了一个机制,用一个 Threading::ThreadBarrier,开启lock-step模式下同步主线程和渲染线程,用一个Threading::Event同步在非lock-step模型下同步渲染线程和主线程,这里的出现了两个同步,这两种模型下的同步分别具备不同的意义,
- 在lock-step模型下,同步意味着主线程和渲染线程的帧率是一样的,也即是两个线程拥有同一个绝对的时间线,渲染线程先完成一帧的渲染,如果主线程还没有完成这一帧的逻辑,就会让渲染线程等待主线程,如果主线程先完成逻辑运算但是渲染线程还没有完成渲染,则让主线程等待渲染线程。在被等待线程完成工作到达同步点的时候,会更新帧的定时器等数据,然后唤醒等待线程;
- 在非lock-step模式下,渲染线程不需要等待,主线程需要等待,在这种模式下,就可能会造成开始讨论的FrameSyncSharedData数据的线程冲突,比如渲染线程跑到第n帧,逻辑线程在第n帧卡在,并且在这个时候通过帧n检索了一个共享数据的引用,然后cpu转到渲染线程,渲染线程进入下一帧,更新帧号到n+1, 并检索n+1对应的共享数据进行写入,这个时候渲染线程写入的共享数据局是主线程读取的共享数据。所以在非 lock-step模式下最好慎重对待FrameSyncSharedData。
在多线程渲染框架下,两个线程共享一个MasterTimer, 同时每个线程都有一个线程安全的LocalTimer : FrameSyncTimer。
Entity映射
有了前面的准备工作后,我们开始对Stage, Entity 进行进一步的剖析。结合第一篇<N3资源系统>,我们知道主线程的舞台是由GraphicsEntity及其子类构成的,类图如下:
而主线程这边的实体构成本身是不管在渲染数据的,真正的渲染Entity需要映射到渲染线程的internalStage及其构成,类图如下:
两个对象的构成几乎一摸一样,只是在主线程的操作Entity类前加了个前缀 internal,是的,在主线程创建一个类型的GraphicsEntity的时候都会发送一个EntityCreate消息到渲染线程,消息中会携带属性信息,以及一个ObjRef,用于渲染线程返回对应实体,还有一个FrameSyncSharedData,用于在两个线程之间共享数据。到渲染线程这边,这些实体类型 大部分是不可见的,都是功能类型的实体,其中只有InternalModelEntity实体是一个可见实体,当然InternalModelEntity也可以是不可见的,要看它的构成。
到这里有个比较需要提一下,就是GraphicsEntity和InternalGraphicsEntity的数据成员构成对比:(左边是 GraphicsEntity, 右边是 InternalGraphicsEntity)
发现 GraphicsEntity 操作的数据非常简洁,可以控制 坐标, 包围盒, 是否可见。在InteralGraphicsEntity中同样包含 transform, localBox 等, 但会多几个数据 clipStatus, timeFactor, entityTime, 这些数据会通过 FrameSyncShareData共享给 主线程,还有一个比较有意思的数据项 links, 这个里面保存了一些链接,N3设计这个是为了做裁剪和光影渲染加速,设计中的links是用来存储 InternalCameraEntity 和 InternalLightEntity的, 而且设计者反复说道这个连接关系是一个双向关系,也就是说,如果一个相机被加入到一个ModelEntity的Camera链接队列,那么这个ModelEntity同时会被加入到相机的Camera链接队列中,对于LightEntity也是一样的,后面在用到的时候会继续对这个做深入的讨论。
到这一步可能您已经发现了这么一件刚刚认为已经找到了的东西,就是GraphicsEntity 和 InternalGraphicsEntity都没有从这一级别定义父子关系,一个Entity没有孩子节点,突然感觉少了点什么,突然要去想怎么实现DX和OpenGL宝典中的那个经典例子,渲染一个太阳系,各种实体的父子关系,然后是各种相对坐标系的继承。是的,N3这GraphicsEntity这一层没有做这么一件事情,但是N3实现一个AttachmentManager,用来专门做这么一件事情,比如要在游戏里面要让一个角色骑上一个狮子,让狮子载着角色狂奔在大草原上。AttachmentManager 操作的是 主线程的数据,在渲染线程由一个AttachmentServer负责管理Entity的绑定事件,以及维护这些绑定点的数据更新。这里有一个需要注意的是,对于多层绑定,N3并没有去特意的做什么机制,比如 A 上的joint_0绑定一个 ResId_B, 得到绑定无是B, 同时在 B的joint_0 上绑定一个ResId_C, 得到C,又在C的joint_0上绑定.......,面对这种绑定,需要使用者自己包装AttachmentServer 和 AttachmentManager 能够以合适的顺序进行数据维护,和前面资源管理系统提到概念一样,对于一个具备层级的树,构造,析构,和更新最好是按照既定的一个可控规则去保证顺序比如前序遍历,或者后续遍历。这里对于绑定系统不再深入了,这个是属于应用层的东西,只是这里需要提出的是这里的links不是通常意义上的孩子节点,不要用干绑定的事情。
下面依然对各种Entity的主线程类型和渲染线程类型的数据成员做一个比较(同时会在右边列出他们之间的共享数据):
(CameraEntity VS InternalCameraEntity)
(AbstrcatLightEntity VS InternalAbstrcatLightEntity)
(GlobalLightEntity VS InternalGlobalLightEntity)
(PointLightEntity VS InternalPointLightEntity)
(SpotLightEntity VS InternalSpotLightEntity)
(ModelEntity VS InternalModelEntity)
通过前面的数据对比,从左边可以看出主线程的控制力度,从右边可以大概知道渲染系统是怎么处理各种类型的Entity,以及渲染数据的在Entity这一层的组织方式,从右边的共享数据可以看出,从渲染可以反馈到主线程的数据是哪些。下面贴一个共享数据的类图:
从这个简单的类图可以看出主线程和渲染线程之间数据的共享结构是非常简单的。通过这一步,我们进一步清晰了操作的Entity和渲染的Entity之间的对应关系,下面深入InternalGraphicsEntity,分析他的构成,清晰渲染对象的数据构成。
InternalGraphicsEntity
在前面的部分中,我们以及了解了InternalGraphicsEntity的继承图,而且知道在子类的实现中,除了InternalModelEntity是可见的Entity类型之外,其他的LightEntity和CameraEntity都是渲染辅助类型。接下里我们将把重点放在InternalModelEntity上。
先再次贴上集成图:
然后依次对每个类型的Entity进行解析,先是基本的InternalGraphicsEntity ,对于这个,N3的设计者总结为:它是渲染系统的原子图形对象,在渲染系统只会有三种类型的这种对象,一个是相机,一个是光源,一个是可见的模型。在可见性剔除检查的时候,会把相机节点link到所有当前相机能够看到的模型和光源上,而光源会link到所有被当前光源影响了的模型和相机上。对于InternalCameraEntity 以及InternalLightEntity的作用是辅助渲染,也就是设置渲染流水线的各种状态。对于相机,以及相机的操作是也是一个相对重要的环节,因为游戏开发的早期迭代就会把这个当做一个基本需求给确定和完成。而对于光源的讨论往往会和阴影以及shade进行讨论,并且光源对于渲染流水线的设置是一个非常宽广的话题,这里先点到为止,不做进一步的探讨,在N3中会有专有的管理器lightServer进行管理。接下来看最重要的一个类型。
一个InternalModelEntity对应一个 ManagedModel ,以及一个 ModelInstance, 而ManagedModel封装了一个 Model,所有同一个resId的ManagedMode共享一个Model实例,这个实例相当于一个原始资源的Copy,而ModelInstance 是从 Model 构建出来的,存储的是每个 InternalModelEntity对应的实例数据。在资源章节中我们以及接触过这一些构成关系,只是没有深入的了解 Model 和 ModelInstance的关系,这里对Model 和 ModelInstance做一个详尽的解析,一个 可渲染的InternalModelEntity实际上是一个可见的ModelInstance,而这个可见的ModelInstance是根据资源模板Model构建出来的,所有同一个ResId的ModelInstance共享同一个Model,一个 ModelInstance 与 一个 Model绑定,同时还与一个 InternalModelEntity一一对应。而一个ModelInstance是一棵由各种类型ModleNodeInstance的树组成,这颗树是由组成Model的ModelNode这棵树生成的,真正渲染的数据就是各种可见的ModelNodeInstance。我们先列举一下Model, ModelInstance 以及 ModelNode, 还有 ModelNodeInstance的类图:
再看下类的协助关系图:
一个Model包含组成它的所有ModeNode, 同时也会保留所有当前Model创建的ModelInstance的实例指针。同时里面还包含Model中所有可见的ModelNode的一个容器。
一个ModelInstance 包含组成它的所有ModeNodeIntance, 同时会保留一个与之关联的InternalModelEntity的实例指针,同时还会保留一个当前ModelInstance对应的Model。
一个ModeNode 包含一个所属的 Model指针,以及 ModelNode 创建的可见 ModelNodeIntance,
一个ModeNodeInstance 包含一个所属的 ModelNode, 以及当前属于的 ModeInstance
有了这些概念后,我们已经可以大概的论述渲染数据了,由ModeNode 和 ModeNodeInstance组成,ModeNode是所有ModeNodeInstance共享的,ModeNode 执行设置公共状态,而ModeNodeInstance提高实例的私有数据到渲染流水线,打个比方,一个ModeNode是一个ShapeNode及其子类,那么Node中肯定就含有一个Mesh, 要渲染这个Node对应的一个NodeInstance, 这个时候重点就是在一个指定的地点渲染一个Mesh,Mesh本身是固定的,只有Mesh的模型空间是不一样的,这样由NodeInstance负责提供一个模型空间的变化矩阵,由Node提供Mesh的VertexBuffer, 以及 IndexBuffer。这样流水线的中需要的几何数据就以及完成了,通过这个就简单的例子,我们就可以知道渲染是由各种Node和NodeInstance配合完成的。下面我们需要列举各个Node以及相应的NodeInstance在整个渲染过程中各自的分工和扮演的角色。
我们指定这两个类图是渲染数据的来源,而流失线则是由其他的模块负责(上图,没有完全列举所有的ModelNode, 还有一个 AnimatorNode),我们先解析 , Node 和 NodeInstance 之间的一个关系。先看下N3的设计定位:
ModelNode : 代表组成Model的一棵层级树上的节点,ModelNode的子类代表的是3D模型的变换信息,或者是几何形状,这些节点按照3d空间层级组织。在Model中这些节点存储在一个数组中,可以有效防止递归访问。一个ModelNode相当于Nebula2中的一个SceneNode。其中与渲染相关的函数是:
这个函数用来设置渲染流水线中的当前ModelNode供所有ModelNodeInstance使用的渲染参数
ModelNodeInstance:一个ModeNodeInstance拥有一个ModeNode的Instance的私有数据,这个类处理相关Model的大部分渲染相关的工作。相关的函数如下:
其中看到了一个ApplyState()和 Render(), 这不一一列举各种Node的实现,只是把关键的路径函数列举出来,
//------------------------------------------------------------------------------------------------
渲染流程的关键函数已经出现。要渲染一个Mesh,可以简单的定义为如下:
由TransformNodeInstance 计算出当前的模型矩阵,然后由StateNode 提交一个shaderInstance, 作为当前的渲染管线,以及负责提交当前渲染管线用到的Texture, 和 其他ShaderVariable, 接着由ShapeNode提交Mesh相关的VertexBuffer和IndexBuffer, 最后是由ShapeNodeInstance发起渲染命令的Call: Device::DrawPrimitive(......)。支持一个简单的渲染流程出现了。
至于N3的渲染框架则还没用结束。这里对整个渲染框架的函数调用路径做一个说明,然后对框架中用到的各个分开单独说明,这些分块都比较庞大,就像前面还完全没用提到骨骼-蒙皮相关的任何东西。
由InternalGraphicsServer::OnFrame发起 ----》
设置相机的各种矩阵:
this->defaultView->ApplyCameraSettings();
对当前要渲染的舞台执行裁剪工作
this->defaultView->GetStage()->OnCullBefore(curTime, globalTimeFactor, frameIndex);
从相机角度更新可见的链接信息
this->defaultView->UpdateVisibilityLinks();
从相机角度更新光阴链接信息
this->defaultView->GetStage()->UpdateLightLinks();
执行渲染动作
this->defaultView->Render(frameIndex);
至此一帧的正常渲染工作就算完成, 在接下来对 view->Render() 分解一下:
// 更新当前帧的光源信息
this->ResolveVisibleLights();
// 把当前帧的可见ModelNodeInstance全部挑选出来
this->ResolveVisibleModelNodeInstances(frameIndex);
//交给frameShader执行渲染pass的调用
this->frameShader->Render();
至此 view的渲染工作也算完成了,接下来对frameShader->Render()进一步分解:
//一次对定义的每个pass执行渲染工作
this->pass[i]->Render()
到这里frameShader把工作全部交给了 pass, 先交代一下pass, 一个 pass 就是把场景类容绘制一遍,多个pass就会绘制多次场景, 这里的多次绘制是有用的,可以绘制到纹理等,这样绘制后的纹理可以在下一个pass里面用到,这样可以实现一些特别的效果。
在接下来分解 pass->Render(): 需要注意的是N3的 pass 分为两种,一个是普通的FramePass,还有一个FramePostEffect,这里先来看下普通的FramePass:
// 先条用renderTarget的各种配置函数,包括清理模板缓存,深度缓存,颜色缓存,还有渲染模板的标准位
this->renderTarget->SetClearFlags(.....);
// 接着是提交各种渲染状态参数,以及shader中用到各种变量
this->shaderVariables[varIndex]->Apply();
// 最后是对pass中的各个批次进行渲染
this->batches[batchIndex]->Render();
分解批次渲染调用 这里 批次的概念比较好理解, 一个场景是各种可见的ModelNodeInstance组成,但是这些ModelNodeInstance是有类型区别的,比如水体,比如刚体等,UI, 文字等,这里一个批次渲染一类ModelNodeInstance可以减少渲染流程线的状态变更,而且更好的利用底层提供的Instances渲染,DX10就实现了这个多实例渲染。好废话不多说,分解一下 batch->Render():
//设置batch对应的渲染参数
this->shaderVariables[varIndex]->Apply();
this->RenderBatch();
// UI, Shapes, Text, Lights, MouserPointer, ResolveDepthBuffer, 这些批次忽略,下面看下正常的场景内容渲染
// 根据nodeFilter 提取当前可见的Model
const Array<Ptr<Model> >& models = visResolver->GetVisibleModels(this->nodeFilter);
// 根据nodeFilter 提取Model中当前可见的 ModelNode
const Array<Ptr<ModelNode> >& modelNodes = visResolver->GetVisibleModelNodes(this->nodeFilter, models[modelIndex]);
// 设置当前modelNode的状态到渲染管线
modelNode->ApplySharedState(frameIndex);
// 根据nodeFilter 提取 ModelNode 中所有可见的 ModelNodeInstance
const Array<Ptr<ModelNodeInstance> >& nodeInstances = visResolver->GetVisibleModelNodeInstances(this->nodeFilter, modelNode);
// 设置当前modelNodeInstance的状态到渲染管线
nodeInstance->ApplyState();
// 调用节点的Render 接口,这个接口实现调用的设备的 drawPrimitive(,,,,,), 这与绘制Call 用到定点坐标,纹理参数,Shader变量等都是通过前面的各种 ApplySharedState , ApplyState()设置,然后由 ShaderInstance->Commit()提交给显卡的。
nodeInstance->Render();
至此,N3的整个渲染流程已经清晰和明了。各种参数的,渲染管线的设置也再流程中体现出来了。后面用详细的文章说明渲染管线中没有深入的各个组件,这其中包括:Mesh - Skeleton - FrameShader - Shader - ShaderInstance - ShaderVariable, LightServer - AnimatorNode - Animate - VisibleSystem - Cull - RenderModels::rtPlugin 等。
这些每一个都是一个主题,而且内容也非常丰富。
期待ing :
看完这些东东,还有一个非常有意思层, Applation 层,
以及 ScriptFeature
以及 PhysicFeature 层。
以及 NetWorkFeature
以及 FmodFeature
期待 ing