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

公司的社交游戏有一套双人牵手玩法,由我负责 3C 方面的开发。发起方控制移动,接收方跟随;两名角色需要适应地形、保持手部接触,并与游戏中的个人动作和操作玩法配合。下面从能实现基本表现的跟随与握手设计讲起,结合调试过程和体验反馈,逐步说明旋转位移、手部姿态和网络同步的改进。

双人牵手联调画面,左侧为接收方,右侧为发起方

双人牵手联调画面,已裁去外围界面。

1. 设计目标与控制模型

牵手看起来只是“让两名玩家的手靠在一起”,实际上需要同时协调四个系统:

  1. 互动状态:谁发起、谁接受、何时开始、何时结束。
  2. 根节点位移:接收方跟随发起方,适应地面高度、转向和传送。
  3. 动画表现:双方主要动作保持一致,接收方的步态又能反映自己的实际移动。
  4. 手部 IK:不同长度、不同朝向的手臂,在关节限制内尽量靠近同一个接触点。

此外,这些结果还要在发起方、接收方和旁观者三种视角中保持一致。项目采用“发起方单点权威”的控制模型:

  • 发起方拥有本地移动权,计算接收方的跟随位置、移动动画参数和共享 IK 目标。
  • 接收方冻结本地移动输入和 KCC Motor,消费发起方的计算结果。
  • 旁观者消费同一份成对快照,在统一的播放时刻显示双方。

这里的权威指客户端表现计算的来源。服务器维护互动关系和相关业务数据,客户端根据房间状态建立或解除控制。身体位移、手臂目标和动画状态分别管理,避免多个系统同时写入同一份表现结果。

2. 整体架构与数据流

2.1 组件分工与更新时机

HoldHandComponent 组合三个控制器,外层由 PlayerInteractionRuntimeComponent 根据房间互动状态分发:

组件 职责与更新时机
HoldHandRuntimeController 维护本地互动、冻结与恢复控制;在 KCC 更新完成后计算或消费位姿,驱动接收方动画并发出广播
HoldHandObserverSyncController 管理旁观者看到的双人配对,每帧采样双方共用的快照缓冲
HoldHandIKRuntimeController 管理骨架注册、共享接触点、IK 暂停与权重,以及 IK 目标的发送和消费
HoldHandIKLateUpdateDriver 以执行顺序 10000 的 LateUpdate 驱动 IK,在动画和根节点更新后处理手臂

位移、远端播放和骨骼求解具有不同的更新时机。将它们分开后,个人动作暂时占用手部时,可以释放 IK,同时保留牵手关系和位置跟随。



flowchart TB
    UI[亲密互动入口] -->|请求、接受、取消| Room[房间 PlayerData]
    Room --> Dispatch[互动状态分发]
    Dispatch --> Runtime[本地牵手运行时]
    Runtime -->|KCC 更新后计算| Pair[成对位姿与接收方动画]
    Pair --> Recipient[接收方快照缓冲]
    Pair --> Observer[旁观者快照缓冲]
    Room --> IK[手部 IK 控制器]
    IK -->|LateUpdate 求解| Targets[发起方局部空间 IK 目标]
    Targets --> RemoteIK[远端 LimbIK 求解与关节限制]

2.2 一次完整交互的时序

请求响应成功只表示协议处理成功。客户端以房间中实际生效的 IntimacyInteractState 驱动牵手表现:



sequenceDiagram
    participant A as 发起方
    participant S as 服务端与房间数据
    participant B as 接收方
    participant V as 旁观者
    A->>S: IntimacyInteractReq
    S-->>B: PendingIntimacyRequest
    B->>S: IntimacyInteractReplyReq(Accept=true)
    S-->>A: Initiator、OtherUid、Revision
    S-->>B: Recipient、OtherUid、Revision
    A->>A: 初始化,下一帧开始跟随计算
    B->>B: 冻结 Motor,准备位置与动画
    A-->>B: 成对位姿、SampleTime、Revision
    A-->>V: 同一份成对位姿
    A-->>B: 接收方动画参数、IK 目标
    A-->>V: 动画与 IK 目标
    A->>S: IntimacyInteractCancelReq
    S-->>A: 清除互动状态
    S-->>B: 清除互动状态

3. 互动状态与生命周期

3.1 请求和接受

互动入口通过 IntimacyComponent.RequestInteractionAsync、ReplyInteractionAsync 等接口发送请求。请求前检查目标、同房间关系、双方忙碌状态以及变身等玩法限制;发出邀请前,还会结束可能冲突的个人动作。

请求的核心字段如下:

1
2
3
4
5
IntimacyInteractReq
Category = Continuous
Type = DataHoldHandControl.HoldHandInteractionType
TargetUid = 目标玩家 UID
Source = 交互来源

Type 对应配置表中的 ItemId.IntimacyAction_QianShou,当前值为 7030001。服务端确认接受后,双方收到互相指向的状态:

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

配对校验要求角色、类型和伙伴 UID 相互匹配。位姿包中的 InteractionRevision 还要与当前互动版本一致,防止另一段牵手关系的迟到数据进入当前表现。

3.2 准备与正式跟随

运行时通过状态和配置判断是否需要重新 Begin。下图表示表现阶段,初始化阶段由开始帧控制:



stateDiagram-v2
    [*] --> 无互动
    无互动 --> 初始化: 房间牵手状态生效
    初始化 --> 跟随中: 发起方到达下一帧
    跟随中 --> 初始化: 互动身份或配置改变
    初始化 --> 无互动: 状态失效
    跟随中 --> 无互动: 取消、离场或状态失效

发起方将 m_FollowStartFrame 设为当前帧加一,初始化绕行方向和广播计时器。接收方立即冻结移动、禁用 Motor、清理动画请求并切换到远端动画控制器;若伙伴实体已加载,则按准备偏移对齐并播放传送表现。

这一帧的间隔用于区分初始化与持续驱动。IK 配对也会等到首次观察后的下一帧,再正式进入连接阶段。

3.3 退出与换房

正常取消通过取消请求和互动状态清理收尾。Stop 负责解除冻结,恢复进入前的 Motor 启用状态和 Animator,清除快照缓冲、驱动位置、转向状态及动画速度缓存;失效配对的 IK 也会释放。

切房前先解除双人关系,取消失败时阻止继续切房;成功后先清理本地状态,再停止表现,避免下一帧重新进入牵手。伙伴离开本房间时,外层分发器同样停止本地运行时。同房间内的大距离传送则由位置跟随模块处理。

4. 位置跟随与移动平滑

4.1 设计目标

接收方需要维持合适的双人站位,同时具备自然的追赶感。直线跑动应能跟上,原地转向时绕行速度应可控,斜坡和台阶上的高度应合理,传送时应及时到达新位置。

这些目标分别涉及水平位移、绕行旋转、垂直高度和离散位置变化,需要独立计算和调参。网络缓冲负责远端播放,本节描述发起方如何生成每帧的权威位置。

下文统一用红色圆头三角身图标表示发起方,蓝色圆头菱形身图标表示接收方,图标脚底中点表示角色根节点。十字或空心标记表示计算目标,不能与角色实际位置混淆。每组图保持相同场景和比例,用“状态一”“状态二”等标记处理前后的状态;解释放在图外。

4.2 局部偏移与距离比例跟随

先从一个简单的设计入手:在发起方身边放置一个随朝向旋转的目标点,让接收方按距离追赶它。这样就能实现基本的双人站位与跟随,控制参数也集中在偏移和追赶速度上。

4.2.1 正式跟随目标

先只看两个人。 发起方是带着走的人,接收方是被带着走的人。图中红色三角身图标表示发起方,蓝色菱形身图标表示接收方,脚底中点是角色的位置。

没有跟随控制时,发起方往前走,接收方仍留在原地:

