1、前言

  距离示例:22-MultiInstance-PBR-Sphere 代码上传有近1年时间了,本来应该在去年中秋节前就该把这个示例中的要点分享出来的,只是一入 PBR 之门后那种兴奋与狂热,让我一头扎进了写代码及调试的无尽乐趣中,一发不可收拾,写代码写到几乎停不下来,最终连 IBL 的基本光照示例都写完后,算是松了一口气,暂时停下来,把这段时间的示例及其中运用到的基本方法和技巧梳理一下,分享给各位。

  本章教程,重点为大家介绍多实例渲染的方法和技巧。这主要是为了较完整的展示 PBR 金属工作流渲染出的材质球之间的差异,通常这也是向美工显示金属度、粗糙度等核心参数如何控制表面反射效果的最直观的方法。当然多实例渲染本身也是一项非常非常重要的技术,所以我将这个方法单独提出来,作为一篇的教程,尽量聚焦一个或几个知识点,降低学习难度。当然这也是必须掌握的D3D12编程基础技巧之一。

  本章示例运行效果如下( 可以键盘方向键等控制上下左右前后查看细致的差别,还有 Pgdown和PgUp键 ):


  图中从左到右是从金属到非金属( 对应金属度参数从1.0f ~ 0.04f ),从上到下是粗糙度依次提升( 对应粗糙度参数从0.0f ~ 1.0f),尤其注意最左上角的光滑纯金属球,看上去很黑,其实这就是金属的本质属性,即几乎没有任何漫反射光,只有镜面高光导致的,所以我们只能看到几个高光的光点,所以在纯的只有几个点光源的照射下,是没法完全展示 PBR 金属工作流的惊艳效果的,当然大家不用着急后续我会详细讲解如何做到那种看上去非常惊艳的效果的(主要使用 IBL ,示例代码也已上传各位可以先自行学习)。

  当然这里并不是说我们编写的 PBR Shader 有问题,恰恰相反,这充分说明了我们编写的PBR Shader 是完全正确的,因为金属就是几乎没有漫反射光线的,只是我们可能平时都没有注意这个细节。其实如果各位仔细观察过周围环境的话,若真是光滑纯金属的话,在夜晚就只有几个简单光源的环境中也是很不“明亮”的,看上去会更黑,但是镜面反光依然强烈,这是金属固有的光学物理属性使然(金属是可以吸收光的!),我们只是简单的通过近似在计算机中模拟了出来而已!平时只是可能我们因熟视无睹而忽略了这样的细节而已。

  而这个金属的物理效果的表现,恰恰也是 PBR 光照渲染与传统的光照渲染不一样的地方,在传统光照中,无论什么材质都会有漫反射光项,所以在传统光照中,很多金属物体因带有漫反射光而表现出浓浓的“塑料风”效果,就是因为这个原因。当然本质上讲这样的简单的描述二者的区别是很不严谨的,但是对我们理解视觉效果和“原理性”掌握 PBR 是很有帮助的。也希望大家能够理解和记住这样的视觉效果差带来的问题,这对我们追求更加真实的渲染效果,或者说分辨渲染效果的“好坏”是很有帮助的。

2、什么是多实例(Multi-Instance)渲染

  多实例渲染,本质上就是说将同一份模型(Mesh),按不同参数渲染成多份,每一份被称作一个实例,并且大多数情况下要求每个实例都要有差异。

  在游戏中常见这类使用场景,例如在即时战略游戏中,同一种飞机可能需要渲染几百个,它们在外观上几乎没什么差异,或者差异很少,主要是位置不同,运动状态不同,颜色不同等;在休闲游戏中,这可能是一组相同的方块,差异可能只是位置和颜色不同,或者是纹理不同等;在 RPG 游戏中,同一种怪物或敌人就需要在一个较集中的小范围内重复渲染几十份,以表示有几十个敌人或怪物,他们的差异除了之前所说的,最重要的就是动画序列状态不同,有些敌人可能正在移动,有些敌人可能正在挥舞大刀,有些敌人可能已经被主角击中,而有些可能被击倒等等;

  再比如,在一些RPG游戏中“大赚特赚”的换皮肤功能,可能就是使用 Multi-Instance 功能为相同的模型换上了不同的纹理而已,让场景中相同的角色人物因为不同玩家的个性需求而表现的很不一样,而很可能玩家就需要为这不同的皮肤花上好几百的货币,这样看上去,Multi-Instance还是一个非常非常有价值的功能!

  总之多实例渲染是游戏渲染中常见的技巧和方法,也是最最常用的技术。

