DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

时间:2022-09-10 17:00:04

这一章中,我们将学习使用立方体环境贴图(Cube Mapping)实现天空环境、模型反射效果。

在《赛达尔传说:荒野之息》中,明朗或阴暗的天空都可以通过Cube Mapping来实现:
DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果
(图为《赛达尔传说:荒野之息》游戏截图)

一、使用Cube Map实现天空效果

立方体环境贴图是一个数组纹理,用于模拟全包围的环境,类似带有6个面的正方体,因而称为 Cube Map。

DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

实际上打开dds文件后,只是一组合并6张图片的纹理:
DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

立方体环境贴图有6个面,我们可以把它们跟世界坐标轴对齐,每个轴有两个方向分别对应2个面,因此(±X, ±Y, ±Z)对应了六个面。DirectX还提供了一个枚举类型D3D11_TEXTURECUBE_FACE来指示Cube Map的每个面:

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;

为什么只需要6个面就可以获得整个天空环境呢?可以这样想,一周360度,一个面可以获得90度的内容,那么你围绕一圈的4个面就可以获得一圈的环境,还有抬头与低头两个面也是90度的内容。

我们可以介绍预先渲染这样一张环境贴图的方法,DirectX也提供了一个工具DxTex.exe让我们通过载入6个面来制作一张Cube Map。

DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

我们以前对2D纹理进行采样的时候,是通过uv坐标采样的,那么对于cube map该怎么采样呢?我们可以想象把cube map折叠成一个正方体,那么通过在原点处通过一个方向向量来获得一个颜色。如下图所示(化简的2D图示):

DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

例如,在像素着色器中可以这样采样:

float3 v = float3(x,y,z); // 3D方向向量
float4 color = gCubeMap.Sample(gTriLinearSam, v); //通过3D方向来采样

目前描述三维天空的方案主要包括三种模型:

1.平面型天空(Sky Plane),仅用一个平面放到玩家头顶。这种方案太弱了,太容易被玩家们看穿,真实感太低,技术含量也太低。但是对于并不太注意远景的场景,用天空平面也不失为一种办法。在这种情况下,用纯色的雾来覆盖整个远景,使得远处充满神秘,遮一下羞也效果凑合。

2.天空盒(Sky Box),将Cube Map映射到正方体,即用一个模拟的映射正方体包围住场景。效果一般,但是实现简单、性能较好。Unity3D默认也是这种方案。但是在VR中不能使用该方案,会看到明显的边角。

DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

3.天空穹庐(Sky Dome),将Cube Map映射到球体,即用一个模拟的映射球体包围住场景。效果最好,但也略微费时。也是本篇博文使用的方案。

DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

我们先载入该cube map文件,生成2D纹理和着色器资源视图:

ID3D11ShaderResourceView* mCubeMapSRV;
HR(D3DX11CreateShaderResourceViewFromFile(device,
cubemapFilename.c_str(), 0, 0, &mCubeMapSRV, 0)
)
;

然后再设置着色器资源视图进.fx文件:

// .fx 变量
TextureCube gCubeMap;
// .cpp 代码
ID3DX11EffectShaderResourceVariable* CubeMap;
CubeMap = mFX->GetVariableByName("gCubeMap")->AsShaderResource();

CubeMap->SetResource(cubemap);

因为我们选用的方案是将Cupe Map投影到球体,那么就需要创建一个球体。(但是这个球体不需要真的非常大,因为我们在fx文件中进行变换的时候会让z = w使得z/w=1,顶点总是处于远平面)我们只需要创建球体外形的模型顶点即可,半径不重要:

Sky::Sky(ID3D11Device* device, const std::wstring& cubemapFilename, float skySphereRadius)
{
HR(D3DX11CreateShaderResourceViewFromFile(device, cubemapFilename.c_str(), 0, 0, &mCubeMapSRV, 0));

GeometryGenerator::MeshData sphere;
GeometryGenerator geoGen;
geoGen.CreateSphere(skySphereRadius, 30, 30, sphere);

std::vector<XMFLOAT3> vertices(sphere.Vertices.size());

for(size_t i = 0; i < sphere.Vertices.size(); ++i)
{
vertices[i] = sphere.Vertices[i].Position;
}

D3D11_BUFFER_DESC vbd;
vbd.Usage = D3D11_USAGE_IMMUTABLE;
vbd.ByteWidth = sizeof(XMFLOAT3) * vertices.size();
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vbd.CPUAccessFlags = 0;
vbd.MiscFlags = 0;
vbd.StructureByteStride = 0;

D3D11_SUBRESOURCE_DATA vinitData;
vinitData.pSysMem = &vertices[0];

HR(device->CreateBuffer(&vbd, &vinitData, &mVB));


mIndexCount = sphere.Indices.size();

D3D11_BUFFER_DESC ibd;
ibd.Usage = D3D11_USAGE_IMMUTABLE;
ibd.ByteWidth = sizeof(USHORT) * mIndexCount;
ibd.BindFlags = D3D11_BIND_INDEX_BUFFER;
ibd.CPUAccessFlags = 0;
ibd.StructureByteStride = 0;
ibd.MiscFlags = 0;

std::vector<USHORT> indices16;
indices16.assign(sphere.Indices.begin(), sphere.Indices.end());

D3D11_SUBRESOURCE_DATA iinitData;
iinitData.pSysMem = &indices16[0];

HR(device->CreateBuffer(&ibd, &iinitData, &mIB));
}

Effect文件如下:

//=====================================================================
// Sky.fx by Frank Luna (C) 2011 All Rights Reserved.
//
// Effect used to shade sky dome.
//=====================================================================
cbuffer cbPerFrame
{
float4x4 gWorldViewProj;
};

// Nonnumeric values cannot be added to a cbuffer.
TextureCube gCubeMap;

SamplerState samTriLinearSam
{
Filter = MIN_MAG_MIP_LINEAR;
AddressU = Wrap;
AddressV = Wrap;
};

struct VertexIn
{
float3 PosL : POSITION;
};

struct VertexOut
{
float4 PosH : SV_POSITION;
float3 PosL : POSITION;
};

VertexOut VS(VertexIn vin)
{
VertexOut vout;
// Set z = w so that z/w = 1 (i.e., skydome always on far plane).
vout.PosH = mul(float4(vin.PosL, 1.0f), gWorldViewProj).xyww;
// Use local vertex position as cubemap lookup vector.
vout.PosL = vin.PosL;
return vout;
}

float4 PS(VertexOut pin) : SV_Target
{
return gCubeMap.Sample(samTriLinearSam, pin.PosL);
}

RasterizerState NoCull
{
CullMode = None;
};

DepthStencilState LessEqualDSS
{
// Make sure the depth function is LESS_EQUAL and not just LESS.
// Otherwise, the normalized depth values at z = 1 (NDC) will
// fail the depth test if the depth buffer was cleared to 1.
DepthFunc = LESS_EQUAL;
};

technique11 SkyTech
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, VS()));
SetGeometryShader(NULL);
SetPixelShader(CompileShader(ps_5_0, PS()));
SetRasterizerState(NoCull);
SetDepthStencilState(LessEqualDSS, 0);
}
}

程序运行结果截图:

DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

二、使用静态Cube Map实现模型反射效果

除了将Cube Map运用到天空效果的制作外,Cube Map还可以用来制作模型的反射效果。

原理是视线e通过模型三角面的法线n折射会得到一个方向向量v,再用这个方向向量v可以在Cube Map采样得到一个环境的颜色。

DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

.fx文件采样示例代码:

litColor = texColor*(ambient + diffuse) + spec;
if(gReflectionEnabled)
{
float3 incident = -toEye;
float3 reflectionVector = reflect(incident, pin.NormalW);
float4 reflectionColor = gCubeMap.Sample(
samAnisotropic, reflectionVector);
litColor += gMaterial.Reflect*reflectionColor;
}

