Unity 逻辑帧与渲染帧分离
一、核心概念
-
渲染帧(Update)
- 频率不固定(30/60/120/波动)
- 只负责画面表现、输入采集、音效
- 不处理影响游戏结果的逻辑
-
逻辑帧(FixedUpdate / 自定义 FixedTimestep)
- 固定频率(如 30Hz、50Hz、60Hz)
- 负责游戏核心逻辑、物理、状态计算
- 保证所有客户端执行结果完全一致
-
分离目标
- 逻辑:稳定、精准、可复现
- 渲染:流畅、高帧率、不卡顿
二、推荐方案:自定义固定时间步长(最灵活)
Unity 自带 FixedUpdate 可用,但自定义逻辑帧管理器更适合帧同步、战斗、网络游戏。
核心实现思路
- 用一个累积时间计数器收集渲染帧流逝时间
- 达到固定逻辑步长时,执行一次逻辑更新
- 渲染帧只做插值渲染,不修改逻辑状态
三、完整可运行代码
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
);
}
}
四、使用步骤
- 创建空物体,挂载
LogicFrameManager - 角色对象分两个脚本:
PlayerLogic:逻辑状态PlayerRender:渲染表现
- 所有影响游戏结果的代码,都写在
OnLogicUpdate里 - 所有画面、特效、UI,都写在
Update里
五、关键规则(必须遵守)
❌ 禁止在 Update 写:
- 角色移动逻辑
- 伤害计算
- 技能判定
- 物理模拟
- 状态机变更
✅ 只在 LogicUpdate 写:
- 游戏核心逻辑
- 物理
- 技能/AI/战斗
- 所有会影响胜负的代码
✅ 只在 Update 写:
- 画面显示
- 动画、特效、音效
- UI 刷新
- 摄像机抖动
- 输入采集
六、进阶优化(帧同步必备)
-
固定浮点精度
逻辑全部使用定点数(float→fixed),保证跨平台一致。 -
逻辑回滚与预测
用于 MOBA、格斗游戏帧同步。 -
逻辑与数据彻底分离
- LogicData:纯数据类
- LogicSystem:纯逻辑类
- RenderView:纯表现类
七、总结
- 分离核心:用累积时间驱动固定逻辑帧,渲染只做插值
- 逻辑帧:固定频率、精准、决定游戏结果
- 渲染帧:高帧率、流畅、只做表现
- 代码规范:逻辑写
OnLogicUpdate,表现写Update
逻辑帧 ↔ 渲染帧 数据同步
一、先记住 3 条铁律(绝对不能违反)
- 逻辑 → 渲染 只能读,不能写
渲染脚本永远不修改逻辑数据,只读取。 - 渲染 → 逻辑 只能传指令,不能传状态
比如“按下跳跃”,而不是“把位置设为XX”。 - 所有游戏状态只由逻辑帧产生
位置、血量、技能、状态机 → 全在逻辑层。
二、数据同步的 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 |
五、最关键的总结:同步流程
你只需要记住这个固定流程,永远不会乱:
一帧完整流程
-
渲染帧(Update)
- 采集输入 → 发指令给逻辑
- 根据插值因子平滑显示模型
-
时间累积
-
执行 N 次 逻辑帧(LogicUpdate)
- 处理指令
- 更新位置、状态、物理
- 生成新的状态数据
- 给渲染层发送状态快照
-
渲染帧
- 使用新旧两份逻辑状态 + 插值 → 流畅画面
六、一句话核心
逻辑帧生产数据,渲染帧消费数据;
逻辑决定世界是什么样,渲染只负责把它画得更流畅。
总结
- 逻辑 → 渲染:用插值(Lerp) 同步位置/旋转,直接赋值同步数值
- 渲染 → 逻辑:只发指令/标记,绝不修改逻辑状态
- 同步关键:保存上一帧逻辑快照,渲染做插值
- 规则:单向数据流,渲染只读逻辑,逻辑不管渲染