3、为什么要多实例(Multi-Instance)渲染

  就像刚才所说的,既然这是一个直接就可以“赚钱”的功能,我也就不太想过多的解释为什么了!因为这太显然了。

  首先什么是赚钱呢?赚钱就是要获取利润,注意利润不同于收入,而利润就是你卖出商品的收入减去你投入的成本之后剩下的钱,当然你还要交税,剩下的才是你的利润,所以赚钱的核心目标就是降低成本提高利润!

  其次,游戏里如何降低成本提高利润呢?那就是想方设法的“氪金”!比如换皮肤,个性化的角色表达,这里的成本在哪里呢?那就在美工加工模型的社会平均劳动时间与你的团队中美工加工模型的劳动时间之间的差异上,如果你的团队产品的时间成本低,那么你推出的“皮肤商品”就会丰富,或者说你的“皮肤商品”质量就会高(相同时间代价条件下),玩家个性需求的满足度就会更高,就更愿意为你的“皮肤”商品买单。如果你不得不为每个个性化的角色都创建一套完整的模型,那么成本将是非常恐怖的,想象一下为了几万人能够同时在线的MMORPG中每个玩家都创建一个不同的模型,需要付出极其高昂的代价,估计即使微软都能被搞破产了。

  那么最终如何降低这个成本呢?那就是一套模型,不断变换“纹理、子网格或者动画序列等”(表面上看上去可能就是换了皮肤、换了装备、换了发型头饰、甚至换了动作等等),通过“组合”的方式来满足玩家的需求(这里的组合可以直接理解为数学意义上的组合运算,假设你只有一个美工,创建了一个人物模型,花了大概一周时间创建了10套皮肤、10套装备、10套不同的动作,10套发型,10套脸型,然后呢为了节约成本,你就请他另谋高就了,接着你算算你能大概组合出多少不同的角色?然后假设平均每个玩家大概都能为此付给你100块左右,当然双十一你还可以打折,然后大概有1w左右玩家为此付费,而所有这一切的直接成本不过就是你只需要付给那个美工一周的薪水而已,当然后来你可能良心发现又付给他一笔奖金…ok,扯远了自己脑补吧)。这样最终你的成本就得以极大的压缩,虽然例子可能不太真实,但至少这说明可以赚钱就是 Mutil-Instance 渲染的最大价值。

  最终如何实现这冒着“浓浓的金钱的味道”不同“角色”在几乎一样的场景里呢?Multi-Instance! 我想作为程序的你不会想的有多少角色我就循环多少次 Draw Call 每个角色吧?如果那样大概有些玩家大概在看到约100个左右的角色时,基本电脑已经卡死了,他们因此可能会嚷嚷着退钱,然后残酷的骂你开发的是“氪金”游戏,接着你可能连同你的公司一起消失在了慢慢的历史长河之中…从此赚钱再与你无关。

  OK! 希望你看明白了我在说啥,如果你不懂政治经济学,而无法明白这里的逻辑的话,那么只需要简单的知道,Multi-Instance 是你的产品能够赚钱的可靠功能之一就行了。别的原因就显得那么的不重要了。所以为了生存,必须掌握这一技能!当然这一切如果再加上 PBR 的视觉 Buff 的加持,那么你就可能赚到更多的钱,所以为什么需要 PBR 也就更加显而易见了!

  最终,请记住“没有人需要它,就不会有它!”

4、关于上一篇教程中“菲涅尔”项的补充说明

  在前面的教程《DirectX12(D3D12)基础教程(十八)—— PBR基础从物理到艺术(中)》推导漫反射率时我们得到了一个关于漫反射与镜面反射之间基于“能量守恒” 的公式:
fr=kdflambert+ksfspecular−reflection式中:kd+ks⩽1{f}_r = k_d {f}_{lambert} + k_s {f}_{specular-reflection} \\[2ex] 式中:k_d + k_s \leqslant 1 fr​=kd​flambert​+ks​fspecular−reflection​式中:kd​+ks​⩽1
并且在《DirectX12(D3D12)基础教程(十八)—— PBR基础从物理到艺术(下)》中讲解基本的 “CooK-Torrance”近似渲染方程时,忘记详细描述上式中的 “ksk_sks​” 其实就等于该方程中的 FFF 函数即 “菲涅尔” 项的值。所以最终在点光源 PBR 示例的完整实现中,关于 “kdk_dkd​” 计算的Shader代码片段如下:

float3 kS = F;
float3 kD = float3( 1.0f,1.0f,1.0f ) - kS;
kD *= 1.0 - g_fMetallic;

  Shader代码已经很清楚的表达了:

  1、“ ks=Fk_s = Fks​=F ”,即 “菲涅尔” 系数就是镜面反射系数,当然这种说法很不严谨,更严谨的说法应该说菲涅尔系数是指光被表面反射后其中“方向性”很强的那部分在总反射能量中的占比;

  2、“ kd=1.0−ksk_d = 1.0 - k_skd​=1.0−ks​ ”,这就是能量守恒在反射后的光线中的关系,这个很好理解,因为我们假定的表面反射模型中就只有两部分;

  3、最后 “ kdk_dkd​ ” 即漫反射系数根据 “非金属度($ 1 - Metallic)”进行了缩放,当金属度参数“)”进行了缩放,当金属度参数 “)”进行了缩放,当金属度参数“Metallic = 1.0 $ ” 时,那么计算后的 “ kdk_dkd​ ” 就为 000 ;也既光滑的纯金属球是没有漫反射光的,因为金属会吸收光(其实是因其内部自由电子多的缘故)!这也与本章示例中看到的情况一致。

  这样最终短短的 3 行代码就保证了 PBR 在表现金属与非金属材质上的 “正确性” 和便捷性。当然这也是迪士尼公司相关工作人员在进行了大量的观察研究并进行数据分析之后得出的非常棒的经验方程和参数。在此为他们的辛勤而卓有成效的工作表示感谢!

