DirectX11入门到实践-(5)总结篇
环境配置
Windows10 SDK
需要链接一些DX的lib。
DX的dll,以及插件assimp的dll。
Include目录
Shader
渲染管线

附一张OpenGL的:

可见两者差的不多,都是比较通用的管线
Shader编写
HLSL编译着色器的三种方法(需要安装HLSL Compiler)
本教程不考虑Effects11(FX11),而是基于原始的HLSL。
目前编译与加载着色器的方法如下:
-
使用Visual Studio中的HLSL编译器,随项目编译期间一同编译,并生成.cso(Compiled Shader Object)对象文件,在运行期间加载该文件以读取字节码。
-
使用Visual Studio中的HLSL编译器,随项目编译期间一同编译,并生成.inc或.h的头文件,着色器字节码在编译期间就可以确定。
-
在程序运行期间编译着色器代码,并读取生成的字节码。
在个人的DX11项目中,使用的是方法1(优先)和方法3的混合形式。尽管方法2是最近了解到的,但个人目前并不考虑更换为该方法。
与着色器相关的文件扩展名
为了符合微软的约定,需要为你的着色器代码使用下面的扩展名(有所修改):
-
扩展名为.hlsl的文件用于编写HLSL的源代码,参与编译
-
扩展名为.hlsli的文件作为HLSL的标头文件,不参与编译
-
扩展名为.cso的文件作为已编译的着色器对象(Compiled Shader Object)
-
扩展名为.inc或.h的文件是C++的头文件,但它的内部包含了着色器的字节码,使用BYTE数组来记录
Shader读取
我们将Shader类封装,使用D3DReadFileToBlob来读取shaderFile.cso文件,用device->CreateXXXXShader创建shader,并将输入布局也一并封装。
最后交给deviceContext给渲染管线某一着色阶段设置对应的着色器SetShader。
注意: 类似给渲染管线绑定资源的一切方法,在绑定之后就会一直生效,而不是说仅能够使用一次。所以,以后如果你需要用别的特效去绘制当前物体,就要重新绑定好渲染管线所需要的一切资源。
输入布局
在HLSL中,用于输入的结构体为:
struct VertexIn
{
float3 pos : POSITION;
float4 color : COLOR;
};
与之对应的C++结构体为:
struct Vertex
{
DirectX::XMFLOAT3 pos;
DirectX::XMFLOAT4 color;
(可选)static const D3D11_INPUT_ELEMENT_DESC inputLayout[2];
};
为了能够建立C++结构体与HLSL结构体的对应关系,需要使用ID3D11InputLayout输入布局来描述每一个成员的用途、语义、大小等信息。
还要留意的是,其中inputLayout并不是结构体VertexPosColor的内部成员,而是静态成员,不占用该结构体的空间。我们使用D3D11_INPUT_ELEMENT_DESC结构体来描述待传入结构体中每个成员的具体信息,定义如下:
D3D11_INPUT_ELEMENT_DESC layout[] =
{
{"POSITION", 0, DXGI_FORMAT::DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_CLASSIFICATION::D3D11_INPUT_PER_VERTEX_DATA, 0 },
{"TEXCOORD", 0, DXGI_FORMAT::DXGI_FORMAT_R32G32_FLOAT, 0,
D3D11_APPEND_ALIGNED_ELEMENT,D3D11_INPUT_CLASSIFICATION::D3D11_INPUT_PER_VERTEX_DATA, 0 },
};
由此可见,HLSL,C++ Struct,InputLayout 需要一一对应。
DXGI_FORMAT在这里通常描述数据的存储方式、大小。用DXGI_FORMAT_R32G32B32_FLOAT仅仅是解释为3个float类型的值;而用DXGI_FORMAT_R32G32B32A32_FLOAT在这里是说明颜色按RGBA存储,并且为4个float类型的值。
最后,device->CreateInputLayout 创建输入布局,
上下文 ID3D11DeviceContext::IASetInputLayout方法,输入装配阶段设置输入布局。
(XJun代码中是将layout写在Vertex中的,他实现了一种自动适配多种Vertex结构体的方法,当需要时可以这么写)
顶点缓冲区
为顶点结构体Vertex服务
缓冲区描述D3D11_BUFFER_DESC,
存放的数据D3D11_SUBRESOURCE_DATA vertexBufferData,当我们编写好顶点数组后就会把数据存放到这里,
最后创建device->CreateBuffer(&vertexBufferDesc, &vertexBufferData, this->buffer.GetAddressOf())。
索引缓冲区
同上
此类设置我们放在Draw函数中,根据不同的物体,做不同的设置并每帧绘制:
//Draw
this->deviceContext->VSSetConstantBuffers(0, 1, this->cb_vs_vertexshader->GetAddressOf());
this->deviceContext->PSSetShaderResources(0, 1, &this->texture); //Set Texture
this->deviceContext->IASetIndexBuffer(this->indexBuffer.Get(), DXGI_FORMAT::DXGI_FORMAT_R32_UINT, 0);
UINT offset = 0;
this->deviceContext->IASetVertexBuffers(0, 1, this->vertexBuffer.GetAddressOf(), this->vertexBuffer.StridePtr(), &offset);
this->deviceContext->DrawIndexed(this->indexBuffer.IndexCount(), 0, 0);
常量缓冲区
在HLSL中,常量缓冲区的变量类似于C++这边的全局常量,供着色器代码使用。下面是一个HLSL常量缓冲区
示例:
cbuffer ConstantBuffer : register(b0)
{
matrix World;
matrix View;
matrix Proj;
}
cbuffer用于声明一个常量缓冲区
matrix 等价于 float4x4,同样有vector等价于float4。
其中D3D中的矩阵默认是行主矩阵形式,但是到了HLSL的matrix默认是列主矩阵形式。
register(b0) 指的是该常量缓冲区位于寄存器索引为0的缓冲区
既然HLSL中定义了结构体,那么C++这边也得定义,我们放到ConstantBufferType文件中:
struct ConstantBuffer
{
XMMATRIX world;
XMMATRIX view;
XMMATRIX proj;
};
目前资源有两种动态更新方式:
-
允许常量缓冲区从GPU写入,然后使用ID3D11DeviceContext::UpdateSubresource方法更新,虽然这种方法可能会比较快一点,但还需要拷贝临时资源到GPU中(更新是异步操作),等待资源结束占用才会复制到该资源中。
-
允许常量缓冲区从CPU写入,需要等待资源完成使用才能映射到内存中,此操作是阻塞行为。然后修改映射好的内存后解除占用,完成更新。ApplyChanges
不仅常量缓冲区,一般的缓冲区和纹理资源更新都可以使用上述两种方式。其中第一种方式适合更新不频繁(隔一段时间更新),或者仅一次更新的数据。
由于常量缓冲区大多数需要频繁更新,因此后续都将主要使用DYNAMIC更新。我们编写ApplyChanges函数:
bool ApplyChanges()
{
D3D11_MAPPED_SUBRESOURCE mappedResource;
HRESULT hr = this->deviceContext->Map(buffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedResource);
if (FAILED(hr))
{
ErrorLogger::Log(hr, "Failed to map constant buffer.");
return false;
}
CopyMemory(mappedResource.pData, &data, sizeof(T));
this->deviceContext->Unmap(buffer.Get(), 0);
return true;
}
……
PSSetConstantBuffers即可绑定
注意:在创建常量缓冲区时,描述参数ByteWidth必须为16的倍数,因为HLSL的常量缓冲区本身以及对它的读写操作需要严格按16字节对齐。
总结之前的打包规则:
-
C++中的结构体数据是以字节流的形式传输给HLSL的;
-
HLSL常量缓冲区中的向量不允许拆分;
-
HLSL常量缓冲区中多个相邻的变量若有空缺则优先打包进同一个4D向量中;
-
HLSL常量缓冲区中,结构体常量前面的所有常量都会被打包成4D向量,内部也进行打包操作,但结构体的最后一个成员可能会和后续的常量打包成4D向量;
-
数组中的每一个元素都会独自打包,但对于最后一个元素来说如果后续的变量不是数组、结构体且还有空缺,则可以进行打包操作。
所以避免出现潜在问题的办法如下:
-
若要使用数组,确保数组的每个元素都是16字节对齐;
-
结构体的总大小也需要按16字节对齐。
纹理贴图
用DDSTextureLoader和WICTextureLoader这两个库来读取DDS位图和WIC位图
纹理坐标系和屏幕、图片坐标系的有些相似,它们的U轴都是水平朝右,V轴竖直向下。但是纹理的X和Y的取值范围都为[0.0, 1.0],分别映射到[0, Width]和[0, Height]

