前言
这一章我们主要学习由6个纹理所构成的立方体映射,以及用它来实现一个静态天空盒。
但是在此之前先要消除两个误区:
- 认为这一章的天空盒就是简单的在一个超大立方体的六个面内部贴上天空盒纹理;
- 认为天空盒的顶点都是固定的,距离起始点的位置特别远。
我提出这两个误区,是因为看到有些人的作品直接贴了六个立方体,就说自己用到了天空盒技术,但是当你真正学这一章的话会发现此天空盒非彼天空盒,而且该篇教程除了天空盒的技术实现外,还有其余的一些干货值得学习,建议认真研读。
在此之前还需要回顾一下里面有关纹理子资源的部分:
章节回顾 |
---|
深入理解与使用2D纹理资源(重点了解纹理子资源、纹理数组和纹理天空盒) |
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。
立方体映射(Cube Mapping)
一个立方体(通常是正方体)包含六个面,对于立方体映射来说,它的六个面对应的是六张纹理贴图,然后以该立方体建系,中心为原点,且三个坐标轴是轴对齐的。我们可以使用方向向量(±X,±Y,±Z),从原点开始,发射一条射线(取方向向量的方向)来与某个面产生交点,取得该纹理交点对应的颜色。
注意:
- 方向向量的大小并不重要,只要方向一致,那么不管长度是多少,最终选择的纹理和取样的像素都是一致的。
- 使用方向向量时要确保所处的坐标系和立方体映射所处的坐标系一致,如方向向量和立方体映射同时处在世界坐标系中。
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
来表示。
环境映射(Environment Maps)
关于立方体映射,应用最广泛的就是环境映射了。为了获取一份环境映射,我们可以将摄像机绑定到一个物体的中心(或者摄像机本身视为一个物体),然后使用90°的垂直FOV和水平FOV(即宽高比1:1),再让摄像机朝着±X轴、±Y轴、±Z轴共6个轴的方向各拍摄一张不包括物体本身的场景照片。因为FOV的角度为90°,这六张图片已经包含了以物体中心进行的透视投影,所记录的完整的周遭环境。接下来就是将这六张图片保存在立方体纹理中,以构成环境映射。综上所述,环境映射就是在立方体表面的纹理中存储了周围环境的图像。
由于环境映射仅捕获了远景的信息,这样附近的许多物体都可以共用同一个环境映射。这种做法称之为静态立方体映射,它的优点是仅需要六张纹理就可以轻松实现,但缺陷是该环境映射并不会记录临近物体信息,在绘制反射时就看不到周围的物体了。
注意到环境映射所使用的六张图片不一定非得是从Direct3D程序中捕获的。因为立方体映射仅存储纹理数据,它们的内容通常可以是美术师预先生成的,或者是自己找到的。
一般来说,我们能找到的天空盒有如下三种:
- 已经创建好的.dds文件,可以直接通过
DDSTextureLoader
读取使用 - 6张天空盒的正方形贴图,格式不限。(暂不考虑只有5张的)
- 1张天空盒贴图,包含了6个面,格式不限,图片宽高比为4:3
对于第三种天空盒,其平面分布如下:
对于其余两种天空盒,这里也提供了3种方法读取。
使用DXTex构建天空盒
准备6张天空盒的正方形贴图,如果是属于上述第三种情况,可以用截屏工具来截取出6张贴图,但是要注意按原图的分辨率来进行截取。
打开放在Github项目中Utility文件夹内的DxTex.exe,新建纹理:
Texture Type
要选择Cubemap Texture
Dimensions
填写正方形纹理的像素宽度和高度
如果你需要自动生成mipmaps,则指定mipmap Level为1.如果你需要手工填充mipmaps,由于1024x1024的纹理mipmap最大数目为11,你可以指定mipmap Level为2-11的值。
对于Surface/Volume Format
,通常情况下使用Unsigned 32-bit: A8R8G8B8
格式,如果想要节省内存(但是会牺牲质量),可以选用Four CC 4-bit: DXT1
格式,可以获得6:1甚至8:1的压缩比。
创建好后会变成这样:
可以看到当前默认的是+X纹理。
接下来就是将这六张图片塞进该立方体纹理中了,选择View-Cube map Face,并选择需要修改的纹理:
在当前项目的Texture文件夹内已经准备好了有6张贴图。
选择File-Open To This Cubemap Face来选择对应的贴图以加载进来即可。每完成当前的面就要切换到下一个面继续操作,直到六个面都填充完毕。此时填充的是Mipmap Level为0的子资源:
如果你需要自动生成纹理,则可以点击下面的选项生成,要求创建时MipMap Level为1:
最后就可以点击File-Save As来保存dds文件了。
这种做法需要比较长的前期准备时间,它不适合批量处理。但是在读取上是最方便的。
使用代码读取天空盒
对于创建好的DDS立方体纹理,我们只需要使用DDSTextureLoader
就可以很方便地读取进来:
HR(CreateDDSTextureFromFile(
device.Get(),
cubemapFilename.c_str(),
nullptr,
textureCubeSRV.GetAddressOf()));
然而从网络上能够下到的天空盒资源经常要么是一张天空盒贴图,要么是六张天空盒的正方形贴图,用DXTex导入还是比较麻烦的一件事情。我们也可以自己编写代码来构造立方体纹理。
将一张天空盒贴图转化成立方体纹理需要经历以下4个步骤:
- 读取天空盒的贴图
- 创建包含6个纹理的数组
- 选取原天空盒纹理的6个子正方形区域,拷贝到该数组中
- 创建立方体纹理的SRV
而将六张天空盒的正方形贴图转换成立方体需要经历这4个步骤:
- 读取这六张正方形贴图
- 创建包含6个纹理的数组
- 将这六张贴图完整地拷贝到该数组中
- 创建立方体纹理的SRV
可以看到这两种类型的天空盒资源在处理上有很多相似的地方。
有关天空盒读取的代码实现如果你想了解,需要回到开头了解"深入理解与使用2D纹理资源"这章
绘制天空盒
尽管天空盒是一个立方体,但是实际上渲染的是一个很大的"球体"(由大量的三角形逼近)表面。使用方向向量来映射到立方体纹理对应的像素颜色,同时它也指向当前绘制的"球"面上对应点。另外,为了保证绘制的天空盒永远处在摄像机能看到的最远处,通常会将该球体的中心设置在摄像机所处的位置。这样无论摄像机如何移动,天空盒也跟随摄像机移动,用户将永远到不了天空盒的一端。当然更好的做法是强行抹除掉摄像机和天空球的平移分量来进行变换和绘制,以避免可能出现的天空盒抖动问题。可以说这和公告板一样,都是一种欺骗人眼的小技巧。
天空球体和纹理立方体的中心一致,不需要管它们的大小关系。
实际绘制的天空球体
绘制天空盒需要以下准备工作:
- 将天空盒载入HLSL的TextureCube中
- 在光栅化阶段关闭背面消隐(正面是球面向外,但摄像机在球内)
- 在输出合并阶段的深度/模板状态,设置深度比较函数为小于等于,以允许深度值为1的像素绘制
新的深度/模板状态
在RenderStates.h
引进了一个新的ID3D11DepthStencilState
类型的成员DSSLessEqual
,定义如下:
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()));
在绘制天空盒前就需要设置该深度/模板状态:
deviceContext->OMSetDepthStencilState(RenderStates::DSSLessEqual.Get(), 0);
HLSL代码
现在我们需要一组新的特效来绘制天空盒,其中与之相关的是Sky.hlsli
, Sky_VS.hlsl
和Sky_PS.hlsl
,当然在C++那边还有新的SkyEffect
类来管理,需要了解自定义Effect的可以回看第13章。
// 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);
}
注意: 在过去,应用程序首先绘制天空盒以取代渲染目标和深度/模板缓冲区的清空。然而“ATI Radeon HD 2000 Programming Gudie"(现在已经404了)建议我们不要这么做。首先,为了获得内部硬件深度优化的良好表现,深度/模板缓冲区需要被显式清空。这对渲染目标同样有效。其次,通常绝大多数的天空会被其它物体给遮挡。因此,如果我们先绘制天空,再绘制物体的话会导致二次绘制,还不如先绘制物体,然后让被遮挡的天空部分不通过深度测试。因此现在推荐的做法为:总是先清空渲染目标和深度/模板缓冲区,天空盒的绘制留到最后。
模型的反射
关于环境映射,另一个主要应用就是模型表面的反射(只有当天空盒记录了除当前反射物体外的其它物体时,才能在该物体看到其余物体的反射)。对于静态天空盒来说,通过模型看到的反射只能看到天空盒本身,因此还是显得不够真实。至于动态天空盒就还是留到下一章再讲。
下图说明了反射是如何通过环境映射运作的。法向量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
来控制反射程度,以达到自己想要的效果。
还有一个问题就是,在平面上进行环境映射并不会取得理想的效果。这是因为上面的HLSL代码关于反射的部分只使用了方向向量来进行采样,这会导致以相同的的倾斜角度看平面时,不同的位置看到的反射效果却是一模一样的。正确的效果应该是:摄像机在跟随平面镜做平移运动时,平面镜的映象应该保持不动。下面用两张图来说明这个问题:
这里给出龙书所提供相关论文,用以纠正环境映射出现的问题: Brennan02
本项目现在不考虑解决这个问题。
SkyRender类
SkyRender类支持之前所述的3种天空盒的加载,由于在构造的同时还会创建球体,建议使用unique_ptr
来管理对象。
下面是SkyRender
的完整实现:
class SkyRender
{
public:
template<class T>
using ComPtr = Microsoft::WRL::ComPtr<T>;
SkyRender() = default;
~SkyRender() = default;
// 不允许拷贝,允许移动
SkyRender(const SkyRender&) = delete;
SkyRender& operator=(const SkyRender&) = delete;
SkyRender(SkyRender&&) = default;
SkyRender& operator=(SkyRender&&) = default;
// 需要提供完整的天空盒贴图 或者 已经创建好的天空盒纹理.dds文件
HRESULT InitResource(ID3D11Device* device,
ID3D11DeviceContext* deviceContext,
const std::wstring& cubemapFilename,
float skySphereRadius, // 天空球半径
bool generateMips = false); // 默认不为静态天空盒生成mipmaps
// 需要提供天空盒的六张正方形贴图
HRESULT InitResource(ID3D11Device* device,
ID3D11DeviceContext* deviceContext,
const std::vector<std::wstring>& cubemapFilenames,
float skySphereRadius, // 天空球半径
bool generateMips = false); // 默认不为静态天空盒生成mipmaps
ID3D11ShaderResourceView* GetTextureCube();
void Draw(ID3D11DeviceContext* deviceContext, SkyEffect& skyEffect, const Camera& camera);
// 设置调试对象名
void SetDebugObjectName(const std::string& name);
private:
HRESULT InitResource(ID3D11Device* device, float skySphereRadius);
private:
ComPtr<ID3D11Buffer> m_pVertexBuffer;
ComPtr<ID3D11Buffer> m_pIndexBuffer;
UINT m_IndexCount;
ComPtr<ID3D11ShaderResourceView> m_pTextureCubeSRV;
};
HRESULT SkyRender::InitResource(ID3D11Device* device, ID3D11DeviceContext* deviceContext, const std::wstring& cubemapFilename, float skySphereRadius, bool generateMips)
{
// 防止重复初始化造成内存泄漏
m_pIndexBuffer.Reset();
m_pVertexBuffer.Reset();
m_pTextureCubeSRV.Reset();
HRESULT hr;
// 天空盒纹理加载
if (cubemapFilename.substr(cubemapFilename.size() - 3) == L"dds")
{
hr = CreateDDSTextureFromFile(device,
generateMips ? deviceContext : nullptr,
cubemapFilename.c_str(),
nullptr,
m_pTextureCubeSRV.GetAddressOf());
}
else
{
hr = CreateWICTexture2DCubeFromFile(device,
deviceContext,
cubemapFilename,
nullptr,
m_pTextureCubeSRV.GetAddressOf(),
generateMips);
}
if (hr != S_OK)
return hr;
return InitResource(device, skySphereRadius);
}
HRESULT SkyRender::InitResource(ID3D11Device* device, ID3D11DeviceContext* deviceContext, const std::vector<std::wstring>& cubemapFilenames, float skySphereRadius, bool generateMips)
{
// 防止重复初始化造成内存泄漏
m_pIndexBuffer.Reset();
m_pVertexBuffer.Reset();
m_pTextureCubeSRV.Reset();
HRESULT hr;
// 天空盒纹理加载
hr = CreateWICTexture2DCubeFromFile(device,
deviceContext,
cubemapFilenames,
nullptr,
m_pTextureCubeSRV.GetAddressOf(),
generateMips);
if (hr != S_OK)
return hr;
return InitResource(device, skySphereRadius);
}
ID3D11ShaderResourceView* SkyRender::GetTextureCube()
{
return m_pTextureCubeSRV.Get();
}
void SkyRender::Draw(ID3D11DeviceContext* deviceContext, SkyEffect& skyEffect, const Camera& camera)
{
UINT strides[1] = { sizeof(XMFLOAT3) };
UINT offsets[1] = { 0 };
deviceContext->IASetVertexBuffers(0, 1, m_pVertexBuffer.GetAddressOf(), strides, offsets);
deviceContext->IASetIndexBuffer(m_pIndexBuffer.Get(), DXGI_FORMAT_R32_UINT, 0);
// 抹除平移分量,避免摄像机移动带来天空盒抖动
XMMATRIX V = camera.GetViewXM();
V.r[3] = g_XMIdentityR3;
skyEffect.SetWorldViewProjMatrix(V * camera.GetProjXM());
skyEffect.SetTextureCube(m_pTextureCubeSRV.Get());
skyEffect.Apply(deviceContext);
deviceContext->DrawIndexed(m_IndexCount, 0, 0);
}
void SkyRender::SetDebugObjectName(const std::string& name)
{
#if (defined(DEBUG) || defined(_DEBUG)) && (GRAPHICS_DEBUGGER_OBJECT_NAME)
// 先清空可能存在的名称
D3D11SetDebugObjectName(m_pTextureCubeSRV.Get(), nullptr);
D3D11SetDebugObjectName(m_pTextureCubeSRV.Get(), name + ".CubeMapSRV");
D3D11SetDebugObjectName(m_pVertexBuffer.Get(), name + ".VertexBuffer");
D3D11SetDebugObjectName(m_pIndexBuffer.Get(), name + ".IndexBuffer");
#else
UNREFERENCED_PARAMETER(name);
#endif
}
HRESULT SkyRender::InitResource(ID3D11Device * device, float skySphereRadius)
{
HRESULT hr;
auto sphere = Geometry::CreateSphere<VertexPos>(skySphereRadius);
// 顶点缓冲区创建
D3D11_BUFFER_DESC vbd;
vbd.Usage = D3D11_USAGE_IMMUTABLE;
vbd.ByteWidth = sizeof(XMFLOAT3) * (UINT)sphere.vertexVec.size();
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vbd.CPUAccessFlags = 0;
vbd.MiscFlags = 0;
vbd.StructureByteStride = 0;
D3D11_SUBRESOURCE_DATA InitData;
InitData.pSysMem = sphere.vertexVec.data();
hr = device->CreateBuffer(&vbd, &InitData, &m_pVertexBuffer);
if (hr != S_OK)
return hr;
// 索引缓冲区创建
m_IndexCount = (UINT)sphere.indexVec.size();
D3D11_BUFFER_DESC ibd;
ibd.Usage = D3D11_USAGE_IMMUTABLE;
ibd.ByteWidth = sizeof(DWORD) * m_IndexCount;
ibd.BindFlags = D3D11_BIND_INDEX_BUFFER;
ibd.CPUAccessFlags = 0;
ibd.StructureByteStride = 0;
ibd.MiscFlags = 0;
InitData.pSysMem = sphere.indexVec.data();
return device->CreateBuffer(&ibd, &InitData, &m_pIndexBuffer);
}
与其配套的SkyEffect
可以在源码中观察到。
项目演示
说了那么多内容,是时候看一些动图了吧。
该项目加载了三种类型的天空盒,可以随时切换。
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。