一、静态天空盒与反射天空盒
这一章我们主要学习由6个纹理所构成的立方体映射,以及用它来实现一个静态天空盒。
但是在此之前先要消除两个误区:
- 认为这一章的天空盒就是简单的在一个超大立方体的六个面内部贴上天空盒纹理;
- 认为天空盒的顶点都是固定的,距离起始点的位置特别远。
这两个误区,是因为看到有些人的作品直接贴了六个立方体,就说自己用到了天空盒技术,但是当你真正学这一章的话会发现此天空盒非彼天空盒。
1.1 立方体映射(Cube Mapping)
一个立方体(通常是正方体)包含六个面,对于立方体映射来说,它的六个面对应的是六张纹理贴图,然后以该立方体建系,中心为原点,且三个坐标轴是轴对齐的。我们可以使用方向向量(±X,±Y,±Z),从原点开始,发射一条射线(取方向向量的方向)来与某个面产生交点,取得该纹理交点对应的颜色。
注意:
1. 方向向量的大小并不重要,只要方向一致,那么不管长度是多少,最终选择的纹理和取样的像素都是一致的。
2. 使用方向向量时要确保所处的坐标系和立方体映射所处的坐标系一致,如方向向量和立方体映射同时处在世界坐标系中。
Direct3D提供了枚举类型D3D11_TEXTURECUBE_FACE来标识立方体某一表面:
typedef enum D3D11_TEXTURECUBE_FACE {
D3D11_TEXTURECUBE_FACE_POSITIVE_X = 0,
D3D11_TEXTURECUBE_FACE_NEGATIVE_X = 1,
D3D11_TEXTURECUBE_FACE_POSITIVE_Y = 2,
D3D11_TEXTURECUBE_FACE_NEGATIVE_Y = 3,
D3D11_TEXTURECUBE_FACE_POSITIVE_Z = 4,
D3D11_TEXTURECUBE_FACE_NEGATIVE_Z = 5
} D3D11_TEXTURECUBE_FACE;
可以看出:
- 索引0指向+X表面;
- 索引1指向-X表面;
- 索引2指向+Y表面;
- 索引3指向-Y表面;
- 索引4指向+Z表面;
- 索引5指向-Z表面;
使用立方体映射意味着我们需要使用3D纹理坐标进行寻址。在HLSL中,立方体纹理用TextureCube来表示。
1.2 环境映射(Environment Maps)
关于立方体映射,应用最广泛的就是环境映射了。为了获取一份环境映射,我们可以将摄像机绑定到一个物体的中心(或者摄像机本身视为一个物体),然后使用90°的垂直FOV和水平FOV(即宽高比1:1),再让摄像机朝着±X轴、±Y轴、±Z轴共6个轴的方向各拍摄一张不包括物体本身的场景照片。因为FOV的角度为90°,这六张图片已经包含了以物体中心进行的透视投影,所记录的完整的周遭环境。接下来就是将这六张图片保存在立方体纹理中,以构成环境映射。综上所述,环境映射就是在立方体表面的纹理中存储了周围环境的图像。
由于环境映射仅捕获了远景的信息,这样附近的许多物体都可以共用同一个环境映射。这种做法称之为静态立方体映射 ,它的优点是仅需要六张纹理就可以轻松实现,但缺陷是该环境映射并不会记录临近物体信息,在绘制反射时就看不到周围的物体了。
注意到环境映射所使用的六张图片不一定非得是从Direct3D程序中捕获的。因为立方体映射仅存储纹理数据,它们的内容通常可以是美术师预先生成的,或者是自己找到的。
一般来说,我们能找到的天空盒有如下三种:
- 已经创建好的.dds文件,可以直接通过DDSTextureLoader读取使用
- 6张天空盒的正方形贴图,格式不限。(暂不考虑只有5张的)
- 1张天空盒贴图,包含了6个面,格式不限,图片宽高比为4:3
对于第三种天空盒,其平面分布如下:
1.3 读取天空盒
对于创建好的DDS立方体纹理,我们只需要使用DDSTextureLoader就可以很方便地读取进来:
HR(CreateDDSTextureFromFile(
device.Get(),
cubemapFilename.c_str(),
nullptr,
textureCubeSRV.GetAddressOf()));
我们也可以自己编写代码来构造立方体纹理。
将一张天空盒贴图转化成立方体纹理需要经历以下4个步骤:
- 读取天空盒的贴图
- 创建包含6个纹理的数组
- 选取原天空盒纹理的6个子正方形区域,拷贝到该数组中
- 创建立方体纹理的SRV
而将六张天空盒的正方形贴图转换成立方体需要经历这4个步骤:
- 读取这六张正方形贴图
- 创建包含6个纹理的数组
- 将这六张贴图完整地拷贝到该数组中
- 创建立方体纹理的SRV
可以看到这两种类型的天空盒资源在处理上有很多相似的地方。
1.4 绘制天空盒
尽管天空盒是一个立方体,但是实际上渲染的是一个很大的"球体"(由大量的三角形逼近)表面。使用方向向量来映射到立方体纹理对应的像素颜色,同时它也指向当前绘制的"球"面上对应点。另外,为了保证绘制的天空盒永远处在摄像机能看到的最远处,通常会将该球体的中心设置在摄像机所处的位置。这样无论摄像机如何移动,天空盒也跟随摄像机移动,用户将永远到不了天空盒的一端。当然更好的做法是强行抹除掉摄像机和天空球的平移分量来进行变换和绘制,以避免可能出现的天空盒抖动问题。可以说这和公告板一样,都是一种欺骗人眼的小技巧。
天空球体和纹理立方体的中心一致,不需要管它们的大小关系。
绘制天空盒需要以下准备工作:
- 将天空盒载入HLSL的TextureCube中
- 在光栅化阶段关闭背面消隐(正面是球面向外,但摄像机在球内)
- 在输出合并阶段的深度/模板状态,设置深度比较函数为小于等于,以允许深度值为1的像素绘制
新增深度/模板状态
D3D11_DEPTH_STENCIL_DESC dsDesc;
// 允许使用深度值一致的像素进行替换的深度/模板状态
// 该状态用于绘制天空盒,因为深度值为1.0时默认无法通过深度测试
dsDesc.DepthEnable = true;
dsDesc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL;
dsDesc.DepthFunc = D3D11_COMPARISON_LESS_EQUAL;
dsDesc.StencilEnable = false;
HR(device->CreateDepthStencilState(&dsDesc, DSSLessEqual.GetAddressOf()));
1.5 HLSL代码
现在我们需要一组新的特效来绘制天空盒,其中与之相关的是Sky.hlsli, Sky_VS.hlsl和Sky_PS.hlsl。
// Sky.hlsli
TextureCube g_TexCube : register(t0);
SamplerState g_Sam : register(s0);
cbuffer CBChangesEveryFrame : register(b0)
{
matrix g_WorldViewProj;
}
struct VertexPos
{
float3 PosL : POSITION;
};
struct VertexPosHL
{
float4 PosH : SV_POSITION;
float3 PosL : POSITION;
};
// Sky_VS.hlsl
#include "Sky.hlsli"
VertexPosHL VS(VertexPos vIn)
{
VertexPosHL vOut;
// 设置z = w使得z/w = 1(天空盒保持在远平面)
float4 posH = mul(float4(vIn.PosL, 1.0f), g_WorldViewProj);
vOut.PosH = posH.xyww;
vOut.PosL = vIn.PosL;
return vOut;
}
// Sky_PS.hlsl
#include "Sky.hlsli"
float4 PS(VertexPosHL pIn) : SV_Target
{
return g_TexCube.Sample(g_Sam, pIn.PosL);
}
1.6 模型的反射
关于环境映射,另一个主要应用就是模型表面的反射(只有当天空盒记录了除当前反射物体外的其它物体时,才能在该物体看到其余物体的反射)。对于静态天空盒来说,通过模型看到的反射只能看到天空盒本身,因此还是显得不够真实。至于动态天空盒就还是留到下一章再讲。
下图说明了反射是如何通过环境映射运作的。法向量n对应的表面就像是一个镜面,摄像机在位置e,观察点p时可以看到经过反射得到的向量v所指向的天空盒纹理的采样像素点:
首先在之前的Basic.hlsli中加入TextureCube:
// Basic.hlsli
Texture2D g_DiffuseMap : register(t0);
TextureCube g_TexCube : register(t1);
SamplerState g_Sam : register(s0);
// ...
然后只需要在Basic_PS.hlsl添加如下内容:
float4 litColor = texColor * (ambient + diffuse) + spec;
if (g_ReflectionEnabled)
{
float3 incident = -toEyeW;
float3 reflectionVector = reflect(incident, pIn.NormalW);
float4 reflectionColor = g_TexCube.Sample(g_Sam, reflectionVector);
litColor += g_Material.Reflect * reflectionColor;
}
litColor.a = texColor.a * g_Material.Diffuse.a;
return litColor;
在C++端,将采样器设置为各向异性过滤以获取更好的绘制效果:
// 在RenderStates.h/.cpp可以看到
ComPtr<ID3D11SamplerState> RenderStates::SSAnistropicWrap;
D3D11_SAMPLER_DESC sampDesc;
ZeroMemory(&sampDesc, sizeof(sampDesc));
// 各向异性过滤模式
sampDesc.Filter = D3D11_FILTER_ANISOTROPIC;
sampDesc.AddressU = D3D11_TEXTURE_ADDRESS_WRAP;
sampDesc.AddressV = D3D11_TEXTURE_ADDRESS_WRAP;
sampDesc.AddressW = D3D11_TEXTURE_ADDRESS_WRAP;
sampDesc.ComparisonFunc = D3D11_COMPARISON_NEVER;
sampDesc.MaxAnisotropy = 4;
sampDesc.MinLOD = 0;
sampDesc.MaxLOD = D3D11_FLOAT32_MAX;
HR(device->CreateSamplerState(&sampDesc, SSAnistropicWrap.GetAddressOf()));
// 在BasicEffect.cpp可以看到
deviceContext->PSSetSamplers(0, 1, RenderStates::SSAnistropicWrap.GetAddressOf());
通常一个像素的颜色不完全是反射后的颜色(只有镜面才是100%反射)。因此,我们将原来的光照等式加上了材质反射的分量。当初Material的Reflect成员现在就派上了用场:
// 物体表面材质
struct Material
{
Material() { memset(this, 0, sizeof(Material)); }
DirectX::XMFLOAT4 Ambient;
DirectX::XMFLOAT4 Diffuse;
DirectX::XMFLOAT4 Specular; // w = 镜面反射强度
DirectX::XMFLOAT4 Reflect;
};
我们可以指定该材质的反射颜色,如果该材质只反射完整的红光部分,则在C++指定Reflect = XMFLOAT4(1.0f, 0.0f, 0.0f, 0.0f)。
使用带加法的反射容易引发一个问题:过度饱和。两个颜色的相加可能会存在RGB值超过1而变白,这会导致某些像素的颜色过于明亮。通常如果我们添加反射分量的颜色,就必须减小材质本身的环境分量和漫反射分量来实现平衡。另一种方式就是对反射分量和像素颜色s进行插值处理:
这样我们就可以通过调整系数t来控制反射程度,以达到自己想要的效果。
二、动态天空盒
现在如果我们要让拥有反射/折射属性的物体映射其周围的物体和天空盒的话,就需要在每一帧重建动态天空盒,具体做法为:在每一帧将摄像机放置在待反射/折射物体中心,然后沿着各个坐标轴渲染除了自己以外的所有物体及静态天空盒共六次,一次对应纹理立方体的一个面。这样绘制好的动态天空盒就会记录下当前帧各物体所在的位置了。
但是这样做会带来非常大的性能开销,加上动态天空盒后,现在一个场景就要渲染七次,对应七个不同的渲染目标!如果要使用的话,尽可能减少所需要用到的动态天空盒数目。对于多个物体来说,你可以只对比较重要,关注度较高的反射/折射物体使用动态天空盒,其余的仍使用静态天空盒,甚至不用。毕竟动态天空盒也不是用在场景绘制,而是在物体上,可以不需要跟静态天空盒那样大的分辨率,通常情况下设置到256x256即可.
2.1 资源视图(Resource Views)
由于动态天空盒的实现同时要用到渲染目标视图(Render Target View)、深度模板视图(Depth Stencil View)和着色器资源视图(Shader Resource View),这里简单看一下。
由于资源(ID3D11Resource)本身的类型十分复杂,比如一个ID3D11Texture2D本身既可以是一个纹理,也可以是一个纹理数组,但纹理数组在元素个数为6时有可能会被用作立方体纹理,就这样直接绑定到渲染管线上是无法确定它本身究竟要被用作什么样的类型的。比如说作为着色器资源,它可以是Texture2D, Texture2DArray, TextureCube的任意一种。
因此,我们需要用到一种叫资源视图(Resource Views)的类型,它主要有下面4种功能:
- 绑定要使用的资源
- 解释该资源具体会被用作什么类型
- 指定该资源的元素范围,以及纹理的子资源范围
- 说明该资源最终在渲染管线上的用途
渲染目标视图用于将渲染管线的运行结果输出给其绑定的资源,即仅能设置给输出合并阶段。这意味着该资源主要用于写入,但是在进行混合操作时还需要读取该资源。通常渲染目标是一个二维的纹理,但它依旧可能会绑定其余类型的资源。这里不做讨论。
深度/模板视图同样用于设置给输出合并阶段,但是它用于深度测试和模板测试,决定了当前像素是通过还是会被抛弃,并更新深度/模板值。它允许一个资源同时绑定到深度模板视图和着色器资源视图,但是两个资源视图此时都是只读的,深度/模板视图也无法对其进行修改,这样该纹理就还可以绑定到任意允许的可编程着色器阶段上。如果要允许深度/模板缓冲区进行写入,则应该取消绑定在着色器的资源视图。
着色器资源视图提供了资源的读取权限,可以用于渲染管线的所有可编程着色器阶段中。通常该视图多用于像素着色器阶段,但要注意无法通过着色器写入该资源。
2.2 Render-To-Texture 技术
在我们之前的程序中,我们都是渲染到后备缓冲区里。经过了这么多的章节,应该可以知道它的类型是ID3D11Texture2D,仅仅是一个2D纹理罢了。在之前代码里可以看到这部分的代码:
// 重设交换链并且重新创建渲染目标视图
ComPtr<ID3D11Texture2D> backBuffer;
HR(m_pSwapChain->ResizeBuffers(1, m_ClientWidth, m_ClientHeight, DXGI_FORMAT_B8G8R8A8_UNORM, 0)); // 注意此处DXGI_FORMAT_B8G8R8A8_UNORM
HR(m_pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), reinterpret_cast<void**>(backBuffer.GetAddressOf())));
HR(m_pd3dDevice->CreateRenderTargetView(backBuffer.Get(), nullptr, m_pRenderTargetView.GetAddressOf()));
backBuffer.Reset();
这里渲染目标视图绑定的是重新调整过大小的后备缓冲区。然后把该视图交给输出合并阶段:
// 将渲染目标视图和深度/模板缓冲区结合到管线
m_pd3dImmediateContext->OMSetRenderTargets(1, m_pRenderTargetView.GetAddressOf(), m_pDepthStencilView.Get());
这样经过一次绘制指令后就会将管线的运行结果输出到该视图绑定的后备缓冲区上,待所有绘制完成后,再调用IDXGISwapChain::Present方法来交换前/后台以达到画面更新的效果。
如果渲染目标视图绑定的是新建的2D纹理,而非后备缓冲区的话,那么渲染结果将会输出到该纹理上,并且不会直接在屏幕上显示出来。然后我们就可以使用该纹理做一些别的事情,比如绑定到着色器资源视图供可编程着色器使用,又或者将结果保存到文件等等。
虽然这个技术并不高深,但它的应用非常广泛:
- 小地图的实现
- 阴影映射(Shadow mapping)
- 屏幕空间环境光遮蔽(Screen Space Ambient Occlusion)
- 利用天空盒实现动态反射/折射(Dynamic reflections/refractions with cube maps)
2.3 初始化动态纹理立方体资源
2.3.1 创建动态纹理立方体
在更新动态天空盒的时候,该纹理将会被用做渲染目标;而完成渲染后,它将用作着色器资源视图用于球体反射/折射的渲染。因此它需要在BindFlag设置D3D11_BIND_RENDER_TARGET和D3D11_BIND_SHADER_RESOURCE。
// ******************
// 1. 创建纹理数组
//
ComPtr<ID3D11Texture2D> texCube;
D3D11_TEXTURE2D_DESC texDesc;
texDesc.Width = dynamicCubeSize;
texDesc.Height = dynamicCubeSize;
texDesc.MipLevels = 0;
texDesc.ArraySize = 6;
texDesc.SampleDesc.Count = 1;
texDesc.SampleDesc.Quality = 0;
texDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
texDesc.Usage = D3D11_USAGE_DEFAULT;
texDesc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE;
texDesc.CPUAccessFlags = 0;
texDesc.MiscFlags = D3D11_RESOURCE_MISC_GENERATE_MIPS | D3D11_RESOURCE_MISC_TEXTURECUBE;
// 现在texCube用于新建纹理
hr = device->CreateTexture2D(&texDesc, nullptr, texCube.ReleaseAndGetAddressOf());
if (hr != S_OK)
return hr;
// ...
把MipLevels设置为0是要说明该纹理将会在后面生成完整的mipmap链,但不代表创建纹理后立即就会生成,需要在后续通过GenerateMips方法才会生成出来。为此,还需要在MiscFlags设置D3D11_RESOURCE_MISC_GENERATE_MIPS。当然,把该纹理用作天空盒的D3D11_RESOURCE_MISC_TEXTURECUBE标签也不能漏掉。
2.3.2 创建对应渲染目标视图
接下来就是创建渲染目标视图的部分,纹理数组中的每个纹理都需要绑定一个渲染目标视图:
// ******************
// 2. 创建渲染目标视图
//
D3D11_RENDER_TARGET_VIEW_DESC rtvDesc;
rtvDesc.Format = texDesc.Format;
rtvDesc.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2DARRAY;
rtvDesc.Texture2DArray.MipSlice = 0;
// 一个视图只对应一个纹理数组元素
rtvDesc.Texture2DArray.ArraySize = 1;
// 每个元素创建一个渲染目标视图
for (int i = 0; i < 6; ++i)
{
rtvDesc.Texture2DArray.FirstArraySlice = i;
hr = device->CreateRenderTargetView(texCube.Get(), &rtvDesc,
m_pDynamicCubeMapRTVs[i].GetAddressOf());
if (hr != S_OK)
return hr;
}
// ...
2.3.3 创建着色器资源视图
然后就是为整个纹理数组以天空盒的形式创建着色器资源视图:
// ******************
// 3. 创建着色器目标视图
//
D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc;
srvDesc.Format = texDesc.Format;
srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURECUBE;
srvDesc.TextureCube.MostDetailedMip = 0;
srvDesc.TextureCube.MipLevels = -1; // 使用所有的mip等级
hr = device->CreateShaderResourceView(texCube.Get(), &srvDesc,
m_pDynamicCubeMapSRV.GetAddressOf());
if (hr != S_OK)
return hr;
2.3.4 为动态天空盒创建深度缓冲区和视口
通常天空盒表面分辨率和后备缓冲区的分辨率不一致,这意味着我们还需要创建一个和天空盒表面分辨率一致的深度缓冲区(无模板测试):
// ******************
// 4. 创建深度/模板缓冲区与对应的视图
//
texDesc.Width = dynamicCubeSize;
texDesc.Height = dynamicCubeSize;
texDesc.MipLevels = 1;
texDesc.ArraySize = 1;
texDesc.SampleDesc.Count = 1;
texDesc.SampleDesc.Quality = 0;
texDesc.Format = DXGI_FORMAT_D32_FLOAT;
texDesc.Usage = D3D11_USAGE_DEFAULT;
texDesc.BindFlags = D3D11_BIND_DEPTH_STENCIL;
texDesc.CPUAccessFlags = 0;
texDesc.MiscFlags = 0;
ComPtr<ID3D11Texture2D> depthTex;
hr = device->CreateTexture2D(&texDesc, nullptr, depthTex.GetAddressOf());
if (hr != S_OK)
return hr;
D3D11_DEPTH_STENCIL_VIEW_DESC dsvDesc;
dsvDesc.Format = texDesc.Format;
dsvDesc.Flags = 0;
dsvDesc.ViewDimension = D3D11_DSV_DIMENSION_TEXTURE2D;
dsvDesc.Texture2D.MipSlice = 0;
hr = device->CreateDepthStencilView(
depthTex.Get(),
&dsvDesc,
m_pDynamicCubeMapDSV.GetAddressOf());
if (hr != S_OK)
return hr;
同样,视口也需要经过适配。不过之前的摄像机类可以帮我们简化一下:
// ******************
// 5. 初始化视口
//
m_pCamera.SetViewPort(0.0f, 0.0f, static_cast<float>(dynamicCubeSize), static_cast<float>(dynamicCubeSize));
return S_OK;
}
2.4 动态天空盒的绘制
讲完了初始化的事,就要开始留意帧与帧之间的动态天空盒渲染操作了。总结如下:
- 缓存设备上下文绑定的后备缓冲区、深度/模板缓冲区
- 清空设置在像素着色器的着色器资源视图(绑定了动态天空盒资源)
- 对准某一个坐标轴,以90度垂直视野(FOV),1.0f的宽高比架设摄像机,并调整视口
- 清理当前天空盒面对应的纹理和深度缓冲区,并绑定到设备上下文
- 和往常一样绘制物体和静态天空盒
- 回到步骤3,继续下一个面的绘制,直到6个面都完成渲染
- 为设备上下文恢复后备缓冲区、深度/模板缓冲区并释放内部缓存(防止交换链ResizeBuffer时因为引用的遗留出现问题)
- 让动态天空盒生成mipmap链,并将其绑定到像素着色器
- 利用动态天空盒绘制反射/折射物体,和往常一样绘制剩余物体,并利用静态天空盒绘制天空
2.4.1 缓存渲染目标视图
该方法对应上面所说的第1,2步:
void Cache(ComPtr<ID3D11DeviceContext> deviceContext, BasicEffect& effect)
{
deviceContext->OMGetRenderTargets(1, m_pCacheRTV.GetAddressOf(), m_pCacheDSV.GetAddressOf());
// 清掉绑定在着色器的动态天空盒,需要立即生效
effect.SetTextureCube(nullptr);
effect.Apply(deviceContext.Get());
}
2.4.2 指定天空盒某一面开始绘制
该方法对应上面所说的第3,4步:
void BeginCapture(ID3D11DeviceContext* deviceContext, BasicEffect& effect, const XMFLOAT3& pos,
D3D11_TEXTURECUBE_FACE face, float nearZ, float farZ)
{
static XMFLOAT3 ups[6] = {
{ 0.0f, 1.0f, 0.0f }, // +X
{ 0.0f, 1.0f, 0.0f }, // -X
{ 0.0f, 0.0f, -1.0f }, // +Y
{ 0.0f, 0.0f, 1.0f }, // -Y
{ 0.0f, 1.0f, 0.0f }, // +Z
{ 0.0f, 1.0f, 0.0f } // -Z
};
static XMFLOAT3 looks[6] = {
{ 1.0f, 0.0f, 0.0f }, // +X
{ -1.0f, 0.0f, 0.0f }, // -X
{ 0.0f, 1.0f, 0.0f }, // +Y
{ 0.0f, -1.0f, 0.0f }, // -Y
{ 0.0f, 0.0f, 1.0f }, // +Z
{ 0.0f, 0.0f, -1.0f }, // -Z
};
// 设置天空盒摄像机
m_pCamera.LookTo(pos, looks[face], ups[face]);
// 这里尽可能捕获近距离物体
m_pCamera.SetFrustum(XM_PIDIV2, 1.0f, nearZ, farZ);
// 应用观察矩阵、投影矩阵
effect.SetViewMatrix(m_pCamera.GetViewXM());
effect.SetProjMatrix(m_pCamera.GetProjXM());
// 清空缓冲区
deviceContext->ClearRenderTargetView(m_pDynamicCubeMapRTVs[face].Get(), reinterpret_cast<const float*>(&Colors::Black));
deviceContext->ClearDepthStencilView(m_pDynamicCubeMapDSV.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
// 设置渲染目标和深度模板视图
deviceContext->OMSetRenderTargets(1, m_pDynamicCubeMapRTVs[face].GetAddressOf(), m_pDynamicCubeMapDSV.Get());
// 设置视口
deviceContext->RSSetViewports(1, &m_pCamera.GetViewPort());
}
在调用该方法后,就可以开始绘制到天空盒的指定面了,直到下一次BeginCapture或Restore被调用。
2.4.3 恢复之前绑定的资源并清空缓存
该方法对应上面所说的第7,8步:
void Restore(ComPtr<ID3D11DeviceContext> deviceContext, BasicEffect& effect, const Camera & camera)
{
// 恢复默认设定
deviceContext->RSSetViewports(1, &camera.GetViewPort());
deviceContext->OMSetRenderTargets(1, m_pCacheRTV.GetAddressOf(), m_pCacheDSV.Get());
// 生成动态天空盒后必须要生成mipmap链
deviceContext->GenerateMips(m_pDynamicCubeMapSRV.Get());
effect.SetViewMatrix(camera.GetViewXM());
effect.SetProjMatrix(camera.GetProjXM());
// 恢复绑定的动态天空盒
effect.SetTextureCube(m_pDynamicCubeMapSRV);
// 清空临时缓存的渲染目标视图和深度模板视图
m_pCacheDSV.Reset();
m_pCacheRTV.Reset();
}
2.4.4 场景绘制
关于3D场景的绘制可以简化成这样:
void DrawScene()
{
// ******************
// 生成动态天空盒
//
// 保留当前绘制的渲染目标视图和深度模板视图
m_pDaylight->Cache(m_pd3dImmediateContext.Get(), m_BasicEffect);
// 绘制动态天空盒的每个面(以球体为中心)
for (int i = 0; i < 6; ++i)
{
m_pDaylight->BeginCapture(m_pd3dImmediateContext.Get(), m_BasicEffect,
XMFLOAT3(0.0f, 0.0f, 0.0f), static_cast<D3D11_TEXTURECUBE_FACE>(i));
// 不绘制中心球
DrawScene(false);
}
// 恢复之前的绘制设定
m_pDaylight->Restore(m_pd3dImmediateContext.Get(), m_BasicEffect, *m_pCamera);
// ******************
// 绘制场景
//
// 预先清空
m_pd3dImmediateContext->ClearRenderTargetView(m_pRenderTargetView.Get(), reinterpret_cast<const float*>(&Colors::Black));
m_pd3dImmediateContext->ClearDepthStencilView(m_pDepthStencilView.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
// 绘制中心球
DrawScene(true);
// 省略文字绘制部分...
}
三、Render-To-Texture
在前面的章节中,我们默认的渲染目标是来自DXGI后备缓冲区,它是一个2D纹理。而Render-To-Texture技术,实际上就是使用一张2D纹理作为渲染目标,但一般是自己新建的2D纹理。与此同时,这个纹理还能够绑定到着色器资源视图(SRV)供着色器所使用,即原本用作输出的纹理现在用作输入。
它可以用于:
- 小地图的实现
- 阴影映射(Shadow mapping)
- 屏幕空间环境光遮蔽(Screen Space Ambient Occlusion)
- 利用天空盒实现动态反射/折射(Dynamic reflections/refractions with cube maps)
在这一部分,我们将展示下面这三种应用:
- 屏幕淡入/淡出
- 小地图(有可视范围的)
- 保存纹理到文件
3.1 TextureRender初始化
现在我们需要完成下面5个步骤:
- 创建纹理
- 创建纹理对应的渲染目标视图
- 创建纹理对应的着色器资源视图
- 创建与纹理等宽高的深度/模板缓冲区和对应的视图
- 初始化视口
- 具体代码如下:
HRESULT InitResource(ID3D11Device* device, int texWidth, int texHeight, bool generateMips)
{
// 防止重复初始化造成内存泄漏
m_pOutputTextureSRV.Reset();
m_pOutputTextureRTV.Reset();
m_pOutputTextureDSV.Reset();
m_pCacheRTV.Reset();
m_pCacheDSV.Reset();
m_GenerateMips = generateMips;
HRESULT hr;
// ******************
// 1. 创建纹理
//
ComPtr<ID3D11Texture2D> texture;
D3D11_TEXTURE2D_DESC texDesc;
texDesc.Width = texWidth;
texDesc.Height = texHeight;
texDesc.MipLevels = (m_GenerateMips ? 0 : 1); // 0为完整mipmap链
texDesc.ArraySize = 1;
texDesc.SampleDesc.Count = 1;
texDesc.SampleDesc.Quality = 0;
texDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
texDesc.Usage = D3D11_USAGE_DEFAULT;
texDesc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE;
texDesc.CPUAccessFlags = 0;
texDesc.MiscFlags = D3D11_RESOURCE_MISC_GENERATE_MIPS;
// 现在texture用于新建纹理
hr = device->CreateTexture2D(&texDesc, nullptr, texture.ReleaseAndGetAddressOf());
if (FAILED(hr))
return hr;
// ******************
// 2. 创建纹理对应的渲染目标视图
//
D3D11_RENDER_TARGET_VIEW_DESC rtvDesc;
rtvDesc.Format = texDesc.Format;
rtvDesc.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D;
rtvDesc.Texture2D.MipSlice = 0;
hr = device->CreateRenderTargetView(texture.Get(), &rtvDesc, m_pOutputTextureRTV.GetAddressOf());
if (FAILED(hr))
return hr;
// ******************
// 3. 创建纹理对应的着色器资源视图
//
D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc;
srvDesc.Format = texDesc.Format;
srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
srvDesc.Texture2D.MostDetailedMip = 0;
srvDesc.TextureCube.MipLevels = -1; // 使用所有的mip等级
hr = device->CreateShaderResourceView(texture.Get(), &srvDesc,
m_pOutputTextureSRV.GetAddressOf());
if (FAILED(hr))
return hr;
// ******************
// 4. 创建与纹理等宽高的深度/模板缓冲区和对应的视图
//
texDesc.Width = texWidth;
texDesc.Height = texHeight;
texDesc.MipLevels = 0;
texDesc.ArraySize = 1;
texDesc.SampleDesc.Count = 1;
texDesc.SampleDesc.Quality = 0;
texDesc.Format = DXGI_FORMAT_D24_UNORM_S8_UINT;
texDesc.Usage = D3D11_USAGE_DEFAULT;
texDesc.BindFlags = D3D11_BIND_DEPTH_STENCIL;
texDesc.CPUAccessFlags = 0;
texDesc.MiscFlags = 0;
ComPtr<ID3D11Texture2D> depthTex;
hr = device->CreateTexture2D(&texDesc, nullptr, depthTex.GetAddressOf());
if (FAILED(hr))
return hr;
D3D11_DEPTH_STENCIL_VIEW_DESC dsvDesc;
dsvDesc.Format = texDesc.Format;
dsvDesc.Flags = 0;
dsvDesc.ViewDimension = D3D11_DSV_DIMENSION_TEXTURE2D;
dsvDesc.Texture2D.MipSlice = 0;
hr = device->CreateDepthStencilView(depthTex.Get(), &dsvDesc,
m_pOutputTextureDSV.GetAddressOf());
if (FAILED(hr))
return hr;
// ******************
// 5. 初始化视口
//
m_OutputViewPort.TopLeftX = 0.0f;
m_OutputViewPort.TopLeftY = 0.0f;
m_OutputViewPort.Width = static_cast<float>(texWidth);
m_OutputViewPort.Height = static_cast<float>(texHeight);
m_OutputViewPort.MinDepth = 0.0f;
m_OutputViewPort.MaxDepth = 1.0f;
return S_OK;
}
3.2 开始对当前纹理进行渲染
该方法缓存当前渲染管线绑定的渲染目标视图、深度/模板视图以及视口,并替换初始化好的这些资源。注意还需要清空一遍缓冲区:
void Begin(ID3D11DeviceContext * deviceContext)
{
// 缓存渲染目标和深度模板视图
deviceContext->OMGetRenderTargets(1, m_pCacheRTV.GetAddressOf(), m_pCacheDSV.GetAddressOf());
// 缓存视口
UINT num_Viewports = 1;
deviceContext->RSGetViewports(&num_Viewports, &m_CacheViewPort);
// 清空缓冲区
float black[4] = { 0.0f, 0.0f, 0.0f, 1.0f };
deviceContext->ClearRenderTargetView(m_pOutputTextureRTV.Get(), black);
deviceContext->ClearDepthStencilView(m_pOutputTextureDSV.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
// 设置渲染目标和深度模板视图
deviceContext->OMSetRenderTargets(1, m_pOutputTextureRTV.GetAddressOf(), m_pOutputTextureDSV.Get());
// 设置视口
deviceContext->RSSetViewports(1, &m_OutputViewPort);
}
3.3 结束对当前纹理的渲染,还原状态
在对当前纹理的所有绘制方法调用完毕后,就需要调用该方法以恢复到原来的渲染目标视图、深度/模板视图以及视口。若在初始化时还指定了generateMips为true,还会给该纹理生成mipmap链:
void TextureRender::End(ComPtr<ID3D11DeviceContext> deviceContext)
{
// 恢复默认设定
deviceContext->RSSetViewports(1, &m_CacheViewPort);
deviceContext->OMSetRenderTargets(1, m_pCacheRTV.GetAddressOf(), m_pCacheDSV.Get());
// 若之前有指定需要mipmap链,则生成
if (m_GenerateMips)
{
deviceContext->GenerateMips(m_pOutputTextureSRV.Get());
}
// 清空临时缓存的渲染目标视图和深度模板视图
m_pCacheDSV.Reset();
m_pCacheRTV.Reset();
}
最后就可以通过TextureRender::GetOutputTexture方法获取渲染好的纹理了。
注意:不要将纹理既作为渲染目标,又作为着色器资源,虽然不会报错,但这样会导致程序运行速度被拖累。在VS的输出窗口你可以看到它会将该资源强制从着色器中撤离,置其为NULL,以保证不会同时绑定在输入和输出端。
3.4 屏幕淡入/淡出效果的实现
该效果对应的特效着色器文件为ScreenFade_VS.hlsl和ScreenFade_PS.hlsl。
首先是ScreenFade.hlsli:
// ScreenFade.hlsli
Texture2D gTex : register(t0);
SamplerState gSam : register(s0);
cbuffer CBChangesEveryFrame : register(b0)
{
float g_FadeAmount; // 颜色程度控制(0.0f-1.0f)
float3 g_Pad;
}
cbuffer CBChangesRarely : register(b1)
{
matrix g_WorldViewProj;
}
struct VertexPosTex
{
float3 PosL : POSITION;
float2 Tex : TEXCOORD;
};
struct VertexPosHTex
{
float4 PosH : SV_POSITION;
float2 Tex : TEXCOORD;
};
然后分别是对于的顶点着色器和像素着色器实现:
// ScreenFade_VS.hlsl
#include "ScreenFade.hlsli"
// 顶点着色器
VertexPosHTex VS(VertexPosTex vIn)
{
VertexPosHTex vOut;
vOut.PosH = mul(float4(vIn.PosL, 1.0f), g_WorldViewProj);
vOut.Tex = vIn.Tex;
return vOut;
}
// ScreenFade_PS.hlsl
#include "ScreenFade.hlsli"
// 像素着色器
float4 PS(VertexPosHTex pIn) : SV_Target
{
return g_Tex.Sample(g_Sam, pIn.Tex) * float4(g_FadeAmount, g_FadeAmount, g_FadeAmount, 1.0f);
}
为了实现屏幕的淡入淡出效果,我们需要一张渲染好的场景纹理,即通过TextureRender来实现。
首先我们看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 (m_KeyboardTracker.IsKeyPressed(Keyboard::Escape))
{
m_FadeSign = -1.0f;
m_FadeUsed = true;
}
启动程序的时候,mFadeSign的初始值是1.0f,这样就使得打开程序的时候就在进行屏幕淡入。
而用户按下Esc键退出的话,则先触发屏幕淡出效果,等屏幕变黑后再发送关闭程序的消息给窗口。注意发送消息到真正关闭还相隔一段时间,在这段时间内也不要关闭淡出效果的绘制,否则最后那一瞬间又突然看到场景了。
然后在DrawScene方法中,我们可以将绘制过程简化成这样:
// ******************
// 绘制Direct3D部分
//
// 预先清空后备缓冲区
m_pd3dImmediateContext->ClearRenderTargetView(m_pRenderTargetView.Get(), reinterpret_cast<const float*>(&Colors::Black));
m_pd3dImmediateContext->ClearDepthStencilView(m_pDepthStencilView.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
if (mFadeUsed)
{
// 开始淡入/淡出
m_pScreenFadeRender->Begin(m_pd3dImmediateContext.Get());
}
// 绘制主场景...
if (mFadeUsed)
{
// 结束淡入/淡出,此时绘制的场景在屏幕淡入淡出渲染的纹理
m_pScreenFadeRender->End(m_pd3dImmediateContext.Get());
// 屏幕淡入淡出特效应用
m_ScreenFadeEffect.SetRenderDefault(m_pd3dImmediateContext.Get());
m_ScreenFadeEffect.SetFadeAmount(m_FadeAmount);
m_ScreenFadeEffect.SetTexture(m_pScreenFadeRender->GetOutputTexture());
m_ScreenFadeEffect.SetWorldViewProjMatrix(XMMatrixIdentity());
m_ScreenFadeEffect.Apply(m_pd3dImmediateContext.Get());
// 将保存的纹理输出到屏幕
m_pd3dImmediateContext->IASetVertexBuffers(0, 1, m_FullScreenShow.modelParts[0].vertexBuffer.GetAddressOf(), strides, offsets);
m_pd3dImmediateContext->IASetIndexBuffer(m_FullScreenShow.modelParts[0].indexBuffer.Get(), DXGI_FORMAT_R16_UINT, 0);
m_pd3dImmediateContext->DrawIndexed(6, 0, 0);
// 务必解除绑定在着色器上的资源,因为下一帧开始它会作为渲染目标
m_ScreenFadeEffect.SetTexture(nullptr);
m_ScreenFadeEffect.Apply(m_pd3dImmediateContext.Get());
}
由于屏幕淡入淡出效果需要先绘制主场景到纹理,然后再用该纹理完整地绘制到屏幕上,就不说前面还进行了大量的深度测试了,两次绘制下来使得在渲染淡入淡出效果的时候帧数下降比较明显。因此不建议经常这么做。
3.5 小地图的实现
关于小地图的实现,有许多种方式。常见的如下:
- 美术预先绘制一张地图纹理,然后再在上面绘制一些2D物件表示场景中的物体
- 捕获游戏场景的俯视图用作纹理,但只保留静态物体,然后再在上面绘制一些2D物件表示场景中的物体
- 通过俯视图完全绘制出游戏场景中的所有物体
可以看出,性能的消耗越往后要求越高。
本项目采用第二种方式,但是地图可视范围为摄像机可视区域,并且不考虑额外绘制任何2D物件。
小地图对应的特效着色器文件为Minimap_VS.hlsl和Minimap_PS.hlsl。同样这里只关注HLSL实现。
首先是Minimap.hlsli:
// Minimap.hlsli
Texture2D g_Tex : register(t0);
SamplerState g_Sam : register(s0);
cbuffer CBChangesEveryFrame : register(b0)
{
float3 g_EyePosW; // 摄像机位置
float g_Pad;
}
cbuffer CBDrawingStates : register(b1)
{
int g_FogEnabled; // 是否范围可视
float g_VisibleRange; // 3D世界可视范围
float2 g_Pad2;
float4 g_RectW; // 小地图xOz平面对应3D世界矩形区域(Left, Front, Right, Back)
float4 g_InvisibleColor; // 不可视情况下的颜色
}
struct VertexPosTex
{
float3 PosL : POSITION;
float2 Tex : TEXCOORD;
};
struct VertexPosHTex
{
float4 PosH : SV_POSITION;
float2 Tex : TEXCOORD;
};
为了能在小地图中绘制出局部区域可视的效果,还需要依赖3D世界中的一些参数。其中gRectW对应的是3D世界中矩形区域(即x最小值, z最大值, x最大值, z最小值)。
然后是顶点着色器和像素着色器的实现:
// Minimap_VS.hlsl
#include "Minimap.hlsli"
// 顶点着色器
VertexPosHTex VS(VertexPosTex vIn)
{
VertexPosHTex vOut;
vOut.PosH = float4(vIn.PosL, 1.0f);
vOut.Tex = vIn.Tex;
return vOut;
}
// Minimap_PS.hlsl
#include "Minimap.hlsli"
// 像素着色器
float4 PS(VertexPosHTex pIn) : SV_Target
{
// 要求Tex的取值范围都在[0.0f, 1.0f], y值对应世界坐标z轴
float2 PosW = pIn.Tex * float2(g_RectW.zw - g_RectW.xy) + g_RectW.xy;
float4 color = g_Tex.Sample(g_Sam, pIn.Tex);
[flatten]
if (g_FogEnabled && length(PosW - g_EyePosW.xz) / g_VisibleRange > 1.0f)
{
return g_InvisibleColor;
}
return color;
}
接下来我们需要通过Render-To-Texture技术,捕获整个场景的俯视图。关于小地图的绘制放在了InitResource中:
bool InitResource()
{
// ...
m_pMinimapRender = std::make_unique<TextureRender>();
HR(m_pMinimapRender->InitResource(m_pd3dDevice.Get(), 400, 400, true));
// 初始化网格,放置在右下角200x200
m_Minimap.SetMesh(m_pd3dDevice, Geometry::Create2DShow(0.75f, -0.66666666f, 0.25f, 0.33333333f));
// ...
// 小地图摄像机
m_MinimapCamera = std::unique_ptr<FirstPersonCamera>(new FirstPersonCamera);
m_MinimapCamera->SetViewPort(0.0f, 0.0f, 200.0f, 200.0f); // 200x200小地图
m_MinimapCamera->LookTo(
XMVectorSet(0.0f, 10.0f, 0.0f, 1.0f),
XMVectorSet(0.0f, -1.0f, 0.0f, 1.0f),
XMVectorSet(0.0f, 0.0f, 1.0f, 0.0f));
m_MinimapCamera->UpdateViewMatrix();
// ...
// 小地图范围可视
m_MinimapEffect.SetFogState(true);
m_MinimapEffect.SetInvisibleColor(XMVectorSet(0.0f, 0.0f, 0.0f, 1.0f));
m_MinimapEffect.SetMinimapRect(XMVectorSet(-95.0f, 95.0f, 95.0f, -95.0f));
m_MinimapEffect.SetVisibleRange(25.0f);
// 方向光(默认)
DirectionalLight dirLight[4];
dirLight[0].Ambient = XMFLOAT4(0.15f, 0.15f, 0.15f, 1.0f);
dirLight[0].Diffuse = XMFLOAT4(0.25f, 0.25f, 0.25f, 1.0f);
dirLight[0].Specular = XMFLOAT4(0.1f, 0.1f, 0.1f, 1.0f);
dirLight[0].Direction = XMFLOAT3(-0.577f, -0.577f, 0.577f);
dirLight[1] = dirLight[0];
dirLight[1].Direction = XMFLOAT3(0.577f, -0.577f, 0.577f);
dirLight[2] = dirLight[0];
dirLight[2].Direction = XMFLOAT3(0.577f, -0.577f, -0.577f);
dirLight[3] = dirLight[0];
dirLight[3].Direction = XMFLOAT3(-0.577f, -0.577f, -0.577f);
for (int i = 0; i < 4; ++i)
m_BasicEffect.SetDirLight(i, dirLight[i]);
// ******************
// 渲染小地图纹理
//
m_BasicEffect.SetViewMatrix(m_MinimapCamera->GetViewXM());
m_BasicEffect.SetProjMatrix(XMMatrixOrthographicLH(190.0f, 190.0f, 1.0f, 20.0f)); // 使用正交投影矩阵(中心在摄像机位置)
// 关闭雾效
m_BasicEffect.SetFogState(false);
m_pMinimapRender->Begin(m_pd3dImmediateContext.Get());
DrawScene(true);
m_pMinimapRender->End(m_pd3dImmediateContext.Get());
m_MinimapEffect.SetTexture(m_pMinimapRender->GetOutputTexture());
// ...
}
通常小地图的制作,建议是使用正交投影矩阵,XMMatrixOrthographicLH函数的中心在摄像机位置,不以摄像机为中心的话可以用XMMatrixOrthographicOffCenterLH函数。
然后如果窗口大小调整,为了保证小地图在屏幕的显示是在右下角,并且保持200x200,需要在OnResize重新调整网格模型:
void GameApp::OnResize()
{
// ...
// 摄像机变更显示
if (mCamera != nullptr)
{
// ...
// 小地图网格模型重设
m_Minimap.SetMesh(m_pd3dDevice.Get(), Geometry::Create2DShow(1.0f - 100.0f / m_ClientWidth * 2, -1.0f + 100.0f / m_ClientHeight * 2,
100.0f / m_ClientWidth * 2, 100.0f / m_ClientHeight * 2));
}
}
最后是DrawScene方法将小地图纹理绘制到屏幕的部分:
// 此处用于小地图和屏幕绘制
UINT strides[1] = { sizeof(VertexPosTex) };
UINT offsets[1] = { 0 };
// 小地图特效应用
m_MinimapEffect.SetRenderDefault(m_pd3dImmediateContext.Get());
m_MinimapEffect.Apply(m_pd3dImmediateContext.Get());
// 最后绘制小地图
m_pd3dImmediateContext->IASetVertexBuffers(0, 1, m_Minimap.modelParts[0].vertexBuffer.GetAddressOf(), strides, offsets);
m_pd3dImmediateContext->IASetIndexBuffer(m_Minimap.modelParts[0].indexBuffer.Get(), DXGI_FORMAT_R16_UINT, 0);
m_pd3dImmediateContext->DrawIndexed(6, 0, 0);
3.6 保存纹理到文件
保存纹理到文件直接获取纹理缓冲,然后调用API保存:
// 若截屏键Q按下,则分别保存到output.dds和output.png中
if (m_PrintScreenStarted)
{
ComPtr<ID3D11Texture2D> backBuffer;
// 输出截屏
m_pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), reinterpret_cast<void**>(backBuffer.GetAddressOf()));
HR(SaveDDSTextureToFile(m_pd3dImmediateContext.Get(), backBuffer.Get(), L"Screenshot\\output.dds"));
HR(SaveWICTextureToFile(m_pd3dImmediateContext.Get(), backBuffer.Get(), GUID_ContainerFormatBmp, L"Screenshot\\output.bmp", &GUID_WICPixelFormat32bppBGRA));
// 结束截屏
m_PrintScreenStarted = false;
}