那些天,我写过的Vulkan环境光遮蔽

发布于:2022-12-09 ⋅ 阅读:(966) ⋅ 点赞:(0)
到底自己为什么要向着某个目的地前进呢 ?这样不断问下去,渐渐地你就会明白答案。
——意外的幸运签

那些天,

上次写文章,已经是上上个月的事情了,七月末终于能休假,趁着成都疫情之前跑回家休息了20多天,本来刚回家的时候还想着时不时学点啥的,不过还是太天真了,基本属于是在家里躺尸了两周,八月中下旬回来之后才开足马力学了一阵子,现在变成这么个效果了:

Vulkan-AirEngine环境光遮蔽、键盘声音控制与渲染流程切换114 播放 · 1 赞同视频正在上传…重新上传取消​

地址还是这个:

FREEstriker/Air_TileBasedForward (github.com)​github.com/FREEstriker/Air_TileBasedForward

其实主要就是这么几件事:

1、加了输入控制,可以在逻辑循环中处理键盘与鼠标输入了

看看这冗长的命名空间,也是挺离谱的

2、看了看OpenAL,把声音加进去了,支持简单的声音设置

3、重写了渲染流程,看了看URP的接口,学习了一下,把资源和逻辑分开了,帧率提升挺多的反正

4、学习了一下环境光遮蔽,想办法在里面加了SSAO、HBAO、GTAO这三种效果

这个是HBAO

其实在家那段时间是有点焦虑的,今年看学长们找工作很不顺利,我也很是紧张,所以才有了八月份写的那篇发牢骚的文章,我现在感觉好多了,走一步说一步吧,能做自己喜欢的事情终究还是很开心的。

非常感谢大家对我的支持,感谢!

写过的

先从两个简单的部分开始吧:

输入控制

输入控制是非常简单的,因为本身就已经在使用QT的窗口系统了,QT肯定是有窗口接受输入的接口的,所以说只要把它的一些东西包层皮,放进来就可以啦(得意)。

实锤了,我不会画UML。。。

左边是窗口线程,属于QT,右边是Input Manager和逻辑线程,属于AirEngine。由于我不太清楚qt的输入是否是同步的,所以我还是加了个mutex同步一下,放入输入缓冲中。而在逻辑线程中,在每次循环前,都首先把输入缓冲中的数据挪进输入队列中,并用一个输入状态表来记录按键的状态。这样之后循环中的OnUpdate()们就可以使用了。

线性调整音量

但是注意在使用状态表的时候,会存在一个键的按下状态持续多帧,所以不能用KeyStatus来判断键是否被按下,而应使用KeyDown、KeyUp、KeyAny来判断。KeyStatus应该和DurationTiem来配合使用,例如上面实现的按键线性调整音量的功能。

实际上我把按键信息放入输入缓冲的功能写成一个函数了,这样在逻辑线程内也可以更改输入缓冲的东西,可以用来做一些模拟按键的功能。

InputKey用来修改输入缓冲

需要注意的是,QT的窗口事件会对输入按键做一个自动插入按键的操作,所以需要用isAutoRepeat()函数判断这个事件是否是QT自动插入的。

总的来说,非常简单。

声音

我当时在网上也搜了一些c++的声音库,找了半天才发现OpenAL,感觉完全不火啊,中文教程都没有多少。看名字就知道API是模仿的OpenGL,所以只能翻翻手册才能勉强用一下。

需要注意的是,单单使用OpenAL库是不能够正确配置播放所需要的设置的,需要额外使用一个叫做libsndfile的库,用来解析声音文件的格式、采样率之类的东西,当然如果你能够确定每个音频文件的格式和采样率,不用也不是不可以。

解析格式

lambda内同步使用OpenAL的API

说实话我也不清楚OpenAL能不能多线程使用,考虑到现在的AssetManager是多线程加载资源的,并且OpenAL也是不支持多线程的,所以干脆所有的OpenAL操作都只能通过一个mutex同步过的接口SubmitCommand来使用,一劳永逸,也不是不能用,哈哈哈哈。

同时还仿照Unity,封了AudioListener和AudioSource两个Component,就是使用SubmitCommand直接做一些OpenAL操作。

使用起来也是比较方便的,这接口,真像Unity,哈哈哈哈。

主要就是教程比较少,难度很低。

SRP

