物理模拟在 ECS 中的确定性积分步长:固定帧率物理调度器实现

封面信息图

在 Unity DOTS/ECS 环境下构建多人联机对战或动作游戏时,很多刚接触面向数据技术栈的开发者会直接在常规的 SystemBase.OnUpdate 里使用 SystemAPI.Time.DeltaTime 来更新实体的刚体速度与位置坐标:
$$\vec{x}_{t+1} = \vec{x}_t + \vec{v}_t \cdot \Delta t$$

这种基于可变渲染帧耗时(Variable Delta Time)的欧拉积分,是联机确定性模拟(Deterministic Simulation)的死敌。
因为不同客户端的渲染帧率是动态波动的(一台手机跑 58.2 FPS,另一台跑 61.5 FPS,$\Delta t$ 在 16.1ms 到 17.2ms 之间反复横跳)。即使两次运行的输入按键完全一致,由于浮点乘法在不同微小时间步长下的累积舍入误差,角色的运动轨迹也会在短短两秒内产生巨大的空间偏离,导致物理碰撞判定完全分叉。

要保证物理模拟在所有客户端上绝对确定且可复现,必须实现一套严格固定逻辑步长(Fixed Timestep)的累加器物理调度器(Accumulator-based Physics Scheduler),并对渲染层执行精准的状态插值(Render State Interpolation)。


固定步长累加器(Fix Your Timestep)算法模型

[ 真实渲染帧耗时 deltaTime ] ──► [ 注入全局时间累加器 (accumulator += dt) ]
                                              │
                    ┌─────────────────────────┴─────────────────────────┐
                    ▼                                                   ▼
       [ accumulator >= FIXED_DELTA_TIME ? ]              [ 剩余时间残差 residual ]
                    │ 是                                                │
                    ├──────────────────────┐                            │
                    ▼                      │ (循环消费)                  │
       [ 执行一次确定性 ECS 物理步进 ]        │                            │
       - 速度积分 / 碰撞求解               │                            │
       - 保存 PreviousState / CurrentState │                            │
       - accumulator -= FIXED_DELTA_TIME   │                            │
                    │                      │                            │
                    └──────────────────────┘                            │
                                                                        ▼
                                                   [ 计算渲染插值因子 alpha = accumulator / FIXED_DELTA_TIME ]
                                                                        │
                                                                        ▼
                                                   [ 渲染系统执行表现层姿态平滑插值: Lerp(Prev, Curr, alpha) ]
  1. 逻辑帧解耦:物理与战斗逻辑必须以恒定的频率运行(例如固定 30Hz,即 $\Delta t_{fixed} = 0.03333\text{s}$,或固定 60Hz,即 $\Delta t_{fixed} = 0.01666\text{s}$)。
  2. 防螺旋死锁(Spiral of Death Guard):当某帧发生严重掉帧(如后台加载导致单帧耗时 200ms),累加器会尝试在单帧内连续 Tick 6 次物理步进。如果物理计算本身耗时过大,会引发更严重的下一帧掉帧,导致引擎陷入“步进越多 $\rightarrow$ 越卡 $\rightarrow$ 步进更多”的恶性循环。因此必须设置单帧最大步进上限(MaxSubSteps = 4)。
  3. 状态双缓冲与平滑插值:渲染帧率(如 120Hz 高刷屏)通常高于物理模拟帧率(30Hz)。渲染系统绝不能直接读取未经平滑的逻辑物理坐标,而必须依据当前累加器的剩余时间残差计算插值因子 $\alpha = \frac{\text{accumulator}}{\Delta t_{fixed}}$,在上一逻辑帧快照(PreviousState)与当前逻辑帧快照(CurrentState)之间做线性插值(Lerp / Slerp)。

Unity ECS 确定性物理调度系统实现

下面展示了基于 Unity DOTS ECS 的完整确定性调度架构:

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

// 1. 物理状态双缓冲组件(用于表现层无撕裂插值)
public struct PhysicsInterpolationState : IComponentData
{
    public float3 PreviousPosition;
    public float3 CurrentPosition;
    public quaternion PreviousRotation;
    public quaternion CurrentRotation;
}

