渲染追求「看起来对」,物理追求「算得对」,而它们运行在两条时间线上:渲染帧率随负载浮动(60~144 FPS),物理却必须用稳定步长推进以保证数值稳定与可复现。把这两条时间线缝合起来,是游戏引擎里最容易出 bug 的环节之一——抖动、穿透、回滚错位、网络不同步,几乎都源于此。本文从刚体与碰撞讲起,深入固定步长、插值、角色控制器、布料与确定性同步,最后给出物理与渲染解耦的架构。相关基础可参考 https://plumephp.com/game-engine-architecture-ecs/ 与 https://plumephp.com/graphics-pipeline-theory/。
一、物理与渲染的关系
一句话:物理是「离散时间的状态机」,渲染是「任意时刻的采样器」,二者必须通过插值或外推对齐。
1.1 两条时间线
| 维度 | 物理 | 渲染 |
|---|---|---|
| 频率 | 固定(如 60 Hz) | 可变(跟随 vsync) |
| 目标 | 数值稳定、可复现 | 视觉平滑 |
| 步长 | 固定 dt | 可变 frame time |
| 容错 | 严格(穿透不可接受) | 宽松(可插值) |
| 并行 | 可并行但有依赖 | 高度并行 |
1.2 耦合的三种模式
模式 A:物理 = 渲染帧(简单但不可取)
每帧调用一次 Step(frameTime) → 帧率相关,物理不稳定
模式 B:固定步长 + 插值(推荐)
累加器驱动固定 Step,渲染在两次物理状态间插值
模式 C:异步物理(高级)
物理在线程/独立频率运行,渲染读最新状态 + 外推
1.3 核心矛盾
- 若物理步长跟随渲染帧,帧率波动会导致物理行为不一致(子弹穿透、堆叠不稳)。
- 若物理固定步长,渲染帧与物理步不同步,会出现抖动(jitter)。
解决方案是模式 B:累加器 + 固定步长 + 渲染插值。
一句话:物理必须固定步长(为了稳定),渲染必须插值(为了平滑),这就是「时间线缝合」的全部。
二、刚体动力学与碰撞检测
一句话:物理世界的两大支柱是「积分求运动」与「碰撞求约束」——前者用数值积分,后者用碰撞检测与求解器。
2.1 刚体状态与积分
刚体状态包含位置、朝向、线速度、角速度、质量、惯性张量:
// C++:刚体状态
struct FRigidBody {
glm::vec3 position;
glm::quat orientation;
glm::vec3 linearVelocity;
glm::vec3 angularVelocity;
float mass;
float invMass; // 0 表示静态
glm::mat3 inertiaTensor; // 世界空间惯性张量
glm::mat3 invInertiaTensor;
float restitution; // 弹性
float friction; // 摩擦
};
// 半隐式欧拉积分(游戏物理最常用)
void Integrate(FRigidBody& b, const glm::vec3& gravity, float dt) {
b.linearVelocity += gravity * dt; // 先更新速度
b.position += b.linearVelocity * dt; // 再用新速度更新位置
b.orientation = IntegrateQuat(b.orientation, b.angularVelocity, dt);
b.angularVelocity *= exp(-angularDamping * dt);
}
2.2 碰撞检测的两阶段
| 阶段 | 目的 | 方法 |
|---|---|---|
| Broad Phase | 快速筛出可能碰撞的对 | BVH / Sweep-and-Prune / Grid |
| Narrow Phase | 精确求交与接触点 | GJK/EPA、SAT、Minkowski |
| Solver | 求解约束(防穿透) | 顺序冲量、投影 |
Broad Phase 常用 Sweep-and-Prune:把物体按 AABB 的最小 x 排序,再线性扫描——一旦某物体的最小 x 超过当前物体的最大 x 即剪枝,复杂度从 O(n²) 降到接近 O(n log n)。
2.3 求解器:顺序冲量
顺序冲量(Sequential Impulse)是游戏物理的主流:迭代求解每个接触点的冲量,逐步逼近无穿透状态:
// C++:单次接触点冲量求解(简化)
void SolveContact(FContact& c, float dt) {
glm::vec3 rv = c.bodyB->linearVelocity - c.bodyA->linearVelocity;
float velAlongNormal = glm::dot(rv, c.normal);
if (velAlongNormal > 0) return; // 正在分离,跳过
float e = std::min(c.bodyA->restitution, c.bodyB->restitution);
float j = -(1.0f + e) * velAlongNormal;
j /= c.bodyA->invMass + c.bodyB->invMass; // 除以有效质量
glm::vec3 impulse = j * c.normal;
c.bodyA->linearVelocity -= c.bodyA->invMass * impulse;
c.bodyB->linearVelocity += c.bodyB->invMass * impulse;
// 摩擦冲量(库仑摩擦,受法向冲量约束)...
}
2.4 穿透与 CCD
高速物体(子弹)会在两帧之间「穿过」薄壁。连续碰撞检测(CCD)用扫掠体或射线检测补足:
| 方案 | 精度 | 成本 | 适用 |
|---|---|---|---|
| 离散检测 | 低 | 低 | 慢速物体 |
| 射线 CCD | 高 | 高 | 子弹、快速投射物 |
| 子步进(Substep) | 高 | 中高 | 全局 |
一句话:物理的稳定性来自「固定步长 + 迭代求解 + 必要时 CCD」,任何一环缺失都会以抖动或穿透的形式暴露。
三、固定步长、插值与外推
一句话:累加器把可变的渲染帧时间切成固定的物理步,渲染再用插值因子在两次物理状态间平滑采样。
3.1 累加器模式
// C++:固定步长累加器(Gaffer on Games 经典实现)
class FPhysicsLoop {
double accumulator = 0.0;
double fixedDt = 1.0 / 60.0;
double maxFrameTime = 0.25; // 防止死亡螺旋
public:
void Update(double frameTime) {
frameTime = std::min(frameTime, maxFrameTime);
accumulator += frameTime;
while (accumulator >= fixedDt) {
previousState = currentState; // 保存上一物理状态
StepPhysics(fixedDt); // 固定步长推进
accumulator -= fixedDt;
}
}
// 渲染插值因子:0 = 上一状态,1 = 当前状态
float GetAlpha() const { return float(accumulator / fixedDt); }
};
3.2 渲染插值
渲染时用 alpha 在 previousState 与 currentState 之间插值,得到平滑的视觉位置:位置用 mix(prev, curr, alpha),朝向用 slerp(prev, curr, alpha)(球面插值保证旋转平滑)。
3.3 外推(Extrapolation)
插值引入 1 个物理步的延迟。对延迟敏感的场景(VR、快节奏 FPS)可用外推:按当前速度预测未来位置,即 pos' = pos + vel · dt,代价是可能过冲甚至穿墙。
| 方案 | 延迟 | 平滑度 | 风险 |
|---|---|---|---|
| 直接使用当前状态 | 0 | 差(抖动) | 无 |
| 插值 | 1 物理步 | 好 | 无 |
| 外推 | 负(提前) | 中 | 过冲、穿墙 |
| 混合 | 可调 | 好 | 需调参 |
3.4 死亡螺旋
若一帧耗时过长,累加器会要求执行大量物理步,进一步拖慢帧率,形成「死亡螺旋」。防护:
- 钳制单帧最大时间(如 0.25 s)。
- 钳制单帧最大物理步数(如 5 步)。
- 超限时丢弃多余时间(接受物理「变慢」)。
一句话:固定步长 + 插值是标准答案,外推是延迟敏感场景的加速器,而「死亡螺旋防护」是必须的保险丝。
四、角色控制器
一句话:角色控制器不能用纯刚体——它需要「精确的期望运动 + 滑动碰撞响应」,因此多为运动学控制器 + 胶囊体。
4.1 为什么不用刚体
纯刚体角色会有:站不稳、上坡下滑、被小台阶卡住、无法精确控制跳跃高度。因此角色通常是运动学(kinematic) 的:由代码决定期望位移,物理只负责「碰撞与滑动」。
4.2 胶囊体与扫掠
胶囊体(Capsule)是角色碰撞体的标准选择:无棱角、便于扫掠、贴合人体。
// C++:角色移动与滑动(简化)
glm::vec3 MoveCharacter(const FCapsule& capsule, glm::vec3 desiredMove,
const FWorld& world, int maxSlideIterations = 4) {
glm::vec3 position = capsule.center;
glm::vec3 remaining = desiredMove;
for (int i = 0; i < maxSlideIterations && glm::length(remaining) > 1e-4; ++i) {
FSweepResult hit = world.SweepCapsule(capsule, position, remaining);
if (!hit.hit) { position += remaining; break; }
// 前进到碰撞点前一点点
position += remaining * hit.time;
// 把剩余位移投影到碰撞平面(滑动)
remaining = remaining - hit.normal * glm::dot(remaining, hit.normal);
remaining *= (1.0f - 1e-3f); // 避免贴面抖动
}
return position;
}
4.3 台阶、坡度与地面检测
| 功能 | 实现 |
|---|---|
| 台阶(Step) | 向上扫掠,允许一定高度抬升 |
| 坡度(Slope) | 比较法线与向上向量夹角,超过阈值则下滑 |
| 地面检测 | 向下短射线/扫掠,判断 dot(normal, up) > cos(maxSlope) |
| 斜坡下滑 | 坡度超阈值时施加沿坡向下的重力分量 |
| 挤压(Squeeze) | 双向扫掠,卡住时回退 |
4.4 与物理世界交互
角色本身是运动学的,但仍需推动刚体(推箱子)。做法是:角色移动时对接触的刚体施加冲量,或用一个「隐藏的动力学代理」参与求解。
一句话:角色控制器是「运动学移动 + 扫掠滑动 + 地面/台阶/坡度特判」的组合,纯刚体做不出好的手感。
五、布料与绳索模拟
一句话:布料用质点-弹簧网或 PBD 约束求解,绳索是它的「一维退化」——关键都是「约束迭代 + 时间积分」。
5.1 质点-弹簧模型
布料 = 一张质点网格,相邻质点间用弹簧(结构、剪切、弯曲)连接:
// C++:质点-弹簧布料的一步(半隐式欧拉 + 弹簧约束)
void SimulateCloth(std::vector<FClothParticle>& ps,
const std::vector<FSpring>& springs, float dt) {
for (auto& p : ps) { // 1. 积分受力
if (p.invMass == 0.0f) continue; // 固定点
glm::vec3 f = glm::vec3(0, -9.8f, 0) * (1.0f / p.invMass) + damping * -p.velocity;
p.velocity += f * p.invMass * dt;
p.position += p.velocity * dt;
}
for (int it = 0; it < solverIterations; ++it) // 2. 约束迭代
for (const auto& s : springs) {
glm::vec3 d = ps[s.b].position - ps[s.a].position;
float len = glm::length(d);
float w = ps[s.a].invMass + ps[s.b].invMass;
if (w == 0.0f) continue;
glm::vec3 corr = d * ((len - s.restLength) / len) * stiffness;
ps[s.a].position += corr * (ps[s.a].invMass / w);
ps[s.b].position -= corr * (ps[s.b].invMass / w);
}
}
5.2 PBD / XPBD
基于位置的动力学(PBD)先积分再投影约束,天然稳定、易实现;XPBD 引入柔度参数,使刚度与步长解耦,是现代布料的首选。
// GLSL(compute):XPBD 距离约束
void SolveDistanceConstraint(inout vec3 p0, inout vec3 p1, float w0, float w1,
float restLength, float compliance, float dt) {
vec3 delta = p1 - p0;
float len = length(delta);
if (len < 1e-6) return;
float C = len - restLength; // 约束误差
float alpha = compliance / (dt * dt); // 柔度
float dlambda = -C / (w0 + w1 + alpha);
vec3 n = delta / len;
p0 -= w0 * dlambda * n;
p1 += w1 * dlambda * n;
}
5.3 布料与渲染的集成
| 问题 | 方案 |
|---|---|
| 布料穿插身体 | 碰撞胶囊约束 / 碰撞深度图 |
| 顶点评分过高 | 降分辨率布料 + 蒙皮插值 |
| 自碰撞 | 空间哈希 + 排斥约束(昂贵) |
| 风 | 按法线施加风力 |
| 撕裂 | 约束长度超阈值则断开 |
| 方案 | 稳定性 | 成本 | 适用 |
|---|---|---|---|
| 质点弹簧(显式) | 低(需小步长) | 低 | 简单 |
| PBD | 高 | 中 | 通用布料 |
| XPBD | 高 | 中 | 现代布料 |
| 有限元(FEM) | 高 | 高 | 离线/影视 |
一句话:布料的关键是「约束求解器」而非「力模型」——PBD/XPBD 用位置投影替代了脆弱的显式积分。
六、确定性与网络同步
一句话:确定性物理意味着「相同输入 + 相同步长 → 相同结果」,这是帧同步网络的前提,而浮点与并行是它的最大敌人。
6.1 确定性的层级
| 层级 | 要求 | 难度 |
|---|---|---|
| 位精确(Bit-exact) | 跨平台完全一致 | 极难(浮点) |
| 状态同步(State Sync) | 服务端权威 | 中 |
| 帧同步(Lockstep) | 同输入同结果 | 高 |
6.2 破坏确定性的因素
- 浮点运算顺序:
(a+b)+c ≠ a+(b+c),并行归约顺序不定。 - 不同 CPU/编译器的浮点行为:FMA、x87、SIMD。
- 物理引擎的并行求解:多线程顺序不定。
- 随机数:必须用确定性种子与顺序。
6.3 确定性实践
// C++:确定性物理的落地要点(固定步长、固定顺序、确定性随机)
void DeterministicStep(FWorld& world, float fixedDt, uint32 frame) {
world.SortEntitiesByID(); // 按 ID 排序,而非内存地址
SetRandomSeed(Hash(frame)); // 每帧种子固定
world.Solver.SetDeterministicMode(true); // 固定归约顺序
world.Step(fixedDt);
}
6.4 回滚与重放
确定性物理支持回滚网络(Rollback Netcode):本地预测 + 收到远端输入后回滚到过去状态并重新模拟。核心是「恢复快照 → 用历史输入重放 → 直到追上当前帧」:先 RestoreSnapshot(fromFrame),再逐帧 Step(fixedDt) + ApplyInput(inputs[f])。快照频率与内存占用是回滚网络的主要权衡。
| 同步模式 | 带宽 | 一致性 | 适用 |
|---|---|---|---|
| 状态同步 | 高 | 服务端权威 | MMO、FPS |
| 帧同步 | 低 | 需确定性 | RTS、格斗 |
| 客户端预测 + 回滚 | 低 | 需确定性 + 快照 | 格斗、快节奏 FPS |
一句话:确定性是「易说难做」——浮点、并行与随机数三个陷阱,任何一个没处理好都会让回滚网络崩溃。
七、物理与渲染的解耦架构
一句话:物理与渲染通过「数据接口」解耦——物理产出变换,渲染消费变换,中间用双缓冲与插值缝合。
7.1 数据流向
输入 → 物理世界(固定步长)
↓ 写:TransformComponent(物理侧)
[双缓冲 / 快照]
↓ 读:渲染侧(插值)
渲染场景图 → 提取 → 绘制
7.2 ECS 中的集成
在 ECS 架构里,物理与渲染是两个 System,共享 Transform 组件:
// C++:ECS 中物理系统写 Transform,渲染系统读并插值
struct TransformComponent {
glm::vec3 prevPosition, position;
glm::quat prevRotation, rotation;
};
void PhysicsSystem(World& w, float fixedDt) { // 物理:写权威状态
for (auto [e, body, tf] : w.Query<RigidBody, TransformComponent>()) {
tf.prevPosition = tf.position; // 保存上一状态
tf.prevRotation = tf.rotation;
StepBody(body, fixedDt);
tf.position = body.position; // 写入新状态
tf.rotation = body.orientation;
}
}
void RenderExtractSystem(World& w, float alpha) { // 渲染:插值后提交
for (auto [e, tf, mesh] : w.Query<TransformComponent, MeshComponent>()) {
FTransform t;
t.position = glm::mix(tf.prevPosition, tf.position, alpha);
t.rotation = glm::slerp(tf.prevRotation, tf.rotation, alpha);
SubmitMesh(mesh, t);
}
}
7.3 线程与频率解耦
高级架构把物理放到独立线程,以自己(可能更高)的频率运行,渲染线程读取最新快照:
| 架构 | 物理频率 | 延迟 | 复杂度 |
|---|---|---|---|
| 同线程同步 | = 渲染 | 1 步 | 低 |
| 同线程固定步 | 固定 | 1 步 | 低 |
| 独立线程 | 独立 | 1~2 帧 | 中 |
| 异步 + 外推 | 独立 | 低 | 高 |
一句话:解耦的本质是「物理只负责产出权威状态,渲染只负责消费并平滑」——职责清晰,bug 就少一半。
八、常见问题与工程实践(FAQ)
Q:物体抖动(jitter)?
A:多半是渲染直接用了物理当前状态而非插值;或物理步长随帧率变化。检查累加器与插值因子。
Q:物体穿透薄墙?
A:启用 CCD 或子步进;对高速物体单独做射线扫掠。
Q:堆叠不稳定、慢慢滑动?
A:增加求解器迭代次数、启用「休眠(sleeping)」、提高摩擦精度、避免过大的步长。
Q:角色卡在小台阶上?
A:启用台阶抬升(step up),并确保扫掠时允许一定高度补偿。
Q:布料穿模?
A:加入身体碰撞胶囊/深度图约束,或提高约束迭代次数与碰撞厚度。
Q:网络不同步?
A:检查是否固定步长、是否用了非确定性随机、实体处理顺序是否稳定、浮点模式是否一致。
一句话:物理 bug 的排查顺序是「步长 → 插值 → 求解器迭代 → CCD → 确定性」,逐层定位。
总结
物理模拟与渲染集成是一条「缝合两条时间线」的工程链路,本文要点:
- 两条时间线:物理要固定步长求稳定,渲染要插值求平滑,二者通过累加器与插值因子缝合。
- 刚体与碰撞:半隐式欧拉积分 + Broad/Narrow Phase + 顺序冲量求解,是游戏物理的基本盘。
- 固定步长:累加器模式 + 渲染插值(外推可选)+ 死亡螺旋防护,是标准实现。
- 角色控制器:运动学胶囊 + 扫掠滑动 + 台阶/坡度/地面特判,纯刚体做不出好手感。
- 布料绳索:PBD/XPBD 用位置投影替代显式积分,稳定性与成本兼得。
- 确定性:位精确是网络同步与回滚的前提,浮点、并行、随机数是三大陷阱。
- 解耦架构:物理产出权威状态、渲染消费并平滑,职责清晰是少 bug 的根本。
物理与渲染的集成看似是「胶水代码」,实则是引擎稳定性与手感的核心。理解固定步长、插值与确定性的原理,你就能在「物理正确」与「视觉平滑」之间找到恰当的平衡。建议继续阅读 https://plumephp.com/game-engine-architecture-ecs/ 理解物理/渲染作为 System 的组织方式,以及 https://plumephp.com/graphics-skeletal-animation/ 了解动画系统与物理的协同。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。