本文只讨论客户端:从玩家按下扳机,到屏幕上出现命中反馈,这中间客户端要处理哪些问题。服务器权威判定、延迟补偿回滚、反作弊校验只在与客户端相关时提及。
一、客户端在射击系统中的职责
无论项目采用 Hitscan、实体弹道还是混合方案,只要伤害由服务器权威决定,客户端的职责就可以归纳为三件事:
- 准确地产生射击事件:在正确的时间、从正确的位置、朝正确的方向开火,并把这些信息可靠地告诉服务器
- 立即给出可信的反馈:不等服务器回包,就让玩家感到"枪响了、打中了"
- 把逻辑结果包装成好的表现:曳光弹、弹孔、音效、准星反馈,同时与服务器结果保持一致
完整的客户端射击管线如下:
输入采样 ──► 开火节奏控制 ──► 射击参数生成 ──► 本地预测判定 ──► 表现层播放 (帧级/Sub-tick) (射速累加器) (原点/方向/散布) (射线/弹道) (特效/音效/UI) │ ▼ ShotEvent 上行 ──► 服务器判定 ──► 确认/拒绝 ──► 结果对账下面按管线顺序逐段展开。
二、输入与开火节奏
2.1 射速不能依赖帧率
最常见的新手错误是"每帧检测扳机,间隔到了就开一枪"。这样做会让实际射速受帧率影响:
- 600 RPM 的武器,开火间隔为 100ms
- 帧率 30fps 时,每帧约 33ms,开火时刻只能落在 33/66/99→133ms……误差会累积,实际射速下降
- 帧率波动时,射速还会抖动,压枪手感变得不稳定
正确做法是使用累加器,并且允许一帧内开多枪(低帧率或高射速时):
voidWeaponComponent::Tick(floatdt,doubleframeTime){fireCooldown-=dt;while(triggerHeld&&fireCooldown<=0.0f&&HasAmmo()){// fireCooldown 为负数,说明这一枪本应在本帧之前就开出doubleexactFireTime=frameTime+fireCooldown;// 精确到帧内的开火时刻FireShot(exactFireTime);fireCooldown+=fireInterval;// 累加而不是重置,保证长期射速准确}if(!triggerHeld){fireCooldown=std::max(fireCooldown,0.0f);// 松开扳机后不允许"攒枪"}}2.2 帧内时间戳(Sub-tick)
网络 Tick 通常是 30/60/64/128Hz,而玩家的点击可能发生在两个 Tick 之间。如果开火时间被量化到 Tick 边界,高精度对枪时会出现"明明我先开枪"的情况。
现代做法(CS2 公开的 Sub-tick 机制是代表)是:
- 输入事件记录精确时间戳(或在 Tick 内的相对偏移)
- 开火时的视角方向使用该时刻的视角,而不是 Tick 结束时的视角
- 服务器按这个精确时间做延迟补偿
客户端需要配合的是:鼠标输入和视角更新的频率要高于网络 Tick,并且保留开火瞬间的视角快照,不能在打包时才读取当前视角。
2.3 首发延迟
从按键到枪口火焰出现,链路上的每一环都会增加延迟:
| 环节 | 典型延迟 | 优化手段 |
|---|---|---|
| 输入采样 | 0~1 帧 | 输入线程独立采样,渲染前最后时刻读取 |
| 游戏逻辑 | 1 帧 | 开火逻辑放在帧早期执行 |
| 渲染管线 | 1~3 帧 | 减少 GPU 排队(Reflex / Anti-Lag 类技术) |
| 显示 | 显示器刷新与响应时间 | 不可控 |
开火反馈(火焰、音效、视角后坐)必须本地立即播放,绝不能等服务器。
三、射击原点与方向
3.1 摄像机与枪口的矛盾
FPS 中,摄像机在眼睛位置,枪口在右下方。射线从哪里发出,会带来完全不同的问题:
| 方案 | 优点 | 问题 |
|---|---|---|
| 从摄像机发出 | 准星指哪打哪 | 枪口被墙挡住时,子弹仍能从"眼睛"射出 |
| 从枪口发出 | 物理真实 | 准星与弹着点有视差,近距离尤其明显 |
| 折中(业界常用) | 兼顾两者 | 实现稍复杂 |
折中方案:
ShotOriginComputeShotOrigin(){// 1. 从摄像机求出玩家真正瞄准的点RaycamRay(camera.pos,camera.forward);Vec3 aimPoint=Trace(camRay,maxRange).hitPointOr(camRay.At(maxRange));// 2. 逻辑判定仍以摄像机射线为准(保证准星可信)// 3. 但要检查"枪口到瞄准点"之间是否被近处遮挡Vec3 muzzle=GetMuzzleWorldPos();// 注意:要用第三人称/世界空间的枪口if(Trace(muzzle,aimPoint).blockedWithin(blockCheckDist)){// 枪口顶墙:命中改为遮挡点,防止"把枪藏在墙后,用眼睛开枪"return{muzzle,Normalize(aimPoint-muzzle),true};}return{camera.pos,camera.forward,false};}3.2 第一人称武器模型(Viewmodel)的陷阱
大多数 FPS 会单独渲染手臂和枪:使用独立的 FOV(通常更小),并在深度上做特殊处理,防止枪穿模进墙里。这会带来一个问题:
屏幕上看到的枪口位置,不等于世界空间中的枪口位置。
所以:
- 判定一律使用世界空间数据(摄像机或第三人称模型的枪口)
- 表现(枪口火焰、曳光起点)使用 Viewmodel 的枪口,需要在两个空间之间做转换
- 换算时注意 Viewmodel 的 FOV 差异,否则曳光弹会从奇怪的位置"飞出来"
3.3 第三人称越肩视角
越肩视角的摄像机偏离角色,问题比第一人称更严重:角色躲在墙后,摄像机却能看到墙外的敌人。常见对策是在开火时额外检测"角色头部或枪口到目标"的遮挡,并在 UI 上提示"射线被阻挡"(很多 TPS 会把准星变色)。
四、散布与后坐力
4.1 两个容易混淆的概念
| 后坐力(Recoil) | 散布(Spread) | |
|---|---|---|
| 作用对象 | 视角(摄像机朝向) | 子弹方向(相对视角的偏移) |
| 玩家能否控制 | 能(压枪) | 基本不能(只能通过停止移动、点射来减小) |
| 是否可预测 | 通常是固定或半固定的模式 | 随机 |
| 主要实现位置 | 客户端视角系统 | 客户端与服务器共同计算 |
4.2 后坐力的分层
成熟项目会把后坐力拆成多层,分别服务于"手感"和"判定":
视觉后坐(Visual Kick) ── 只影响 Viewmodel 抖动和镜头晃动,不影响弹道 + 视角后坐(Aim Punch) ── 真实改变摄像机朝向,影响弹道,需要玩家压枪 + 恢复(Recovery) ── 停火后视角回落的速度和曲线视觉后坐可以做得很夸张,以增强打击感;视角后坐决定武器的真实操控难度。两者解耦后,美术和战斗策划可以分别调参而互不影响。
后坐力模式通常配置为曲线或点序列(第 N 发的偏移量),并在执行时进行平滑插值,避免视角跳变。
4.3 散布的确定性
散布必须客户端与服务器结果一致,否则预测命中与服务器判定会频繁不一致。做法是用共享种子生成随机数:
Vec3ApplySpread(constVec3&dir,floathalfAngleRad,uint32 shooterId,uint32 shotSeq,intpelletIdx){DeterministicRngrng(Hash(shooterId,shotSeq,pelletIdx));// 两端算出相同的随机序列floatu=rng.Next01();floattheta=rng.Next01()*2.0f*PI;// sqrt(u) 使弹着点在圆盘内均匀分布;去掉 sqrt 则弹着点向中心集中(很多游戏更偏好这种手感)floatr=std::sqrt(u)*std::tan(halfAngleRad);Vec3 right,up;BuildOrthonormalBasis(dir,right,up);returnNormalize(dir+right*(r*std::cos(theta))+up*(r*std::sin(theta)));}要点:
- 随机数生成器必须是跨平台确定性的实现,不能使用标准库的
rand()或平台相关的分布函数 - 霰弹枪的多颗弹丸用
pelletIdx区分,只需同步一个事件 - 客户端不提交散布后的方向,只提交原始瞄准方向,由服务器自行计算散布,防止外挂伪造"零散布"
五、本地预测判定
5.1 为什么客户端也要判定
如果等服务器结果再显示命中,玩家会在 RTT 延迟之后才看到反馈。100ms 的延迟就足以让射击变得"肉"。所以客户端需要在本地预测命中,再由服务器确认。
5.2 Hitscan 的本地判定
客户端判定时,射线检测的对象是当前屏幕上渲染的敌人,也就是经过插值、位于过去时刻的远端玩家。这正是服务器延迟补偿要回滚到的状态,两端看的是同一个世界,预测才可能准确。
预测不一致的主要来源:
| 来源 | 说明 | 对策 |
|---|---|---|
| Hitbox 姿态不同 | 客户端用完整动画,服务器可能用简化姿态 | 服务器与客户端使用同一套 Hitbox 驱动逻辑,或客户端判定也用简化姿态 |
| 插值时刻不同 | 客户端插值延迟抖动 | 在 ShotEvent 中上报客户端实际使用的插值时刻 |
| 目标状态突变 | 目标在 RTT 内死亡、被技能击退 | 无法完全消除,依赖反馈分级来掩盖 |
| 散布不一致 | 随机数实现不同 | 确定性 RNG |
5.3 实体弹道的本地预测
实体弹道的子弹会飞行一段时间,客户端需要:
- 立即生成本地预测子弹,在本地逐帧模拟和渲染
- 服务器同样生成权威子弹
- 本地子弹命中时,只播放弱反馈(弹孔、命中特效)
- 服务器确认后播放强反馈(伤害数字、击杀提示)
如果服务器把权威子弹的状态同步下来(部分项目会这样做),客户端不应直接"瞬移"到权威位置,而应在几帧内平滑融合,否则曳光弹会出现可见的跳变。
5.4 反馈分级
这是射击手感设计中最重要的工程策略之一:
| 反馈类型 | 示例 | 触发依据 | 理由 |
|---|---|---|---|
| 开火反馈 | 枪口火焰、枪声、后坐、弹壳 | 本地立即 | 与判定无关,必须零延迟 |
| 弱命中反馈 | 弹孔、溅射、血雾、命中音 | 本地预测 | 偶尔误判,玩家不易察觉 |
| 强命中反馈 | 伤害数字、命中标记、爆头提示 | 服务器确认 | 直接关系到对"是否打中"的判断,不能错 |
| 结算反馈 | 击杀提示、击杀播报、计分 | 服务器确认 | 必须准确 |
不同项目对"命中标记"是否预测有不同取舍:预测能让手感更跟手,但误报会让玩家觉得"打中了却没掉血"。竞技项目通常选择等待确认,休闲项目可以预测。
六、表现层
6.1 曳光弹:逻辑与视觉的分离
逻辑上子弹从摄像机发出,视觉上曳光弹应该从枪口发出。两者之间的偏移需要在飞行过程中逐渐收敛:
// 视觉位置 = 逻辑位置 + 一个随飞行距离衰减为 0 的偏移Vec3TracerVisualPos(constVec3&logicPos,floattraveledDist,constVec3&muzzleOffset,floatconvergeDist){floatw=std::max(0.0f,1.0f-traveledDist/convergeDist);w=w*w;// 平滑收敛,避免肉眼可见的折线returnlogicPos+muzzleOffset*w;// muzzleOffset = 视觉枪口 - 逻辑原点}其他曳光弹技巧:
- Hitscan 武器:命中点在开火瞬间就已确定,曳光弹只是从枪口飞向命中点的特效,速度可以人为调低,方便玩家看清
- 实体弹道武器:曳光弹跟随模拟轨迹,远距离下坠清晰可见,本身就是重要的玩法信息
- 不是每颗子弹都有曳光:现实中通常每隔几发装一颗曳光弹,游戏里也常按"每 N 发显示一次"来控制画面信息量和性能
- 拉伸渲染:高速子弹在单帧内移动距离很大,通常用沿速度方向拉伸的 Billboard 或 Ribbon,长度与速度相关
6.2 命中特效
命中特效由表面材质驱动,一般做成查找表:
物理材质(混凝土/金属/木头/玻璃/水/肉体…) × 武器类型(小口径/大口径/霰弹/爆炸) → 弹孔贴花 + 粒子特效 + 命中音效 + 可选的物理冲量注意事项:
- 弹孔贴花需要总量预算,用环形缓冲区淘汰最旧的贴花
- 动态物体(门、载具、角色)上的贴花要附着到骨骼或物体上,随物体移动
- 穿透时,入口和出口都要生成特效,出口特效方向沿子弹方向
- 肉体命中特效通常受分级设置(血液开关)控制
6.3 声音
射击声音直接影响玩家对方位和距离的判断,在竞技项目中属于玩法信息:
| 声音 | 说明 |
|---|---|
| 枪声(Muzzle Report) | 按距离切换近/中/远三层素材,远处加低通滤波和混响 |
| 声速延迟 | 远处枪声按 343 m/s 延迟播放,1000m 外约延迟 2.9s |
| 超音速爆音(Crack) | 子弹从玩家附近掠过时的音爆,比枪声更早到达 |
| 掠过声(Whiz / Flyby) | 子弹从耳边飞过,提示"有人在打我" |
| 命中音 | 区分命中身体、爆头、护甲 |
掠过声的检测方法:计算子弹本帧线段到听者的最近距离。
voidCheckFlyby(constVec3&segStart,constVec3&segEnd,constVec3&listener,floatradius){Vec3 closest=ClosestPointOnSegment(segStart,segEnd,listener);if(DistanceSq(closest,listener)<radius*radius&&!bullet.flybyPlayed){PlaySound3D(FlybySound,closest);bullet.flybyPlayed=true;// 每颗子弹只触发一次}}6.4 远端玩家的射击
其他玩家开枪时,客户端收到他们的 ShotEvent,需要:
- 在对应的角色模型上播放枪口火焰、枪声和抛壳
- 用相同的种子和参数在本地重新计算弹道,播放曳光弹和命中特效
- 不做伤害判定,伤害只由服务器下发
时间上的处理:收到事件时,子弹实际已经飞了一段时间(上行延迟 + 服务器处理 + 下行延迟)。对实体弹道而言,有两种选择:
- 快进:直接把子弹推进到当前应在的位置。轨迹准确,但子弹看起来不是从枪口出来的
- 从枪口播放:视觉连贯,但子弹落后于真实位置
常见折中是从枪口开始播放,在前几帧内以略快的速度追上真实轨迹。
七、瞄具与远程射击
7.1 开镜(ADS)
- 开镜时 FOV 改变,鼠标灵敏度需要按 FOV 比例换算,保证开镜前后"屏幕上移动同样距离所需的鼠标位移"一致
- 开镜时通常散布更小、后坐更低,这些参数由武器配置驱动
- 开镜过渡期间(动画播放中)的散布和准星位置需要单独定义,否则会出现"开镜一半就开枪"的边界问题
7.2 瞄准镜渲染
| 方案 | 说明 | 代价 |
|---|---|---|
| 全屏遮罩 | 开镜后整个屏幕放大,周围用黑色遮罩 | 便宜,但沉浸感弱 |
| 画中画(Render to Texture) | 镜片内单独渲染一个放大视角,镜外保持原 FOV | 需要渲染两次场景,开销显著 |
| 视差模拟 | 镜片内的分划板根据眼睛位置偏移,模拟真实瞄准镜的出瞳效果 | 拟真向项目使用 |
画中画方案中,判定仍以主摄像机射线为准,镜内画面只是表现,两者的中心必须严格对齐,否则会出现"十字准星对准了却打偏"。
7.3 归零与分划
实体弹道武器需要考虑:
- 归零距离:瞄具视线与枪管之间有一个小夹角,使弹道在归零距离处与视线相交。这个夹角应该在发射方向上体现
- 分划板:密位分划可以帮助玩家估算下坠量。分划刻度应由弹道配置计算得出,不要美术手绘后再让策划对数值
- 测距辅助:部分游戏提供可调归零或测距工具,这些都依赖客户端能够对弹道进行解析或预计算
八、性能
客户端射击相关的开销主要在表现层,而不是判定。
| 开销来源 | 优化手段 |
|---|---|
| 曳光弹 | GPU 实例化渲染,对象池;远处曳光降低频率或合并 |
| 命中粒子 | GPU 粒子,按距离分级(LOD),远处只保留关键效果 |
| 弹孔贴花 | 总量上限 + 环形淘汰;远处不生成 |
| 射线检测 | 批量提交(Unity 的RaycastCommand、Unreal 的异步 Trace);检测通道只包含必要的碰撞层 |
| 弹道模拟 | SoA 数据布局 + Job 并行;远端玩家的子弹降低模拟精度 |
| 声音 | 并发声音数量限制,按距离和优先级裁剪 |
大规模交火场景(几十名玩家同时开火)是性能的压力上限,应作为专项压测用例。
九、边界情况清单
这些问题在开发后期经常以 Bug 的形式出现,建议在设计阶段就逐一处理:
| 场景 | 问题 | 处理建议 |
|---|---|---|
| 枪口贴墙 | 子弹从墙内发出 | 枪口遮挡检测(见 3.1) |
| 目标贴脸 | 射线起点已在目标碰撞体内 | 起点稍后移,或单独做重叠检测 |
| 开火同帧死亡 | 本地开了枪,服务器认为已死亡 | 服务器拒绝,客户端回收特效和弹药显示 |
| 切枪 / 换弹打断 | 开火状态与动画状态不一致 | 开火条件以武器状态机为准,不依赖动画事件 |
| 低帧率高射速 | 一帧需要开多枪 | 累加器循环开火(见 2.1),每枪使用各自的精确时刻 |
| 时间缩放(慢动作) | 弹道和射速是否随之变化 | 明确区分游戏时间与真实时间 |
| 断线重连 | 在飞子弹状态丢失 | 客户端特效直接清理,只保留服务器结果 |
| 预测命中被拒绝 | 已播放的弹孔和血雾 | 弹孔保留,伤害数字不显示;避免撤回已播放的特效 |
十、调试工具
射击系统的问题往往"玩家觉得不对,但说不清哪里不对",所以调试工具的价值非常高:
- 射击可视化:绘制客户端射线(绿色)和服务器回滚后的射线及 Hitbox(红色),一眼看出偏差
- 命中对账日志:记录每发子弹的预测结果与服务器结果,统计不一致率,按原因分类
- 网络状态面板:RTT、丢包、插值延迟、服务器回溯时长
- 弱网模拟:内置延迟、抖动、丢包注入,便于开发人员随时复现问题
- 射击录像回放:录制输入和网络包,离线复现玩家反馈的"打中不掉血"问题
- 弹道预览:编辑器中直接绘制武器在不同距离、初速、归零参数下的弹道曲线,并标注下坠量、飞行时间和伤害衰减,策划调参时不用进游戏实测
- Hitbox 查看器:在运行时显示角色的 Hitbox 与渲染模型的贴合程度,排查"打在模型上却没命中"的问题
调试绘制要区分本地预测与服务器回包两种数据源。下面是一个对账日志的数据结构示例:
structShotAuditRecord{uint32 shotSeq;doubleclientFireTime;floatrttAtFire;// 开火时的 RTTfloatinterpDelay;// 客户端插值延迟HitInfo predicted;// 本地预测:目标、部位、命中点HitInfo confirmed;// 服务器结果MismatchReason reason;// 未命中/部位不同/目标不同/被拒绝(射速、弹药、时间戳)};// 按 reason 聚合统计,配合 RTT 分桶,就能看出不一致主要发生在哪个网络区间、哪类原因对账数据最好能在线上小比例采样上报。很多"手感差"的反馈最终能定位到某个具体原因,例如某把武器的 Hitbox 姿态与服务器不同步,或某个地区插值延迟偏高。
十一、客户端模块划分
把前面的内容落到代码结构上,建议按职责拆成以下模块,彼此通过事件或接口通信:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ InputSampler │──►│ WeaponStateMachine│──►│ FireController │ │ 输入采样+时间戳 │ │ 装填/切枪/开镜状态│ │ 射速累加器 │ └─────────────────┘ └──────────────────┘ └────────┬────────┘ │ FireRequest(精确时刻、视角快照) ┌────────▼────────┐ │ ShotBuilder │ │ 原点/方向/散布 │ └────────┬────────┘ ┌────────────────────────────┼─────────────────────┐ ▼ ▼ ▼ ┌───────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ BallisticSimulator│ │ NetShotSender │ │ RecoilController │ │ 射线/弹道/穿透 │ │ ShotEvent 上行 │ │ 视角后坐/恢复 │ └─────────┬─────────┘ └──────────────────┘ └──────────────────┘ │ PredictedHit ┌─────────▼─────────┐ ┌──────────────────┐ │ ShotReconciler │◄──────│ NetHitReceiver │ │ 预测与确认对账 │ │ Confirm / Reject │ └─────────┬─────────┘ └──────────────────┘ │ FeedbackEvent(分级) ┌─────────▼─────────────────────────────────────┐ │ FeedbackDispatcher → VFX / Decal / Audio / UI │ └───────────────────────────────────────────────┘几个关键点:
- BallisticSimulator 与服务器共享代码。弹道积分、穿透计算、散布生成放在一个不依赖渲染和引擎表现层的纯逻辑库里,客户端与服务器编译同一份源码,这是预测一致性最有效的保障
- ShotReconciler 是唯一能决定强反馈的地方。表现层不直接读取判定结果,只响应分级后的反馈事件,避免各处各自判断"算不算打中"
- 远端玩家的射击走同一条 BallisticSimulator 路径,只是跳过 NetShotSender,并把 PredictedHit 降级为纯表现
ShotReconciler 的核心逻辑示例:
voidShotReconciler::OnPredictedHit(uint32 shotSeq,constHitInfo&hit){pending[shotSeq]={hit,Now()};feedback.Emit(FeedbackLevel::Weak,hit);// 弹孔、血雾、命中音}voidShotReconciler::OnServerResult(uint32 shotSeq,constServerHitResult&res){autoit=pending.find(shotSeq);if(res.accepted){feedback.Emit(FeedbackLevel::Strong,res.hit);// 伤害数字、命中标记if(res.killed)feedback.Emit(FeedbackLevel::Settlement,res.hit);}// 拒绝时不撤回弱反馈,只记录对账日志if(it!=pending.end()){audit.Record(shotSeq,it->second.hit,res);pending.erase(it);}}voidShotReconciler::Tick(){// 超时未收到回包(丢包),清理并按"未确认"处理,防止 pending 无限增长EraseIf(pending,[&](auto&p){returnNow()-p.second.time>kConfirmTimeout;});}注意:服务器回包可能只包含"有命中"的结果。如果协议设计成只回命中,客户端要用超时来判断"未命中",超时阈值应大于当前 RTT 加上一定余量。
十二、主流引擎落地要点
12.1 Unreal Engine
| 需求 | 可用设施 |
|---|---|
| 射线检测 | LineTraceSingleByChannel/LineTraceMultiByChannel(穿透用 Multi);大量检测用AsyncLineTraceByChannel |
| 自定义碰撞通道 | 在项目设置中新增 Weapon Trace 通道,只与需要的对象类型交互 |
| 物理材质 | UPhysicalMaterial配合 Surface Type 驱动命中特效与穿透系数 |
| 特效 | Niagara 实现曳光、命中粒子;大量曳光可用一个 Niagara 系统统一管理多条轨迹 |
| 弹孔 | Deferred Decal 或 Niagara 贴花,注意总量控制 |
| 网络预测 | GAS 的 Target Data 机制可用于客户端提交目标数据、服务器确认;官方示例 Lyra 的远程武器能力是很好的参考 |
| 投射物 | 自带UProjectileMovementComponent适合低速投射物(榴弹、火箭);高速子弹建议自写轻量弹道系统,不要每颗子弹生成一个 Actor |
常见坑:Skeletal Mesh 的物理资产(Physics Asset)就是 Hitbox 来源。专用服务器上骨骼动画默认可能不更新(为节省 CPU 会关闭或降频),这会导致服务器 Hitbox 姿态与客户端不同,需要显式配置。
12.2 Unity
| 需求 | 可用设施 |
|---|---|
| 射线检测 | Physics.Raycast/RaycastNonAlloc;批量检测用RaycastCommand+ Job System |
| 弹道模拟 | IJobParallelFor+ Burst 编译,NativeArray存放子弹数据(SoA) |
| 特效 | VFX Graph(GPU 粒子)适合大量曳光和命中效果 |
| 弹孔 | URP/HDRP 的 Decal Projector,或自写贴花批渲染 |
| 网络 | Netcode for Entities 提供物理世界历史(用于延迟补偿)和客户端预测框架;Netcode for GameObjects 需要自行实现回滚 |
常见坑:Physics.Raycast默认会检测触发器(取决于 Query Hits Triggers 设置),射击检测通常需要显式排除;RaycastCommand只返回第一个命中,穿透需要多轮提交。
12.3 Godot
- 射线检测使用
PhysicsDirectSpaceState3D.intersect_ray,不建议为每颗子弹创建RayCast3D节点 - 大量子弹建议在 C# 或 GDExtension(C++)中集中管理,用
MultiMeshInstance3D渲染曳光 - 内置多人网络较轻量,延迟补偿需要自行实现 Hitbox 历史缓存
十三、开发检查清单
上线前,建议逐项确认:
开火
- 射速与帧率无关,低帧率下射速正确
- 开火时间戳精确到帧内,视角取开火瞬间的快照
- 开火反馈本地零等待
方向与散布
- 判定使用世界空间数据,不受 Viewmodel FOV 影响
- 枪口贴墙时有遮挡处理
- 散布使用跨平台确定性 RNG,客户端不上报散布后方向
预测与对账
- 弹道逻辑与服务器共享同一份代码
- 反馈分级明确,强反馈只来自服务器确认
- 回包丢失有超时处理
- 预测不一致率可统计、可按原因分类
表现
- 曳光从视觉枪口出发并平滑收敛到逻辑轨迹
- 弹孔、粒子、声音都有总量预算
- 掠过声、远距离枪声延迟等方位信息正确
性能与调试
- 大规模交火场景压测通过
- 具备射线可视化、对账日志、弱网模拟、录像回放工具
十四、总结
客户端射击系统的本质,是在服务器权威的前提下,尽量消除玩家感知到的延迟与不一致:
- 时间要准:累加器控制射速,帧内时间戳记录开火时刻
- 空间要对:判定用世界空间,表现用视觉空间,两者之间平滑过渡
- 随机要同:确定性种子让散布在两端一致
- 反馈要分级:能立即给的立即给,可能出错的等确认
- 代码要共享:弹道逻辑客户端与服务器同源,这是预测准确的根本
- 问题要可见:可视化和对账数据,让"手感不对"变成可定位的具体原因
子弹模型选 Hitscan 还是实体弹道,决定的是玩法;而上述这些客户端工程细节,决定的是同一套玩法最终给玩家带来什么样的手感。