DirectX12(D3D12)基础教程(七)——渲染到纹理、正交投影、UI渲染基础

目录

 

1、前言

2、渲染到纹理

3、调试支持

4、正交投影

5、UI渲染基础

6、本章完整代码链接


1、前言

记得那是在差不多10多年前,我在工作中认识了一位好兄弟小杨。那时他刚毕业,跟我是同一所大学,算我的校友。彼时,我已工作快10年了,在当时那家不大的软件公司管VC++研发部。小杨加入了我的团队。他长了个圆脸,跟我一样戴副眼镜,话语不多,而且眼睛中并没有闪烁着我希望看到的那种灵光,感觉憨憨傻傻的,第一印象像只熊。当时我也没有太多的留意他的其他特质,只是觉得先当个小学徒用着吧,甚至连培养的兴趣都没有。

之后我遇到了工作上第一次瓶颈,因为我已经算是公司的中层了,再往上最多也就是个大部门副总的位置而已。其实我那时已经直接向老板汇报工作了,而且实质上也担任着这家不算很大的软件公司的技术总监的职务,只是没有正式任命,但公司大小技术决策基本都是我说了算。这种尴尬的情况下,不用我多说,上过班的各位很快就会想到我会立即卷入所谓的政治斗争中,这几乎是职场的铁律了。当时稚嫩的我,根本没有任何手段和方法去应对,只能天天感觉心情非常不爽,上班就像上坟一样,可那时自己又始终不知道到底为什么?

正好,当时我们最重要的客户需要一个数据清洗转换工具,也就是今天大家都耳熟能详的所谓ETL工具,按照那时所在公司的尿性,那必须要我的团队进行自主研发的。其实,那时工作中的非工作性质的事情,已经折磨的我很没有那种兴趣了,于是大概写了个框架,也没有想很明白,就跟小杨讲了,告诉他大概的思路,连设计文档都懒得写了,全部口授了,当然中间具体实现的过程中还要使用嵌入式汇编,因为是一个可编程的动态工具。当然我真实的想法就是难为下他,让他差不多知难而退,赶紧走人,以发泄下我心中的不爽情绪。说完我就下班回家睡大觉去了,就想的他过两天应该会很沮丧的找我说搞不定,然后我就假装“安慰”下他,暗示他赶紧走人了。反正我也是不想干了,把团队拆了再走也不错,哈哈哈。现在想来那时的我是有多么的幼稚。

谁知,第二天一上班,小杨居然兴奋的告诉我,他一宿没睡搞定了,做出了一个初步的原型,中间是用了汇编,可以按照脚本中的逻辑,任意调用工具提供的各种函数。并且,他激动的给我做了演示,还跟我说我的想法很好,很有启发性,他一下就打开了思路,居然高兴的一晚上写代码根本停不下来。我惊愕的看着小杨,但表面上故作镇定,我自己都没想明白,你是怎么搞出来的?抄的?不太可能啊,那时的网站上也没有那么多现成的例子啊?!我立马让小杨把全部源码给我,我看后彻底信服了,是的,是他自己实现的没错。于是我赶紧抽了支烟,冷静了一下。

接着我就找他到会议室单独聊了一上午,从兴趣、爱好、看过的书、对C++语言的了解、对开发的认识、甚至游戏开发等等一直聊到未来发展、人生规划、梦想等等。最后我才真正发现,他居然和我一样热爱开发游戏,而且彼时我感觉他的基本功甚至不在我之下。他还给我展示了他在上大学期间业余时间实现的一个打飞机游戏,类似“雷电”的那种,并且给了我源码,我至今一直珍藏着。

真是人不可貌相!很快我们就无话不谈,于是我们顺理成章的成了很好的朋友,我也当他是我的小兄弟,他也当我是他的大哥,那感觉就像人生中又一次遇到了初恋。我们经常一起买书、看书、一起讨论、一起写代码、一起上论坛,甚至周末我有时候都会喊他到我家来,然后我做一桌子拿手菜,然后听他讲他新学到的一些东西,当然有时我们也聊喜欢的电影,也聊军事。记得那时我跟他都为《肖申克的救赎》深深着迷。但我们不聊女人,因为那时他对这个没什么兴趣。其实如果你能体会我说的这种感觉,真正在人生中遇到了志同道合的知己的那种感觉,你就明白我当时是什么样的心情和体验。试问人生能得几个知己?

于是我们约好一定一起到游戏公司去开发游戏。很快我们便付之行动,一家初创的棋牌游戏公司找到了我们,要我们做主程。一开始我跟小杨就定好了行动方案,甚至技术路线选型都做好了,在没有正式离职前已经开始了工作。我们确定使用HGE引擎来做开发,因为是要开发2D版的棋牌游戏,使用GDI的话不光性能成问题,而且很多效果没法简单实现,其他的引擎的话又太复杂。小杨跟我说用原始的HGE引擎,然后慢慢扩展,但是它不支持中文,我说你下载了源码给我。于是我也花了一个晚上,改了一个UNICODE版的HGE出来,支持所有UNICODE字符。第二天小杨看见之后,给我竖了一个大拇指!我依然表面装作这是小Case的样子,但心里却乐开了花!

