DirectX11 With Windows SDK--29 计算着色器:内存模型、线程同步;实现顺序无关透明度(OIT)

前言

因为透明混合在不一样的绘制顺序下结果会不一样,这就要求绘制前要对物体进行排序,而后再从后往前渲染。但即使是仅渲染一个物体(如上一章的水波),也会出现透明绘制顺序不对的状况,普通的绘制是没法避免的。若是要追求正确的效果,就须要对每一个像素位置对全部的像素按深度值进行排序。本章将介绍一种仅DirectX11及更高版本才能实现的顺序无关的透明度(Order-Independent Transparency,OIT),虽然它是用像素着色器来实现的,可是用到了计算着色器里面的一些相关知识。html

这一章综合性很强,在学习本章以前须要先了解以下内容:node

章节内容
11 混合状态
12 深度/模板状态、平面镜反射绘制(仅深度/模板状态)
14 深度测试
24 Render-To-Texture(RTT)技术的应用
28 计算着色器:波浪(水波)
深刻理解与使用缓冲区资源(结构化缓冲区、字节地址缓冲区)

学习目标:git

  1. 熟悉内存模型、线程同步
  2. 熟悉顺序无关透明度

DirectX11 With Windows SDK完整目录github

Github项目源码web

欢迎加入QQ群: 727623616 能够一块儿探讨DX11,以及有什么问题也能够在这里汇报。算法

DirectCompute 内存模型

DirectCompute提供了三种内存模型:基于寄存器的内存设备内存组内共享内存。不一样的内存模型在内存大小、速度、访问方式等地方有所区别。编程