两个人会逐渐拉开,所以先要规定接收方应该站到哪里。

再增加一个站位点。 在发起方身边选一个位置,比如左侧略靠后,用十字表示这个希望接收方站到的位置。发起方前进时,站位点一起前进;每帧直接把接收方放到这里,就有了最基本的跟随:

这样能保持双人站位,但接收方像固定在发起方身上一样,缺少被带着走的追赶感。站位点突然变化时,它也会立即跳过去。下一小节保留这个站位点,只改变接收方到达它的方式。

把站位写成相对角色的偏移。 左右、前后两个距离共同决定站位点,它们随发起方的位置和朝向一起变化。代码中的 FollowOffset 就描述这份偏移;例如左侧约 0.82 米、后方约 0.17 米,可以形成略有前后错位的并排站位。无论发起方朝向如何,这份相对站位都能保持一致。

进入互动时还会使用 PreparationOffset 做一次准备位置对齐;正式跟随后使用 FollowOffset。准备位置与持续追赶的站位点用途不同,对应关系如下:

对象与方向 对应代码 作用
“发起方” initiator.RootTransform.position 提供整组站位在世界中的位置
角色右侧、前方及其朝向 initiator.RootTransform.rotation 确定角色的右侧、前方,以及局部偏移旋转到世界的方向
左右和前后的站位偏移 FollowOffset 决定接收方目标在角色左右、前后多远的位置;水平配置的两个分量对应局部 X/Z
准备偏移与准备位置 PreparationOffset、准备位置 在互动进入时做一次位置对齐
站位点 targetPosition 偏移变换到世界后,相对根节点得到的跟随目标

4.2.2 弹簧模型

让接收方逐帧追赶。 不再每帧直接对齐站位点,而是从接收方自己的位置向它走一小段。离得越远,追得越快;离得越近,追得越慢。可以把它想象成两者之间连着一根弹簧:

“弹簧系数”决定相同距离下的追赶力度。蓝色箭头表示本帧步长,它还取决于本帧经过的时间:同样的速度下,帧间隔越短,单步越小。每次推进的结果保留下来,作为下一帧的驱动位置。

单纯按距离决定速度有两个边界:距离很大时可能追得过快,距离很小时又可能拖得太慢。

给速度加上最低值和最高值。 小距离也保持足够的跟随力度,大距离则不会让速度无限增加:

上下限让追赶速度更容易配置,但很接近站位点时,最低速度仍可能带来过大的步长。基础处理可以在进入停止半径后直接贴到目标,完成最后一小段对齐;固定距离贴合带来的顿挫,放到 4.6.1 中继续处理。

MoveTowards 根据速度与帧时长推进;本帧预算足以到达时停在目标,避免越过。几何图用于说明距离和步长的关系,不按下面的示例参数等比例绘制。

几何标记 对应代码 作用
追赶开始时的接收方 m_DrivenPosition / drivenPosition 使用发起方上一次算出的结果作为本帧起点
“剩余距离” Vector3.Distance(drivenPosition, targetPosition) 决定当前要追赶多远,作为速度计算输入
推进后的接收方 nextPosition 保存本帧推进结果,作为下一帧的起点
“本帧步长” 速度与 elapseSeconds,帧时长经非负保护后使用 同一速度下,帧间隔越短,本帧位移越小;不会反向推进

这一计算由 DataHoldHandControl.CalculateFollowSpeed 和 Vector3.MoveTowards 完成,基础跟随使用的参数示例如下:

参数 控制对象 示例值 含义
SpringCoefficient “弹簧系数” 16 决定相同距离下的追赶力度
MinFollowSpeed “最低速度” 12 m/s 离开停止半径后的最低跟随速度
MaxFollowSpeed “最高速度” 24 m/s 限制距离很大时的追赶速度
StopRadius “停止半径” 0.02 m 直接贴合目标的误差半径

这里的“弹簧”不是严格的二阶物理弹簧,而是“距离比例速度 + 上下限 + 受限位移”的工程化模型。它不依赖质量和阻尼参数,策划更容易调出稳定手感。

一个关键细节是 m_DrivenPosition 不读取远端接收方实体的插值位置。远端实体可能因为网络包延迟暂时落后,如果把它重新作为下一帧起点,就会把网络抖动反馈进权威计算,形成越追越抖的闭环。

4.2.3 基本表现与取舍

到这里,接收方已经可以被带到发起方身边,并在移动时保持追赶。这套设计有两个直接的好处:

  • 局部偏移天然保持双人相对站位,旋转和移动逻辑简单。
  • 受限弹簧不会因为单个快照误差直接瞬移,速度上下限可独立调参。

发起方急转、快速跨越障碍或被服务器纠正时,接收方必然存在追赶延迟。(其实这也不能算缺点, 如果位置移动很硬的话就不像主从运动了)

因此,基本表现并不要求接收方时刻钉在目标上;适当的滞后本身就是被牵着走的感觉。接下来需要改善的是急转时的速度和地形适应,让这种追赶保持自然。

4.3 体验中的速度与高度问题

这套局部偏移与距离比例速度可以保持双人站位,但发起方的转向会直接旋转整个偏移。原地急转时,接收方的目标也会快速绕到另一侧,追赶过程容易出现被甩过去的感觉;单纯降低跟随速度,又会影响正常走路和跑步。

实际调参时,很难同时兼顾跑步和急转:把跟随速度调到能正常追上走路、跑步时,接收方在快速转向时又会绕得太快。调低速度后,转向时的表现确实缓和了,但继续降低,正常跑步又会跟不上。直线追赶和转身绕行共用一套速度约束,很难只靠调整同一个速度值兼顾两种表现,需要把它们分开处理。

进一步体验还暴露了两个相关问题:固定的高度偏移会让接收方插进地面,原地转向时接收方又可能维持 Idle,看起来像站着滑过去。因此,绕行目标、地面高度和实际移动动画需要一起调整,才能让跟随表现自然起来。

4.4 分开计算水平跟随与绕行旋转

为了保留追赶感,同时让转向速度可以单独调整,我将水平追赶、绕行旋转和地面高度拆开求解。距离比例跟随继续负责移动到目标,独立的旋转状态决定目标如何绕行,地面探测负责高度。

先看目标直接转动的情况。 “站在左后方”是相对发起方的朝向而言的。如果发起方突然向右转,站位点立即采用这个朝向,就会一下换到另一侧:

弹簧看到的是追赶距离突然变大,于是让接收方加速。只降低追赶速度虽然能缓和急转,却又容易影响正常跑步。

让站位点也逐帧绕行。 保留接收方的弹簧追赶,只改变站位点的运动方式:发起方转身后,站位点围绕它逐帧绕过去;每一帧先更新站位点,再让接收方从上一次的位置追向它。

黑色虚线表示站位点已经走过的轨迹,蓝色实线表示接收方实际走过的轨迹。蓝色虚线箭头指向本帧尚未追到的站位点:

到这里,模型从两个独立角色,逐步变成了“接收方用弹簧追赶,站位点平滑绕行”。接收方每帧仍做有限步长的直线推进,许多短位移连接起来才形成实际轨迹。

为了分清两条轨迹,这组平地示意把追赶系数设为 8、速度范围设为 0.2~0.6 m/s;旋转平滑时间仍为 0.2 s,绕行角速度上限仍为 180°/s,停止区半径仍为 0.05 m。图按代码的更新顺序、以每秒 60 帧计算,未加入网络延迟和碰撞。实际资源配置追赶更紧,两条轨迹通常更接近。

再将高度单独交给地面处理。 前面的图只看平地。角色带有俯仰或侧倾时,旋转后的偏移可能包含高度,直接使用会把接收方带离地面。因此先保留水平方向的偏移,让跟随目标落在发起方所在高度;之后再处理贴地或空中跟随:

图中的蓝色“旋转后偏移”是局部偏移按站位参考旋转变换后的结果,去掉垂直分量后得到红色“水平偏移”。这里的参考旋转专门用于计算站位点,它会平滑追赶发起方的转身;其实现和接收方身体朝向的区别见 4.6.2。

PreparationOffset 和 FollowOffset 使用水平面上的两个分量,分别描述左右和前后距离。准备时让接收方站到发起方后方一米;正式跟随时,目标位于左侧约 0.55 米、后方约 0.22 米。偏移方向先经过绕行平滑,再投影到水平面,高度交给贴地或空中跟随处理。接收方的身体朝向取发起方朝向。

实际推进仍从发起方保存的驱动位置开始:先按距离选择受上下限约束的追赶速度,进入停止区域后再逐渐减速,结合本帧时长决定前进距离。到达目标时停止;停止半径配置为零时,省略这段额外的减速处理。

这里还要区分距离的计算时机:允许贴地时,先临时把目标高度设为驱动位置的高度,按水平距离推进,再探测地面;没有开启贴地时,按包含高度差的三维距离追赶。 超距瞬移判断发生在这次高度处理之前,始终包含高度差。

参数 图中标记 当前资源值 作用
SpringCoefficient “弹簧系数” 40 决定未限幅时速度随距离增加的幅度
MinFollowSpeed “最低速度” 2.5 m/s 停止区减速前的速度下限
MaxFollowSpeed “最高速度” 8 m/s 跟随速度上限
StopRadius “减速半径” 0.05 m 限定连续减速作用的距离范围
FollowTeleportDistance “瞬移阈值” 8 m 三维距离超过阈值时重建位置基线

4.5 KCC 接入与模块划分

KCC 是项目中角色运动的底层驱动。发起方继续使用正常的 KCC 运动,接收方在牵手期间禁用本地 Motor,并清零移动、跳跃、重力输入和基础速度;退出时恢复保存的启用状态。

模块 职责
HoldHandRuntimeController 维护驱动位置,计算跟随、贴地和传送结果
HoldHandRotationFollowState 维护绕行滞后角和角速度
HoldHandGroundSnapDebouncer 管理接地状态切换的防抖
ControllerSelfPlayer.OnKccUpdateDone 在 KCC 更新完成后应用牵手位姿,随后供相机采样
DataHoldHandControl 集中配置偏移、速度、高度和各类平滑参数

4.6 移动与平滑优化

4.6.1 停止区连续减速

前面用最低速度保证追赶力度,但到了站位点附近仍可能迈出过大的步长。足够近时直接贴过去能够收尾,却会产生不随帧间隔缩小的跳变。

于是只增加一个减速区:接收方进入站位点周围的这片区域后,剩余距离越小,追赶速度就越低,最后一小段也逐帧完成:

增大 StopRadius 会让额外减速更早介入。最低速度只是减速前的下限,因此停止区内实际速度可以低于它;弹簧系数、速度上下限和减速半径共同决定追赶与收尾的响应。停止半径为零时,省略这段额外减速。

MoveTowards 将速度与本帧时长一起用于位移计算。只有本帧步长足以到达时才落到目标,因而高帧率下的小距离运动也受时间步长约束。

驱动位置只来自权威端自己的计算,不读取远端实体已经插值的位置,避免把网络抖动重新反馈进跟随过程。

4.6.2 绕行旋转滞后与限速

4.4 已经把站位点的绕行与接收方的追赶分开。现在先补清一个容易混淆的关系:走向哪里和身体朝哪里是两件事。接收方的位置继续追赶站位点,身体朝向却直接跟随发起方,所以转身时二者可能不同:

决定站位点绕到哪里的是 followRotation。它是计算站位时采用的参考旋转;发起方已经转过身,而这份参考还在追赶时,站位点便逐帧绕行。它不直接设置接收方的身体旋转。

只有“逐帧转动”还不够,急转时依然需要控制响应。因此再加两项约束:旋转平滑控制追赶转向的过程,最大角速度限制一帧实际能转过多少。当前 RotationFollowSmoothTime 为 0.2 s,MaxRotationFollowSpeed 为 180°/s。

每帧的处理顺序如下:

  1. 读取发起方本帧偏航变化,累加尚未消化的转角。
  2. 用 SmoothDamp 消化转角,再按本帧时长限制实际转过的角度,得到站位参考旋转。
  3. 将 FollowOffset 按这个参考旋转变换,生成本帧站位点。
  4. 从接收方保存的驱动位置计算到目标的距离,用距离比例速度、速度上下限和停止区减速确定步长,再执行 MoveTowards。
  5. 按接地条件修正高度,写入接收方位置;身体旋转直接使用发起方的旋转。
对象 对应代码 作用
发起方身体朝向 initiatorRotation 及其偏航 给出本帧希望跟上的方向
站位参考旋转 HoldHandRotationFollowState.Update 的结果,即 followRotation 决定站位点绕行到哪里
本帧站位点 targetPosition 由参考旋转与跟随偏移算出的追赶目标
接收方当前位置 m_DrivenPosition 本帧从这里继续推进
黑色站位点轨迹 每帧的 targetPosition 受旋转平滑与角速度限制
蓝色接收方轨迹 每帧的 nextPosition 逐帧追向站位点,受移动速度与停止区减速约束

作为对照,按当前资源配置、以每秒 60 帧计算同样的原地右转 90°,目标与接收方的距离差最大约为 6 mm。4.4 的教学图放慢了直线追赶,便于看清处理顺序;实际距离差取决于直线追赶与旋转限速两套约束。

m_AngularVelocity 保存旋转平滑的速度状态,m_LagAngle 保存尚未追完的角度。连续转动时保留绕行方向,但不累计需要补转的整圈;停转约 0.05 s 后就近归位,反向转身时也限制残留角速度继续把目标推远。平滑时间或最大绕行角速度为零时,直接使用发起方朝向。

这一处理将直线追赶速度与目标绕行速度分开。跑步可以保持足够的追赶能力,转身时目标仍按受控的速度绕行;接收方是否产生明显滞后,取决于两套约束在当前动作中的相对大小。

4.6.3 贴地防抖与落点限高

先看最简单的高度处理:接收方沿用发起方高度。在坡面上,两人脚下的地面却可能不同,接收方就会悬空或陷入地面。

因此从接收方上方向下发出射线,用命中的地面高度修正它的位置;水平方向继续沿用弹簧跟随:

这能适应普通坡面,但地形边缘可能让接地状态反复变化,射线也可能打到高处或深处的平台。接下来分别加入状态防抖与落点限高。

发起方稳定接地、KCC 没有禁止吸附,并且双方高度差在限制内时,系统允许接收方贴地。先在水平面推进,再向下发射射线探测地面。

接地状态在台阶边缘可能交替变化,HoldHandGroundSnapDebouncer 要求候选状态持续一定时间才切换。当前资源的防抖时间为 2 s,最大高度差为 0.5 m;高度差超限会立即关闭贴地,不等待防抖计时。

射线命中后还要检查落点与发起方的高度差。这个检查是硬限制,防止已开启的贴地状态把接收方吸到过高的平台或过深的地面。

左侧 “发起方”、“接收方位置” 是双方位置,射线从接收方上方的 “射线起点” 向下检测,“地面候选” 是地面候选点,“落点高度差” 与蓝色高度边界共同决定是否接受落点。右侧红线 “接地候选” 是候选状态,蓝线 “贴地状态” 是实际贴地模式,“防抖时长” 标出切换所需的持续时间。侧视几何不按米等比绘制。