5、Multi-Instance的基本原理

  其实从纯技术的角度来说,Multi-Instance 的原理并不难理解,直白的说,本质上它就是可以为一个网格模型(Mesh)指定不同的多组参数进行同时渲染,使的网格被充分复用,从而节约了 Draw Call 的次数,也使得一次提交给显卡的工作量可以达到接近“饱和”的状态,并且这些参数完全由你来控制,对应的每组参数最终生成的模型就可以被称作一个实例。也就是说所有的同一个模型的多个实例只需要一次 Draw Call 调用即可,这也是在 D3D12 异步渲染架构中极力推荐的做法。

  当然在 D3D12 中,因为本质上 Draw Call 异步的原因,所以循环调用 Draw Call 在规模不大时也不会有什么太大问题,但是当规模过大时,依然会产生问题。那么具体理解这个问题可以看下图:

  其实这种差别主要是因为GPU可以高度并行的执行命令的架构使然,在 D3D12 中两种方式对于 CPU 侧来说是没有啥影响的,因为 Draw Call 现在只是条命令,并且被以命令列表的方式一次性提交给了 GPU ,而 CPU 不用再等待 GPU 的返回,希望到这里大家已经深刻的理解了 D3D12 中 CPU 和 GPU 可以完全并行的同步执行工作的原理,并且完全理解了 Draw Call 等命令已经变成了异步执行的确切含义!

  典型的这些不同实例的参数可以是位置矩阵、缩放矩阵、旋转矩阵或者它们的复合矩阵,还可以是对应的纹理索引数组、或者是一些影响渲染结果的开关参数等,比如透明不透明,动画序列的动画矩阵索引关键帧索引动作索引等等,这样只要有一个模型,就可以渲染出成千上万个不同状态的实例,而这些只需要一个 Draw Call 调用即可!

  这不但可以节约 Draw Call 调用的数量,并且可以使 GPU 尽可能的高度饱和的工作,同时系统的整体运行性能也得到了根本上的保障。潜在的通过修改 Multi-Instance 的每个 Instance 的数据,那么就可以简单的控制一组对象呈现不同的状态、外观等,而这几乎不需要变动任何代码,从而极大的降低了耦合度。

6、Multi-Intance的第一步:模型顶点数据与实例数据

  在D3D12中,要实现 Multi-Instance 的调用,首先需要确定的是每个 Instance 的状态数据结构,这个完全由程序根据需要来设定,在本章示例中,主要是设定了如下的实例参数:

struct ST_GRS_PER_INSTANCE
{XMFLOAT4X4 mxModel2World;XMFLOAT4  mv4Albedo;    // 反射率float       mfMetallic;    // 金属度float      mfRoughness;    // 粗糙度float     mfAO;    //
};

  其中第一个参数很好理解,那就是具体到每个小球需要在世界空间中摆放的位置,如果对于更复杂的模型来说,这可能是一系列模型局部坐标系中的变换(缩放->旋转->平移等)复合之后的矩阵。而后续4个参数就是每个小球需要表现的不同的 PBR 材质的参数了,分别为 基础反射率、金属度、粗糙度、和环境遮挡系数(这个参数我们还从来没有介绍过,目前可以简单的将它理解为一个遮光的系数即可)。

  当然依照惯例,我们还需要定义 Mesh 顶点自身的数据及扩展的属性,在我们示例中,顶点数据结构定义如下:

struct ST_GRS_VERTEX
{XMFLOAT4 m_v4Position;   //PositionXMFLOAT4 m_v4Normal;    //NormalXMFLOAT2 m_v2UV;         //Texcoord
};

7、Multi-Intance 的第二步:在 PSO 中描述多实例数据结构(元数据)

  定义了顶点数据和实例数据后,我们来思考一个问题,那就是实例数据怎么和顶点数据 “组合”?或者更直白的说,这两个数据怎么传入渲染管线中?按照我们之前掌握的技巧,传递顶点数据大家应该已经很熟悉了,那就是按照我们既定的格式准备好所有顶点的数据在一个缓冲区中,然后在创建 PSO 的时候指定我们传入的顶点的数据结构的 “元信息”(元数据)即可。通常这就是描述一个结构体的数组如下:

typedef struct D3D12_INPUT_ELEMENT_DESC
{LPCSTR SemanticName;UINT SemanticIndex;DXGI_FORMAT Format;UINT InputSlot;UINT AlignedByteOffset;D3D12_INPUT_CLASSIFICATION InputSlotClass;UINT InstanceDataStepRate;
}   D3D12_INPUT_ELEMENT_DESC;

  当然如果你有过数据库设计的经验的话,理解起这个结构体的话是没什么难度的,这其实就相当于一个“字段”的描述信息而已。因此我也亲切的将这个结构体的数组描述信息称之为 “元数据” 。针对前面的定义的本章示例的顶点结构,其“元数据”描述如下:

D3D12_INPUT_ELEMENT_DESC stIALayoutSphere[] =
{// 前三个是每顶点数据从插槽0传入{ "POSITION",0, DXGI_FORMAT_R32G32B32A32_FLOAT,0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },{ "NORMAL",  0, DXGI_FORMAT_R32G32B32A32_FLOAT,0,16, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },{ "TEXCOORD",0, DXGI_FORMAT_R32G32_FLOAT,      0,32, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
};

  上述记录中顶点的数据结构定义是很清晰了(注意我说了“记录”)。它的含义是说一个 Mesh 中每个顶点数据结构中包含3个字段(注意我又说了“字段”),但其实一个Mesh中又是有成千上万个顶点组成的,而每个 Mesh 只是对应一个实例,那么多实例中的每个实例的“元数据”又该怎么定义呢?

  显然,每实例的数据不能紧跟在每顶点数据之后,因为那样每实例的数据就被“笛卡尔积”为每顶点数据了,浪费空间不说,还严重占用显存并影响效率。 根据我们需要实现的多实例渲染的功能需求,那么每实例数据的“元数据”究竟该怎样描述呢?

  这就要用到我们在DirectX12(D3D12)基础教程(十七)——让小姐姐翩翩起舞(3D骨骼动画渲染) 系列教程中用到的“多插槽”(Multi-Slot)方法(当然我更愿意将这个说成是技能,希望你已经完全掌握了之前教程中的完整技能树),对于多实例数据的“元数据”使用单独的插槽(Slot)来传入渲染管线,同时在定义每实例数据的“元数据时”指定 D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA 标志,从其名称中的 “ Per Instance Data” 即可猜出其含义,具体的,根据之前我们定义的每实例数据结构,可以对应的描述其“元数据”如下:

D3D12_INPUT_ELEMENT_DESC stIALayoutSphere[] =
{// 前三个是每顶点数据从插槽0传入{ "POSITION",0, DXGI_FORMAT_R32G32B32A32_FLOAT,0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },{ "NORMAL",  0, DXGI_FORMAT_R32G32B32A32_FLOAT,0,16, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },{ "TEXCOORD",0, DXGI_FORMAT_R32G32_FLOAT,      0,32, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },// 下面的是没实例数据从插槽1传入,前四个向量共同组成一个矩阵,将实例从模型局部空间变换到世界空间{ "WORLD",   0, DXGI_FORMAT_R32G32B32A32_FLOAT,1, 0, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 1 },{ "WORLD",   1, DXGI_FORMAT_R32G32B32A32_FLOAT,1,16, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 1 },{ "WORLD",   2, DXGI_FORMAT_R32G32B32A32_FLOAT,1,32, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 1 },{ "WORLD",   3, DXGI_FORMAT_R32G32B32A32_FLOAT,1,48, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 1 },{ "COLOR",   0, DXGI_FORMAT_R32G32B32A32_FLOAT,1,64, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 1 },{ "COLOR",   1, DXGI_FORMAT_R32_FLOAT,         1,80, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 1 },{ "COLOR",   2, DXGI_FORMAT_R32_FLOAT,         1,84, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 1 },{ "COLOR",   3, DXGI_FORMAT_R32_FLOAT,         1,88, D3D12_INPUT_CLASSIFICATION_PER_INSTANCE_DATA, 1 },
};

  从这个完整的输入数据的记录中,可以明确的看出每实例数据与每顶点数据描述之间的差异与相同点,其实重点的就是插槽(Slot)序号和输入数据类型(Identifies the type of data contained in an input slot.)。而第三处不同就是最后一个参数 InstanceDataStepRate (实例数据步进率),这是功能更强的一个参数,它的含义是说,用当前的一份实例的数据,重复绘制多少个实例。当然对于每顶点数据来说这个参数毫无意义,而对于每实例数据来说,当它大于1时,就是表示最终我们绘制的 一个Mesh的重复实例总数=InstanceDataStepRate×实例数据个数一个Mesh 的重复实例总数 = InstanceDataStepRate \times 实例数据个数一个Mesh的重复实例总数=InstanceDataStepRate×实例数据个数 。

  最终这样的 “元数据” 记录描述为我们带来了极大的灵活性,首先,当我们实际使用多实例绘制时,每个实例的具体参数,是可以按照我们真实的需要自由发挥的,而不必非要像本章教程这里这样的定义,具体需要什么数据都可以在这里自由定义;其次,每个实例数据还可以单独再指定重复的次数,这为很多几乎相同的多个实例绘制提供了极大的便利。

8、Multi-Intance 的第三步:在 Vertex Shader 中接收多实例的数据

  定义完顶点以及对应的多实例数据的 “元数据” 后,面临的问题就是在 Vertex Shader 中应该如何来接收并使用这些数据呢?当然顶点数据的使用,我们已经是轻车熟路了。对于多实例数据,根据本章示例代码中定义的“元数据格式”,就需要像下面这样在 Vertex Shader 中接收:

struct VSInput
{float4     mv4LocalPos     : POSITION;float4       mv4LocalNormal  : NORMAL;float2     mv2UV           : TEXCOORD;float4x4 mxModel2World   : WORLD;float4      mv4Albedo       : COLOR0;    // 反射率float        mfMetallic      : COLOR1;    // 金属度float        mfRoughness     : COLOR2;    // 粗糙度float        mfAO            : COLOR3;    // 环境遮挡系数uint      mnInstanceId    : SV_InstanceID;
};

  上面的结构体定义就是来自于本章示例教程的 Multiple_Instances_VS.hlsl Shader 程序文件中。其中的前三个参数很好理解,需要注意的第一个地方是,第四个参数 mxModel2World 在 Shader 中我们直接使用了 float4x4类型,这相当于 4 个 float4 ,也就是对应我们之前定义的 “元数据” 中语义为 “WORLD” 的连续四个数据。 这里实质上为我们揭示两个具有启发意义的潜在知识:1、PSO 定义中的 D3D12_INPUT_ELEMENT_DESC 结构体记录中的语义需要与Shader中的对应参数语义相对应,当然这应该是已知的知识了,这里只是再次强调一下,方便大家能够更深刻的理解;2、在 D3D12_INPUT_ELEMENT_DESC 结构体记录中定义的数据类型或者说描述的个数不一定非要和Shader中的一一对应,而只需要语义相同,然后加起来的数据大小一致即可;一般我们往往是在D3D12_INPUT_ELEMENT_DESC 结构体记录中使用多条语义相同而顺序连续的多条记录来组成一个Shader中对应的参数或字段,这主要因为在D3D12_INPUT_ELEMENT_DESC 结构体记录中使用的 DXGI 的数据类型没有矩阵等复杂数据类型的对应枚举值引起的。

  接着需要注意的第二个地方是,后续的 COLOR0~3 三个字段虽然用了常见的 COLOR 语义,但除了数据类型两边保持了一致并做到字段一一对应之外,该字段跟 Color 本身并没有太大关系。在这里,其实完全可以按照字段本身含义来命名,比如金属度字段,就可以在两边都定义语义为 “METALLIC”,这是在高版本的 HLSL Shader Model 中被允许的做法。这也就启发我们说,在 D3D12_INPUT_ELEMENT_DESC 结构体记录中和 Shader 中,“语义”名称的本质含义其实只是为了对应相应的字段而已,其命名完全是自由的,有时候甚至跟“语义”本身没有任何关系,因此建议大家在必要的时候尽量使用正确含义的“语义”名称来命名字段。而对于相应的 “SV_” 开头的系统变量语义来说,就不能这样做了,因为系统变量语义命名完全是固定的,就像一般语言中的 “关键字” 一样。当然这也就是说你完全可以将位置字段指定语义为NORMAL,这完全没有问题,当然这样引起的后果就是你的代码将非常难以理解,包括你自己,更为严重的后果就是你很可能被你的队友狠揍一顿。

  最后需要注意的地方就是,mnInstanceId:SV_InstanceID 这个字段是我们在Shader中直接引用的系统变量,而并没有在 D3D12_INPUT_ELEMENT_DESC 结构体记录中做对应的定义,这就是说类似 SV_ 这样开头的一些系统变量,尤其是类似这里的Instance ID一类的系统变量字段,一般在渲染管线中都是隐含的,不需要我们再去定义字段去对应。也就是说你用或不用,它就在那里,不增不减。

  综合起来这时候在 Shader 中我们发现,最终所谓的每实例数据还是被扩展到了每顶点数据上,似乎我们还是绕回了之前说的那个 “笛卡尔积” 的地方。实际上这完全是我们多虑了。其实在 Shader 编译并且最终创建 PSO 的时候, PSO 管线内部已经做了相应的调整,虽然形式上我们传递的每实例数据被扩展填充到了每顶点数据中,但其实它们是被分开存储摆放的,每顶点数据则是在对应的顶点缓冲区中,而每实例数据在这里其实仅仅只是放了个“引用”而已,还是每个实例只有一份并单独集中放在另一块缓冲区中,而最终这两块缓冲区都是由我们的程序来创建和填充的,并且完全由程序来控制。而 Vertex Shader 中的输入参数定义只是一个“引用”定义而已,并不明确的指出这些值的摆放位置,而这些都最终是由我们在C++程序中调用D3D12的API接口来实现控制的。

9、Multi-Intance 的第四步:准备每顶点数据和每实例数据

  到这里大家应该已经搞明白了多实例数据定义的技能,接着就是按照刚才所讨论的,分别为每顶点数据和每顶点数据准备缓冲区,并克隆数据。在本章示例中因为按照开始所描述的已经定义了对应的结构体,所以二者的缓冲区其实就是结构体缓冲而已,并且为了简洁性,两个缓冲我们都放置在了上传堆中,也就是共享的缓冲区中,并没有再进一步传递到默认堆也既显存中。而在实际项目代码中,对于模型数据为了优化访问效率,一般都需要传递到显存中,这个区别请大家一定要注意。因为这里毕竟是教程示例,而不是正式的项目代码,所以更注重简洁性和易理解性。

  首先,本章示例中准备的 Mesh 缓冲如下:

CHAR pszMeshFile[MAX_PATH] = {};
StringCchPrintfA(pszMeshFile, MAX_PATH, "%sAssets\\sphere.txt", T2A(g_pszAppPath));ST_GRS_VERTEX* pstVertices = nullptr;
UINT* pnIndices = nullptr;
UINT  nVertexCnt = 0;
LoadMeshVertex(pszMeshFile, nVertexCnt, pstVertices, pnIndices);
nIndexCnt = nVertexCnt;g_stBufferResSesc.Width = nVertexCnt * sizeof(ST_GRS_VERTEX);
//创建 Vertex Buffer 仅使用Upload隐式堆
GRS_THROW_IF_FAILED(pID3D12Device4->CreateCommittedResource(&g_stUploadHeapProps, D3D12_HEAP_FLAG_NONE, &g_stBufferResSesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&pIVB)));
GRS_SET_D3D12_DEBUGNAME_COMPTR(pIVB);//使用map-memcpy-unmap大法将数据传至顶点缓冲对象
UINT8* pVertexDataBegin = nullptr;GRS_THROW_IF_FAILED(pIVB->Map(0, nullptr, reinterpret_cast<void**>(&pVertexDataBegin)));
memcpy(pVertexDataBegin, pstVertices, nVertexCnt * sizeof(ST_GRS_VERTEX));
pIVB->Unmap(0, nullptr);//创建 Index Buffer 仅使用Upload隐式堆
g_stBufferResSesc.Width = nIndexCnt * sizeof(UINT);
GRS_THROW_IF_FAILED(pID3D12Device4->CreateCommittedResource(&g_stUploadHeapProps, D3D12_HEAP_FLAG_NONE, &g_stBufferResSesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&pIIB)));
GRS_SET_D3D12_DEBUGNAME_COMPTR(pIIB);UINT8* pIndexDataBegin = nullptr;
GRS_THROW_IF_FAILED(pIIB->Map(0, nullptr, reinterpret_cast<void**>(&pIndexDataBegin)));
memcpy(pIndexDataBegin, pnIndices, nIndexCnt * sizeof(UINT));
pIIB->Unmap(0, nullptr);//创建Vertex Buffer View
stVBV[0].BufferLocation = pIVB->GetGPUVirtualAddress();
stVBV[0].StrideInBytes = sizeof(ST_GRS_VERTEX);
stVBV[0].SizeInBytes = nVertexCnt * sizeof(ST_GRS_VERTEX);//创建Index Buffer View
stIBV.BufferLocation = pIIB->GetGPUVirtualAddress();
stIBV.Format = DXGI_FORMAT_R32_UINT;
stIBV.SizeInBytes = nIndexCnt * sizeof(UINT);GRS_SAFE_FREE(pstVertices);
GRS_SAFE_FREE(pnIndices);

  这段代码大家应该已经见过很多遍了,就不再啰嗦讲解了。唯一需要提醒大家就是注意其中定义 Vertex Buffer View 时,使用了数组,并且顶点数据缓冲的Vertex Buffer View 数组索引是 0,这与其“元数据”中定义的插槽序号是对应的。

 接着就需要创建独立的每实例数据(Per Instance Data)的缓冲区了,在本章示例中,终极的目的就是排放一堆材质球,按列逐步缩小金属度参数,按行逐渐增大粗糙度参数,反射率和环境遮挡系数就取了线性的常数,因为对于金属度-粗糙度PBR工作流来说,重要的参数就是金属度和粗糙度,分别影响微表面的菲涅尔反射项及微观遮挡系数以及微观表面的法线随机分布情况(这是上一讲的重点内容,希望你看明白并且理解了,这里只是在简略的阐述一下,加深各位的理解)。具体的本章示例中创建每实例数据缓冲,及对应的每实例数据的代码如下:

