4683 字
23 分钟
计算机图形学笔记(五):动画与物理仿真
NOTE

前四部分把”怎么把一帧画出来”讲完了。这部分换一个维度:怎么让一帧变成一段——动画与物理仿真。关键词是时间、插值、微分方程、约束。本章把骨骼蒙皮、弹簧质点、数值积分、约束求解的底层全串起来,为 Part 6 里的 PBD 布料、SPH 流体、FEM 做好数学准备。

目录#

  1. 动画基础 — 插值理论 / SLERP / Catmull-Rom 样条
  2. 关键帧与骨骼动画 — 关键帧曲线 / 骨骼层次 / LBS / DQS
  3. 牛顿力学回顾 — 位置-速度-加速度 / 牛顿第二定律 / 机械能
  4. 弹簧质点系统 — 胡克定律 / 阻尼 / 拓扑(绳/布)
  5. 数值积分 — 显式欧拉 / Verlet / RK4 / 隐式欧拉 / 辛 / 稳定性
  6. 约束求解 — 位置投影 / PBD / SHAKE
  7. 刚体与流体简介(Part 6 前导)

二十四、动画基础#

24.1 插值理论#

24.1.1 一维线性插值#

给定 (t0,f0),(t1,f1)(t_0, f_0), (t_1, f_1),线性插值:

f(t)=(1u)f0+uf1,u=tt0t1t0[0,1]f(t) = (1-u) f_0 + u f_1, \qquad u = \frac{t - t_0}{t_1 - t_0} \in [0, 1]

性质:

  • 端点插值:f(t0)=f0,f(t1)=f1f(t_0) = f_0, f(t_1) = f_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:球面线性插值#

给定单位四元数 q0,q1\mathbf{q}_0, \mathbf{q}_1,夹角 θ=arccos(q0q1)\theta = \arccos(\mathbf{q}_0 \cdot \mathbf{q}_1):

slerp(q0,q1,u)=sin((1u)θ)sinθq0+sin(uθ)sinθq1\text{slerp}(\mathbf{q}_0, \mathbf{q}_1, u) = \frac{\sin((1-u)\theta)}{\sin\theta} \mathbf{q}_0 + \frac{\sin(u\theta)}{\sin\theta} \mathbf{q}_1

性质:

  • 始终在单位球面上 → 结果仍是合法旋转
  • 等角速度 → 动画看起来匀速旋转
  • 最短大圆弧 → 走短路

📌 SLERP 的推导#

q(u)=a(u)q0+b(u)q1\mathbf{q}(u) = a(u)\mathbf{q}_0 + b(u)\mathbf{q}_1。三个条件:

  1. q(0)=q0a(0)=1,b(0)=0\mathbf{q}(0) = \mathbf{q}_0 \Rightarrow a(0) = 1, b(0) = 0
  2. q(1)=q1a(1)=0,b(1)=1\mathbf{q}(1) = \mathbf{q}_1 \Rightarrow a(1) = 0, b(1) = 1
  3. q(u)=1\|\mathbf{q}(u)\| = 1(单位性)

展开 q(u)2=a2+b2+2abcosθ=1\|\mathbf{q}(u)\|^2 = a^2 + b^2 + 2ab\cos\theta = 1。代入解析候选 a=sin((1u)θ)/sinθ,b=sin(uθ)/sinθa = \sin((1-u)\theta)/\sin\theta, b = \sin(u\theta)/\sin\theta 验证即可。

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 是经典选择:

C(u)=12(1uu2u3)(0200101025411331)(P0P1P2P3)\mathbf{C}(u) = \tfrac{1}{2}\begin{pmatrix} 1 & u & u^2 & u^3 \end{pmatrix} \begin{pmatrix} 0 & 2 & 0 & 0 \\ -1 & 0 & 1 & 0 \\ 2 & -5 & 4 & -1 \\ -1 & 3 & -3 & 1 \end{pmatrix} \begin{pmatrix} \mathbf{P}_0 \\ \mathbf{P}_1 \\ \mathbf{P}_2 \\ \mathbf{P}_3 \end{pmatrix}