基于寄存器的内存:它的访问速度很是快,可是寄存器不只有数目限制,寄存器指向的资源内部也有大小限制。如纹理寄存器(t#),常量缓冲区寄存器(b#),无序访问视图寄存器(u#),临时寄存器(r#或x#)等。并且咱们在使用寄存器时也不是直接指定某一个寄存器来使用,而是经过着色器对象(例如tbuffer,它是在GPU内部的内存,所以访问速度特别快)对应到某一寄存器,而后使用该着色器对象来间接使用该寄存器的。并且这些寄存器是随着着色器编译出来后定死了的,所以寄存器的可用状况取决于当前使用的着色器代码。windows

下面的代码展现了如何声明一个基于寄存器的内存:数组

tbuffer tb : register(t0)
{
    float weight[256];      // 能够从CPU更新,只读
}

设备内存:一般指的是D3D设备建立出来的资源(如纹理、缓冲区),这些资源能够长期存在,只要引用计数不为0。你能够给这些资源建立很大的内存空间来使用,而且你还能够将它们做为着色器资源或者无序访问资源绑定到寄存器中供使用。固然,这种做为着色器资源的访问速度仍是没有直接在寄存器上建立的内存对象来得快,由于它是存储在GPU外部的显存中。尽管这些内存能够经过很是高的内部带宽来访问,可是在请求值和返回值之间也有一个相对较高的延迟。尽管无序访问视图能够用于在设备内存中实现与基于寄存器的内存相同的操做,但当执行频繁的读写操做时,性能将会收到严重影响。此外,因为每一个线程均可以经过无序访问视图读取或写入资源中的任何位置,这须要手动同步对资源的访问,也可使用原子操做,又或者定义一个合理的访问方式避免出现多个线程访问到设备内存的同一个数据。缓存

组内共享内存:前面两种内存模型是全部可编程着色阶段均可使用的,可是group shared memory只能在计算着色器使用。它的访问速度比设备内存资源快些,比寄存器慢,可是也有明显的内存限制——每一个线程组最多只能分配32KB内存,供内部全部线程使用。组内共享的内存必须肯定线程将如何与内存交互和使用内存,所以它还必须同步对该内存的访问。这将取决于正在实现的算法,但它一般涉及到前面描述的线程寻址。

这三种类型的内存提供了不一样的访问速度和可用的大小,使得它们能够用于与其能力相匹配的不一样状况,这也给计算着色器提供了更大的内存操做灵活性。下表则是对内存模型的总结:

内存模型 访问速度 可用内存 使用方式
基于寄存器的内存 很快 声明寄存器内存对象、全局变量
设备内存 较慢 经过特定视图绑定到渲染管线
组内共享内存 较快 较小 仅支持计算着色器,在全局变量前面加groupshared

线程同步

因为大量线程同时运行,而且线程可以经过组内共享内存或经过无序访问视图对应的资源进行交互,所以须要可以同步线程之间的内存访问。与传统的多线程编程同样,许多线程可用读取和写入相同的内存位置,存在写后读(Read After Write,简称RAW)致使内存损坏的危险。如何在不损失GPU并行性带来的性能的状况下还可以高效地同步这么多线程?幸运的是,有几种不一样的机制可用用于同步线程组内的线程。

内存屏障(Memory Barriers)

这是一种最高级的同步技术。HLSL提供了许多内置函数,可用于同步线程组中全部线程的内存访问。须要注意的是,它只同步线程组中的线程,而不是整个调度。这些函数有两个不一样的属性。第一个是调用函数时线程正在同步的内存类别(设备内存、组内共享内存,仍是二者都有),第二个则指定给定线程组中的全部线程是否同步到其执行过程当中的同一处。根据这两个属性,衍生出了下面这些不一样版本的内置函数:

不带组内同步 带组内同步
GroupMemoryBarrier GroupMemoryBarrierWithGroupSync
DeviceMemoryBarrier DeviceMemoryBarrierWithGroupSync
AllMemoryBarrier AllMemoryBarrierWithGroupSync

这些函数中都会阻止线程继续,直到知足该函数的特定条件位置。其中第一个函数GroupMemoryBarrior()阻塞线程的执行,直到线程组中的全部线程对组内共享内存的全部写入都完成。这用于确保当线程在组内共享内存中彼此共享数据时,所需的值在被其余线程读取以前有机会写入组内共享内存。这里有一个很重要的区别,即着色器核心执行一个写指令,而那个指令其实是由GPU的内存系统执行的,而且写入内存中,而后在内存中它将再次对其余线程可用。从开始写入值到完成写入到目标位置有一个可变的时间量,这取决于硬件实现。经过执行阻塞操做,直到这些写操做被保证已经完成,开发人员能够肯定不会有任何写后读错误引起的问题。

不过话说了那么多,总得实践一下。我的将双调排序项目中BitonicSort_CS.hlsl第15行的GroupMemoryBarrierWithGroupSync()修改成GroupMemoryBarrier(),执行后发现屡次运行程序会出现一例排序结果不一致的状况。所以能够这样判断:GroupMemoryBarrier()仅在线程组内的全部线程组存在线程写入操做时阻塞,所以可能会出现阻塞结束时绝大多数线程完成了共享数据写入,但仍有少许线程甚至还没开始写入共享数据。所以实际上不多可以见到他出场的机会。

而后是GroupMemoryBarriorWithGroupSync()函数,相比上一个函数,他还阻止那些先到该函数的线程执行,直到全部的线程都到达该函数才能继续。很明显,在全部组内共享内存都加载以前,咱们不但愿任何线程前进,这使它成为完美的同步方法。

而第二对同步函数也执行相似的操做,只不过它们是在设备内存池上操做。这意味着在继续执行着色器程序前,能够同步经过无序访问视图写入资源的全部挂起内存的写入操做。这对于同步更大数目的内存更为有用,若是所需的共享存储器的大小太大不适合用组内共享内存,则能够将数据存在更大的设备内存的资源中。

第三对同步函数则是一块儿执行前面两种类型的同步,用于同时存在共享内存和设备内存的访问和同步上。

原子操做

内存屏障对于同步线程中的全部线程很是有用。然而,在许多状况下,还须要较小规模的同步,可能一次只须要几个线程。在其余状况下,线程应该同步的位置可能在同一个执行点,也可能不在同一个执行点(例如,当线程组中的不一样线程执行异构任务时)。Shader Model 5引入了许多新的原子操做,能够在线程之间提供更细力度的同步。这样在多线程访问共享资源时,可以确保全部其余线程都不能在统一时间访问相同资源。原子操做保证该操做一旦开始,就一直运行到结束:

原子操做
InterlockedAdd
InterlockedMin
InterlockedMax
InterlockedOr
InterlockedAnd
InterlockedXor
InterlockedCompareStore
InterlockedCompareExchange
InterlockedExchange

原子操做也能够用于组内共享内存和资源内存。这里举个使用的例子,若是计算着色器程序但愿保留遇到特定数据值的线程数的计数,那么总计数能够初始化为0,而且每一个线程能够在组内共享内存(以最快的访问速度)或资源(在调度调用之间持续存在)上执行InterLockedAdd函数。这些原子操做确保总计数正确递增,而不会被不一样线程重写中间值。

每一个函数都有其独特的输入要求和操做,所以在选择合适的函数时应参考Direct3D 11文档。像素着色阶段也可使用这些函数,容许它跨资源同步(注意像素着色器不支持组内共享内存)。

顺序无关透明度

如今让咱们再回顾一下正确的透明计算法。对每个像素来讲,若当前的背景色为\(c_0\),而后待渲染的透明像素片元按深度值从大到小排序为\(c_1, c_2, ..., c_n\),透明度为\(a_1, a_2, ..., a_n\)则最终的像素颜色为:
\[ c=[a_n c_n + (1 - a_n)...[a_2 c_2 + (1 - a_2)[a_1 c_1 + (1 - a_1)c_0]...] \]
在以往的绘制方式,咱们没法控制透明像素片元的绘制顺序,运气好的话还能正确呈现,一旦换了视角就会出现问题。要是场景里各类透明物体交错在一块儿,基本上不管你怎么换视角都没法呈现正确的混合效果。所以为了实现顺序无关透明度,咱们须要预先收集这些像素,而后再进行深度排序,最后再计算出正确的像素颜色。

逐像素使用链表(Per-Pixel Linked Lists)

Direct3D 11硬件为许多新的渲染算法打开了大门,尤为是对PS写入UAV、附着在Buffer的原子计数器的支持,为Per-Pixel Linked Lists带来了可能,它能够实现诸如OIT,间接阴影,动态场景的光线追踪等。

可是,因为着色器只有按值传递,没有指针和引用,在GPU是作不到使用基于指针或引用的链表的。为此,咱们使用的数据结构是静态链表,它能够在数组中实现,本来做为next的指针则变成了下一个元素的索引值。

由于数组是一个连续的内存区域,咱们还能够在一个数组中,存放多条静态链表(只要空间足够大)。基于这个思想,咱们能够为每一个像素建立一个链表,用来收集对应屏幕像素位置的待渲染的全部像素片元。

该算法须要历经两个步骤:

  1. 建立静态链表。经过像素着色器,利用相似头插法的思想在一个大数组中逐渐造成静态链表。
  2. 利用静态链表渲染。经过计算着色器,取出当前像素对应的链表元素,进行排序,而后将计算结果写入到渲染目标。

建立静态链表

首先须要强调的是,这一步须要的是像素着色器5.0而不是计算着色器5.0。由于这一步其实是把本来要绘制到渲染目标的这些像素片元给拦截下来,放到静态链表当中。而要写入Buffer就须要容许像素着色器支持无序访问视图(UAV),只有5.0及更高的版本才能这样作。

此外,咱们还须要原子操做的支持,这也须要使用着色器模型5.0。

最后咱们还须要建立两个支持读/写的缓冲区,用于绑定到无序访问视图:

  1. 片元/连接缓冲区:该缓冲区存放的是片元数据和指向下一个元素的索引(即连接),而且因为它须要承担全部像素的静态链表,须要预留足够大的空间来存放这些片元(元素数目一般为渲染目标像素总数的数倍)。所以该算法的一个主要开销是GPU内存空间。其次,片元/连接缓冲区必需要使用结构化缓冲区,而不是多个有类型的缓冲区,由于只有RWStructuredBuffer才可以开启隐藏的计数器,而这个计数器又是实现静态链表必不可少的一部分,它用于统计缓冲区已经存放的链表节点数目。
  2. 首节点偏移缓冲区:该缓冲区的宽度与高度渲染目标的一致,存放的元素是对应渲染目标像素在片元/连接缓冲区对应的静态链表的首节点偏移。并且因为采用的是头插法,指向的一般是当前像素最后一个写入的片元位置。在使用以前咱们须要定义-1(如果uint则为0xFFFFFFFF)为达到链表末端,所以每次使用以前都须要初始化连接值为-1.该缓冲区使用的是RWByteAddressBuffer,由于它可以支持原子操做

下图展现了经过像素着色器建立静态链表的过程:

看完这个动图后其实应该基本上能理解了,可能你的脑海里已经有了初步的代码构造,但如今仍是须要跟着现有的代码学习才能实现。

首先放出实现该效果须要用到的常量缓冲区、结构体和函数:

// OIT.hlsli

cbuffer CBFrame : register(b6)
{
    uint g_FrameWidth;      // 帧像素宽度
    uint g_FrameHeight;     // 帧像素高度
    uint2 g_Pad2;
}

struct FragmentData
{
    uint Color;             // 打包为R8G8B8A8的像素颜色
    float Depth;            // 深度值
};

struct FLStaticNode
{
    FragmentData Data;      // 像素片元数据
    uint Next;              // 下一个节点的索引
};

// 打包颜色
uint PackColorFromFloat4(float4 color)
{
    uint4 colorUInt4 = (uint4) (color * 255.0f);
    return colorUInt4.r | (colorUInt4.g << 8) | (colorUInt4.b << 16) | (colorUInt4.a << 24);
}

// 解包颜色
float4 UnpackColorFromUInt(uint color)
{
    uint4 colorUInt4 = uint4(color, color >> 8, color >> 16, color >> 24) & (0x000000FF);
    return (float4) colorUInt4 / 255.0f;
}

一个像素颜色的类型为float4,要是用它做为数据存储到缓冲区会特别消耗显存,由于最终显示到后备缓冲区的类型为R8G8B8A8_UNORMB8G8R8A8_UNORM,要是可以将其打包成uint型,就能够节省这部份内存到原来的1/4。

固然,更狠的作法是,若是已知全部透明物体的Alpha值相同(都为0.5),那咱们又能够将颜色压缩成R5G6B5_UNORM,而后再把深度值压缩成16为规格化浮点数,这样一个像素只须要一半的内存空间就可以表达了,固然代价为:颜色和深度都是有损的。

接下来是用于存储像素片元的着色器:

#include "Basic.hlsli"
#include "OIT.hlsli"

RWStructuredBuffer<FLStaticNode> g_FLBuffer : register(u1);
RWByteAddressBuffer g_StartOffsetBuffer : register(u2);

// 静态链表建立
// 提早开启深度/模板测试,避免产生不符合深度的像素片元的节点
[earlydepthstencil]
void PS(VertexPosHWNormalTex pIn)
{
    // 省略常规的光照部分,最终计算获得的光照颜色为litColor
    // ...
    
    // 取得当前像素数目并自递增计数器
    uint pixelCount = g_FLBuffer.IncrementCounter();
    
    // 在StartOffsetBuffer实现值交换
    uint2 vPos = (uint2) pIn.PosH.xy;  
    uint startOffsetAddress = 4 * (g_FrameWidth * vPos.y + vPos.x);
    uint oldStartOffset;
    g_StartOffsetBuffer.InterlockedExchange(
        startOffsetAddress, pixelCount, oldStartOffset);
    
    // 向片元/连接缓冲区添加新的节点
    FLStaticNode node;
    // 压缩颜色为R8G8B8A8
    node.Data.Color = PackColorFromFloat4(litColor);
    node.Data.Depth = pIn.PosH.z;
    node.Next = oldStartOffset;
    
    g_FLBuffer[pixelCount] = node;
}

这里面多了许多有趣的部分,须要逐一仔细讲解一番。

首先是UAV寄存器,这里要先留个印象,寄存器索引初值不能从0开始,具体的缘由要留到讲C++的某个API时才能说的明白。

来到PS,咱们也能够给像素着色器添加属性,就像上面的[earlydepthstencil]那样。由于在绘制透明物体以前咱们已经绘制了不透明的物体,而不透明的物体会阻挡它后面的透明像素片元。虽然通常状况下深度测试是在像素着色器以后,但也但愿能拒绝掉那些被遮挡的像素片元写入到片元/连接缓冲区种。所以咱们可使用属性[earlydepthstencil],把深度/模板测试提早到光栅化后,像素着色阶段以前,这样就能够有效剔除被遮挡的像素,既减少了性能开销,又保证了渲染的正确。

而后是RWStructuredBuffer特有的方法IncrementCounter,它会返回当前的计数值,并给计数器+1.与之对应的逆操做为DecrementCounter。它也属于原子操做,由于涉及到大量的线程要访问一个计数器,必需要有相应的同步操做才能保证一个时刻只有一个线程访问该计数器,从而确保安全性。

这里又要再提一遍SV_POSITION,在做为顶点着色器的输出时,提供的是未通过透视除法的NDC坐标;而做为像素着色器的输入时,它历经了透视除法、视口变换,获得的是对应像素的坐标值。好比说第233行,154列的像素对应的xy坐标为(232.5, 153.5),抛弃小数部分正好能够用做同宽高纹理相同位置的索引。

紧接着是RWByteAddressBufferInterlockedExchange方法:

void InterlockedExchange(
  in  uint dest,            // 目的地址
  in  uint value,           // 要交换的值
  out uint original_value   // 取出来的原值
);

你能够将其看做是一个写入缓冲区的函数,同时它又吐出原来存储的值。惟一要注意的是一切RWByteAddressBuffer的原子操做的地址值必须为4的倍数,由于它的读写单位都是32位的uint

实际渲染阶段

如今咱们须要让片元/连接缓冲区和首节点偏移缓冲区都做为着色器资源。由于还须要准备一个存放渲染了场景中不透明物体的背景图做为混合初值,同时又还要将结果写入到渲染目标,这样的话咱们还须要用到TextureRender类,存放与后备缓冲区等宽高的纹理,而后将场景中不透明的物体都渲染到此处。

对于顶点着色器来讲,由于是渲染整个窗口,能够直接传顶点:

// OIT_Render_VS.hlsl
#include "OIT.hlsli"

// 顶点着色器
float4 VS(float3 vPos : POSITION) : SV_Position
{
    return float4(vPos, 1.0f);
}

而到了像素着色器,咱们须要对当前像素对应的链表进行深度排序。因为访问设备内存的效率相对较低,并且排序又涉及到频繁的内存操做,在UAV进行链表排序的效率会很低。更好的作法是将全部像素拷贝到临时寄存器数组,而后再作排序,这样效率会更高,其实也就是在像素着色器开辟一个全局静态数组来存放这些链表节点的元素。因为是静态数组,数组元素固定,开辟较大的空间并非一个比较好的选择,这不只涉及到排序的复杂程度,还涉及到显存开销。所以咱们须要限制排序的像素片元数目,同时也意味着只须要读取链表的前面几个元素便可,这是一种比较折中的作法。

因为排序算法的好坏也会影响最终的效率,对于小规模的排序,可使用插入排序,它不只是原址操做,对于已经有序的序列不会有多余的交换操做。又由于是线程内的排序,不能使用双调排序。

像素着色器的代码以下:

// OIT_Render_PS.hlsl
#include "OIT.hlsli"

StructuredBuffer<FLStaticNode> g_FLBuffer : register(t0);
ByteAddressBuffer g_StartOffsetBuffer : register(t1);
Texture2D g_BackGround : register(t2);

#define MAX_SORTED_PIXELS 8

static FragmentData g_SortedPixels[MAX_SORTED_PIXELS];

// 使用插入排序,深度值从大到小
void SortPixelInPlace(int numPixels)
{
    FragmentData temp;
    for (int i = 1; i < numPixels; ++i)
    {
        for (int j = i - 1; j >= 0; --j)
        {
            if (g_SortedPixels[j].Depth < g_SortedPixels[j + 1].Depth)
            {
                temp = g_SortedPixels[j];
                g_SortedPixels[j] = g_SortedPixels[j + 1];
                g_SortedPixels[j + 1] = temp;
            }
            else
            {
                break;
            }
        }
    }
}



float4 PS(float4 posH : SV_Position) : SV_Target
{
    // 取出当前像素位置对应的背景色
    float4 currColor = g_BackGround.Load(int3(posH.xy, 0));
    
    // 取出当前像素位置链表长度
    uint2 vPos = (uint2) posH.xy;
    int startOffsetAddress = 4 * (g_FrameWidth * vPos.y + vPos.x);
    int numPixels = 0;
    uint offset = g_StartOffsetBuffer.Load(startOffsetAddress);
    
    FLStaticNode element;
    
    // 取出链表全部节点
    while (offset != 0xFFFFFFFF)
    {
        // 按当前索引取出像素
        element = g_FLBuffer[offset];
        // 将像素拷贝到临时数组
        g_SortedPixels[numPixels++] = element.Data;
        // 取出下一个节点的索引,但最多只取出前MAX_SORTED_PIXELS个
        offset = (numPixels >= MAX_SORTED_PIXELS) ?
            0xFFFFFFFF : element.Next;
    }
    
    // 对全部取出的像素片元按深度值从大到小排序
    SortPixelInPlace(numPixels);
    
    // 使用SrcAlpha-InvSrcAlpha混合
    for (int i = 0; i < numPixels; ++i)
    {
        // 将打包的颜色解包出来
        float4 pixelColor = UnpackColorFromUInt(g_SortedPixels[i].Color);
        // 进行混合
        currColor.xyz = lerp(currColor.xyz, pixelColor.xyz, pixelColor.w);
    }
    
    // 返回手工混合的颜色
    return currColor;
}

HLSL部分结束了,但C++端还有不少棘手的问题要处理。

OITRender类

在进行OIT像素收集时,须要经过替换像素着色器的手段来完成,所以它须要依附于BasicEffect,很差做为一个独立的Effect使用。在此先放出OITRender类的定义:

class OITRender
{
public:
    template<class T>
    using ComPtr = Microsoft::WRL::ComPtr<T>;

    OITRender() = default;
    ~OITRender() = default;
    // 不容许拷贝,容许移动
    OITRender(const OITRender&) = delete;
    OITRender& operator=(const OITRender&) = delete;
    OITRender(OITRender&&) = default;
    OITRender& operator=(OITRender&&) = default;

    HRESULT InitResource(ID3D11Device* device, 
        UINT width,         // 帧宽度
        UINT height,        // 帧高度
        UINT multiple = 1); // 用多少倍于帧像素数的缓冲区存储像素片元

    // 开始收集透明物体像素片元
    void BeginDefaultStore(ID3D11DeviceContext* deviceContext);
    // 结束收集,还原状态
    void EndStore(ID3D11DeviceContext* deviceContext);
    
    // 将背景与透明物体像素片元混合完成最终渲染
    void Draw(ID3D11DeviceContext * deviceContext, ID3D11ShaderResourceView* background);

    void SetDebugObjectName(const std::string& name);

private:
    struct {
        int width;
        int height;
        int pad1;
        int pad2;
    } m_CBFrame;                                                // 对应OIT.hlsli的常量缓冲区
private:
    ComPtr<ID3D11InputLayout> m_pInputLayout;                   // 绘制屏幕的顶点输入布局

    ComPtr<ID3D11Buffer> m_pFLBuffer;                           // 片元/连接缓冲区
    ComPtr<ID3D11Buffer> m_pStartOffsetBuffer;                  // 起始偏移缓冲区
    ComPtr<ID3D11Buffer> m_pVertexBuffer;                       // 绘制背景用的顶点缓冲区
    ComPtr<ID3D11Buffer> m_pIndexBuffer;                        // 绘制背景用的索引缓冲区
    ComPtr<ID3D11Buffer> m_pConstantBuffer;                     // 常量缓冲区

    ComPtr<ID3D11ShaderResourceView> m_pFLBufferSRV;            // 片元/连接缓冲区的着色器资源视图
    ComPtr<ID3D11ShaderResourceView> m_pStartOffsetBufferSRV;   // 起始偏移缓冲区的着色器资源视图

    ComPtr<ID3D11UnorderedAccessView> m_pFLBufferUAV;           // 片元/连接缓冲区的无序访问视图
    ComPtr<ID3D11UnorderedAccessView> m_pStartOffsetBufferUAV;  // 起始偏移缓冲区的无序访问视图

    ComPtr<ID3D11VertexShader> m_pOITRenderVS;                  // 透明混合渲染的顶点着色器
    ComPtr<ID3D11PixelShader> m_pOITRenderPS;                   // 透明混合渲染的像素着色器
    ComPtr<ID3D11PixelShader> m_pOITStorePS;                    // 用于存储透明像素片元的像素着色器
    
    ComPtr<ID3D11PixelShader> m_pCachePS;                       // 临时缓存的像素着色器

    UINT m_FrameWidth;                                          // 帧像素宽度
    UINT m_FrameHeight;                                         // 帧像素高度
    UINT m_IndexCount;                                          // 绘制索引数
};

这里不放出初始化的代码,但在调用初始化的时候须要注意提供合理的帧像素的倍数,若设置的过低,则缓冲区可能不足以容纳透明像素片元而渲染异常。

OITRender::BeginDefaultStore方法--在默认特效下收集像素片元

无论写什么渲染类,渲染状态的管理是最复杂的,一处错误都会致使渲染结果的不理想。

该方法首先要解决两个主要问题:UAV的初始化、绑定到像素着色阶段。

ID3D11DeviceContext::ClearUnorderedAccessViewUint--使用特定值/向量设置UAV初始值

void ClearUnorderedAccessViewUint(
  ID3D11UnorderedAccessView *pUnorderedAccessView,  // [In]待清空UAV
  const UINT [4]            Values                  // [In]清空值/向量
);

该方法对任何UAV都有效,它是以二进制位的形式来清空值。若为DXGI特定类型,如R16G16_UNORM,则该方法会根据Values的前两个元素取出各自的低16位分别复制到每一个数组元素的x份量和y份量。若为原始内存的视图或结构化缓冲区的视图,则只取Values的第一个元素来复制到缓冲区的每个4字节内。

ID3D11DeviceContext::OMSetRenderTargetsAndUnorderedAccessViews--输出合并阶段设置渲染目标并设置UAV

既然像素着色器可以使用UAV,一开始找了半天都没找到ID3D11DeviceContext::PSSetUnorderedAccessViews,结果发现竟然是在OM阶段的函数提供UAV绑定。

void ID3D11DeviceContext::OMSetRenderTargetsAndUnorderedAccessViews(
  UINT                      NumRTVs,                        // [In]渲染目标数
  ID3D11RenderTargetView    * const *ppRenderTargetViews,   // [In]渲染目标视图数组
  ID3D11DepthStencilView    *pDepthStencilView,             // [In]深度/模板视图
  UINT                      UAVStartSlot,                   // [In]UAV起始槽
  UINT                      NumUAVs,                        // [In]UAV数目
  ID3D11UnorderedAccessView * const *ppUnorderedAccessViews,    // [In]无序访问视图数组
  const UINT                *pUAVInitialCounts                  // [In]各个无序访问视图的计数器初始值
);

前三个参数和后三个参数应该都没什么问题,但中间的那个参数是一个大坑。对于像素着色器,UAVStartSlot应当等于已经绑定的渲染目标视图数目。渲染目标和无序访问视图在写入的时候共享相同的资源槽,这意味着必须为UAV指定偏移量,以便于它们放在待绑定的渲染目标视图以后的插槽中。所以在前面的HLSL代码中,u寄存器须要从1开始就是这里来的。

注意:RTV、DSV、UAV不能独立设置,它们都须要同时设置。

两个绑定了同一个子资源(也所以共享同一个纹理)的RTV,或者是两个UAV,又或者是一个UAV和RTV,都会引起冲突。

OMSetRenderTargetsAndUnorderedAccessViews在如下状况才能运行正常:

NumRTVs != D3D11_KEEP_RENDER_TARGETS_AND_DEPTH_STENCILNumUAVs != D3D11_KEEP_UNORDERED_ACCESS_VIEWS时,须要知足下面这些条件:

  • NumRTVs <= 8
  • UAVStartSlot >= NumRTVs
  • UAVStartSlot + NumUAVs <= 8
  • 全部设置的RTVs和UAVs不能有资源冲突
  • DSV的纹理必须匹配RTV的纹理(但不是相同)

NumRTVs == D3D11_KEEP_RENDER_TARGETS_AND_DEPTH_STENCIL时,说明OMSetRenderTargetsAndUnorderedAccessViews只绑定UAVs,须要知足下面这些条件:

  • UAVStartSlot + NumUAVs <= 8
  • 全部设置的UAVs不能有资源冲突

它还会解除绑定下面这些东西:

  • 全部在slots >= UAVStartSlot的RTVs
  • 全部与待绑定的UAVs发生资源冲突的RTVs
  • 全部当前绑定的资源(SOTargets,CS UAVs, SRVs)冲突的UAVs

提供的深度/模板缓冲区会被忽略,而且已经绑定的深度/模板缓冲区并无被卸下。

NumUAVs == D3D11_KEEP_UNORDERED_ACCESS_VIEWS时,说明OMSetRenderTargetsAndUnorderedAccessViews只绑定RTVs和DSV,须要知足下面这些条件

  • NumRTVs <= 8

  • 这些RTVs相互没有资源冲突

  • DSV的纹理必须匹配RTV的纹理(但不是相同)

它还会解除绑定下面这些东西:

  • 全部在slots < NumRTVs的UAVs

  • 全部与待绑定的RTVs发生资源冲突的UAVs

  • 全部当前绑定的资源(SOTargets,CS UAVs, SRVs)冲突的RTVs

    提供的UAVStartSlot忽略。

如今能够把目光放回到OITRender::BeginDefaultStore上:

void OITRender::BeginDefaultStore(ID3D11DeviceContext* deviceContext)
{
    deviceContext->RSSetState(RenderStates::RSNoCull.Get());
    
    UINT numClassInstances = 0;
    deviceContext->PSGetShader(m_pCachePS.GetAddressOf(), nullptr, &numClassInstances);
    deviceContext->PSSetShader(m_pOITStorePS.Get(), nullptr, 0);

    // 初始化UAV
    UINT magicValue[1] = { 0xFFFFFFFF };
    deviceContext->ClearUnorderedAccessViewUint(m_pFLBufferUAV.Get(), magicValue);
    deviceContext->ClearUnorderedAccessViewUint(m_pStartOffsetBufferUAV.Get(), magicValue);
    // UAV绑定到像素着色阶段
    ID3D11UnorderedAccessView* pUAVs[2] = { m_pFLBufferUAV.Get(), m_pStartOffsetBufferUAV.Get() };
    UINT initCounts[2] = { 0, 0 };
    deviceContext->OMSetRenderTargetsAndUnorderedAccessViews(D3D11_KEEP_RENDER_TARGETS_AND_DEPTH_STENCIL,
        nullptr, nullptr, 1, 2, pUAVs, initCounts);

    // 关闭深度写入
    deviceContext->OMSetDepthStencilState(RenderStates::DSSNoDepthWrite.Get(), 0);
    // 设置常量缓冲区
    deviceContext->PSSetConstantBuffers(6, 1, m_pConstantBuffer.GetAddressOf());
}

上面的代码有两个点要特别注意:

  1. 由于是透明物体,须要关闭背面消隐
  2. 由于没有产生实际绘制,须要关闭深度写入

OITRender::EndStore方法--结束收集

方法以下:

void OITRender::EndStore(ID3D11DeviceContext* deviceContext)
{
    // 恢复渲染状态
    deviceContext->PSSetShader(m_pCachePS.Get(), nullptr, 0);
    ComPtr<ID3D11RenderTargetView> currRTV;
    ComPtr<ID3D11DepthStencilView> currDSV;
    ID3D11UnorderedAccessView* pUAVs[2] = { nullptr, nullptr };
    deviceContext->OMSetRenderTargetsAndUnorderedAccessViews(D3D11_KEEP_RENDER_TARGETS_AND_DEPTH_STENCIL,
        nullptr, nullptr, 1, 2, pUAVs, nullptr);
    m_pCachePS.Reset();
}

OITRender::Draw方法--对透明像素片元进行排序混合并完成绘制

方法以下,到这一步其实已经没那么复杂了:

void OITRender::Draw(ID3D11DeviceContext* deviceContext, ID3D11ShaderResourceView* background)
{

    UINT strides[1] = { sizeof(VertexPos) };
    UINT offsets[1] = { 0 };
    deviceContext->IASetVertexBuffers(0, 1, m_pVertexBuffer.GetAddressOf(), strides, offsets);
    deviceContext->IASetIndexBuffer(m_pIndexBuffer.Get(), DXGI_FORMAT_R32_UINT, 0);

    deviceContext->IASetInputLayout(m_pInputLayout.Get());
    deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

    deviceContext->VSSetShader(m_pOITRenderVS.Get(), nullptr, 0);
    deviceContext->PSSetShader(m_pOITRenderPS.Get(), nullptr, 0);

    deviceContext->GSSetShader(nullptr, nullptr, 0);
    deviceContext->RSSetState(nullptr);

    ID3D11ShaderResourceView* pSRVs[3] = {
        m_pFLBufferSRV.Get(), m_pStartOffsetBufferSRV.Get(), background};
    deviceContext->PSSetShaderResources(0, 3, pSRVs);
    deviceContext->PSSetConstantBuffers(6, 1, m_pConstantBuffer.GetAddressOf());

    deviceContext->OMSetDepthStencilState(nullptr, 0);
    deviceContext->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF);

    deviceContext->DrawIndexed(m_IndexCount, 0, 0);

    // 绘制完成后卸下绑定的资源便可
    pSRVs[0] = pSRVs[1] = pSRVs[2] = nullptr;
    deviceContext->PSSetShaderResources(0, 3, pSRVs);

}

场景绘制

如今场景中除了山体、波浪,还有两个透明相交的立方体。只考虑开启OIT的GameApp::DrawScene方法以下:

void GameApp::DrawScene()
{
    assert(m_pd3dImmediateContext);
    assert(m_pSwapChain);

    m_pd3dImmediateContext->ClearRenderTargetView(m_pRenderTargetView.Get(), reinterpret_cast<const float*>(&Colors::Silver));
    m_pd3dImmediateContext->ClearDepthStencilView(m_pDepthStencilView.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
    
    // 渲染到临时背景
    m_pTextureRender->Begin(m_pd3dImmediateContext.Get(), reinterpret_cast<const float*>(&Colors::Silver));
    {
        // ******************
        // 1. 绘制不透明对象
        //
        m_BasicEffect.SetRenderDefault(m_pd3dImmediateContext.Get(), BasicEffect::RenderObject);
        m_BasicEffect.SetTexTransformMatrix(XMMatrixIdentity());
        m_Land.Draw(m_pd3dImmediateContext.Get(), m_BasicEffect);
    
        // ******************
        // 2. 存放透明物体的像素片元
        //
        m_pOITRender->BeginDefaultStore(m_pd3dImmediateContext.Get());
        {
            m_RedBox.Draw(m_pd3dImmediateContext.Get(), m_BasicEffect);
            m_YellowBox.Draw(m_pd3dImmediateContext.Get(), m_BasicEffect);
            m_pGpuWavesRender->Draw(m_pd3dImmediateContext.Get(), m_BasicEffect);
        }
        m_pOITRender->EndStore(m_pd3dImmediateContext.Get());
    }
    m_pTextureRender->End(m_pd3dImmediateContext.Get());
    
    // 渲染到后备缓冲区
    m_pOITRender->Draw(m_pd3dImmediateContext.Get(), m_pTextureRender->GetOutputTexture());

    // ******************
    // 绘制Direct2D部分
    //
    // ...

    HR(m_pSwapChain->Present(0, 0));
}

演示

下面演示了关闭OIT和深度写入、关闭OIT但开启深度写入、开启OIT下的场景渲染效果:

开启OIT的平均帧数为2700,而默认平均帧数为4200。可见影响渲染性能的主要因素有:RTT的使用、场景中透明像素的复杂程度、排序算法的选择和n的限制。所以要保证渲染效率,最好是可以减小透明物体的复杂程度、场景中透明物体的数目,必要时甚至是避免透明混合。

练习题

  1. 尝试改动HLSL代码,将颜色压缩成R5G6B5_UNORM(规定透明物体Alpha统一为0.5),而后再把深度值压缩成16为规格化浮点数。同时也要改动C++端代码来适配。

参考资料

  1. DirectX SDK Samples中的OIT
  2. OIT-and-Indirect-Illumination-using-DX11-Linked-Lists 演示文件

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 能够一块儿探讨DX11,以及有什么问题也能够在这里汇报。

相关文章
相关标签/搜索