int    iRowCnts = 20;             // 行数
int    iColCnts = 2 * iRowCnts;   // 列数:每行的实例个数//创建 Per Instance Data 仅使用Upload隐式堆 iRowCnts行 * iColCnts列个实例
g_stBufferResSesc.Width = (UINT64)(iRowCnts * iColCnts * sizeof(ST_GRS_PER_INSTANCE));
GRS_THROW_IF_FAILED(pID3D12Device4->CreateCommittedResource(&g_stUploadHeapProps, D3D12_HEAP_FLAG_NONE, &g_stBufferResSesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&pIInstanceData)));
GRS_SET_D3D12_DEBUGNAME_COMPTR(pIInstanceData);ST_GRS_PER_INSTANCE* pPerInstanceData = nullptr;
GRS_THROW_IF_FAILED(pIInstanceData->Map(0, nullptr, reinterpret_cast<void**>(&pPerInstanceData)));// 双层循环按照行、列计算每个示例的数据
int   iColHalf = iColCnts / 2;
int   iRowHalf = iRowCnts / 2;
float fDeltaPos = 2.5f;
float fDeltaX = 0.0f;
float fDeltaY = 0.0f;
float fDeltaMetallic = 1.0f / iColCnts;
float fDeltaRoughness = 1.0f / iRowCnts;
float fMinValue = 0.04f; //金属度和粗糙度的最小值,当二者都为0时就没有意义了
float fDeltaAO = 0.92f;
XMFLOAT4 v4Albedo = { 1.00f,0.86f,0.57f,0.0f };for ( int iRow = 0; iRow < iRowCnts; iRow++ )
{// 行循环for ( int iCol = 0; iCol < iColCnts; iCol++ ){// 列循环// 为以(0,0,0)为中心,从左上角向右下角排列fDeltaX = (iCol - iColHalf) * fDeltaPos;fDeltaY = (iRowHalf - iRow) * fDeltaPos;XMStoreFloat4x4(&pPerInstanceData[iRow * iColCnts + iCol].mxModel2World, XMMatrixTranslation(fDeltaX, fDeltaY, 1.0f));// 金属度按列变小    pPerInstanceData[iRow * iColCnts + iCol].mfMetallic += max(1.0f - (iCol * fDeltaMetallic), fMinValue);// 粗糙度按行变大pPerInstanceData[iRow * iColCnts + iCol].mfRoughness += max(iRow * fDeltaRoughness, fMinValue);pPerInstanceData[iRow * iColCnts + iCol].mv4Albedo = v4Albedo;pPerInstanceData[iRow * iColCnts + iCol].mfAO = fDeltaAO;}
}
pIInstanceData->Unmap(0, nullptr);//创建Per Instance Data 的 Vertex Buffer View
stVBV[1].BufferLocation = pIInstanceData->GetGPUVirtualAddress();
stVBV[1].StrideInBytes = sizeof(ST_GRS_PER_INSTANCE);
stVBV[1].SizeInBytes = iRowCnts * iColCnts * sizeof(ST_GRS_PER_INSTANCE);

  上面这段代码重点就是三部分,首先我们简略的使用上传堆创建了每实例数据的缓冲,接着就是一个双层循环填充每个实例对应的参数,最后呢就是创建缓冲对应的 Vertex Buffer View,需要注意的就是这里对应的是顶点缓冲视图数组的第二个元素,这也与元数据中描述的插槽序号相对应。

  至此应该深刻理解和明白关于每实例数据的几个事实:

1、每实例数据的具体数据结构是可以自由定义的,通常每个实例最大不同的参数就是实例对应的位置不同;

2、每实例数据的缓冲与顶点数据的缓冲是独立的,但是必须声明在同一个 D3D12_INPUT_ELEMENT_DESC 结构体记录中;

