欢迎光临
我们一直在努力

DX12 快速教程(13) —— 蒙皮骨骼动画

快速导航

  • 新建项目 "013-RenderGLTFAnimation"
  • 蒙皮骨骼动画
    • Pose 姿势
    • 动画帧与实时解算播放
      • 动画帧、关键帧、AnimeTPS、mDuration
      • 播放时间与当前帧换算
    • SQT 矩阵
      • Scaling 放缩变换
      • Quaternion-Rotation 四元数旋转
        • 欧拉角旋转
        • 欧拉角旋转与万向锁问题
        • 旋转矩阵与四元数
        • 四元数插值
      • Transforlation 平移
    • 关于项目代码架构
  • 第十三章全代码
    • main.cpp
    • shader.hlsl

教程代码资源: dgaf 的 《DirectX 12 快速教程》配套代码 (A Sample of dgaf’s DirectX 12 Quick Beginner Tutorial) 在这里插入图片描述

本章我们要使用 DirectX 12 + Assimp 渲染《超次元游戏:海王星》中的 Neptune 女神化形态"绀紫之心" gltf 模型,理解如何解析并加载蒙皮骨骼动画,按空格可以切换动画状态。

在这里插入图片描述

鸣谢作者大大 reizibarrientos 提供的模型,源链接: https://sketchfab.com/3d-models/purpleheartanime-7e744a429b9d4041bc43aae7939a4250


新建项目 “013-RenderGLTFAnimation”

在这里插入图片描述

蒙皮骨骼动画

在这里插入图片描述

Pose 姿势

在这里插入图片描述

我们在拍照的时候常常要"摆 Pose",摆的姿势就是骨骼动画中常说的 Pose,Pose 就是模型在某一特定时刻,其所有骨骼所处的位置状态的“快照”。

建模师为了方便绑定骨骼,通常会弄一个静止状态 (Static Pose),静止状态下的模型通常自然摊开身体,形成 T-Pose 或者 A-Pose。很多骨骼动画没有播放动画的初始状态都是 Static Pose:

在这里插入图片描述

T-Pose

在这里插入图片描述

A-Pose

动画帧与实时解算播放

在这里插入图片描述

动画帧、关键帧、AnimeTPS、mDuration

在这里插入图片描述

在 Assimp 中,每个模型动画都有独立的动画帧 (Tick),有时可多达上万帧。然而为了减小模型文件体积,模型是不会存储所有帧的,只会从其中挑出来几个帧作为关键帧 (KeyTick),存储关键帧的数据,剩余的动画帧要交给我们开发者自行处理,例如进行实时解算等等。

切记!这个动画帧 (Tick) 和我们在 DX12 上使用的渲染帧/窗口帧 (Frame) 是两个完全不同的东西!不能直接用,需要用下文的方法转换! 在这里插入图片描述

Assimp 存储模型动画,重要的成员有以下几个,读者可以通过 aiScene::HasAnimations()aiScene::mNumAnimations 来检查模型是否有骨骼动画。

const aiAnimations* anime = m_ModelScene->mAnimations[0];// 第一个骨骼动画
anime->mDuration;// 该动画总帧数 (单位: Tick)
anime->mTicksPerSecond;// 该动画每秒帧率 (TPS,单位: Ticks/s)
anime->mName;// 动画名称
anime->mNumChannels;// 该动画通道总数 (影响的骨骼节点总数,一个通道影响一个骨骼节点)
anime->mChannels;// 动画通道指针 (一个通道就是一个有动画数据的骨骼节点)

播放时间与当前帧换算

在这里插入图片描述

问题来了,既然渲染帧和动画帧不同,那怎么才能正确播放动画呢?

在这里插入图片描述

GetTickCount64 是一个 WinAPI 函数,返回一个 UINT64 整数,表示从系统启动开始到现在的时间 (单位:ms 毫秒),它的精度还可以,对我们而言足够用了。

我们需要两个 UINT64 变量:一个用来记录当前时间 CurrentTime,一个用来记录上一帧渲染的时间 RecordedTime:

UINT64 RecordedTime = 0;// 上一次计时 (以毫秒为单位),用于更新动画
UINT64 CurrentTime = 0;// 当前计时 (以毫秒为单位),用于更新动画

可是,这两个变量只能记录两帧的时间,我们需要一个新变量 AnimeTime (单位:s 秒) 来记录动画播放时间:

float AnimeTime = 0;// 动画时间 (以秒为单位)

每次切换动画 (BeginAnimationRender) 的时候,将 RecordedTime 和 CurrentTime 用当前时间设置初值,将 AnimeTime 重置为 0;

void BeginAnimationRender()
{
if (AnimationIndex != 0)// 如果不是 Static Pose,就渲染
{
// 更新上一次计时与当前计时
RecordedTime = CurrentTime = GetTickCount64();
// 清空时间
AnimeTime = 0;
}
}

每次更新渲染帧 (UpdateConstantBuffer) 的时候,更新 CurrentTime,计算当前时间与上一次记录的差值,AnimeTime 累加差值 (注意 ms -> s 的单位转换!),随后更新 RecordedTime:

void UpdateConstantBuffer()
{
if (AnimationIndex != 0)// 如果不是 Static Pose,就渲染
{
// 获取当前时间
CurrentTime = GetTickCount64();
// AnimeTime 累增,这里可以决定动画播放速度 (注意单位转换,1000 ms = 1 s)
AnimeTime += (CurrentTime RecordedTime) / 1000.0;
// 相当于 AnimeTime = AnimeTime % AnimeDuration,如果动画时间到了,就取模,让它重新播放
AnimeTime = fmod(AnimeTime, AnimeDuration);
// 更新记录时间
RecordedTime = CurrentTime;
}
}

在 BeginAnimationRender 上还可以获取动画的 TPSAnimeDuration 持续时间 (单位:s 秒):

void BeginAnimationRender()
{
if (AnimationIndex != 0)// 如果不是 Static Pose,就渲染
{
// 更新 TPS (TicksPerSecond 每秒播放的动画帧数),如果原动画没指定 TPS,就默认 1 秒 30 动画帧
AnimeTPS = m_ModelScene->mAnimations[AnimationIndex 1]->mTicksPerSecond;
AnimeTPS = AnimeTPS == 0 ? 30.0 : AnimeTPS;

// 更新持续时间,注意 Assimp 的 mDuration 指的是该动画一共有多少动画帧,不是持续多少秒
// AnimeDuration (Seconds) = mDuration (Ticks) / TPS
AnimeDuration = m_ModelScene->mAnimations[AnimationIndex 1]->mDuration / AnimeTPS;
}
}

实时解算,我们可以用下面的公式计算当前动画帧,下文也会用到:

在这里插入图片描述 在这里插入图片描述

SQT 矩阵

在这里插入图片描述 在这里插入图片描述

接下来是本章的重点:SQT 矩阵 (

S

c

a

l

i

n

g

Q

u

a

t

e

r

n

i

o

n

_

R

o

t

a

t

i

o

n

T

r

a

n

s

l

a

t

i

o

n

 

M

a

t

r

i

x

\\boldsymbol{Scaling-Quaternion\\_Rotation-Translation \\ Matrix}

ScalingQuaternion_RotationTranslation Matrix 缩放-四元数旋转-平移 复合变换矩阵),SQT 矩阵是 Assimp 骨骼变换的核心,它描述了骨骼节点在相对坐标系下将要发生的变换,以及变换后的位置

注意一下,一个骨骼可以有两种偏移矩阵: 1.骨骼节点没有被动画指定 channel,使用 Static Pose 的偏移矩阵 (aiNode::mTransformation) 2.骨骼节点被动画指定了某个 channel,这个骨骼节点需要进行 SQT 变换,使用变换后的新位置 (aiAnimation::aiNodeAnim::mPosition/Rotation/ScalingKeys) 在这里插入图片描述 在这里插入图片描述

按道理来说,骨骼动画会驱动从上到下所有骨骼节点运动,所有骨骼节点都理应有 SQT 矩阵,为什么 aiChannel 的数量总是要小于骨骼节点的数量呢?换句话说,为什么有的骨骼节点没有 SQT 矩阵?这个问题留给读者你们思考: 在这里插入图片描述

被骨骼动画直接影响的节点,会对应一个 aiChannel,每个 aiChannel 会分别给出缩放 (aiChannel::mScalingKeys)、旋转 (aiChannel::mRotationKeys)、平移 (aiChannel::mPositionKeys) 的关键帧数据。

在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

Keys 只会存储对应时间 mTime (单位: Tick) 关键帧数据 mValue,我们需要找到当前帧所处的关键帧区间,并进行插值

在这里插入图片描述

平移、缩放、旋转的关键帧数据是相互独立

在这里插入图片描述

关键帧插值

Scaling 放缩变换

在这里插入图片描述

骨骼动画是很少用到放缩变换的,因为很少有需要变大变小的需求,所以一般都只有一个放缩关键帧。

在这里插入图片描述

相关函数: XMMatrixScaling/XMMatrixScalingFromVector 从xyz放缩值/向量中构建放缩矩阵 XMVectorLerp 对两个向量进行线性插值,第三个参数是插值因子,范围

[

0

,

1

]

\\boldsymbol{[0,1]}

[0,1],越靠近 0 越接近第一个参数,越靠近 1 越接近第二个参数

Quaternion-Rotation 四元数旋转

在这里插入图片描述

欧拉角旋转

在这里插入图片描述 在这里插入图片描述

欧拉角是由 18 世纪瑞士传奇数学巨星莱昂哈德·欧拉在 1775 年的论文《刚体运动理论》(Theory of the Motion of Rigid Bodies) 中首次系统提出的。

在此之前,很多天文学家都对行星和月球运动的精确描述抓耳挠腮,争吵不停,其中一个核心问题就是三维旋转的定义

在这里插入图片描述

欧拉仅凭他强大的心算能力和超群的洞察力,发现了三维旋转的两个重要定理:

1.欧拉旋转定理:“任何三维空间中的旋转都可以表示为绕某个固定轴的单一旋转。”

在这里插入图片描述

2.欧拉角定理:“任何三维旋转都可以分解为三个连续的绕坐标轴的旋转。”

在这里插入图片描述

自此,欧拉角诞生,并统治了三维变换领域 200 多年,直到上世纪 80 年代才被踢下来。它的影响力一直延续至今,哪怕到现在,四元数在某些领域也有一战之力。

有趣的是,欧拉本人从未称其为"欧拉角"!这个术语是后世数学家为了纪念他而命名的。在他自己的著作中,他称其为"三个旋转角度"。

更多有趣故事:https://icegl.cn/ask/article/30.html

欧拉角旋转与万向锁问题

在这里插入图片描述

欧拉角旋转非常简单:三次连续的绕轴旋转,这三个轴可以是任意轴,哪怕不垂直也行,但是人们为了方便,还是会用三个轴相互垂直的笛卡尔坐标系

在这里插入图片描述

三次旋转的顺序会影响结果,旋转后,原来互相垂直的轴可能不再垂直,当前步骤只能影响下一个旋转步骤,不能影响之前的旋转步骤,很多欧拉角旋转 (例如 Unity 的实现) 都是 y -> x -> z。

这种旋转会导致什么问题?如果前两次旋转中的某一次旋转 (旋转 90°) 让某一个轴和另一个轴重合了,就会丢失一个旋转自由度,引发万向锁问题 (Gimbal Lock):

在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

上面三个都是丢失一个自由度之后,其余两个旋转都会在同一平面进行

为什么会这样呢?我们用矩阵来推导:

在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

在这里插入图片描述

万向锁其实在生活中很常见,并且非常有用。欧拉角最贴近生活并且具有使用价值的体现是万向节,为什么呢?

机载相机在拍摄时,相机可以放在稳定器上,无论怎么移动稳定器,相机永远对着屏幕的方向。稳定器使用的就是万向节技术,通过三个自由度的旋转保证它的朝向。包括大家喜欢玩的无人机,无人机在飞行时存在很多抖动,但画面总是非常稳定,其实也是使用万向节的技术。

在这里插入图片描述

此外还有一个非常重要的应用是陀螺仪,在没有 GPS 导航的时代,如何保证射击出去的导弹不偏航呢?实际上在导弹中有一个高速旋转的陀螺,陀螺因为它的惯性原理,会始终指向一个方向。在导弹飞行过程中通过测量陀螺仪的角度变化,结合速度等积分计算出导弹的轨迹。虽然在这个时候的误差还比较大,可能飞行 1000 公里存在着几百米的误差,但已经非常了不起了。

在这里插入图片描述

请读者们思考一下,我们的摄像机 Camera 用到的也是欧拉角,为什么没有出现万向锁问题呢? 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

旋转矩阵与四元数

在这里插入图片描述

你们觉得旋转矩阵这玩意可以插值吗?

答案:可以插值!而且能用的方法有足足四种!

1. 线性插值 + 正交化 (最简单的方法,但有严重缺陷,需要校正,不推荐)

在这里插入图片描述

2. 极分解 (计算量较大,实现复杂,不推荐)

在这里插入图片描述

3. 李群映射插值 (计算复杂,精度误差大,不推荐)

在这里插入图片描述

4. SVD 分解 (SVD 计算很慢,不稳定,不推荐)

在这里插入图片描述

看懂了吗?无论是欧拉角还是直接对旋转矩阵插值,或多或少都有不小的缺陷:计算复杂度更高、实现更复杂、数值稳定性更差、不保证最短路径、内存和计算开销更大。

要想完美解决旋转插值问题,需要用到四元数 (Quaternion):

在这里插入图片描述

威廉·罗曼·哈密顿

1843年,英国数学家、物理学家、力学家威廉·罗曼·哈密顿 (William Rowan Hamilton 1805~1865,他在英国本土的地位仅次于牛顿) 在对复数长期研究的基础上,正式提出了四元数 (quaternion):

在这里插入图片描述

四元数是一种复数的拓展,是一种简单的超复数。四元数的出现撼动了数学界,一是因为它的虚部是由三个分量组成:

i

,

j

,

k

\\boldsymbol{i,j,k}

i,j,k;二是破坏了代数的交换性,四元数不满足乘法交换律,在此之前,交换性在数学家的眼中是代数的固有性质,而这样的非交换代数的横空出世冲垮了许多人的心理防线。许多人诧异这样的数是否有实际应用场景 (数学王子高斯曾经也致力于研究一种不符合结合律的"三维复数"体系,但是因为这种体系不符合实际需要翻车了):

在这里插入图片描述

但哈密顿不简单,这一位物法双修的人才提出不久,就能给四元数找补:

在物理模拟中,四元数可以用于描述刚体的旋转运动和流体中物体的旋转行为。弄出了一个广泛用于物理学的哈密顿算子

在这里插入图片描述

更多关于哈密顿的有趣故事: 数学中的一朵“奇葩”——四元数:https://baijiahao.baidu.com/s?id=1755546044182417529 数学中的四元数:https://zhuanlan.zhihu.com/p/1904842329250051526

然而关于四元数的实用性争议仍然不息,直到 200 多年后,传奇 3D 游戏教父约翰·卡马克才把四元数引入图形学中,自此焕发新生:

在这里插入图片描述

约翰·卡马克

总之,四元数是一个非常有用的数学工具,它的一般形式是:

在这里插入图片描述

在图形学中,它是这样表示的:

在这里插入图片描述

四元数和普通复数一样,拥有加法、减法、乘法,求逆 (特殊除法),但唯独没有乘法交换律 (和矩阵乘法一样!矩阵是不是也和四元数一样也有共同的性质呢?)

在这里插入图片描述 在这里插入图片描述

冷知识:四元数乘法的结果,去掉实数部分

w

\\boldsymbol{w}

w,其他部分其实是三维向量的叉乘

这么奇怪的四元数,卡马克到底相中了四元数的哪个地方,才把它引入到图形学中呢?

答案:四元数本身就可以平滑地表示任意一个三维旋转,而且不存在万向锁问题:

在这里插入图片描述

你有可能会好奇,四元数有四个量,是怎么表示三维变换的?

注意,好戏开始了,别眨眼!不妨我们先从复数表示二维旋转开始:

在这里插入图片描述

第五章我们可以知道圆上任意一点都可以写成

(

cos

x

,

sin

x

)

\\boldsymbol{(\\cos x, \\sin x)}

(cosx,sinx) 的坐标形式,平面复数也不例外,横轴作实数轴,纵轴作虚数轴,下面这个就是复数的轴角表达式 (极坐标表达式),最早也是由欧拉发现的:

在这里插入图片描述

有了轴角表达式,我们就能清楚四元数到底要旋转多少度了。设四元数的虚数部分对应三维空间一个旋转轴,而且这个旋转轴还是单位向量

在这里插入图片描述

在这里插入图片描述

然而,这个表达式真的能转到

θ

\\boldsymbol{\\theta}

θ 角吗?我们不妨再弄一个超空间:

x

,

y

\\boldsymbol{x,y}

x,y 轴构成了一个平面,这个平面代表了物体所处的三维空间

z

\\boldsymbol{z}

z 轴表示四维空间的

w

\\boldsymbol{w}

w。如你所见,这个超空间只是四维空间到三维空间的一个投影:

在这里插入图片描述

图上

q

\\boldsymbol{q}

q 旋转了

θ

2

\\boldsymbol{\\frac{\\theta}{2}}

2θ 的角度,竟然把它拉出了三维空间!真是不可思议,向量在四维空间上旋转,其实同时发生了放缩旋转变换。由于模长发生了改变 (不再是 1 了,三维空间上的旋转变换是不能改变模长的),物体现在处于棍母状态,我们只能感知到它的存在,但不能看到它。我们需要再乘一个

q

1

\\boldsymbol{q^{-1}}

q1,才能把它拉回去:

在这里插入图片描述

也就是说,一次旋转

θ

\\boldsymbol{\\theta}

θ 角度的单位四元数,虽然数值上填的是

θ

2

\\boldsymbol{\\frac{\\theta}{2}}

2θ,但它实际上同时执行了两次旋转:

q

\\boldsymbol{q}

q 旋转了

θ

2

\\boldsymbol{\\frac{\\theta}{2}}

2θ (把物体拉出了三维空间),共轭四元数

q

\\boldsymbol{-q}

q 也旋转了

θ

2

\\boldsymbol{\\frac{\\theta}{2}}

2θ (把物体拉回了三维空间)。

完整的四元数旋转,犹如《空之轨迹 3rd》中的“金之道、银之道” —— 两条看似分离的道路,实则为同一场试炼的一体两面。金之道,如同四元数

q

\\boldsymbol{q}

q 本身,将物体从三维空间提升至四维的“第三星层”;银之道,则对应其共轭四元数

q

\\boldsymbol{-q}

q,在完成旋转后,将物体重新“投影”回三维现实。二者各自旋转

θ

2

\\boldsymbol{\\frac{\\theta}{2}}

2θ ,一“升”一“降”,一“出”一“回”,在四维空间中交织成一条完整的螺旋轨迹。最终,物体在三维世界中只呈现为一个干净利落的旋转

θ

\\boldsymbol{\\theta}

θ —— 看似一步到位,实则经历了一场跨越维度的“双翼之旅”:

在这里插入图片描述

《空之轨迹 3rd》金之道、银之道,这里主角一行人分兵同时前进

所以,四元数真正对物体旋转的式子是

V

=

q

V

q

1

