客户端打击感工程 2026:命中反馈的时序与预算
打击感不是美术问题,而是一道客户端时序题。本文把一次命中拆成视觉、听觉、触觉、时间四条通道,逐条给出工程口径:顿帧为什么必须用毫秒而非帧定义、创伤模型的平方衰减如何把能量集中在冲击瞬间、伤害到幅度该走平方根映射而非线性、多目标命中如何先聚合伤害再映射、每个通道的并发预算与降级顺序,以及从输入到光子的延迟总账与四条可落地的可观测指标,全部数值现场自算并附一手文档与源码出处。
约 41 分钟阅读12,143 字3 次阅读博主

打击感不是美术问题,而是一道客户端时序题。本文把一次命中拆成视觉、听觉、触觉、时间四条通道,逐条给出工程口径:顿帧为什么必须用毫秒而非帧定义、创伤模型的平方衰减如何把能量集中在冲击瞬间、伤害到幅度该走平方根映射而非线性、多目标命中如何先聚合伤害再映射、每个通道的并发预算与降级顺序,以及从输入到光子的延迟总账与四条可落地的可观测指标,全部数值现场自算并附一手文档与源码出处。

一句话摘要:打击感不是美术问题,而是一道客户端时序题——把一次命中拆成视觉、听觉、触觉、时间四条通道,每条通道都要有自己的时间戳、自己的预算和自己的降级策略,本文用可复现的数值把这件事讲成可落地的工程。
先说结论:玩家说"这个游戏打起来不爽",九成时候指向的不是资源质量,而是时间对不上。
学术侧有一个专门的领域在研究这件事。IT University of Copenhagen 的 Martin Pichlmair 与 Mads Johansen 在 2020 年 11 月 18 日发布的综述《Designing Game Feel. A Survey》(arXiv:2011.09201,cs.HC)分析了 200 多份资料,把游戏手感拆成三个领域:物理性(physicality)、放大(amplification)、支撑(support),对应的打磨动作分别是调校(tuning)、加汁(juicing)、顺滑(streamlining)。其中加汁的定义被写得很直白——加汁是"相对于用户输入提供过量反馈"的动作,它带来赋能感,同时通过传达游戏事件的重要性来提供反馈的清晰度。
这篇综述还专门划了一条我们做客户端工程时最需要记住的界线:"加汁在机械层面是多余的,但它把与系统的交互变成更愉快的体验。"换句话说,打击感的所有产物——顿帧、震屏、闪白、飘字——在战斗结算表里一行都查不到。它们是纯粹的表达层。
反过来,结算侧写对了不代表玩家有感。2026 年 10 月 29 日本站发布的《客户端战斗结算工程 2026:确定性、叠加与判定仲裁》把精度分级写得很清楚:表现层数值(飘字偏移、屏幕震动幅度、UI 动画进度)用纯 float,随便算,不参与任何一致性要求;结算层数值需要确定性。同站 10 月 28 日的《客户端红点与通知工程 2026》与 10 月 30 日的《客户端养成系统 2026》也各自把表现与结算的接缝处理干净了。
**但全站 733 篇已发布正文里,"顿帧"零命中、"命中停顿"零命中、"屏幕震动强度"零命中、"受击反馈"作为独立话题零命中。**唯一的几次出现,是结算那篇在讲精度分级时把"飘字偏移、屏幕震动幅度"当作一个词组列举——那是被顺带提了一句,不是被展开。
这正是本文要补的位置:把"一次命中之后客户端到底发生了什么"当成一个独立的工程系统来设计。
命中不是一个事件,是一组事件。工程上第一个必须做的动作,是把"命中"这个逻辑结果扇出成若干条独立的反馈通道,每条通道独立计时、独立降级、独立可关。
一个典型的近战命中至少会触发这七条:
| 通道 | 载体 | 典型时长 | 主要受众感官 | 关掉会怎样 |
|---|---|---|---|---|
| 顿帧 Hit Stop | 时间缩放 / 局部冻结 | 30–90 毫秒 | 视觉(重量感) | 打击变"轻",像打空气 |
| 震屏 Screen Shake | 相机偏移 | 100–400 毫秒 | 视觉 | 冲击感显著变弱 |
| 受击闪白 | 材质参数 / 后处理 | 1–3 帧 | 视觉 | 玩家不知道打中了没有 |
| 伤害飘字 | UI 文本 | 600–1200 毫秒 | 视觉 + 认知 | 无法读出数值 |
| 命中音效 | 音频 | 100–500 毫秒 | 听觉 | 打击"闷" |
| 震动马达 | 手柄 / 手机 | 50–200 毫秒 | 触觉 | 打击缺少身体感 |
| 击退 / 硬直 | 角色状态 | 若干帧 | 物理 | 命中没有反作用 |
这张表的价值在于:它把"要不要做打击感"这个模糊问题,变成了"哪几个通道在预算内、哪些被降级了"这个可测问题。
arXiv 那篇综述的第三章第三节把反馈手法分了类,其中"事件显著性"(Event Signification)下面列了十项:Screen Shake、Recoil、One-shot Particles、Cooldown Visualisation、Ragdoll Physics、Colour Flashing、Impact Markers、Hit Stop、Audio Feedback、Haptic Feedback。而"时间操纵"(Time Manipulation)下面另列四项:Freeze Frames、Slow Motion、Bullet Time、Instant Replay。
综述里对 Hit Stop 的定义值得逐字对照:动画在命中瞬间短暂停顿,这个效果有时也叫 Impact Freeze,是格斗与动作游戏里的标配,也是冲击反馈可视化和研究得最充分的现象。它通常用来传达命中的严重程度,但可以走得更远——综述举的例子是《Samurai Gunn》落地时有一个微妙变体,角色会把平台"震晕"几帧,取决于下落高度。
综述同时区分了两个常被混为一谈的概念:Hit Stop 是局部现象——一个或多个屏幕上的对象被暂停,被排除在游戏其余部分的时间流之外;Freeze Frames 则技术性地冻结整个游戏时间的推进。这两个在工程实现上完全不同:前者只需要停一个对象的动画采样,后者要动全局时间轴。
顿帧是七个通道里最容易被写坏的一个,因为它的设计单位天然被绑定在了帧上。
先看一组纯算术。假设策划在动作表里写"顿帧 3 帧",这段配置在不同帧率设备上的实际时长是:
| 设备帧率 | 单帧时长 | "3 帧"实际顿帧时长 |
|---|---|---|
| 30 fps | 33.333 毫秒 | 100.0 毫秒 |
| 60 fps | 16.667 毫秒 | 50.0 毫秒 |
| 90 fps | 11.111 毫秒 | 33.33 毫秒 |
| 120 fps | 8.333 毫秒 | 25.0 毫秒 |
**30 fps 设备上的"3 帧"是 120 fps 设备上的 4 倍。**这意味着同一个配置在低端机和旗舰机上产生完全不同的打击重量——低端机玩家抱怨"打击软",而问题出在配置单位,不在美术。
正确的做法是用毫秒定义,用帧率换算成帧数执行。但换算本身有量化误差。把目标毫秒数四舍五入到最近的整帧后,实际施加值与目标值的偏差如下:
| 目标时长 | 30 fps | 60 fps | 90 fps | 120 fps |
|---|---|---|---|---|
| 40 毫秒 | 33.33(−6.67) | 33.33(−6.67) | 44.44(+4.44) | 41.67(+1.67) |
| 55 毫秒 | 66.67(+11.67) | 50.00(−5.00) | 55.56(+0.56) | 58.33(+3.33) |
| 70 毫秒 | 66.67(−3.33) | 66.67(−3.33) | 66.67(−3.33) | 66.67(−3.33) |
| 90 毫秒 | 100.00(+10.00) | 83.33(−6.67) | 88.89(−1.11) | 91.67(+1.67) |
误差上界就是帧时长的一半:30 fps 是 ±16.667 毫秒,60 fps 是 ±8.333 毫秒,90 fps 是 ±5.556 毫秒,120 fps 是 ±4.167 毫秒。这个上界本身就是一个重要结论——在 30 fps 设备上,你无法把顿帧控制在 ±17 毫秒以内,所以低端机的顿帧参数不应该与高端机共用同一套数值,而应该按帧率分档配置。
如果你的动作系统允许更细的粒度(子帧),就可以绕开量化:把顿帧做成"在第 N 帧开始、把该角色的动画时间轴锁住到第 N + duration_ms 对应的时间点",这样实际时长就是毫秒精确的。代价是渲染层要处理半帧插值,实现复杂度明显上升。
**第三个坑是时间缩放的副作用。**Unity 的 Time.timeScale 文档写得很明确:这个属性控制时间流逝的速率,可以用于慢动作或加速;当值为 1.0 时时间与真实时间同速,值为 0.5 时时间以一半速度流逝;当值被设为零时,如果你的所有函数都与帧率无关,应用表现得就像被暂停了。Godot 的对应物是 Engine.time_scale,文档同样把它定义为"游戏内时钟更新时相对于真实时间的速度倍乘数",并给了 2.0 表示两倍速、0.5 表示半速的例子。
这两个 API 的副作用完全一样:全局时间缩放会同时冻住 UI 动画、粒子、网络心跳、AI 决策定时器、动画状态机过渡。在战斗里全局顿帧 60 毫秒,往往会导致血条补间的动画也停住、正在播放的受击音效在音高上被拉低(如果走了 pitch 缩放)、甚至把状态机的取消窗口给等掉了。
工程上的三种实现路线,按副作用从小到大:
hitStopUntil 时间戳,在该时间戳之前不接受新的动画推进指令。这是与综述里"Hit Stop 是局部现象"的定义完全对齐的路线。gameplayTime 与 presentationTime,顿帧只缩放后者。Unity 里对应 Time.unscaledDeltaTime(文档定义为"与 timeScale 无关的、从上一帧到当前帧的秒数间隔,只读")这一类不随缩放走的时钟。Time.timeScale 或 Engine.time_scale。只在明确的、短暂的、孤立的演出时刻使用(例如 Boss 登场慢动作),不要用它做常规顿帧。还有一个工程上容易忽略的连带问题:顿帧期间,角色的动作取消窗口和输入缓冲窗口是否继续走?如果顿帧是靠停住角色时间轴实现的,那么在这 60 毫秒内玩家按下的"闪避"键,是被丢弃、被缓冲、还是被立刻执行?三种行为的手感差异巨大,而默认实现往往是无意间选了最糟的一种。这个决策必须写进设计文档,不能留给默认值。
震屏是最常被实现成"每帧随机偏移一下"的功能,也是最容易做废的功能。
综述里给了一个我认为应该被直接抄进设计规范的判断:不要随机移动相机,而要用一个精心挑选的、在语义显著方向上的缓动函数——后者传达的信息量明显更多。换句话说,"往上弹一下"和"往左下退一下"传达的是不同的物理事件,玩家能读出差别。
第二句评语来自综述对 Screen Shake 的定义:它有时也叫 camera shake,通过震动相机(或震动世界)来传达一个显著事件——通常是爆炸、受伤,或类似高冲击力的动作。Nijman 和 Jonasson & Purho 都提到过它;缓动与插值函数是这类动态运镜的技术基础。
"创伤模型"(trauma model)是这套方法里最实用的一个。它的核心是:不直接控制震动偏移量,而是控制一个叫 trauma 的标量(0 到 1),偏移量由 trauma 的某次幂导出,trauma 随时间线性衰减。
我把这个模型的两个变体算了一遍。设最大幅度 A 等于 1、总时长 T 等于 1 秒,偏移量分别为线性衰减(p 等于 1)与平方衰减(p 等于 2):
| 指标 | 线性衰减 p=1 | 平方衰减 p=2 | 平方/线性 |
|---|---|---|---|
| 峰值速度 | 1.000 | 2.000 | 2.000 倍 |
| 能量(偏移量对时间积分) | 0.50000 | 0.33333 | 0.6667 倍 |
| 高于半峰值的时间 | 0.5000 秒 | 0.2929 秒 | 0.5858 倍 |
读这张表:平方衰减的初始速度是线性的两倍(冲击更"炸"),但总能量只有线性的三分之二(不会把屏幕摇到吐),而且衰减更快——高于半峰值的时间从 0.5 秒缩到 0.2929 秒。
这就是平方衰减的完整工程理由:**它把能量集中在开头,代价是总位移更小。**打击感要的是"一记重拳",不是"持续晃了半秒"。很多团队用线性衰减做出"软绵绵"的手感,就是因为峰值速度不够;而用平方衰减又觉得"太短",多半是把时长参数也一起缩短了。
Unreal 的相机震动实现能直接看到这套思路的引擎级落地。我抓取了 Engine/Source/Runtime/Engine/Classes/Camera/CameraShakeBase.h 的源码,FCameraShakeUpdateParams 结构体里有三个关键字段:DeltaTime(距上次更新的经过时间)、ShakeScale(震动的基础缩放)、DynamicScale(由相机管理器逐帧传下来的动态缩放),而 GetTotalScale() 返回 FMath::Max(ShakeScale * DynamicScale, 0.f)——基础缩放与动态缩放相乘,下限钳到 0。这个设计与 trauma 模型完全同构:静态缩放对应"这次震动有多大",动态缩放对应"当前该施加多少"(可以随距离、随状态衰减)。
同一个镜像仓库里 Engine/Plugins/Cameras/GameplayCameras 插件下的 PerlinNoiseCameraShakePattern 展示了噪声型震动的另一条路线。它的 FPerlinNoiseShaker 只有两个可调参数——Amplitude(噪声幅度,默认 1.0)与 Frequency(振荡频率,默认 1.0)。更新函数的实现很短,核心是三行:
const float TotalAmplitude = Amplitude * AmplitudeMultiplier;
if (TotalAmplitude != 0.f)
{
InOutCurrentOffset += DeltaTime * Frequency * FrequencyMultiplier;
return TotalAmplitude * FMath::PerlinNoise1D(InOutCurrentOffset);
}
这里有两个值得学的细节。第一,频率是随时间累加进一个偏移量的(InOutCurrentOffset += DeltaTime * Frequency * ...),而不是每帧重新采样——这保证了噪声在时间上连续,不会出现"抖动"(jitter)感。第二,幅度与频率各自有独立的乘子(AmplitudeMultiplier / FrequencyMultiplier),所以"同样幅度但更快"和"同样频率但更大"是两种不同的手感,可以分开调。
这个实现还有一个默认值上的细节值得注意:UPerlinNoiseCameraShakePattern 的构造函数里把 RotationAmplitudeMultiplier 初始化为 0.0,也就是默认只震位置不震旋转。这是个正确的默认值——位置偏移比旋转偏移温和得多,而旋转抖动在第一人称视角里极易引发眩晕(这也是很多游戏只给第一人称玩家提供开关的原因)。UE 还提供了 ECameraShakeUpdateResultFlags 里的 ApplyAsAbsolute 标志,允许子类返回绝对值而非叠加值,配合 SkipAutoScale 与 SkipAutoPlaySpace 构成一套"子类自己管缩放与坐标空间"的出口。
注意:上面这些 Unreal 细节取自公开镜像仓库 chenyong2github/UnrealEngine 的 5.3 分支,这是一个社区维护的源码镜像而非 Epic 官方仓库,且版本固定在 5.3,写进项目文档时应标注版本与镜像性质。官方 dev.epicgames.com 的对应文档页在本次实测中返回 HTTP 200 但去标签后正文只有 17 个字符(纯客户端渲染),因此未从中引用任何细节。
第二个容易被做废的环节是幅度映射——把"伤害是多少"翻译成"震动多强、闪白多亮、震马达多大力"。
最直觉的做法是线性映射:伤害 100 就是 0.1,伤害 1000 就是 1.0。但线性映射有一个致命问题:在低伤害区间,幅度差值几乎不可感知。我把不同映射在几个伤害档位上的归一化输出算了出来(伤害上限取 1000):
| 伤害 | 线性映射 | 平方根映射 | 对数映射 |
|---|---|---|---|
| 10 | 0.0100 | 0.1000 | 0.3471 |
| 20 | 0.0200 | 0.1414 | 0.4407 |
| 50 | 0.0500 | 0.2236 | 0.5691 |
| 100 | 0.1000 | 0.3162 | 0.6680 |
| 200 | 0.2000 | 0.4472 | 0.7676 |
| 500 | 0.5000 | 0.7071 | 0.8998 |
| 1000 | 1.0000 | 1.0000 | 1.0000 |
再看相邻档位之间的幅度差值,也就是"玩家能感知到的变化量":
| 伤害区间 | 线性映射的差值 | 平方根映射的差值 | 平方根的展开倍数 |
|---|---|---|---|
| 10 → 20 | +0.0100 | +0.0414 | 4.142 倍 |
| 20 → 50 | +0.0300 | +0.0822 | 2.740 倍 |
| 50 → 100 | +0.0500 | +0.0926 | 1.852 倍 |
| 100 → 200 | +0.1000 | +0.1310 | 1.310 倍 |
| 200 → 500 | +0.3000 | +0.2599 | 0.866 倍 |
| 500 → 1000 | +0.5000 | +0.2929 | 0.586 倍 |
**平方根映射在低伤害区把可感知的变化量放大了最多 4.142 倍(10 到 20 这一档),在高伤害区反而收窄。**这正是想要的形状:轻击之间要有区分度(否则玩家感觉"打没打着都一样"),重击之间可以压缩(反正都足够震撼)。
另一组对比是同一伤害在三种映射下的比例关系:100 伤害与 10 伤害的幅度比,线性是 10.000,平方根是 3.162,对数是 1.925。线性映射让"十倍伤害"在感官上也是十倍冲击,但人的知觉不是线性的——对数量级的感知在低区间更敏感。所以平方根是一个实用折中:它保留了单调性,压缩了高区间,抬升了低区间的分辨率。
配置层的正确形态是每个通道独立一条曲线,而不是全局共用一个系数。震屏用平方根、闪白用线性(因为闪白有饱和上限)、震马达用查表(因为手柄马达只有强弱两档,Godot 的 Input.start_joy_vibration 就是这个签名:weak_magnitude 与 strong_magnitude 都在 0.0 到 1.0 之间)。
一次技能命中十个敌人时,十个命中事件几乎同时到达反馈系统。如果每个事件都独立触发一次完整震屏,屏幕会直接失控。
我把聚合策略算了一遍。设定单次伤害 100、伤害上限 1000、平方根映射,单次命中的目标幅度是 0.3162。不同聚合方式的结果:
| 聚合策略 | 10 个目标时的总幅度 | 相对单次命中 | 评价 |
|---|---|---|---|
| 直接求和 | 3.1623 | 10.000 倍 | 屏幕失控 |
| 平方根求和(先聚合伤害再映射) | 1.0000 | 3.162 倍 | 合理 |
| 取最大值 | 0.3162 | 1.000 倍 | 偏保守 |
直接求和比平方根求和高 3.1623 倍。而这个比值随目标数增长:3 个目标时是 1.732 倍,5 个是 2.236 倍,10 个是 3.162 倍,20 个是 4.472 倍。命中目标越多,朴素实现的失控越严重。
正确的顺序是先聚合伤害,再做一次幅度映射,而不是先做映射再聚合。数学上也说得通:平方根是凹函数,先聚合再映射得到的是"总伤害的平方根",符合"打十个和打一个总伤害相同"的直觉。
伤害飘字有同样的问题,而且解法略有不同。假设飘字存活 600 毫秒、每 60 毫秒刷新一个、每个数字占 28 像素行高,那么在一个不重叠处理里最多会同时存在 11 个数字:
| 方案 | 需要的垂直跨度 | 在 1080p 屏幕上占比 |
|---|---|---|
| 逐个垂直错开 | 11 × 28 = 308 像素 | 28.52% |
| 时间窗内合并 | 28 像素 | 2.59% |
所以伤害飘字的标准工程做法是:在短时间窗内把同目标的伤害合并成一个累加数字并做合并动画。10 次 100 伤害的命中显示为 1 个"1000",数字数量减少 10 倍,屏幕占用从 28.52% 降到 2.59%。这也是为什么市面上大多数动作游戏的飘字都是"跳一下变大再消失",而不是每一下都新弹一个。
有了映射与聚合,还需要预算。反馈系统的失控很少来自单次触发,更多来自并发。
预算的核心思想是:**每个通道有一个"当前是否已满"的状态,以及一个触发时的抢占规则。**具体到每条通道:
音效通道是最容易拥塞的。Unity 的 AudioSource 文档说明了两个相关机制:PlayOneShot 用来在一个 AudioSource 上播放多个声音(不打断当前播放),而 priority 属性设置 AudioSource 的优先级;pitch 控制音高,outputAudioMixerGroup 决定信号送往哪个混音组。工程实践里的典型配置是:同类音效设一个优先级组,触发时先检查该组是否已达并发上限(一个 AudioSource 装不下就另开),达上限时按优先级抢占——抢占的策略通常是让位给更重要的音效,而不是简单地把新音效丢掉。
Godot 侧的对应物是 AudioServer 的总线系统:bus_count 属性(默认值为 1)表示可用音频总线数量,set_bus_volume_db 设置总线音量(分贝),set_bus_mute 静音,set_bus_send 设置发送目标,set_bus_solo 独奏,另有 set_bus_effect_enabled 与 swap_bus_effects 用于效果器链。把"战斗反馈"单独放在一条总线上,好处是可以整体压低(打 Boss 时把常规命中音效压下去)或整体静音(过场动画时),而不需要逐个音效处理。
震动马达通道有它自己的并发坑。Godot 文档写得很明确:start_joy_vibration 的 duration 传 0.0 时会尽可能长时间播放,大约 65 秒。这个默认值是个陷阱——如果某处代码漏传了时长参数,手柄会持续震动近一分钟。同时文档也说明 get_joy_vibration_duration() 返回的是传给 start_joy_vibration() 的那个值,即使震动实际已经结束也不变,要判断当前是否还在震动得用 is_joy_vibrating()。
触觉反馈在学术侧也被归入"强调性"而非"关键交互":综述里说触觉反馈通常被称为 force feedback 或简单的 controller vibration,是主机控制器的标准功能、也内置于绝大多数手机,它通常用于强调,而不是作为交互的关键组成部分。综述还指出大多数平台发行商都有明确的触觉反馈使用指南,因此平台独占的游戏往往比跨平台游戏更善于利用这些特性。
Android 侧的 API 把这套语义做成了枚举。android.os.VibrationEffect 的常量里,DEFAULT_AMPLITUDE 的值是 −1(0xffffffff),代表使用设备默认强度;EFFECT_CLICK 值 0(API 级别 29 引入)、EFFECT_DOUBLE_CLICK 值 1、EFFECT_TICK 值 2、EFFECT_HEAVY_CLICK 值 5。工厂方法包括 createOneShot(milliseconds, amplitude)、createWaveform(timings, amplitudes, repeat)、createPredefined(effectId)、createRepeatingEffect。createWaveform 的双数组签名(时序数组 + 幅度数组)是做"重击—顿—轻击"这类复合触感的关键——单次的 one-shot 做不出层次。另外要注意,同一批触感原语在组合 API 里的推荐时长目标是不一致的:Android Haptics Constants 给出的估算是 PRIMITIVE_CLICK 为 12 毫秒、PRIMITIVE_TICK 为 5 毫秒。
震动的时长同样有量化问题。在 60 fps 下按帧边界调度:
| 目标震动时长 | 折合帧数 | 对齐后实际时长 |
|---|---|---|
| 50 毫秒 | 3.0 | 50.00 毫秒 |
| 80 毫秒 | 4.8 | 83.34 毫秒 |
| 120 毫秒 | 7.2 | 116.67 毫秒 |
| 200 毫秒 | 12.0 | 200.00 毫秒 |
80 毫秒被拉长到 83.34 毫秒(+4.2%),120 毫秒被截短到 116.67 毫秒(−2.8%)。这种误差玩家不会察觉,但如果你在用"持续时间"来编码语义(比如"轻击 = 50 毫秒"),就必须把语义挂在幅度上而不是时长上。
降级策略是预算的最后一环。移动端与 PC 的能力差异巨大,一个务实的降级顺序是:
关键工程约束是:降级必须发生在通道内部,不能让上层逻辑知道"现在是低端机"。也就是说,震屏模块对外暴露的接口永远是"施加一次强度为 X 的震动",至于最终变成什么,由模块内部根据设备档位、当前通道占用、系统设置共同决定。这样新增降级维度时不需要改任何调用方。
反馈系统还欠一笔总账:从玩家按下按键,到屏幕上真的亮起来,中间有多久。
把这条链路拆开:输入采样(最坏情况等一帧)→ 游戏逻辑(自有预算)→ 渲染提交(自有预算)→ 呈现与扫描输出(约一帧)。以逻辑 2 毫秒、渲染 8 毫秒计:
| 帧率 | 输入采样 | 逻辑 | 渲染 | 呈现 | 总延迟 | 不可压缩的帧数地板 |
|---|---|---|---|---|---|---|
| 60 fps | ≤16.67 毫秒 | 2 毫秒 | 8 毫秒 | ~16.67 毫秒 | 43.33 毫秒 | 33.33 毫秒 |
| 120 fps | ≤8.33 毫秒 | 2 毫秒 | 8 毫秒 | ~8.33 毫秒 | 26.67 毫秒 | 16.67 毫秒 |
**"不可压缩的帧数地板"是这张表最重要的一列。**60 fps 下,光是两次帧的时间就占掉 33.33 毫秒,而总延迟是 43.33 毫秒——帧数相关的部分占了总延迟的 76.9%。从 60 fps 提到 120 fps,帧数相关的部分从 33.33 毫秒降到 16.67 毫秒,正好省下 16.67 毫秒,也就是这一部分的 50%。
这解释了为什么高刷设备的"手感"提升有物理上限:你能砍掉的是帧数地板,砍不掉的是逻辑与渲染时间。
回到顿帧,它占用的是动作节奏预算。假设一次挥击的动作周期是 200 毫秒:
| 顿帧时长 | 占动作周期比例 |
|---|---|
| 50 毫秒 | 25% |
| 70 毫秒 | 35% |
| 90 毫秒 | 45% |
45% 意味着将近一半的动作时间玩家什么都做不了。这个比例一旦超过某个阈值,游戏就从"重"变成了"卡"。顿帧的上限不该由美术的兴奋度决定,而该由动作周期决定——顿帧时长与动作周期的比值,应当是一个受上限约束的参数,比如硬性封顶在 40%。
还有一个容易被忽略的交互:顿帧会改变后续输入的时间感。因为输入缓冲窗口是按毫秒计的(本站 10 月 27 日的《客户端战斗结算工程 2026》专门讨论过这一点:窗口应该用秒或毫秒定义而不是帧号,否则高刷玩家会比低刷玩家更灵敏),所以一次 70 毫秒的顿帧期间,玩家按下的按键会被缓冲住,等顿帧结束时集中释放。这在连击场景里表现为"顿帧结束后突然连打出去两下",玩家会描述为"操作不跟手",但根因是顿帧与缓冲窗口的耦合。
把上面所有内容收成工程结构,需要三样东西。
**第一是分层。**反馈系统至少分三层,且只能单向依赖:
战斗结算层(确定性,产出 HitEvent)
↓ 只读事件
反馈编排层(仲裁、聚合、预算、降级)
↓ 派发指令
通道执行层(顿帧器 / 震屏器 / 飘字池 / 音效池 / 震动器)
结算层只产出事件,绝不知道反馈的存在——这与 10 月 29 日那篇结算工程里"表现层数值不参与任何一致性要求"的分级原则一致。反馈层反过来也绝不允许回写战斗状态。这条单向约束是整个系统可测试的前提:给定一串 HitEvent,输出必须是确定的。
**第二是配置化。**每个通道的曲线、时长、并发上限、优先级、降级档位都应该是数据而不是代码。一份典型的反馈配置表大致是这样:
hit_feedback:
channels:
hit_stop:
driver: local_freeze
duration_ms: [40, 55, 70] # 按伤害档位
max_concurrent: 1 # 同一目标
degrade_priority: 4 # 最先被砍
screen_shake:
driver: trauma
trauma_curve: power2
peak_trauma: [0.15, 0.35, 0.60]
decay_per_second: 2.5
rotation_amplitude: 0.0 # 默认只震位置不震旋转
degrade_priority: 3
damage_number:
driver: pooled_ui
merge_window_ms: 60
lifetime_ms: 900
max_visible: 12
degrade_priority: 2
hit_sound:
driver: priority_pool
group: combat
bus: SFX_Combat
max_concurrent: 6
degrade_priority: 1
rumble:
driver: waveform
pattern: [tick, gap, click]
duration_ms: 80
degrade_priority: 5 # 最先被砍
amplitude_map:
default: sqrt # 伤害 → 幅度
hit_stop: linear
rumble: lookup_table
有了这张表,策划调手感不再需要改代码,QA 也能在低端机档位上按配置复现。
**第三是可观测。**反馈系统最难的不是实现而是回归——因为它是主观的。建议落四个指标:
**可访问性在这里不是附加项。**综述里提到一个时间维度的无障碍案例:《Disc Room》里那些会杀死玩家的判定盒,会在最多 50 毫秒的延迟之后才激活。这本质上就是把"结算时间"与"表现时间"解耦,让玩家有反应窗口。同理,把顿帧做成可关闭或可调节的选项(尤其是对光敏或前庭敏感的玩家)也是一种责任——震屏强度分级、闪白强度分级都应当进设置。
Conversation
0 条