很快我们先后离职,然后悄悄的一起去了那家公司。在初创公司,加班当然就是日常了。我们一起开心的搞框架、搞引擎、指挥着大家一起写代码、一起吃着盒饭,忙的不亦乐乎。他负责客户端,我负责服务端和全局。那时我最骄傲的就是用一台PC机当服务器,轻松支撑起了6000多个客户端。当然公司也招了很多年轻人,很多都是刚刚毕业,大多比小杨还年轻,有些甚至就是游戏学院毕业的,C++基本都只知道些皮毛。当然其中也有一些美女,主要是做美工的,也有搞程序的,而且小杨很快在这群美女中找到了他未来的爱人。

好景不长,很快我们共同的弱点就暴漏了,一方面是因为我还不擅长管理和带领团队,或者说很不成熟,而无法驾驭一只产品团队,因为之前我主要从事项目类系统的开发,并管理小规模的纯开发型团队。而现在除了行政之外,公司所有的团队都要我来管理,包括美工、策划等等,甚至还要指挥运维团队,还要负责和潜在的商务合作伙伴进行接洽沟通,所以中间的很多事情于我而言都毫无经验。另一方面就是我们共同的基础薄弱问题,导致项目在质量上、进度上、安全性等等方面都暴露出了不少问题,甚至碰到的有些问题远远的超出了我跟他在那时的能力范围之外。加上期间不断的加班、工资被拖欠、远离家乡、以及那些超出我们掌控的问题、无形的压力,让我渐渐的开始对小杨以及其他人要求都变得异常苛刻了,在他们眼中我甚至成了老板的帮凶和打手。而我也在不知不觉中跟他开始隔阂了。

有一次,我对他说,HGE核心用的是DX8,太古老了,现在都已经出DX10了,考虑内核升级一下呗,最好还能支持Shader,搞出比较炫的效果。结果他只是淡淡的跟我说了句,你改个DX9版本的出来呀,你改的出来再说!我语塞了,甚至认为这是对我的挑衅,于是我带着赌气的性质,开始抽时间看HGE的内核,一边琢磨着怎么改成DX9的,并且让它支持自定义Shader,我一定要让小杨看到哥不是徒有虚名!我也要让他心服口服。结果我一败涂地,那时我才明白,了解一样东西,跟你真正掌握一样东西,差距是多么大!最终我还是没能搞定,当然主要还是那时我已经开始心浮气躁,并且忙于实现别的东西,根本也没有太多时间静下来仔细琢磨。渐渐的小杨不再像以前那样相信我了。随着时间的磨砺,我们也渐渐没有了开初的那种志同道合的感觉。

后来又发生了一些不好说明的更夸张的事情,很多人都离开了,最终小杨也弃我而去了。他没有多说什么,只是换了手机号、删除了我的QQ号,甚至我俩之间共同的好友他都删除了,他还隐藏了校内,人人网的账号和主页,我再也没有找到他。友谊的小船就这样翻了。我心中也很懊悔,只是想假如能给我多一点点时间,让我放下所有的自大与自负,好好的去琢磨一下,也许我就能实现,也许那样小杨就还是会相信我,也许他就不会跟我闹掰了,也许现在我就不是孤孤单单一个人琢磨怎么用D3D12去封装一个引擎,也许我们已经在开始我们自己的DXR之旅了,也许我不再因为一个问题无人去探讨而感到迷茫了。也许我们的友谊可以继续……

之所以啰嗦的讲这个故事,只是我想以今天的这篇文章来回答小杨。更主要是为了纪念下这段友谊,失去他就像失去了初恋一样。快10年了,我心里还是放不下。无论他看得见与否,我都要告诉他,如果你还需要,今天我就可以还你一个DX12版的HGE内核实现!如果还有机会,我希望我们能重新开始,当然不再是古老的HGE,2D引擎技术,我们用DXR+多线程+多显卡渲染的酷炫内核实现带VR的3D渲染引擎,不为什么产品,也不为生计,更不为什么风口上的猪,一切只为心中那共同的梦想!我们一起快意人生!

而用D3D12实现HGE引擎渲染核心部分的内容就是今天我们文章中要提到的正交投影渲染和UI渲染基础的部分,有兴趣的朋友,可以在了解了本文内容的基础上,去自己下载和修改下HGE内核中古老的DX8部分,把它变成DX12版的实现。也算最终替我了了这桩往事和心愿。或者权当练手也行。

另外,在学习研究D3D12接口的过程中,我发现很多教程或资料都没有明确的提到在D3D12中怎样渲染到纹理,而这个技术本身对于很多复杂渲染技术来说都是必须的基础功能,于是我就特别的将它提出来,作为基础之一分享出来,按新闻联播的说法,算是填补一项空白吧。

本章我们将实现的结果如下:

请大家注意左上角那个小的矩形框中的内容,这就是使用渲染到纹理技术,先给场景拍一张俯视图,然后再用它做一个UI矩形显示成一个类似小地图窗口的效果。

2、渲染到纹理

其实从本质上讲,渲染目标本身就是一个纹理。只是说我们平常为了能够渲染到显示器,按照DXGI和D3D12的设计,我们使用的渲染目标是创建了与显示器关联的交换链对象之后,从其中获取的渲染目标纹理。

