[从头搭建游戏引擎] 延迟渲染管线与PBR(上)

最近我开始从头搭建游戏引擎了,打算是学习虚幻以及unity引擎的一些特性,造一个很小的轮子,不求功能完善,但求粗略地了解一下架构,以及尽可能熟悉其中一小部分系统。

github地址: https://github.com/MrySwk/GravityEngine (目前渲染部分代码乱七八糟,之后要重构一次)

图形库用的是DirectX,目前引擎已经完成了基本的界面浏览功能、延迟管线、模型加载以及PBR,效果图如下
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

框架选择


界面

造这么一个轮子的目的是造一个playground,以后学习什么算法的时候可以直接在这个环境下面试验,不用每次头从头做一个小框框,方便我学习引擎中的一些原理,最终目的不是做一个功能完善的可以用的东西出来,首先我想要尽可能简单的就是UI界面,这部分的选择有很多,比如

  1. QT
  2. WPF/Winform
  3. HTML5+CEF

这几个各有各的好处,因为不想在界面上面浪费时间做美工,想轻轻松松就能做的好看,那HTML其实是这里面最好的选择,因为有大量的现有的框架和样式表可以嫖,而且js写一些动态的效果比其他两个方便多了,然而不幸的是我已经很久没写js的东西了,所以这个选择的学习成本比前两个都大。

然后QT和WPF都可以做出好看的效果,QT好处是跨平台,虽然这里没啥跨平台的需求,因为引擎一般还是在win下面用,而且图形库已经选了DX了,所以基本上就是C#和C++的选择了,因为C#写的比C++舒服,不用费心费力管指针管内存,所以这里我最终选择了WPF。


前后台交互

然后现在我希望引擎内核是C++的,界面是WPF,(可能之后还会有lua或者mono做脚本),那么就涉及到了c++和c#的交互,选择有这么几种

  1. P/Invoke
  2. 用C++/CLI封一层
  3. 用C++/CX封一层

第一个是效率最高的,来回的成本最小,但是非常麻烦,中间封装的这层必须按C的标准来,C#里是不能用c++里的类的(虽然还是有办法用),然后交互的时候有很多需要注意的事情
第二个选择,C++/CLI,这个选择很好但是尝试的时候也遇到了一个小问题,那就是Microsoft::WRL和CLI里的一些东西冲突了,其实主要是dx12的示例程序里面都用的是WRL::ComPtr这个指针,貌似也是一种智能指针,我暂时没有去确认下把ComPtr全部换成智能指针会不会有别的问题,感觉这个应该不是必须的,所以把WRL相关的都去掉应该就行了,不是什么大问题,不过为什么要用Com指针而不用普通智能指针可能是有原因的。
第三个选择,C++/CX,提到这个主要是因为这个跟WRL不冲突,而且本身和CLI差不多,也可以作为一种选择。

但是最后我选择了第一种,本来是觉得CLI写会很方便,可以直接用类,但是反过来一想,P/Invoke不可以封装任何成员变量,反倒是减少了C#界面和C++内核的耦合,要用类的话,完全可以c#调用c++的方法,告诉c++来初始化一个对象,然后再用c++提供的接口来间接访问这个类,这样的话有个好处,那就是所有c++里的事情都由c++自己来管,而且原来以为Dll没法debug,结果发现还是可以,只要在项目设置里打开非托管debug就行,所以这样考虑下来应该还是效率最高的P/Invoke作为首选了。


遇到的一个小坑

实际上做这个界面的时候我还是用了Winform嵌入到WPF,主要原因是WPF底层是基于DX的,实际上整个窗口都是一起绘制出来的,只有一个handle,而winform底层是GDI,每个user control都有自己的handle,DX绘图的时候需要传过来一个handle,如果选择用这个handle绘制的话那就把整个窗口给覆盖了,所以其实这里viewport最好能有一个自己的handle,虽然directx里也可以通过设置ScissorRect和Viewport来解决,但是这里我还希望能只在这个viewport里拦截消息,然后把这个viewport里的消息发送给c++来处理,所以这种情况用Winform来是最好的,因此用了一个Winform的control来做viewport。