\\boldsymbol{V' = q V q^{-1}}

V=qVq1,旋转

θ

\\boldsymbol{\\theta}

θ 真正的轴角表达式是:

在这里插入图片描述

有了轴角表达式,我们就能很方便地表示四元数了。可是我们在骨骼混合的时候还是要用旋转矩阵。我们需要四元数转换成旋转矩阵。我们搬出第五章用过的圆盘模型:

在这里插入图片描述

我们把上面那个

v

\\boldsymbol{\\vec v}

v

的式子整理一下,就可以得到著名的罗德里格旋转公式

在这里插入图片描述

我们把四元数带进去,就能得到旋转矩阵了:

在这里插入图片描述 在这里插入图片描述

四元数插值

有了旋转矩阵就好办了,四元数有两种插值:线性插值球面线性插值:

线性插值只会在旋转角度很小的时候 (小于 1°) 才会用到,此时两种插值结果相差不大,线性插值更快,线性插值套用的是向量的插值公式

在这里插入图片描述 在这里插入图片描述

然而,线性插值意味着角速度随时会发生改变 (还会改变模长),角度一大就会偏离正确结果。我们需要一个恒定角速度的插值,所以要用到球面线性插值:

在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

进行球面线性插值时,很多线性代数库实现的函数都会自动选择线性插值球面线性插值。

Assimp 给定的四元数,相当于骨骼节点在相对坐标系下旋转后的状态,我们进行球面线性插值后,将四元数单位化再转换成旋转矩阵。注意这里一定要先单位化四元数!否则会失效:

在这里插入图片描述

相关函数: XMQuaternionSlerp 对四元数进行球面线性插值 (XMVECTOR 也可以存储四元数) XMQuaternionNormalize 对四元数进行单位化 XMMatrixRotationQuaternion 将四元数转换成对应的旋转矩阵

Transforlation 平移

在这里插入图片描述

缩放 -> 旋转 ->平移 (注意!顺序不能调换!),我们将这三个矩阵依次累乘,就能得到用于骨骼动画的复合变换矩阵了:

在这里插入图片描述 在这里插入图片描述

关于项目代码架构

在这里插入图片描述

在这里插入图片描述

我简单说明一下我的代码架构,这里我就不再对项目代码详解了,相信各位读者经过这五章的学习,对 Assimp 的用法已经了然于胸了,你们早就可以看出我项目的各种不足与缺陷,相信你们能写的更好,加油!

在这里插入图片描述

我们把存储变换矩阵的 TransformGroup 分成用途不同的两组,一组 BoneNode_TransformGroup 用于 Static Pose,另一组 BoneNode_Animation_TransformGroup 用于骨骼动画。

也许你会问,不能直接复用 Static Pose 的矩阵到动画组吗?我这边的架构是不允许的,因为 BoneNode_TransformGroup 已经存储的是 Static Pose 下最终状态的矩阵,而骨骼动画会影响不定数量的骨骼节点,新的 SQT 变换会破坏原来整个骨骼树的状态,必须重新计算。所以分成两组做不同用途,而且每帧 BoneNode_Animation_TransformGroup 都是要重新算的。

之所以第 11 章 (引用小字部分) 要注意 Mesh_To_Bone 的映射键值对,是因为 BoneNode_IndexGroup 是两组共用的,在计算 Static Pose 的时候就已经把所有可能的键值对算好了 (详情请看 CalcModelNodeMatrix 函数),输入到 GPU 的顶点,它们对应的骨骼索引固定了。所以每帧计算动画组,都要把新的矩阵一一对应,按键值对重新算上去,这样才能正确加载到骨骼动画。

在这里插入图片描述

上图就是我们主要新增的逻辑。我们设置了两种状态,在窗口回调函数中处理字符消息,检测到空格就切换到动画状态,触发状态切换后 CallBackFuncUpdateConstantBuffer 相关联的函数根据选择的状态,依次执行函数。具体的代码逻辑在项目代码中,读者们也可以做更好的魔改。


第十三章全代码

main.cpp

// (13) RenderGLTFAnimation: 使用 DirectX 12 + Assimp 渲染《超次元游戏 海王星》中的 Neptune 涅普顿(绀紫之心) GLTF模型 的骨骼动画
// 鸣谢原作者大大: reizibarrientos (https://sketchfab.com/reizibarrientos)
// 模型项目地址: https://sketchfab.com/3d-models/purpleheartanime-7e744a429b9d4041bc43aae7939a4250

// windows.h 与标准库里的 min/max 函数重名导致冲突了,禁用 windows.h 里面的 min/max 函数
#define NOMINMAX
// C++ 17 开始把 std::codecvt_utf8 给取消了,直接使用会报错 (和 scanf 一样),加这个宏可以绕过报错,继续使用
#define _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING

#include<Windows.h>// Windows 窗口编程核心头文件
#include<d3d12.h>// DX12 核心头文件
#include<dxgi1_6.h>// DXGI 头文件,用于管理与 DX12 相关联的其他必要设备,如 DXGI 工厂和 交换链
#include<DirectXColors.h>// DirectX 颜色库
#include<DirectXMath.h>// DirectX 数学库
#include<d3dcompiler.h>// DirectX Shader 着色器编译库
#include<wincodec.h>// WIC 图像处理框架,用于解码编码转换图片文件

#include<wrl.h>// COM 组件模板库,方便写 DX12 和 DXGI 相关的接口
#include<string>// C++ 标准 string 库
#include<sstream>// C++ 字符串流处理库
#include<functional>// C++ 标准函数对象库,用于下文的 std::function 函数包装器与 std::bind 绑定回调函数
#include<fstream>// C++ 文件流处理库
#include<vector>// C++ STL vector 容器库
#include<codecvt>// C++ 字符编码转换库,用于 string 转 wstring

#include<assimp/Importer.hpp>// Assimp Importer 模型导入器,用于导入模型,读取模型数据
#include<assimp/postprocess.h>// PostProcess 后处理,提供多种标志 (aiProcess_xxx),用于改善模型的导入质量与性能
#include<assimp/scene.h>// Scene 核心组件库,用于存储与管理导入的 3D 模型的所有数据

#pragma comment(lib,"d3d12.lib")// 链接 DX12 核心 DLL
#pragma comment(lib,"dxgi.lib")// 链接 DXGI DLL
#pragma comment(lib,"dxguid.lib")// 链接 DXGI 必要的设备 GUID
#pragma comment(lib,"d3dcompiler.lib")// 链接 DX12 需要的着色器编译 DLL
#pragma comment(lib,"windowscodecs.lib")// 链接 WIC DLL
#pragma comment(lib,"assimp-vc143-mtd.lib")// 链接 Assimp DLL

using namespace Microsoft;
using namespace Microsoft::WRL;// 使用 wrl.h 里面的命名空间,我们需要用到里面的 Microsoft::WRL::ComPtr COM智能指针
using namespace DirectX;// DirectX 命名空间

// 1.项目 -> 属性 -> VC++ 目录 -> 包含目录 -> 添加 Assimp/include (或者写 $(SolutionDir)Assimp/include )
// 2.项目 -> 属性 -> VC++ 目录 -> 库目录 -> 添加 Assimp/lib (或者写 $(SolutionDir)Assimp/lib )
// 3.将 Assimp/lib 里面的 assimp-vc143-mtd.lib 与 assimp-vc143-mtd.dll 复制到
// exe 所在的目录下 (x64/Debug 文件夹),生成的 exe 需要依赖这两个链接库

// —————————————————————————————————————

// 命名空间 DX12TextureHelper 包含了帮助我们转换纹理图片格式的结构体与函数
namespace DX12TextureHelper
{
// 纹理转换用,不是 DX12 所支持的格式,DX12 没法用

// Standard GUID -> DXGI 格式转换结构体
struct WICTranslate
{
GUID wic;
DXGI_FORMAT format;
};

// WIC 格式与 DXGI 像素格式的对应表,该表中的格式为被支持的格式
static WICTranslate g_WICFormats[] =
{
{ GUID_WICPixelFormat128bppRGBAFloat, DXGI_FORMAT_R32G32B32A32_FLOAT },
{ GUID_WICPixelFormat64bppRGBAHalf, DXGI_FORMAT_R16G16B16A16_FLOAT },
{ GUID_WICPixelFormat64bppRGBA, DXGI_FORMAT_R16G16B16A16_UNORM },
{ GUID_WICPixelFormat32bppRGBA, DXGI_FORMAT_R8G8B8A8_UNORM },
{ GUID_WICPixelFormat32bppBGRA, DXGI_FORMAT_B8G8R8A8_UNORM },
{ GUID_WICPixelFormat32bppBGR, DXGI_FORMAT_B8G8R8X8_UNORM },
{ GUID_WICPixelFormat32bppRGBA1010102XR, DXGI_FORMAT_R10G10B10_XR_BIAS_A2_UNORM },
{ GUID_WICPixelFormat32bppRGBA1010102, DXGI_FORMAT_R10G10B10A2_UNORM },
{ GUID_WICPixelFormat16bppBGRA5551, DXGI_FORMAT_B5G5R5A1_UNORM },
{ GUID_WICPixelFormat16bppBGR565, DXGI_FORMAT_B5G6R5_UNORM },
{ GUID_WICPixelFormat32bppGrayFloat, DXGI_FORMAT_R32_FLOAT },
{ GUID_WICPixelFormat16bppGrayHalf, DXGI_FORMAT_R16_FLOAT },
{ GUID_WICPixelFormat16bppGray, DXGI_FORMAT_R16_UNORM },
{ GUID_WICPixelFormat8bppGray, DXGI_FORMAT_R8_UNORM },
{ GUID_WICPixelFormat8bppAlpha, DXGI_FORMAT_A8_UNORM }
};

// GUID -> Standard GUID 格式转换结构体
struct WICConvert
{
GUID source;
GUID target;
};

// WIC 像素格式转换表
static WICConvert g_WICConvert[] =
{
// 目标格式一定是最接近的被支持的格式
{ GUID_WICPixelFormatBlackWhite, GUID_WICPixelFormat8bppGray },// DXGI_FORMAT_R8_UNORM
{ GUID_WICPixelFormat1bppIndexed, GUID_WICPixelFormat32bppRGBA },// DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat2bppIndexed, GUID_WICPixelFormat32bppRGBA },// DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat4bppIndexed, GUID_WICPixelFormat32bppRGBA },// DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat8bppIndexed, GUID_WICPixelFormat32bppRGBA },// DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat2bppGray, GUID_WICPixelFormat8bppGray },// DXGI_FORMAT_R8_UNORM
{ GUID_WICPixelFormat4bppGray, GUID_WICPixelFormat8bppGray },// DXGI_FORMAT_R8_UNORM
{ GUID_WICPixelFormat16bppGrayFixedPoint, GUID_WICPixelFormat16bppGrayHalf },// DXGI_FORMAT_R16_FLOAT
{ GUID_WICPixelFormat32bppGrayFixedPoint, GUID_WICPixelFormat32bppGrayFloat },// DXGI_FORMAT_R32_FLOAT
{ GUID_WICPixelFormat16bppBGR555, GUID_WICPixelFormat16bppBGRA5551 },// DXGI_FORMAT_B5G5R5A1_UNORM
{ GUID_WICPixelFormat32bppBGR101010, GUID_WICPixelFormat32bppRGBA1010102 },// DXGI_FORMAT_R10G10B10A2_UNORM
{ GUID_WICPixelFormat24bppBGR, GUID_WICPixelFormat32bppRGBA },// DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat24bppRGB, GUID_WICPixelFormat32bppRGBA },// DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat32bppPBGRA, GUID_WICPixelFormat32bppRGBA },// DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat32bppPRGBA, GUID_WICPixelFormat32bppRGBA },// DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat48bppRGB, GUID_WICPixelFormat64bppRGBA },// DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat48bppBGR, GUID_WICPixelFormat64bppRGBA },// DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat64bppBGRA, GUID_WICPixelFormat64bppRGBA },// DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat64bppPRGBA, GUID_WICPixelFormat64bppRGBA },// DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat64bppPBGRA, GUID_WICPixelFormat64bppRGBA },// DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat48bppRGBFixedPoint, GUID_WICPixelFormat64bppRGBAHalf },// DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat48bppBGRFixedPoint, GUID_WICPixelFormat64bppRGBAHalf },// DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat64bppRGBAFixedPoint, GUID_WICPixelFormat64bppRGBAHalf },// DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat64bppBGRAFixedPoint, GUID_WICPixelFormat64bppRGBAHalf },// DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat64bppRGBFixedPoint, GUID_WICPixelFormat64bppRGBAHalf },// DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat48bppRGBHalf, GUID_WICPixelFormat64bppRGBAHalf },// DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat64bppRGBHalf, GUID_WICPixelFormat64bppRGBAHalf },// DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat128bppPRGBAFloat, GUID_WICPixelFormat128bppRGBAFloat },// DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat128bppRGBFloat, GUID_WICPixelFormat128bppRGBAFloat },// DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat128bppRGBAFixedPoint, GUID_WICPixelFormat128bppRGBAFloat },// DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat128bppRGBFixedPoint, GUID_WICPixelFormat128bppRGBAFloat },// DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat32bppRGBE, GUID_WICPixelFormat128bppRGBAFloat },// DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat32bppCMYK, GUID_WICPixelFormat32bppRGBA },// DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat64bppCMYK, GUID_WICPixelFormat64bppRGBA },// DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat40bppCMYKAlpha, GUID_WICPixelFormat64bppRGBA },// DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat80bppCMYKAlpha, GUID_WICPixelFormat64bppRGBA },// DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat32bppRGB, GUID_WICPixelFormat32bppRGBA },// DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat64bppRGB, GUID_WICPixelFormat64bppRGBA },// DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat64bppPRGBAHalf, GUID_WICPixelFormat64bppRGBAHalf },// DXGI_FORMAT_R16G16B16A16_FLOAT

{ GUID_WICPixelFormat128bppRGBAFloat, GUID_WICPixelFormat128bppRGBAFloat },// DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat64bppRGBAHalf, GUID_WICPixelFormat64bppRGBAHalf },// DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat64bppRGBA, GUID_WICPixelFormat64bppRGBA },// DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat32bppRGBA, GUID_WICPixelFormat32bppRGBA },// DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat32bppBGRA, GUID_WICPixelFormat32bppBGRA },// DXGI_FORMAT_B8G8R8A8_UNORM
{ GUID_WICPixelFormat32bppBGR, GUID_WICPixelFormat32bppBGR },// DXGI_FORMAT_B8G8R8X8_UNORM
{ GUID_WICPixelFormat32bppRGBA1010102XR, GUID_WICPixelFormat32bppRGBA1010102XR },// DXGI_FORMAT_R10G10B10_XR_BIAS_A2_UNORM
{ GUID_WICPixelFormat32bppRGBA1010102, GUID_WICPixelFormat32bppRGBA1010102 },// DXGI_FORMAT_R10G10B10A2_UNORM
{ GUID_WICPixelFormat16bppBGRA5551, GUID_WICPixelFormat16bppBGRA5551 },// DXGI_FORMAT_B5G5R5A1_UNORM
{ GUID_WICPixelFormat16bppBGR565, GUID_WICPixelFormat16bppBGR565 },// DXGI_FORMAT_B5G6R5_UNORM
{ GUID_WICPixelFormat32bppGrayFloat, GUID_WICPixelFormat32bppGrayFloat },// DXGI_FORMAT_R32_FLOAT
{ GUID_WICPixelFormat16bppGrayHalf, GUID_WICPixelFormat16bppGrayHalf },// DXGI_FORMAT_R16_FLOAT
{ GUID_WICPixelFormat16bppGray, GUID_WICPixelFormat16bppGray },// DXGI_FORMAT_R16_UNORM
{ GUID_WICPixelFormat8bppGray, GUID_WICPixelFormat8bppGray },// DXGI_FORMAT_R8_UNORM
{ GUID_WICPixelFormat8bppAlpha, GUID_WICPixelFormat8bppAlpha }// DXGI_FORMAT_A8_UNORM
};

// 查表确定兼容的最接近格式是哪个
bool GetTargetPixelFormat(const GUID* pSourceFormat, GUID* pTargetFormat)
{
*pTargetFormat = *pSourceFormat;
for (size_t i = 0; i < _countof(g_WICConvert); ++i)
{
if (InlineIsEqualGUID(g_WICConvert[i].source, *pSourceFormat))
{
*pTargetFormat = g_WICConvert[i].target;
return true;
}
}
return false;// 找不到,就返回 false
}

// 查表确定最终对应的 DXGI 格式是哪一个
DXGI_FORMAT GetDXGIFormatFromPixelFormat(const GUID* pPixelFormat)
{
for (size_t i = 0; i < _countof(g_WICFormats); ++i)
{
if (InlineIsEqualGUID(g_WICFormats[i].wic, *pPixelFormat))
{
return g_WICFormats[i].format;
}
}
return DXGI_FORMAT_UNKNOWN;// 找不到,就返回 UNKNOWN
}
}

// 用于绑定回调函数的中间层
class CallBackWrapper
{
public:

// 用于保存 DX12Engine 类的成员回调函数的包装器
inline static std::function<LRESULT(HWND, UINT, WPARAM, LPARAM)> Broker_Func;

// 用于传递到 lpfnWndProc 的静态成员函数,内部调用保存 DX12Engine::CallBackFunc 的函数包装器
// 静态成员函数属于类,不属于类实例对象,所以没有 this 指针,可以直接赋值给 C-Style 的函数指针
static LRESULT CALLBACK CallBackFunc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
return Broker_Func(hwnd, msg, wParam, lParam);
}

};

