在游戏开发中实现网络同步的双人牵手玩法
公司的社交游戏最近新出了牵手玩法的需求, 由我负责3C方面的开发. 两个牵手的玩家要彼此跟随移动, 动态计算地形位置, 保持手部IK连接, 同时游戏内的其它常驻玩法也要有限度的保留.
注: 本文为笔者手敲, 仅部分图表使用了AI生成辅助创作. 文章内出现的所有图片都由AI二次生成以进行脱敏处理.

1. 系统分析
所谓牵手, 看起来只是“让两名玩家的手靠在一起”,实际上同时包含四个需要保持一致的系统:
- 互动状态:谁发起、谁接受、何时开始、何时结束。
- 根节点位移:发起方继续走路,接收方被带到发起方身边。
- 动画同步:接收方要复现发起方的移动、跳跃和姿态,但不能重复驱动手臂层。
- 手部 IK:两条不同长度、不同朝向的手臂要收敛到同一个视觉接触点。
基于游戏内目前3C系统的实现, 笔者最后选择了“发起方单点权威”的模型:
- 发起方拥有本地移动权,也计算接收方的跟随位置、动画镜像和手部 IK 目标。
- 接收方冻结本地输入和 KCC Motor (KCC可以理解为3C的底层驱动, 冻结它就冻结了玩家的操控),只消费发起方广播的位姿、动画和 IK 结果。
- 第三方客户端不参与控制,同样消费发起方产生的成对快照。
单点权威意味着所有同步操作都尽可能在单端完成计算, 减少了整个系统的不确定性.
2. 数据流
2.1 组件分工
考虑到牵手是个比较独立的玩法, 基于项目架构编写了HoldHandComponent控制类, 和其它逻辑做区分.
HoldHandComponent 只是生命周期入口,内部组合两个独立控制器:
- HoldHandRuntimeController 在普通更新阶段处理状态机、输入冻结、根节点位移和位姿广播。
- HoldHandIKRuntimeController 处理骨架注册、可达性求解、IK 权重和 IK 广播。
把根节点控制和手臂控制拆开, 一方面是为了可读性, 另一方面它们也确实不是同一层面的逻辑, 我们要遵循单一职责. 此外, 策划希望手臂暂时够不到时可以淡出IK而不必结束整段互动, 这种实现也更方便处理这种情况.
在策划的设计里, 牵手实际上还会影响一些其他状态 (比如双方玩家好感值等), 因此服务器除了做位置和动作的转发也要记录牵手状态本身. 不过这是篇客户端文章, 此处不是讨论重点, 因此做了下简化.
1 | flowchart LR |
2.2 一次完整交互的时序
请求响应成功只表示协议请求成功,不代表画面已经进入牵手。客户端必须等待房间数据落地后再进入表现层。
1 | sequenceDiagram |
3. 互动状态机
3.1 请求和接受
测试 UI 发起请求前会确认目标 UID 有效、不是自己、与本地玩家同房间,且本地没有待处理请求或正在进行的互动, 进行简单的本地校验. 随后由服务器处理和转法请求. 大部分逻辑都写在客户端, 所以请求体很简单:
1 | IntimacyInteractReq |
我们项目的网络层设计是基于增量数据, 因此客户端不直接接入Push一类的协议. 接收方客户端会每 0.1 秒扫描 (本地增量数据里) 其他玩家的 PendingIntimacyRequest,当筛选到合法请求时即启用接受牵手按钮.
服务端确认接受后,双方此时收到互相指向的状态:
| 玩家 | Role | OtherUid |
|---|---|---|
| 发起方 | Initiator | 接收方 UID |
| 接收方 | Recipient | 发起方 UID |
3.2 预准备状态
接收方接受请求后, 不会立刻进入牵手状态, 而是先进入一帧的准备操作: 这个状态在 HoldHandRuntimeController 的状态很小,但刻意保留了准备帧:
1 | stateDiagram-v2 |
Begin 会先清理旧状态,再把正式开始帧设为当前帧加一。接收方在准备阶段就冻结移动、跳跃、重力和 Motor,并切换本地动画驱动器;如果发起方实体已经加载,则按准备偏移放到预备点。下一帧才开始正式跟随,避免状态刚到达时同一帧既重置又驱动。
3.3 退出
主动结束时发送 IntimacyInteractCancelReq。收到成功响应后,客户端仍等待房间 InteractState 清除;状态消失后的下一次更新才执行 Stop:
接收方解除 FreezeMoveJump,恢复原 Animator 和 KCC Motor。
清除伙伴的权威位姿目标、待消费快照、弹簧状态和计时器。
IK 双方权重淡出,删除失效的配对状态。
4. 根节点位置算法
4.1 准备位置
准备阶段使用发起方根节点作为参考系:
其中 P_i 和 R_i 是发起方根节点位置、旋转,O_prepare 是 PreparationOffset。测试值为 (0, 0, -1),表示接收方先站到发起方后方一米处。
这一步是一次对齐,不是平滑跟随。接收方还会立即发送一次位置广播和位置请求,让房间内其他客户端尽快知道初始位置。
4.2 正式跟随目标
发起方每帧计算接收方的理论目标:
代码中的 FollowOffset 是相对发起方根节点的局部坐标,资源值为 (-0.82, 0, -0.17)。这样设计的好处是无论发起方朝向如何,接收方都保持相同的相对站位。
4.3 弹簧模型
实现没有直接把接收方瞬移到 P_target,而是保留一个只属于发起方的 m_DrivenPosition:
对应代码为 DataHoldHandControl.CalculateFollowSpeed 加 Vector3.MoveTowards。当前资源参数是:
| 参数 | 值 | 含义 |
|---|---|---|
| SpringCoefficient | 16 | 距离转速度的比例 |
| MinFollowSpeed | 12 m/s | 离开停止半径后的最低跟随速度 |
| MaxFollowSpeed | 24 m/s | 防止大误差导致速度失控 |
| StopRadius | 0.02 m | 直接贴合的误差半径 |
这里的“弹簧”不是严格的二阶物理弹簧,而是“距离比例速度 + 上下限 + 受限位移”的工程化模型。它不依赖质量和阻尼参数,策划更容易调出稳定手感。
一个关键细节是 m_DrivenPosition 不读取远端接收方实体的插值位置。远端实体可能因为网络包延迟暂时落后,如果把它重新作为下一帧起点,就会把网络抖动反馈进权威计算,形成越追越抖的闭环。
4.4 接收方本地消费
接收方的移动输入在每帧被清零,包括前后左右、跳跃、重力和 KCC 基础速度;RootKinematicCharacterMotor 在牵手期间被禁用,并在退出时恢复原启用状态。
收到权威目标后,接收方在 KCC 更新之后执行:
- 位置用 SmoothDamp,平滑时间为 max(0.01, PositionSyncInterval * 0.5)。
- 旋转用指数形式的球面插值:
- 误差小于快照阈值时直接吸附,避免在最后几毫米持续抖动。
- 写回完成后清零 Motor 速度,并按同步间隔发送位置请求作为保活。
把写回放到 KCC 更新之后,是为了避免下一帧 KCC 又把表现层位置覆盖掉,也让相机采样到更稳定的根节点。
4.5 三种视角的网络策略
BroadcastHoldHandTransformUpdate 一次携带发起方和接收方的成对位置、旋转,默认每 0.1 s 广播一次。
- 发起方本地:直接驱动接收方实体,不经过网络插值。
- 接收方本地:消费自己的待处理目标;发起方实体以接收方根节点为相对锚点平滑。
- 第三方:对双方使用绝对权威目标平滑。
接收方视角使用相对锚点的原因是:如果双方都在世界坐标中独立插值,两个误差会叠加,手臂和身体的相对关系会出现周期性拉伸。相对锚定把“伙伴相对我在哪里”作为主要变量,能显著降低这种抖动。
普通玩家位姿包即使晚到,也只更新 PlayerData 缓存;当完整牵手配对存在时,不允许它覆盖牵手权威目标。
4.6 位置方案的优点与不足
优点
- 局部偏移天然保持双人相对站位,旋转和移动逻辑简单。
- 受限弹簧不会因为单个快照误差直接瞬移,速度上下限可独立调参。
- 成对快照同时描述两个人,旁观者不会收到“只更新一半”的状态。
- 相对锚定优先保证接收方视角的局部关系,视觉稳定性好。
不足
- 发起方急转、快速跨越障碍或被服务器纠正时,接收方必然存在追赶延迟。(其实这也不能算缺点, 如果位置移动很硬的话就不像主从运动了)
- MoveTowards 没有做路径规划,遇到墙体、台阶或狭窄空间可能穿插、卡位,或者被迫拉长手臂。
- 成对位姿流、普通位姿流和本地预测同时存在,优先级与兼容分支较复杂。
- 发起方离线、实体未加载或网络长时间丢包时,接收方只能停留在最后一个目标,缺少更强的超时策略。
5. 动画同步
5.1 为什么不让接收方自己播动画
接收方已经失去移动和跳跃控制,如果仍运行自己的状态机,它可能根据本地输入、落地检测或网络插值生成与发起方不同的动画。当前方案把发起方当作动画源:
- 发起方进入牵手后为各动画层建立同步请求。
- DriveRecipient 每帧把发起方的非手臂层快照应用到接收方实体。
- 远端客户端收到发起方动画后,先显示发起方,再按完整配对复制一份给接收方。
UpperLeftHand 和 UpperRightHand 两个角色专用层被明确跳过。它们由牵手手臂参考状态和 IK 权重控制,避免普通动画和 IK 同时写同一套骨骼。
5.2 接收方 Animator 控制器
本地接收方会暂时切换到 OtherPlayerAnimatorController,并抑制本地动画同步请求;进入个人展示或 JSON 动作时也会被牵手约束拦截。退出后恢复进入牵手前保存的控制器,再恢复本地同步。
这个处理解决的是“控制权”问题,而不是单纯的动画参数复制:即使网络包暂时丢失,接收方也不会因为本地输入重新获得一套独立的移动状态机。
5.3 动画方案的优点与不足
优点
- 双方身体动画来源一致,走路、跳跃和停步不会因为本地预测分叉。
- 手臂层与 IK 解耦,可以只调整接触点和权重,不修改整套 locomotion 动画。
- 远端客户端复用同一份发起方快照,旁观者看到的双人动作更一致。
不足
- 接收方对发起方动画包有依赖,丢包时可能停留在旧状态或出现时间跳变。
- 整体切换到 OtherPlayerAnimatorController 依赖资源存在且参数兼容,控制器切换失败只能记录告警。
- 只排除两个手臂层无法表达复杂的上半身碰撞、肩膀避让或双手交叉等情况。
6. 手部 IK
6.1 骨架初始化与运行时注册
每个 Avatar 加载完成后,InitializeHoldHandIK 做一次骨架检查:
- 发起方使用 G_LweaponTransform 作为左手牵手挂点。
- 接收方使用 G_RweaponTransform 作为右手牵手挂点。
- 从挂点向上解析 hand -> lowerArm -> upperArm 三段链。
- 配置两个 Final IK LimbIK,弯曲方向为 Arm,初始位置和旋转权重为零。
任一挂点、父级链或 LimbIK 无效,整名角色不会注册到牵手 IK 控制器;这比在运行时对空骨骼求解更安全。Avatar 隐藏、回收或销毁时会注销并重置权重。
6.2 只对完整互指配对求解
IK 控制器每帧枚举已注册实体,但只处理通过 TryGetInitiatorPair 的配对。这样可以避免以下半成品状态进入求解:
- 发起方状态已经到达,接收方状态还没有到达。
- 两边类型不同或 OtherUid 没有反向指回。
- Avatar 还在下载,挂点尚未建立。
不满足条件时,双方权重按淡出速度回到零,互动的根节点位移仍可继续。
6.3 从两个接触 Pose 得到共享 Pose
先读取当前发起方左手接触 Pose C_i=(p_i,q_i) 和接收方右手接触 Pose C_r=(p_r,q_r),取位置中点和旋转球面中点:
(p_0,q_0) 是“偏好接触 Pose”,表示如果双方手臂都够长,手掌希望在哪里相遇。
然后分别应用角色偏移:
其中 o_i/r_i 和 o_r/r_r 分别来自发起方左手、接收方右手配置。当前资源中的偏移用于修正不同 Avatar 的手掌朝向,而不是改变双方根节点位置。
6.4 用可达球体近似两段手臂
对每一侧手臂,代码取:
- 球心:上臂位置加上接触挂点偏移变换后的修正。
- 半径:
这相当于把两段骨骼链近似成一个“从肩部出发、半径为臂长的可达球体”。得到双方球体 S_i=(O_i,L_i) 与 S_r=(O_r,L_r) 后,调用 TryConstrainSharedContact 约束共享接触点。
6.5 球体交集约束算法
约束函数按以下顺序处理:
- 如果偏好点同时在两个球体内部,直接使用偏好点,HasSharedReach=true。
如果两球心距离 D > L_i + L_r,两球不相交,沿球心连线按臂长比例取折中点:
此时返回 false,表示双方不能同时完全够到。
- 如果球体相交但偏好点在外部,先把偏好点投影到每个球面;若某个投影点也位于另一个球体内部,就选离偏好点更近的那个投影。
如果两个投影都不满足,求两球相交圆。令 D=|O_r-O_i|,交圆平面距第一个球心的距离为:
交圆半径为:
将偏好点投影到该平面,再把径向长度限制为 rho,得到离偏好点最近的共同可达点。
这套算法没有求解完整的肘部姿态,但能保证接触点不会同时落在双方手臂可达范围之外。
6.6 平滑、连接滞回和权重
可达性求解得到的目标点仍会经过 Vector3.SmoothDamp,平滑时间为当前资源的 ContactPositionSmoothTime=0.08 s。平滑后如果双方球体确实有交集,会再次投影回交集,避免平滑过程把点带出可达区域。
IK 是否“连接”由玩家根节点距离决定,但使用了滞回:
1 | 首次判定:distance <= DisconnectDistance 才连接 |
当前资源值为 ReconnectDistance=1.1 m、DisconnectDistance=1.51 m。两个阈值之间的间隔防止角色在边界附近来回闪断。
连接状态只影响表现层 IK,不会清除房间互动状态。权重按线性速度淡入淡出:
最终位置权重和旋转权重分别为:
当前最大位置、旋转权重均为 1,淡入 0.15 s,淡出 0.1 s。每帧将两个目标写入 LimbIK.solver 并调用 solver.Update(),手臂动画层同步切换到牵手参考状态。
6.7 IK 权威广播与远端重建
只有本地发起方计算完整 IK。每 IKTargetSyncInterval=0.05 s 广播一份独立的 BroadcastHoldHandIKTargetUpdate,字段包括:
- 发起方、接收方 UID 与互动类型。
- 单调递增 Sequence。
- 发起方左手和接收方右手两套目标 Pose。
- BlendWeight、Connected、ArmAnimationOverrideActive、HasSharedReach。
两套 Pose 都先转换到发起方根节点局部空间:
这样做比直接发送世界坐标更适合双人相对运动:发起方移动时,目标随根节点一起移动,量化误差也不会直接叠加到世界坐标上。
远端消费流程是:
- 校验 UID、互动类型和本地缓存中的完整配对。
- 使用带回绕处理的序列比较,拒绝旧包和乱序包。
在发起方局部空间对两套 Pose 和权重做指数插值:
再用当前发起方根节点变换回世界空间,交给本地两个 LimbIK。
远端没有收到第一份权威 IK 包时不会自行猜测目标,而是保持释放状态。当前实现没有独立的 IK 包超时计时器:配对仍有效时会继续平滑消费最后一份目标,直到配对失效、骨架不可用或互动状态退出。因此网络长时间中断时可能保持过期目标,这也是后续需要补上的保护。
6.8 IK 方案的优点与不足
优点
- 共享接触点让两只手在视觉上拥有同一个“握手位置”,比两端各自追目标更稳定。
- 球体交集算法计算量小、边界明确,能显式处理两侧臂长不同的情况。
- 距离滞回和权重淡入淡出消除了阈值抖动与突然吸附。
- 发起方统一求解并广播,接收方和第三方不需要重复运行完整可达性逻辑,结果更一致。
- Final IK LimbIK 可以复用现有骨架和弯曲方向配置,落地成本低。
不足
- 球体只表达“能不能够到”,没有表达肘部偏好、肩部关节限位、碰撞体和衣服约束,某些姿势会出现肘部翻转或穿模。
- 角色体型差异越大,固定的左右手偏移越难兼顾,需要按 Avatar 做校准。
- IK 包增加了带序列号的状态管理和网络带宽;丢包、实体重建或根节点切换时需要处理旧目标。
- Final IK 依赖正确的挂点和三段骨链,任一资源缺失会让整对 IK 停用;每帧求解也会产生额外 CPU 成本。
7. 网络一致性与异常路径
7.1 三条表现流的优先级
牵手期间实际上存在三条不同频率的流:
| 流 | 主要内容 | 当前频率/策略 |
|---|---|---|
| 成对位姿 | 两个根节点的位置和旋转 | 0.1 s,发起方权威 |
| IK 目标 | 两个手部目标、权重和连接状态 | 0.05 s,发起方权威,带 Sequence |
| 动画快照 | 非手臂动画层参数和状态 | 由动画同步请求驱动 |
普通玩家位姿流不能覆盖前两条流建立的权威目标;IK 也不能反过来修改根节点位置。把“身体位置”“手臂目标”“动画状态”分成不同责任域,是避免互相写值的基础。
7.2 当前降级行为
| 异常 | 当前行为 |
|---|---|
| 请求被拒绝或过期 | 清除待处理请求,不进入运行时 |
| 双方状态尚未同时到达 | 本地可先进入准备,配对相关广播和 IK 等待互指完成 |
| 对方实体或 Avatar 未加载 | 位移驱动暂缓,IK 释放,实体注册后继续 |
| IK 配置无效 | 位移和互动仍可存在,只重置 IK 并记录告警 |
| 挂点或骨链缺失 | 该配对 IK 淡出,根节点和动画继续 |
| 普通位姿包晚到 | 只更新缓存,不覆盖牵手权威目标 |
| 状态字段改变 | 清理旧状态,重新从 Preparing 开始 |
| 互动状态消失 | 解除冻结、恢复控制器、清除目标和权重 |
这种“局部降级”比“一处出错、整段互动立刻销毁”更适合社交玩法,但也意味着画面可能暂时出现“身体仍牵着、手臂已经松开”的状态,需要在产品层明确这是允许的表现。
8. 方案对比
| 设计点 | 当前方案 | 主要优势 | 主要不足 |
|---|---|---|---|
| 控制权 | 发起方单点权威 | 结果一致、无需双端协商 | 依赖发起方网络和实体生命周期 |
| 跟随 | 距离比例速度 + MoveTowards | 手感可调、不会瞬移 | 无路径规划,急转时有滞后 |
| 位姿包 | 发起方/接收方成对快照 | 相对关系完整 | 协议和流优先级更复杂 |
| 视角稳定 | 接收方相对锚定 | 双人局部关系稳定 | 需要维护锚点和坐标转换 |
| 动画 | 发起方快照镜像,手臂层交给 IK | 身体动作一致、职责清晰 | 丢包时动画会停在旧状态 |
| IK 接触 | 共享点 + 球体交集 | 稳定、实现成本低 | 不是完整骨骼约束,缺少碰撞和肘部解 |
| 连接判定 | 距离滞回 | 避免阈值闪断 | 阈值区间过大时会出现拉伸感 |
| IK 同步 | 发起方广播局部目标 | 远端不重复求解 | 增加带宽、序列和过期处理成本 |
如果玩法目标是“短距离、强社交展示、双方动作必须一致”,当前折中是合理的;如果目标变成“可穿越复杂地形、可被第三方物理推挤、支持长时间断线恢复”,就需要把移动权威和 IK 约束升级为更强的会话级方案。