因此为了能够直接渲染到一副普通的纹理,我们就要先创建一个纹理,然后我们将这个纹理设置为渲染目标进行渲染,最后这个纹理就可以作为一个普通的纹理使用,用于物体的贴图等等。

但是在D3D12中,这个方法的实现,还是折腾了我好久。因为D3D12中加入了资源状态的管理,还有资源屏障等等同步状态转换机制之后,要实现渲染到纹理,不明白其中的要点的话,你就会被各种莫名其妙的错误提示给绕晕的。

1、用于渲染目标的纹理,在创建时,其Resource Flag标志字段一定要设定为可以用于渲染目标的标志值,具体代码如下:

D3D12_RESOURCE_DESC stTexDepthsDesc = CD3DX12_RESOURCE_DESC::Tex2D(
    emRTFormat
    , iWndWidth
    , iWndHeight
    , 1
    , 1
    , 1
    , 0
    , D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET
//注意这个标志是纹理可以作为渲染目标使用的关键
);

上面为了方便我们依然使用了D3D12X扩展中的类:CD3DX12_RESOURCE_DESC。它的用法我就不多啰嗦了,对应的结构体D3D12_RESOURCE_DESC的原型如下:

typedef struct D3D12_RESOURCE_DESC {
    D3D12_RESOURCE_DIMENSION Dimension;
    UINT64                   Alignment;
    UINT64                   Width;
    UINT                     Height;
    UINT16                   DepthOrArraySize;
    UINT16                   MipLevels;
    DXGI_FORMAT              Format;
    DXGI_SAMPLE_DESC         SampleDesc;
    D3D12_TEXTURE_LAYOUT     Layout;
    D3D12_RESOURCE_FLAGS     Flags;
} D3D12_RESOURCE_DESC;

实质上我们设置的字段就是枚举变量D3D12_RESOURCE_FLAGS     Flags;这个枚举值可以有下面这些值:

typedef enum D3D12_RESOURCE_FLAGS {
    D3D12_RESOURCE_FLAG_NONE,
    D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET,
    D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL,
    D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS,
    D3D12_RESOURCE_FLAG_DENY_SHADER_RESOURCE,
    D3D12_RESOURCE_FLAG_ALLOW_CROSS_ADAPTER,
    D3D12_RESOURCE_FLAG_ALLOW_SIMULTANEOUS_ACCESS,
    D3D12_RESOURCE_FLAG_VIDEO_DECODE_REFERENCE_ONLY
};

当然为了能够让纹理用于渲染目标,我们需要设置的值就是Allow Render Target。注意它的写法和命名,其实这是一个扩展型标志,就是说使用包含这个标志值被创建的纹理,除了可以当做普通纹理来用之外,还有什么扩展的用途或属性。因此在理解上把这个标志值理解为与原始的纹理默认属性是或的关系。但是其中的Deny Shader Resource是个特例,它的意思就是把原来的可以用于Shader中访问的纹理属性给去除,这里要注意。当然这些标志值是可以通过或运算进行组合的,以使纹理具有多种扩展的用途。具体的用途就可以看值的名称也能猜出个一二三了。这里就不多啰嗦了,以后在具体用到时再扩展介绍吧。

之所以详细的介绍这个这个结构体的这个字段,其实是因为我发现饶了很多圈之后,很多高级的纹理用法,基本都是围绕这个标志展开的。因此特意多粘贴下代码,让大家印象深刻。

2、作为渲染目标创建的纹理,需要一个初始化的清除颜色值,并且这个颜色值最好跟后面我们在渲染前Clear Render Target的颜色值保持一致,因为这样驱动程序会为我们做一个专门的优化。所以在我们本章的例子代码中,我就将这个颜色值统一定义在了靠近WinMain函数开头的部分中,如下:

//被渲染纹理的清除颜色,创建时清除色和渲染前清除色保持一致,
//否则会出现一个未得到优化的警告
const float f4RTTexClearColor[] = { 0.8f, 0.8f, 0.8f, 0.0f };
具体在创建时如下来使用:
D3D12_CLEAR_VALUE stClear = {};
stClear.Format = emRTFormat;
memcpy(&stClear.Color, &f4RTTexClearColor, 4 * sizeof(float));

GRS_THROW_IF_FAILED(pID3DDevice->CreateCommittedResource(
    &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT)
    , D3D12_HEAP_FLAG_NONE
    , &stTexDepthsDesc
    , D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE |
        D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE
    , &stClear
, IID_PPV_ARGS(&pIResRenderTarget)));

而在渲染循环中,我们像下面这样来清除渲染目标:

pICmdList->ClearRenderTargetView(stRTVHandle, f4RTTexClearColor, 0, nullptr);

这样一来我们即得到了驱动层的一个优化处理,也避免了在调试时,因为渲染循环反复执行而不断输出的一个因为两个颜色不一致,而产生的未优化警告信息。有兴趣的朋友可以在清理时使用一个不同的颜色值试一下。

3、需要注意的就是上面的创建代码中,我们使用了一个相对复杂的初始状态标志:

D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE |
D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE。

