一、核心概念

  1. 渲染帧(Update)

    • 频率不固定(30/60/120/波动)
    • 只负责画面表现、输入采集、音效
    • 不处理影响游戏结果的逻辑
  2. 逻辑帧(FixedUpdate / 自定义 FixedTimestep)

    • 固定频率(如 30Hz、50Hz、60Hz)
    • 负责游戏核心逻辑、物理、状态计算
    • 保证所有客户端执行结果完全一致
  3. 分离目标

    • 逻辑:稳定、精准、可复现
    • 渲染:流畅、高帧率、不卡顿

二、推荐方案:自定义固定时间步长(最灵活)

Unity 自带 FixedUpdate 可用,但自定义逻辑帧管理器更适合帧同步、战斗、网络游戏。

核心实现思路

  1. 用一个累积时间计数器收集渲染帧流逝时间
  2. 达到固定逻辑步长时,执行一次逻辑更新
  3. 渲染帧只做插值渲染,不修改逻辑状态

三、完整可运行代码

1. 逻辑帧管理器(全局单例)

using UnityEngine;

/// <summary>
/// 逻辑帧与渲染帧分离 核心管理器
/// </summary>
public class LogicFrameManager : MonoBehaviour
{
    public static LogicFrameManager Instance { get; private set; }

    [Header("逻辑帧配置(固定频率)")]
    [Tooltip("逻辑帧率 30/50/60 推荐 50Hz")]
    public int logicFrameRate = 50;

    // 固定逻辑步长(秒)
    private float _logicFixedDeltaTime;
    // 累积时间
    private float _accumulatedTime;
    // 当前逻辑帧号
    public int CurrentLogicFrame { get; private set; }

    // 逻辑帧更新事件(所有逻辑脚本监听这个)
    public delegate void LogicUpdateHandler();
    public event LogicUpdateHandler OnLogicUpdate;

    private void Awake()
    {
        // 单例
        if (Instance != null && Instance != this) Destroy(gameObject);
        else { Instance = this; DontDestroyOnLoad(gameObject); }
    }

    private void Start()
    {
        // 计算固定步长
        _logicFixedDeltaTime = 1.0f / logicFrameRate;
        _accumulatedTime = 0f;
        CurrentLogicFrame = 0;

        // 关闭Unity自动物理,完全自己控制
        Physics.autoSimulation = false;
        Physics2D.autoSimulation = false;
    }

    private void Update()
    {
        // ====================== 渲染帧 ======================
        // 1. 采集用户输入
        // 2. 渲染表现(动画、特效、UI)
        // 3. 插值平滑显示

        // 累积渲染帧流逝时间
        _accumulatedTime += Time.deltaTime;

        // ====================== 逻辑帧 ======================
        // 固定频率执行逻辑(可能一帧执行多次,防止卡顿)
        while (_accumulatedTime >= _logicFixedDeltaTime)
        {
            ExecuteLogicFrame();
            _accumulatedTime -= _logicFixedDeltaTime;
        }
    }

    /// <summary>
    /// 执行一帧逻辑
    /// </summary>
    private void ExecuteLogicFrame()
    {
        CurrentLogicFrame++;

        // 1. 物理更新(固定步长)
        Physics.Simulate(_logicFixedDeltaTime);
        Physics2D.Simulate(_logicFixedDeltaTime);

        // 2. 触发所有逻辑更新
        OnLogicUpdate?.Invoke();
    }

    /// <summary>
    /// 获取逻辑帧间隔(固定值)
    /// </summary>
    public float GetLogicDeltaTime() => _logicFixedDeltaTime;
}

2. 逻辑脚本(只写逻辑)

using UnityEngine;

/// <summary>
/// 逻辑类:只处理游戏状态,不处理渲染
/// </summary>
public class PlayerLogic : MonoBehaviour
{
    // 逻辑位置(真正决定游戏结果)
    public Vector3 logicPosition;
    // 逻辑速度
    private Vector3 _logicVelocity;

    private void Start()
    {
        logicPosition = transform.position;
        // 注册逻辑帧更新
        LogicFrameManager.Instance.OnLogicUpdate += LogicUpdate;
    }

    /// <summary>
    /// 【逻辑帧】固定频率执行
    /// </summary>
    private void LogicUpdate()
    {
        float deltaTime = LogicFrameManager.Instance.GetLogicDeltaTime();
        
        // 移动逻辑(精准、可复现)
        _logicVelocity = new Vector3(1, 0, 0) * 5f;
        logicPosition += _logicVelocity * deltaTime;
    }

    private void OnDestroy()
    {
        LogicFrameManager.Instance.OnLogicUpdate -= LogicUpdate;
    }
}

3. 渲染脚本(只做表现)

using UnityEngine;

