第 29 章 · 即时反馈与本地预演
这一章解决:玩家按下按钮后,如何立刻得到反馈,同时不让本地反馈变成最终状态。
先看一下
第 26 章的轻实时范式把子弹轨迹留在本地预演,只把命中请求交给 GameState Owner。这样做能减少同步压力,但会带来一个新问题:按下按钮到 Owner 确认之间,本地画面应该显示什么?
这一章会拿到什么
- 一条固定边界:本地预演只改表现,不改共识状态
- 一份
pendingRequestId+predictedState的最小片段 - 一张「能先播 / 必须等确认」判断表
- 一段把 Gambetta 客户端预测翻译到 VRChat 约束下的说明
依赖前面
- 第 20 章的
requestId/lastProcessedRequestId幂等工具 - 第 21 章的参数化 Network Event 和通道决策表
- 第 26 章的「事件做反馈、状态做共识」
- 创作者视角 6 的「体感撒谎边界」
先把预测降级
Section titled “先把预测降级”传统客户端预测的原模型来自权威服务器架构:客户端先按输入模拟,服务器稍后返回权威状态和已处理到的输入序号,客户端再把未确认输入重放一遍。这个模型有两个关键前提:服务器是最终真相,客户端和服务器能跑接近一致的模拟。
VRChat 世界没有自定义权威服务器。GameState Owner 只是实例内某个玩家客户端,它能裁决状态,但不能成为可信服务器。第六部因此把 prediction(预测)降级为本地预演:先让玩家看到按钮按下、音效播放、准星扩散、子弹飞出去,最终分数、伤害、胜负仍等 Owner 确认。
可以把边界写成一句工程规则:本地预演可以提前改变表现,不能提前改变共识状态。
这条规则让第 29 章不去复刻完整 server reconciliation(服务器调和),只处理 30 到 200 ms 的体感空白。
三层状态不要混
Section titled “三层状态不要混”同一个「释放技能」动作里会出现三层状态。它们的写权不同。
| 层 | 例子 | 写权 | 通道 |
|---|---|---|---|
| 本地表现 | 按钮变灰、播放抬手动画、发出本地音效 | 本地客户端 | 本地变量 |
| 待确认请求 | requestId=42、技能类型、目标 ID | 玩家自己的 PlayerObject 或参数化事件 | 同步字段 / Network Event |
| 共识结果 | 伤害生效、冷却开始、分数变化、敌人死亡 | GameState Owner 或目标对象 Owner | 同步字段 |
本地表现可以立即发生。待确认请求要能被追踪,通常带 requestId。共识结果必须由负责该状态的 Owner 写入同步字段,迟入玩家只认这一层。
最小本地预演片段
Section titled “最小本地预演片段”下面片段只展示决策点。它不替代闭环 3 的 PlayerObject + GameState 骨架,只是在玩家本地加一层 pending 视图。
private int nextLocalRequestId;private int pendingRequestId = -1;private bool predictedButtonPressed;
public void PressSkillButton(){ if (pendingRequestId >= 0) return;
nextLocalRequestId++; pendingRequestId = nextLocalRequestId; predictedButtonPressed = true; ApplyLocalPreview();
IssueSkillRequest(pendingRequestId, selectedSkillId, targetId);}
private void ApplyLocalPreview(){ skillButton.interactable = false; localAudio.PlayOneShot(skillCastPreview); localMuzzleFlash.Play();}predictedButtonPressed 保持为本地字段。它只是本地 UI 的影子状态。IssueSkillRequest 可以沿用第 12 章的同步字段请求,也可以在第 21 章的参数化事件里带上 requestId。关键点在本地预演和共识状态分层,通道选择排在后面。
确认回来后再落地
Section titled “确认回来后再落地”确认有两种结果:Owner 接受,或 Owner 拒绝。接受时,本地 pending 清空,UI 改成真实冷却;拒绝时,撤销预演,给一个轻量提示。
public void OnSkillConfirmed(int confirmedRequestId, bool accepted){ if (confirmedRequestId != pendingRequestId) return;
pendingRequestId = -1; predictedButtonPressed = false;
if (accepted) { StartConfirmedCooldown(); return; }
CancelLocalPreview();}这段和传统 reconciliation 的相似点是:客户端保存了未确认输入,并在确认到来后清理。差异也很明确:这里没有从权威服务器状态重放未确认输入,因为 VRChat 没有那套服务器模拟前提。第 30 章会把这个确认回包做成 lastProcessedRequestId 形态。
什么可以先播
Section titled “什么可以先播”判断标准是:确认失败时,撤销它会不会破坏玩家对状态的信任。
| 内容 | 能否本地先播 | 确认失败时怎么处理 |
|---|---|---|
| 按钮按下态 | 可以 | 恢复按钮,显示短提示 |
| 本地音效 | 可以 | 不回收声音,避免二次突兀 |
| 枪口火光 / 施法动作 | 可以 | 动画自然结束,不回滚 |
| 命中飘字 | 谨慎 | 建议等确认后播 |
| 伤害数字 | 谨慎 | PvE 可先播「预估」,确认后修正 |
| 分数、胜负、阶段切换 | 不先播 | 必须等同步字段 |
| 其他玩家 HP | 不先改 | 必须等对应 Owner / GameState |
这张表刻意把「命中飘字」放在中间。合作 PvE 里,飘一个本地预估数字通常能接受;竞技 PvP 里,预估命中会让玩家产生「明明打中了」的冲突。第 31 章会继续拆命中判定的边界。
和 Network Event 的关系
Section titled “和 Network Event 的关系”本地预演不一定发网络事件。两者解决的问题不同:本地预演让发起者立刻有反馈,Network Event 让其他玩家也看到一次性表现。
一个技能释放可以这样分层:本地立即播抬手和音效;向 GameState Owner 发带 requestId 的请求;Owner 接受后写同步字段并向 All 播一个确认事件;所有客户端根据确认事件播命中特效,迟入玩家则只看同步字段里的最终 HP / 分数。
如果只向 All 播事件,没有同步字段,迟入玩家会丢状态。如果只写同步字段,没有本地预演,发起者会感到按钮慢一拍。两层各自保留,体感和恢复才都稳。
挑一条试。
- 把
ApplyLocalPreview里的音效删掉,只保留按钮变灰。比较输入体感的差异:少掉的到底是确认速度,还是反馈密度? - 把命中飘字改成「等待确认后再播」。如果网络延迟 200 ms,玩家还能接受吗?在哪类玩法里会显得慢?
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. 单客户端按技能按钮 | 按钮立即变灰,本地音效立即播放;共识字段未提前变化 | … |
| 2. Owner 接受请求 | pending 清空,进入真实冷却,同步字段更新 | … |
| 3. Owner 拒绝请求 | 本地按钮恢复,分数 / HP / 阶段没有跳变 | … |
| 4. 迟入玩家加入 | 不补播刚才的本地预演,只看到最新共识状态 | … |
| 5. 网络拥塞时连续点击 | pending 未清空前不会重复发同一类请求 | … |
- 能解释「本地预演只改表现,不改共识状态」
- 能把一次技能释放拆成本地表现、待确认请求、共识结果三层
- 能说出传统客户端预测在 VRChat 里缺少哪两个前提
- 能判断一个反馈是可以先播、谨慎先播,还是必须等确认
- 能说明 Network Event 和本地预演的区别
- Gabriel Gambetta · Client-Side Prediction and Server Reconciliation(访问日期:2026-06-17)— 客户端预测与服务器调和的原模型。
- VRChat Creator Docs · Networking and Synchronization — Owner、同步变量、Network Event、迟入恢复的官方说明。
- 第 21 章 · 参数化 Network Event 与决策表 — 事件不重放和参数限制。
- 第 26 章 · 轻实时范式 — 「事件做反馈、状态做共识」的来源。
- 创作者视角 6 · 体感是欺骗的艺术,但有边界 — 本章的设计边界。