曲线经过 P1,P2\mathbf{P}_1, \mathbf{P}_2,切向量由 (P2P0)/2(\mathbf{P}_2 - \mathbf{P}_0)/2(P3P1)/2(\mathbf{P}_3 - \mathbf{P}_1)/2 估计(中点差分)。C1C^1 连续,适合相机路径、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 关键帧曲线#

关键帧 = 时间-值对(ti,vit_i, v_i),中间时刻通过插值求值。工业引擎给每个关键帧配切线(线性/样条/阶跃/贝塞尔控制柄)来精细控制过渡。

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):

vskinned=kwk(MkMkbind,1)vbind,kwk=1\mathbf{v}_{\text{skinned}} = \sum_{k} w_k \, (\mathbf{M}_k \cdot \mathbf{M}_k^{\text{bind}, -1}) \, \mathbf{v}_{\text{bind}}, \qquad \sum_k w_k = 1
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 论文是经典来源。

对偶四元数:q^=qr+ϵqd\hat{\mathbf{q}} = \mathbf{q}_r + \epsilon \mathbf{q}_d,其中 qr\mathbf{q}_r 是旋转部分,qd=12tqr\mathbf{q}_d = \tfrac{1}{2} \mathbf{t} \mathbf{q}_r 编码了平移。

📌 DQS 的加权流程#

  1. 每根骨头的世界变换转为对偶四元数 q^k\hat{\mathbf{q}}_k
  2. 半球对齐:若 qr(0)qr(k)<0\mathbf{q}_r^{(0)} \cdot \mathbf{q}_r^{(k)} < 0 则取反(和 SLERP 同样的理由)
  3. 加权和:q^=kwkq^k\hat{\mathbf{q}} = \sum_k w_k \hat{\mathbf{q}}_k
  4. 归一化:q^q^/qr\hat{\mathbf{q}} \leftarrow \hat{\mathbf{q}} / \|\mathbf{q}_r\|
  5. 作用在顶点上 v=q^v\mathbf{v}' = \hat{\mathbf{q}} \cdot \mathbf{v}(记住单位对偶四元数作用顶点的公式)
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;
}
};
WARNING

DQS 对非均匀缩放无能为力(对偶四元数只表达刚体运动)。如果骨头有缩放,要么回退 LBS,要么做 LBS + DQS 混合蒙皮(Optimized Centers of Rotation Skinning 等现代算法)。


二十六、牛顿力学回顾#

NOTE

这一章是给后面弹簧质点、刚体、流体用的共同基石。符号与术语统一:位置 x\mathbf{x}、速度 v\mathbf{v}、加速度 a\mathbf{a}、力 F\mathbf{F}

26.1 运动学#

v(t)=dxdt,a(t)=dvdt=d2xdt2\mathbf{v}(t) = \frac{d\mathbf{x}}{dt}, \qquad \mathbf{a}(t) = \frac{d\mathbf{v}}{dt} = \frac{d^2\mathbf{x}}{dt^2}

匀加速运动的解析解a=const\mathbf{a} = \text{const}):

v(t)=v0+at,x(t)=x0+v0t+12at2\mathbf{v}(t) = \mathbf{v}_0 + \mathbf{a}t, \qquad \mathbf{x}(t) = \mathbf{x}_0 + \mathbf{v}_0 t + \tfrac{1}{2}\mathbf{a}t^2

26.2 牛顿第二定律#

F=ma=md2xdt2\mathbf{F} = m\mathbf{a} = m\frac{d^2\mathbf{x}}{dt^2}

力的叠加:合力 = 各力的矢量和,a=(iFi)/m\mathbf{a} = (\sum_i \mathbf{F}_i)/m

26.3 常见力#

形式备注
重力Fg=mg\mathbf{F}_g = m\mathbf{g}g\mathbf{g} 方向向下,量级 9.8 m/s² 或按场景缩放
弹簧力(胡克)Fs=k(L0)e^\mathbf{F}_s = -k(\ell - L_0) \hat{\mathbf{e}}\ell 当前长度,L0L_0 原长,e^\hat{\mathbf{e}} 沿弹簧单位向量
阻尼力Fd=γv\mathbf{F}_d = -\gamma \mathbf{v}线性阻尼,γ\gamma 越大越耗能
库伦摩擦FfμFN\|\mathbf{F}_f\| \leq \mu \|\mathbf{F}_N\|静摩擦 ≤ 阈值;动摩擦沿相对速度反向