3、每顶点数据和每实例数据的插槽是不同的,这样就不必重复创建和传输过多的数据;

4、每实例数据在 Vertex Shader 中接收时,只需要保证同语义的字段总和大小相同即可,并且语义可以按照我们的需要自由定义,当然至少要在SM5.0模型以上;

10、Multi-Intance 的第五步:Draw Multi-Instance in One Call!

  最后在有了PSO,有了 Mesh 以及 Multi-Instance数据及缓冲之后,那么就可以进入渲染了。当然这之前按照一般的步骤,应该还要创建对应的常量缓冲、纹理等等资源之后才能进入渲染阶段。然而这些步骤相信大家都已经了然于胸了,我就不过多赘述了,大家自行看代码学习即可。

  要完成一次性 Draw Multi-Instance 的具体操作,那么就需要我们调用 Command 对象的方法:

void STDMETHODCALLTYPE DrawIndexedInstanced( _In_  UINT IndexCountPerInstance,_In_  UINT InstanceCount,_In_  UINT StartIndexLocation,_In_  INT BaseVertexLocation,_In_  UINT StartInstanceLocation)

  这个方法其实我们之前已经用过很多次了,这是 Command List 对象中仅用的几个 Draw Call 方法之一,它的最直接的兄弟版本是DrawInstanced(还有一个 ExecuteIndirect 方法,后续教程中会详细介绍)。二者的唯一区别就是有没有 Index 数据!在我们之前的系列教程中,有 Index 的 Mesh 和 无 Index 的 Mesh 都用过了,虽然我们没有过多介绍这两个方法,但现在相信大家应该已经能够明白这个差别的意义。

  但是这里需要注意的是,无论你用哪个 Draw 在 D3D12 Command List 里就只有支持 Multi-Instance 的这一类版本(One Instance 包含于 Multi-Instance)! 当然这样精简设计 API 的好处就是方便掌握API的用法,其实这是 Windows API 的一个设计风格,即一个 API 尽可能多的完成一类功能,但又不过于臃肿(这类问题学名叫 API 的颗粒度,有兴趣可以了解下),比如著名的 CreateFile 函数,几乎就实现了Windows 中所有 IO 设备的创建,如果不是为了迎合伯克利套接字标准,这个函数还能创建套接字!当然这个思想也主要是来自于Linux 和 古老的 Dos ,即 “万物皆文件”!这是非常有趣的风格和思想,当然结果就是方便了我们这些码农不用记忆过多的 API ,从而可以“一招鲜吃遍天”!

  但另一方面,这样的风格会导致 API 参数会过于复杂的问题,在 Windows 中就是使用了大量的结构体来规整和简化参数的准备和传递,从而试图去规避这个问题。最终这也就造成了对于这种风格不适应的同学,在学习 D3D12 时觉得特别 “啰嗦” 和 “复杂” 的主要原因,因为其实大家仔细回忆一下就会发现其实 D3D12 中常用的或者说可能需要我们记忆的 API 本身并不多,而过于复杂的往往是那些枚举定义和结构体定义。当然我相信大家通过这么漫长的系列教程学习到这里了,应该已经非常习惯这种风格了。如果再深入的思考一下你就会发现其实这样的 枚举+联合+结构体+API的风格其实很容易转化为 OOP 的封装,只是因为D3D基于糟糕的 COM 规范架构使得这个转换封装突然就复杂了至少一个数量级,因为真实封装时你必须要考虑兼容或封装先天已经固化在 D3D COM 框架中的所谓“对象结构”以及“接口”级的问题,而不是真正从纯 C-Style 转换到 OOP Style 那么简单。

  接着暂且撇开风格不论,那么只有 Multi-Instance 版本的 Draw Call 也让我们更深刻的感悟到使用 Multi-Instance Draw 已经成为一种潜在的标准,或者说是被鼓励的一种 Draw Call 方式,这主要是因为这种方法可以通过简单的方法通过 CPU 侧的一个 Draw Call 命令就将大量的数据传递给 GPU ,从而高效的将 GPU “喂饱”!

  最后回到 DrawIndexedInstanced 函数本身,这个函数中第二个参数 InstanceCount 就是我们需要指定一次需要绘制的 Instance 的数量。在本章示例中,调用如下:

//-----------------------------------------------------------------------------------------------------
// Draw !
//-----------------------------------------------------------------------------------------------------
// 设置根签名
pIMainCMDList->SetGraphicsRootSignature(pIRootSignature.Get());
// 设置管线状态对象
pIMainCMDList->SetPipelineState(pIPSOModel.Get());
// 注意我们使用的渲染手法是三角形列表,也就是通常的Mesh网格
pIMainCMDList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);// 这里一次性设置了两个Slot
pIMainCMDList->IASetVertexBuffers(0, cnSlotCount, stVBV);
pIMainCMDList->IASetIndexBuffer(&stIBV);ID3D12DescriptorHeap* ppHeaps[] = { pICBVSRVHeap.Get() };
pIMainCMDList->SetDescriptorHeaps(_countof(ppHeaps), ppHeaps);D3D12_GPU_DESCRIPTOR_HANDLE stGPUSRVHandle = { pICBVSRVHeap->GetGPUDescriptorHandleForHeapStart() };
// 设置CBV
pIMainCMDList->SetGraphicsRootDescriptorTable(0, stGPUSRVHandle);// Draw Call(一次性绘制多个实例)
pIMainCMDList->DrawIndexedInstanced(nIndexCnt, iRowCnts * iColCnts, 0, 0, 0);
//-----------------------------------------------------------------------------------------------------

  上面的代码注释也比较清晰了,大家应该已经很熟悉了,要注意的就是在这里 DrawIndexedInstanced 的第二个参数就是我们需要一次渲染的实例的数量,本例中指定的就是材质球组成的阵列的行数乘以列数。这几乎就是小学生的计算题,就不多啰嗦了。