public struct DeterministicVelocity : IComponentData
{
    public float3 Linear;
    public float3 Angular;
}

// 2. 确定性固定物理更新组(Fixed Step Simulation Group)
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial class DeterministicPhysicsSchedulerSystem : SystemBase
{
    private const float FIXED_TIMESTEP = 1.0f / 30.0f; // 固定 30 FPS 逻辑步长
    private const int MAX_SUB_STEPS = 4;              // 防螺旋死锁最大补偿步数

    private float _accumulator = 0.0f;

    protected override void OnUpdate()
    {
        float frameDeltaTime = math.min(SystemAPI.Time.DeltaTime, 0.2f);
        _accumulator += frameDeltaTime;

        int subSteps = 0;
        while (_accumulator >= FIXED_TIMESTEP && subSteps < MAX_SUB_STEPS)
        {
            // 执行单次逻辑帧物理模拟
            StepDeterministicPhysics(FIXED_TIMESTEP);

            _accumulator -= FIXED_TIMESTEP;
            subSteps++;
        }

        // 计算渲染插值权重 alpha ∈ [0, 1)
        float alpha = _accumulator / FIXED_TIMESTEP;

        // 调度表现层插值 Job,消除 30 帧逻辑在 120 帧高刷屏幕上的卡顿抖动
        Dependency = new InterpolateTransformJob
        {
            Alpha = alpha
        }.ScheduleParallel(Dependency);
    }

    private void StepDeterministicPhysics(float fixedDelta)
    {
        // 调度物理积分与碰撞 Job
        Dependency = new IntegrateVelocityJob
        {
            FixedDeltaTime = fixedDelta
        }.ScheduleParallel(Dependency);
    }
}

// 3. 确定性积分 Job (Burst 编译,保证跨线程极致性能)
[BurstCompile]
public partial struct IntegrateVelocityJob : IJobEntity
{
    public float FixedDeltaTime;

    private void Execute(
        ref PhysicsInterpolationState interp,
        ref LocalTransform transform,
        in DeterministicVelocity velocity)
    {
        // 记录历史状态快照
        interp.PreviousPosition = interp.CurrentPosition;
        interp.PreviousRotation = interp.CurrentRotation;

        // 确定性欧拉积分
        interp.CurrentPosition += velocity.Linear * FixedDeltaTime;

        // 更新瞬时逻辑坐标
        transform.Position = interp.CurrentPosition;
        transform.Rotation = interp.CurrentRotation;
    }
}

// 4. 表现层平滑插值 Job
[BurstCompile]
public partial struct InterpolateTransformJob : IJobEntity
{
    public float Alpha;

    private void Execute(
        ref LocalTransform transform,
        in PhysicsInterpolationState interp)
    {
        // 平滑插值出屏幕最终呈现的世界坐标
        transform.Position = math.lerp(interp.PreviousPosition, interp.CurrentPosition, Alpha);
        transform.Rotation = math.slerp(interp.PreviousRotation, interp.CurrentRotation, Alpha);
    }
}

确定性物理落地三大军规

  1. 严禁在逻辑 Job 中读取系统时钟:逻辑步进内部的所有系统,严禁访问 System.DateTime.NowTime.realtimeSinceStartup 等非确定性外部时间源,时间推移必须仅由逻辑帧计数器(TickIndex * FIXED_TIMESTEP)唯一决定。
  2. 逻辑数据与渲染数据彻底隔离:逻辑系统只能读写 PhysicsInterpolationState 与私有逻辑组件,绝不能依赖渲染组件的临时变换结果(如 Animator 导出的骨骼坐标)。
  3. 结合定点数数学库(Fixed-Point Math):如果项目要求跨平台(如 iOS ARM64 与 PC x86_64)在网络帧同步下达到 100% 字节级无差异,必须将 float3 替换为定点数向量(如 64 位 Q32.32 定点数),彻底斩断浮点硬件 FMA 指令微小精度的非确定性干扰。

将时间掌控在确定性积分步长之内,不仅让网络对战与录像回放坚不可摧,更能让低帧率物理逻辑在高端高刷设备上呈现如丝般顺滑的视觉体验。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