使用电脑端体验更佳

如何让一只狐狸动起来 - 四元数与骨骼

发布于:1793902621895 | 修改于:1793933186136 | 分类: No GameEngine | 浏览: 83

> 计算机角色动画入门与实践 Animation是图形学十分重要的分支之一了,但意想不到的这也是Robotic的入门必修课之一。机器人学与图形学的重合在学完前向动力学(forward kinematics,后文以fk简称)和反向运动学(inverse kinematics,后文以ik简称,如果有的话)就结束了。对于游戏开发者,为了让角色的动作渲染的更加真实,我们的议题通常更加深入。 但对于这篇文章,我不会深入探究一些高级议题。毕竟高级动画系统一般都是引擎内开箱即用的事物,没有造轮子的必要,这只是一篇笔记and简易教程,以入门和实践为目的。 相比看4个小时的games106,我相信我这篇文章会更加快速点... 在看完这篇文章后,你应该有能力写出一个简易角色动画播放工具。 在开始之前,让我们把时间拉回到你决定做游戏的那一刻。 你决定做一款关于狐狸的游戏,主角是一只狐狸owo。可能是狐狸幸存者,也可能是狐狸版只狼,但重要的是,这只狐狸需要会动起来,走路,跑步.... ![](https://pic.feiqi3.cn/blogPic/fox_binding_pos_1.png) > 小狐狸最可爱! [此文用的模型下载](https://raw.githubusercontent.com/feiqi3/Renderer/refs/heads/master/resources/Fox/fox.glb),基于GLTFSample仓库的Fox做了一些修改,以适配本文 ## 四元数 第一步是让小狐狸旋转起来(?),再简单不过。只要在你用的游戏引擎中把这只狐狸的节点设置一下Rotation属性。你想用代码设置,这样可以做到狐狸跟着鼠标移动旋转。 这里我要引入一个数学工具,Quaternion,中文叫四元数,后文简称为quat。 quat可以表示一个旋转。 你可能很疑惑,unity中编辑器用的是xyz啊?或者你已经知道unity的代码中有Quaternion这个类,但是你从头到尾只用FromEuler把xyz转换为四元数,好奇为什么要这个累赘?又或者你觉得轴角/旋转矩阵已经足够了,“如无必要勿增实体”,你抗议着强加于你的累赘 于是你去知乎上搜索,大部分的回答让你看的想要放弃理解,还有一小部分人推荐你看Shoemake的论文 > 分支:你真去看了Shoemake关于四元数的论文,五十多页,充满了神秘的代数公式高维空间推导,没有一行字你看得懂的。 ## 如果没有四元数 是的,这真不值得,让我们用欧拉角来尝试做这件事情吧。 首先有一个问题,如果用xyz表示旋转,那xyz分别代表什么呢? 绕x y z轴旋转的度数!你会答到 对,但是顺序呢?欧拉角旋转是顺序敏感的,因为所有轴上的旋转最后都要转化为三个不同轴上*旋转矩阵的乘积*,而矩阵的乘法是没有交换律的。 > 这个叫顺规问题 按x y z这个顺序就好? 也行。但是这个抉择会导致后续的一系列问题,比如如果你尝试加载其他动画格式,你可能会发现他们甚至只用两个轴来组合旋转,比如xyx xzx等等... 这太恐怖了,因为如果你要把这些数据加载成你的xyz格式,你就得先把旋转矩阵乘出来,然后再拆分为xyz的顺序作为存储在你引擎里的数据. 我的线性代数知识完全不够用,或许你可以! > 或者偷懒点,直接保存为一个矩阵qwq 现在你把旋转用xyz表示出来了,你想让小狐狸绕坐标轴转起来,很顺利,你写了一个脚本,让它每帧在x\y\z上增加Dt。 ![](https://pic.feiqi3.cn/blogPic/fox_rotation_000.webp) 你兴奋的摆弄着小狐狸,把它节点的欧拉角不断修改。突然你发现一个奇怪的Bug,当Y轴旋转到90度的时候,修改x和修改z竟然在表现上等价了? 这就是[万向节锁](https://www.bilibili.com/list/ml3471426434?oid=771397545&bvid=BV1Nr4y1j7kn)问题。 好吧,你可能认为我太过钻牛角尖了。不过是一行限制代码的事情,你可能这么想。那我们继续吧。 事情突然变得有些棘手了,你被要求(又或许是你自己想出来)做一个从角度A旋转到角度B的效果,要转的丝滑平稳,仿佛是狐狸自己在转身一样。 简单,你想,在欧拉角A和欧拉角B之间连续插值,再应用到节点上。 这对么?我之前提到过,基于欧拉角的旋转实际上是用欧拉角*三个分量构成的*-*绕轴旋转矩阵的*-*乘积* 也就是,这插值的数学含义并不是在A朝向到B朝向的插值 天知道他的运动轨迹有多诡异。 你可能不太相信,你决定让狐狸在vec3(0),和vec3$(0.5\pi,0.5\pi,0)$之间做一个欧拉角的旋转动画,用lerp在两个vec3之间插值 ![](https://pic.feiqi3.cn/blogPic/fox_rotation_002.webp) 为什么旋转路径是一条弧线啊! 看来之前的假设没错...欧拉角的旋转是不太能用了 你突然想到,既然欧拉角的旋转问题这么多,我们能不能直接切入最终形式,旋转矩阵呢? 当然可以了!由于我们知道,对于一个正交阵,其几何意义可以理解为把一个坐标变换到由这个矩阵的几个向量构成的坐标系下。 > 那什么是正交阵呢?就是构成矩阵的向量为一组正交基,什么叫正交基呢?就是这组向量间两两点乘为0,**且长度为1** > 举个例子,一个Identity就是正交阵 也就是说,只要你计算能力够强,你可以手写一个正交基构成旋转矩阵!甚至可以不用计算,快速列出一些旋转矩阵 于是你写出了一个绕y轴旋转九十度的矩阵。 $$ R_y(90^\circ) = \begin{bmatrix} 0 & 0 & 1 \\\\ 0 & 1 & 0 \\\\ 1 & 0 & 0 \end{bmatrix} $$ 但是你知道这件事情也不可能持续,比如绕着轴$(1,1,1)$旋转30度,应该怎么写出这个矩阵呢?你犯了难。 这时候就该引入我们的新工具,*绕轴旋转公式* $$ R = I\cos\theta + \sin\theta \begin{bmatrix} 0 & -k_z & k_y \\\\ k_z & 0 & -k_x \\\\ -k_y & k_x & 0 \end{bmatrix} + (1-\cos\theta)\mathbf{k}\mathbf{k}^{\mathsf{T}} $$ 其中K是旋转轴,theta是旋转角度 看着可能有些繁琐,但是代码比较直观 ```glsl vec3 erot(vec3 p, vec3 ax, float ro) { return mix(dot(ax, p)*ax, p, cos(ro)) + cross(ax, p)*sin(ro); } ``` 于是小狐狸绕着vec3($1,1,1$)旋转,出来吧! ![](https://pic.feiqi3.cn/blogPic/fox_rotation003.webp) 于是你想到,既然旋转能用轴和角度表示,我们何不储存这两个信息呢? 这确实是一个好想法!不论什么格式进来我们都可以转换为旋转矩阵,然后再把旋转矩阵提取出轴角信息来储存。 现在你成功规避了万向节死锁问题,那么轴角表示物体旋转是不是一个好方法呢? 此刻你想让小狐狸从$vec3(0,1,0)$平滑旋转到$vec3(1,1,1)$转90°的方向。 我已经猜到你的想法了,你会把旋转轴平滑插值,同时把旋转角度平滑插值。但问题是,这么做的结果明显不是平滑的 ![](https://pic.feiqi3.cn/blogPic/fox_rotation_004.webp) > 阴影消失是因为CSM的投影面积裁剪发力了...我不是很想重新录象了,私密马赛! 明显一开始有个上抬的趋势却又变成了向下旋转,太怪异了....这并不平滑 那把轴角转化为旋转矩阵再插值就好了!你反驳。 但是两个旋转矩阵插值后就不一定再是正交阵了,你需要做一次正交化来维持正交阵性质,而正交化后的矩阵能让这个旋转过程平滑吗? 答案显然是不能,我没试过,但我猜这个旋转应该是两头慢中间快... 你有些失望,我相信你也厌倦了这连续不断的脑经急转弯。 好吧,允许我介绍四元数 ## 四元数怎么用 百分之九十九的情况下,我们用四元数的唯一目的就是旋转,因此你可以把很多属于四元数的性质忽略不计。 首先,四元数遵从如下的形式 $$ quat = x+y+z+wi $$ 其中i是虚部。我不甚了解数学内涵,但是在计算机的世界里,我会把它写成一个vector4$(x,y,z,w)$ 首先的首先,有一个最最易懂的性质, 1. 如果一个四元数要表示一个旋转,他得是一个单位向量,也就是说,一个normalize后的vector4。 对于我,我有个不太成熟的且一定错误的观点,那就是四元数是一种高级的轴角表示方法。我之所以有这个念头,是因为轴角表示和四元数有着一种很直接且很快的转化方式, $$ (x,y,z)=\mathbf u\sin(\theta/2),\qquad w=\cos(\theta/2) $$ 其中,u是轴,theta是角。u必须是一个单位向量。因此可以观察到其确实满足 1. 的性质 你可能会想到,那么得到任何一个四元数直接转换为绕轴旋转公式不就好了..不对,那为什么还要四元数呢? 因为由有更好的方案! 一般你会看到这个公式 $$ v' = qvq^{-1} $$ 其中v是待旋转的点,q是四元数,$q^{-1}$是四元数的逆,四元数的逆就是四元数非虚部的三个成员乘以-1...然后q v q逆需要按顺序进行四元数乘法,因为四元数的乘法不满足交换律 等等...四元数的乘法? $$a\,b = \left( w_a \mathbf{u}_b + w_b \mathbf{u}_a + \mathbf{u}_a \times \mathbf{u}_b, \quad w_a w_b - \mathbf{u}_a \cdot \mathbf{u}_b \right)$$ 实际上代码中我们不用记得这么复杂,只要知道把点用一个四元数旋转的函数是: ```cpp vec3 rotByQuat(vec3 p,quat q){ auto u = (q.x, q.y, q.z) auto t = 2 * cross(u, p) return p + q.w * t + cross(u, t) } ``` 假如要把向量A旋转到向量B的方向,这个旋转也可以用一个四元数表示,代码写作 ```cpp v = cross(a, b) q = normalize((v.x, v.y, v.z, 1 + dot(a, b))) ``` 当然,最重要的那一刻你一定期待着!怎么用四元数表示一个平滑旋转呢? 你可能会朴素的想到用lerp,也就是直接插值两个四元数的vector4,可以是可以...但是会破坏我最开始提到的第一条性质,所以在插值后我们还得做一个归一化使其恢复旋转四元数。结果是旋转的时候并非匀速旋转,会两头慢中间快,这个被称为NLerp ![](https://pic.feiqi3.cn/blogPic/fox_nlerp_rotation.gif) > 感觉缺陷好像有些不太明显.... 所以我们通常用Slerp,这是一种定义在球面上的插值 它的旋转角速度是匀速的 $$ \operatorname{Slerp}(q_0,q_1,t) =\frac{\sin((1-t)\theta)}{\sin\theta}\,q_0 +\frac{\sin(t\theta)}{\sin\theta}\,q_1 $$ ![](https://pic.feiqi3.cn/blogPic/fox_slerp_rotation.gif) 同时,四元数还有一个很有趣的性质,就是q和-q其实表示同一个朝向,用于表示旋转的时候,-q会从反方向旋转到目标 ![](https://pic.feiqi3.cn/blogPic/fox_slerp_rotation_neg.gif) 还有一个常见的需求是进行四元数的叠加,比如说先旋转A再旋转B到达角度Q,能不能直接合成一个直线旋转路径C?可以的,得到 $Q_c = Q_a * Q_b$ 其中Qc就是先旋转A在旋转B后的最终朝向,也可以理解为旋转的最短路径。 至此,你应该已经拥有了所有该有的数学背景知识,让我们继续深入角色动画吧! ## 骨骼 人体有骨骼,电脑里的虚拟小狐狸也要有骨骼才能动起来! 骨骼是由关节(Joint)和骨头(Bone)构成的,一个关节上可能连着好几根骨头,于是这是一个天然的树形结构。 关节旋转带动了骨头旋转,骨头旋转又带动了其另一头的关节旋转,但通常情况你不需要关心Bone这个概念,只需要知道joint到了哪,这就是骨骼动画的基础。 > 注: Bone是一个抽象的易于理解的概念,在真正的开发中并不存在 对于一个骨骼,我们只会存储该Joint相对于其父Joint的位置和旋转,也就是说以父Joint为原点的空间下的变换。 可能你有点蒙,那先从一个简单的机械臂开始吧: ![](https://pic.feiqi3.cn/blogPic/exec-a5b57470-020d-48b0-b5b4-5357f0dcd895.png) 如图,有三个个关节,J0 J1和J2,给定一些必要的条件,我们就可以算出每个关节在世界空间中的位置 约定: - $P_i$:$J_i$ 的世界坐标。 - $P_{i,i+1}$:$J_{i+1}$ 在 $J_i$ 局部坐标系中的相对位置。 - $Q_i$:$J_i$ 的世界旋转。 - $Q_{i,i+1}$:$J_{i+1}$ 相对 $J_i$ 的旋转。 因此,对于这条机械臂: $$P_1=P_0+Q_0P_{0,1}$$ $$\qquad Q_1=Q_0Q_{0,1}$$ $$P_2=P_1+Q_1P_{1,2}$$ 展开得到: $P_2=P_0+Q_0P_{0,1}+Q_0Q_{0,1}P_{1,2}$ 这是一个链式的传导!显而易见,影响是由根节点传导到末端,对于这种形式,我们称之为正向运动学,Forward Kinematics,简称FK 对于三维空间中的FK,我们可以简写为一个特别简介的形式, 直接把J1在J0空间中的变换表示为一个矩阵 $$ M_0=T_0R_0S_0 $$ > 注: 也就是在以J0为原点的空间中 于是可以简单得到 $$ P_1=M_0P_0 $$ $$ Q_1=R_0 * Q_0 $$ $$ P_2=M_1P_1 $$ $$ Q2=R1 * Q_1 $$ > 但是其实,你根本不用单独计算Q这个信息!因为对我们重要的只是Joint Position而已! 这样形式清爽多了! 对于一个骨架,我们用其给定的变换计算出来了所有的关节点位置,构成了一个姿势(Pose),这个初始姿势被称作为绑定姿势(BindingPose) 举个例子 ![](https://pic.feiqi3.cn/blogPic/digital_circus_bind_pose.webp) 这种双手平举,直直站立的资质是一种BindingPose,又称T-Pose,其他时候我们还能看到A-Pose,这里没有图片,不过样子是人的手臂斜向两侧下方伸去 --------------------------------- 小狐狸时间!你可能摩拳擦掌,把小狐狸的绑定姿势画出来了! 虽然很多具体实现还没讲...虽然还没让小狐狸动起来.... 但这是下篇文章的事情啦! 代码可参考: [DebugSkeletonRenderComponent.cpp](https://github.com/feiqi3/Renderer/blob/master/src/Components/DebugSkeletonRenderComponent.cpp) ![](https://pic.feiqi3.cn/blogPic/fox_skl_0001.webp) > 为什么在不明位置多了一根线?! ## 关于下一篇文章: 如何读取动画,如何采样动画,如何把动画应用到骨骼上?


评论