DirectX11 With Windows SDK--24 Render-To-Texture(RTT)技术的应用

前言

尽管在上一章的动态天空盒中用到了Render-To-Texture技术,但那是针对纹理立方体的特化实现。考虑到该技术的应用层面非常广,在这里抽出独立的一章专门来讲有关它的通用实现以及各种应用。

章节回顾
深入理解与使用2D纹理资源(重点阅读ScreenGrab库)

DirectX11 With Windows SDK完整目录

Github项目源码

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

Render-To-Texture技术

在前面的章节中,我们默认的渲染目标是来自DXGI后备缓冲区,它是一个2D纹理。而Render-To-Texture技术,实际上就是使用一张我们自己新建的2D纹理作为渲染目标。与此同时,这个纹理还能够绑定到着色器资源视图(SRV)供着色器所使用,即原本用作输出的纹理现在用作输入。

它可以用于很多方面:

  1. 小地图的实现
  2. 阴影贴图(Shadow mapping)
  3. 屏幕空间环境光遮蔽(Screen Space Ambient Occlusion)
  4. 利用天空盒实现动态反射/折射(Dynamic reflections/refractions with cube maps)
  5. 延迟渲染
  6. 场景渲染到临时纹理,然后进行后处理

在这一章,我们将展示下面这两种应用:

  1. 屏幕淡入/淡出
  2. 小地图(有可视范围的)

Texture2D头文件

这里面包含了大量常用的类。常规的2D纹理类有:

  • Texture2D
  • Texture2DArray
  • TextureCube
  • Texture2DMS(带多重采样)
  • Texture2DMSArray(带多重采样)

而深度缓冲区有如下四种:

  • Depth2D
  • Depth2DArray
  • Depth2DMS
  • Depth2DMSArray

画粗体的类是目前我们常用的。这些类会根据用户设置的BIND_FLAG自动创建所需的视图,具体支持的如下:

完整SRV 单个SRV的数组 完整RTV 单个RTV的数组 单个UAV的数组
Texture2D
Texture2DArray
TextureCube
Texture2DMS
Texture2DMSArray
完整DSV 单个DSV的数组 完整SRV 单个SRV的数组
Depth2D
Depth2DArray
Depth2DMS
Depth2DMSArray

其中Texture相关的类创建的完整着色器资源视图能够与着色器中的类型名称相对应。

这些类可能会有下述方法:

GetRenderTarget();		// 获取完整资源的RTV
GetRenderTarget(idx);	// 仅限数组/TextureCube
GetDepthStencil();		// 获取完整资源的DSV
GetDepthStencil(idx);	// 仅限数组
GetUnorderedAccess();

如果我们要将场景渲染到一张Texture2D,那么我们还需要对应的Depth2D和视口,用来同时绑定到输出合并阶段:

Texture2D litTexture(m_pd3dDevice.Get(),1024, 1024, DXGI_FORMAT_R8G8B8A8_UNORM);
Depth2D depthBuffer(m_pd3dDevice.Get(),1024, 1024);	// 默认D24S8
CD3D11_VIEWPORT viewport(0.0f, 0.0f, (float)litTexture.GetWidth(), (float)litTexture.GetHeight());

m_pd3dImmediateContext->RSSetViewports(1, &viewport);
m_pd3dImmediateContext->OMSetRenderTargets(1, litTexture.GetRenderTarget(), depthBuffer.GetDepthStencil());

在应用层面上,读者不需要过于关注内部实现。但如果想搞懂的话可以去阅读Texture2D.h/.cpp文件

接下来重要的事情说三遍:

  • 不要将同一个纹理(子资源)既作为渲染目标,又作为着色器资源同时绑定到渲染管线上!
  • 不要将同一个纹理(子资源)既作为渲染目标,又作为着色器资源同时绑定到渲染管线上!
  • 不要将同一个纹理(子资源)既作为渲染目标,又作为着色器资源同时绑定到渲染管线上!