// 摄像机类
class Camera
{
private:

XMVECTOR EyePosition = XMVectorSet(1, 1, 1, 1);// 摄像机在世界空间下的位置
XMVECTOR FocusPosition = XMVectorSet(0, 0, 0, 1);// 摄像机在世界空间下观察的焦点位置
XMVECTOR UpDirection = XMVectorSet(0, 1, 0, 0);// 世界空间垂直向上的向量

// 摄像机观察方向的单位向量,用于前后移动
XMVECTOR ViewDirection = XMVector3Normalize(FocusPosition EyePosition);

// 焦距,摄像机原点与焦点的距离,XMVector3Length 表示对向量取模
float FocalLength = XMVectorGetX(XMVector3Length(FocusPosition EyePosition));

// 摄像机向右方向的单位向量,用于左右移动,XMVector3Cross 求两向量叉乘
// 注意叉乘不符合交换律,交换后结果方向相反,如果左右移动方向反了,可能需要检查一下叉乘
XMVECTOR RightDirection = XMVector3Normalize(XMVector3Cross(UpDirection, ViewDirection));

POINT LastCursorPoint = {};// 上一次鼠标的位置

float FovAngleY = XM_PIDIV4;// 垂直视场角
float AspectRatio = 16.0 / 9.0;// 投影窗口宽高比
float NearZ = 0.1;// 近平面到原点的距离
float FarZ = 1000;// 远平面到原点的距离

XMMATRIX ModelMatrix;// 模型矩阵,模型空间 -> 世界空间
XMMATRIX ViewMatrix;// 观察矩阵,世界空间 -> 观察空间
XMMATRIX ProjectionMatrix;// 投影矩阵,观察空间 -> 齐次裁剪空间

XMMATRIX MVPMatrix;// MVP 矩阵,类外需要用公有方法 GetMVPMatrix 获取

public:

Camera()// 摄像机的构造函数
{
// 模型矩阵,这里设置成单位矩阵,是因为模型导入的时候已经是 y 轴朝上的了,无需再进行旋转
ModelMatrix = XMMatrixIdentity();
// 观察矩阵,注意前两个参数是点,第三个参数才是向量
ViewMatrix = XMMatrixLookAtLH(EyePosition, FocusPosition, UpDirection);
// 投影矩阵 (注意近平面和远平面距离不能 <= 0!)
ProjectionMatrix = XMMatrixPerspectiveFovLH(FovAngleY, AspectRatio, NearZ, FarZ);
}

// 摄像机前后移动,参数 Stride 是移动速度 (步长),正数向前移动,负数向后移动
void Walk(float Stride)
{
EyePosition += Stride * ViewDirection;
FocusPosition += Stride * ViewDirection;
}

// 摄像机左右移动,参数 Stride 是移动速度 (步长),正数向右移动,负数向左移动
void Strafe(float Stride)
{
EyePosition += Stride * RightDirection;
FocusPosition += Stride * RightDirection;
}

// 鼠标在屏幕空间 y 轴上移动,相当于摄像机以向右的向量 RightDirection 向上向下旋转,人眼往上下看
void RotateByY(float angleY)
{
// 以向右向量为轴构建旋转矩阵,旋转 ViewDirection 和 UpDirection
XMMATRIX R = XMMatrixRotationAxis(RightDirection, angleY);

UpDirection = XMVector3TransformNormal(UpDirection, R);
ViewDirection = XMVector3TransformNormal(ViewDirection, R);

// 利用 ViewDirection 观察向量、FocalLength 焦距,更新焦点位置
FocusPosition = EyePosition + ViewDirection * FocalLength;
}

// 鼠标在屏幕空间 x 轴上移动,相当于摄像机绕世界空间的 y 轴向左向右旋转,人眼往左右看
void RotateByX(float angleX)
{
// 以世界坐标系下的 y 轴 (0,1,0,0) 构建旋转矩阵,三个向量 ViewDirection, UpDirection, RightDirection 都要旋转
XMMATRIX R = XMMatrixRotationY(angleX);

UpDirection = XMVector3TransformNormal(UpDirection, R);
ViewDirection = XMVector3TransformNormal(ViewDirection, R);
RightDirection = XMVector3TransformNormal(RightDirection, R);

// 利用 ViewDirection 观察向量、FocalLength 焦距,更新焦点位置
FocusPosition = EyePosition + ViewDirection * FocalLength;
}

// 更新上一次的鼠标位置
void UpdateLastCursorPos()
{
GetCursorPos(&LastCursorPoint);
}

// 当鼠标左键长按并移动时,旋转摄像机视角
void CameraRotate()
{
POINT CurrentCursorPoint = {};
GetCursorPos(&CurrentCursorPoint);// 获取当前鼠标位置

// 根据鼠标在屏幕坐标系的 x,y 轴的偏移量,计算摄像机旋转角
float AngleX = XMConvertToRadians(0.25 * static_cast<float>(CurrentCursorPoint.x LastCursorPoint.x));
float AngleY = XMConvertToRadians(0.25 * static_cast<float>(CurrentCursorPoint.y LastCursorPoint.y));

// 旋转摄像机
RotateByY(AngleY);
RotateByX(AngleX);

UpdateLastCursorPos();// 旋转完毕,更新上一次的鼠标位置
}

// 更新 MVP 矩阵
void UpdateMVPMatrix()
{
// 主要是更新观察矩阵
ViewMatrix = XMMatrixLookAtLH(EyePosition, FocusPosition, UpDirection);
MVPMatrix = ModelMatrix * ViewMatrix * ProjectionMatrix;
}

// 获取 MVP 矩阵
inline XMMATRIX& GetMVPMatrix()
{
// 每次返回前,都更新一次
UpdateMVPMatrix();
return MVPMatrix;
}

// 设置摄像机位置
inline void SetEyePosition(XMVECTOR pos)
{
EyePosition = pos;

// 改变位置后,观察向量、焦距、右方向向量也要改变,否则会发生视角瞬移
ViewDirection = XMVector3Normalize(FocusPosition EyePosition);
FocalLength = XMVectorGetX(XMVector3Length(FocusPosition EyePosition));
RightDirection = XMVector3Normalize(XMVector3Cross(UpDirection, ViewDirection));
}

// 设置摄像机焦点
inline void SetFocusPosition(XMVECTOR pos)
{
FocusPosition = pos;

// 改变位置后,观察向量、焦距、右方向向量也要改变,否则会发生视角瞬移
ViewDirection = XMVector3Normalize(FocusPosition EyePosition);
FocalLength = XMVectorGetX(XMVector3Length(FocusPosition EyePosition));
RightDirection = XMVector3Normalize(XMVector3Cross(UpDirection, ViewDirection));
}

// 设置摄像机的模型矩阵
inline void SetModelMatrix(XMMATRIX ModelMatrix)
{
this->ModelMatrix = ModelMatrix;
}
};

