在游戏开发中实现网络同步的双人牵手玩法

公司的社交游戏最近新出了牵手玩法的需求, 由我负责3C方面的开发. 两个牵手的玩家要彼此跟随移动, 动态计算地形位置, 保持手部IK连接, 同时游戏内的其它常驻玩法也要有限度的保留.

注: 本文为笔者手敲, 仅部分图表使用了AI生成辅助创作. 文章内出现的所有图片都由AI二次生成以进行脱敏处理.

holdhand2

1. 系统分析

所谓牵手, 看起来只是“让两名玩家的手靠在一起”,实际上同时包含四个需要保持一致的系统:

  1. 互动状态:谁发起、谁接受、何时开始、何时结束。
  2. 根节点位移:发起方继续走路,接收方被带到发起方身边。
  3. 动画同步:接收方要复现发起方的移动、跳跃和姿态,但不能重复驱动手臂层。
  4. 手部 IK:两条不同长度、不同朝向的手臂要收敛到同一个视觉接触点。

基于游戏内目前3C系统的实现, 笔者最后选择了“发起方单点权威”的模型:

  • 发起方拥有本地移动权,也计算接收方的跟随位置、动画镜像和手部 IK 目标。
  • 接收方冻结本地输入和 KCC Motor (KCC可以理解为3C的底层驱动, 冻结它就冻结了玩家的操控),只消费发起方广播的位姿、动画和 IK 结果。
  • 第三方客户端不参与控制,同样消费发起方产生的成对快照。

单点权威意味着所有同步操作都尽可能在单端完成计算, 减少了整个系统的不确定性.

2. 数据流

2.1 组件分工

考虑到牵手是个比较独立的玩法, 基于项目架构编写了HoldHandComponent控制类, 和其它逻辑做区分.
HoldHandComponent 只是生命周期入口,内部组合两个独立控制器:

  • HoldHandRuntimeController 在普通更新阶段处理状态机、输入冻结、根节点位移和位姿广播。
  • HoldHandIKRuntimeController 处理骨架注册、可达性求解、IK 权重和 IK 广播。

把根节点控制和手臂控制拆开, 一方面是为了可读性, 另一方面它们也确实不是同一层面的逻辑, 我们要遵循单一职责. 此外, 策划希望手臂暂时够不到时可以淡出IK而不必结束整段互动, 这种实现也更方便处理这种情况.

在策划的设计里, 牵手实际上还会影响一些其他状态 (比如双方玩家好感值等), 因此服务器除了做位置和动作的转发也要记录牵手状态本身. 不过这是篇客户端文章, 此处不是讨论重点, 因此做了下简化.

1
2
3
4
5
6
7
8
9
flowchart LR
UI[HoldHandTest] -->|请求/接受/取消| Room[房间 PlayerData]
Room --> Runtime[HoldHandRuntimeController]
Runtime -->|根节点位姿| Transform[成对位姿广播]
Runtime -->|发起方动画快照| Anim[动画同步/镜像]
Room --> IK[HoldHandIKRuntimeController]
IK -->|发起方权威目标| IKPacket[IK 目标广播]
IKPacket --> RemoteIK[远端 IK 平滑消费]
Transform --> RemoteTransform[接收方与第三方位姿平滑]

2.2 一次完整交互的时序

请求响应成功只表示协议请求成功,不代表画面已经进入牵手。客户端必须等待房间数据落地后再进入表现层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sequenceDiagram
participant A as 发起方
participant S as 房间状态
participant B as 接收方
participant V as 第三方客户端

A->>S: IntimacyInteractReq(Continuous, type=1, targetUid)
S-->>B: PendingIntimacyRequest
B->>S: IntimacyInteractReplyReq(Accept=true)
S-->>A: Initiator + OtherUid=B
S-->>B: Recipient + OtherUid=A
A->>A: Preparing -> Active
B->>B: 冻结输入、切换 Animator、等待目标
A-->>V: 成对根节点位姿
A-->>B: 成对根节点位姿
A-->>B: IK 权威目标
A-->>V: IK 权威目标
A->>S: IntimacyInteractCancelReq
S-->>A: 清除 InteractState
S-->>B: 清除 InteractState

