本文主要是介绍Frank Luna DirectX12阅读笔记:绘制的不同主题(第十五章-第二十三章),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
这里写目录标题
- 第十五章 第一人称摄像机和动态索引
- 摄像机部分略
- 15.5 动态索引
- 第十六章 实例化(Instancing)和视锥裁剪(Frustrum Culling)
- 16.1 硬件Instancing
- 16.2 包围几何体和视锥
- 16.3 Frustrum Culling
- 第十七章 拾取
- 17.1 屏幕到世界坐标的转换
- 17.2 拾取射线
- 17.3 网格射线求交
- 17.4 Demo
- 第十八章 立方体纹理
- 18.1 Cube Mapping
- 18.2 环境映射
- 18.3 天空纹理
- 18.4 反射建模
- 18.5 动态立方体映射
- 18.6 使用Geometry Shader进行动态立方体映射
- 第十九章 法向映射
- 19.1 动机
- 19.2 法向映射
- 19.3 纹理/切向空间 & 19.4 顶点切向空间 & 19.5 纹理空间到物体空间
- 19.6 法向贴图shader代码
- 第二十章 阴影映射
- 20.1 绘制场景深度
- 20.2 正交投影
- 20.3 投影纹理坐标
- 20.4 阴影映射
- 20.4.1 算法描述
- 20.4.2 阴影走样
- 20.4.3 PCF(Percentage Closer Filtering)
- 20.4.4 建立阴影贴图
- 20.4.5 阴影系数
- 20.4.6 阴影映射测试
- 20.4.7 绘制阴影贴图
- 20.5 大的PCF kernel
- 第二十一章 环境光遮蔽(Ambient Occlusion)
- 21.1 通过Ray Casting进行环境光遮蔽
- 22.2 屏幕空间的环境光遮蔽(SSAO)
- 第二十二章 四元数
- 第二十三章 动画
- 其他
第十五章 第一人称摄像机和动态索引
摄像机部分略
15.5 动态索引
- 之前,每个render item都需要传一个材质和一个纹理(保存在每个item的constant buffer中),如果场景中有大量物体,使用了同样的材质和纹理,那么每次渲染重新传,是比较耗时的。动态索引指在一次draw call时就上传所有的材质和纹理,而每个render item的 constant buffer只需要记录一个材质ID和纹理ID,则就能节省很多时间,具体策略如下:
- 构建一个structured buffer,来存储所有的材质
- 每个物体的constant buffer中增加一项MaterialIndex
- 将所有texture SRV一次性地载入场景
- 在材质中增加一项DiffuseMapIndex,来表示材质使用到的纹理
- 详见代码
第十六章 实例化(Instancing)和视锥裁剪(Frustrum Culling)
16.1 硬件Instancing
- 动机如下,显然,复制很多个vertex data和index data是很浪费的,因此,我们只存储一份局部几何信息(vertex list和index list),但多份不同的世界变换矩阵和材质
- 几个树的模型,多次重复,得到森林
- 几个小行星的模型,多次重复,得到小行星带
- 几个人物模型,多次重复,得到人群
- 这一方法虽然节省了空间,但仍需要每个render item调用一次draw call,造成API调用的损耗(虽然Direct3D 12相对Direct3D 11,已经减少了API调用的损失)。实例化API允许用户使用一次draw call,绘制多个物体。和上一章中的动态索引结合,这一方法更加高效
- 之所以API调用的损耗是重要的,是因为和GPU相比,CPU才是运算的瓶颈。一个关卡中,往往有非常多的物体,为了达到实时的效果,基本上只能调用几千次的draw call。如果能通过一个draw call绘制多个物体,就能提升效率。硬件实例化就是其中一个方法
- 之前的代码已经使用硬件Instancing了,即mCommandList->DrawIndexedInstanced(),只不过仅绘制了一次。在
- 等等
16.2 包围几何体和视锥
- 利用DirectXCollision.h(DirectX Math的一部分)中的BoundingBox、BoundingOrientedBox、BoundingSphere、BoundingFrustrum结构
- 其余略
16.3 Frustrum Culling
- 在裁剪阶段,视锥之外的三角形会被丢弃。但是,如果我们的场景非常复杂,数量庞大的三角形仍然会进行vertex shader、(可能)细分的几个shader、(可能)geometry shader的运算,才会在裁剪阶段被丢弃,因此是非常低效的
- 因此,我们应该尽早进行视锥裁剪的步骤,通过视锥和物体包围盒的比较,早早地将视锥外的物体丢弃。虽然这一过程小小地增加了CPU的负担,但大大减少了GPU的运算
- 详见代码
第十七章 拾取
17.1 屏幕到世界坐标的转换
- 略
17.2 拾取射线
- 略
17.3 网格射线求交
- 略
17.4 Demo
- 略
第十八章 立方体纹理
18.1 Cube Mapping
- 六个面:0=+X,1=-X,2=+Y,3=-Y,4=+Z,5=-Z
- 和2D texture不同,我们使用3D texture坐标来获取一个texel,3D坐标为空间的一个方向,该方向从原点出发,和立方体相交处就是这个坐标对应的texel
- 在HLSL中,它的代码类似如下:
TextureCube gCubeMap : register(t0);
SamplerState gsamLinearWrap : register(s0);
// pixel shader中
gCubeMap.Sample(gsamLinearWrap, p.PosL);
18.2 环境映射
- 假设要在物体O处建立一个环境映射,那么需要在O的中心,沿着六个方向,以90°视角,拍摄六张图片,作为立方体纹理。如果有很多个点都进行环境映射(环境映射可以提供环境光,在反射中可以提高真实感),则代价很大。对此有两个方法应对:
- 仅在几个关键点获取环境映射纹理,中间点进行纹理插值。这一方法虽然会产生不正确的反射,但很难注意到
- 仅对整个环境,做背景的环境映射纹理,不进行任何局部的环境映射
- 我们可以先用3D世界编辑器搭建一个场景,然后将场景预先渲染到立方体贴图中。Terragen可以用来创建照片级真实感的户外场景
- 可以使用texassemble命令行来将6张图片合并成一个DDS纹理文件
- 仍然可以使用DDSTextureLoader.h/.cpp来加载DDS文件,唯一的不同是在建立SRV时,要将维度设置为D3D12_SRV_DIMENSION_TEXTURECUBE,并填写D3D12_SHADER_RESOURCE_VIEW::TextureCube结构
18.3 天空纹理
- 我们可以创建一个大球体包围住整个场景来作为天空,通过立方体纹理采样获取球体的颜色
- 天空球以相机为中心,这一过程可以在vertex shader中完成
- 以前人们可能会用绘制天空来代替清除render target,因此第一个先绘制天空。但现在通常不这么做,因为以下原因。因此,现在往往最后绘制天空
- 清除render target和depth/stencil buffer比较容易进行硬件层的优化
- 天空常常大部分被遮挡,不需要全部绘制出来
- 绘制天空需要不同的shader,因此需要一个新的PSO。此外,由于摄像机在天空球内部,因此需要禁用背面剔除;修改深度测试比较函数为D3D12_COMPARISON_FUNC_LESS_EQUAL(而不是LESS),才能将天空显示出来
18.4 反射建模
- 第八章光照建模时,高光只来自光源方向,其他间接光只有和环境无关的环境光。可以通过环境映射来增强高光的真实感
- 从摄像机出发,在顶点上反射,再和纹理求交,如下:
float3 r = reflect(-toEyeW, pin.NormalW);
float4 reflectionColor = gCubeMap.Sample(gsamLinearWrap, r);
float3 fresnelFactor = SchlickFresnel(fresnelR0, pin.NormalW, r);
litColor.rgb = shininess * fresnelFactor * reflectionColor.rgb;
- 上述方法是正确的,但代码是错误的,它会导致如下的结果:
- 正确的详见代码
18.5 动态立方体映射
- 预先做好立方体纹理当然是比较简单高效的,但如果场景中有一些移动的物体,也需要作为立方体贴图一部分,就需要每帧先渲染6个纹理,再作为立方体纹理。比如,角色靠近玻璃球,玻璃球需要能反射出角色自身,由于角色本身是移动的,因此对于玻璃球每帧需更新纹理
- 动态立方体纹理是非常昂贵的,因此我们需要尽量减少动态立方体映射。比如
- 仅在关键物体上进行渲染动态立方体纹理
- 减小立方体纹理的分辨率
- 代码中给了一个立方体映射的辅助类CubeRenderTarget,可供参考
18.6 使用Geometry Shader进行动态立方体映射
- 之前的方法是非常昂贵的,使用geometry shader有助于提高效率
- 首先,之前我们对每个面都创建一个render target view和对应的texture,现在我们仅创造一个render target view,它的维度类型为D3D10_DSV_DIMENSION_TEXTURE2DARRAY,在一个RTV中包含了6个texture,类似地,也只创造一个depth stencil view,它的维度类型也是D3D10_DSV_DIMENSION_TEXTURE2DARRAY,在一个DSV中包含6个texture
- 然后在constant buffer中计算好6个视角的变换矩阵,在geometry shader中,将每个三角形变换6次,分别映射到不同的render target上。映射到不同render target由SV_RenderTargetArrayIndex控制,这一参数仅能由geometry shader输出
- 具体geometry shader代码如下:
struct PS_CUBEMAP_IN {float4 Pos : SV_POSITION;float2 Tex : TEXCOORD0;uint RTIndex : SV_RenderTargetArrayIndex;
};
[maxvertexcount(18)]
void GS_CubeMap(triangle GS_CUBEMAP_IN input[3],inout TriangleStream<PS_CUBEMAP_IN> CubeMapStream) {// 每个三角形for (int f=0; f<6; ++f) {PS_CUBEMAP_IN output;output.RTIndex = f;for (int v=0; v<3; v++) {output.Pos = mul(input[v].Pos, g_mViewCM[f]);output.Pos = mul(output.Pos, mProj);output.Tex = input[v].Tex;CubeMapStream.Append(output);}CubeMapStream.RestartStrip();}
}
- 通过这样的方法,我们可以将6次draw call变成1次draw call。然而它在另一方面产生了较大的代价:
- geometry shader输出了过多的数据(18个顶点),这会导致效率下降
- 一个三角形通常只会渲染到一个纹理上,因此复制的6份中,有5份最终都会被裁剪。因此在实际应用中,还是应该先进行视锥裁剪,再绘制。而这也导致了geometry shader无法使用
- 但另一方面,这一策略适合于绘制场景的包围网格,如会随时间变化的天空球(云朵飘动、天空颜色改变等)。因为天空在发生变化,因此无法使用预先bake的纹理。此外,如果GPU不是性能瓶颈,则geometry shader效率低一些也无妨
第十九章 法向映射
19.1 动机
- 如果没有法向贴图,则不平整的纹理和平整的法向、光滑的高光形成矛盾
- 细分也无法解决法向问题
- 提前将光照bake到纹理中则无法解决动态光源的问题
19.2 法向映射
- 法向映射是一个纹理,但它的rgb通道分别表示了法向的三个坐标轴xyz
- 由于法向往往垂直于平面,因此法向纹理往往z轴的值较大,因此在视觉上呈现为蓝色
- 采样和之前完全一致:
float3 normalT = gNormalMap.Sample(gTriLinearSam, pin.Tex);
19.3 纹理/切向空间 & 19.4 顶点切向空间 & 19.5 纹理空间到物体空间
- 这一部分考虑如何将纹理空间上的法向转换为物体空间中的法向
- 对于三角形面上一点,那么纹理空间上的三角形到物体空间的三角形仅仅是一个刚性变换,法向也相似地变换即可。在纹理空间上的xyz三轴,转换到世界坐标中,我们记为T(Tangent)轴、B(binormal)轴和N(normal)轴。这里的N轴实际上就是模型三角形的面法向,法向贴图上的值基于TBN坐标系,对面上顶点的法向进行了修正
- 对于三角形和三角形之间的顶点,则N轴就是相邻三角形的法向平均,T轴也是相邻三角形的T轴平均(通过施密特正交化法和N轴正交),B轴由N轴、T轴叉乘得到
- 有了坐标系的变换,那么法向贴图的法向也相应变换即可
19.6 法向贴图shader代码
- TBN需要在CPU中先算好,再传入到GPU中(这一步似乎比较难在GPU中完成,因为vertex buffer仅在点上运算 ,hull shader仅对patch进行细分,geometry shader仅考虑面元,都无法取得邻近三角形数据)。由于TBN是由顶点位置和顶点纹理坐标决定的,因此CPU计算完成后,可以保存在模型文件中,下次直接读取即可
float3 NormalSampleToWorldSpace(float3 normalMapSample,float3 unitNormalW, float3 tangentW) {float3 normalT = 2.0f * normalMapSample - 1.0f;float3 N = unitNormalW;float3 T = normalize(tangentW - dot(tangentW, N) * N);float3 B = cross(N, T);float3 TBN = float3x3(T, B, N);float3 bumpedNormalW = mul(normalT, TBN);return bumpedNormalW;
}
第二十章 阴影映射
20.1 绘制场景深度
- 首先以光源为视角,绘制场景深度;然后以摄像机为视角,以之前的场景深度作为一个贴图,比较像素和光源的距离是否小于等于贴图
20.2 正交投影
- 略
20.3 投影纹理坐标
- 投影纹理坐标,指将纹理像投影仪那样,将光线投射到任意的表面上
- 我们可以将投影仪视为一个摄像机,纹理就是一个视平面。对于三维空间中的一个顶点,首先将顶点投影到视平面上,然后转换到NDC(标准设备坐标)空间,再转换到纹理坐标,就可以得到它的颜色,如下图所示:
- 对于投影到纹理外的点,通常赋值0即可。另一个策略是使用聚光灯,因为聚光灯内部光强较大,向外逐渐减弱,因此有一个自然的过渡
- 投影时,除了使用透视投影外,也可使用平行投影
20.4 阴影映射
20.4.1 算法描述
- 透视投影表现聚光源,平行投影表现平行光源。平行投影限制了平行光在一个矩形范围内,因此若要包含一个很大的空间,则要将扩大投影的矩形
- 顶点p和光源的距离为d§,若d§>s§,则在阴影中;若d§<=s§,则不在阴影中
20.4.2 阴影走样
- 在非阴影的区域,会出现条纹状artifact,如下:
- 这是因为阴影贴图每个texel只是对应了texel中心距离光源的深度,因此在texel边缘的深度会高于或低于中心,呈现锯齿状(shadow acne):
- 因此,我们需要进行一定的偏移(增大s§到s’§),使得所有锯齿状的深度都能保证d§<=s’§,如图:
- 但偏移不能太大,否则一些应为阴影的点会被判定为非阴影,从而产生物体悬浮在场景上方的现象(peter-panning):
- 然而,一个固定的bias不能对所有的物体都有效。在光线和物体平面几乎垂直时,需要的bias非常小;而光线和物体几乎平行时,则需要的bias很大,如下图:
- 因此,我们希望偏移量和光线、物体平面之间角度相关,越平行则bias越大。硬件对此有内置的支持,即slope-scled-bias,即:
typedef struct D3D12_RASTERIZER_DESC {// ...// Bias = (float)DepthBias*r + SlopeScaledDepthBias * MaxDepthSlope// 其中r为最小浮点数,如24位float,则r=2^{-24}// MaxDepthSlope则是像素在水平方向和竖直方向上的深度斜率的最大值INT DepthBias; // 固定biasFLOAT DepthBiasClamp; // 最大允许的biasFLOAT SlopeScaledDepthBias; // 基于物体平面角度的bias乘数// ...
} D3D12_RASTERIZER_DESC;
- depth bias发生在光栅化的阶段,因此不影响裁剪
- 不同的场景需要的参数可能非常不同,因此需要尝试不同的参数来适应场景
- Kilgard在2001年的Shadow Mapping with Today’s OpenGL Hardware(https://www.slideshare.net/Mark_Kilgard/shadow-mappingwith-todays-opengl-hardware—)中建议:
- 通常这一参数是有效的:scale=1.1,bias=4.0(虽然不知道和这里的比例关系是否一致)
- 两权相害取其轻,太大的bias比太小的bias好
- shadow map精度越高,需要的bias越小
- 如果shadow map被放大了(应该指点光源/聚光源的情况),需要的scale越大
20.4.3 PCF(Percentage Closer Filtering)
- 直接使用阴影贴图,会产生硬边。如果空间上一点投影到纹理上,它不在texel中心,按照之前纹理的方法,需要进行双线性插值。但在阴影中,双线性插值无法避免硬边的问题。类似地,使用mipmap技术,也无法解决硬边的问题。因此,我们需要对最邻近的4个texel中心,先按每个中心计算,该点是否在阴影中,再将这一结果进行平均,这就是PCF。它的HLSL代码如下:
static const float SMAP_SIZE = 2048.0f; // 阴影贴图分辨率
static const float SMAP_DX = 1.0f / SMAP_SIZE;
// ...
// 找到最邻近的4个texel中心点,并进行采样,比较深度
float s0 = gShadowMap.Sample(gShadowSam, projTexC.xy).r;
float s1 = gShadowMap.Sample(gShadowSam, projTexC.xy + float2(SMAP_DX, 0)).r;
float s2 = gShadowMap.Sample(gShadowSam, projTexC.xy + float2(0, SMAP_DX)).r;
float s3 = gShadowMap.Sample(gShadowSam, projTexC.xy + float2(SMAP_DX, SMAP_DX)).r;
float result0 = depth <= s0;
float result1 = depth <= s1;
float result2 = depth <= s2;
float result3 = depth <= s3;
// 转换到纹理空间
float2 texelPos = SMAP_SIZE * projTexC.xy;
float2 t = frac(texelPos); // frac()取小数部分
// 对结果进行双线性插值
return lerp(lerp(result0, result1, t.x), lerp(result2, result3, t.y))
- 效果如下图所示:
- PCF最大的缺点是需要进行4次采样,毕竟采样是GPU中最耗时的操作之一。不过Direct3D 11+的图形硬件内置SampleCmpLevelZero()支持PCF:
Texture2D gShadowMap : register(t1);
SamplerComparisonState gsamShadow : register(s6);
// 计算深度
shadowPosH.xyz /= shadowPosH.w;
float depth = shadowPosH.z;
// 自动PCF
gShadowMap.SampleCmpLevelZero(gsamShadow, shadowPosH.xy, depth).r;
- 注意这里使用的采样器是SamplerComparisonState,而不是通常的SamplerState,因此我们在设置静态采样器时,需如下设置:
const CD3DX12_STATIC_SAMPLER_DESC shadow(6, // shader register,D3D12_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT, // filterD3D12_TEXTURE_ADDRESS_MODE_BORDER, // addressUD3D12_TEXTURE_ADDRESS_MODE_BORDER, // addressVD3D12_TEXTURE_ADDRESS_MODE_BORDER, // addressW0.0f, // mipLODBias16, // maxAnisotropyD3D12_COMPARISON_FUNC_LESS_EQUAL,D3D12_STATIC_BORDER_COLOR_OPAQUE_BLACK
)
- 上述的例子中我们采样了最近的4个texel,更大的采样范围可以获得更柔和的边缘
- 另外,注意到PCF只需要在阴影的边缘计算即可,Isidoro在2006年的方法可以解决这一问题
- PCF也不一定取最近的四个texel,也有一些方法通过随机采点的方式组成PCF kernel
20.4.4 建立阴影贴图
- 详见代码。注意可以关闭颜色绘制,因为我们仅关心深度
20.4.5 阴影系数
- 判断出哪里是阴影,哪里不是阴影后,剩下来的就是光照上要乘上一个阴影系数,详见代码
20.4.6 阴影映射测试
- 比较d§和s§,详见代码
20.4.7 绘制阴影贴图
- 详见代码
20.5 大的PCF kernel
- 使用PCF可能会带来一些问题,比如在下图中,p点没有受到任何遮挡,但由于PCF的原因,它被认定为有1/3在阴影中
- 这一问题可以使用bias来解决,但随着PCF的扩大,bias就要越来越大,带来bias过大的问题。因此,仅仅依靠bias是不够的
- 首先学习一下HLSL中的ddx()和ddy()函数,它们分别衡量了∂p/∂x和∂p/∂y,写成数学公式的形式就是 q x + 1 , y − q x , y q_{x+1,y} - q_{x,y} qx+1,y−qx,y。它们可以用来表示
- 相邻像素间颜色如何变化
- 相邻像素间深度如何变化
- 相邻像素间法向如何变化
- 首先介绍Tuft在2010提出的方法。这一方法基于p相邻像素和p落在同一平面上的假设。如果在平面上,那么根据x轴/y轴距离的远近,和x轴/y轴上的梯度,推算出PCF采样点处的bias。数学推导详见书本
- Isidoro在2006年的方法思想和上述一致,但实现上不同。详见书本
第二十一章 环境光遮蔽(Ambient Occlusion)
- 第八章中,物体的环境光表现为环境光系数乘以散射系数,如果没有贴图,那么就是一个常量。它们唯一的作用就是让阴影中的物体不要变成全黑,和真实感物理没有什么关系。本章要对此进行改进
21.1 通过Ray Casting进行环境光遮蔽
- 在顶点p向外半球发射射线,若总共发射的N条射线中,有h条击中了邻近的网格(击中阈值之外的网格仍能产生环境光),则这h条不产生环境光,因此遮蔽系数为h/N,如下:
- 通过这种方法,环境光遮蔽的对比效果如下:
- 对于静态模型,可以预计算环境光遮蔽,它可以在初始化时计算,然后存储成顶点上的一个属性。一些工具(http://www.xnormal.net)甚至还可以形成环境光遮蔽的贴图。但是对于会运动的物体,这些方法都失效了。Ray Casting的方法计算环境光需消耗大量时间,因此在实时计算中是不现实的
22.2 屏幕空间的环境光遮蔽(SSAO)
- 在模型的每个顶点上计算AO非常耗时,如果仅在屏幕所显示的像素上计算,就能高效许多。我们将顶点法向渲染到render target上,将深度渲染到depth/stencil buffer上,那么,我们就可以根据这些构造一个屏幕可以看见区域的三维场景,并在这个场景内计算AO,这一技术就是屏幕空间环境光遮蔽(SSAO)
- 如图,p是待计算AO的顶点,q是p外向半球上随机选取的一点,r点是视线看往q点时和眼睛最近的一个点,如果r和q的距离小于阈值,且向量r-p和法向n夹角小于90°,则认为r点遮挡了p的环境光。这样的q点随机多采几个,取平均,就构成了p点的环境光遮蔽系数。q的选取,一方面是随机的,另一方面又需要尽量均匀分布。代码中采取了这样的方法:首先生成固定的均匀分布的向量(比如立方体8个顶点+6个面中心),然后生成一个随机向量的贴图,从而每个顶点都又一个随机的向量,最后将那些固定的均匀分布向量按照随机向量反射,反射得到了既随机又均匀分布的向量
- 首先将法向渲染到DXGI_FORMAT_R16G16B16A16_FLOAT的纹理上,将深度渲染都depth/stencil buffer上
- 然后在各个像素上计算SSAO系数,这些系数组成了SSAO贴图。一般法向渲染的分辨率和屏幕一致,但SSAO贴图可以是屏幕的一半,这样可以提高效率,且对结果影响不大,因为SSAO本来就是一个低频的光照
- 由于采样点数量有限,因此得到的SSAO贴图有较多噪声。可以对SSAO贴图做blur,这样可以降低噪声。这里blur没有使用compute shader,因为通过率已经被保存在贴图中,如果给定像素之间的距离,那么就可以计算得到邻近点的坐标,从而通过Sample的方法得到邻近点的值。如何邻近点法向和中心点法向相差太大,或者邻近点深度和中心点深度相差太大,则认为不应该blur时不应考虑邻近点。相比于compute shader,个人觉得优势在于减少graphics shader和compute shader之间的切换,缺点在于重复sample次数较多,没法通过共享内存减少sample次数
第二十二章 四元数
略
第二十三章 动画
略
其他
这本书写得非常详细,配套的代码也非常好,不过它主要讲解Direct3D 12的使用,涉及渲染方面的知识比较基础,如延迟渲染、光线追踪等内容都没有包含进来。下面会列举一些找到的其他资料:
- https://developer.nvidia.com/rtx/raytracing/dxr/dx12-raytracing-tutorial-part-1
这篇关于Frank Luna DirectX12阅读笔记:绘制的不同主题(第十五章-第二十三章)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!