Blog·Studio
文章系列日历归档关于搜索
Blog·Studio

一个记录思考、笔记与作品的技术博客。

Connect

© 2026 · Blog Studio

鄂ICP备19019526号

crafted with care

stay curious ✦

  1. 文章
  2. ›客户端打击感工程 2026:命中反馈的时序与预算

Index

  • 一、打击感是时序问题,不是贴图问题
  • 二、一次命中会触发七个反馈通道
  • 三、Hit Stop:用毫秒定义,不要用帧
  • 四、屏幕震动:从随机噪声到有语义的包络
  • 五、幅度映射:伤害到反馈强度的感知曲线
  • 六、多目标命中:聚合策略决定上限
  • 七、通道预算、并发上限与降级
  • 八、输入到光子:反馈延迟的预算账
  • 九、落地形态:分层、配置化、可观测
  • 参考文献

客户端打击感工程 2026:命中反馈的时序与预算

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

2026年10月31日·约 41 分钟阅读·12,143 字·3 次阅读·博主
#游戏开发
客户端打击感工程 2026:命中反馈的时序与预算

Index

  • 一、打击感是时序问题,不是贴图问题
  • 二、一次命中会触发七个反馈通道
  • 三、Hit Stop:用毫秒定义,不要用帧
  • 四、屏幕震动:从随机噪声到有语义的包络
  • 五、幅度映射:伤害到反馈强度的感知曲线
  • 六、多目标命中:聚合策略决定上限
  • 七、通道预算、并发上限与降级
  • 八、输入到光子:反馈延迟的预算账
  • 九、落地形态:分层、配置化、可观测
  • 参考文献

客户端打击感工程 2026:命中反馈的时序与预算

一句话摘要:打击感不是美术问题,而是一道客户端时序题——把一次命中拆成视觉、听觉、触觉、时间四条通道,每条通道都要有自己的时间戳、自己的预算和自己的降级策略,本文用可复现的数值把这件事讲成可落地的工程。

一、打击感是时序问题,不是贴图问题

先说结论:玩家说"这个游戏打起来不爽",九成时候指向的不是资源质量,而是时间对不上。

学术侧有一个专门的领域在研究这件事。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 则技术性地冻结整个游戏时间的推进。这两个在工程实现上完全不同:前者只需要停一个对象的动画采样,后者要动全局时间轴。

三、Hit Stop:用毫秒定义,不要用帧

顿帧是七个通道里最容易被写坏的一个,因为它的设计单位天然被绑定在了帧上。

先看一组纯算术。假设策划在动作表里写"顿帧 3 帧",这段配置在不同帧率设备上的实际时长是:

设备帧率单帧时长"3 帧"实际顿帧时长
30 fps33.333 毫秒100.0 毫秒
60 fps16.667 毫秒50.0 毫秒
90 fps11.111 毫秒33.33 毫秒
120 fps8.333 毫秒25.0 毫秒

**30 fps 设备上的"3 帧"是 120 fps 设备上的 4 倍。**这意味着同一个配置在低端机和旗舰机上产生完全不同的打击重量——低端机玩家抱怨"打击软",而问题出在配置单位,不在美术。

正确的做法是用毫秒定义,用帧率换算成帧数执行。但换算本身有量化误差。把目标毫秒数四舍五入到最近的整帧后,实际施加值与目标值的偏差如下:

目标时长30 fps60 fps90 fps120 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 缩放)、甚至把状态机的取消窗口给等掉了。

工程上的三种实现路线,按副作用从小到大:

  1. 局部冻结(推荐):只冻结被击中对象的动画采样与逻辑推进,其他系统照常跑。实现上给角色加一个 hitStopUntil 时间戳,在该时间戳之前不接受新的动画推进指令。这是与综述里"Hit Stop 是局部现象"的定义完全对齐的路线。
  2. 分层时间缩放:维护两个时间轴——gameplayTime 与 presentationTime,顿帧只缩放后者。Unity 里对应 Time.unscaledDeltaTime(文档定义为"与 timeScale 无关的、从上一帧到当前帧的秒数间隔,只读")这一类不随缩放走的时钟。
  3. 全局时间缩放:直接改 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.0002.0002.000 倍
能量(偏移量对时间积分)0.500000.333330.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):

伤害线性映射平方根映射对数映射
100.01000.10000.3471
200.02000.14140.4407
500.05000.22360.5691
1000.10000.31620.6680
2000.20000.44720.7676
5000.50000.70710.8998
10001.00001.00001.0000

再看相邻档位之间的幅度差值,也就是"玩家能感知到的变化量":

伤害区间线性映射的差值平方根映射的差值平方根的展开倍数
10 → 20+0.0100+0.04144.142 倍
20 → 50+0.0300+0.08222.740 倍
50 → 100+0.0500+0.09261.852 倍
100 → 200+0.1000+0.13101.310 倍
200 → 500+0.3000+0.25990.866 倍
500 → 1000+0.5000+0.29290.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.162310.000 倍屏幕失控
平方根求和(先聚合伤害再映射)1.00003.162 倍合理
取最大值0.31621.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.050.00 毫秒
80 毫秒4.883.34 毫秒
120 毫秒7.2116.67 毫秒
200 毫秒12.0200.00 毫秒

80 毫秒被拉长到 83.34 毫秒(+4.2%),120 毫秒被截短到 116.67 毫秒(−2.8%)。这种误差玩家不会察觉,但如果你在用"持续时间"来编码语义(比如"轻击 = 50 毫秒"),就必须把语义挂在幅度上而不是时长上。