26.4 机械能#

E=K+U,K=12mv2,U=mgh+12k(L0)2+E = K + U, \qquad K = \tfrac{1}{2} m \|\mathbf{v}\|^2, \qquad U = mgh + \tfrac{1}{2} k (\ell - L_0)^2 + \cdots

能量漂移是评估数值积分器好坏的核心指标——保守系统的理想积分器应让 EE 长期保持有界(辛积分器做得到,显式欧拉做不到,见 §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 胡克力(含阻尼)#

带阻尼的弹簧力,沿弹簧方向的相对速度被阻尼掉:

Fij=k(L0)e^kd(vije^)e^,e^=xjxi\mathbf{F}_{ij} = -k(\ell - L_0)\hat{\mathbf{e}} - k_d (\mathbf{v}_{ij} \cdot \hat{\mathbf{e}}) \hat{\mathbf{e}}, \qquad \hat{\mathbf{e}} = \frac{\mathbf{x}_j - \mathbf{x}_i}{\ell}
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 的关系#

弹簧系数 kk 可以由材料参数推出:

k=EAL0k = \frac{E \cdot A}{L_0}
  • EE:杨氏模量(Pa)
  • AA:等效截面积
  • L0L_0:原长

这让”弹簧系数”从凭感觉调变成”给材料类型”。


二十八、数值积分(核心)#

WARNING

整个物理仿真的工程难点几乎都压在数值积分器身上。选错会发散、能量漂移、抖动。先记住这三条原则:刚度越高,步长越小显式方法能量正漂、隐式方法能量负漂保守系统用辛积分器

28.1 显式欧拉#

vn+1=vn+Δtan,xn+1=xn+Δtvn\mathbf{v}^{n+1} = \mathbf{v}^n + \Delta t \, \mathbf{a}^n, \qquad \mathbf{x}^{n+1} = \mathbf{x}^n + \Delta t \, \mathbf{v}^n

优点:最简单,一眼看懂。

缺点:条件稳定 —— 对弹簧系统要求 Δt<2/ω,ω=k/m\Delta t < 2/\omega, \omega = \sqrt{k/m},且总是把能量注入系统(正能量漂移),长时间仿真会爆炸。

28.2 辛欧拉(Symplectic Euler,半隐式)#

把位置更新改用新速度:

vn+1=vn+Δtan,xn+1=xn+Δtvn+1\mathbf{v}^{n+1} = \mathbf{v}^n + \Delta t \, \mathbf{a}^n, \qquad \mathbf{x}^{n+1} = \mathbf{x}^n + \Delta t \, \mathbf{v}^{n+1}

代码改动只有一行,但能量长期有界,游戏物理里的默认选择。

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#

消掉速度,只看位置:

xn+1=2xnxn1+Δt2an\mathbf{x}^{n+1} = 2\mathbf{x}^n - \mathbf{x}^{n-1} + \Delta t^2 \, \mathbf{a}^n

速度作为差分估计 v(xn+1xn1)/(2Δt)\mathbf{v} \approx (\mathbf{x}^{n+1} - \mathbf{x}^{n-1})/(2\Delta t)。约束投影型仿真(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#

xn+1=xn+Δtvn+12Δt2an\mathbf{x}^{n+1} = \mathbf{x}^n + \Delta t \, \mathbf{v}^n + \tfrac{1}{2}\Delta t^2 \, \mathbf{a}^nvn+1=vn+12Δt(an+an+1)\mathbf{v}^{n+1} = \mathbf{v}^n + \tfrac{1}{2}\Delta t \, (\mathbf{a}^n + \mathbf{a}^{n+1})

二阶精度 + 辛,做分子动力学与天体仿真几乎必选。

28.4 Runge-Kutta 4(RK4)#

经典四阶显式法,精度远高于欧拉:

k1=f(xn,tn),k2=f(xn+Δt2k1,tn+Δt2),k3=f(xn+Δt2k2,tn+Δt2),k4=f(xn+Δtk3,tn+Δt),xn+1=xn+Δt6(k1+2k2+2k3+k4).\begin{aligned} \mathbf{k}_1 &= f(\mathbf{x}^n,\, t^n), \\ \mathbf{k}_2 &= f(\mathbf{x}^n + \tfrac{\Delta t}{2}\mathbf{k}_1,\, t^n + \tfrac{\Delta t}{2}), \\ \mathbf{k}_3 &= f(\mathbf{x}^n + \tfrac{\Delta t}{2}\mathbf{k}_2,\, t^n + \tfrac{\Delta t}{2}), \\ \mathbf{k}_4 &= f(\mathbf{x}^n + \Delta t\,\mathbf{k}_3,\, t^n + \Delta t), \\ \mathbf{x}^{n+1} &= \mathbf{x}^n + \tfrac{\Delta t}{6}(\mathbf{k}_1 + 2\mathbf{k}_2 + 2\mathbf{k}_3 + \mathbf{k}_4). \end{aligned}
WARNING

RK4 不是的——对保守系统仍会缓慢漂移能量。高刚度系统用 RK4 仍可能不稳(刚性问题 → 用隐式法)。

28.5 隐式欧拉#

vn+1=vn+Δta(xn+1),xn+1=xn+Δtvn+1\mathbf{v}^{n+1} = \mathbf{v}^n + \Delta t \, \mathbf{a}(\mathbf{x}^{n+1}), \qquad \mathbf{x}^{n+1} = \mathbf{x}^n + \Delta t \, \mathbf{v}^{n+1}

右侧的 a(xn+1)\mathbf{a}(\mathbf{x}^{n+1}) 依赖未知的 xn+1\mathbf{x}^{n+1},要解一个线性/非线性方程组。Baraff-Witkin 1998 的布料仿真就是隐式积分的开山之作——线性化后解一个稀疏线性系统:

(MΔt2KΔtD)Δv=Δt(F+ΔtKv)(\mathbf{M} - \Delta t^2 \mathbf{K} - \Delta t \mathbf{D}) \Delta \mathbf{v} = \Delta t (\mathbf{F} + \Delta t \mathbf{K} \mathbf{v})

其中 K\mathbf{K} 是力的雅可比(刚度矩阵),D\mathbf{D} 是阻尼雅可比。

优点:无条件稳定

代价:每步都要解线性系统;数值阻尼 使能量长期衰减(这正是”布料运动看起来黏黏的”的来源)。

28.6 稳定性与 CFL 条件#

28.6.1 弹簧-质点系统的显式稳定性#

线性振子 x¨=ω2x\ddot{x} = -\omega^2 xω=k/m\omega = \sqrt{k/m}。显式欧拉的放大因子 1+iωΔt|1 + i\omega\Delta t| 总 > 1 → 任意步长都不稳(严格说能量单调增)。辛欧拉稳定条件:

Δt<2ω=2mk\Delta t < \frac{2}{\omega} = 2\sqrt{\frac{m}{k}}

28.6.2 CFL 条件(波方程)#

信息传播速度 cc,空间离散 Δx\Delta x,则:

ΔtαΔxc,α0.5\Delta t \leq \alpha \frac{\Delta x}{c}, \qquad \alpha \approx 0.5

对弹簧:c=k/mreducedc = \sqrt{k / m_{\text{reduced}}}Δx=L0\Delta x = L_0,CFL 给出与稳定性条件一致的上限。实用建议:在稳定性阈值的 50% 以下选步长,留一半余量给非线性。


二十九、约束求解#

WARNING

很多”物理现象”本质是约束:绳不可拉伸、刚体两点距离不变、角色脚不穿地。直接用胡克建模就得要超大 kk,逼到显式积分步长小得跑不动。把约束当约束本身求解(而不是当作软弹簧)是现代实时物理的主线。

29.1 位置投影#

距离约束 C(xi,xj)=xjxiL0=0C(\mathbf{x}_i, \mathbf{x}_j) = \|\mathbf{x}_j - \mathbf{x}_i\| - L_0 = 0。任意时刻若偏离,做一次投影修正:

Δxi=+wiwi+wjCe^,Δxj=wjwi+wjCe^\Delta \mathbf{x}_i = + \frac{w_i}{w_i + w_j} C \hat{\mathbf{e}}, \qquad \Delta \mathbf{x}_j = -\frac{w_j}{w_i + w_j} C \hat{\mathbf{e}}
  • wi=1/miw_i = 1/m_i(固定点 w=0w = 0
  • e^\hat{\mathbf{e}} 为沿当前连线的单位向量
  • 反比质量分配修正,保证动量守恒
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 提出的现代范式。核心套路:

  1. 用显式/辛欧拉预测 x~\tilde{\mathbf{x}}
  2. 迭代投影每条约束到 x~\tilde{\mathbf{x}}
  3. 从修正后的位置反推新速度 vn+1=(xn+1xn)/Δt\mathbf{v}^{n+1} = (\mathbf{x}^{n+1} - \mathbf{x}^n)/\Delta t
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 的性格:

  • 几乎无条件稳定(都是位置修正,不会像力爆炸)
  • 刚度通过”迭代次数”和”每次投影的比例因子”间接控制——不像胡克 kk 直接对应物理
  • XPBD (2016) 补上了拉格朗日乘子的严谨版本,让刚度变成物理参数
TIP

游戏里 90% 的布料、绳、毛发都是 PBD/XPBD 实现。它和 Part 6 的 FEM/SPH 并不互斥——PBD 可以当作 FEM 的快速近似。

29.3 SHAKE / RATTLE(分子动力学侧路线)#

SHAKE 是 Verlet 上的约束扩展,用拉格朗日乘子迭代直到约束误差小于阈值:

C(xn+1)=0xn+1xn+1λC/mC(\mathbf{x}^{n+1}) = 0 \Rightarrow \mathbf{x}^{n+1} \leftarrow \mathbf{x}^{n+1} - \lambda \nabla C / m

比 PBD 更强调数学严格性(蒙卡尔洛分子动力学要求严格保约束),图形里用得少,但理解它就知道 PBD 是一种”用位置投影逼近拉格朗日乘子”的近亲。


三十、刚体与流体概述(Part 6 前导)#

30.1 刚体#

质点的推广:带旋转的状态 (x,v,q,ω)(\mathbf{x}, \mathbf{v}, \mathbf{q}, \boldsymbol{\omega})。核心方程:

mv˙=F,I(t)ω˙+ω×(I(t)ω)=τm\dot{\mathbf{v}} = \mathbf{F}, \qquad \mathbf{I}(t)\dot{\boldsymbol{\omega}} + \boldsymbol{\omega} \times (\mathbf{I}(t)\boldsymbol{\omega}) = \boldsymbol{\tau}
  • I(t)=R(t)IbodyR(t)T\mathbf{I}(t) = \mathbf{R}(t) \mathbf{I}_{\text{body}} \mathbf{R}(t)^T:世界坐标惯性张量
  • τ\boldsymbol{\tau}:合力矩
  • 四元数运动学:q˙=12ωq\dot{\mathbf{q}} = \tfrac{1}{2} \boldsymbol{\omega} \mathbf{q}(把角速度当纯四元数左乘)

碰撞响应用冲量法 Δv=jn^/m\Delta \mathbf{v} = j \hat{\mathbf{n}} / mjj 由恢复系数 + 库伦摩擦锥求解。Part 6 会展开。

30.2 流体(欧拉 vs 拉格朗日)#

  • 欧拉法(网格) 2003 “Stable Fluids”,在固定网格上解 Navier-Stokes,烟、火焰常用
  • 拉格朗日法(粒子) (Smoothed Particle Hydrodynamics)、PBF (Position Based Fluids),液体自由表面常用

两者的核心都是 Navier-Stokes:

ut+(u)u=1ρp+ν2u+f\frac{\partial \mathbf{u}}{\partial t} + (\mathbf{u} \cdot \nabla)\mathbf{u} = -\frac{1}{\rho}\nabla p + \nu \nabla^2 \mathbf{u} + \mathbf{f}

完整推导、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 流体,把前五部分的底层知识串成当代图形学的全景。

计算机图形学笔记(五):动画与物理仿真
https://kyc001.github.io/posts/计算机图形学笔记五/
作者
kyc001
发布于
2025-07-24
许可协议
CC BY-NC-SA 4.0