锐单电子商城 , 一站式电子元器件采购平台!
  • 电话:400-990-0325

究极摸鱼挂科王终于击败了无敌可怕Vulkan大魔王

时间:2022-11-21 23:00:00 sc连接器挂掉的原因

如果你所期望,你会得到强烈的回应。
——M八七

上次写知乎已经三个月了,一个学期过去了,真的很快。

写完最后一篇文章,我继续改进软渲染器,可以连续显示移动物体,但由于效率很低,复杂的帧需要一两秒,基本上完成了,所以没有更新,转向学习vulkan最后,我做了这样一件事,我愿意称之为具有对象管理和异步加载的接口就像Unity的Vulkan渲染器”

反射、多光源法线、折射、破碎玻璃、天空盒

对象遍历,包括去除视椎体

资源池

Github地址是,200 我真的厌倦了提交:

FREEstriker/Air_Forward (github.com)github.com/FREEstriker/Air_Forwardhttps://link.zhihu.com/?target=https://github.com/FREEstriker/Air_Forward

这是一个用Vulkan实现的Forward基于渲染器c 使用了17个标准SpirvReflect、Glfw、FreeImage、Glm、Assimp、Rttr、NlohmannJson库,现阶段完成的特点包括:

vulkan对象管理

多线程绘制

视椎体剔除

Opaque Pass、Background Pass、Transparent Pass

加载多线程资源

模仿unity的对象管理

未完成部分:

资源销毁

vulkan对象销毁

序列化反序列化

这三个月学vulkan我的头真的很大,太复杂了。我一开始就看到了vulkan tutorial,画一个简单的图形需要快1000行,光理解那个tutorial用了半个月,说的不够清楚,只能再查手册、博客、stackoverflow,并且vulkan信息真的很少,有问题只能去google上找,中文网站如何配置?vulkan,一键安装的东西竟然有这么多教程,真的不懂。