几何标记 对应代码 作用
“起点抬高” RecipientGroundProbeHeight 从待投影位置上方 10 m 发射射线
“射线长度” RecipientGroundProbeDistance 从射线起点向下最多检测 100 m
“允许高度差”、“落点高度差” GroundSnapMaxHeightDifference、实际高度差 允许落点偏离发起方高度最多 0.5 m;命中射线并不自动通过限高
“防抖时长” GroundSnapDebounceDuration 候选接地状态持续达到此时长才切换,当前资源值为 2 s
“接地候选” 稳定接地且 SnappingPrevented 为假 只有可吸附的稳定地面才能请求开启贴地
“贴地状态” useGroundSnapping 决定先水平推进再投影地面,还是按三维位置追赶

射线只检测接收方配置的稳定地面层,层配置无效时使用地图层,并忽略触发器。防抖保留的是接地模式;高度差一旦超限,模式立即关闭,射线落点也必须单独通过限高检查。

4.6.4 对齐 KCC 与相机的采样阶段

如果跟随先读取物理位置,而相机随后读取插值后的显示位置,同一帧的身体与相机就可能使用不同阶段的数据。最直接的改进是统一采样顺序:先完成角色显示位置更新,再计算跟随,最后让相机读取结果。

权威计算和接收方写回都放在 OnKccUpdateDone,使用 KCC 插值后的显示位置,并在相机位置采样前完成。这样身体、跟随目标和相机使用相同阶段的位置,避免固定步长的运动与渲染帧混用造成抖动。

发起方本地的接收方实体直接应用逐帧计算结果,避免将本地跟随平滑与网络播放平滑叠加。

4.6.5 传送时重置平滑状态

普通移动可以继续用弹簧追赶,但传送会让两个人突然相距很远,缓慢追过去就不合适了。因此增加一个距离阈值:超过阈值时直接对齐新的站位,并从这里重新开始跟随。

虚线圆是三维阈值球的截面,“瞬移阈值” 对应 FollowTeleportDistance,“追赶距离” 是驱动位置到目标的三维距离。图中的 “高度差” 强调垂直位移也参与判断;恰好到达阈值时仍使用普通跟随。

驱动位置到目标的距离超过 8 m 时,直接采用发起方当前朝向下的新站位,并重置绕行角、贴地防抖和接收方动画速度,立即广播带 Teleported 标记的结果。

传送属于位置的不连续变化,清理平滑缓存可以避免角色沿旧方向漂移或跨越大段距离缓慢追赶。远端对应的缓冲重置见网络同步章节。

4.7 联调中的动作衔接

加入旋转解算后,通过演示和实际操作与策划确认绕行表现,再结合走路、跑步和原地转向继续检查。这一轮联调的重点,是确认目标移动更平稳后,角色的身体朝向与步态能否自然衔接。

接收方绕着发起方移动时,自身也在转身;如果此时仍播放 Idle,就会出现站着平移的感觉。因此,身体朝向继续跟随发起方,移动动画则根据接收方自身的实际速度驱动,具体放在第 5 节说明。

4.8 两种跟随方式的对比

两种做法都以发起方为计算来源,并保留接收方自己的驱动位置。区别主要在于如何生成目标、如何分配速度,以及如何处理接近目标和地形变化的情况。

设计点 局部偏移与距离比例跟随 分离绕行与水平跟随
目标转向 偏移直接随发起方朝向旋转 偏移方向单独平滑,并限制绕行角速度
速度调节 同一组追赶参数影响直线移动和转向绕行 直线追赶速度与绕行角速度分别配置
接近目标 进入停止半径后直接贴合 在停止区内连续减速,位移受帧间隔约束
高度处理 使用根节点变换后的三维偏移 水平偏移与高度分开,贴地带防抖和落点限高
大跨度变化 按追赶速度向目标移动 超距时直接对齐,并重置运动和平滑状态
实现成本 核心状态少,容易调出基本跟随 需要维护绕行角速度、贴地状态和重置时机

局部偏移与距离比例速度已经能够表达双人跟随;当目标还包括自然转向、复杂地面和稳定的帧间表现时,拆开这些约束能提供更明确的调参空间。

5. 动画同步与参数平滑

5.1 设计目标

双方需要共享走路、跳跃、滑翔等主要动作语义,同时让接收方的步态匹配自己的位移。例如发起方原地转向时,接收方仍可能绕行;发起方停止后,接收方也可能继续追赶。

动画系统还要给持物、收获、单手操作和个人动作保留控制空间,避免牵手镜像覆盖正在执行的操作。

5.2 系统设计与原理

发起方提供可镜像动画层的快照,并根据每帧求得的接收方水平位移计算移动速度。速度转换到发起方根节点的局部坐标后,生成 SpeedPerc_XZ、SpeedPerc_LocalX 和 SpeedPerc_LocalZ,用于接收方的移动混合树。

普通地面 Move/Idle 状态按接收方速度驱动;跳跃、滑翔等状态继续使用发起方的动作状态。动画广播携带接收方专属快照,远端直接消费这些参数,使三种视角都使用相同的速度来源。

5.3 Animator 接入与模块划分

模块 职责
HoldHandRuntimeController 每帧生成并平滑一份接收方动画速度,维护周期补发
ControllerSelfPlayer.HoldHand 复制允许镜像的动画快照,写入接收方移动参数
HoldHandStateUtility 确定可镜像层、接收方操作层,以及右手是否被操作占用
接收方动画驱动器 消费权威状态,在远端控制器与个人动作控制器之间切换

接收方平时使用 OtherPlayerAnimatorController,本地移动输入不参与其普通步态决策。个人动作通过动作 ID 校验和加载 Clip,持物、收获等操作由各自逻辑驱动。

5.4 动画平滑与兼容优化

5.4.1 速度滤波只推进一次

贴地高度变化不应被当作跑动,因此先移除位移的 Y 分量,再将速度限制到 MaxFollowSpeed 以内。之后以 AnimationVelocitySmoothTime = 0.1 s 做指数平滑:

\[\alpha=1-e^{-\Delta t/\tau},\qquad \bar v_{next}=\operatorname{Lerp}(\bar v,v_{raw},\alpha)\]

这份速度只在发起方每帧完成位移求解后更新一次。动画层和广播都只读取它,避免动画层数量或发送次数改变滤波速度,从而抑制 SpeedPerc 波动引起的步态抖动。

5.4.2 起停滞回与传送清零

启动和停止分别使用 0.1 m/s、0.05 m/s 阈值。停止判定同时检查原始速度与平滑速度,降低接近静止时移动状态反复切换的概率。传送时清零速度和移动标记,避免把大跨度位移解释为高速跑步。

基础层按独立间隔补发,即使发起方处于待机,接收方的追赶或绕行动画也能持续更新。

5.4.3 操作层与个人动作让位

ShouldMirrorAnimationLayer 排除接收方的上半身、单手、双手、Base_1 等操作层,以及个人动作展示层,让各层保留自己的驱动来源。

进入受支持的个人动作时,运行时记录进入次数,暂停牵手 IK 和对应音乐,并按需恢复本地 Animator 控制器;最后一个个人动作结束后,恢复牵手控制器和持物表现。接收方操作占用右手时,该侧 IK 权重暂时归零,让手臂服从操作动画。

6. 手部 IK

6.1 设计目标

手部 IK 需要让发起方左手与接收方右手尽量接触,同时维持自然的肩、肘、腕姿态。角色的臂长、挂点偏移和身体位置可能不同,因此共享目标必须考虑双方可达范围,骨骼结果还要满足关节限制。

跑步、跳跃和转向时,接触点应稳定地随双人位置移动;滑翔或个人动作占用手臂时,应能平滑释放和恢复。IK 只调整手臂表现,角色根节点的位置由移动系统负责。

IK 图沿用红色发起方、蓝色接收方图标,每一幅都画出两条参与牵手的骨链,共六个圆形节点。红色“肩 → 肘 → 腕”属于发起方,蓝色“肩 → 肘 → 腕”属于接收方,分别对应 LimbIK 的 bone1 → bone2 → bone3。十字表示“掌心”“目标握点”等逻辑点位,虚线骨链表示参考,不增加实际节点。