其实我也不知道我写的是不是SRP,只能说接口有那么一点像。Unity的SRP内部好像是RenderGraph,可以通过上层代码提交的渲染命令,自动添加资源的Barrier、Semaphore等同步命令,以提高性能。当然我是不可能一个人就写出这种东西的,核心部分的自动资源资源同步根本没写,现在还是手动添加同步命令,只有一个皮,不过提高了易用性(因为就是抄的Unity的用法),并且将资源和逻辑分离,防止每帧重复分配内存显存。总的来说是长这个样子的:

我是仿照Unity的SRP,将渲染流程分成了三层:

1、RenderPipeline,这个是根元素,Manager一次只能使用一个Pipeline,在Unity内用来配置不同的渲染质量。

Pipeline使用了多个Renderer

2、Renderer,一个Pipeline可以拥有多个Renderer,一个相机必须指定其所使用的Renderer,包含了渲染至相机的Attachment上的所有流程,在Unity里也是差不多的用法。

Renderer使用了多个RenderFeature,AO的Blur和Cover实现了复用

3、RenderFeature是实际使用CommandBuffer进行渲染的元素,可以被Renderer复用。

RenderFeature进行实际的渲染工作

所以说,实际上上面两层是为了方便实用而存在的,RenderFeature才是实际用来复用渲染代码的元素。

上面说的部分只是包含了渲染的逻辑,是自动跟随RenderPipeline创建的。而渲染的数据,比如所需的深度图、噪声图、UniformBuffer之类的东西,会随着相机的创建,从对应的Renderer中动态创建,只需要填写对应的虚函数,就可以自动创建出与使用的Renderer与RenderFeature对应结构的数据集合。

Camera获得对应Renderer的Data

这样,在对每个Camera进行渲染时,就会使用这个Camera所拥有的RendererData与RenderFeatureData,装填入对应的Renderer内,进行实际的渲染,实现数据与逻辑的分离,这样就可以在申请的时候只申请一次资源,之后随便使用,不像之前写的那种资源与逻辑混合,每帧都需要动态创建销毁资源,非常浪费性能。

使用RendererData装填对应的Renderer进行渲染

我是将资源的创建分为了两个阶段:

1、创建,先创建RendererData,在创建多个FeatureData,并将其连接起来。

2、链接,由于一个Feature可能会使用其他Feature的数据,所以需要在这个阶段获得所需Feature提供的前置数据,再初始化一部分相关的资源。

例如,在使用Forward+的Renderer中:首先在创建阶段,GeometryRenderFeatureData先创建一份可采样的深度图和法线图;在链接阶段,用来构建灯光索引表的TBForward_LightList_RenderFeatureData获得上一个阶段GeometryRenderFeatureData创建拥有的深度图,用于之后渲染的实际使用。

链接深度图、不透明物体光照索引表、透明物体光照索引表

虽然几乎渲染流程都重写了一遍,但填充CommandBuffer我还是使用了多线程:

填充CommandBuffer的OnExcute()函数封装成Task提交给图形线程池执行

除了OnExcute()这个函数,我还给RenderFeature封装了OnPerpare()、OnSubmit()、OnFinish()这几个执行函数,调用顺序如下:

1、首先顺序调用OnPrepare()函数,作为执行前的准备阶段。

2、向线程池提交所有作为Task的OnExcute()函数,进行并行填充CommandBuffer操作,作为执行阶段。

3、顺序等待所有Task执行完毕,这就说明CommandBuffer填充完毕了,接着顺序执行OnSubmit(),作为提交阶段。

4、顺序执行OnFinish()函数,作为执行完毕的结束阶段。

RenderFeature的函数执行顺序

其中,第三步保证了OnSubmit()执行时CommandBuffer是填充完毕的,所以我是在这个阶段才使用commandBuffer.Submit()实际提交渲染,使用框架保证提交到Vulkan队列的命令是有序的(因为我没使用Semaphore,只使用了Barrier)。当然,如果使用Semaphore进行CommandBuffer的同步,那么你就可以直接在执行阶段就提交渲染,性能会好一点,我这么写只是懒得改了。

实际上与之前写的不同的是,每次渲染中,是对每个相机对应的Renderer都执行同一阶段,再同时执行下一个阶段,这样就可以保证将所有的填充人物都提交给线程池。而不是像之前一样,对每个相机执行一遍流程,浪费部分并行性。

对所有相机的Renderer执行同一个阶段

逻辑清楚了之后,写起来是不会有太大问题的。

Demo场景

Forward+渲染器