// —————————————————————————————————————

// DX12 引擎
class DX12Engine
{
private:

int WindowWidth = 1280;// 窗口宽度
int WindowHeight = 720;// 窗口高度
HWND m_hwnd;// 窗口句柄

ComPtr<ID3D12Debug> m_D3D12DebugDevice;// D3D12 调试层设备
UINT m_DXGICreateFactoryFlag = NULL;// 创建 DXGI 工厂时需要用到的标志

ComPtr<IDXGIFactory5> m_DXGIFactory;// DXGI 工厂
ComPtr<IDXGIAdapter1> m_DXGIAdapter;// 显示适配器 (显卡)
ComPtr<ID3D12Device4> m_D3D12Device;// D3D12 核心设备

ComPtr<ID3D12CommandQueue> m_CommandQueue;// 命令队列
ComPtr<ID3D12CommandAllocator> m_CommandAllocator;// 命令分配器
ComPtr<ID3D12GraphicsCommandList> m_CommandList;// 命令列表

ComPtr<IDXGISwapChain3> m_DXGISwapChain;// DXGI 交换链
ComPtr<ID3D12DescriptorHeap> m_RTVHeap;// RTV 描述符堆
ComPtr<ID3D12Resource> m_RenderTarget[3];// 渲染目标数组,每一副渲染目标对应一个窗口缓冲区
D3D12_CPU_DESCRIPTOR_HANDLE RTVHandle;// RTV 描述符句柄
UINT RTVDescriptorSize = 0;// RTV 描述符的大小
UINT FrameIndex = 0;// 帧索引,表示当前渲染的第 i 帧 (第 i 个渲染目标)

ComPtr<ID3D12Fence> m_Fence;// 围栏
UINT64 FenceValue = 0;// 用于围栏等待的围栏值
HANDLE RenderEvent = NULL;// GPU 渲染事件
D3D12_RESOURCE_BARRIER beg_barrier = {};// 渲染开始的资源屏障,呈现 -> 渲染目标
D3D12_RESOURCE_BARRIER end_barrier = {};// 渲染结束的资源屏障,渲染目标 -> 呈现

ComPtr<ID3D12DescriptorHeap> m_DSVHeap;// DSV 描述符堆
D3D12_CPU_DESCRIPTOR_HANDLE DSVHandle;// DSV 描述符句柄
ComPtr<ID3D12Resource> m_DepthStencilBuffer;// DSV 深度模板缓冲资源

// DSV 资源的格式
// 深度模板缓冲只支持四种格式:
// DXGI_FORMAT_D24_UNORM_S8_UINT(每个像素占用四个字节 32 位,24 位无符号归一化浮点数留作深度值,8 位整数留作模板值)
// DXGI_FORMAT_D32_FLOAT_S8X24_UINT(每个像素占用八个字节 64 位,32 位浮点数留作深度值,8 位整数留作模板值,其余 24 位保留不使用)
// DXGI_FORMAT_D16_UNORM(每个像素占用两个字节 16 位,16 位无符号归一化浮点数留作深度值,范围 [0,1],不使用模板)
// DXGI_FORMAT_D32_FLOAT(每个像素占用四个字节 32 位,32 位浮点数留作深度值,不使用模板)
// 这里我们选择最常用的格式 DXGI_FORMAT_D24_UNORM_S8_UINT
DXGI_FORMAT DSVFormat = DXGI_FORMAT_D24_UNORM_S8_UINT;

// —————————————————————————————————————

std::string ModelFileName = "purpleheartanime/scene.gltf";// 模型文件名
std::string ModelTextureFilePath = "purpleheartanime/";// 模型所在文件夹

Assimp::Importer* m_ModelImporter = new Assimp::Importer;// 模型导入器
const aiScene* m_ModelScene = nullptr;// 模型/场景对象

// 导入模型使用的标志
// aiProcess_ConvertToLeftHanded: Assimp 导入的模型是以 OpenGL 的右手坐标系为基础的,将模型转换成 DirectX 的左手坐标系
// aiProcess_Triangulate:模型设计师可能使用多边形对模型进行建模的,对于用多边形建模的模型,将它们都转换成基于三角形建模
// aiProcess_FixInfacingNormals:建模软件都是双面显示的,所以设计师不会在意顶点绕序方向,部分面会被剔除无法正常显示,需要翻转过来
// aiProcess_LimitBoneWeights: 限制网格的骨骼权重最多为 4 个,其余权重无需处理
// aiProcess_GenBoundBoxes: 对每个网格,都生成一个 AABB 体积盒
// aiProcess_JoinIdenticalVertices: 将位置相同的顶点合并为一个顶点,从而减少模型的顶点数量,优化内存使用和提升渲染效率。
UINT ModelImportFlag = aiProcess_ConvertToLeftHanded | aiProcess_Triangulate |
aiProcess_FixInfacingNormals | aiProcess_LimitBoneWeights |
aiProcess_GenBoundingBoxes | aiProcess_JoinIdenticalVertices;

// 材质/纹理结构体
struct Material
{
std::string FilePath;// 材质文件路径
aiTextureType Type;// 材质类型
ComPtr<ID3D12Resource> UploadTexture;// 位于上传堆的纹理
ComPtr<ID3D12Resource> DefaultTexture;// 位于默认堆的纹理

D3D12_CPU_DESCRIPTOR_HANDLE CPUHandle;// CPU 句柄
D3D12_GPU_DESCRIPTOR_HANDLE GPUHandle;// GPU 句柄
};

std::vector<Material> MaterialGroup;// 模型用到的材质组

// 模型骨骼-索引组,key 是骨骼名,value 是 该骨骼相对于整个模型的偏移矩阵 在 TransformGroup 的索引
std::unordered_map<std::string, UINT> BoneNode_IndexGroup;

// 模型骨骼-偏移矩阵组,存储对应骨骼在整个模型的偏移矩阵,用 vector 存储矩阵是为了方便 memcpy 复制
// 骨骼模型最开始还要绑骨骼,将网格影响权重与骨骼节点相关联,这一状态下骨骼模型是静止的,所以被称为 Static Pose 静止状态
// Static Pose 很多时候都是我们常见的 T-Pose,它是其他骨骼动画变换的基础,无论切换到哪个动画,Static Pose 都是不变的
// 注意这里是 Static Pose 静态姿势下的偏移矩阵组,动画组用的是另一套
std::vector<XMMATRIX> BoneNode_TransformGroup;

struct VERTEX// 顶点数据结构体
{
XMFLOAT4 position;// 顶点位置
XMFLOAT2 texcoordUV;// 顶点纹理坐标
XMFLOAT4 color;// 对于自发光纹理而言,顶点可能带有的颜色
UINT BoneIndices[4];// 骨骼索引
float BoneWeights[4];// 骨骼权重
};

struct MESH// 网格数据结构体
{
UINT MaterialIndex;// Mesh 用到的材质索引

UINT VertexGroupOffset;// 顶点信息在 VertexGroup 的偏移
UINT IndexGroupOffset;// 索引信息在 IndexGroup 的偏移
UINT VertexCount;// 顶点数量
UINT IndexCount;// 索引数量
};

ComPtr<ID3D12Resource> UploadVertexResource;// 上传堆顶点资源
ComPtr<ID3D12Resource> UploadIndexResource;// 上传堆索引资源
ComPtr<ID3D12Resource> DefaultVertexResource;// 默认堆顶点资源
ComPtr<ID3D12Resource> DefaultIndexResource;// 默认堆索引资源

D3D12_VERTEX_BUFFER_VIEW VertexBufferView = {};// 顶点信息描述符
D3D12_INDEX_BUFFER_VIEW IndexBufferView = {};// 索引信息描述符

std::vector<VERTEX> VertexGroup;// 顶点组,存储整个模型的顶点信息

std::vector<UINT> VertexWeightsCountGroup;// 骨骼权重次数组,记录每个顶点记录的骨骼权重次数

std::vector<UINT> IndexGroup;// 索引组,存储整个模型的索引信息

std::vector<MESH> MeshGroup;// 网格信息组

struct AABB// AABB 盒结构体
{
float minBoundsX;// 最小坐标点 X 值
float minBoundsY;// 最小坐标点 Y 值
float minBoundsZ;// 最小坐标点 Z 值

float maxBoundsX;// 最大坐标点 X 值
float maxBoundsY;// 最大坐标点 Y 值
float maxBoundsZ;// 最大坐标点 Z 值
};

AABB ModelBoundingBox;// 模型 AABB 盒,用于调整摄像机视野,防止模型在摄像机视野外飞出去

// —————————————————————————————————————

ComPtr<IWICImagingFactory> m_WICFactory;// WIC 工厂
ComPtr<IWICBitmapDecoder> m_WICBitmapDecoder;// 位图解码器
ComPtr<IWICBitmapFrameDecode> m_WICBitmapDecodeFrame;// 由解码器得到的单个位图帧
ComPtr<IWICFormatConverter> m_WICFormatConverter;// 位图转换器
ComPtr<IWICBitmapSource> m_WICBitmapSource;// WIC 位图资源,用于获取位图数据
UINT TextureWidth = 0;// 纹理宽度
UINT TextureHeight = 0;// 纹理高度
UINT BitsPerPixel = 0;// 图像深度,图片每个像素占用的比特数
UINT BytePerRowSize = 0;// 纹理每行数据的真实字节大小,用于读取纹理数据、上传纹理资源
DXGI_FORMAT TextureFormat = DXGI_FORMAT_UNKNOWN;// 纹理格式

ComPtr<ID3D12DescriptorHeap> m_SRVHeap;// SRV 描述符堆

UINT TextureSize = 0;// 纹理的真实大小 (单位:字节)
UINT UploadResourceRowSize = 0;// 上传堆资源每行的大小 (单位:字节)
UINT UploadResourceSize = 0;// 上传堆资源的总大小 (单位:字节)

UINT SRVDescriptorSize;// SRV 描述符的大小

ComPtr<ID3D12Resource> m_CBVResource;// 常量缓冲资源,用于存放 MVP 矩阵,MVP 矩阵每帧都要更新,所以需要存储在常量缓冲区中
struct CBuffer// 常量缓冲结构体
{
XMFLOAT4X4 MVPMatrix;// MVP 矩阵,用于将顶点数据从顶点空间变换到齐次裁剪空间

XMFLOAT4X4 BoneTransformMatrix[512];// 骨骼偏移矩阵组,每个矩阵对应一块骨骼,这里仅设置了最多 512 个骨骼,实际可以更多
};
CBuffer* MVPBuffer = nullptr;// 常量缓冲结构体指针,里面存储的是 MVP 矩阵信息,下文 Map 后指针会指向 CBVResource 的地址

Camera m_FirstCamera;// 第一人称摄像机

ComPtr<ID3D12RootSignature> m_RootSignature;// 根签名
ComPtr<ID3D12PipelineState> m_PipelineStateObject;// 渲染管线状态

// 视口
D3D12_VIEWPORT viewPort = D3D12_VIEWPORT{ 0, 0, float(WindowWidth), float(WindowHeight), D3D12_MIN_DEPTH, D3D12_MAX_DEPTH };
// 裁剪矩形
D3D12_RECT ScissorRect = D3D12_RECT{ 0, 0, WindowWidth, WindowHeight };

public:

// 初始化窗口
void STEP1_InitWindow(HINSTANCE hins)
{
WNDCLASS wc = {};// 用于记录窗口类信息的结构体
wc.hInstance = hins;// 窗口类需要一个应用程序的实例句柄 hinstance

// 绑定回调函数,利用 std::bind,将 DX12Engine::CallBackFunc 绑定到 CallBackWrapper 的函数包装器上
// std::bind 的作用是将 CallBackFunc 带参数绑定到一个对象上,并生成对应的函数包装器
// 第一个参数表示对象的成员函数的指针,因为编译器不会将对象的成员函数隐式转换成函数指针,所以必须在前面添加 &
// 第二个参数表示对象的地址,这个参数就表示类成员函数需要传递的 this 指针
// 之后的表示将要绑定的参数,std::placeholders 表示占位符,用户调用函数,传递实参时,这个占位符会将实参一一对应
CallBackWrapper::Broker_Func = std::bind(&DX12Engine::CallBackFunc, this,
std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4);

wc.lpfnWndProc = CallBackWrapper::CallBackFunc;// 窗口类需要一个回调函数,用于处理窗口产生的消息,注意这里传递的是中间层的回调函数
wc.lpszClassName = L"DX12 Game";// 窗口类的名称

RegisterClass(&wc);// 注册窗口类,将窗口类录入到操作系统中

// 使用上文的窗口类创建窗口
m_hwnd = CreateWindow(wc.lpszClassName, L"绀紫之心 — Static Pose 静态姿态", WS_SYSMENU | WS_OVERLAPPED,
10, 10, WindowWidth, WindowHeight,
NULL, NULL, hins, NULL);

// 因为指定了窗口大小不可变的 WS_SYSMENU 和 WS_OVERLAPPED,应用不会自动显示窗口,需要使用 ShowWindow 强制显示窗口
ShowWindow(m_hwnd, SW_SHOW);
}

// 创建调试层
void STEP2_CreateDebugDevice()
{
::CoInitialize(nullptr);// 注意这里!DX12 的所有设备接口都是基于 COM 接口的,我们需要先全部初始化为 nullptr

#if defined(_DEBUG)// 如果是 Debug 模式下编译,就执行下面的代码

// 获取调试层设备接口
D3D12GetDebugInterface(IID_PPV_ARGS(&m_D3D12DebugDevice));
// 开启调试层
m_D3D12DebugDevice->EnableDebugLayer();
// 开启调试层后,创建 DXGI 工厂也需要 Debug Flag
m_DXGICreateFactoryFlag = DXGI_CREATE_FACTORY_DEBUG;

#endif
}

// 创建设备
bool STEP3_CreateDevice()
{
// 创建 DXGI 工厂
CreateDXGIFactory2(m_DXGICreateFactoryFlag, IID_PPV_ARGS(&m_DXGIFactory));

// DX12 支持的所有功能版本,你的显卡最低需要支持 11.0
const D3D_FEATURE_LEVEL dx12SupportLevel[] =
{
D3D_FEATURE_LEVEL_12_2,// 12.2
D3D_FEATURE_LEVEL_12_1,// 12.1
D3D_FEATURE_LEVEL_12_0,// 12.0
D3D_FEATURE_LEVEL_11_1,// 11.1
D3D_FEATURE_LEVEL_11_0// 11.0
};

// 用 EnumAdapters1 先遍历电脑上的每一块显卡
// 每次调用 EnumAdapters1 找到显卡会自动创建 DXGIAdapter 接口,并返回 S_OK
// 找不到显卡会返回 ERROR_NOT_FOUND

for (UINT i = 0; m_DXGIFactory->EnumAdapters1(i, &m_DXGIAdapter) != ERROR_NOT_FOUND; i++)
{
// 找到显卡,就创建 D3D12 设备,从高到低遍历所有功能版本,创建成功就跳出
for (const auto& level : dx12SupportLevel)
{
// 创建 D3D12 核心层设备,创建成功就返回 true
if (SUCCEEDED(D3D12CreateDevice(m_DXGIAdapter.Get(), level, IID_PPV_ARGS(&m_D3D12Device))))
{
DXGI_ADAPTER_DESC1 adap = {};
m_DXGIAdapter->GetDesc1(&adap);
OutputDebugStringW(L"当前使用的显卡:");
OutputDebugStringW(adap.Description);
OutputDebugStringW(L"\\n");
return true;
}
}
}

// 如果找不到任何能支持 DX12 的显卡,就退出程序
if (m_D3D12Device == nullptr)
{
MessageBox(NULL, L"找不到任何能支持 DX12 的显卡,请升级电脑上的硬件!", L"错误", MB_OK | MB_ICONERROR);
return false;
}
}

// 创建命令三件套
void STEP4_CreateCommandComponents()
{
// 队列信息结构体,这里只需要填队列的类型 type 就行了
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
// D3D12_COMMAND_LIST_TYPE_DIRECT 表示将命令都直接放进队列里,不做其他处理
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
// 创建命令队列
m_D3D12Device->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&m_CommandQueue));