如果你看过之前教程中说的D3D12反人类设计的吐槽的话,那么这里请你根据这两个状态标志值的名字猜猜它的含义?如果你感觉这两个标志好像应该描述的是一组矛盾的状态的话,那么恭喜你,你又一次成功的掉进了D3D12反人类设计的陷阱中。其实这两个标志的含义是这样的,第一个Pixel Shader Resource是说这个纹理的当前状态是可以用于Pixel Shader中访问的,而第二个Non Pixel Shader Resource标志是说这个纹理当前状态还可以用于非Pixel Shader的其它Shader中,比如Vertex Shader、Hull Shader、Domain Shader中。

怎么样看明白了这个标志的含义之后是不是感觉很崩溃?这其实也是我被纹理状态错误警告折磨了又一段时间之后,折回头仔细看了下这两个标志值的MSDN描述之后才恍然大悟的。那么为什么会需要这两个标志联合设置呢?其实自从D3D10中的统一流处理器模型,并且到Shader Module 4.0-5.0最终一统江湖之后,所有的Shader中几乎都可以调用相同的指令集了。因此所有的Shader中都可以访问纹理了。但是在创建时,我们就需要区分和设置清楚那个纹理可以用于Pixel Shader中,那些用于Non Pixel Shader中。当然这里我已无力再继续吐槽了,既然要区分,那么请问D3D12中为什么不彻底精细区分清楚是Vertex Shader或Hull Shader等哪个Shader可以访问呢?为什么只是Pixel或Non Pixel?难道是为了区分阴阳?

4、接下来,在具体使用时,就需要我们使用资源屏障来保证正确同步的切换纹理的状态了,因为初始化的时候我们是把它设置为了用于Shader Resource,所以在渲染时我们就需要使用下面的代码来将纹理状态切换到用于渲染目标:

//设置屏障将渲染目标纹理从资源转换为渲染目标状态
pICmdList->ResourceBarrier(1
    , &CD3DX12_RESOURCE_BARRIER::Transition(
        pIResRenderTarget.Get()
        , D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE |
            D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE
        , D3D12_RESOURCE_STATE_RENDER_TARGET)
);

接着就是将这个切换状态后的纹理,用于渲染目标,并进行渲染,代码如下:

CD3DX12_CPU_DESCRIPTOR_HANDLE stRTVHandle(pIDHRTVTex->GetCPUDescriptorHandleForHeapStart());
CD3DX12_CPU_DESCRIPTOR_HANDLE dsvHandle(pIDHDSVTex->GetCPUDescriptorHandleForHeapStart());

//设置渲染目标
pICmdList->OMSetRenderTargets(1, &stRTVHandle, FALSE, &dsvHandle);
pICmdList->RSSetViewports(1, &stViewPort);
pICmdList->RSSetScissorRects(1, &stScissorRect);
pICmdList->ClearRenderTargetView(stRTVHandle, f4RTTexClearColor, 0, nullptr);
pICmdList->ClearDepthStencilView(dsvHandle, D3D12_CLEAR_FLAG_DEPTH, 1.0f, 0, 0, nullptr);
//===================================================================================
//第一次执行每个场景物体的的捆绑包,渲染到纹理
for (int i = 0; i < nMaxObject; i++)
{
    ID3D12DescriptorHeap* ppHeapsSkybox[] = { stModuleParams[i].pISRVCBVHp.Get(),stModuleParams[i].pISampleHp.Get() };
    pICmdList->SetDescriptorHeaps(_countof(ppHeapsSkybox), ppHeapsSkybox);
    pICmdList->ExecuteBundle(stModuleParams[i].pIBundle.Get());
}
//===================================================================================

// 最后我们再把纹理的状态正确切回用于Shader纹理的状态:

//渲染完毕将渲染目标纹理由渲染目标状态转换回纹理状态,并准备显示
pICmdList->ResourceBarrier(1
    , &CD3DX12_RESOURCE_BARRIER::Transition(
        pIResRenderTarget.Get()
    , D3D12_RESOURCE_STATE_RENDER_TARGET
    , D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE
         | D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE)
);

当然最后就是正确的关闭命令列表,然后执行命令,使用围栏同步GPU和CPU之后,进行之后的图形显示渲染了。在我们本章例子中我们最后就是使用这个纹理显示为例子程序左上角那个小窗口中的一个画面。

3、调试支持

如果你耐心的阅读过了我之前的那些教程,那么你应该对我每次遇到问题然后又成功解决了的描述印象深刻。当然我每次都会说通过转移注意力或放松一下的方式最终解决了。但其实这个方法可能对各位来说是不太适合的,毕竟有可能你是在通过写代码谋生,而我现在纯粹是业余爱好而已,比较各自的境况和压力是不同的,我这个方法也许太行云流水天马行空了,用流行的说法就是“干货”太少。那么现在我就在这里为大家补充一下调试支持的内容,方便各位以后遇到问题时知道怎么灵活应用调试支持来最终定位问题并解决问题。