这些图是二维几何说明,连续状态保持上臂、前臂长度一致,用来展示输入、目标和约束的变化;角色比例与姿态不作为实机参数标定。

6.2 共享接触点与可达范围

实现基本握手可以先解决一个问题:让两条手臂追向同一个接触位置。由两只手的 Pose 得到偏好点,用双方的可达范围约束它,再交给 Final IK 的 LimbIK 求解,配合连接状态和权重过渡,就能建立基本的牵手表现。

6.2.1 从两个接触 Pose 得到共享 Pose

先只看两条手臂和一个共同握点。每条手臂由肩、肘、腕三个节点组成,暂时把手腕末端当作接触位置:固定肩的位置,只调整肘和腕,让双方末端靠向同一个目标。

给定末端目标,再反向调整关节以到达它,就是逆向运动学,简称 IK。这个模型能建立基本接触,但还没有回答两条手臂是否都够得到,下一步需要补上可达范围。

把发起方左手和接收方右手的两个接触 Pose 放到同一个世界空间后,位置取两者的算术中点,旋转通过 Quaternion.Slerp 取两者之间 0.5 处的方向。这个尚未经过可达约束、位置平滑和最终偏移的结果,就是本文所说的偏好 Pose;代码在约束前将它保存为 preferredContact,便于观察后续处理对目标造成的变化。

当前实现中,这两个输入来自 6.4.1 保存并还原的稳定参考,不会每帧重新采用动画摆动后的手部 Pose。双方最终使用同一个接触位置,只分别叠加 InitiatorContactRotationOffset 和 RecipientContactRotationOffset 调整握持朝向。

这一步建立的是共同目标,LimbIK 实际接受的手腕目标还要根据掌心挂点反算,具体见 6.5.3。

6.2.2 用可达球体近似两段手臂

上一小节先让两条骨链追向共同位置。现在把目标往远处移动:肘部可以展开,手却不能无限伸长,因为上臂和前臂的长度固定。

先只增加“两段骨长”这个约束,再用它们能覆盖的范围近似可达球体。图中画出球体截面,比较弯曲与展开两种状态:

最直接的近似是把上臂与前臂长度相加,作为可达半径;肘部弯曲时手靠近身体,展开时能到达更远的位置,但骨长与范围本身不变。掌心挂点相对手腕存在偏移时,还可以按挂点修正球心,图中因而把“球心”与“肩”分开标记;后面的 6.4.2 再说明另一种预留范围的处理。

得到双方的可达球体后,TryConstrainSharedContact 负责把偏好点限制在共同区域。球体表达伸展范围,肘部应往哪边弯、关节能转多远仍需要另外处理。

6.2.3 球体交集约束算法

单侧可达范围确定了,还不能保证两只手碰到一起:两个人相距太远时,范围可能完全分离。先观察两人从远处靠近、范围逐渐相交的过程:

红、蓝虚线圆是双方可达球的截面。出现交集后,如果原握点在共同范围外,就把它移回双方都能伸到的位置;没有交集时,只能保留折中目标,不能保证双方同时够到。

实现中进一步按下面的候选顺序选择目标:

  1. 偏好点已经位于两个球体内部,直接使用。
  2. 两球分离时,沿球心连线按双方伸展能力选取折中点,并将 HasSharedReach 记为假。
  3. 两球相交但偏好点在外部时,先检查投影到单侧球体的候选,选择同时满足双方范围、且离偏好点最近的一个。
  4. 单侧投影都不满足时,利用两球的交圆确定共同区域,将偏好点投影并限制在其中。

两球相交形成的交圆用于确定共同边界;具体半径还取决于挂点预留方式。这里先讲几何上能否到达,肘部应向哪边弯、关节能转多远仍需要另外限制。

6.2.4 平滑、连接状态和权重

两只手能够求向同一位置后,目标变化或连接切换仍可能显得突然。先给共享目标加上平滑,让它逐帧靠近计算结果;再处理连接边界和手臂的过渡程度。

共同目标在发起方根节点的局部空间中经过 Vector3.SmoothDamp 平滑,当前资源使用 ContactPositionSmoothTime = 0.01 s。平滑后若双方范围有交集,再将结果限制回交集,避免过渡过程中越界;完整的局部空间处理见 6.6.2。

IK 配对首次被观察到时先经过一个准备帧;从下一帧开始,只要配对仍然有效且没有暂停原因,就进入连接状态。滑翔、个人动作等暂停原因出现时解除连接,全部消失后再恢复。

当前 UpdateConnection 不按双方距离决定连接状态。双方是否同时够得到由 HasSharedReach 单独记录,它不会直接断开互动或 IK 连接。

连接状态决定目标权重是 1 还是 0,实际权重再逐帧靠近这个目标,避免整条手臂立即跳到另一个姿态:

两张图显示权重过渡的端点,中间仍逐帧推进。连接状态只影响 IK,不清除房间互动状态。当前最大位置、旋转权重均为 1,淡入、淡出时间均为 0.3 s;位置权重决定骨链追向目标的程度,旋转权重决定手腕服从目标朝向的程度。每帧将目标交给 LimbIK.solver,再手动求解。

6.2.5 基本表现与取舍

共享目标、可达约束与权重过渡已经能把两只手连在一起。这套设计的优点在于:

  • 共享接触点让两只手在视觉上拥有同一个“握手位置”,比两端各自追目标更稳定。
  • 球体交集算法计算量小、边界明确,能显式处理两侧臂长不同的情况。
  • 准备帧、暂停状态和权重淡入淡出让手臂能够连续地进入、释放和恢复。
  • 发起方统一求解并广播,接收方和第三方不需要重复运行完整可达性逻辑,结果更一致。
  • Final IK LimbIK 可以复用现有骨架和弯曲方向配置,落地成本低。

这些特点让它适合先建立基本的手部接触,再结合动作体验细调偏移与权重。接触点的几何可达性确定以后,还需要检查手臂在运动中的姿态。

6.3 运动中的手部姿态问题

球体只表达“能不能够到”,没有表达肘部偏好、肩部关节限位、碰撞体和衣服约束,某些姿势会出现肘部翻转或穿模。两球分离时也只能返回折中目标,不能保证双方同时够到;接触目标、骨骼动画和根节点运动同时变化时,还容易把手部摆动带进求解。

跑测中,静止状态下能把两只手调到一起,还不足以保证运动中的表现。接触处可能折起来,移动时有明显卡顿和手部摆动,手腕偶尔会弯到不自然的角度,大跳下落时手臂还会往上拉。接触点对齐之外,还需要控制动画变化对求解的影响。

一个比较明确的复现是:跑步中被碰撞卡住后,IK 位置会一直不对,要断开再连接才能恢复;小跳和大跳结束后的手部表现也不自然。针对这些现象,优化集中在两个方向:固定接触点和骨骼的参考,避免它们不断被当前动画改写;再为肩、肘、腕分别加上姿态限制。

联调中的接触点异常与重新连接后的手部状态对比

参考姿态稳定性的问题复现:左侧为接触异常,右侧为重新建立连接后的姿态。

6.4 稳定参考与受约束的共享目标

为了让基本的手部接触在跑步、跳跃和暂停恢复时也保持稳定,我保留共享接触点与球体约束,并将求解输入固定到稳定参考姿态。目标只随角色之间的相对关系变化,骨骼结果再经过独立的关节限制和平滑处理。

6.4.1 发起方计算共享接触目标

每帧从当前手部动画取接触点,容易把摆臂或一次异常姿态带进后续目标。于是先保存一份参考:牵手建立时,记录两只手相对各自角色的位置与旋转。

