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

双人牵手联调画面,已裁去外围界面。
1. 设计目标与控制模型
牵手看起来只是“让两名玩家的手靠在一起”,实际上需要同时协调四个系统:
- 互动状态:谁发起、谁接受、何时开始、何时结束。
- 根节点位移:接收方跟随发起方,适应地面高度、转向和传送。
- 动画表现:双方主要动作保持一致,接收方的步态又能反映自己的实际移动。
- 手部 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 | IntimacyInteractReq |
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。
每帧的处理顺序如下:
- 读取发起方本帧偏航变化,累加尚未消化的转角。
- 用
SmoothDamp消化转角,再按本帧时长限制实际转过的角度,得到站位参考旋转。 - 将
FollowOffset按这个参考旋转变换,生成本帧站位点。 - 从接收方保存的驱动位置计算到目标的距离,用距离比例速度、速度上下限和停止区减速确定步长,再执行
MoveTowards。 - 按接地条件修正高度,写入接收方位置;身体旋转直接使用发起方的旋转。
| 对象 | 对应代码 | 作用 |
|---|---|---|
| 发起方身体朝向 | 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 做指数平滑:
这份速度只在发起方每帧完成位移求解后更新一次。动画层和广播都只读取它,避免动画层数量或发送次数改变滤波速度,从而抑制 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 球体交集约束算法
单侧可达范围确定了,还不能保证两只手碰到一起:两个人相距太远时,范围可能完全分离。先观察两人从远处靠近、范围逐渐相交的过程:
红、蓝虚线圆是双方可达球的截面。出现交集后,如果原握点在共同范围外,就把它移回双方都能伸到的位置;没有交集时,只能保留折中目标,不能保证双方同时够到。
实现中进一步按下面的候选顺序选择目标:
- 偏好点已经位于两个球体内部,直接使用。
- 两球分离时,沿球心连线按双方伸展能力选取折中点,并将
HasSharedReach记为假。 - 两球相交但偏好点在外部时,先检查投影到单侧球体的候选,选择同时满足双方范围、且离偏好点最近的一个。
- 单侧投影都不满足时,利用两球的交圆确定共同区域,将偏好点投影并限制在其中。
两球相交形成的交圆用于确定共同边界;具体半径还取决于挂点预留方式。这里先讲几何上能否到达,肘部应向哪边弯、关节能转多远仍需要另外限制。
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 | limb.enabled = false; |
关闭 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。这样可以缓和求解结果的微小跳变,同时让角色整体转身和父骨旋转及时传递到手臂。
平滑之后执行三轴硬限制,并缓存受约束后的旋转,防止滤波状态在限位之外积累误差。单次手臂更新的顺序为:
- 保存当前动画姿态,恢复稳定参考姿态,用 LimbIK 求出完整 IK 结果。
- 应用相对参考姿态的整体最大转角限制,再按位置、旋转权重与动画混合。
- 根据混合后的手腕朝向重新反算掌心偏移,再调用 LimbIK 校正接触位置。
- 对肩、肘、腕做旋转平滑、三轴硬限制和防反折,作为本帧骨骼输出。
最后一步之后不能再次运行 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 目标平滑 | 收到的手部目标和权重 | 目标发送频率与视觉连续性 |
这些处理发生在不同数据和参考系中。调试时先确定抖动来自权威运动、动画参数、骨骼求解还是网络播放,再调整对应参数,避免用多层平滑掩盖更新顺序或控制权冲突。