至于为什么不学opengl去学vulkan,我只能说我很草率,想想既然vulkan是新一代api那我肯定学新的啊,ε=(′ο`*)))唉,只能说知道这么麻烦我一定要学opengl。。。

分为三个线程:Graphic、IO和Logic,所以下面按顺序说实现思路。

Graphic

包装麻烦对象

显然,vulkan这是非常繁琐的,如果以后要长时间使用,最好自己封装一个简单的框架,以后的工作量,

我大概把它分成了:Asset、Command、Core、Instance。Asset主要包括一些需要从硬盘上加载的东西,如网格着色器和地图;Command包括一些与绘制命令相关的类别,如CommandPool、CommandBuffer、Fence、Semaphore和Barrier;Core包括一些初始化Vulkan用到的类,例如VulkanDevice、VulkanInstance和GlfwWindow;Instance内是一些对vulkan对象的简单包装,如Image、Buffer、Memory等。

从初始化开始Vulkan说起:

我是采用了Creator的模式,Creator记录初始配置,检查硬件支持,然后调用Create实际创建函数,但实际上初始化过程并不完美Window、Instance和Device在这三个类别中,有一些工作需要在其他类别完成后创建,所以我添加了一些隐藏的对象初始化:

实线Create调用,内部调用虚线

之后是对Vulkan对象的一些简单封装,主要目的是减少在代码中大量的使用Vulkan的什么CreateInfo这些东西写起来真的很麻烦,还加了一些RAII特征(但还没有完全写完,只是部分写了。。

其中Image、Buffer、Sampler包装简单,而且FrameBuffer、Memory、DescriptorSet对应使用Manager创造的类,由类创造的Manager负责销毁,详细的会在下一个小节说。

Buffer的简单包装

Command空间的主要部分是CommandPool和CommandBuffer。Buffer从Pool中创,用一个string标记Buffer名字,方便找和Debug。

绘制命令包装

因为我以前写过Unity的Urp,所以对它的CommandBuffer我也知道一点,就跟着unity把Vulkan的一些vkCmd开头的绘制命令已经实现CommandBuffer使用方便。

并且我在CommandBuffer内天减了一个fence,提交命令后可直接使用WaitForFinish()直接等待命令完成的方法,也是为了方便使用。

WaitForFinish()

Asset命名空间下的类别需要从硬盘加载,因此使用了线程池,但线程池和资源管理的具体实现不在本节中,主要是如何与Vulkan对接的。

网格使用Mesh其中使用了类加载Assimp库,自动三角形化,创建法线,切线,然后和解Vulkan Tutorial同样的加载是可以的,但是提到的我自己包装的。Buffer和CommandBuffer,可可以看到代码大大简化,很舒服。最后,添加计算OBB便于去除视椎体。

加载网格

Texture分为了2D和Cube,事实上,这两个过程基本相同。它们都是从硬盘加载到暂存的Buffer再传入Image,只是其中的Image不同,加载略有不同。需要使用传入其中。ImageMemoryBarrier以正确转换图像的格式,直接加载,因为加载队列不需要渲染,并且由逻辑线程完成PipelineStage参数直接写top和bottom就好了。

加载TextureCube

最困难的部分是Shader类,因为涉及参数反射,我用了SpirvReflect这个头文件,并且将反射出来的资源转换为自己的几种资源类型(我叫它SlotType):Texture2D、UniformBuffer和TextureCube,减少适应反射数据的共同工作量。另外,我是将军Vulkan的PipelineLayout、Pipeline放在了Shader因为我是把Shader理解为一个完整的渲染执行过程。

SlotType

并且我照着Unity的Shader的样子,用json写了一个.shader指定各阶段实际情况的文件spv代码路径和一些类似RenderPass、CullMode、BlendMode反正这样的参数是按照的Unity抄。

.shader文件(由于Vulkan其实所有的参数类型都是uint32_t所以序列化后是数字。

我将Shader加载过程分为上图中的几个函数:ParseShaderData加载并反序列化.shader文件、LoadSpirvs从硬盘读取spirv文件、CreateShaderModules创建Vulkan的ShaderModule以及参数序列化的数据,PopulateShaderStage装填VkPipelineShaderStageCreateInfo数据、PopulateVertexInputState根据顶点着色器in参数分析所需的顶点数据并相应填写Vulkan数据结构、CheckAttachmentOutputState检查像素着色器的输出是否和RenderPass的颜色附着相符合、PopulatePipelineSettings根据.shader填写文件中的参数pipeline所需的数据,CreateDescriptorLayouts和PopulateDescriptorLayouts将shader将内部绑定的数据组合转换为SlotType并创建DesriptorSetLayout、CreatePipeline进行实际的创建VulkanPipeline、最后使用DestroyData销毁暂存的数据。

可以看到真的挺麻烦的,当时Shader类也确实写了好长时间,Shader和Material可也说是最麻烦的两个类了,写的也是非常乱。

那些Manager们

先说MemoryManager吧,由于显存和内存不太一样,显存是有一个分配数量限制的,分配超过一定次数后就不能再次分配了,所以显存管理是有必要的,我是直接写了个最简单的。

首先对显卡支持的每种显存类型分配一个ChunkSet,在每次请求内存时,都会在所需要的显存类型的ChunkSet中寻找可用的显存;如果一个ChunkSet中没有Chunk,就会使用Vulkan的api请求一块大的显存,总之就是在ChunkSet寻找可用的Chunk;显存最小的单位是Block,请求时根据请求大小和字节对齐从可用的Chunk中取出一小块作为Block,并记录。Chunk是Manager向Vulkan请求分配的单位,Block是程序向Manager请求分配的单位,Chunk内分配Block使用首次适应策略,所以说是最简单的。

实线Chunk,虚线Block

对应VRAM类型和ChunkSet使用的是vector,因为VkMemoryRequirements内memoryTypeBits是一个uint32_t,所以把它隐含在vector索引中就可以了;ChunkSet存储Chunk使用的是map,VkDeviceMemory作为键,Chunk作为值,这样便于销毁时快速找到对应Chunk;Chunk内存储Block使用了两个map,一个作为分配空间表,一个作为未分配空间表,都使用起始相对字节地址作为键。

写这玩意的时候我就在想本科时候那个操作系统实验班做的miniOS,本来想改内存管理,不过作者写的太好根本无从下手,最后直接把注释从日文改成中文就交了,当时我还和大哥、sc在敦煌,哈哈哈哈,刚旅游完回家就开始疫情了。。。怀念没有疫情的日子。

FrameBufferManager的主要作用是管理FrameBuffer,我也将它分为了两个阶段,第一个阶段是添加Attachment,通过给Attachment给予名称的方式可以方便地获取到对应的Attachment,也方便Debug查看。

创建并获得Attachment

通过名称创建并获得Frameuffer

DescriptorSetManager中也使用了池,对每个SlotType创建一个DescriptorSet池,池中包括若干个Chunk,每个Chunk包含若干个一次性创建的VkDescriptorSet,这样就可以减少分配次数和实现描述符复用。至于为啥这个叫pool而MemoryManager里的叫ChunkSet是因为这两个不是一起写的,中间隔了一两周,都忘了。。。

而由于这个每个chunk里的东西都一样,所以使用pool使用map存储多个Chunk比较好,使用可分配描述符数作为键,可以加快分配时的速度。

LightManager主要用于将逻辑线程送来的灯光组件分为Main、Important、UnImportant、Skybox几种,并且选择后将部分数据写入设备Buffer中,功能非常简单,我并没有像Unity那样会选择效果强的作为重要光源,我是直接从灯光组件中选了前几个作为重要光,在选几个作为非重要光,其他的直接舍弃,差不多得了先有个样子就行。

筛选的灯光数据

RenderPassManager是比较重要的,它存储了一些RenderPass及其执行优先度,便于以正确顺序绘制图像,具体的实现下一节再说。

三个RenderPass

原来用Unity的时候,好像material下面会有一个Queue的选项,可以调节渲染顺序,好像shader里也有一个tag不过我记不太清了,urp就更是直接有RenderPass类了,反正就是根据记忆中的用法造了一个出来,简化版的,只根据RenderPass的渲染顺序数来确定渲染顺序。

我是将它分成了这么几个虚方法:

OnCreate会在将这个RenderPass加入到Manager时调用,用于配置一下创建的RenderPass,也是使用了Creator。

OnPrepare阶段会在OnCreate后调用,用于创建RenderPass所需要的FrameBuffer。

OnPopulateCommandBuffer用来填充CommandBuffer,这个方法是在线程池中执行的,所以要先从对应线程的CommandPool中分配一个CommandBuffer在进行填充,并且这个阶段只填充,不提交,因为填充结束顺序是不确定的,需要等待填充完毕后在进行提交。

OnRender阶段会进行实际的提交,并且需要配置一下Semaphore的依赖。

OnClear会在每帧绘制完毕后执行,用于销毁当前帧申请的CommandBuffer。

反正这几个我用的是挺好的,可以完成按顺序渲染的任务。我现在只实现了三个基本的RenderPass:不透明、背景和半透明。

其实这三个RenderPass没啥可说的,就很基本,需要注意的是半透明Pass,我是采用的通过Barrier限制向ColorAttachment写入的顺序,先执行远处的物体,在执行近处的,由于这个Barrier与加载时用的Barrier不同,它是在pipeline内部的,所以要在创建RenderPass时添加一个自我依赖,才能够正确的按序执行。

添加自依赖,注意blend阶段属于output阶段的read访问

draw后添加等待colorAttachment写入完毕的Barrier

不透明Pass就不用添加Barrier了

线程池

c++标准库并没有线程池、任务之类的东西,所以还是得自己搞一个。我是从GitHub上找的一个,然后自己又改成了需要的样子,通过向一个共有的任务队列中添加任务并获得一个future来满足延时获取的需求。

同时给每一个子线程一个CommandPool,用来满足多线程填充CommandBuffer的需求。

Shader中的计算

shader我是写的glsl,并使用glslangValidator编译为spirv,shader里启用了GL_GOOGLE_include_directive拓展,将一些可重用的方法都写在了单独的文件里。

环境光我是直接使用的从Skybox中采样的,虽然是不对的,但是将就用吧。

环境光

点光源和方向光的漫反射和高光反射都和之前写的软渲染器的算法一样,写在了Light.glsl中,没啥可说的。

Common.glsl中的函数也都很常规,比如坐标转换、方向转换、TBN之类的,也很常规。

Camera.glsl中有一个比较麻烦的函数PositionScreenToNearFlatWorld,也就是将屏幕坐标转换为世界坐标,由于之前没有关心过Vulkan的手相,导致怎么计算都不太对,就还是挺无语的。

其实原理很简单,就是坐标转换后根据相机的长宽数据、坐标数据和朝向数据还原出世界坐标,仔细想一下没有难度,搞清楚手相就好了。

有的时候还需要获得相机的视线,我是写了两种,一种是直接观察点-相机坐标,还有一种是根据相机类型来的,正交相机就是相机的前方向,透视相机是观察点-相机坐标,可以根据需要选择调用。

由于都写成了通用的方法,所以shader里是比较简洁的,哈哈哈哈。

此外,我还把非重要光的光照写成了顶点光照,就像上面一样。

反射没啥难的,折射需要注意球体有两次折射,不要忘了。

球体折射

显示流程

流程伪代码(隐藏了同步)

逻辑线程和图形线程是一个串行的流程,逻辑线程结束后才能进行图形线程,首先拷贝数据,接着对每一个渲染器做视椎体剔除,并把它放入对应的RenderPass的列表中,然后遍历RenderPass填充CommandBuffer,填充结束后提交命令,最后将渲染好的图像拷贝到交换链上并通知逻辑线程可以继续运行。

需要注意的是,由于坐标的问题,如果直接显示的话,它是上下颠倒的,网上有两种方法,一种是加一个拓展然后把视口的高度取个负值,另一种是给glsl的glPosition的y加个负号,对我来说两种都有点麻烦,我是直接在最后的拷贝时直接反着拷贝了(其实是Blit),因为我渲染时都当它是正的,所以效果也一样。

IO

IO内主要就是一个线程池和一个AssetManager,就是用来在逻辑线程内实现异步加载网格贴图之类的东西,我写的也是比较简单,线程池就和上面Graphic里的线程池一样,不过AssetManager和之前软渲染器里的不太一样,之前那个是每次用的时候都用一个key去Manager里找,这次改成了加载时找一次,之后只需要转类型就可以使用。

我是将资源分成了Asset和AssetInstance,每个路径的资源在AssetManager只会作为AssetInstance加载一次,加载后获得的是作为AssetInstance包装的Asset,Asset里会包含一个AssetInstance指针,参数都从AssetInstance中获取,没有修改资源的功能。

但实际上,这个写的还是有问题的,只是考虑到了加载时的同步,没有考虑加载卸载同时发生的情况,应该是后会再改一版。

Logic

对象管理

对象管理是从之前的软渲染器里搬过来再改的,GameObject的管理几乎一样,仍然是孩子兄弟二叉树,只是Component的管理又重新想了一下,完全改了。

首先要明确遍历Component的顺序,它应该每次只完全遍历一种Component,且顺序应该是先按照GameObject的层次顺序来,同一个GameObject内的Component应该按照添加顺序来,画个图就是这样:

先按GameObject层次遍历

GameObject内的多个Component按添加顺序

让我们先假设我们已经完成了GameObject的遍历,现在要对单个GameObject内的Component进行遍历,显然,如果我们把全部的Component都放在一个按添加顺序组织的数组里是不合适的,因为我们要一次性遍历同一种Component,不分类放在一起显然会极大地增加每次遍历寻找对应Component类型的时间。所以我们需要一种既能够分类又能够保留添加顺序的数据结构,我才用的是一种类似于十字链表的存储方式:

横轴连接同一类型的Component,纵轴连接全局添加顺序的所有Component,需要注意的是,横轴还包含了局部的添加顺序,添加一个Component时直接添加在对应类别横轴的尾部,并且也添加到纵轴的尾就可以了。

而关于横轴的多个Head的组织方式,我是采用了简化类型为枚举并使用map存储的方式:

反正遍历的时候按横轴就行了。

但实际上,GameObject的层次遍历也是一个问题,虽然孩子兄弟树是很好层次遍历的,但是需要考虑到Behaviour是可能会在遍历过程中修改对象树的,这就很麻烦了,可能深搜到一半本身早就被销毁了。因为之前在网上曾经看到过Unity是延迟销毁的言论,所以还思考了一下怎么个延迟销毁,其实我原来写的那个软渲染器就是延迟销毁的,无非就是先把它放到一个销毁池里,边遍历边打标签,遍历完在统一销毁,但其实涉及到的隐藏问题挺多的。

我实际选择的是遍历的时候把每个对象加到一个哈希集中,每遍历到下一个时就检查这个是否存在在哈希集中,如果不在就说明之前销毁掉了,跳过就可以,如果在哈希表中存在,说明这个对象是可以被迭代的。并且,不能简单的使用深搜的方法,因为GameObject可能会移动,所以必须遍历前就把它的孩子全部取出来,即使孩子们换了位置,也会在当前轮次中迭代到。

非常多的检测是否存在于哈希集中的代码

Component的反射

大家肯定都用过Unity里面的GetComponent()函数,这个还是挺好用的,可以直接通过类型或类型名来获得当前GameObject下的Component,由于C#是有反射的,而C++虽然有但是不是很完整,所以要想实现这个东西我是引入了一个Rttr的反射库,只要利用这个库,再结合自己的系统结构封装就可以了。

上面说道,现在的Component的十字链表的横轴的key是一个枚举类型,而要通过类型名来寻找,就肯定要先确定这个类型名属于哪一个枚举类型。我是直接用各个类型的基类和枚举值做了一个表,如果这个类是表中类的子类,那么就用对应的枚举值去Component十字链表的横轴中去找就可以了。

定位横轴后,需要在一堆同基类的Component中寻找符合要求的那一个Component,我用的逻辑是下面这样的:

图画烂了,凑活看吧

之所以当输入是子类时需要额外检查的原因是:虽然输入是子类但是有可能实际里面是new的一个基类,基类对象是不可能凭空变成子类的,所以要额外检查一下,这样就可以找到符合的Component了。

这个接口终于接近Unity了,真好。“这引擎我就不用了,怕认错引擎。”

生命周期

我是直接把生命周期虚方法写到了Component里,所有的Component都有生命周期,无非就是有的回调里面少有的回调里面多罢了(只有Behaviour回调里面多),函数名也是抄的Unity,哈哈哈哈:

不过现在由于序列化GameObject并没有做,所以OnAwake根本没有调用,OnDestroy的调用存在内存泄漏,只有OnStart和OnUpdate是正常的,下一个版本应该就会实现完整。

DEMO展示

做的展示场景里面有三个球,一个纯反射一个纯折射一个有贴图和法线,发现球还在不断自转;六个灯光,一个方向光,一个天空盒作为漫反射光源,还有四个球按照正四面体的顶点排列围绕法线球旋转;五个玻璃平面,是使用的半透明Pass渲染的;一个背景CubeMap,用来绘制背景;一个相机围绕着原点做旋转。

具体情况把代码跑一下就可以看明白了。反正就是这么个情况。

:||

干了三个月就做了这么个东西出来,而且也不太完整,只是能看罢了,里面隐藏的内存泄漏我想都不敢想,之后还得好好检查测试。

不过这个前向渲染我是应该不会更新了,我下面准备加上Tile Based Forward和Cluster Based Forward,框架的Debug应该会在那个里面做了,而且会添加一些新的东西,ComputeShader啥的,流程可能也会微调。现在研一结束了,要负责项目了,可能就只能闲的时候做了,不能像这个学期一样啥都不干就写这个了,可恶。

总之,写的时候困难重重,写后在回顾其实难度也不是那么的大,只是稍微繁琐一些,自己也是第一次一次性写这么多C++,只能说希望对以后找工作有帮助吧。

一会就要开组会了,其实我啥都没干,寄。

开完会去聚餐,为了减肥吃这一顿我从昨天午饭后就一口东西都没吃了,饿死我了。

这次原神的2.7版本,为了抽夜兰大姐姐,把我的原石全都抽完了,还歪了刻晴,不过我还挺想要刻师傅的,只是不知道下个版本的万叶抽不抽的出来了,唉,寄!

因为原神太长草了,所以我又下了个崩坏三,真实有年代感啊,不过手感还可以,哈哈哈哈。

还有,我需要看新奥特曼,呜呜呜。

锐单商城拥有海量元器件数据手册IC替代型号,打造电子元器件IC百科大全!

相关文章