这就是局部空间:参考记录的是“相对这个角色在哪里”,而不是地图上的绝对位置。角色整体移动时,参考点跟着移动;动画中的手腕可以摆动,却不重新定义参考:

配对首次达到挂点与骨链都可用的状态时,把两侧接触 Pose 分别保存到各自根节点的局部空间。之后每帧随当前根节点还原这两个参考,先用位置中点与旋转中值形成偏好 Pose,再进入可达约束、局部平滑和最终偏移;当前动画中的手部摆动不会重新定义基准。

这组图画的是读取参考阶段,实线骨链表示送入 IK 前的动画姿态。preferredContact 对应刚形成、尚未处理的偏好 Pose;可达约束会修改它的位置,但不会修改其中间旋转。随后才进行局部平滑、再次约束并叠加 ContactPositionOffset,最后生成双方共用的位置与各自独立的旋转目标。完整计算由发起方执行,远端消费局部目标。

6.4.2 两侧可达球体的交集

前面按完整臂长近似可达范围,仍可能把手腕推到伸展极限。掌心挂点在手腕之外,握持朝向或接触距离改变时还需要一点余量。

因此保持球心在肩部,从半径中预留掌心挂点的偏移;需要额外掌心间距时,再预留这段接触距离。图中每次只改变一项预留:

可达球心取肩部。半径从上臂、前臂的总长度中,预留掌心挂点偏移以及 PalmContactDistance 的绝对长度;这样手腕旋转和接触距离调整时仍有伸展余量。扣除后半径非正,就无法建立有效可达球。

图中接触距离的变化用于展示参数作用,当前资源的 PalmContactDistance 为零。球体交集沿用 6.2.3 的候选与投影处理;没有交集时保留折中目标继续尝试,最终手臂仍受关节限制保护。

6.4.3 配对有效性与连接状态

IK 只处理状态互指、类型一致、Avatar 已下载且骨架就绪的配对。配对经过首次观察的准备帧,且没有暂停原因时进入连接。

滑翔、个人动作等通过暂停原因集合控制连接;只有所有原因解除后才恢复。HasSharedReach 描述几何可达性,不直接决定互动关系或 IK 是否断开。骨架失效、天气变身和接收方右手操作分别有对应的释放或让位逻辑。

6.5 Final IK 的应用与模块划分

6.5.1 使用 LimbIK 求解单侧手臂

项目使用 Final IK(命名空间 RootMotion.FinalIK) 插件,其中 LimbIK 是用于单条肢体的 IK 组件。每个角色准备左、右两条手臂的 LimbIK,牵手配对使用发起方的左臂和接收方的右臂。

LimbIK 内部使用 IKSolverLimb,它继承基于余弦定理的解析式 IKSolverTrigonometric。求解链由上臂、前臂、手腕三个骨骼节点组成,对应两段骨长;给定末端目标和弯曲平面后,求解器计算骨链的姿态,并提供末端旋转和权重控制。

项目将弯曲模式设置为 IKSolverLimb.BendModifier.Arm,让插件提供适合手臂的弯曲平面。共享目标计算、网络权威、暂停状态和三轴关节限制由项目自己的模块负责,插件承担具体的骨链求解。

6.5.2 骨链绑定与手动更新

EntityBaseHead.InitializeHoldHandIK 使用 G_LweaponTransform 和 G_RweaponTransform 作为左右接触挂点。从挂点依次向上取得手腕、前臂和上臂,校验骨链后建立求解器。核心配置可概括为:

1
2
3
4
5
6
7
8
9
10
limb.enabled = false;
limb.fixTransforms = false;
limb.solver.goal = goal; // LeftHand 或 RightHand
limb.solver.target = null;
limb.solver.bendModifier = IKSolverLimb.BendModifier.Arm;
limb.solver.bendModifierWeight = 1f;
limb.solver.maintainRotationWeight = 0f;
limb.solver.SetIKPositionWeight(0f);
limb.solver.SetIKRotationWeight(0f);
limb.solver.SetChain(upperArm, lowerArm, hand, Transform);

关闭 LimbIK 组件的自动更新后,项目通过 SetIKPosition、SetIKRotation 设置目标,并在受控阶段手动调用 limb.solver.Update()。这样可以在动画完成后求解,并在求解之后施加关节限制,避免组件独立更新造成顺序冲突。

fixTransforms = false 让骨骼参考姿态的恢复由项目代码控制;target = null 表示直接写入目标 Pose,不依赖目标 Transform。任一挂点、骨链或 LimbIK 无效,角色就不进入有效 IK 注册。

6.5.3 把掌心目标转换为手腕目标

前面把末端接触简化为一个点;实际接入插件时,LimbIK 的末端是手腕,玩法希望对齐的却是掌心。直接把掌心目标当作手腕目标,会忽略两者之间的挂点偏移。

先把掌心目标固定住,再观察握持朝向改变时的情况:偏移方向旋转后,手腕也必须换一个位置,掌心才能仍然停在目标上。

玩法要求对齐掌心挂点,LimbIK 的末端却是手腕骨骼。因此要先抵消挂点相对手腕的局部旋转,得到手腕朝向;再把经过角色缩放的挂点位置偏移按这个朝向转到世界空间,从掌心目标向回退到手腕目标。

图中将挂点偏移放大以便观察。ContactTargetToHandTarget 处理这一转换,输入包括接触目标 Pose、contact.localRotation、contact.localPosition 和手腕的 lossyScale。反算后的手腕 Pose 才送入 LimbIK;PalmContactDistance 的调整发生在此之前。

6.5.4 项目模块与插件的边界

模块 职责
HoldHandIKRuntimeController 枚举有效配对、计算共享目标、管理权重与暂停、发送或消费权威目标
HoldHandIKPairState 保存参考接触 Pose、位置平滑状态、网络序列和远端目标
EntityBaseHead.HoldHandIK 注册骨架、绑定 LimbIK、转换掌心目标、管理手臂参考姿态并执行求解
Final IK 的 LimbIK / IKSolverLimb 根据手腕目标和弯曲模式计算三节点手臂骨链
HoldHandArmRotationLimits 对肩、肘、腕执行旋转平滑、三轴限制和肘部防反折
HoldHandIKLateUpdateDriver 统一安排每帧 IK 更新时机
DataHoldHandControl 配置接触偏移、平滑时间、权重和关节范围

6.6 IK 稳定性与平滑优化

6.6.1 固定参考姿态,隔离动画扰动

跑步摆臂和跳跃会持续改变骨骼姿态。如果每帧从动画结果重新提取基准,目标点和限制参考也会随之变化,甚至把一次异常姿势留在下一次求解中。

系统分别保存配对的局部接触 Pose,以及首次有效求解时的手臂骨骼参考旋转。接触点随角色根节点移动,骨链从稳定参考姿态开始求解。滑翔等临时暂停保留这些参考,配对重建或骨架失效时才按生命周期重置。

6.6.2 在角色局部空间平滑接触点

固定参考减少了摆臂带来的扰动,但两人相对运动时,共享点仍可能轻微跳动。直接在世界空间平滑又会让角色整体前进、跳跃时的目标落后。

因此只平滑目标相对发起方的变化:整体位移立即带动目标,相对变化再逐帧追近。

共享点在发起方根节点局部空间做 SmoothDamp,平滑时间为 0.01 s,然后变换回世界空间。这样平滑只作用于双方相对位置的变化,角色整体移动和跳跃可以直接带动目标,避免世界空间平滑造成目标落后。

图中先只画局部目标与平滑握点。接入可达约束和配置偏移时,还需要下面两步:

有共同可达区域时,平滑结果会再次投影回该区域。之后按发起方根节点旋转叠加 ContactPositionOffset,当前值为 (-0.04, -0.31, -0.01);该偏移不回写平滑缓存,避免累加或被下一次平滑覆盖。偏移可能把目标推到可达区域外,需要与骨骼限制一起调试。