通常,模型反射的颜色不是完全由Cube Map决定的,只有镜面反射才是(像之前的示例)。所以我们可以通过调节反射的颜色比例,来调节从Cube Map获得的反射颜色。比如,你想只获得红色,就设置mR = (1, 0, 0)。在本例中,我们设置了mR = (0.4, 0.4, 0.4)。

有一个需要注意的问题是,如果像我们上面那样直接叠加反射采样的Cube Map像素,那么可能会造成过饱和(oversaturation)现象。我们需要减少环境光、漫反射光以达到平衡。一种解决方案是通过一个比例t来调整叠加颜色的平衡,如下所示:

DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

还有一个问题就是,这样的反射贴图不能应用于平面,如下图所示。因为从两个角度相同但是位置不同的观察点e和e’经过法线反射后,得到的反射方向向量v是相同的,这样就会造成非常奇怪的扭曲效果。所以我们只能应用于曲面。

DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

程序运行结果截图:
DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

三、使用动态Cube Map实现模型反射效果

上面我们介绍了静态Cube Map实现的模型反射效果,我们还可以使用动态的Cube Map,也就是通过程序运行过程中产生实时的Cube Map。为什么需要这样呢?我们注意到上面的程序运行之后,因为我们用的是天空Cube Map,只有天空的反射,而场景中的物体不能被反射出来,只能死死的盯住天空环境,除此之外的物体都不能看到。而且如果我们场景中有运动的物体也是不可能被反射出来,因为上面这张Cube Map是静态的。

现在为了捕捉周围产生的物体,我们需要每帧渲染以带有反射效果的物体为中心的Cube Map。如下图所示:

DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

注意,实时产生Cube Map是很昂贵的开销,因为我们需要渲染6次!只有对某些重要的物体来产生Cube Map比较合适,而无关紧要、很可能被忽略的物体就不要去动态产生Cube Map了。另外动态Cube Map更适合低分辨率的纹理,例如每个方向是256 × 256,因为这样可以减少填充率,提高渲染速度。

创建Cube Map 2D纹理:

static const int CubeMapSize = 256;
//
// Cubemap is a special texture array with 6 elements. We
// bind this as a render target to draw to the cube faces,
// and also as a shader resource, so we can use it in a pixel shader.
//
D3D11_TEXTURE2D_DESC texDesc;
texDesc.Width = CubeMapSize;
texDesc.Height = CubeMapSize;
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;
ID3D11Texture2D* cubeTex = 0;
HR(md3dDevice->CreateTexture2D(&texDesc, 0, &cubeTex));

需要注意的是,MiscFlags中包含了D3D11_RESOURCE_MISC_TEXTURECUBE来表明这个纹理时Cube Map,而且需要指定D3D11_RESOURCE_MISC_GENERATE_MIPS来允许使用GenerateMips方法产生mipmap levels。

接着就是创建6个渲染目标视图:

// Create a render target view to each cube map face
// (i.e., each element in the texture array).
//
ID3D11RenderTargetView* mDynamicCubeMapRTV[6];
D3D11_RENDER_TARGET_VIEW_DESC rtvDesc;
rtvDesc.Format = texDesc.Format;
rtvDesc.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2DARRAY;
rtvDesc.Texture2DArray.MipSlice = 0;
// Only create a view to one array element.
rtvDesc.Texture2DArray.ArraySize = 1;
for(int i = 0; i < 6; ++i)
{
// Create a render target view to the ith element.
rtvDesc.Texture2DArray.FirstArraySlice = i;
HR(md3dDevice->CreateRenderTargetView(
cubeTex, &rtvDesc, &mDynamicCubeMapRTV[i]));
}

创建6个和渲染目标视图匹配的深度缓存和视口:

static const int CubeMapSize = 256;
ID3D11DepthStencilView* mDynamicCubeMapDSV;
D3D11_TEXTURE2D_DESC depthTexDesc;
depthTexDesc.Width = CubeMapSize;
depthTexDesc.Height = CubeMapSize;
depthTexDesc.MipLevels = 1;
depthTexDesc.ArraySize = 1;
depthTexDesc.SampleDesc.Count = 1;
depthTexDesc.SampleDesc.Quality = 0;
depthTexDesc.Format = DXGI_FORMAT_D32_FLOAT;
depthTexDesc.Usage = D3D11_USAGE_DEFAULT;
depthTexDesc.BindFlags = D3D11_BIND_DEPTH_STENCIL;
depthTexDesc.CPUAccessFlags = 0;
depthTexDesc.MiscFlags = 0;
ID3D11Texture2D* depthTex = 0;
HR(md3dDevice->CreateTexture2D(&depthTexDesc, 0, &depthTex));
// Create the depth stencil view for the entire buffer.
D3D11_DEPTH_STENCIL_VIEW_DESC dsvDesc;
dsvDesc.Format = depthTexDesc.Format;
dsvDesc.Flags = 0;
dsvDesc.ViewDimension = D3D11_DSV_DIMENSION_TEXTURE2D;
dsvDesc.Texture2D.MipSlice = 0;
HR(md3dDevice->CreateDepthStencilView(depthTex,
&dsvDesc, &mDynamicCubeMapDSV));
// View saves reference.
ReleaseCOM(depthTex);

……

D3D11_VIEWPORT mCubeMapViewport;
mCubeMapViewport.TopLeftX = 0.0f;
mCubeMapViewport.TopLeftY = 0.0f;
mCubeMapViewport.Width = (float)CubeMapSize;
mCubeMapViewport.Height = (float)CubeMapSize;
mCubeMapViewport.MinDepth = 0.0f;
mCubeMapViewport.MaxDepth = 1.0f;

接下来需要设置摄像机的位置在该模型的中心,还有6个Cube Map的方向:

Camera mCubeMapCamera[6];
void DynamicCubeMapApp::BuildCubeFaceCamera(float x, float y, float z)
{
// Generate the cube map about the given position.
XMFLOAT3 center(x, y, z);
XMFLOAT3 worldUp(0.0f, 1.0f, 0.0f);
// Look along each coordinate axis.
XMFLOAT3 targets[6] =
{
XMFLOAT3(x+1.0f, y, z), // +X
XMFLOAT3(x-1.0f, y, z), // -X
XMFLOAT3(x, y+1.0f, z), // +Y
XMFLOAT3(x, y-1.0f, z), // -Y
XMFLOAT3(x, y, z+1.0f), // +Z
XMFLOAT3(x, y, z-1.0f) // -Z
};
// Use world up vector (0,1,0) for all directions except +Y/-Y.
// In these cases, we are looking down +Y or -Y, so we need a
// different "up" vector.
XMFLOAT3 ups[6] =
{
XMFLOAT3(0.0f, 1.0f, 0.0f), // +X
XMFLOAT3(0.0f, 1.0f, 0.0f), // -X
XMFLOAT3(0.0f, 0.0f, -1.0f), // +Y
XMFLOAT3(0.0f, 0.0f, +1.0f), // -Y
XMFLOAT3(0.0f, 1.0f, 0.0f), // +Z
XMFLOAT3(0.0f, 1.0f, 0.0f) // -Z
};
for(int i = 0; i < 6; ++i)
{
mCubeMapCamera[i].LookAt(center, targets[i], ups[i]);
mCubeMapCamera[i].SetLens(0.5f*XM_PI, 1.0f, 0.1f, 1000.0f);
mCubeMapCamera[i].UpdateViewMatrix();
}
}

最后就是绘制场景了,这里绘制了两次,一次是不绘制要产生Cube Map反射的物体来产生实时的Cube Map。第二次是重新绘制所有物体,包括带有Cube Map反射的物体(因为第二次已经有实时产生的Cube Map了)。