写代码写到这个地步,你就会发现写好HLSL代码,写好特效管理,再写完绘制部分代码,尝试运行的时候,总免不了报一堆WARNING或者ERROR,其中又可能会花费很多的时间来尝试解决上述这个WARNING。虽然不属于报错,但这样会导致程序运行速度被拖累。在VS的输出窗口你可以看到它会将该资源强制从着色器中撤离,置其为NULL,以保证不会同时绑定在输入和输出端。而且尤其要注意当前帧结束时候资源的绑定情况,到下一帧开始尝试绑定资源的时候有可能会出现冲突

以一个后处理绘制过程为例,在绘制前我们绑定了一个着色器资源。那么在绘制之后应该要把它撤下来:

pImpl->m_pEffectHelper->SetShaderResourceByName("g_Tex", input);    // 绑定input给g_Tex
deviceContext->OMSetRenderTargets(1, &output, nullptr);	            // 绑定output作为渲染目标
auto pPass = pImpl->m_pEffectHelper->GetEffectPass("XXXX");
pPass->Apply(deviceContext);
deviceContext->Draw(3, 0);

// 清空
input = nullptr;
output = nullptr;
deviceContext->OMSetRenderTargets(0, &output, nullptr);

// 使用MapShaderResourceSlot获取g_Tex对应的slot并立即解绑
deviceContext->PSSetShaderResources(pImpl->m_pEffectHelper->MapShaderResourceSlot("g_Tex"), 1, &input);

// 注意:不要只调用  pImpl->m_pEffectHelper->SetShaderResourceByName("g_Tex", nullptr);
// 这样不会立刻解绑,需要到Apply才解绑

用一个三角形绘制全屏特效

我们知道,一个全屏矩形可以分拆成两个三角形,但这种绘制方式有几个缺点:

  • 由于梯度(ddx/ddy)的计算(用于计算mip level)是以2x2的像素块为单位的,在对角线的交界区域的像素需要产生额外无用的计算用于辅助梯度计算
  • 缓存命中率相对单个三角形全屏绘制较低

现在我们只需要这三个顶点:(-1, 1)(3, 1)(-1, -3)就可以完整覆盖[-1, 1]x[-1, 1]的区域,并且我们也可以干掉顶点缓冲区和输入布局。只需要根据顶点ID的系统语义SV_VertexID产生所需的顶点即可。具体的着色器代码如下:

// 使用一个三角形覆盖NDC空间 
// (-1, 1)________ (3, 1)
//        |   |  /
// (-1,-1)|___|/ (1, -1)   
//        |  /
// (-1,-3)|/      
float4 FullScreenTriangleVS(uint vertexID : SV_VertexID) : SV_Position
{
    float2 grid = float2((vertexID << 1) & 2, vertexID & 2);
    float2 xy = grid * float2(2.0f, -2.0f) + float2(-1.0f, 1.0f);
    return float4(xy, 1.0f, 1.0f);
}

void FullScreenTriangleTexcoordVS(uint vertexID : SV_VertexID,
                                  out float4 posH : SV_Position,
                                  out float2 texcoord : TEXCOORD)
{
    float2 grid = float2((vertexID << 1) & 2, vertexID & 2);
    float2 xy = grid * float2(2.0f, -2.0f) + float2(-1.0f, 1.0f);
    texcoord = grid * float2(1.0f, 1.0f);
    posH = float4(xy, 1.0f, 1.0f);
}

绘制的时候直接Draw(3, 0)即可,顶点缓冲区跟输入装配器都可以不用管。

可以参考下面几篇文章看看他们是怎么说的:

屏幕淡入/淡出效果的实现

该效果作为一种后处理方法,放入了特效管理类PostProcessEffect中,可以直接调用PostProcessEffect::RenderScreenFade方法。着色器文件为ScreenFade_VS.hlslScreenFade_PS.hlsl

首先是PostProcess.hlsli

// PostProcess.hlsli