其实如果你仔细阅读了我前一篇教程“多线程渲染”中的例子代码之后,你可能会发现,我悄悄的加入了很多调试支持的内容。只是碍于前一篇教程内容太多的缘故,我就没有介绍了。这次我们就有机会来接触下D3D12基本的原初的调试支持。其核心思想就是为每个D3D12或DXGI对象接口进行自定义命名,这样当遇到错误时,D3D12或DXGI底层的调试输出就会准确的用这些名字输出有意义的调试信息,从而使我们快速知道是哪个对象出了什么问题,以便快速的解决。当然,并不是所有的调试错误或警告信息输出都是那么的容易理解,但聊胜于无,起码我们可以快速的去针对某个具体的对象做一些有意义的修改从而最终理解并解决问题。

要充分利用DXGI或D3D12的调试支持,首先就需要我们打开调试支持,这个在本系列教程一开初我们就这样在做了,具体代码如下:

        //2、打开显示子系统的调试支持
        {
#if defined(_DEBUG)
            ComPtr<ID3D12Debug> debugController;
            if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(&debugController))))
            {
                debugController->EnableDebugLayer();
                // 打开附加的调试支持
                nDXGIFactoryFlags |= DXGI_CREATE_FACTORY_DEBUG;
            }
#endif
        }

接着我们就是使用标志值,调用高版本的DXGI工厂函数打开DXGI层的调试支持,代码如下:

// 打开附加的调试支持
nDXGIFactoryFlags |= DXGI_CREATE_FACTORY_DEBUG;
GRS_THROW_IF_FAILED(CreateDXGIFactory2(nDXGIFactoryFlags, IID_PPV_ARGS(&pIDXGIFactory5)));
GRS_SET_DXGI_DEBUGNAME_COMPTR(pIDXGIFactory5);

这样DXGI和D3D12的基础调试支持功能就被打开了,这样我们在VS环境中调试运行程序时,如果发生错误或警告之类,在输出窗口中就可以看到D3D12的各种“抱怨”了。

进一步,在示例代码的开头我放入了一堆辅助函数和宏定义,用来帮助我们在代码中方便的为所有的对象接口命名,这样输出窗口中的信息就会使用这些名称,具体显示是哪个对象接口发生了什么问题。代码如下:

//------------------------------------------------------------------------------------------------------------
// 为了调试加入下面的内联函数和宏定义,为每个接口对象设置名称,方便查看调试输出
#if defined(_DEBUG)
inline void GRS_SetD3D12DebugName(ID3D12Object* pObject, LPCWSTR name)
{
    pObject->SetName(name);
}
inline void GRS_SetD3D12DebugNameIndexed(ID3D12Object* pObject, LPCWSTR name, UINT index)
{
    WCHAR _DebugName[MAX_PATH] = {};
    if (SUCCEEDED(StringCchPrintfW(_DebugName, _countof(_DebugName), L"%s[%u]", name, index)))
    {
        pObject->SetName(_DebugName);
    }
}
#else
inline void GRS_SetD3D12DebugName(ID3D12Object*, LPCWSTR)
{
}
inline void GRS_SetD3D12DebugNameIndexed(ID3D12Object*, LPCWSTR, UINT)
{
}
#endif
#define GRS_SET_D3D12_DEBUGNAME(x)                     GRS_SetD3D12DebugName(x, L#x)
#define GRS_SET_D3D12_DEBUGNAME_INDEXED(x, n)           GRS_SetD3D12DebugNameIndexed(x[n], L#x, n)
#define GRS_SET_D3D12_DEBUGNAME_COMPTR(x)              GRS_SetD3D12DebugName(x.Get(), L#x)
#define GRS_SET_D3D12_DEBUGNAME_INDEXED_COMPTR(x, n) GRS_SetD3D12DebugNameIndexed(x[n].Get(), L#x, n)

#if defined(_DEBUG)
inline void GRS_SetDXGIDebugName(IDXGIObject* pObject, LPCWSTR name)
{
    size_t szLen = 0;
    StringCchLengthW(name, 50, &szLen);
    pObject->SetPrivateData(WKPDID_D3DDebugObjectName, static_cast<UINT>(szLen - 1), name);
}
inline void GRS_SetDXGIDebugNameIndexed(IDXGIObject* pObject, LPCWSTR name, UINT index)
{
    size_t szLen = 0;
    WCHAR _DebugName[MAX_PATH] = {};
    if (SUCCEEDED(StringCchPrintfW(_DebugName, _countof(_DebugName), L"%s[%u]", name, index)))
    {
        StringCchLengthW(_DebugName, _countof(_DebugName), &szLen);
        pObject->SetPrivateData(WKPDID_D3DDebugObjectName, static_cast<UINT>(szLen), _DebugName);
    }
}
#else
inline void GRS_SetDXGIDebugName(ID3D12Object*, LPCWSTR)
{
}
inline void GRS_SetDXGIDebugNameIndexed(ID3D12Object*, LPCWSTR, UINT)
{
}
#endif
#define GRS_SET_DXGI_DEBUGNAME(x)                      GRS_SetDXGIDebugName(x, L#x)
#define GRS_SET_DXGI_DEBUGNAME_INDEXED(x, n)        GRS_SetDXGIDebugNameIndexed(x[n], L#x, n)
#define GRS_SET_DXGI_DEBUGNAME_COMPTR(x)               GRS_SetDXGIDebugName(x.Get(), L#x)
#define GRS_SET_DXGI_DEBUGNAME_INDEXED_COMPTR(x, n)      GRS_SetDXGIDebugNameIndexed(x[n].Get(), L#x, n)
//------------------------------------------------------------------------------------------------------------