void DynamicCubeMapApp::DrawScene()
{
ID3D11RenderTargetView* renderTargets[1];

// Generate the cube map.
md3dImmediateContext->RSSetViewports(1, &mCubeMapViewport);
for(int i = 0; i < 6; ++i)
{
// Clear cube map face and depth buffer.
md3dImmediateContext->ClearRenderTargetView(mDynamicCubeMapRTV[i], reinterpret_cast<const float*>(&Colors::Silver));
md3dImmediateContext->ClearDepthStencilView(mDynamicCubeMapDSV, D3D11_CLEAR_DEPTH|D3D11_CLEAR_STENCIL, 1.0f, 0);

// Bind cube map face as render target.
renderTargets[0] = mDynamicCubeMapRTV[i];
md3dImmediateContext->OMSetRenderTargets(1, renderTargets, mDynamicCubeMapDSV);

// Draw the scene with the exception of the center sphere to this cube map face.
DrawScene(mCubeMapCamera[i], false);
}

// Restore old viewport and render targets.
md3dImmediateContext->RSSetViewports(1, &mScreenViewport);
renderTargets[0] = mRenderTargetView;
md3dImmediateContext->OMSetRenderTargets(1, renderTargets, mDepthStencilView);

// Have hardware generate lower mipmap levels of cube map.
md3dImmediateContext->GenerateMips(mDynamicCubeMapSRV);

// Now draw the scene as normal, but with the center sphere.
md3dImmediateContext->ClearRenderTargetView(mRenderTargetView, reinterpret_cast<const float*>(&Colors::Silver));
md3dImmediateContext->ClearDepthStencilView(mDepthStencilView, D3D11_CLEAR_DEPTH|D3D11_CLEAR_STENCIL, 1.0f, 0);

DrawScene(mCam, true);

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

程序运行结果截图:
DirectX11 使用Cube Mapping 立方体环境贴图实现天空、物体反射效果

四、通过几何着色器进行后续优化

想想前面的方案,其实还是有可以优化的地方,比如每个模型要经过六次的draw调用非常耗时。其实我们可以通过几何着色器,将三角形分配到对应的渲染目标视图,这样只需要调用一次draw call:

struct PS_CUBEMAP_IN
{
float4 Pos : SV_POSITION; // Projection coord
float2 Tex : TEXCOORD0; // Texture coord
uint RTIndex : SV_RenderTargetArrayIndex;
};

[maxvertexcount(18)]
void GS_CubeMap(triangle GS_CUBEMAP_IN input[3],
inout TriangleStream<PS_CUBEMAP_IN> CubeMapStream)
{
// For each triangle
for(int f = 0; f < 6; ++f)
{
// Compute screen coordinates
PS_CUBEMAP_IN output;
// Assign the ith triangle to the ith render target.
output.RTIndex = f;
// For each vertex in the triangle
for(int v = 0; v < 3; v++)
{
// Transform to the view space of the ith cube face.
output.Pos = mul(input[v].Pos, g_mViewCM[f]);
// Transform to homogeneous clip space.
output.Pos = mul(output.Pos, mProj);
output.Tex = input[v].Tex;
CubeMapStream.Append(output);
}
CubeMapStream.RestartStrip();
}
}

这样的方法其实也有一些不容易注意到的缺点:
1. 几何着色器输出太多的顶点会造成一定的性能下降。
2. 通常来说,一个三角形不会同时重复出现在6个方向的Cube Map,这个方法会浪费5次的绘制计算。我们可以对模型使用视锥裁剪的方法来减少顶点的运算,但是在几何着色器中无法做模型的视锥裁剪。

不过,从某些场景来说这个方法还是可取的,比如说动态的天气环境,我们无法通过静态的Cube Map来实现,而且天空网格(sky mesh)在六个面都是可见的时候,视锥裁剪也没有必要,这个时候使用这种方法比起draw 6 次就比较有效。

项目源代码:
https://github.com/ljcduo/Introduction-to-3D-Game-Programming-With-DirectX11/tree/master/Chapter%2017%20Cube%20Mapping