回滚后角色突然跳了一格,为什么只能平滑画面,不能慢慢修改 World?

客户端预测角色在 x = 10,随后收到权威状态:同一 tick 的正确位置其实是 x = 7。为了避免画面瞬移,一种很自然的写法是:

1
2
// 存在严重隐患:为了视觉平滑,反过来修改了模拟状态。
world.player.x += (authoritativeX - world.player.x) * 0.1f;

画面看起来柔和了,但碰撞、子弹命中、下一帧预测和状态 hash 仍在错误位置上运行。客户端甚至可能刚执行完权威 restore/replay,又被渲染代码拉回一条不存在的中间历史。

正确边界是:模拟层立即接受权威状态,渲染层可以让显示位置短暂保留视觉误差,再逐帧衰减。Fighting 当前渲染还没有实现这种平滑,它直接绘制最新 WorldSnapshot;但现有的只读数据流已经为后续平滑划清了边界。

本文以 ClientRender 为背景,解释如何分离 simulation state(模拟状态)与 presentation state(表现状态),并讲清世界坐标变换、SDL 资源生命周期以及网络 HUD 应该怎样解读。项目使用 C++20、SDL2 和 SDL2_ttf;最小示例只依赖标准库,可以独立运行。

1. 为什么回滚会造成视觉跳变?

客户端为了即时响应,会先用本地输入预测:

1
2
预测世界:x = 8 → 9 → 10
↑ 当前显示

权威 State 晚几帧到达后,客户端从旧 tick 恢复并重放,得到新的当前状态:

1
2
校正世界:x = 8 → 8 → 7
↑ 正确当前状态

从模拟角度,10 → 7 必须立即发生,否则下一次碰撞仍会使用错误位置。从视觉角度,单帧跳 3 个单位可能刺眼。两者目标不同:

目标 能否延后权威校正
模拟层 规则、碰撞和网络状态正确 不能
渲染层 画面连续、反馈清晰 可以只平滑显示误差

渲染平滑不是让模拟“慢慢变正确”,而是让正确模拟状态通过一段短暂的视觉过渡显示出来。

2. 当前数据流为什么适合保持这个边界?

RenderFrame 的核心接口是:

1
2
3
4
5
void RenderFrame(RenderCtx& render,
const WorldSnapshot& snapshot,
std::uint32_t rollbackCount,
std::uint32_t hashMismatchCount,
const NetworkStats* networkStats);

WorldSnapshotconst 引用传入。渲染层读取迷宫、玩家、弹道和 HUD 数据,却不持有 World,也不能通过该参数修改模拟快照。

1
2
3
4
5
6
7
Input / State

预测、Restore、Replay

WorldSnapshot(模拟事实)
↓ 只读
ClientRender(屏幕表现)

这个方向不能反过来。摄像机位置、屏幕抖动、插值缓存和粒子效果可以属于表现层;HP、冷却、弹道位置和碰撞坐标必须来自模拟层。

3. 最小可运行版本:模拟立即校正,显示误差逐帧衰减

下面不用 SDL,只演示最核心的双状态设计。权威校正把模拟位置立即从 10 改为 7;显示位置仍从 10 开始,用指数形式逐帧接近 7。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <cassert>
#include <cmath>
#include <iomanip>
#include <iostream>

float SmoothToward(float displayed,
float simulation,
float sharpness,
float frameSeconds) {
const float alpha = 1.0f - std::exp(-sharpness * frameSeconds);
return displayed + (simulation - displayed) * alpha;
}

int main() {
constexpr float frameSeconds = 1.0f / 60.0f;
constexpr float sharpness = 12.0f;

float simulationX = 10.0f;
float displayedX = simulationX;

// 权威状态到达:模拟立即正确,显示位置暂时保留旧画面。
simulationX = 7.0f;
assert(simulationX == 7.0f);

std::cout << std::fixed << std::setprecision(3);
for (int frame = 1; frame <= 5; ++frame) {
displayedX = SmoothToward(
displayedX, simulationX, sharpness, frameSeconds
);
std::cout << "frame=" << frame
<< " simulation=" << simulationX
<< " displayed=" << displayedX << '\n';
}

assert(std::fabs(displayedX - simulationX) < 3.0f);
assert(std::fabs(displayedX - simulationX) > 0.0f);
}