// 创建命令分配器,它的作用是开辟内存,存储命令列表上的命令,注意命令类型要一致
m_D3D12Device->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&m_CommandAllocator));

// 创建图形命令列表,注意命令类型要一致
m_D3D12Device->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, m_CommandAllocator.Get(),
nullptr, IID_PPV_ARGS(&m_CommandList));

// 命令列表创建时处于 Record 录制状态,我们需要关闭它,这样下文的 Reset 才能成功
m_CommandList->Close();
}

// 创建渲染目标,将渲染目标设置为窗口
void STEP5_CreateRenderTarget()
{
// 创建 RTV 描述符堆 (Render Target View,渲染目标描述符)
D3D12_DESCRIPTOR_HEAP_DESC RTVHeapDesc = {};
RTVHeapDesc.NumDescriptors = 3;// 渲染目标的数量
RTVHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;// 描述符堆的类型:RTV
// 创建一个 RTV 描述符堆,创建成功后,会自动开辟三个描述符的内存
m_D3D12Device->CreateDescriptorHeap(&RTVHeapDesc, IID_PPV_ARGS(&m_RTVHeap));

// 创建 DXGI 交换链,用于将窗口缓冲区和渲染目标绑定
DXGI_SWAP_CHAIN_DESC1 swapchainDesc = {};
swapchainDesc.BufferCount = 3;// 缓冲区数量
swapchainDesc.Width = WindowWidth;// 缓冲区 (窗口) 宽度
swapchainDesc.Height = WindowHeight;// 缓冲区 (窗口) 高度
swapchainDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;// 缓冲区格式,指定缓冲区每个像素的大小
swapchainDesc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;// 交换链类型,有 FILP 和 BITBLT 两种类型
swapchainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;// 缓冲区的用途,这里表示把缓冲区用作渲染目标的输出
swapchainDesc.SampleDesc.Count = 1;// 缓冲区像素采样次数

// 临时低版本交换链接口,用于创建高版本交换链,因为下文的 CreateSwapChainForHwnd 不能直接用于创建高版本接口
ComPtr<IDXGISwapChain1> _temp_swapchain;

// 创建交换链,将窗口与渲染目标绑定
// 注意:交换链需要绑定到命令队列来刷新,所以第一个参数要填命令队列,不填会创建失败!
m_DXGIFactory->CreateSwapChainForHwnd(m_CommandQueue.Get(), m_hwnd,
&swapchainDesc, nullptr, nullptr, &_temp_swapchain);

// 通过 As 方法,将低版本接口的信息传递给高版本接口
_temp_swapchain.As(&m_DXGISwapChain);

// 创建完交换链后,我们还需要令 RTV 描述符 指向 渲染目标
// 因为 ID3D12Resource 本质上只是一块数据,它本身没有对数据用法的说明
// 我们要让程序知道这块数据是一个渲染目标,就得创建并使用 RTV 描述符

// 获取 RTV 堆指向首描述符的句柄
RTVHandle = m_RTVHeap->GetCPUDescriptorHandleForHeapStart();
// 获取 RTV 描述符的大小
RTVDescriptorSize = m_D3D12Device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);