其实这些代码就是针对D3D12接口调用SetName接口方法,针对DXGI结构调用SetPrivateData方法设置一个名称。为方便起见,这组方法提供了一套配合ComPtr类使用的方法。另外使用宏的#运算符直接将代码中的接口变量名变换为字符串用作接口的名字。同时还提供了支持数组的方法。

这组宏跟方法都参考自D3D12示例代码,我只是做了下整理加工。可以直接使用。当然你可以使用宏,或者直接使用原始函数,我在代码中已经这样调用了。具体的调用方法我就不啰嗦了。基本的使用原则就是创建一个对象,就命名一个对象,养成这个良好的习惯。

当然最后提醒大家的就是这还只是比较基础的调试支持,更高级的调试支持就需要使用PIX工具了,本系列教程文章中就先不介绍了,等有机会我在别的文章中再介绍。

4、正交投影

其实如果你了解了本系列教程之前的教程的话,你会发现,我基本都是围绕D3D12接口本身的使用方法展开讲解的,这主要是像我一开初说的不想添加太多的其他内容让大家分散精力,而是先考虑掌握D3D12本身的概念、接口、方法和基本的使用方法。因为D3D12较之前版本的D3D接口在概念上扩展了很多,同时方法也不在很直接了,比如加载纹理,我们现在就需要两次Copy大法来完成,这在之前的D3D接口中是没有的。

但是即使这样简化,有些内容还是绕不开的,比如3D数学就是这样,因为我们不可能不用3D数学的东西,向量、矩阵、变换、透视等等我们都用到了,但我并没有过多的介绍,只是说使用DirectXMath库的对应函数即可,并且介绍了DirectXMath库的基本用法。当然我相信各位应该还是对这些概念有所了解的,虽然可能还不具备可以直接推导比如投影矩阵这样的能力,但是对于透视投影矩阵的用法还是了如指掌的。其实一般情况下达到这种水平也就基本不影响我们进行3D应用程序的开发了。

另外其实我也一直在构思怎样能够不太数学概念化的让大家彻底理解这些3D数学知识并能够灵活应用呢?现在有一点点心得了,具体的我将在后续的3D数学知识部分进行详细的讲解。核心的,其实我发现大家学习3D数学之所以困难是因为,我们最终控制的是图形,而我们使用的证明、公式推导、代码等等其实都是代数化的,大家只是不能完全图形化的理解代数化的公式之间的联系。所以看推导过程和结果时就一头雾水,根本就没法理解为啥这样计算以后图形就变成哪个样子了?比如最简单的向量叉乘运算,几乎所有的教程、资料等等都是给你个公式推导,最后都会讲哪个毫无营养的行列式算法,很少有人会跟你说,其实你只需要想想成物理中的力臂和力矩之间的乘法,更直观的你想想这是一口古井上的辘辘,你摇动手柄,然后手柄和辘辘的旋转中心就形成了一个向量叉乘关系,摇动的瞬时方向和从转动轴心方向到摇柄之间的连线就是垂直的向量,他们的叉积就是转动轴向,形成了一个物理上称之为科里奥利力的力矩。如果还没法想明白,就回忆一下你玩指尖陀螺时的感觉,你明显会感到垂直于转动轴的轴向上就有一个力,让你在改变转动轴的方向时感觉到抵抗的作用。此时陀螺转动边上的一点的瞬时方向是切向,与从转动轴心到这一点间的连线叉乘之后就是转动轴向上那个垂直于陀螺旋转面的力矩。而这就是叉乘的本意,如果还没弄明白,不要紧,敬请期待我的3D数学教程系列上线啦,当然时间我无法做出承诺,望大家见谅。

而现在让我们收回下思想,这里我们要简单介绍下正交投影,我就不啰嗦推导公式,直接上图:

wp-content/uploads/2022/09/https://www.caxkernel.com/wp-content/uploads/2022/09/20220914043058-63215902f3ec5.jpg

从图中我想大家应该明白正交投影的意思了吧?其实可以简单的想象成将一个3D物体正对着你不改变大小的拍扁,只留下X、Y坐标就行了。

而这种投影最大的用处就是在2D渲染中,尤其是3D引擎中的UI(用户界面)、Spirit(精灵)等都会用到。因为这种变换会保持X、Y方向的大小不变,正好是2D渲染中需要的效果。而在3D中往往就需要远小近大的透视效果来模拟我们眼睛看物体时的真实效果了。

那么在我前面提到的HGE引擎核心部分,就是使用了这样一个投影。具体的在HGE中为了与真正的显示屏幕坐标统一,它做了一下变换。首先我们知道一般的屏幕坐标是窗体的左上角坐标是原点坐标,X轴正方形沿屏幕平面朝右手边,而Y方向沿屏幕平面垂直向下。往往没有负半轴。而一般我们的3D渲染环境中,屏幕的中心点往往是原点坐标,X轴向右,Y轴向上。

因此在HGE中,具体是使用了下面系列组合变换完成了Y坐标轴的翻转:

XMMATRIX mxOrthographic = XMMatrixScaling(1.0f, -1.0f, 1.0f); //上下翻转,配合之前的Quad的Vertex坐标一起使用
mxOrthographic = XMMatrixMultiply(mxOrthographic, XMMatrixTranslation(-0.5f, +0.5f, 0.0f)); //微量偏移,矫正像素位置
mxOrthographic = XMMatrixMultiply(
    mxOrthographic
    , XMMatrixOrthographicOffCenterLH(stViewPort.TopLeftX
        , stViewPort.TopLeftX + stViewPort.Width
        , -(stViewPort.TopLeftY + stViewPort.Height)
        , -stViewPort.TopLeftY
        , stViewPort.MinDepth
        , stViewPort.MaxDepth)
);

其中XMMatrixScaling方法,大家都知道是表示一个缩放矩阵,这里就是HGE引擎设计比较巧妙的地方,它通过把Y方向的缩放倍数设置为-1从而使它改变了方向,而所有坐标的大小就保持不变。

而XMMatrixOrthographicOffCenterLH的意思是指定一个相对于屏幕垂直的立方体块作为正交投影的区域,并且它最终的X坐标、Y坐标、Z坐标就限制在6个对应的参数之间,也就是左边、右边、上边、下边、前边和后边的值。作为我们2D渲染来说为了模仿窗口的坐标系,其实左边和上边的值就是0,也就是该函数的第一个和第三个的值。但是这里要注意的是因为我们已经使用了Y轴上下颠倒的变换,因此其Y轴向的两个参数Top和Bottom需要变换一下顺序和方向,也就是变成负值,并颠倒次序。最终加上第一个矩阵的颠倒作用,这样一来最终物体的Y坐标其实就被朝世界坐标轴Y轴的负方向移动了,而效果正好就是我们指定正的Y值物体向下移动,从而达到我们想要的模拟屏幕窗口坐标系的效果。

同时需要注意的就是XMMatrixOrthographicOffCenterLH形成的矩阵最终不会改变坐标值的大小,这样我们就利用这一点,为最终需要变换的物体设置成像素单位大小的坐标值,就可以保证最终图形显示的大小就是我们想要的像素大小。

当然为了最终能形成2D窗口坐标系的真正效果,那么我们最终要显示的矩形在定义时也要上下颠倒一下,如下:

ST_GRS_VERTEX_QUAD stTriangleVertices[] =
{
    { { 0.0f, 0.0f, 0.0f, 1.0f }, { 1.0f, 0.0f, 0.0f, 1.0f }, { 0.0f, 0.0f }  },
    { { 1.0f, 0.0f, 0.0f, 1.0f }, { 0.0f, 1.0f, 0.0f, 1.0f }, { 1.0f, 0.0f }  },
    { { 0.0f, 1.0f, 0.0f, 1.0f }, { 1.0f, 1.0f, 1.0f, 1.0f }, { 0.0f, 1.0f }  },
    { { 1.0f, 1.0f, 0.0f, 1.0f }, { 0.0f, 0.0f, 1.0f, 1.0f }, { 1.0f, 1.0f }  }
};

首先,这个矩形顶点属性中,第一个4元组是顶点坐标,第二个四元组是顶点的颜色值,用于不使用纹理而使用纯色画一个矩形时,第三个二元组就是纹理的坐标。而这个顶点格式也就是HGE引擎中定义的顶点格式。

其实如果你在一个如下的3D平面坐标系(忽略Z轴)里绘制这个矩形顶点时,你会发现如下图所示它是一个正常定义的矩形:

实际上,最终因为我们上下颠倒了Y轴,它其实变成了下面的样子:

而这正是我们想要的正确的模拟屏幕坐标系后的样子。当然为了方便起见我们仅定义成了一个单位正方形。

而在最终使用时,我们就利用位移变换来指定我们要显示的矩形的左上角坐标,使用缩放变换指定最终显示矩形的宽和高即可,在本章示例中我们指定的如下:

// 设置矩形框的位置,即矩形左上角的坐标,注意因为正交投影的缘故,这里单位是像素
XMMATRIX xmMVO = XMMatrixMultiply(
    XMMatrixTranslation(10.0f, 10.0f, 0.0f)
    , mxOrthographic);
// 设置矩形框的大小,单位同样是像素
xmMVO = XMMatrixMultiply(
    XMMatrixScaling(320.0f, 240.0f, 1.0f)
    , xmMVO);
//设置MVO
XMStoreFloat4x4(&pMOV->m_mMVO, xmMVO);

这里需要注意的就是与真实的HGE引擎中不同,这里是无论什么矩形,我们都是用那个单位正方形框的顶点定义,而将位置和大小信息使用矩阵乘法做处理,这样的话我们可以将矩形坐标考虑像纹理坐标那样直接上传到默认堆中固化即可。每次都使用像这里的这样的两个变换指定具体的每个不同矩形的位置和大小即可。

而在真实的HGE引擎中它是每次都使用指定的像素坐标位置和大小定义一个新的称为Quad的对象,然后排队渲染。当然这里说的方法也可以用在修改HGE引擎中,最终就看哪个性能高就用哪个。