DirectX12(D3D12)基础教程(十九)—— 多实例渲染相关推荐

  1. 【Visual C++】游戏开发五十一 浅墨DirectX教程十九 网格模型进阶之路

    本系列文章由zhmxy555(毛星云)编写,转载请注明出处. 文章链接: http://blog.csdn.net/zhmxy555/article/details/8770426 作者:毛星云(浅墨 ...

  2. PVE系列教程(十九)、ubuntu22.04使用Nginx配置chevereto服务器

    PVE系列教程(十九).ubuntu22.04使用Nginx配置chevereto服务器 为了更好的浏览体验,欢迎光顾勤奋的凯尔森同学个人博客http://www.huerpu.cc:7000 一.环 ...

  3. 群晖NAS教程(十九)、利用Docker安装青龙面板(京东薅羊毛)

    为了更好的浏览体验,欢迎光顾勤奋的凯尔森同学个人博客 群晖NAS教程(十九).利用Docker安装青龙面板(京东薅羊毛) 一.安装qinglong容器 在群晖docker套件中,搜索qinglong, ...

  4. ComicEnhancerPro 系列教程十九:用JpegQuality看JPG文件的压缩参数

    作者:马健 邮箱:stronghorse_mj@hotmail.com 主页:http://www.comicer.com/stronghorse/ 发布:2017.07.23 教程十九:用JpegQ ...

  5. javascript基础教程_JavaScript基础教程(九)对象、类的定义与使用

    对象.类的定义与使用 对象与类是面向对象程序设计语言教学过程中不可避免需要讲解的内容之一.很多人将两者混为一谈,简单认为对象就是类,类就是对象.实际上深入分析的话,对象与类的区别还是较为明显的.本文主 ...

  6. twisted系列教程十九–cancel deferred

    Introduction twisted 是一个正在发展的项目,twisted 的开发者们会添加一些新的特色或者扩展旧的.随着twisted 10.1.0 的发布,开发者们增加了一个新的功能–取消,这 ...

  7. nCode:DesignLife案例教程十九

    nCode:DesignLife 案例十九--DesignLife中的非线性几何载荷 19.1 案例文件 19.2 回顾FE应力结果和载荷情况 16.3 利用信号处理分离振动和回弹载荷 19.4 配置 ...

  8. DirectX12(D3D12)基础教程(六)——多线程渲染

    目录 1.前言 2.为什么要多线程渲染 3.多线程 3.1.什么是线程 3.2.进程的主线程 3.3.线程的入口函数 3.4.创建线程 3.5.CreateThread示例 3.6.C/C++创建线程 ...

  9. java基础总结(十九)--JDK各个版本的区别

    来自:https://blog.csdn.net/papima/article/details/78219001 jdk1.5的新特性: 1. 泛型    ArrayList list=new Arr ...

  10. Python基础教程(九):面向对象、正则表达式

    Python 面向对象 Python从设计之初就已经是一门面向对象的语言,正因为如此,在Python中创建一个类和对象是很容易的.本章节我们将详细介绍Python的面向对象编程. 如果你以前没有接触过 ...

最新文章

  1. Verilog 中输入输出信号的类型?
  2. STL priority实例
  3. bs4之标签树的平行遍历
  4. 如何确定电脑主板坏了_【不良资产 】(第1422期)银行在打包不良资产出售之前,会如何处置不良资产?...
  5. 对5种主流编程语言的吐槽
  6. Java –从列表中删除所有空值
  7. 跨平台开发框架 Lynx 初探
  8. 为什么大家都只谈薪资,却不谈梦想?
  9. netty 高低位转码_Netty解决粘包和拆包问题的四种方案
  10. 【转】Asp.Net MVC详解Controller之Filter
  11. 微信抢红包插件 android 8.0,微信抢红包插件
  12. 与大数据同行—学习和教育的未来 - 电子书下载(高清版PDF格式+EPUB格式)
  13. xampp 可道云_利用xampp+可道云KodExplorer本地搭建私有云
  14. 清华领军计划计算机试题,清华大学2017年自主招生领军计划笔试真题
  15. Access denied for user 'root'@'localhost'. Account is locked
  16. 看了下华为工资,我不加班了
  17. 编译原理chatpter04 文法的形式和文法的类型
  18. access 查找工龄大于30_Access操作题
  19. 指点迷津 北大教授告诉你什么是 C语言!
  20. 电商项目业务整体概览

热门文章

  1. 【新知实验室】腾讯云TRTC初体验
  2. SVN出现红绿双向箭头原因及处理办法
  3. Ubuntu桌面卡死、You are in emergency mode
  4. 文本地址智能识别组件(一)
  5. python bytes转str_Python3中bytes类型转换为str类型
  6. php如何把pdf转图片,PHP中使用imagick实现把PDF转成图片
  7. L1-059 敲笨钟 (20 分)
  8. LUOGU P1512 伊甸园日历游戏
  9. 深度网络梯度爆炸的原因、产生的影响和解决方法(常用激活函数)
  10. 微软开始彻底封杀IE浏览器