for (UINT i = 0; i < 3; i++)
{
// 从交换链中获取第 i 个窗口缓冲,创建第 i 个 RenderTarget 渲染目标
m_DXGISwapChain->GetBuffer(i, IID_PPV_ARGS(&m_RenderTarget[i]));

// 创建 RTV 描述符,将渲染目标绑定到描述符上
m_D3D12Device->CreateRenderTargetView(m_RenderTarget[i].Get(), nullptr, RTVHandle);

// 偏移到下一个 RTV 句柄
RTVHandle.ptr += RTVDescriptorSize;
}
}

// 创建围栏和资源屏障,用于 CPU-GPU 的同步
void STEP6_CreateFenceAndBarrier()
{
// 创建 CPU 上的等待事件
RenderEvent = CreateEvent(nullptr, false, true, nullptr);

// 创建围栏,设定初始值为 0
m_D3D12Device->CreateFence(FenceValue, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&m_Fence));

// 设置资源屏障
// beg_barrier 起始屏障:Present 呈现状态 -> Render Target 渲染目标状态
beg_barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;// 指定类型为转换屏障
beg_barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT;
beg_barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET;

// end_barrier 终止屏障:Render Target 渲染目标状态 -> Present 呈现状态
end_barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
end_barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET;
end_barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT;
}

// 创建 DSV 深度模板描述符堆 (Non-Shader Visible)
void STEP7_CreateDSVHeap()
{
D3D12_DESCRIPTOR_HEAP_DESC DSVHeapDesc = {};// DSV 描述符堆结构体
DSVHeapDesc.NumDescriptors = 1;// 描述符只有 1 个,因为我们只有一个渲染目标
DSVHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_DSV;// 描述符堆类型

// 创建 DSV 描述符堆 (Depth Stencil View,深度模板描述符),用于深度测试与模板测试
m_D3D12Device->CreateDescriptorHeap(&DSVHeapDesc, IID_PPV_ARGS(&m_DSVHeap));

// 获取 DSV 的 CPU 句柄
DSVHandle = m_DSVHeap->GetCPUDescriptorHandleForHeapStart();
}

// 创建深度与模板缓冲,用于开启深度测试,渲染物体正确的深度与遮挡关系
void STEP8_CreateDepthStencilBuffer()
{
D3D12_RESOURCE_DESC DSVResourceDesc = {};// 深度模板缓冲资源信息结构体
DSVResourceDesc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D;// 深度缓冲其实也是一块纹理
DSVResourceDesc.Format = DSVFormat;// 资源纹理格式
DSVResourceDesc.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN;// 深度缓冲的布局也是 UNKNOWN
DSVResourceDesc.Width = WindowWidth;// 宽度和渲染目标一致
DSVResourceDesc.Height = WindowHeight;// 高度和渲染目标一致
DSVResourceDesc.MipLevels = 1;// Mipmap 层级,设置为 1 就行
DSVResourceDesc.DepthOrArraySize = 1;// 纹理数组大小 (3D 纹理深度),设置为 1 就行
DSVResourceDesc.SampleDesc.Count = 1;// 采样次数,设置为 1 就行
DSVResourceDesc.SampleDesc.Quality = 0;// 采样质量,设置为 0 就行
// 资源标志,创建深度模板缓冲,必须要填 D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL,否则会创建失败
DSVResourceDesc.Flags = D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL;

D3D12_CLEAR_VALUE DepthStencilBufferClearValue = {};// 用于清空深度缓冲的信息结构体,DX12 能对这个进行优化
DepthStencilBufferClearValue.DepthStencil.Depth = 1.0f;// 要清空到的深度值,清空后会重置到该值
DepthStencilBufferClearValue.DepthStencil.Stencil = 0;// 要清空到的模板值,清空后会重置到该值
DepthStencilBufferClearValue.Format = DSVFormat;// 要清空缓冲的格式,要和上文一致

// 默认堆属性,深度缓冲也是一块纹理,所以用默认堆
D3D12_HEAP_PROPERTIES DefaultProperties = { D3D12_HEAP_TYPE_DEFAULT };

// 创建资源,深度缓冲只会占用很少资源,所以直接 CreateCommittedResource 隐式堆创建即可,让操作系统帮我们管理
m_D3D12Device->CreateCommittedResource(&DefaultProperties, D3D12_HEAP_FLAG_NONE, &DSVResourceDesc,
D3D12_RESOURCE_STATE_DEPTH_WRITE, &DepthStencilBufferClearValue, IID_PPV_ARGS(&m_DepthStencilBuffer));
}

// 创建 DSV 描述符,DSV 描述符用于描述深度模板缓冲区,这个描述符才是渲染管线要设置的对象
void STEP9_CreateDSV()
{
D3D12_DEPTH_STENCIL_VIEW_DESC DSVViewDesc = {};
DSVViewDesc.Format = DSVFormat;// DSV 描述符格式要和资源一致
DSVViewDesc.ViewDimension = D3D12_DSV_DIMENSION_TEXTURE2D;// 深度缓冲本质也是一块 2D 纹理
DSVViewDesc.Flags = D3D12_DSV_FLAG_NONE;// 这个 Flag 是用来设置读写权限的,深度值和模板值均可以读写

// 创建 DSV 描述符 (Depth Stencil View,深度模板描述符)
m_D3D12Device->CreateDepthStencilView(m_DepthStencilBuffer.Get(), &DSVViewDesc, DSVHandle);
}

// —————————————————————————————————————

// 读取模型数据,数据会存储在 aiScene 对象
bool STEP10_OpenModelFile()
{
// 使用 ReadFile 函数直接传递文件路径打开文件,路径可以有中文文字这些 utf-8 字符 (Assimp 最近修复了这个 bug)
m_ModelScene = m_ModelImporter->ReadFile(ModelFileName, ModelImportFlag);

// 如果模型没有成功载入 (无法载入,载入未完成,载入后无根节点)
if (!m_ModelScene || m_ModelScene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !m_ModelScene->mRootNode)
{
// Assimp 载入模型的错误信息
std::string Assimp_error_msg = m_ModelImporter->GetErrorString();

std::string errorMsg = "载入文件 ";
errorMsg += ModelFileName;
errorMsg += " 失败!错误原因:";
errorMsg += Assimp_error_msg;
MessageBoxA(NULL, errorMsg.c_str(), "错误", MB_ICONERROR | MB_OK);
return false;
}

return true;
}