Texture2D g_Tex : register(t0);
SamplerState g_Sam : register(s0);

// ...

struct VertexPosTex
{
    float3 posL : POSITION;
    float2 tex : TEXCOORD;
};

struct VertexPosHTex
{
    float4 posH : SV_POSITION;
    float2 tex : TEXCOORD;
};

然后分别是对于的顶点着色器和像素着色器实现:

// ScreenFade_VS.hlsl
#include "PostProcess.hlsli"

// 使用一个三角形覆盖NDC空间 
// (-1, 1)________ (3, 1)
//        |   |  /
// (-1,-1)|___|/ (1, -1)   
//        |  /
// (-1,-3)|/      
VertexPosHTex VS(uint vertexID : SV_VertexID)
{
    VertexPosHTex vOut;
    float2 grid = float2((vertexID << 1) & 2, vertexID & 2);
    float2 xy = grid * float2(2.0f, -2.0f) + float2(-1.0f, 1.0f);
    vOut.tex = grid * float2(1.0f, 1.0f);
    vOut.posH = float4(xy, 1.0f, 1.0f);
    return vOut;
}

#include "PostProcess.hlsli"

// 像素着色器
float4 PS(VertexPosHTex pIn, uniform float fadeAmount) : SV_Target
{
    return g_Tex.Sample(g_Sam, pIn.tex) * float4(fadeAmount, fadeAmount, fadeAmount, 1.0f);
}

该套着色器通过gFadeAmount来控制最终输出的颜色,我们可以通过对其进行动态调整来实现一些效果。当fadeAmount从0到1时,屏幕从黑到正常显示,即淡入效果;而当fadeAmount从1到0时,平面从正常显示到变暗,即淡出效果。

一开始像素着色器的返回值采用的是和Rastertek一样的tex.Sample(sam, pIn.Tex) * gFadeAmount,但是在截屏出来的.dds文件观看的时候颜色变得很奇怪

原本以为是输出的文件格式乱了,但当我把Alpha通道关闭后,图片却一切正常了

故这里应该让Alpha通道的值乘上1.0f以保持Alpha通道的一致性

然后是C++端,PostProcessEffect::RenderScreenFade方法的实现:

void PostProcessEffect::RenderScreenFade(
    ID3D11DeviceContext* deviceContext, 
    ID3D11ShaderResourceView* input, 
    ID3D11RenderTargetView* output, 
    const D3D11_VIEWPORT& vp, 
    float fadeAmount)
{
    deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
    deviceContext->RSSetViewports(1, &vp);
    deviceContext->OMSetRenderTargets(1, &output, nullptr);
    pImpl->m_pEffectHelper->SetShaderResourceByName("g_Tex", input);
    auto pPass = pImpl->m_pEffectHelper->GetEffectPass("ScreenFade");
    pPass->PSGetParamByName("fadeAmount")->SetFloat(fadeAmount);
    pPass->Apply(deviceContext);
    deviceContext->Draw(3, 0);

    // 清空
    output = nullptr;
    input = nullptr;
    deviceContext->OMSetRenderTargets(0, &output, nullptr);
    deviceContext->PSSetShaderResources(pImpl->m_pEffectHelper->MapShaderResourceSlot("g_Tex"), 1, &input);
}

GameApp::UpdateScene方法中有用于控制屏幕淡入淡出的部分:

// 更新淡入淡出值
if (m_FadeUsed)
{
    m_FadeAmount += m_FadeSign * dt / 2.0f;    // 2s时间淡入/淡出
    if (m_FadeSign > 0.0f && m_FadeAmount > 1.0f)
    {
        m_FadeAmount = 1.0f;
        m_FadeUsed = false;    // 结束淡入
    }
    else if (m_FadeSign < 0.0f && m_FadeAmount < 0.0f)
    {
        m_FadeAmount = 0.0f;
        SendMessage(MainWnd(), WM_DESTROY, 0, 0);    // 关闭程序
        // 这里不结束淡出是因为发送关闭窗口的消息还要过一会才真正关闭
    }
}