还需要说明的一个问题就是,我们只是定义了顶点,而没有定义顶点索引,其实这里就是利用了一个渲染手法,在第二讲时我提了一下,但没有说清楚这个问题,这里算是补充说明一下。所谓的渲染手法,就是在Draw Call之前,指定不需要索引缓冲支持的图元绘制方法,具体的渲染时代码如下:

pICmdList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
pICmdList->IASetVertexBuffers(0, 1, &stVBViewQuad);
//Draw Call!!!
pICmdList->DrawInstanced(_countof(stTriangleVertices), 1, 0, 0);

代码中我们通过IASetPrimitiveTopology命令函数,指定了后面绘制的方法是D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP,也就是通常所说的三角形带。意思就是说我们提供的顶点坐标是每三个组成一个三角形,并且前一个三角形和后一个三角形共享两个顶点一条边。

关于渲染手法的内容往往是3D图形学中几何图元表示部分的基础内容,我这里就不在赘述了,大家去复习下即可。而需要补充说明的就是,与OpenGL丰富的图元表示手法不同,D3D中只支持有限但够用的几种而已。不要被这个绘制手法枚举值的D3D定义给蒙蔽了,虽然它看上去有很多值,但本质上其实就是只有点、线、三角形+曲面细分控制点等几种而已,其余都只是简单扩展而已。这不是说D3D不如OpenGL,其实本质上这个细微的差别仅仅只是二者主要的用途不同而已。并不影响各自的渲染能力、性能等等。

最后在HGE引擎的矩阵框架中,为了与透视投影保持一致,仍然保留了View矩阵,当然只是一个简单的单位矩阵,所以我也在示例中保留了View。代码如下:

// View * Orthographic 正交投影时,视矩阵通常是一个单位矩阵,这里之所以乘一下以表示与射影投影一致
mxOrthographic = XMMatrixMultiply(
    XMMatrixIdentity()
    , mxOrthographic
);

之所以保留这个View矩阵的运算,是因为为了考虑将来在封装引擎或仅仅是为了做演示的小程序时,保持与3D渲染中的Module、World、View、Projection等矩阵形成的渲染框架的一致性。

最终通过上面的矩阵变换和矩形定义相结合,那么我们就实现了类似屏幕像素坐标系的2D渲染框架的基础部分了。当然还可以继续实现2D线段、2D曲线、2D多边形等相关图元渲染的支持,而具体实现上就是按照屏幕坐标系指定对应图元的像素坐标即可。

5、UI渲染基础

有了前面所述的正交投影变换的基础,其实UI渲染的基础封装的其他部分,就主要集中在渲染管线的Alpha混合状态上了,这可以通过在创建渲染管线状态对象时,为D3D12_GRAPHICS_PIPELINE_STATE_DESC结构体的成员D3D12_BLEND_DESC BlendState;子结构体设置参数即可。通常可以像下面这里来设置:

D3D12_GRAPHICS_PIPELINE_STATE_DESC transparentPsoDesc = opaquePsoDesc;
D3D12_RENDER_TARGET_BLEND_DESC transparencyBlendDesc;
transparencyBlendDesc.BlendEnable = true;
transparencyBlendDesc.LogicOpEnable = false;
transparencyBlendDesc.SrcBlend = D3D12_BLEND_SRC_ALPHA;
transparencyBlendDesc.DestBlend = D3D12_BLEND_INV_SRC_ALPHA;
transparencyBlendDesc.BlendOp = D3D12_BLEND_OP_ADD;
transparencyBlendDesc.SrcBlendAlpha = D3D12_BLEND_ONE;
transparencyBlendDesc.DestBlendAlpha = D3D12_BLEND_ZERO;
transparencyBlendDesc.BlendOpAlpha = D3D12_BLEND_OP_ADD;
transparencyBlendDesc.LogicOp = D3D12_LOGIC_OP_NOOP;
transparencyBlendDesc.RenderTargetWriteMask = D3D12_COLOR_WRITE_ENABLE_ALL;
transparentPsoDesc.BlendState.RenderTarget[0] = transparencyBlendDesc;
ThrowIfFailed(md3dDevice->CreateGraphicsPipelineState(&transparentPsoDesc, IID_PPV_ARGS(&mPSOs)));

具体的关于Alpha混合的例子就不给出了。这里只是给出一个参考,大家有兴趣可以结合本文前一小节的方法,自己实现一个支持带有Alpha通道的PNG图片来试着做出具有透明效果的UI渲染框架出来。更进一步可以通过这些方法试着修改一下HGE引擎中的渲染部分。当然因为HGE引擎太过古老,首先你就要能够编译通过它。然后再做修改。这权当是练习吧,我就不过多讲解了。

最后需要说明的是,我们这里说的是UI渲染核心,而不是说告诉大家完整的UI系统如何去实现。完整的UI系统还需要输入获取、输入判定、文字系统等等的支持,有些甚至还需要音效支持等等,这些都已经不是我们这个系列教程要讲解的内容了。这里请大家不要误解,当然本章介绍的内容肯定是UI渲染的基础技术能力之一,也是本系列文章之所以称之为基础教程的原因。

6、本章完整代码链接

链接:D3D12RenderToTexture.rar

提取码:xo05

二维码:

 

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片