NOTE前四部分把”怎么把一帧画出来”讲完了。这部分换一个维度:怎么让一帧变成一段——动画与物理仿真。关键词是时间、插值、微分方程、约束。本章把骨骼蒙皮、弹簧质点、数值积分、约束求解的底层全串起来,为 Part 6 里的 PBD 布料、SPH 流体、FEM 做好数学准备。
目录
- 动画基础 — 插值理论 / SLERP / Catmull-Rom 样条
- 关键帧与骨骼动画 — 关键帧曲线 / 骨骼层次 / LBS / DQS
- 牛顿力学回顾 — 位置-速度-加速度 / 牛顿第二定律 / 机械能
- 弹簧质点系统 — 胡克定律 / 阻尼 / 拓扑(绳/布)
- 数值积分 — 显式欧拉 / Verlet / RK4 / 隐式欧拉 / 辛 / 稳定性
- 约束求解 — 位置投影 / PBD / SHAKE
- 刚体与流体简介(Part 6 前导)
二十四、动画基础
24.1 插值理论
24.1.1 一维线性插值
给定 ,线性插值:
性质:
- 端点插值:
- 线性性:关于两端点值的加权平均
- 分量独立:对向量/颜色/多维值按分量各自插值
template <typename T>T lerp(const T& a, const T& b, float u) { return a + u * (b - a); // 一行版本,对浮点更稳定}TIP写
a * (1-u) + b * u在数值上不如a + u * (b - a)稳定,而且多一次乘法。
24.1.2 为什么线性插值对旋转不够
用欧拉角线性插值会有万向节锁,用旋转矩阵逐元素插值会失去正交性。四元数是正确答案,但还要解决”单位球面上的插值”问题。
24.2 SLERP:球面线性插值
给定单位四元数 ,夹角 :
性质:
- 始终在单位球面上 → 结果仍是合法旋转
- 等角速度 → 动画看起来匀速旋转
- 最短大圆弧 → 走短路
📌 SLERP 的推导
设 。三个条件:
- (单位性)
展开 。代入解析候选 验证即可。
Eigen::Quaternionf slerp(Eigen::Quaternionf q0, Eigen::Quaternionf q1, float u) { float cos_t = q0.dot(q1); // 选最短路径:q 与 -q 表示同一旋转 if (cos_t < 0) { q1.coeffs() = -q1.coeffs(); cos_t = -cos_t; } // 极近时退化为 nlerp,避免 sin(theta) 趋零导致数值爆炸 if (cos_t > 0.9995f) { Eigen::Quaternionf r(q0.coeffs() + u * (q1.coeffs() - q0.coeffs())); r.normalize(); return r; } float theta = std::acos(cos_t); float s = std::sin(theta); float w0 = std::sin((1 - u) * theta) / s; float w1 = std::sin(u * theta) / s; return Eigen::Quaternionf(w0 * q0.coeffs() + w1 * q1.coeffs());}WARNING引擎里骨骼旋转插值几乎全用 nlerp + 归一化 而非严格 SLERP——误差肉眼不可见但快 3 倍。SLERP 只在精度关键(长时间轨道、科学计算)时才用。
24.3 Catmull-Rom 样条(平滑曲线穿过控制点)
Part 3 的贝塞尔/B 样条不一定经过控制点。动画里关键帧就是约束点,要求曲线必须穿过。Catmull-Rom 是经典选择:
曲线经过 ,切向量由 和 估计(中点差分)。 连续,适合相机路径、UI 补间动画。
Eigen::Vector3f catmull_rom(const Eigen::Vector3f& p0, const Eigen::Vector3f& p1, const Eigen::Vector3f& p2, const Eigen::Vector3f& p3, float u) { float u2 = u * u, u3 = u2 * u; return 0.5f * ( (2.0f * p1) + (-p0 + p2) * u + (2.0f*p0 - 5.0f*p1 + 4.0f*p2 - p3) * u2 + (-p0 + 3.0f*p1 - 3.0f*p2 + p3) * u3 );}二十五、关键帧与骨骼动画
25.1 关键帧曲线
关键帧 = 时间-值对(),中间时刻通过插值求值。工业引擎给每个关键帧配切线(线性/样条/阶跃/贝塞尔控制柄)来精细控制过渡。
template <typename T>struct Keyframe { float t; T value; enum Interp { LINEAR, CUBIC, STEP } interp = LINEAR; T in_tangent, out_tangent; // Hermite 插值用};
template <typename T>class AnimCurve { std::vector<Keyframe<T>> keys; // 按 t 升序public: T evaluate(float t) const { if (keys.empty()) return T{}; if (t <= keys.front().t) return keys.front().value; if (t >= keys.back().t) return keys.back().value; auto it = std::lower_bound(keys.begin(), keys.end(), t, [](const Keyframe<T>& k, float t){ return k.t < t; }); const auto& b = *it; const auto& a = *(it - 1); float u = (t - a.t) / (b.t - a.t); switch (a.interp) { case Keyframe<T>::STEP: return a.value; case Keyframe<T>::LINEAR: return lerp(a.value, b.value, u); case Keyframe<T>::CUBIC: return hermite(a.value, a.out_tangent, b.value, b.in_tangent, u); } return a.value; }};25.2 骨骼层次
骨骼是有根的树。每根骨头用两个变换刻画:
- 绑定姿势 bind pose
下的世界变换(静态) - 当前姿势 current pose:每帧根据关键帧曲线+IK 算出的世界变换(动态)
顶点绑定时记录的是”相对于 bind pose 的位置”。渲染时先把顶点用 逆 bind 矩阵 拉回骨头局部空间,再用当前 world 矩阵推到新世界位置。
struct Bone { int parent = -1; Eigen::Affine3f local; // 相对父骨的变换(关键帧驱动) Eigen::Affine3f world; // 当前世界变换(每帧更新) Eigen::Affine3f inverse_bind; // T-pose 下 world 的逆(bind 时算好)};
void update_world_transforms(std::vector<Bone>& bones) { for (size_t i = 0; i < bones.size(); ++i) { const auto& b = bones[i]; bones[i].world = (b.parent < 0) ? b.local : bones[b.parent].world * b.local; }}NOTE父子顺序很关键:遍历时必须保证父节点已更新。工程上骨骼数组通常按拓扑序排列,正向一次扫过即可。
25.3 线性混合蒙皮(LBS, Linear Blend Skinning)
每个顶点绑定若干骨头 + 权重(通常 ≤ 4):
Eigen::Vector3f skin_lbs(const Eigen::Vector3f& v_bind, const std::array<int, 4>& bone_ids, const std::array<float, 4>& weights, const std::vector<Bone>& bones) { Eigen::Vector3f v_out = Eigen::Vector3f::Zero(); for (int k = 0; k < 4; ++k) { if (weights[k] <= 0) continue; Eigen::Affine3f M = bones[bone_ids[k]].world * bones[bone_ids[k]].inverse_bind; v_out += weights[k] * (M * v_bind); } return v_out;}优点
缺点:糖果扭曲(candy wrapper)——绕肘关节旋转 180° 时体积急剧塌缩。
25.4 双四元数蒙皮(DQS, Dual Quaternion Skinning)
用对偶四元数表达刚体运动(旋转 + 平移),加权平均后仍保持等距——不会塌。Kavan 2008 论文是经典来源。
对偶四元数:,其中 是旋转部分, 编码了平移。
📌 DQS 的加权流程
- 每根骨头的世界变换转为对偶四元数
- 半球对齐:若 则取反(和 SLERP 同样的理由)
- 加权和:
- 归一化:
- 作用在顶点上 (记住单位对偶四元数作用顶点的公式)
struct DualQuat { Eigen::Quaternionf real, dual; DualQuat operator+(const DualQuat& o) const { return { {real.coeffs() + o.real.coeffs()}, {dual.coeffs() + o.dual.coeffs()} }; } DualQuat operator*(float s) const { return { {s * real.coeffs()}, {s * dual.coeffs()} }; } void normalize() { float n = real.norm(); real.coeffs() /= n; dual.coeffs() /= n; } Eigen::Vector3f transform(const Eigen::Vector3f& v) const { Eigen::Vector3f t = 2.0f * (dual * real.conjugate()).vec(); return real * v + t; }};WARNINGDQS 对非均匀缩放无能为力(对偶四元数只表达刚体运动)。如果骨头有缩放,要么回退 LBS,要么做 LBS + DQS 混合蒙皮(Optimized Centers of Rotation Skinning 等现代算法)。
二十六、牛顿力学回顾
NOTE这一章是给后面弹簧质点、刚体、流体用的共同基石。符号与术语统一:位置 、速度 、加速度 、力 。
26.1 运动学
匀加速运动的解析解():
26.2 牛顿第二定律
力的叠加:合力 = 各力的矢量和,。
26.3 常见力
| 力 | 形式 | 备注 |
|---|---|---|
| 重力 | 方向向下,量级 9.8 m/s² 或按场景缩放 | |
| 弹簧力(胡克) | 当前长度, 原长, 沿弹簧单位向量 | |
| 阻尼力 | 线性阻尼, 越大越耗能 | |
| 库伦摩擦 | 静摩擦 ≤ 阈值;动摩擦沿相对速度反向 |
26.4 机械能
能量漂移是评估数值积分器好坏的核心指标——保守系统的理想积分器应让 长期保持有界(辛积分器做得到,显式欧拉做不到,见 §28)。
二十七、弹簧质点系统
27.1 基本对象
struct Mass { Eigen::Vector3f x; // 当前位置 Eigen::Vector3f x_prev; // 上一步位置(Verlet 用) Eigen::Vector3f v; // 速度 Eigen::Vector3f f = Eigen::Vector3f::Zero(); // 累积力 float m = 1.0f; bool pinned = false; // 固定不动};
struct Spring { int i, j; // 两端质点索引 float k; // 弹簧系数 float L0; // 原长 float kd = 0.0f; // 结构阻尼(可选)};27.2 胡克力(含阻尼)
带阻尼的弹簧力,沿弹簧方向的相对速度被阻尼掉:
void apply_spring(Mass& a, Mass& b, const Spring& s) { Eigen::Vector3f d = b.x - a.x; float len = d.norm(); if (len < 1e-8f) return; Eigen::Vector3f e = d / len; Eigen::Vector3f v_rel = b.v - a.v; Eigen::Vector3f F = s.k * (len - s.L0) * e + s.kd * v_rel.dot(e) * e; a.f += F; // 牛顿第三定律 b.f += -F;}27.3 典型拓扑
27.3.1 绳(1D 链)
线性串接质点:springs.emplace_back(i, i+1, k, L0),一端或两端 pin 住。
27.3.2 布料(2D 网格)
| 弹簧种类 | 连接方式 | 物理作用 |
|---|---|---|
| 结构弹簧 | 水平 + 垂直邻居 | 抵抗拉伸 |
| 剪切弹簧 | 对角线 | 抵抗面内剪切形变 |
| 弯曲弹簧 | 跨一个节点的直线连接 | 抵抗面外弯折 |
void build_cloth_springs(int rows, int cols, float k_struct, float k_shear, float k_bend, float dx, std::vector<Spring>& out) { auto id = [cols](int r, int c){ return r * cols + c; }; // 结构 for (int r = 0; r < rows; ++r) for (int c = 0; c < cols; ++c) { if (c + 1 < cols) out.push_back({id(r,c), id(r,c+1), k_struct, dx}); if (r + 1 < rows) out.push_back({id(r,c), id(r+1,c), k_struct, dx}); } // 剪切 for (int r = 0; r + 1 < rows; ++r) for (int c = 0; c + 1 < cols; ++c) { float d = dx * std::sqrt(2.0f); out.push_back({id(r,c), id(r+1,c+1), k_shear, d}); out.push_back({id(r+1,c), id(r,c+1), k_shear, d}); } // 弯曲 for (int r = 0; r < rows; ++r) for (int c = 0; c + 2 < cols; ++c) out.push_back({id(r,c), id(r,c+2), k_bend, 2*dx}); for (int r = 0; r + 2 < rows; ++r) for (int c = 0; c < cols; ++c) out.push_back({id(r,c), id(r+2,c), k_bend, 2*dx});}27.3.3 杨氏模量与 k 的关系
弹簧系数 可以由材料参数推出:
- :杨氏模量(Pa)
- :等效截面积
- :原长
这让”弹簧系数”从凭感觉调变成”给材料类型”。
二十八、数值积分(核心)
WARNING整个物理仿真的工程难点几乎都压在数值积分器身上。选错会发散、能量漂移、抖动。先记住这三条原则:刚度越高,步长越小、显式方法能量正漂、隐式方法能量负漂、保守系统用辛积分器。
28.1 显式欧拉
优点:最简单,一眼看懂。
缺点:条件稳定 —— 对弹簧系统要求 ,且总是把能量注入系统(正能量漂移),长时间仿真会爆炸。
28.2 辛欧拉(Symplectic Euler,半隐式)
把位置更新改用新速度:
代码改动只有一行,但能量长期有界,游戏物理里的默认选择。
void symplectic_euler(Mass& p, float dt) { if (p.pinned) { p.f.setZero(); return; } p.v += dt * (p.f / p.m); p.x += dt * p.v; p.f.setZero();}28.3 Verlet 积分
28.3.1 位置 Verlet
消掉速度,只看位置:
速度作为差分估计 。约束投影型仿真(PBD、布料)首选。
void position_verlet(Mass& p, float dt) { if (p.pinned) { p.f.setZero(); return; } Eigen::Vector3f tmp = p.x; Eigen::Vector3f a = p.f / p.m; p.x = 2.0f * p.x - p.x_prev + a * dt * dt; p.x_prev = tmp; p.f.setZero();}28.3.2 速度 Verlet
二阶精度 + 辛,做分子动力学与天体仿真几乎必选。
28.4 Runge-Kutta 4(RK4)
经典四阶显式法,精度远高于欧拉:
WARNINGRK4 不是辛的——对保守系统仍会缓慢漂移能量。高刚度系统用 RK4 仍可能不稳(刚性问题 → 用隐式法)。
28.5 隐式欧拉
右侧的 依赖未知的 ,要解一个线性/非线性方程组。Baraff-Witkin 1998 的布料仿真就是隐式积分的开山之作——线性化后解一个稀疏线性系统:
其中 是力的雅可比(刚度矩阵), 是阻尼雅可比。
优点:无条件稳定。
代价:每步都要解线性系统;数值阻尼 使能量长期衰减(这正是”布料运动看起来黏黏的”的来源)。
28.6 稳定性与 CFL 条件
28.6.1 弹簧-质点系统的显式稳定性
线性振子 ,。显式欧拉的放大因子 总 > 1 → 任意步长都不稳(严格说能量单调增)。辛欧拉稳定条件:
28.6.2 CFL 条件(波方程)
信息传播速度 ,空间离散 ,则:
对弹簧:,,CFL 给出与稳定性条件一致的上限。实用建议:在稳定性阈值的 50% 以下选步长,留一半余量给非线性。
二十九、约束求解
WARNING很多”物理现象”本质是约束:绳不可拉伸、刚体两点距离不变、角色脚不穿地。直接用胡克建模就得要超大 ,逼到显式积分步长小得跑不动。把约束当约束本身求解(而不是当作软弹簧)是现代实时物理的主线。
29.1 位置投影
距离约束 。任意时刻若偏离,做一次投影修正:
- (固定点 )
- 为沿当前连线的单位向量
- 按反比质量分配修正,保证动量守恒
void project_distance(Mass& a, Mass& b, float L0) { float wa = a.pinned ? 0.0f : 1.0f / a.m; float wb = b.pinned ? 0.0f : 1.0f / b.m; float w_sum = wa + wb; if (w_sum < 1e-8f) return; Eigen::Vector3f d = b.x - a.x; float len = d.norm(); if (len < 1e-8f) return; Eigen::Vector3f e = d / len; float C = len - L0; a.x += (wa / w_sum) * C * e; b.x -= (wb / w_sum) * C * e;}29.2 Position Based Dynamics (PBD)
Müller 2006/2007 提出的现代范式。核心套路:
- 用显式/辛欧拉预测
- 迭代投影每条约束到 上
- 从修正后的位置反推新速度
void pbd_step(std::vector<Mass>& ps, const std::vector<Spring>& cs, const Eigen::Vector3f& g, float dt, int iters) { // 1) 预测 std::vector<Eigen::Vector3f> x_pred(ps.size()); for (size_t i = 0; i < ps.size(); ++i) { if (ps[i].pinned) { x_pred[i] = ps[i].x; continue; } ps[i].v += dt * g; x_pred[i] = ps[i].x + dt * ps[i].v; } // 2) 约束投影 for (int it = 0; it < iters; ++it) { for (const auto& s : cs) { Eigen::Vector3f& xi = x_pred[s.i]; Eigen::Vector3f& xj = x_pred[s.j]; Eigen::Vector3f d = xj - xi; float len = d.norm(); if (len < 1e-8f) continue; Eigen::Vector3f e = d / len; float C = len - s.L0; float wi = ps[s.i].pinned ? 0.0f : 1.0f / ps[s.i].m; float wj = ps[s.j].pinned ? 0.0f : 1.0f / ps[s.j].m; float w_sum = wi + wj; if (w_sum < 1e-8f) continue; xi += (wi / w_sum) * C * e; xj -= (wj / w_sum) * C * e; } } // 3) 更新速度与位置 for (size_t i = 0; i < ps.size(); ++i) { if (ps[i].pinned) continue; ps[i].v = (x_pred[i] - ps[i].x) / dt; ps[i].x = x_pred[i]; }}PBD 的性格:
- 几乎无条件稳定(都是位置修正,不会像力爆炸)
- 刚度通过”迭代次数”和”每次投影的比例因子”间接控制——不像胡克 直接对应物理
- XPBD (2016) 补上了拉格朗日乘子的严谨版本,让刚度变成物理参数
TIP游戏里 90% 的布料、绳、毛发都是 PBD/XPBD 实现。它和 Part 6 的 FEM/SPH 并不互斥——PBD 可以当作 FEM 的快速近似。
29.3 SHAKE / RATTLE(分子动力学侧路线)
SHAKE 是 Verlet 上的约束扩展,用拉格朗日乘子迭代直到约束误差小于阈值:
比 PBD 更强调数学严格性(蒙卡尔洛分子动力学要求严格保约束),图形里用得少,但理解它就知道 PBD 是一种”用位置投影逼近拉格朗日乘子”的近亲。
三十、刚体与流体概述(Part 6 前导)
30.1 刚体
质点的推广:带旋转的状态 。核心方程:
- :世界坐标惯性张量
- :合力矩
- 四元数运动学:(把角速度当纯四元数左乘)
碰撞响应用冲量法 , 由恢复系数 + 库伦摩擦锥求解。Part 6 会展开。
30.2 流体(欧拉 vs 拉格朗日)
- 欧拉法(网格)
2003 “Stable Fluids”,在固定网格上解 Navier-Stokes,烟、火焰常用 - 拉格朗日法(粒子)
(Smoothed Particle Hydrodynamics)、PBF (Position Based Fluids),液体自由表面常用
两者的核心都是 Navier-Stokes:
完整推导、SPH 核函数、PCISPH/PBF 的求解流程放 Part 6。
30.3 Part 5 → Part 6 的进度条
| 主题 | 本部分做到 | Part 6 展开 |
|---|---|---|
| 弹簧质点 | 推导 + 实现 + 拓扑 | XPBD、FEM、有限元连续体 |
| 积分器 | 欧拉 / Verlet / RK4 / 隐式 / 辛 | 指数积分器、IMEX 多尺度 |
| 约束 | PBD 基本框架 | XPBD 数学严格、收缩映射证明 |
| 刚体 | 牛-欧拉方程 + 惯性张量 | 冲量法、LCP 约束接触 |
| 流体 | Navier-Stokes 公式 | Stable Fluids / SPH / PBF |
小结
| 主题 | 工具 | canonical 位置 |
|---|---|---|
| 标量/向量插值 | lerp, Hermite, Catmull-Rom | §24 |
| 旋转插值 | SLERP / nlerp | §24.2 — 骨骼动画的基础 |
| 骨骼与蒙皮 | 骨骼层次 / LBS / DQS | §25 |
| 经典力学 | 牛顿第二定律 / 机械能 | §26 — 后面所有仿真共用的底 |
| 弹簧质点 | 胡克 + 阻尼 + 结构/剪切/弯曲 | §27 |
| 数值积分 | 显式/辛/Verlet/RK4/隐式 | §28 — 稳定性决定工程选择 |
| 约束投影 | PBD / SHAKE / 距离约束 | §29 — 实时布料与绳 |
| 刚体&流体 | 牛-欧拉方程 / Navier-Stokes | §30 概述 → Part 6 展开 |
下一部分进入 Part 6:现代前沿——PBR 管线、RTX/DXR、NeRF、Gaussian Splatting、XPBD、SPH 流体,把前五部分的底层知识串成当代图形学的全景。