// 添加模型材质
void STEP11_AddModelMaterials()
{
// 遍历模型中的所有材质
for (UINT i = 0; i < m_ModelScene->mNumMaterials; i++)
{
// Assimp 解析出来的模型材质
aiMaterial* material = m_ModelScene->mMaterials[i];

// 模型文件携带的材质/纹理文件路径
aiString materialPath;

// 先检测是否为 Emissive Material 自发光贴图,自发光贴图常用于光源,或者是角色眼睛,皮肤等自带弱荧光的表面
// 一般绑定自发光纹理的 Mesh 还会有自带颜色 (mesh->mColors[0]),在 Pixel Shader 需要进行纹理颜色混合
// 有时候 Assimp 导入时,Emssive 和 Diffuse 指向的纹理文件是相同的 (例如第 9 节的 miyu 霞沢美游模型)
// 这种情况不会影响后面的纹理渲染,决定颜色混合的仍然是后文的顶点数据,正常混合即可
if (material->GetTexture(aiTextureType_EMISSIVE, 0, &materialPath) == aiReturn_SUCCESS)
{
Material mt = {};// 新材质
mt.FilePath = ModelTextureFilePath;// 新材质所在的文件夹
mt.FilePath += materialPath.C_Str();// 新材质的文件名
mt.Type = aiTextureType_EMISSIVE;// 新材质的类型
MaterialGroup.push_back(mt);// 添加新材质
}
// 如果不是,就检测并加载 Diffuse Material 漫反射材质贴图,漫反射贴图是表示物体表面颜色的基本纹理,大多数都是漫反射材质
// 漫反射材质自身不发光,一般而言会带有 _diffuse,_base_color,_albedo 这些后缀,一般情况下这三个是等同的
else if (material->GetTexture(aiTextureType_DIFFUSE, 0, &materialPath) == aiReturn_SUCCESS)
{
Material mt = {};// 新材质
mt.FilePath = ModelTextureFilePath;// 新材质所在的文件夹
mt.FilePath += materialPath.C_Str();// 新材质的文件名
mt.Type = aiTextureType_DIFFUSE;// 新材质的类型
MaterialGroup.push_back(mt);// 添加新材质
}
// 如果不是上述两种贴图,就设置默认贴图,有些网格对应的确实是默认贴图,默认贴图不显示颜色
// 一般是图形引擎内置,发布的模型文件无法找到相关纹理
else
{
Material mt = {};// 新材质
mt.Type = aiTextureType_NONE;// 新材质的类型
MaterialGroup.push_back(mt);// 添加新材质
}
}
}

// 递归展开计算模型骨骼节点,计算每个节点相对于整个模型的偏移矩阵
// 骨骼在模型中的呈现形式是骨骼树,在骨骼动画中,父节点会影响子节点,子节点拿到的偏移矩阵是相对于所属父节点的,所以要递归展开计算
// Assimp 中,节点的变换矩阵会影响节点下的全部属性,包括网格、骨骼、子节点,骨骼名是唯一的
void CalcModelNodeMatrix(const aiNode* node, const XMMATRIX& ParentNodeTransform)
{
// 当前节点相对父节点的偏移矩阵,其中 aiMatrix4x4 是 Assimp 内置的矩阵结构体
const aiMatrix4x4 CurMat = node->mTransformation;

// 当前节点的偏移矩阵,DirectXMath 的 XMMATRIX 结构体和 Assimp 内置的不同,先手动转换一下
XMMATRIX CurrentTransformMatrix =
XMMATRIX(
CurMat.a1, CurMat.a2, CurMat.a3, CurMat.a4,
CurMat.b1, CurMat.b2, CurMat.b3, CurMat.b4,
CurMat.c1, CurMat.c2, CurMat.c3, CurMat.c4,
CurMat.d1, CurMat.d2, CurMat.d3, CurMat.d4
);

// 继承父节点的偏移矩阵,计算当前节点相对整个模型的偏移矩阵,切记,矩阵乘法不满足交换律!两个矩阵的位置不能交换!
// 子骨骼空间 -> 父骨骼空间
CurrentTransformMatrix = ParentNodeTransform * CurrentTransformMatrix;

// 骨骼名
std::string BoneName = node->mName.C_Str();
// 骨骼索引
UINT BoneIndex = BoneNode_TransformGroup.size();

// TransformGroup 添加新矩阵
BoneNode_TransformGroup.push_back(CurrentTransformMatrix);

// IndexGroup 添加新键值对,以骨骼名为键,当前骨骼的索引为值
BoneNode_IndexGroup[BoneName] = BoneIndex;

// 注意!有些网格没有绑定到骨骼上,但是它们属于某个骨骼节点,仍然是需要进行骨骼变换的,否则会出错
// 没有骨骼的下文 mNumBones 会是 0,需要特殊处理,这里要先添加并更新节点影响网格对应的名字 _Mesh_i
// 在 Assimp 中,如果网格没有绑定到骨骼上,一个网格只会对应一个节点,所以下文没有骨骼的网格会用 _Mesh_i 索引
for (UINT i = 0; i < node->mNumMeshes; i++)
{
BoneNode_IndexGroup[std::string("_Mesh_") + std::to_string(node->mMeshes[i])] = BoneIndex;
}

// 一个节点下可能有多个子节点,对这些节点逐一计算偏移矩阵
for (UINT i = 0; i < node->mNumChildren; i++)
{
CalcModelNodeMatrix(node->mChildren[i], CurrentTransformMatrix);
}
}

// 添加模型网格数据 (动画数,位置坐标,纹理坐标,自带颜色)
// Mesh 网格相当于模型的皮肤,它存储了模型要渲染的顶点信息
void STEP12_AddModelData()
{
// 读取模型文件拥有的动画数
AnimationCount = m_ModelScene->mNumAnimations;

// 添加骨骼节点,骨骼索引与权重数据
// 骨骼是网格的核心,因为网格需要依靠骨骼驱动才能完成姿势,骨骼影响了附着在其上的网格,导致网格上顶点位置的改变
// 骨骼模型的运动是父节点带动子节点,所以上面的函数要用递归的形式计算偏移矩阵,后面的骨骼动画更是如此

// 整个模型的偏移矩阵
XMMATRIX ModelMatrix = XMMatrixIdentity();

// 从模型的根骨骼节点开始,添加骨骼节点,并计算与存储偏移矩阵
// 注意这里计算的是 Static Pose 下的骨骼变换组
CalcModelNodeMatrix(m_ModelScene->mRootNode, ModelMatrix);

// 当前 Mesh 在 VertexGroup 的索引
UINT CurrentMeshVertexGroupOffset = 0;
// 当前 Mesh 在 IndexGroup 的索引
UINT CurrentMeshIndexGroupOffset = 0;

// 遍历模型所有的 Mesh 信息,在 Assimp 中,Mesh 与 Bone 是分开存储的,一个 Mesh 会有多个顶点
for (UINT i = 0; i < m_ModelScene->mNumMeshes; i++)
{
// 当前网格
const aiMesh* mesh = m_ModelScene->mMeshes[i];

// 如果 Mesh 顶点数量是 0,直接跳过
if (mesh->mNumVertices == 0) continue;

// 要渲染的新 Mesh,注意这里用大括号初始化,把所有成员置 0 了
MESH new_mesh = {};

// 遍历模型所有的 Mesh 信息,一个 Mesh 会有多个顶点
for (UINT j = 0; j < mesh->mNumVertices; j++)
{
// 新顶点
VERTEX new_vertex = {};

// 新顶点位置 (相对于当前 Mesh)
new_vertex.position = XMFLOAT4(mesh->mVertices[j].x, mesh->mVertices[j].y, mesh->mVertices[j].z, 1);

// 新节点纹理 UV,如果有就添加,没有就默认 (-1, -1)
// 注意这个 0 指的是第 0 号 UV 通道 (详情请见 UE5 文档: UV 通道)
// 对于同一个顶点,不同的 UV 通道可以有不同的 UV 坐标,常用于光照,但我们这里不涉及,直接获取第 0 号纹理 UV 即可
if (mesh->HasTextureCoords(0))
{
new_vertex.texcoordUV = XMFLOAT2(mesh->mTextureCoords[0][j].x, mesh->mTextureCoords[0][j].y);
}
else
{
new_vertex.texcoordUV = XMFLOAT2(1, 1);// 默认纹理 UV 坐标,Pixel Shader 会进行处理
}

// 新节点自带颜色,如果有就添加,没有就默认白色 (1, 1, 1, 1),和上面的 TexcoordUV 同理
if (mesh->HasVertexColors(0))
{
new_vertex.color = XMFLOAT4(
mesh->mColors[0][j].r, mesh->mColors[0][j].g, mesh->mColors[0][j].b, mesh->mColors[0][j].a);
}
else
{
new_vertex.color = XMFLOAT4(1, 1, 1, 1);
}

// 添加新顶点拥有的骨骼权重数量,将数量置 0
VertexWeightsCountGroup.push_back(0);

// 添加新节点
VertexGroup.push_back(new_vertex);
}

// 添加 Mesh 的所有顶点索引信息,在 Assimp 中,索引通常是以 Face 为单位存储的
// 因为模型设计师可能会用多边形进行建模,上文指定 aiProcess_Triangulate 就是为了把可能的多边形全部分割成三角形
// 所以我们这里直接指定 mesh->mFaces[j].mIndices 添加节点即可
for (UINT j = 0; j < mesh->mNumFaces; j++)
{
// 对每个 Face,都添加三个索引 (aiProcess_Triangulate 已经保证过每个面有且只有三个索引了)
IndexGroup.push_back(mesh->mFaces[j<span class=\"token pu

赞(0)
未经允许不得转载:171主机测评 » DX12 快速教程(13) —— 蒙皮骨骼动画
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址