在 macOS 或 Linux 上使用 Clang 编译:

1
2
3
clang++ -std=c++20 -O2 -Wall -Wextra -Wpedantic \
visual_smoothing.cpp -o visual_smoothing
./visual_smoothing

预期输出约为:

1
2
3
4
5
frame=1 simulation=7.000 displayed=9.456
frame=2 simulation=7.000 displayed=9.011
frame=3 simulation=7.000 displayed=8.646
frame=4 simulation=7.000 displayed=8.348
frame=5 simulation=7.000 displayed=8.104

每一帧的碰撞位置始终是 7,只有 displayedX 在衰减。指数系数使用真实渲染帧间隔,让过渡速度在不同渲染帧率下大致一致;它不参与固定 tick 模拟,所以不会破坏重放。

这只是位置平滑的最小模型。真实游戏还要决定哪些变化允许平滑:小位置误差可以过渡,死亡、传送、场景切换和巨大校正通常应立即 snap,避免角色穿墙或残影误导玩家。

4. 纠错平滑与固定帧插值是同一件事吗?

不是。两者都可能使用 lerp,但解决不同问题。

4.1 固定帧插值解决渲染帧与模拟帧不同频

模拟以 60 Hz 更新,显示器可能以 144 Hz 渲染。渲染可以在前后两个模拟快照之间插值:

1
2
alpha = accumulator / fixedDt
display = lerp(previousSnapshot, currentSnapshot, alpha)

这里 previous/current 都来自同一条合法模拟历史,目的是填充两个 tick 之间的画面。

4.2 纠错平滑隐藏预测历史被改写后的视觉误差

权威回滚后,旧显示位置可能不再位于合法历史上。表现层可以记录:

1
2
visualError = oldDisplayedPosition - correctedSimulationPosition
display = correctedSimulationPosition + decaying(visualError)

如果把旧预测快照继续当作普通插值端点,画面可能穿过墙或在命中后继续显示已失效弹道。纠错平滑需要阈值、过期时间和 snap 规则,不能与常规 tick 插值混成一个没有语义的 lerpFactor

当前 Fighting 直接绘制最新预测/校正 Snapshot,没有保存 previous snapshot 或 visual error。因此文章中的平滑是可选改进,不是对当前源码行为的描述。

5. 世界坐标怎样映射到屏幕?

项目以窗口中心为世界原点:

1
2
screenX = width  / 2 + worldX × scale
screenY = height / 2 - worldY × 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
2
3
4
5
6
7
8
9
10
11
清空背景

迷宫墙体

玩家方块

存活弹道

HUD 文本

SDL_RenderPresent

后绘制的内容覆盖先绘制内容,所以弹道出现在墙体/玩家之上,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
2
3
SDL_Window*   window
SDL_Renderer* renderer
TTF_Font* font

正常退出时 ShutdownRenderer 按 font → renderer → window 的顺序释放,然后调用 TTF_QuitSDL_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;这些路径没有统一调用 ShutdownRendererTTF_QuitSDL_Quit。进程退出时操作系统会回收资源,但这不是可复用组件应依赖的生命周期策略。

可以用小型 RAII wrapper,或将 main 改成单一 cleanup 出口。无需为了三个指针搭建复杂框架,关键是让“资源所有者”明确,并让所有失败路径自动或集中释放。

8.3 每帧创建文字纹理有什么成本?

当前 drawText 对每一行 HUD 都执行:

1
2
3
TTF_RenderText_Blended → SDL_Surface
SDL_CreateTextureFromSurface → SDL_Texture
绘制后立即 DestroyTexture

对于 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 += ... 看似解决跳变,实质上让渲染反向修改了权威模拟。正确设计应守住以下边界:

  1. 模拟立即 restore/replay 到正确状态,渲染只读 Snapshot。
  2. 常规 fixed-tick 插值与 rollback 纠错平滑解决不同问题,需要不同缓存语义。
  3. 世界到屏幕变换、摄像机和像素量化全部留在表现层。
  4. HUD 指标是诊断线索,不参与同步,也不能脱离计算口径解读。
  5. 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:当前客户端运行效果截图。