远端收到的 IK 目标同样在发起方局部空间平滑,再用本地显示的根节点还原;网络目标的具体消费方式见第 7 节。

6.6.3 肩、肘、腕三轴限制与防反折

共享握点解决了“手到哪里”,可达范围解决了“够不够得到”,仍不能保证关节弯得自然。接下来只给肩、肘、腕增加各自的旋转范围,并防止肘部反折:先得到 IK 姿态,再让最终骨骼服从这些限制。

HoldHandArmRotationLimits 为上臂、肘部、手腕分别提供偏航、俯仰和横滚范围。上臂相对角色建立参考系,肘部相对上臂,手腕相对前臂;骨骼轴在初始化时标定,运行时参考系跟随角色或父骨旋转。

“肩部限角”“肘部限角”“腕部限角”弧线标出旋转约束的位置,实际实现分别限制偏航、俯仰、横滚三个轴。图中用二维姿态说明处理顺序,不作为资源角度的实测结果。

上臂、肘部、手腕分别配置三轴旋转范围的编辑器界面

三关节配置入口与参考系说明,实际使用值见下表。

关节 偏航范围 俯仰范围 横滚范围 旋转平滑时间
上臂 -45° ~ 80° -90° ~ 10° -45° ~ 45° 0.03 s
肘部 0° ~ 145° -5° ~ 5° -80° ~ 80° 0.03 s
手腕 -30° ~ 20° -45° ~ 45° -5° ~ 5° 0.03 s

肘部同时满足角度范围与正常屈曲半空间。两种约束联合计算,避免先限角、再防反折时互相覆盖;范围没有可行交集时,也不能靠反折去满足目标。

6.6.4 关节旋转平滑与求解顺序

每个关节在自己的稳定参考系中进行旋转平滑,当前时间均为 0.03 s。这样可以缓和求解结果的微小跳变,同时让角色整体转身和父骨旋转及时传递到手臂。

平滑之后执行三轴硬限制,并缓存受约束后的旋转,防止滤波状态在限位之外积累误差。单次手臂更新的顺序为:

  1. 保存当前动画姿态,恢复稳定参考姿态,用 LimbIK 求出完整 IK 结果。
  2. 应用相对参考姿态的整体最大转角限制,再按位置、旋转权重与动画混合。
  3. 根据混合后的手腕朝向重新反算掌心偏移,再调用 LimbIK 校正接触位置。
  4. 对肩、肘、腕做旋转平滑、三轴硬限制和防反折,作为本帧骨骼输出。

最后一步之后不能再次运行 IK 覆盖限位。因此在接触精度与关节范围冲突时,系统优先满足关节限制,允许掌心存在少量间隙。

6.6.5 权重过渡与操作让位

只有连接与断开还不足以处理个人动作。牵手关系仍在时,操作动画也可能临时占用一条手臂;如果两套逻辑同时写骨骼,就会互相覆盖。

因此增加暂停与恢复:暂停期间让动画接管,保留这段牵手的参考;动作结束后,再朝同一套参考逐帧恢复权重。

连接状态通过 MoveTowards 推进权重,淡入、淡出时间均为 0.3 s,最大位置和旋转权重均为 1。求解结果与动画按权重混合,使进入、暂停和恢复过程具有连续过渡。

暂停原因用集合管理,避免一种玩法解除暂停时覆盖另一种玩法的占用。接收方自己的操作使用右手时,该侧位置和旋转权重暂时归零,骨骼服从操作动画;天气变身、骨架缺失等状态则按各自路径释放 IK。

6.7 姿态调整与复测

将求解参考固定到进入牵手时的骨骼姿态后,移动中的手部摆动有所改善。和策划复测时,重点继续放在小幅左右抖动、跳跃和碰撞后的恢复,避免只凭静止或直线移动时的效果判断整体表现。

确认剩余问题后,手肘姿态与站着平移分别处理:肩、肘、腕的三轴限制和旋转平滑负责骨骼表现,实际移动速度驱动动画负责步态。这样每次调整都能对应到具体的体验问题。

6.8 两种手部求解方式的对比

两种做法都由发起方决定共享接触目标,并使用 LimbIK 完成本地骨链求解。进一步的处理主要围绕参考姿态、目标平滑和骨骼约束展开。

设计点 共享接触点与可达约束 稳定参考与关节约束
目标参考 从当前手部 Pose 提取偏好接触点 保存各自根节点下的参考 Pose,随根节点还原
骨骼求解起点 在当前动画姿态上求解 从固定的手臂参考旋转开始求解,再与动画混合
可达范围 以臂长和挂点修正近似可达球体 肩部为球心,半径为掌心挂点与接触距离预留空间
接触点平滑 在世界空间平滑目标 在发起方局部空间平滑相对变化,整体移动直接传递
姿态限制 依靠 LimbIK 弯曲模式和可达区域 肩、肘、腕分别平滑与限角,并约束肘部反折
连接与暂停 准备帧后建立连接,以权重平滑进入和释放 显式暂停原因控制让位,临时释放时保留参考姿态
表现取舍 计算和接入较简单,能建立基本握手 运动中的姿态更可控,关节限位可能留下少量掌心间隙
实现成本 管理接触目标、可达性和权重 还需管理参考姿态、关节坐标系、平滑缓存和求解顺序

共享点解决了手在哪里相遇,稳定参考与关节约束则进一步控制手臂如何到达那里。角色体型差异、衣服和身体碰撞仍需要专门处理,不能只靠提高 IK 权重消除。

7. 网络同步与播放平滑

7.1 设计目标

网络层需要把发起方计算的双人结果稳定地呈现给接收方和旁观者,既控制延迟,又避免到包抖动导致身体顿挫、双人相对距离拉伸和手部目标跳动。

位置、动画和 IK 的变化频率不同,三条表现流独立调频;它们共享互动身份和控制来源,但保留各自的消费与平滑逻辑。

7.2 系统设计与数据模型

发起方通过 NetworkServiceComponent 发送成对位姿、动画快照和 IK 目标。成对位姿的关键字段如下:

字段 用途
双方 UID、互动类型 标识配对,校验本地房间状态
双方位置与旋转 同一次采样中的两个根节点 Pose
SampleTime 发起方的单调采样时间,用于排序和速度估算
InteractionRevision 校验快照属于当前互动
Teleported 要求清理旧轨迹并重建缓冲基线

SampleTime 只比较同一发送端的时间差,不要求各客户端时钟同步。接收方和旁观者各自维护播放时间,每次从一份成对快照缓冲中采样双方位姿。

IK 广播独立携带两套局部目标 Pose、Sequence、BlendWeight、Connected 和 HasSharedReach。动画广播则包含发起方生成的接收方移动参数,避免远端依赖网络位移反推步态。

7.3 同步模块划分

模块 职责
NetworkServiceComponent.HoldHand 组织并发送牵手位姿和 IK 广播
BroadcastHoldHandTransformUpdateHandler 校验配对、采样时间和互动版本,并分发位姿
HoldHandPairSnapshot 表示同一采样时刻的双方位姿
HoldHandSnapshotBuffer 保存时间轴,完成插值、有限外推和预测误差消退
HoldHandRuntimeController 消费本地接收方的缓冲,并统一应用双方位姿
HoldHandObserverSyncController 管理旁观者配对,驱动双方实体的缓冲结果
HoldHandIKRuntimeController / HoldHandIKPairState 校验 IK 序列,平滑和还原远端局部目标

7.4 网络与平滑优化

7.4.1 双人共用采样时刻

先看收到位置就直接显示。 发起方发来的位置是一份份离散消息;两次消息之间没有更新,画面会停一下,下一份消息到达后又跳过去:

在相邻位置之间补出中间状态。 保存前后两份位置,显示时逐帧取它们之间的中间值,这就是插值。为了知道下一份位置,需要稍微延后播放,代价是增加一点显示延迟:

再让两个人共用播放时刻。 如果双方各自插值,进度差异仍可能拉伸两人之间的距离。于是把同一次计算中的双人位置放在同一份快照里,再从同一条时间轴一起取值。

下图中,采样时间表示发起方何时记录位置,到包时间表示消息何时抵达,播放指针表示当前显示哪一刻;两个人共用最后这个指针:

HoldHandSnapshotBuffer 最多保存 64 份成对快照。处于相邻样本之间时,位置做线性插值,旋转做球面插值;发起方和接收方始终采用同一个播放时刻,避免两条独立轨迹的误差叠加到手臂距离上。

三条时间轴分别表示发送端采样、本地到包和缓冲播放。到包间隔可以不均匀,但播放指针连续前进,双方始终取同一时刻的结果。底部图标表示双方的采样结果,不是地图站位;运动速度与插值进度由发送端时间计算。

视角 播放方式 插值延迟 最长外推
发起方本地 直接应用逐帧权威结果 无网络缓冲 不需要
接收方本地 KCC 更新后应用同一时刻的双方位姿 0.06 s 0.03 s
旁观者 配对控制器每帧采样并应用双方位姿 0.09 s 0.1 s

缓冲更长可以吸收更多网络抖动,也会增加显示延迟,因此接收方与旁观者分别配置插值延迟和外推上限。

7.4.2 连续播放时间与小幅调速

播放时刻以本地连续时间推进,保持相对最新样本的目标延迟。缓冲深度偏离目标超过一个位置同步间隔时,通过约 ±5% 的播放速度修正漂移,不在每次收包时重新定位时间轴。

这样即使一帧内收到多包,播放进度也不会按到包节奏反复跳动。主线程长时间停顿或积累过大的播放落后时,系统将播放时刻重新对齐到最新样本减去配置延迟,并清理修正量。

7.4.3 有限外推与预测误差消退

缺包时使用最后两份样本的发送时间差估算速度,最多推进配置时长。外推仅作用于水平位置,速度上限为 20 m/s,高度和旋转保持最新样本值,避免继续放大贴地跳变或猜测转向。

当新样本修正了正在播放的预测轨迹,缓冲记录同一播放时刻的预测差异,再按指数衰减逐帧消除位置和旋转误差。修正时间取 max(0.05 s, 插值延迟),既保持收包瞬间的连续性,也让表现逐步回到权威轨迹。

显式传送、大距离跳变或样本间隔超过重同步阈值时,清空旧轨迹与误差,直接重建基线。当前配置下样本间隔阈值为 1 s;达到最长外推时间仍无新样本时,则停止继续预测。

7.4.4 三条表现流独立调频

所有同步参数集中在 DataHoldHandControl,当前周期配置如下:

流 发送间隔 内容与策略
成对位姿 0.1 s,约 10 Hz 双方根节点、采样时间、互动版本和传送标记
接收方基础层动画补发 0.2 s,约 5 Hz 持续更新接收方的地面移动参数,常规动画同步请求也可触发发送
IK 目标 0.2 s,约 5 Hz 两侧局部目标、权重和连接状态,远端逐帧平滑

按职责调频可以分别权衡带宽、延迟和表现精度。传送会触发即时位姿与动画广播,接收方的位置上报复用普通玩家机制;表中的数值表示周期配置,不保证网络实际到包间隔。

7.4.5 拒绝过期数据与防止重复驱动

位姿接收器校验双方互指关系、互动类型、有效采样时间和 InteractionRevision;缓冲拒绝不递增的采样时间。IK 流通过带回绕处理的 Sequence 比较拒绝重复和乱序目标。

普通玩家位姿包可以更新数据缓存,但有效牵手期间不能覆盖成对位姿控制。接收方统一应用双方采样结果,发起方本地直接驱动接收方实体,避免把额外的位置平滑再次叠加到同一条运动轨迹。

没有采样时间的位姿使用目标平滑路径:接收方以 SmoothDamp 消费目标,伙伴使用相对锚点;旁观者分别平滑双方目标。包含有效 SampleTime 的成对位姿使用本节所述的时间轴缓冲。

7.4.6 IK 局部目标同步与重建

先设想直接发送地图上的握点位置:远端身体已经通过插值向前移动,手部目标却可能还停在上一次收到的位置。两种更新错开后,就会出现手落在身体后面的感觉。

因此沿用局部空间,把目标记录为“相对发起方的位置与旋转”。远端先处理这份相对变化,再按自己当前显示的角色位置还原目标:

发起方把 IK 目标描述成相对自身根节点的位置与旋转后发送。远端先在这个局部空间平滑,再按本地显示的根节点变换还原世界目标,交给双方 LimbIK。

图中展示平移,旋转也通过同一根节点变换还原。远端目标与权重的平滑时间取 IK 同步间隔的一半,并保留 0.01 s 的下限,当前为 0.1 s。根节点整体运动直接传递给目标,相对姿态变化由滤波处理。

远端执行自己的 LimbIK 与关节限制,不重复共享球体求解。首份 IK 包到达前释放手臂;配对有效但持续收不到 IK 包时,继续消费最后一份目标。当前没有独立的 IK 包超时,位姿外推上限不会替代这项保护。

7.5 第三方视角复测

第三方同步的卡顿优化完成后,与策划在此前出现问题的设备上重新体验,确认旁观视角下的移动连续性有所改善。

复测关注的是旁观者能否连续地看到两个人一起移动。发起方本地的位置和手臂需要算得稳定,远端的快照播放也要承受住到包抖动,才能把同样的跟随感传递给旁观者。

8. 运行边界与调参关系

8.1 异常与降级行为

场景 处理方式
双方状态尚未同时到达 本地可以先初始化,完整配对的接收校验和 IK 等待互指完成
伙伴实体或 Avatar 未加载 相关位移驱动暂缓,IK 释放,准备完成后继续
位姿乱序或属于其他互动 按采样时间和互动版本拒绝
短时缺少位姿包 限时水平外推,达到上限后停止预测
传送、大跨度变化或长时间样本中断 清理轨迹与修正量,重建播放基线
贴地射线命中超限地面 保留投影前位置,不跨越最大高度差
共享点不可达 保留折中目标继续求解,以关节硬限制保护姿态
滑翔或受支持的个人动作 暂停相应 IK,恢复时保留稳定参考
接收方操作占用右手 该侧 IK 让位于操作动画
伙伴离开房间或切房 停止牵手并清理状态

射线贴地只处理高度,没有提供绕墙和完整的路径规划;手臂限角也没有实现身体、衣服的碰撞约束。这些边界决定了玩法适用范围,以及调试时应允许的手部间隙和姿态变化。

8.2 各类平滑的作用范围

平滑或约束 作用对象 调参时关注的关系
距离比例速度与停止区减速 接收方权威位置 追赶速度与接近目标时的稳定性
绕行旋转平滑与限速 跟随站位的偏移方向 急转表现与绕行滞后
接地防抖与落点限高 地面跟随状态及高度 台阶边缘稳定性与状态切换速度
动画速度滤波与起停阈值 接收方移动参数 步态稳定性与起停响应
局部接触点平滑 双人共享 IK 目标 手部相对抖动与接触响应
关节旋转平滑与硬限制 肩、肘、腕骨骼输出 姿态自然度与掌心接触精度
快照插值、有限外推、误差消退 远端双人位姿 网络抖动容忍度与显示延迟
远端局部 IK 目标平滑 收到的手部目标和权重 目标发送频率与视觉连续性

这些处理发生在不同数据和参考系中。调试时先确定抖动来自权威运动、动画参数、骨骼求解还是网络播放,再调整对应参数,避免用多层平滑掩盖更新顺序或控制权冲突。