3. 互动状态机

3.1 请求和接受

测试 UI 发起请求前会确认目标 UID 有效、不是自己、与本地玩家同房间,且本地没有待处理请求或正在进行的互动, 进行简单的本地校验. 随后由服务器处理和转法请求. 大部分逻辑都写在客户端, 所以请求体很简单:

1
2
3
4
IntimacyInteractReq
Category = Continuous = 1 // 表示这是一个持续化的状态, 服务器设定
Type = DataHoldHandControl.SupportedInteractionType = 1 // 牵手类型, 目前固定为1
TargetUid = 目标玩家 UID

我们项目的网络层设计是基于增量数据, 因此客户端不直接接入Push一类的协议. 接收方客户端会每 0.1 秒扫描 (本地增量数据里) 其他玩家的 PendingIntimacyRequest,当筛选到合法请求时即启用接受牵手按钮.

服务端确认接受后,双方此时收到互相指向的状态:

玩家 Role OtherUid
发起方 Initiator 接收方 UID
接收方 Recipient 发起方 UID

3.2 预准备状态

接收方接受请求后, 不会立刻进入牵手状态, 而是先进入一帧的准备操作: 这个状态在 HoldHandRuntimeController 的状态很小,但刻意保留了准备帧:

1
2
3
4
5
6
7
stateDiagram-v2
[*] --> None
None --> Preparing: 本地 InteractState 有效
Preparing --> Active: 下一帧 FormalStartFrame
Preparing --> None: 状态消失/配置无效
Active --> None: 状态消失/配置无效
Active --> Preparing: Type、Role 或伙伴改变

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 为什么不让接收方自己播动画

接收方已经失去移动和跳跃控制,如果仍运行自己的状态机,它可能根据本地输入、落地检测或网络插值生成与发起方不同的动画。当前方案把发起方当作动画源:

  1. 发起方进入牵手后为各动画层建立同步请求。
  2. DriveRecipient 每帧把发起方的非手臂层快照应用到接收方实体。
  3. 远端客户端收到发起方动画后,先显示发起方,再按完整配对复制一份给接收方。

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 球体交集约束算法

约束函数按以下顺序处理:

  1. 如果偏好点同时在两个球体内部,直接使用偏好点,HasSharedReach=true。
  2. 如果两球心距离 D > L_i + L_r,两球不相交,沿球心连线按臂长比例取折中点:

    此时返回 false,表示双方不能同时完全够到。

  3. 如果球体相交但偏好点在外部,先把偏好点投影到每个球面;若某个投影点也位于另一个球体内部,就选离偏好点更近的那个投影。
  4. 如果两个投影都不满足,求两球相交圆。令 D=|O_r-O_i|,交圆平面距第一个球心的距离为:

    交圆半径为:

    将偏好点投影到该平面,再把径向长度限制为 rho,得到离偏好点最近的共同可达点。

这套算法没有求解完整的肘部姿态,但能保证接触点不会同时落在双方手臂可达范围之外。

6.6 平滑、连接滞回和权重

可达性求解得到的目标点仍会经过 Vector3.SmoothDamp,平滑时间为当前资源的 ContactPositionSmoothTime=0.08 s。平滑后如果双方球体确实有交集,会再次投影回交集,避免平滑过程把点带出可达区域。

IK 是否“连接”由玩家根节点距离决定,但使用了滞回:

1
2
3
首次判定:distance <= DisconnectDistance 才连接
已连接: distance > DisconnectDistance 才断开
已断开: distance <= ReconnectDistance 才重连

当前资源值为 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 都先转换到发起方根节点局部空间:

这样做比直接发送世界坐标更适合双人相对运动:发起方移动时,目标随根节点一起移动,量化误差也不会直接叠加到世界坐标上。

远端消费流程是:

  1. 校验 UID、互动类型和本地缓存中的完整配对。
  2. 使用带回绕处理的序列比较,拒绝旧包和乱序包。
  3. 在发起方局部空间对两套 Pose 和权重做指数插值:

  4. 再用当前发起方根节点变换回世界空间,交给本地两个 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 约束升级为更强的会话级方案。