《物理模拟与渲染集成:固定步长、插值与确定性同步》

物理与渲染是两条频率、精度、目标都不同的时间线,把它们正确缝合是游戏引擎的核心难题。本文讲解刚体动力学与碰撞检测的两阶段流程、固定步长累加器与渲染插值/外推、角色控制器的扫掠与滑动处理、布料与绳索的 PBD/XPBD 约束求解、确定性与网络回滚同步,以及物理与渲染解耦的 ECS 架构设计,给出避免抖动、穿透与回滚错位的完整工程方案。

渲染追求「看起来对」,物理追求「算得对」,而它们运行在两条时间线上:渲染帧率随负载浮动(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 → 确定性」,逐层定位。


总结

物理模拟与渲染集成是一条「缝合两条时间线」的工程链路,本文要点:

  1. 两条时间线:物理要固定步长求稳定,渲染要插值求平滑,二者通过累加器与插值因子缝合。
  2. 刚体与碰撞:半隐式欧拉积分 + Broad/Narrow Phase + 顺序冲量求解,是游戏物理的基本盘。
  3. 固定步长:累加器模式 + 渲染插值(外推可选)+ 死亡螺旋防护,是标准实现。
  4. 角色控制器:运动学胶囊 + 扫掠滑动 + 台阶/坡度/地面特判,纯刚体做不出好手感。
  5. 布料绳索:PBD/XPBD 用位置投影替代显式积分,稳定性与成本兼得。
  6. 确定性:位精确是网络同步与回滚的前提,浮点、并行、随机数是三大陷阱。
  7. 解耦架构:物理产出权威状态、渲染消费并平滑,职责清晰是少 bug 的根本。

物理与渲染的集成看似是「胶水代码」,实则是引擎稳定性与手感的核心。理解固定步长、插值与确定性的原理,你就能在「物理正确」与「视觉平滑」之间找到恰当的平衡。建议继续阅读 https://plumephp.com/game-engine-architecture-ecs/ 理解物理/渲染作为 System 的组织方式,以及 https://plumephp.com/graphics-skeletal-animation/ 了解动画系统与物理的协同。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机图形学」更多文章

  1. 《色彩管理与 HDR 管线:线性工作流、ACES 色调映射与宽色域》
  2. 《可见性剔除系统:视锥、BVH、Hi-Z 与 GPU Driven 剔除》
  3. 《GPU 粒子与特效系统:计算着色器、Transform Feedback 与十万级粒子》