// ...

// 退出程序,开始淡出
if (ImGui::Button("Exit & Fade out"))
{
    m_FadeSign = -1.0f;
    m_FadeUsed = true;
}

启动程序的时候,mFadeSign的初始值是1.0f,这样就使得打开程序的时候就在进行屏幕淡入。

而用户按下Exit & Fade out按钮的话,则先触发屏幕淡出效果,等屏幕变黑后再发送关闭程序的消息给窗口。注意发送消息到真正关闭还相隔一段时间,在这段时间内也不要关闭淡出效果的绘制,否则最后那一瞬间又突然看到场景了。

直接点X关闭窗口的话是没有淡出效果的,因为要改进d3dApp.cpp内会比较麻烦。

GameApp::DrawScene方法中,我们可以将绘制过程简化成这样:

// 绘制主场景
DrawScene(m_FadeUsed ? m_pLitTexture->GetRenderTarget() : GetBackBufferRTV(),
          m_pDepthTexture->GetDepthStencil(),
          m_pCamera->GetViewPort());

if (m_FadeUsed)
{
    // 绘制渐变过程
    m_PostProcessEffect.RenderScreenFade(
        m_pd3dImmediateContext.Get(),
        m_pLitTexture->GetShaderResource(),
        GetBackBufferRTV(),
        m_pCamera->GetViewPort(),
        m_FadeAmount
    );
}

开启淡入/淡出效果绘制的时候我们会将场景先渲染到m_pLitTexture中,然后给该纹理应用特效渲染到后备缓冲区。

对了,如果窗口被拉伸,那我们之前创建的纹理宽高就不适用了,需要重新创建一个。在GameApp::OnResize方法可以看到:

void GameApp::OnResize()
{
    m_pDepthTexture = std::make_unique<Depth2D>(m_pd3dDevice.Get(), m_ClientWidth, m_ClientHeight);
    m_pLitTexture = std::make_unique<Texture2D>(m_pd3dDevice.Get(), m_ClientWidth, m_ClientHeight, DXGI_FORMAT_R8G8B8A8_UNORM);

    // ...
}

小地图的实现

关于小地图的实现,有许多种方式:

  1. 美术预先绘制一张地图纹理,然后再在上面绘制一些2D物件表示场景中的物体
  2. 捕获游戏场景的俯视图用作纹理,但只保留静态物体,然后再在上面绘制一些2D物件表示场景中的物体
  3. 通过俯视图完全绘制出游戏场景中的所有物体

可以看出,性能的消耗越往后要求越高。

因为本演示项目的场景是在夜间森林,并且树是随机生成的,因此采用第二种方式,但是地图可视范围为摄像机可视区域,并且不考虑额外绘制任何2D物件。

小地图的绘制同样放到了后处理中,着色器文件为Minimap_VS.hlslMinimap_PS.hlsl。同样这里只关注HLSL实现。

首先是PostProcess.hlsli

// PostProcess.hlsli

cbuffer CB : register(b0)
{
    float g_VisibleRange;        // 3D世界可视范围
    float3 g_EyePosW;            // 摄像机位置
    float4 g_RectW;              // 小地图xOz平面对应3D世界矩形区域(Left, Front, Right, Back)
}

为了能在小地图中绘制出局部区域可视的效果,还需要依赖3D世界中的一些参数。其中g_RectW对应的是3D世界中矩形区域(即x最小值, z最大值, x最大值, z最小值)。

然后是顶点着色器和像素着色器的实现:

// Minimap_VS.hlsl
#include "PostProcess.hlsli"