/// <summary>
/// 渲染类:只负责画面显示,不修改逻辑
/// </summary>
public class PlayerRender : MonoBehaviour
{
    public PlayerLogic playerLogic;
    private Transform _modelTransform;

    private void Awake()
    {
        _modelTransform = transform;
    }

    private void Update()
    {
        // ====================== 渲染帧 ======================
        // 用插值让画面流畅(关键:分离的核心)
        float interpFactor = LogicFrameManager.Instance.GetComponent<LogicFrameManager>()._accumulatedTime / 
                              LogicFrameManager.Instance.GetLogicDeltaTime();
        
        // 平滑插值渲染,不影响逻辑
        _modelTransform.position = Vector3.Lerp(
            playerLogic.logicPosition, 
            playerLogic.logicPosition + playerLogic._logicVelocity * LogicFrameManager.Instance.GetLogicDeltaTime(), 
            interpFactor
        );
    }
}

四、使用步骤

  1. 创建空物体,挂载 LogicFrameManager
  2. 角色对象分两个脚本:
    • PlayerLogic逻辑状态
    • PlayerRender渲染表现
  3. 所有影响游戏结果的代码,都写在 OnLogicUpdate
  4. 所有画面、特效、UI,都写在 Update

五、关键规则(必须遵守)

❌ 禁止在 Update 写:

  • 角色移动逻辑
  • 伤害计算
  • 技能判定
  • 物理模拟
  • 状态机变更

✅ 只在 LogicUpdate 写:

  • 游戏核心逻辑
  • 物理
  • 技能/AI/战斗
  • 所有会影响胜负的代码

✅ 只在 Update 写:

  • 画面显示
  • 动画、特效、音效
  • UI 刷新
  • 摄像机抖动
  • 输入采集

六、进阶优化(帧同步必备)

  1. 固定浮点精度
    逻辑全部使用定点数(floatfixed),保证跨平台一致。

  2. 逻辑回滚与预测
    用于 MOBA、格斗游戏帧同步。

  3. 逻辑与数据彻底分离

    • LogicData:纯数据类
    • LogicSystem:纯逻辑类
    • RenderView:纯表现类

七、总结

  1. 分离核心:用累积时间驱动固定逻辑帧,渲染只做插值
  2. 逻辑帧:固定频率、精准、决定游戏结果
  3. 渲染帧:高帧率、流畅、只做表现
  4. 代码规范:逻辑写 OnLogicUpdate,表现写 Update

逻辑帧 ↔ 渲染帧 数据同步

一、先记住 3 条铁律(绝对不能违反)

  1. 逻辑 → 渲染 只能读,不能写
    渲染脚本永远不修改逻辑数据,只读取。
  2. 渲染 → 逻辑 只能传指令,不能传状态
    比如“按下跳跃”,而不是“把位置设为XX”。
  3. 所有游戏状态只由逻辑帧产生
    位置、血量、技能、状态机 → 全在逻辑层。

二、数据同步的 2 种核心方向

1. 【最重要】逻辑 → 渲染:状态同步(90%场景)

逻辑把最新的真实状态传给渲染,渲染负责平滑显示

  • 同步数据:位置、旋转、血量、动画状态、特效触发
  • 渲染工作:插值平滑 + 表现播放

2. 渲染 → 逻辑:输入/指令同步

渲染采集用户输入,打包成指令发给逻辑,逻辑在下一帧执行。

  • 同步数据:移动、跳跃、技能、点击
  • 绝对不能直接改逻辑位置/状态

三、3 种最实用的数据同步方法(直接用)

我按简单 → 进阶 → 高级给你完整实现。


方法1:基础同步(直接读取 + 线性插值 Lerp)

适合:大多数动作、RPG、休闲游戏
原理:渲染每帧读取逻辑的最新状态,用插值让移动更丝滑,不跳帧。

完整代码(直接复制)

1. 逻辑类(存储所有真实状态)

using UnityEngine;

/// <summary>
/// 逻辑层:唯一的状态来源
/// </summary>
public class PlayerLogic : MonoBehaviour
{
    // 【逻辑状态】所有数据都在这里
    public Vector3 logicPos;       // 逻辑位置
    public Quaternion logicRot;    // 逻辑旋转
    public float logicHp;          // 逻辑血量
    public bool isAttacking;        // 逻辑状态

    private Vector3 moveDir;

    void Start()
    {
        logicPos = transform.position;
        logicRot = transform.rotation;
        logicHp = 100;
        
        // 注册到逻辑帧
        LogicFrameManager.Instance.OnLogicUpdate += LogicUpdate;
    }

    /// <summary>
    /// 【逻辑帧】只更新状态,不碰渲染
    /// </summary>
    void LogicUpdate()
    {
        float dt = LogicFrameManager.Instance.GetLogicDeltaTime();
        
        // 示例:一直向右移动
        moveDir = Vector3.right;
        logicPos += moveDir * 5f * dt;
    }
}