这个演示场景的Pipeline是一个包含了三个Renderer的Pipeline,分别是Forward、Forward+和显示AO效果的Renderer:

我在Camera下挂载的一个Behaviour中写了通过L键进行切换Renderer:

所以按下键之后,就可以切换渲染器了:

AO可视化渲染器

还将J键用于切换OIT的Depth-Peeling模式与Alpha-Buffer模式,K键用于切换几种不同的AO效果:

这两个是通过控制Renderer内的参数实现的,Renderer内部使用RenderFeature时会根据设置渲染:

根据aoType选择AO效果

还有就是场景下方的板子可以上下移动,方向键可以调节音量,这些都是用新加的输入控制、声音控制与之前的Behaviour实现的:

移到上方了

Vulkan环境光遮蔽

主要写了三个AO:SSAO、HBAO和GTAO,SSAO应该写的没啥问题,HBAO可能没问题,GTAO可能有问题,不过算法是明白大概了,可能细节上有一些没有处理到,无伤大雅吧,薛定谔的懂了也算懂吧,哈哈哈哈。

之前写的代码是存在问题的,由于某些我不懂的原因,导致Vulkan的ndc坐标系和OpenGL不太一样:

我在之前写的文章中已经提到了这件事情:

FREEstriker:究极摸鱼挂科王终于击败了无敌可怕Vulkan大魔王61 赞同 · 4 评论文章正在上传…重新上传取消

我当时采取的方案是:不关心它的ndc坐标系朝向,就按OpenGL来构建MVP矩阵,那么这样渲染出来的图像应该在Y轴上是反着的,我就干脆直接最后反着Y轴把它画到最终的交换链图片上,避免了修改之前从自己写的软渲染器中抄过来的矩阵构造了。

但实际上,这仅仅是逃避处理了这个问题,我在之后的采样法线图中就遇到了这个问题,如果仅仅将图像反转,会导致构建顶点空间旋转矩阵时出现错误的问题,会导致SSAO的半球采样完全错误:

非常离谱

解决方案肯定是有的,修改MVP矩阵来抵消掉这个反向显然是可以的,但是设计这套API的人好像也发现这个东西有点不好用,所以推出了一个“VK_KHR_maintenance1”的拓展,这个拓展可以把窗口设置成负数,这样就可以和OpenGL一样,最后是个右手系了。

viewport设置为负数

当时写SSAO的时候真实检查了一万年,怎么看怎么对,但就是最后结果不对,每天在路上突然想到这个问题,一改,果然对了,真的离谱。

SSAO

SSAO的原理是非常简单的,就是对每个位置使用一个半球进行采样,如果采样点被实际物体挡住了,那这个采样点就会贡献一份遮挡值,就是说会变黑一点,显然应该对每个模型的每个点进行一个半球采样,但这样必然是非常耗费性能的,直接对最后可见的物体做就好了,这就变成了在屏幕空间内采样了,很好。

根据使用全屏的uv和深度图上的值,可以获得ndc坐标,从ndc反算相机空间之前写Forward+已经搞过了,所以没啥大问题,接着就是把这个半球旋转到法线朝向上,就像上图一样。像素的法线我们可以対法线图采样来获得,

相机空间法线图

然后使用叉乘对各个轴进行重建新的Z轴就是采样法线的方向,重建的三个轴就可以组成旋转矩阵了:

构建旋转矩阵

需要注意的是,为增加变化,需要对这个半球绕Z轴随机旋转一个角度,避免采样出现条带状。

之后就没啥了,转换完采样点后再乘个P矩阵,做个透视除法,获得uv,采样一下深度图,就可以比较采样点深度和实际可见深度了。

噪声图

我这里使用的是一个64*64的噪声图,在RenderFeature的链接阶段创建的。

关于采样点,我是用的均匀球面点,在转换为球内点,和LearnOpenGL中不太一样了,不过差别不大。并且,我是用的也不是完整的半球,我是使用了一个通过SampleBiasAngle控制的锥形半球,以减少同平面的影响。

这样最终搞出来的结果是这样的:

底部平板AO错误

看起来是不错的,这还没有经过模糊处理,模糊的处理最后再说。而为什么平面上出现了那种错误的AO效果呢?我只能说,这是立方体mesh自带的顶点法线出现了问题。理论上,立方体的顶点法向应该和面法线同向,类似于这样:

理论上该是左边,但实际上它是右边,这就导致使用了错误的法线,所以是这种错误的效果。看看PLY模型文件,他里面的法线确实是错的,这就没办法了,模型我是不会改的。

总之,责任全在美方,我写的是没问题的,哈哈哈哈。(摊手)

HBAO

HBAO和SSAO不同,他没有使用预设的半球采样点,而是使用在位置附近采样获得最大视平面高度的方法,计算出一个HorizonAngle,并根据该点的面法线获得的TangentAngle来一起计算出AO值。

这种方法存在的问题这篇文章说的很明白,只要跟着做就好了:

YiQiuuu:HBAO(屏幕空间的环境光遮蔽)247 赞同 · 46 评论文章正在上传…重新上传取消

不过面法线我是直接采样相邻两个深度值直接算出来的,没有用dFdx。并且最好采样器设置为ClampToEdge,这样对边缘的像素进行重建的时候,只要用一下clamp就可以避免出现出界的错误情况。

采样相邻像素的深度值

添加BiasAngle的时候要注意一下,不要过早地对sin(HorizonAngle)和sin(TangentAngle)使用clamp,最好等它减去sin(BiasAngle)在做clamp。

我的效果是这样的:

可以看到,好像还有一个描边的效果,我推测应该是因为在边缘跳变处,重建面法线出错了,这好像也是没有办法的事情,不过好在之后模糊了就看不到了,反着就这样吧。(摊手)

GTAO

GTAO是HBAO的升级版,也是先找到HorizonAngle,不过之后是通过积分的方式来计算AO:

不过我没太理解为什么要对sin积分,不过反正照着它推出来的最终式子写了个shader出来,效果是这样的:

边缘部分的错误应该还是面法线重建的锅,但其实有些角度上它的AO会出现明显的错误,我也着实没太搞懂,就是挺无奈的,所以也不敢多说,直接看别人的比较好。。。不敢误导人。。。

YiQiuuu:UE4 Mobile GTAO 实现(HBAO续)194 赞同 · 75 评论文章正在上传…重新上传取消

模糊与覆盖

模糊肯定是用高斯模糊的,但是由于AO效果是和所在的面相关的,如果单纯的使用高斯模糊,可能就会糊掉一大块,导致不该有AO的地方出现AO效果。所以我使用了双边滤波:

让高斯滤波只在需要的一边进行,而另一边不进行。

我是使用相邻的法线作为参数,如果法线相近的话,基本说明是在同一个物体上的,如果差别太大,出现了跳变,那么基本可以判断是其他不相关的面了,就可以避免糊掉一片。

无模糊

第一次横模糊

第一次竖模糊

第二次横模糊

第二次竖模糊

可以看到,基本上没有沾染到其他不相关的部分,效果还行。

由于我输出的是一张R16_SFLOAT的ColorAttachment,所以上面的黑白图是R通道的内容,我之所以将Blur和Cover分开,是因为Occlusion和Blur在Geometry_RenderFeature计算出深度图和法线图后就可以开始计算了,而实际的Cover阶段需要等不透明物体渲染完毕之后,才能覆盖在相机的ColorAttachment上,所以我就把他们分开了。

所以Cover阶段需要对R16的贴图进行采样,并覆盖在ColorAttachment上,由于R16里存的是遮挡值,代表应该变黑的程度,所以最好直接1-R16的值,在把这个值乘给ColorAttachment就好了,遮挡值越大,乘的值就越小,那么它就越黑。所以把Blend参数设置对就好了。

向Alpha输出1-occlusion

ColorAttachment的颜色直接乘上上面输出的Alpha

这样就可以正常的Blend上去了:

这时候还没有画OIT阶段上去

总之,就是这样!

后记

之后应该回去看看阴影和PBR,也该着手刷刷题了,希望明年能有个班上吧。

成都最近疫情严重了,封了快一周校了,有点难受。上周末看了看《夏日重现》,感觉真不错,哈哈哈哈,老师真帅啊,喜欢,黑长直大御姐,真好,哈哈哈哈。

什么时候才能结束啊,前几天看别人发微博说已经有1000天了,从19年到现在,难顶啊,想过正常的生活了,病毒快死掉吧。

原神须弥还没怎么打,这周准备打一打,尼禄真涩啊,哈哈哈,还有坎迪斯,喜欢,想抽!!!

太好看了吧

呜呼。

封校之前还吃了顿必胜客,开心,哈哈哈哈。

一——切——顺——利!

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到