回滚后角色突然跳了一格,为什么只能平滑画面,不能慢慢修改 World?
客户端预测角色在 x = 10,随后收到权威状态:同一 tick 的正确位置其实是 x = 7。为了避免画面瞬移,一种很自然的写法是:
1 | // 存在严重隐患:为了视觉平滑,反过来修改了模拟状态。 |
画面看起来柔和了,但碰撞、子弹命中、下一帧预测和状态 hash 仍在错误位置上运行。客户端甚至可能刚执行完权威 restore/replay,又被渲染代码拉回一条不存在的中间历史。
正确边界是:模拟层立即接受权威状态,渲染层可以让显示位置短暂保留视觉误差,再逐帧衰减。Fighting 当前渲染还没有实现这种平滑,它直接绘制最新 WorldSnapshot;但现有的只读数据流已经为后续平滑划清了边界。
本文以 ClientRender 为背景,解释如何分离 simulation state(模拟状态)与 presentation state(表现状态),并讲清世界坐标变换、SDL 资源生命周期以及网络 HUD 应该怎样解读。项目使用 C++20、SDL2 和 SDL2_ttf;最小示例只依赖标准库,可以独立运行。
1. 为什么回滚会造成视觉跳变?
客户端为了即时响应,会先用本地输入预测:
1 | 预测世界:x = 8 → 9 → 10 |
权威 State 晚几帧到达后,客户端从旧 tick 恢复并重放,得到新的当前状态:
1 | 校正世界:x = 8 → 8 → 7 |
从模拟角度,10 → 7 必须立即发生,否则下一次碰撞仍会使用错误位置。从视觉角度,单帧跳 3 个单位可能刺眼。两者目标不同:
| 层 | 目标 | 能否延后权威校正 |
|---|---|---|
| 模拟层 | 规则、碰撞和网络状态正确 | 不能 |
| 渲染层 | 画面连续、反馈清晰 | 可以只平滑显示误差 |
渲染平滑不是让模拟“慢慢变正确”,而是让正确模拟状态通过一段短暂的视觉过渡显示出来。
2. 当前数据流为什么适合保持这个边界?
RenderFrame 的核心接口是:
1 | void RenderFrame(RenderCtx& render, |
WorldSnapshot 以 const 引用传入。渲染层读取迷宫、玩家、弹道和 HUD 数据,却不持有 World,也不能通过该参数修改模拟快照。
1 | Input / State |
这个方向不能反过来。摄像机位置、屏幕抖动、插值缓存和粒子效果可以属于表现层;HP、冷却、弹道位置和碰撞坐标必须来自模拟层。
3. 最小可运行版本:模拟立即校正,显示误差逐帧衰减
下面不用 SDL,只演示最核心的双状态设计。权威校正把模拟位置立即从 10 改为 7;显示位置仍从 10 开始,用指数形式逐帧接近 7。
1 |
|
在 macOS 或 Linux 上使用 Clang 编译:
1 | clang++ -std=c++20 -O2 -Wall -Wextra -Wpedantic \ |
预期输出约为:
1 | frame=1 simulation=7.000 displayed=9.456 |
每一帧的碰撞位置始终是 7,只有 displayedX 在衰减。指数系数使用真实渲染帧间隔,让过渡速度在不同渲染帧率下大致一致;它不参与固定 tick 模拟,所以不会破坏重放。
这只是位置平滑的最小模型。真实游戏还要决定哪些变化允许平滑:小位置误差可以过渡,死亡、传送、场景切换和巨大校正通常应立即 snap,避免角色穿墙或残影误导玩家。
4. 纠错平滑与固定帧插值是同一件事吗?
不是。两者都可能使用 lerp,但解决不同问题。
4.1 固定帧插值解决渲染帧与模拟帧不同频
模拟以 60 Hz 更新,显示器可能以 144 Hz 渲染。渲染可以在前后两个模拟快照之间插值:
1 | alpha = accumulator / fixedDt |
这里 previous/current 都来自同一条合法模拟历史,目的是填充两个 tick 之间的画面。
4.2 纠错平滑隐藏预测历史被改写后的视觉误差
权威回滚后,旧显示位置可能不再位于合法历史上。表现层可以记录:
1 | visualError = oldDisplayedPosition - correctedSimulationPosition |
如果把旧预测快照继续当作普通插值端点,画面可能穿过墙或在命中后继续显示已失效弹道。纠错平滑需要阈值、过期时间和 snap 规则,不能与常规 tick 插值混成一个没有语义的 lerpFactor。
当前 Fighting 直接绘制最新预测/校正 Snapshot,没有保存 previous snapshot 或 visual error。因此文章中的平滑是可选改进,不是对当前源码行为的描述。
5. 世界坐标怎样映射到屏幕?
项目以窗口中心为世界原点:
1 | screenX = width / 2 + worldX × scale |
Y 取反是因为游戏世界约定向上为正,而 SDL 屏幕坐标通常从左上角开始、向下为正。
scale 根据迷宫尺寸适配 800×600 默认窗口:
1 | scale = min(width / mazeWidth, height / mazeHeight) × 0.8 |
较短边留下约 20% 的整体缩放余量。迷宫单元格坐标先转换成以地图中心为原点的世界坐标,再使用同一个 toScreen;玩家与弹道也共享该变换,所以它们能落在同一地图上。
坐标变换的边界包括:
- 当前没有独立 Camera,对大地图始终整体缩放,而不是跟随玩家;
- width/height 是 RenderCtx 中的固定值,若以后支持窗口缩放,需要响应尺寸变化;
round将浮点屏幕坐标量化成整数像素,低速移动可能出现亚像素抖动;- scale 依赖有效 mazeWidth/mazeHeight,缺失时回退为 50。
这些问题应由渲染层解决,不能为了减少像素抖动去改变 World 中的浮点位置。
6. 迷宫、玩家和弹道为什么都从同一个 Snapshot 绘制?
一次 RenderFrame 的绘制顺序是:
1 | 清空背景 |
后绘制的内容覆盖先绘制内容,所以弹道出现在墙体/玩家之上,HUD 最后覆盖场景。这是当前 Demo 的简单 painter’s order(画家算法顺序),没有深度缓冲和复杂图层。
所有场景元素来自同一 Snapshot 很重要。如果迷宫使用权威 tick、玩家使用最新预测 tick、弹道又来自另一个缓存,单帧画面就没有明确时刻语义。表现层可以插值,但必须知道每项数据属于哪两个状态,不能随意拼接不同 tick。
7. HUD 指标应该怎样读,哪些结论不能直接下?
当前 HUD 显示:tick、rollback 次数、hash mismatch、maze seed、玩家 HP/动作计时,以及网络统计。
| 指标 | 主要含义 | 不能单独证明什么 |
|---|---|---|
| rollback count | 本地玩家显著预测差异次数 | 不等于所有 rebase 次数 |
| hash mismatch | 量化状态对账失败 | 不自动指出哪个字段错 |
| RTT | 输入发送到服务端确认的平滑样本 | 不是纯单向网络传播时间 |
| loss | 服务端根据 seq gap 的估计 | 乱序下不是精确丢包率 |
| input lead | 本地 tick 领先服务端 tick | 不等于 State 延迟 |
| state delay | 收到 State 时的 tick 差 | 不等于 replay 的 CPU 耗时 |
| replay ticks / cost | 本次重放长度和耗时 | 单次值不能代表 p99 |
HUD 的作用是把“手感变差”连接到可观测信号。例如 rollback 上升且 state delay 很大,可能是状态到达太晚;hash mismatch 上升则更像状态闭环或编解码问题。但任何单一指标都只是线索,需要日志和可复现输入进一步定位。
HUD 自身绝不能参与同步决策。不能因为文字纹理创建失败就停止模拟,也不能根据当前 FPS 改变固定 dt。
8. SDL 资源为什么需要比“创建/销毁成对”更严格的管理?
RenderCtx 当前保存三个裸指针:
1 | SDL_Window* window |
正常退出时 ShutdownRenderer 按 font → renderer → window 的顺序释放,然后调用 TTF_Quit 和 SDL_Quit。问题出现在部分初始化失败和后续早退路径。
8.1 InitRenderer 可能只成功一半
窗口创建成功、renderer 创建失败时,函数返回 false,但 window 已经存在;font 打开失败时,window 与 renderer 也可能已经存在。当前主程序只记录 warning 并继续,正常结束仍会清理,但调用者必须知道“false 不代表 RenderCtx 完全为空”。
更稳妥的接口要么在失败时立即回滚已创建资源,要么保证 RAII 对象析构时总能清理部分状态。这样调用者不会依赖每个 return 分支手工拼接释放顺序。
8.2 渲染初始化之后的早退也需要统一清理
当前客户端在 renderer 初始化后,如果 UDP socket、event read 或 tick event 初始化失败,会直接 return;这些路径没有统一调用 ShutdownRenderer、TTF_Quit 和 SDL_Quit。进程退出时操作系统会回收资源,但这不是可复用组件应依赖的生命周期策略。
可以用小型 RAII wrapper,或将 main 改成单一 cleanup 出口。无需为了三个指针搭建复杂框架,关键是让“资源所有者”明确,并让所有失败路径自动或集中释放。
8.3 每帧创建文字纹理有什么成本?
当前 drawText 对每一行 HUD 都执行:
1 | TTF_RenderText_Blended → SDL_Surface |
对于 Demo 很直接,但 HUD 行数增加后,每帧的字体栅格化、分配和纹理创建可能成为 CPU/驱动开销。优化前应先 profile;确有热点时,可缓存不变标签,只在数值变化时重建对应纹理,或使用字形图集。不能仅凭直觉引入复杂缓存失效系统。
9. 从 Demo 走向工程实现,还要注意什么?
9.1 区分本地玩家与数组中的第一个玩家
当前玩家颜色按数组下标固定:P1 蓝色、其他玩家红色,并不根据 localPlayerId 高亮“我”。双客户端调试时要避免把颜色误认为本地/远端身份;后续可把视角玩家 ID 作为纯渲染参数传入。
9.2 失败降级要清楚
没有 renderer 时 RenderFrame 直接返回;没有 font 时场景仍可绘制,只是 HUD 消失。这种降级适合 Demo。正式客户端应在日志或可观测状态中区分“无窗口”“无 HUD”和“模拟停止”,不要让渲染失败静默伪装成网络卡死。
9.3 插值缓存要在 Reset 和场景切换时清空
上一局的 previous snapshot、visual error 或粒子不能进入新 match。收到 Reset/Start、传送或地图变化时,应清除表现历史并 snap 到新基线。
9.4 表现层对象不能进入 state hash
摄像机、粒子、受击闪光和屏幕抖动即使带随机性,也不应影响权威 hash。如果某个视觉效果反过来决定命中范围,它就不再是纯表现状态,必须移回模拟规则并进入 Snapshot。
9.5 线程边界需要显式快照所有权
当前事件循环、模拟和渲染在同一主线程顺序执行,没有并发读写 Snapshot。若以后把渲染移到独立线程,不能直接共享正在修改的 World;应传递不可变快照、双缓冲或带明确同步的副本,并说明谁拥有每一帧数据。
10. 常见误区
10.1 误区:画面平滑就是对 World 坐标做 lerp
这会改变碰撞与网络状态。正确做法是 World 立即权威校正,renderer 对单独的 displayed transform 做过渡。
10.2 误区:渲染 FPS 越高,模拟也应该多 Step
模拟固定 60 Hz,渲染可以更高频读取/插值。让 Step 跟随显示器刷新率,会让不同机器产生不同游戏速度和回放结果。
10.3 误区:rollbackCount 没变,画面就没有接受权威状态
当前 rollbackCount 只统计本地玩家的显著差异,每个有效新 State 仍会 rebase/replay。渲染看到的是校正后的预测世界。
10.4 误区:用 seed 就能在所有平台画出相同迷宫
当前客户端的权威地图来自 Start 中的完整网格。跨标准库仅依赖 std::shuffle 与 seed 不足以保证同一迷宫。
10.5 误区:HUD 越详细,网络问题越容易解决
没有定义、采样窗口和关联日志的数字只会制造噪声。先明确指标计算口径,再增加真正能回答诊断问题的展示。
11. 什么时候应该做视觉平滑?
适合平滑的通常是小幅、短时、不会误导规则判断的位置或摄像机误差。对局部玩家移动,可以在几十到一两百毫秒内衰减视觉偏差,并设置最大距离与最大持续时间。
不适合平滑的场景包括:角色死亡、传送、地图切换、越墙校正、弹道已命中/销毁、差异大到继续显示旧位置会误导玩家。此时应立即 snap,并用特效解释变化,而不是让错误实体缓慢穿过场景。
如果游戏采用远端状态插值而不是全世界回滚,表现缓存的结构也会不同。本文方法适合“模拟已经得到正确当前状态,只想短暂隐藏视觉误差”的客户端,不是所有网络渲染的统一模板。
12. 总结:平滑的是误差,不是规则
开头的 world.player.x += ... 看似解决跳变,实质上让渲染反向修改了权威模拟。正确设计应守住以下边界:
- 模拟立即 restore/replay 到正确状态,渲染只读 Snapshot。
- 常规 fixed-tick 插值与 rollback 纠错平滑解决不同问题,需要不同缓存语义。
- 世界到屏幕变换、摄像机和像素量化全部留在表现层。
- HUD 指标是诊断线索,不参与同步,也不能脱离计算口径解读。
- SDL 资源必须覆盖部分初始化和所有早退路径,性能优化要以 profile 为依据。
最直接的实践建议是:在代码审查中搜索所有渲染模块对 World 或可变 Snapshot 的引用。如果视觉代码能修改碰撞所读取的状态,先切断这条依赖,再讨论插值算法。
13. 当前源码阅读入口
以下路径相对于 Fighting 项目根目录:
include/lab/app/ClientRender.h:RenderCtx、NetworkStats 与只读渲染接口;src/app/ClientRender.cpp:坐标变换、场景绘制和 HUD 纹理;apps/client_main.cpp:SDL/TTF 初始化、RenderFrame 调用与清理路径;include/lab/sim/StateSnapshot.h:渲染消费的世界数据;src/app/InputPrediction.cpp:HUD 使用的动作名称;docs/项目界面.png:当前客户端运行效果截图。