2. 渲染类(只做同步 + 表现)

using UnityEngine;

/// <summary>
/// 渲染层:只从逻辑读数据,平滑显示
/// </summary>
public class PlayerRender : MonoBehaviour
{
    public PlayerLogic logic;
    private Transform modelTrans;

    // 上一帧逻辑状态(用于插值)
    private Vector3 _lastLogicPos;
    private Quaternion _lastLogicRot;

    void Awake()
    {
        modelTrans = transform;
    }

    /// <summary>
    /// 每帧逻辑更新后,渲染层记录快照
    /// </summary>
    void OnEnable()
    {
        LogicFrameManager.Instance.OnLogicUpdate += OnLogicStateUpdated;
    }

    /// <summary>
    /// 【关键】每次逻辑帧结束,保存一份快照
    /// </summary>
    void OnLogicStateUpdated()
    {
        _lastLogicPos = logic.logicPos;
        _lastLogicRot = logic.logicRot;
    }

    /// <summary>
    /// 【渲染帧】插值同步
    /// </summary>
    void Update()
    {
        // 1. 获取插值因子 (0~1)
        float interpFactor = LogicFrameManager.Instance.GetInterpFactor();

        // 2. 位置同步:平滑插值(核心!)
        modelTrans.position = Vector3.Lerp(
            _lastLogicPos,    // 上一帧逻辑位置
            logic.logicPos,   // 当前帧逻辑位置
            interpFactor      // 插值比例
        );

        // 3. 旋转同步
        modelTrans.rotation = Quaternion.Lerp(
            _lastLogicRot,
            logic.logicRot,
            interpFactor
        );

        // 4. 数值同步(血条/UI,无需插值)
        UpdateUIHp();

        // 5. 动画/状态同步
        UpdateAnimation();
    }

    void UpdateUIHp() { /* 直接读 logic.logicHp 赋值 */ }
    void UpdateAnimation() { /* 直接读 logic.isAttacking 播动画 */ }
}

3. 给管理器加一个插值因子(必备)

在之前的 LogicFrameManager 里加这个方法:

/// <summary>
/// 获取渲染插值因子 (0~1)
/// </summary>
public float GetInterpFactor()
{
    return _accumulatedTime / _logicFixedDeltaTime;
}

方法2:快照同步(适合帧同步/战斗/MOBA)

适合:需要回滚、重放、帧同步的游戏
原理:逻辑每帧生成一个数据快照(Snapshot),渲染从快照队列中读取,做平滑表现。

核心结构

// 逻辑快照:纯数据,可序列化、回滚、传输
public struct LogicSnapshot
{
    public int frame;
    public Vector3 pos;
    public Quaternion rot;
    public float hp;
}
  • 逻辑:每帧生成快照,存入队列
  • 渲染:从快照队列取最新两条数据做插值

方法3:指令同步(渲染 → 逻辑)

渲染永远不直接改逻辑数据,只发指令!

正确做法:

// 渲染层 Update 里
void Update()
{
    // 采集输入
    if (Input.GetKeyDown(KeyCode.Space))
    {
        // 发指令给逻辑,不直接跳跃
        logic.JumpCommand();
    }
}

// 逻辑层
bool wantJump;

// 渲染调用:只是标记一个指令
public void JumpCommand()
{
    wantJump = true;
}

// 真正执行:只在逻辑帧执行
void LogicUpdate()
{
    if (wantJump)
    {
        // 跳跃逻辑
        wantJump = false;
    }
}

四、必须掌握的 4 种同步数据类型

数据类型 同步方式 示例
位置/旋转 线性插值 Lerp 角色移动
数值(血量/分数) 直接赋值 血条、UI
触发事件(受伤/技能) 布尔标记 + 重置 特效、音效
动画状态 状态机直接同步 idle/run/attack

五、最关键的总结:同步流程

你只需要记住这个固定流程,永远不会乱:

一帧完整流程

  1. 渲染帧(Update)

    • 采集输入 → 发指令给逻辑
    • 根据插值因子平滑显示模型
  2. 时间累积

  3. 执行 N 次 逻辑帧(LogicUpdate)

    • 处理指令
    • 更新位置、状态、物理
    • 生成新的状态数据
    • 给渲染层发送状态快照
  4. 渲染帧

    • 使用新旧两份逻辑状态 + 插值 → 流畅画面

六、一句话核心

逻辑帧生产数据,渲染帧消费数据;
逻辑决定世界是什么样,渲染只负责把它画得更流畅。


总结

  1. 逻辑 → 渲染:用插值(Lerp) 同步位置/旋转,直接赋值同步数值
  2. 渲染 → 逻辑:只发指令/标记,绝不修改逻辑状态
  3. 同步关键:保存上一帧逻辑快照,渲染做插值
  4. 规则:单向数据流,渲染只读逻辑,逻辑不管渲染