DDS位图和WIC位图
DDS是一种图片格式,是DirectDraw Surface的缩写,它是DirectX纹理压缩(DirectX Texture Compression,简称DXTC)的产物。由NVIDIA公司开发。大部分3D游戏引擎都可以使用DDS格式的图片用作贴图,也可以制作法线贴图。
WIC(Windows Imaging Component)是一个可以扩展的平台,为数字图像提供底层API,它可以支持bmp、dng、ico、jpeg、png、tiff等格式的位图。
过滤器
图片的放大
常量插值法
线性插值法
图片的缩小Mipmap
各向异性过滤
Shader
有了贴图后,我们需要修改shader,既要存放uv信息,又要根据输入的texture采样颜色给pixel:
Texture2D objTexture : TEXTURE : register(t0);
SamplerState objSamplerState : SAMPLER : register(s0);
float4 main(PS_INPUT input) : SV_TARGET
{
float3 pixelColor = objTexture.Sample(objSamplerState, input.inTexCoord);
return float4(pixelColor, alpha);
}
创建
ID3D11Device::CreateSamplerState方法--创建采样器状态
在C++代码层中,我们只能通过D3D设备创建采样器状态,然后绑定到渲染管线中,使得在HLSL中可以根据过滤器、寻址模式等进行采样。
在创建采样器状态之前,需要先填充结构体D3D11_SAMPLER_DESC来描述采样器状态
一个典型的设置是:D3D11_TEXTURE_ADDRESS_WRAP,使用重复的纹理去覆盖整个实数域,可以当做fmod(X, 1.0f)。
ID3D11DeviceContext::PSSetSamplers方法--像素着色阶段设置采样器状态
3D空间
笛卡尔坐标系
笛卡尔坐标系即三个轴X,Y和Z彼此垂直的坐标系,包括左手坐标系和右手坐标系。