// 使用一个三角形覆盖NDC空间 
// (-1, 1)________ (3, 1)
//        |   |  /
// (-1,-1)|___|/ (1, -1)   
//        |  /
// (-1,-3)|/      
VertexPosHTex VS(uint vertexID : SV_VertexID)
{
    VertexPosHTex vOut;
    float2 grid = float2((vertexID << 1) & 2, vertexID & 2);
    float2 xy = grid * float2(2.0f, -2.0f) + float2(-1.0f, 1.0f);
    vOut.tex = grid * float2(1.0f, 1.0f);
    vOut.posH = float4(xy, 1.0f, 1.0f);
    return vOut;
}

// Minimap_PS.hlsl
#include "PostProcess.hlsli"

// 像素着色器
float4 PS(VertexPosHTex pIn) : SV_Target
{
    float2 posW = pIn.tex * float2(g_RectW.zw - g_RectW.xy) + g_RectW.xy;
    
    float4 color = g_Tex.Sample(g_Sam, pIn.tex);
    
    if (length(posW - g_EyePosW.xz) / g_VisibleRange > 1.0f)
    {
        color = float4(0.0f, 0.0f, 0.0f, 1.0f);
    }

    return color;
}

RenderMinimap的实现和RenderScreenFade类似,这里不再赘述。

在生成了随机位置的树木后,我们需要使用正交投影矩阵。XMMatrixOrthographicLH函数的中心在摄像机位置,不以摄像机为中心的话可以用XMMatrixOrthographicOffCenterLH函数。然后在一开始的时候就将场景渲染到小地图的纹理:

// ******************
// 初始化Minimap
//
m_pMinimapTexture = std::make_unique<Texture2D>(m_pd3dDevice.Get(), 400, 400, DXGI_FORMAT_R8G8B8A8_UNORM);
std::unique_ptr<Depth2D> pMinimapDepthTexture = std::make_unique<Depth2D>(m_pd3dDevice.Get(), 400, 400);
CD3D11_VIEWPORT minimapViewport(0.0f, 0.0f, 400.0f, 400.0f);

// ******************
// 渲染小地图纹理
// 

m_BasicEffect.SetViewMatrix(XMMatrixLookToLH(
    XMVectorSet(0.0f, 10.0f, 0.0f, 0.0f), 
    XMVectorSet(0.0f, -1.0f, 0.0f, 0.0f), 
    XMVectorSet(0.0f, 0.0f, 1.0f, 0.0f)));
// 使用正交投影矩阵(中心在摄像机位置)
m_BasicEffect.SetProjMatrix(XMMatrixOrthographicLH(190.0f, 190.0f, 1.0f, 20.0f));	
// 关闭雾效,绘制小地图
m_BasicEffect.SetFogState(false);
DrawScene(m_pMinimapTexture->GetRenderTarget(), pMinimapDepthTexture->GetDepthStencil(), minimapViewport);

GameApp::DrawScene中,在绘制了场景后, 我们可以通过设置视口的方式,将整个小地图纹理绘制到指定的地方。这里选择了右下角300x300的区域:

// 绘制小地图到场景
CD3D11_VIEWPORT minimapViewport(
    std::max(0.0f, m_ClientWidth - 300.0f), 
    std::max(0.0f, m_ClientHeight - 300.0f), 
    std::min(300.0f, (float)m_ClientWidth),
    std::min(300.0f, (float)m_ClientHeight));
m_PostProcessEffect.RenderMinimap(
    m_pd3dImmediateContext.Get(),
    m_pMinimapTexture->GetShaderResource(),
    m_FadeUsed ? m_pLitTexture->GetRenderTarget() : GetBackBufferRTV(),
    minimapViewport);

项目演示

本项目的场景沿用了第20章的森林场景,并搭配了夜晚雾效,在打开程序后可以看到屏幕淡入的效果,按下Exit & Fade out后则屏幕淡出后退出,注意不是直接关掉窗口。

image

然后人物在移动的时候,小地图的可视范围也会跟着移动。

image

DirectX11 With Windows SDK完整目录

Github项目源码

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

posted @ 2018-11-18 12:17  X_Jun  阅读(4673)  评论(0编辑  收藏  举报
levels of contents