本文根据小米互娱 VR 技术专家 房燕良在 MDCC 2016 移动开发者大会上的演讲整理而成,PPT 下载地址:http://download.csdn.net/detail/sinat_14921509/9639244。
房燕良,从 2001 年开始,自主研发 3 代游戏引擎,发布游戏超过 10 款。代表作品有《仙剑3》、《功夫世界》、《龙online》、《神兵传奇》等。从 2007 年开始接触虚幻引擎,对虚幻引擎有深入的研究和实践。目前就职于小米,从事 VR 方面的研发工作。
【以下为演讲实录】
大家上午好!今天,我和大家分享的主题是虚幻 4 渲染系统结构解析。 内容主要包含以下几个模块:
下图相当于一个 3D 引擎与渲染系统相关的几个模块。一个是资源系统,一个是材质系统,还有场景的管理,渲染相关的就是渲染管线的管理。这几个模块在下层都会调用图形 API 实现渲染功能。整个 3D 引擎包括渲染系统最核心面临的问题主要是两个:管理复杂度和效率。
复杂度是现在整个 3D 引擎包括渲染难度系数最高的,要实现各种各样的渲染效果、渲染算法以及各种各样优化算法。
“对游戏来说,效率就是生命”。——卡马克
效率,一是从图形算法方面,可变性的判定、流程控制的优化、平衡 CPU 跟 GPU 的工作。二是软件开发者一定是关心硬件的,意味着另一个核心问题是如何高效发挥 GPU 的高并发流水线的架构以及 GPU 上各种 Cache 如何能够帮助 Driver 提高命中率。
渲染系统模块:
接下来从数据和逻辑两个方面解析虚幻 4 渲染系统,在论及引擎的数据管理与渲染的流程控制之前,我们先理解何为渲染线程。渲染线程机制是从虚幻 3 开始引入的,当时有一个开发代号叫做 Gemini,为什么要引入渲染线程,当然主要是从效率方法考虑。一个游戏最终开发出来之后实际上有三个大的模块是占每一帧时间最多,分别是渲染、游戏逻辑包括脚本更新、以及物理模拟。因此,如果把渲染和游戏逻辑更新并行起来,就可以得到一个显著的效率提升,如下图所示。如果没有渲染线程,游戏逻辑的更新和渲染是串行的,一帧所占的时间是两块执行的总和。如果使用了渲染线程之后,一帧的时间就是两者耗时最长的那个时间,这是一个理想情况,理想情况会有一个显著的渲染提升。
既然多了一个重要的线程,就会涉及到两个线程之间同步的问题。线程之间同步分两方面:
1、因为游戏有运行的速率控制问题,意味着对于游戏来说,往往游戏线程负载是低一些,渲染线程是控制一些,游戏线程疯狂往前跑也没有太大的意义,所以它有一个 Render Command Fence,防止游戏线程跑得太快。好比前台我们正在看的画面,如果是第N帧,渲染线程可以渲染第 N+1 帧,游戏线程可以渲染第 N+2 帧。
2、游戏线程同步场景管理增加了渲染线程后,整个游戏的复杂度大大提升了。游戏线程要修改它的数据,渲染线程也要修改它的数据,也很麻烦,容易出错。所以在虚幻情景下,使用了一个 Proxy 对象的模式去处理它,在游戏逻辑里面处理的一个游戏对象会在渲染线程里面对应一个 Proxy 对象,该Proxy 对象的游戏更新完全在渲染线程里面做。另外在渲染线程里,因为每一帧会有特定的状态数据,这些状态数据每一帧都在变,这个其实也没有太好的办法,在每一帧的时候,要把独特的数据进行拷贝。
下图是渲染线程跟主线程的基本关系,主线程会通过渲染命令的队列往渲染线程发消息,渲染线程会从命令队列里读取命令,它们之间有一个 Render Command Fence 这样一个机制。
接下来看一下虚幻引擎场景的数据管理的一些核心类。虚幻引擎场景的数据管理分了两层,一层是比较熟悉的 UWorld,主要面向游戏逻辑开发,为了在上层做逻辑控制时较为方便去管理,比较方便实现上层控制逻辑。
对于渲染来说,UWorld 对应 FScene 对象,这个数据接口的设计主要面向浏览器,由FSceneRenderer 这一类,实现了两个派生类,一个是 FForwardShadingSceneRenderer 前置渲染,还有一个 FDeferredShadingSceneRenderer 就是延迟渲染。在 model 4.0 以下的,是逻辑渲染。如果是在 Shader Model 4.0 以上会选择延迟渲染。
另外有一核心的类是 FSceneViewFamily,在这一帧可以渲染的多个 view,个人理解最早是在单机游戏多人同时玩的分屏游戏,主要是游戏机上的游戏,比如极品飞车,可以选择两个人同时玩,两个人是在同一台游戏机上玩,在屏幕上就会分两个视图,比如我的游戏视图是再上一版,你的游戏视图是在下面一版。这是分类的一个出发点。现在 VR 兴起之后,要做 VR 渲染,正好也要分屏,左眼的图象在图片左边,右眼的图象在图片右边。
另外还有一类是 FViewInfo,有一个新的 view,FViewInfo 是定义在 Render 的模块里面,在新的 view 里面又渲染了一些新的模块的特定数据,每一帧会有一些自己的状态,要进行一些拷贝,这里面有一部分数据保存在这个新 view 这一类里面。
刚才讲了场景整体,还有单个对象的数据管理,接下来就看一下渲染的流程。这里是一个伪代码,把引擎里渲染相关的一些关键步骤提取出来,这个不是全面的,只是为了突出重点,只是一些重点步骤。
void UGameEngine::Tick( float DeltaSeconds, bool bIdleMode )
{
UGameEngine::RedrawViewports()
{
void FViewport::Draw( bool bShouldPresent)
{
void UGameViewportClient::Draw()
{
//-- 计算ViewFamily、View的各种属性ULocalPlayer::CalcSceneView();
//-- 发送渲染FRendererModu命le令:::BFeDgrianwRSecnedneerCionmgmVainedwFamily()
//-- Draw HUD
PlayerController->MyHUD->PostRender();
}
}}}
FrameEndSyn
void FRendererModule::BeginRenderingViewFamily()
{
// render proxies update
World->SendAllEndOfFrameUpdates();
// Construct the scene renderer.
// This copies the view family attributes
// into its own structures.
FSceneRenderer* SceneRenderer =
FSceneRenderer::CreateSceneRenderer(ViewFamily);
ENQUEUE_UNIQUE_RENDER_COMMAND_ONEPARAMETER(
FDrawSceneCommand,
FSceneRenderer*,SceneRenderer,SceneRenderer,
{
RenderViewFamily_RenderThread(RHICmdList, SceneRenderer);
FlushPendingDeleteRHIResources_RenderThread();
});
}
接下来用伪代码的方式来看一下渲染主干的流程,首先入口还是 RenderViewFamily_RenderThread() 这个函数,第一步进行 InitViews(),首先调用 Primitive Visibility Determination 进行剪裁,然后是透明物的排序,然后是灯光的可见性,然后就是不透明物体的排序。
接下来通过很多的 pass 来实现整个渲染。首先会有一个 base pass,建立一个 base 缓冲,然后通过 base pass,填充 GBuffer 的缓冲,然后是渲染所有的灯光,后面就是渲染天光,渲染大气效果,渲染透明对象,渲染屏幕区特效,所有这些渲染完之后, SceneColor() 就完成了,最后进行后处理,最后是调用 RenderFinish()。
RenderLights 粗略的逻辑是,场景所有的灯光都要调用 RenderLights() 函数,在该函数里面调用两个 Shader 去画灯光在屏幕空间的影响区域。
void FDeferredShadingSceneRenderer::Render()
{
bool FDeferredShadingSceneRenderer::InitViews()
{
//-- Visibility determination.
void FSceneRenderer::ComputeViewVisibility()
{
FrustumCull();
OcclusionCull();
}
//-- 透明对象排序:back to front
FTranslucentPrimSet::SortPrimitives();
//determine visibility of each light
DoFrustumCullForLights();
//-- Base Pass对象排序:front to back
void FDeferredShadingSceneRenderer::SortBasePassStaticData();
}
}
void FDeferredShadingSceneRenderer::{ Render()
{
//-- EarlyZPass
FDeferredShadingSceneRenderer::RenderPrePass();
RenderOcclusion();
//-- Build Gbuffers
SetAndClearViewGBuffer(); FDeferredShadingSceneRender::RenderBasePass();
FSceneRenderTargets::FinishRenderingGBuffer();
//-- Lighting stage
RenderDynamicSkyLighting();
RenderAtmosphere();
RenderFog();
RenderTranslucency();
RenderDistortion();
//-- post processing
SceneContext.ResolveSceneColor();
FPostProcessing::Process();
FDeferredShadingSceneRenderer::RenderFinish();
}
void FDeferredShadingSceneRenderer::RenderLights()
{
foreach(FLightSceneInfoCompact light IN Scene->Lights)
{
void FDeferredShadingSceneRenderer::RenderLight(Light)
{
RHICmdList.SetBlendState(Additive Blending);
// DeferredLightVertexShaders.usf VertexShader = TDeferredLightVS; // DeferredLightPixelShaders.usf PixelShader = TDeferredLightPS;
switch(Light Type)
{
case LightType_Directional:
DrawFullScreenRectangle();
case LightType_Point:
StencilingGeometry::DrawSphere();
case LightType_Spot:
StencilingGeometry::DrawCone();
}
}
}
}
接下来主要分析虚幻 4 的 VR 渲染是如何实现的。虚幻 4 的渲染或者整个引擎实现的思路跟 Unity 差距还是很大,Unity 个人理解最大的好处就是统一性做得非常好,包括 Camera 这块也是做得非常好,Camera 不光代表一个视点,而且也管理一个渲染管线。因为里面如果实现 VR 渲染,相对来说好理解一些,很直接相当于可以放两个摄像机,一个是放左眼图象,一个放右眼图象。这样的结构非常清晰,但是不太好做一些深层次的优化。在虚幻 4 引擎里面,实际上把整个 VR 整合到整个引擎各个逻辑流程,各个模块里面,所以它能够比较好实现优化。新的 VR 主要是 Scene View Family 和Scene View 为基础的。
首先看一下代码目录,在Plugins/Runtime/GoogleVR/GoogleVRHMD等等里面。
插件有两个主要类,一个就是 GoogleVRHMD,另外是 GoogleVR HMDCustomPersent,前面讲了 VR 是把流程整合到每一步的逻辑里面去,所以它会选出来一些接口。这里只列了一些重点函数,接口都挺大的,里面的函数都非常多。
谷歌 VR HMD 主要实现了两个 interface,一个是 AdjustViewRect(),这一类比较简单,上述讲每一帧开始渲染的时候,会计算新 view 的一些状态和参数,相当于有一些函数在不同的时机可以参与计算或者新的 SceneViewFamily 还有 SceneView。这个比较简单,就是模块的起始、停止。
另外还有一个就是 CalculateStereoViewOffset() 接口,这个是实现立体渲染的一些核心操作,都要实现这个接口的一些方法。这两类实际上起到一个包装 VR SDK 和黏合层的作用。
接下来从代码流程来看一下 VR 渲染相关的一些步骤。首先在引擎 Init() 的时候,会查找所有 HMD 的模块,一旦启动了这个插件,它在引擎 Init()的时候,就会创建 HMDDevice,在启动的时候才会启动 VR 渲染。
//-- 在引擎启动时,会创建所有的HMD设备void UEngine::Init()
{
bool UEngine::InitializeHMDDevice()
{
for (auto HMDModuleIt = HMDModules.CreateIterator();
HMDModuleIt; ++HMDModuleIt)
{
IHeadMountedDisplayModule* HMDModule = *HMDModuleIt;
HMDDevice = HMDModule->CreateHeadMountedDisplay();
}
} }
SceneViewFamily 和SceneView是如何启动VR渲染的,首先要看是不是启动立体渲染,如果是立体渲染,view会被强制设定成两个,然后会在每一帧,有一个接口,给你机会做这么几件事,一个是调整那个视口的范围,还有一个就是因为VR渲染两个摄像机的相应眼睛的位置是有一定距离的,可以去调整view的视点的距离。
//-- 在View绘制时,如果是Stereo则绘制两个View
void UGameViewportClient::Draw()
{
const bool bEnableStereo =
GEngine->IsStereoscopic3D(InViewport);
int32 NumViews = bEnableStereo ? 2 : 1;
for (int32 i = 0; i < NumViews; ++i)
{
}
}
void UGameViewportClient::Draw()
{
ULocalPlayer::CalcSceneView()
{
ULocalPlayer::GetProjectionData()
{
GEngine->StereoRenderingDevice->AdjustViewRect(StereoPass);
GEngine->StereoRenderingDevice->CalculateStereoViewOffset(StereoPass);
ProjectionData.ProjectionMatrix=GEngine->StereoRenderingDevice->GetStereoProjectionMatrix(StereoPass);
}
}
}
接下来看一下谷歌 VR HMD 里面插件的代码。首先通过刚才的AdjustViewRect,把viewport作一个调整,如果是左眼pass,就会调整左边的一版,如果是右边pass而就会调整右边的一版。另外通过CalculateStereoViewOffset()的方法去调整试点的位置,首先它是调用了SDK里面取得两眼同距的方法,通过计算算出眼睛view的location的偏离量。
最后是在Render,这个也是接口函数,在RenderThread里调用的一个方法,这个方法最终会调用谷歌 VR 的 API,会把普遍图象和专业图象调到VR SDK,再有它进行操作反映到手机屏幕上。
void FGoogleVRHMD::AdjustViewRect(StereoPass, int32& X,
int32& Y, uint32& SizeX, uint32& SizeY) const
{
SizeX = SizeX / 2;
if( StereoPass == eSSP_RIGHT_EYE )
X += SizeX;
}
void FGoogleVRHMD::CalculateStereoViewOffset()
{
const float EyeOffset = (GetInterpupillaryDistance() * 0.5f)
* WorldToMeters;
const float PassOffset = (StereoPassType == eSSP_LEFT_EYE) ?
-EyeOffset : EyeOffset;
ViewLocation +=
ViewRotation.Quaternion().RotateVector(FVector(0,PassOffset,0
));
}
void FGoogleVRHMD::RenderTexture_RenderThread()
{
gvr_distort_to_screen(GVRAPI,
SrcTexture->GetNativeResource(),
CachedDistortedRenderTextureParams,
&CachedPose,
&CachedFuturePoseTime);
}
了解最新移动开发相关信息和技术,请关注mobilehub公众微信号(ID: mobilehub)。