
最近在写一个模拟项目X时需要在3D场景里把相机从A点平滑移动到B点同时视角还要跟着旋转。一开始我心想这还不简单无非是对3D坐标和四元数各做一次线性插值计算C写个lerp函数跑一遍就完事。真正动手后才意识到坐标插值确实简单四元数那边要是没搞清楚线性插值的几何意义结果会非常诡异。下面内容不需要太深的数学基础我会从坐标插值讲起再拆解四元数的Lerp、NLerp和Slerp最后给出可以直接编译运行的C实现并把我踩过的坑列出来。1. 位置和姿态的“插值空间”不是一回事1.1 为什么3D坐标可以直接线性插值在三维空间里位置向量就是欧氏空间的一个点。这个空间有非常良好的结构任意两个位置 P0、P1 之间做加权平均 (1-t)P0 tP1结果仍然是空间中的一个点而且所有中间点都在连接两者的直线上。这意味着我们不需要“检查插值结果合不合法”──任何插值出来的坐标都在三维空间内语义上一定是“一个位置”。这一点看起来太基础但它是后面理解四元数插值的对照物。位置插值允许你直接在分量上做线性混合因为我们生活的三维位置空间是一个线性空间旋转不是。旋转没有全局一致的“方向分量”可以直接做四则运算。四元数虽然也有四个分量但真正能表示旋转的四元数必须落在四维单位球面上不是整个四维空间都合法。所以四元数那四个分量不能像位置坐标那样随意线性组合。更直白地说坐标插值是在“平直的尺子”上取点四元数插值是在“球的表面”上取点。在尺子上做乘法除法没问题在球面上不能直接走直线直线会穿进球体内部一旦球面约束被破坏它的语义就不再是旋转了。1.2 欧拉角做线性插值的三个大坑有人可能会问姿态用欧拉角不行吗三个角度 t 从0到1做线性插值看起来也没什么明显问题。实际上有三个大坑。第一角度回绕。比如一个轴从 170 度转到 -170 度直接对角度 lerp中间值会沿着 170 - 0 - -170多绕了 340 度。需要额外写回绕逻辑把 -170 映射成 190但回绕逻辑只解决了数值连续性不能保证旋转轴和方向之间关系的连续性。第二旋转顺序依赖。欧拉角必须规定绕轴顺序常用 XYZ、ZYX 等同一个三元组在不同顺序下的最终姿态完全不同。如果你在关键帧 A 和关键帧 B 里分别用了不同约定或者中间状态需要统一成某一种顺序线性插值出来的三元组即使数值连续对应的旋转路径也可能很奇怪动画表现不可控。第三万向锁。某些角度附近系统会损失一个自由度插值过程中姿态会突然“卡住”或抖动尤其在俯仰角接近 ±90 度时。这几个原因加在一起欧拉角几乎不适合做任意旋转之间的平滑插值。旋转矩阵也不适合直接 lerp。两个合法旋转矩阵的元素平均之后行列式几乎不可能还是1列向量之间的正交性也被破坏结果矩阵不再是一个旋转矩阵。简单项目里可以用 Gram-Schmidt 强行正交化但插值路径上会出现速度不均甚至明显违和。相比之下单位四元数只要保持模长为1始终对应一个合法旋转。1.3 单位四元数的球面几何直觉单位四元数可以写成 q (cos(θ/2), n * sin(θ/2))其中 n 是单位旋转轴θ 是旋转角。它在四维空间里是一个单位向量所以所有“合法旋转”在四维空间里构成一个单位球面。两个姿态的插值本质是在这个球面上从 q0 走到 q1。球面上的最短路径是大圆弧。如果直接用直线插值再归一化相当于先穿进球体内部再投影回球面走的是弦而不是大圆弧。弦投影出来的角速度并不均匀。如果希望旋转速度恒定就必须沿大圆弧插值也就是后面要写的 Slerp。这些概念看着抽象但代码一旦落地表现区别是很明显的。2. 三维坐标线性插值的实现与精度问题2.1 基础公式和 C 写法三维坐标线性插值的公式很简单P(t) P0 t * (P1 - P0)或者写成 (1-t)P0 tP1。我在模拟项目X里用的是第一种形式因为它更符合“从起点出发往目标方向走 t 倍”的直觉。C 里定义一个最小可用的 Vec3 结构#include cmath struct Vec3 { double x, y, z; Vec3() : x(0), y(0), z(0) {} Vec3(double x_, double y_, double z_) : x(x_), y(y_), z(z_) {} Vec3 operator(const Vec3 o) const { return Vec3(x o.x, y o.y, z o.z); } Vec3 operator-(const Vec3 o) const { return Vec3(x - o.x, y - o.y, z - o.z); } Vec3 operator*(double s) const { return Vec3(x * s, y * s, z * s); } }; Vec3 LerpPosition(const Vec3 a, const Vec3 b, double t) { return a (b - a) * t; }这段代码里我故意用 double 而不是 float。原因后面会说。运算符重载可以简化表达式如果你的游戏引擎已经有现成数学库直接用它的 Lerp 也行核心逻辑是一样的。2.2 “线性”和“匀速”不是自动绑定实际项目里我见过一种误用把 t 直接用“帧数”来算比如第 n 帧 / 总帧数。如果两个关键点之间的空间距离正好一样这么做没毛病但如果是多段路径每段长度不一样仍按帧数平均分配那每段的速度就不一样。比如第一段距离 1 米第二段 10 米你各用 50% 的时间第二段速度是第一段的10倍动画看起来会忽快忽慢。正确做法是先计算每段长度把长度作为权重分配时间。假设总共有三段长度为 L1、L2、L3那么全局参数 t 落在哪一段就用哪一段的局部参数继续插值。简单实现可以这样遍历线段用t * totalLength减去已经过的长度剩下的除以当前段长度得到段内 t。这个方法只适合折线路径但它给了你一个正确的思考起点。如果想让路径本身更平滑就要换成样条插值。位置用 Catmull-Rom 样条或者 Bezier姿态用 Squad 或 Slerp 套样条但不能再用折线法。样条是另一个大主题这里只要意识到“线性插值不等于路点系统”就够了。2.3 大坐标与浮点精度的低层细节另一个容易忽略的问题是 float 精度。如果你的场景坐标动辄上万单位比如地形、城市场景、大尺度宇宙模拟float 的有效位数只有大约 7 位十进制数在坐标值 10000 附近两个相邻可表示数之间的间隔超过 0.0005插值步长小于这个间隔时位置会“抖动”或“卡住”。同样的计算在 double 下完全没问题因为 double 有效位数约 15 到 16 位。我之前写的坐标插值函数一开始用的是 float单测在小范围跑全部通过放到大尺度场景就发现物体不是平滑移动而是一顿一顿地跳。后来把所有中间计算改成 double问题立刻消失。所以结论是在小 Demo 里 float 可能看不出问题但只要坐标范围大请优先用 double 做 CPU 端的插值计算。渲染端的 float 是为了 GPU 带宽考虑的不代表 CPU 端也得用它。3. 四元数插值从 Lerp 到 Slerp3.1 线性插值之后必须单位化四元数插值最常见的误区是直接把两个四元数的四个分量线性 lerp得到结果就直接用。这样做在大部分情况下会得到一个“模长不是1”的四元数。非单位四元数对应到旋转矩阵时矩阵的行列式和正交性都不满足要求表现就是模型被缩放、拉伸甚至产生错误朝向。所以无论你用的是最简单的方法还是高级方法最后一步都必须把结果归一化。归一化非常简单四个分量各自除以四元数长度。但请注意归一化只能修正模长不能修正插值路径。它不会把一个“穿入球体内部”的路径变成大圆弧只是把路径上的每个点重新投影到球面上。这会让旋转速度在视觉上不均匀。3.2 NLerp最常见的快速近似归一化后的线性插值通常称为 Normalized Lerp简称 NLerpq(t) normalize(q0 t * (q1 - q0))也可以写成normalize((1-t)q0 t*q1)。它的计算量很小只需要四维向量的乘加和一个 normalize连三角函数都不需要。这使它非常适合骨骼动画顶点蒙皮这类一次要处理上万个四元数的场景。但 NLerp 的问题是角速度不均匀。因为在四维单位球面里它沿的是连接 q0 和 q1 的弦而不是球面大圆弧。弦上等距点投影到球面后中间部分对应的圆心角变化比两端更快视觉上旋转会先慢后快再慢。好在当 q0 和 q1 的夹角不大时例如小于10度弦和大圆弧几乎重合误差可以忽略。3.3 Slerp球面线性插值要严格保持角速度恒定就要用球面线性插值即 Slerp。设 q0 与 q1 在四维单位球面上的夹角为 θ插值公式为q(t) (sin((1-t)θ) / sinθ) * q0 (sin(tθ) / sinθ) * q1这个公式来自大圆弧的几何当 t0 时第一项是 sinθ/sinθ * q0 q0第二项为0t1 时反过来。中间项按正弦权重混合正好保证插值点始终在单位球面上且扫过弧长的速度恒定。下面是 C 实现#include cmath #include algorithm struct Quat { double x, y, z, w; Quat() : x(0), y(0), z(0), w(1) {} Quat(double x_, double y_, double z_, double w_) : x(x_), y(y_), z(z_), w(w_) {} Quat operator(const Quat o) const { return Quat(x o.x, y o.y, z o.z, w o.w); } Quat operator-(const Quat o) const { return Quat(x - o.x, y - o.y, z - o.z, w - o.w); } Quat operator*(double s) const { return Quat(x * s, y * s, z * s, w * s); } double Dot(const Quat o) const { return x * o.x y * o.y z * o.z w * o.w; } Quat Normalized() const { double len std::sqrt(x*x y*y z*z w*w); if (len 1e-12) return *this; double inv 1.0 / len; return Quat(x * inv, y * inv, z * inv, w * inv); } }; Quat NormalizedLerp(const Quat a, const Quat b, double t) { return (a (b - a) * t).Normalized(); } Quat Slerp(const Quat a, const Quat b, double t) { Quat q0 a.Normalized(); Quat q1 b.Normalized(); double dot q0.Dot(q1); if (dot 0.0) { q1 q1 * -1.0; dot -dot; } dot std::clamp(dot, -1.0, 1.0); double theta std::acos(dot); const double eps 1e-6; if (theta eps) { return NormalizedLerp(q0, q1, t); } double sinTheta std::sin(theta); double scale0 std::sin((1.0 - t) * theta) / sinTheta; double scale1 std::sin(t * theta) / sinTheta; return (q0 * scale0 q1 * scale1).Normalized(); }实现里做了几件容易漏掉的事先把两个四元数 normalize。输入来自文件或手工配置时可能并不是严格的单位长度这会污染点积和夹角。检查点积。如果 dot 0就把 q1 取反然后重算 dot。这部分是保证“最短路径”的关键下个章节会专门讲。dot 用 clamp 限制在 [-1, 1]。浮点误差可能让点积变成 1.0000001直接 acos 会得到 NaN。θ 很小时回退到 NLerp避免除零和 acos 在 1 附近的数值不稳定。3.4 NLerp 和 Slerp 到底怎么选我常用下面这个表格做对比比空口解释直观得多。维度NLerpSlerp计算量一次乘加 normalize无三角函数acos、sin、两次加权 normalize明显更重角速度不均匀中段偏快两端偏慢沿大圆弧匀速实现复杂度极低需要处理点积正负、clamp、小角度回退适用场景骨骼蒙皮、粒子等数量庞大的实时插值相机过场、机械臂、少量高精度对象小角度表现与 Slerp 几乎无差别表现稳定但计算成本更高如果项目里旋转对象数量少比如只有一个相机、一个机械臂末端直接 Slerp 没任何问题。如果对象数量多顶点动画一次要处理几千个骨骼那 NLerp 是更务实的方案。现代引擎中也有用双四元数做蒙皮的那是另一个层次的话题。4. 一个可直接使用的 Transform 插值实现4.1 完整代码骨架前面给了 Vec3 和独立的插值函数这里把它们组装成一个可用的 Transform 插值工具。下面这段代码包含 Vec3、Quat、Transform 以及三个插值函数没有外部依赖C17 及以上可以直接编译运行。#include cmath #include algorithm struct Vec3 { double x, y, z; Vec3() : x(0), y(0), z(0) {} Vec3(double x_, double y_, double z_) : x(x_), y(y_), z(z_) {} Vec3 operator(const Vec3 o) const { return Vec3(x o.x, y o.y, z o.z); } Vec3 operator-(const Vec3 o) const { return Vec3(x - o.x, y - o.y, z - o.z); } Vec3 operator*(double s) const { return Vec3(x * s, y * s, z * s); } }; Vec3 LerpPosition(const Vec3 a, const Vec3 b, double t) { return a (b - a) * t; } struct Quat { double x, y, z, w; Quat() : x(0), y(0), z(0), w(1) {} Quat(double x_, double y_, double z_, double w_) : x(x_), y(y_), z(z_), w(w_) {} Quat operator(const Quat o) const { return Quat(x o.x, y o.y, z o.z, w o.w); } Quat operator-(const Quat o) const { return Quat(x - o.x, y - o.y, z - o.z, w - o.w); } Quat operator*(double s) const { return Quat(x * s, y * s, z * s, w * s); } double Dot(const Quat o) const { return x * o.x y * o.y z * o.z w * o.w; } Quat Normalized() const { double len std::sqrt(x*x y*y z*z w*w); if (len 1e-12) return *this; double inv 1.0 / len; return Quat(x * inv, y * inv, z * inv, w * inv); } }; Quat NormalizedLerp(const Quat a, const Quat b, double t) { return (a (b - a) * t).Normalized(); } Quat Slerp(const Quat a, const Quat b, double t) { Quat q0 a.Normalized(); Quat q1 b.Normalized(); double dot q0.Dot(q1); if (dot 0.0) { q1 q1 * -1.0; dot -dot; } dot std::clamp(dot, -1.0, 1.0); double theta std::acos(dot); const double eps 1e-6; if (theta eps) { return NormalizedLerp(q0, q1, t); } double sinTheta std::sin(theta); double scale0 std::sin((1.0 - t) * theta) / sinTheta; double scale1 std::sin(t * theta) / sinTheta; return (q0 * scale0 q1 * scale1).Normalized(); } struct Transform { Vec3 position; Quat rotation; }; Transform LerpTransform(const Transform a, const Transform b, double t) { Transform result; result.position LerpPosition(a.position, b.position, t); result.rotation Slerp(a.rotation, b.rotation, t); return result; }这段代码没有把数学库做全比如缺四元数乘法、旋转向量、矩阵转换但作为插值功能已经够了。集成进项目时可以替换成自己引擎里的 Vec3 和 Quat核心逻辑不用变。4.2 为什么这样设计而不是直接插 4x4 矩阵可能会有人问Transform 不就是位置加旋转直接插值 4x4 变换矩阵不行吗如果你真的做了会立刻遇到两个问题。第一矩阵线性平均后不再是相似变换旋转部分的正交性被破坏第二即使你用正交化强行修正旋转部分平移部分的插值路径也未必符合预期。把 Transform 拆成 position 和 rotation 两个字段各自用最合适的数学工具这是图形学里最常见的做法。代码里还有一处容易被忽视Slerp 内部对输入做了 Normalized输出又 Normalized 一次。输入 Normalize 是防止脏数据污染点积计算输出 Normalize 是为了消除中间运算的数值误差。两次 normalize 在性能上当然有损耗但换来的是长期运行也不会漂移。4.3 验证插值结果的小实验拿到代码后别急着直接上线。我一般会做一个简单验证让 q0 是单位四元数q1 绕 Y 轴旋转 90 度然后把一个向量 (1, 0, 0) 分别用 q0、中间四元数、q1 旋转观察向量终点是否按 45 度均匀转动。同时可以每隔 0.1 取一个中间值打印旋转角与 t 的关系Slerp 应当是近似线性NLerp 会有些弯曲。如果你的引擎里没有现成的四元数旋转向量函数可以临时写一段把向量当成纯四元数执行 q * p * conj(q)最后取 xyz。这段验证代码不需要多完善只要能看到中间方向的连续变化即可。很多插值 bug 其实在“中间方向跳变”这一眼就能看出来。4.4 性能优化补充如果需要大量插值比如粒子系统每条路径都插值四元数Slerp 里的 acos、sin、sqrt 确实不便宜。有两种优化思路。一是直接用 NLerp牺牲一点角速度均匀性换回巨大性能收益。二是如果必须用 Slerp可以缓存每次插值的关键帧组合比如把 q0、q1 以及点积、θ 缓存起来只对 t 做变化这样能减少重复的 acos 和 sin。实测下来一个相机路径上几千个采样点缓存后耗时依然很低。另外可以把 double 中间计算换成 float但要注意点积接近 1 时的抖动更明显。我个人建议在 CPU 端用 double 计算存储和同步用 float这是成本最低的稳定性方案。5. 我踩过的几个高频坑和排查思路5.1 插值往“远路”跑有一次我在调相机过渡相机从正面转 60 度到侧面结果它先往反方向绕了 300 度看起来就像“故意绕一圈”。当时第一反应是角度算错了排查完角度没有问题最后发现问题出在四元数的双重覆盖q 和 -q 表示同一个旋转。两个单位四元数的点积如果是负的说明它们在四维球面上“隔着圆心相对”直接插值会走比较长的那条弧。解决方法是插值前判断点积小于 0 就把 q1 取反。改完之后动画立刻变成最短路径。给这个坑留一个判断方法把q0.Dot(q1)打印出来如果是负数你几乎可以确认要绕远路。5.2 模型缩放或变形有次在项目里看到角色动画切到一半模型突然被压扁像是一个非均匀缩放。查了很久最后发现是某个四元数插值函数没有 normalize。非单位四元数转旋转矩阵时矩阵的列向量长度不再是 1正交性也丢失结果就是缩放和剪切。于是我在函数末尾补上Normalized()问题消失。排查这类问题最简单的方式是在插值返回处打印四元数模长。如果偏离 1 超过 1e-5说明有人绕过了归一化步骤。特别是 NLerp公式里面很容易忘掉最后的 normalize出错概率很高。5.3 t 参数把线性插值当成低通滤波我以前见过一种很常见的相机平滑写法每帧执行current lerp(current, target, dt)想着用 dt 控制速度。这其实不是线性插值而是指数趋近。dt是上一帧的时间增量不是 0 到 1 的比例用它做插值系数时每帧只往目标移动一小步移动比例相同剩余距离越大单帧位移越大速度表现为“先快后慢且永不到达”。在四元数里也一样Slerp 的 t 应当是弧长比例不是每帧固定系数。正确的线性插值流程是记录起点、终点的总时长t elapsed / duration然后一次插值。如果要做平滑跟随要么明确采用低通滤波或临界阻尼平滑而不是套用 lerp。5.4 小角度附近的抖动另一个高频坑是姿态接近相同时插值结果剧烈抖动。原理前面提过点积接近 1 时acos 的导数趋向无穷微小的浮点误差会被放大。表现为相机已经几乎静止但画面还在轻微抖动。解决办法是给 θ 设一个阈值小于阈值直接用 NLerp还可以在目标接近当前姿态时直接返回目标加一个小死区。测试时打印点积通常都在 0.9995 以上说明你的阈值起效了。这个坑在实际项目中比想象中频繁。尤其是手柄、VR 控制器这种带有噪声输入的场景抖动如果叠加插值会非常明显。6. 实际选型建议和扩展方向6.1 不同项目里怎么选插值方案我按常见项目类型给一个选型参考相机过场、机械臂路径、VR 手柄平滑数量少、平滑要求高优先 Slerp。位置部分用普通 Lerp 或样条。骨骼动画、大量粒子、顶点蒙皮性能敏感用 NLerp。如果要在骨骼混合里兼顾质量可以考虑双四元数。程序化生成的移动物体大多是位置插值姿态如果有旋转变化按对象数量决定。关键帧动画系统不同关键帧之间的连续插值数量可控用 Slerp 也很好。选型不是绝对的。实测下来短时间内的小角度旋转NLerp 和 Slerp 的视觉差几乎无法察觉大角度旋转时区别才明显。所以先看旋转幅度再来定方案比盲目追求“Slerp 更精确”更实际。6.2 多关键帧位置用样条、姿态用 Squad如果路径不止两个关键帧而是每隔几十毫秒一个朝向那么两两 Slerp 的拼接会造成一阶不连续动画在关键点处会“顿”一下。位置部分可以用 Catmull-Rom 样条姿态部分对应的是 Squad它通过临时的四元数控制点对四元数序列做二阶平滑。Squad 的实现比 Slerp 复杂一些但它本质上是先算出辅助四元数再对 Slerp 做一次 Slerp。如果你目前需求只是两帧过渡完全不用管 Squad一旦做动画系统或者路径录制回放它值得研究。这里只提扩展方向不展开实现是因为很多项目做到这一步时更需要的是先保证基础插值没有 bug。6.3 一个调试小技巧把姿态变成坐标架最后分享一个我实际用得很顺手的调试方法不要在数字里盯着四元数四个分量看把插值得到的方向变成一个小坐标架——即三个互相垂直的单位向量。每个中间姿态渲染出一条红色、绿色、蓝色的轴你立刻能看出旋转路径是否连续、是否绕远路、是否在某个位置跳变。这个方法在相机和机械臂调试里救过我很多次比打日志高效得多。坐标插值的调试可以简单点直接把位置点按 t 密集采样渲染成小球观察是否落在预期直线上。如果是多段路径特意检查每段连接点是否重合。能做到这一层3D坐标和四元数的线性插值基本就不会再出幺蛾子了。