延迟渲染

现代引擎基本上全部都是以延迟管线为主的,比如虚幻和unity(当然一般还是会保留前向渲染),延迟渲染的效率高,可以渲染大量光源,如果有m个物体,n个光源,延迟渲染是O(m+n)级别的运算量,而前向渲染是O(m*n),所以光源和物体数量一旦多起来,延迟渲染的效率会远远超过前向渲染。

关于延迟渲染的具体介绍可以看毛神写的这篇:

延迟渲染(Deferred Rendering)的前生今世 https://zhuanlan.zhihu.com/p/28489928

延迟渲染是首先将漫反射、法线、粗糙、金属、遮蔽等信息一次性写入到一系列render target(称为G-Buffer)中,然后再利用这些G-Buffer中的信息来渲染光照,也就是说光照是一种后处理。

不过延迟渲染会带来一系列坏处,比如因为在后面渲染的时候已经抛弃了所有的几何信息,所以没有办法使用MSAA了,以及不能用透明度混合。不过还是可以解决的,透明度混合可以再做一个前向管线,然后单独用一个渲染层渲染,抗锯齿则需要另写。

我这里实现的是最经典的延迟管线,Geometry Buffer的分布如下
在这里插入图片描述这里我没有用alpha通道,然后为了方便做材质,第四个Buffer用的是和虚幻一样的遮蔽、粗糙、金属,这样就可以在SP里面做好材质然后直接按UE4标准导出OcclusionRoughnessMetallic贴图。

渲染的第一步,是写入GBuffer,我们直接采样albedo、normal等贴图,渲染到多个render target上,这个部分内容非常简单,即使物体数量非常多,也会非常快,因为不要做任何光照计算,只是单纯地拷贝数据。

像素着色器部分如下,应该还是非常简单的

PixelOutput main(VertexOutput input)// : SV_TARGET
{
	MaterialData matData = gMaterialData[gMaterialIndex];

	uint diffuseMapIndex = matData.TextureIndex[0];
	uint normalMapIndex = matData.TextureIndex[1];
	uint OrmMapIndex = matData.TextureIndex[2];

	float3 normalFromTexture = gTextureMaps[normalMapIndex].Sample(Sampler, input.uv).rgb;

	float3 normal = calculateNormalFromMap(normalFromTexture, normalize(input.normal), input.tangent);
	PixelOutput output;
	output.albedo = gTextureMaps[diffuseMapIndex].Sample(Sampler, input.uv);
	output.normal = float4(normalize(normal), 1.0f);
	output.worldPos = float4(input.worldPos, 0.0f);
	float roughness = gTextureMaps[OrmMapIndex].Sample(Sampler, input.uv).g;
	float metal = gTextureMaps[OrmMapIndex].Sample(Sampler, input.uv).b;
	output.occlusionRoughnessMetallic = float4(0, roughness, metal, 0);
	output.albedo.a = input.linearZ;
	//output.shadowPos = input.shadowPos;
	return output;
}

这里我们用了multiple render targets(MRT),dx默认支持在一个pass内渲染到多个render target上。

整个渲染过程如下图所示
在这里插入图片描述
这样,我们就已经完成了左边部分,也就是第一步,我上面的截图里面,下面五个buffer,对应的分别是漫反射、法线、位置、粗糙、金属,接下来我们要利用这五张渲染结果,以及深度缓冲,来渲染出最后的结果。

这里我暂时把SSAO和阴影去掉了,打算去看下别的一些方法,比如距离场AO和HBAO+之类的再重新写进来。

右边这个步骤,也就是第二步,用的是基于物理的渲染(physically based rendering),也就是PBR,下一篇中会给出说明。

猜你喜欢

转载自blog.csdn.net/weixin_43675955/article/details/89096471