降级策略是预算的最后一环。移动端与 PC 的能力差异巨大,一个务实的降级顺序是:

  1. 先降闪白与粒子——这些是纯视觉增益,砍掉不影响信息完整性。
  2. 再降震屏幅度(不是完全关闭,而是降 trauma 峰值)——保留方向感,去掉幅度感。
  3. 再降音高变化与音效层次——保留主音效,去掉装饰层。
  4. 顿帧最后动——因为它是打击感中权重最高但也最影响节奏判断的通道,砍掉顿帧等于放弃打击感。
  5. 震马达可以在系统层面被整体关掉(用户在系统设置里关了,或者设备本身不支持),此时不应影响其余通道的表现,只是不发而已。

关键工程约束是:降级必须发生在通道内部,不能让上层逻辑知道"现在是低端机"。也就是说,震屏模块对外暴露的接口永远是"施加一次强度为 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 也能在低端机档位上按配置复现。

**第三是可观测。**反馈系统最难的不是实现而是回归——因为它是主观的。建议落四个指标:

  • 顿帧实际时长分布(P50 / P95)与目标值的偏差。这是最容易出 bug 的通道,因为它受帧率影响。
  • 每帧反馈事件数与被丢弃/被合并的数量。这条曲线爆炸就说明预算没设对。
  • 通道占用时长(每条通道被占用的时间占比)。音效通道超过某个比例就必然出现可闻的抢占。
  • 反馈到事件的时间差(HitEvent 时间戳 vs 通道实际执行时间戳)。这一条专门用来抓"结算层晚一帧导致表现层错位"这类问题——10 月 29 日那篇结算工程已经指出过,"结算层晚一帧,表现层就错位一帧",多帧累积起来就是"明明打中了却没掉血"这类经典客诉。

**可访问性在这里不是附加项。**综述里提到一个时间维度的无障碍案例:《Disc Room》里那些会杀死玩家的判定盒,会在最多 50 毫秒的延迟之后才激活。这本质上就是把"结算时间"与"表现时间"解耦,让玩家有反应窗口。同理,把顿帧做成可关闭或可调节的选项(尤其是对光敏或前庭敏感的玩家)也是一种责任——震屏强度分级、闪白强度分级都应当进设置。

参考文献

  1. Pichlmair, M. and Johansen, M. "Designing Game Feel. A Survey." arXiv:2011.09201v1 [cs.HC], IT University of Copenhagen, 18 Nov. 2020.
  2. Swink, S. Game Feel. Morgan Kaufmann, 2009.
  3. Nijman, J. W. "The art of screenshake." 2013.
  4. Song, J. "Improving the Combat Impact Of Action Games." Apr. 2005.
  5. Brown, M. "Why Does Celeste Feel So Good to Play? — Game Maker's Toolkit." 2019.
  6. Daniels, D. "Why some games feel better than others - part 3." Mar. 2007.
  7. Hurricane, S. "Impact Freeze." Jan. 2010.
  8. Berbece, N. "Game Feel: Why Your Death Animation Sucks." GDC, San Francisco, CA, 2015.
  9. Orozco, M., Silva, J., Saddik, A. E. and Petriu, E. "The Role of Haptics in Games." In Haptics Rendering and Applications, pp. 217–234. IntechOpen, Jan. 2012.
  10. Zagal, J. P. and Mateas, M. "Time in Video Games: A Survey and Analysis." Simulation & Gaming, vol. 41, no. 6, pp. 844–868, Dec. 2010.
  11. Sabbagh, M. "The art of designing visceral and engaging Bullet Time gunplay." Oct. 2015.
  12. Calis, K., Nijman, J. W., Vellmann, T. and Drucker, A. "Disc Room." 2020.
  13. Brown, M. Celeste. Extremely OK Games, 2018.
  14. Gilbert, T. "Movement Mechanics." Dec. 2012.
  15. Wiltshire, A. "How hitboxes work." Aug. 2020.
  16. Jakobsen, T. "Advanced character physics." In Proceedings of the Game Developers Conference 2001, p. 19, 2001.
  17. Perry, L. "The single most useful advice I can give for making any game better... feedback." 2013.
  18. Stephenson, J. "A UX Analysis of First-Person Shooter Damage Indicators." Mar. 2018.
  19. Doucet, L. "Oil it or Spoil it!" Aug. 2016.
  20. Unity Technologies. "Time.timeScale." Unity Script Reference, Unity 6000.0 文档。
  21. Unity Technologies. "Time.unscaledDeltaTime." Unity Script Reference, Unity 6000.0 文档。
  22. Unity Technologies. "AudioSource." Unity Script Reference, Unity 6000.0 文档。
  23. Godot Engine. "Engine." Godot 4.x Class Reference.
  24. Godot Engine. "Input." Godot 4.x Class Reference.
  25. Godot Engine. "AudioServer." Godot 4.x Class Reference.
  26. Android Open Source Project. "android.os.VibrationEffect." Android API Reference, API 26+。
  27. Android Open Source Project. "Android Haptics Constants." Android API Reference.
  28. Epic Games. "CameraShakeBase.h." Engine/Source/Runtime/Engine/Classes/Camera/, Unreal Engine 5.3 公开镜像仓库 chenyong2github/UnrealEngine.
  29. Epic Games. "PerlinNoiseCameraShakePattern.h / .cpp." Engine/Plugins/Cameras/GameplayCameras/Source/GameplayCameras/, Unreal Engine 5.3 公开镜像仓库 chenyong2github/UnrealEngine.
←返回文章列表

Related

可能也会喜欢

  • AI 商店评论挖掘工程 2026:从玩家反馈到版本决策11月1日
  • 游戏内广告变现工程 2026:eCPM 预测与广告负载决策10月31日
  • 客户端红点与通知工程 2026:依赖图、聚合预算与角标落地10月30日

Conversation

0 条

留下你的想法

加载评论中…

New comment