左手坐标系(最常用):DirectX,Unity模型空间、世界空间、裁剪空间、屏幕空间
右手坐标系(xz互换):Unity观察空间(摄像机)
不同的是,Unity正向面对的是左X右Z,相比DX旋转了90°。
模型空间(对象空间,local)->世界空间
Pworld = Mmodel * Pmodel(P表示位置,M表示矩阵)
场景中第一层的GameObject,属于没有父物体的root层级,其本地坐标系就是世界坐标系,不需要local变换(或者local变换矩阵=1,即XMMatrixIdentity()),其LocalToWorld的变换就是XMMatrixRotationRollPitchYaw(this->rot.x, this->rot.y, this->rot.z) *
XMMatrixTranslation(this->pos.x, this->pos.y, this->pos.z)
子物体就不一样了,子物体需要先做local坐标系变换,即child.local2World = child.local2parent* parent.local2World,才能转换为世界坐标系。
可以封装一个Transform类,来管理场景树,里面有Transform child array 和 Transform* parent
注意,我们所做的如上计算得到的仅是位置坐标,旋转朝向只需要子物体rot+父物体rot即可,缩放同理。
世界空间->观察空间
得到将相机变换到坐标轴原点的逆矩阵,且因变换为右手坐标系,需要对z分量取反
Pview = Mview * Pworld
若已知物体所在位置 Q=(Qx,Qy,Qz) 以及三个互相垂直的坐标轴 u=(ux,uy,uz),v=(vx,vy,vz),w=(wx,wy,wz)
则我们可以得到对应的世界矩阵:
W=⎣⎢⎢⎡uxvxwxQxuyvywyQyuzvzwzQz0001⎦⎥⎥⎤
该矩阵的应用有两种解释方式:
-
将物体从世界坐标系的原点搬移到世界矩阵对应的位置,并按其坐标轴做对应朝向和大小的调整
-
经过世界变化后物体已经在世界坐标系的对应位置,实际上是做从物体坐标系到世界坐标系的变换
然而现在我们需要做的是从世界坐标系转换到观察空间坐标系,如果把摄像机看做物体的话,则实际上做的相当于是世界矩阵的逆变换,从世界坐标系来到了摄像机的局部坐标系(右方向为X轴,上方向为Y轴,目视方向为Z轴),即 V=(RT)−1=T−1R−1=T−1RT
V=⎣⎢⎢⎡uxuyuz−Q⋅uvxvyvz−Q⋅vwxwywz−Q⋅w0001⎦⎥⎥⎤
观察空间->裁剪空间
(裁剪空间又叫投影空间,并没有真正投影,是做空间的降维)
透视投影:视锥体6个裁剪平面,重点依据近剪裁平面、远剪裁平面。FOV改变视野张开角度,计算出远近裁剪平面的高度=2near/fartan(FOV/2),再由横纵比Aspect得到宽度。由此得到投影矩阵 Pclip = Mfrustum * Pview
可以根据±w分量,得到判断变换后的顶点是否位于视锥体内的不等式
正交投影:因为near=far,计算简单很多,w分量仍然是1
裁剪空间->屏幕空间
齐次除法:用w分量除以xyz分量。使透视投影的裁剪空间变为一个长度为1的单位立方体,转化为 归一化设备坐标NDC
再用屏幕映射公式得到ScreenX,Y。Z分量用于深度缓冲
屏幕映射:OpenGL左下角为0点,DirectX左上角为0点
常用API
XMMatrixTranslation( float OffsetX, float OffsetY, float OffsetZ );
根据位移值构建平移矩阵
XMMatrixRotationRollPitchYaw( float Pitch, float Yaw, float Roll );
基于欧拉角构建旋转矩阵
以上两个矩阵相乘(再乘一个scale矩阵)就得到了LocalToWorld矩阵(如果是子物体的话,得到的是LocalToParent矩阵)。
XMVector3TransformCoord( FXMVECTOR V, FXMMATRIX M );
通过给定的矩阵变换3D向量,因为3D向量只有xyz,所以要忽略输入向量的w分量,而使用值1.0。返回向量的w分量始终为1.0。
this->positionVector = XMLoadFloat3(&this->position);
Float3 转 Vector3
XMStoreFloat3(&this->position, position);
Vector3 转 Float3
float XMScalarModAngle( float Value );
计算-XM_PI和XM_PI之间的角度。也就是说会将弧度角大小控制在±π之间,即绝对值不超过180°。
XMMatrixLookAtLH( FXMVECTOR EyePosition, FXMVECTOR FocusPosition, FXMVECTOR UpDirection );
使用摄像机位置,向上方向和焦点位置(即相机的朝向方向,需要normalize),为左手坐标系构建视点矩阵。(其实这三个参数就确定了相机所处的位置和旋转角)
XMMatrixPerspectiveFovLH( float FovAngleY, float AspectRatio, float NearZ, float FarZ );
基于FOV构建左手透视投影矩阵。
XMVectorClamp( FXMVECTOR V, FXMVECTOR Min, FXMVECTOR Max );
将矢量限制到指定的最小和最大范围。
场景建模
立方体
一般来说8顶点就够了,根据索引顺序决定绘制内面还是外面。
Geometry中创建包含24个顶点的数组(立方体一个顶点重复3次,但法向量不相同),以及一个含36个索引的数组
圆柱体
圆柱体可分为三个部分:侧面、顶部圆盖和底部圆盖。(注意这里说的圆柱体不是单纯的圆柱体,也可以泛指圆台、圆锥。当顶部圆盖半径为0就是圆锥)
我们可以定义一个圆柱体,通过指定其底部和顶部半径,还有高度,以及片(Slice)和层(Stack)数。如下图所示:

(图中左边的圆柱体有8片,4层。片数和层数可以控制三角形的密度)
上图有【层数+1】个环(圈 ring),每个环都有片数个顶点。
每两个连续的环的半径之差△r为:
△r = (顶部圆盖半径 - 底部圆盖半径) / 层数
如果底部的环的下标是0,那么第i个环半径r(i):
r(i) = 底部环半径+ iΔr。
△h是层的高度,h是圆柱体高度。那么第i个环的高度h(i)是:
h(i) = -h/2 + i△h
所以我们的基本实现就是迭代每一环来产生每一环上的顶点。
那么如何指定索引呢?
观察下图,每一层中的每一片都有一个四边形(两个三角形)。下图显示了第i层中的第j片:

顶点 A,B,C,D包含第i层中的第j片,由此可以计算出他们对应的索引
ΔABC=(i·n+j, (i+1)·n+j), (i+1)·n+j+1)
ΔACD=(i·n+j, (i+1)·n+j+1, i·n+j+1
n是每一环的顶点数。因此,创建索引缓存的关键的思想是每一层中的每一片应用上述公式。
注意:
-
每个环的第一个和最后一个顶点在位置上重复,但纹理坐标不重复。我们必须这样做才能正确地使用纹理。
-
代码中包含创建圆柱体对象额外的顶点数据,例如法线和纹理坐标,这些为以后的演示提供方便,现在不用担心这些代码。
XJUN的
弧边一般是靠角度计算出来的,比如cosθ是x坐标,sinθ是y坐标。大家都知道圆就是这么来的,所以球啊、圆柱侧面啊都是这样的。
这里我们只分两层,即顶层和底层,来绘制侧面,因此索引也就好算了。
球体
由于3D模型都是用三角形模拟的,这里的球体如果想要效果更佳逼真,需要用到更多的三角形。球体的法向量如前面所述,使用微分法求出。在提供参数的时候,levels决定上下分多少层,slices决定一个水平圆切面的顶点数目。levels和slices越高,生成的顶点数、索引数都会越多。
球体是根据两个角度的迭代计算出来的,可以看做是经度和维度
x和z轴需要先根据纬度拿到所在圆的半径,再确定坐标
y轴直接cos纬度即可
天空盒
最好是封装一个加载天空盒纹理的方法。
天空盒是一个巨大的模型,可以有穹顶、球、立方盒等多种形状(面数不需要很高),内部纹理映射(6张贴图),然后跟随相机移动。
我就先画了个1000半径的立方体,for循环6张贴图,DDS不知为啥用不了只能WIC,然后渲染立方体内面,根据固定的顺序……最后记得要把fov设置的比半径大,否则会被bgcolor覆盖背景。
现在推荐的做法为:总是先清空渲染目标和深度/模板缓冲区,天空盒的绘制留到最后。
摄像机
第一人称相机
移动相机而不是物体更好实现
第三人称相机
绕物体旋转+更新位移
Jpres是每次Draw都要传递viewProjMatrix,而XJun用的更好的方式是将其保存在ConstantBuffer中。
光照
对于8位色来说,每种分量的颜色亮度可以表达出256种,但使用浮点数会大大浪费存储空间。在内存要求苛刻的情况下,我们可以使用32位的数据类型,其中rgba各占8位,若需要映射到浮点数向量,则对应关系为f(x) = x / 255.0f,其中x为整数存储法,表示范围为[0,255],f(x)为浮点存储法,表示范围为[0.0f, 1.0f]。
法向量
法向量变换
物体表面材质:
不同的物体有不同的材质属性,决定了各种颜色分量的反射系数是多少。
环境光(Ambient Lighting)

漫反射光(Diffuse Lighting)
我们可以近似认为光线在照射到物体表面一点后会朝任意方向反射等量的光照,这样我们人眼无论在哪个方向观察该点,呈现的亮度应该是不会变化的(在没有镜面反射的基础)。但是物体的亮度与光线照射的方向有所关系,比如当均匀光线垂直照射物体的时候,此时看到的物体表面是最亮的;而均匀光线不经过物体表面,与表面平行的时候,物体的表面此时几乎是看不到的(此时可能仍有少量的光会到达物体表面,取决于光束的汇聚程度和与物体的距离)。毕竟光束不可能做到完全同一个方向照射,仍会有少数的散射光。

镜面反射光(Specular Lighting)
光照模型
平行光/方向光
把前面的几个光的公式都用上,是一种全局光
点光源
会衰减
XJUN
在LightHelper.h中定义光源结构体,GameApp::InitResource()中给结构体赋值
然后设置 PSConstantBuffer mPSConstantBuffer; // 用于修改用于PS的GPU常量缓冲区的变量
设置完了还要上下文更新常量缓冲区资源,他是把mConstantBuffers[0]设为VS的缓冲区,mConstantBuffers[1]设为PS的缓冲区。数据传输:
md3dImmediateContext->UpdateSubresource(mConstantBuffers[1].Get(), 0, nullptr, &mPSConstantBuffer, 0, 0);
该API功能是 让CPU将数据从内存复制到 在不可映射的内存中创建的子资源。
貌似这样就算有灯光了(我们是需要调用ConstantBuffer的ApplyChange来更新数据,然后Shader在绘制时就有数据了)
Shader这边,LightHelper.hlsli中有对应的结构体类型,然后在VS和PS着色器中具体计算光照。
Jpres
弄明白light是用constantbuffer存储相关变量后,就按照 youtube教程54 操作即可实现光照了。主要步骤:pixelshader 中添加 light buffer,并用 light变量计算最终颜色。对应的constant buffer type增加 light buffer对应的type。最后在grphics中为该type初始化和绑定PSSetConstantBuffers即可。
补充
如果要组织的更好的话,应该每个物体自身负责自己的constantBuffer,而不是graphics来创建
物体封装
Object:包括Transform
Model:包括Mesh(Vertices和Indices),可以被渲染出来
其他不渲染的或者结构方式不一样的如 Car, Camera, Skybox 自己单独继承后实现。