跳转到内容

第 10 章 · CyanPlayerObjectPool:历史方案与兼容案例

约 7 分钟 难度:2 动手章

这一章解决:读懂用 CyanPlayerObjectPool 写的旧世界源码,并把这一类 prefab 池模式迁移到 VRCPlayerObject

先看一下

VRCPlayerObject 是 SDK 内置功能,但它进入 SDK 的时间不算长。在它之前,社区里 per-player 对象的事实标准是 CyanPlayerObjectPool:场景里预先摆好 N 份预制体(N 是房间最大人数),玩家加入时手动从池里抢一份分配给他,离开时回收。许多线上世界仍然在用这套实现。

直接读 CyanPlayerObjectPool 的源码会卡在「为什么要这么设计」上。这一章把它当作谱系标本:它解决过什么、为什么现在不再推荐、迁移到 VRCPlayerObject 的字段对照。

这一章会拿到什么

  • 一份「CyanPlayerObjectPool 解决过的三类问题」清单:join race、Master 验证、per-player 对象池
  • 当前归档状态与迁移决策表
  • 一份字段对照表:旧 Pool 上的 _GetPlayerPooledObject 等接口在 VRCPlayerObject 里对应什么

依赖前面

  • 第 9 章 VRCPlayerObject 的工作机制
  • 第 3 章 Owner 与 Master 的边界
  • 第 5 章迟入恢复

时间倒回 VRCPlayerObject 进入 SDK 之前。要给每位玩家一份对象,社区只能用对象池:

  • 场景里预先放 N 份预制体(比如 N=80),全部 inactive。
  • 玩家加入实例时,由 Master 客户端从池里挑一份,调用 Networking.SetOwner 把这份对象的 Owner 设为这位玩家。
  • 玩家离开时,由 Master 把这份对象重置 + 回收。

听起来直观,工程上要解决三类难题:

两位玩家几乎同时加入实例,两份代码同时跑「找一份空的预制体分给他」。如果两台客户端各自挑了同一份预制体,会发生重复分配。CyanPlayerObjectPool 的解决办法是把分配权固定在 Master 客户端,并加上分配请求的请求队列,逐个处理。

难题 2:Master 客户端崩溃后的状态回收

Section titled “难题 2:Master 客户端崩溃后的状态回收”

Master 客户端在玩家离开瞬间崩溃。这份玩家原本拥有的对象处于「Owner 已经离开、回收逻辑没跑完」的不一致状态。CyanPlayerObjectPool 的解决办法是新 Master 接管时扫描所有池对象,发现 Owner 已离开就强制回收。

难题 3:per-player 对象池本身的维护

Section titled “难题 3:per-player 对象池本身的维护”

预制体数量、命名、生命周期、Owner 检查、字段重置时机、迟入扫描,每一项都需要写代码。社区的工程经验集中在这一份仓库里,许多线上世界因此能直接用。


CyanPlayerObjectPool 仓库已于 2024 年 10 月 11 日由作者归档,最后一个正式版本是 2023 年 5 月 5 日的 v1.1.2。归档不等于代码不能跑,意思是:作者不再接受新功能 PR 和 bug 修复。VRChat 官方在 SDK 中加入 VRCPlayerObject 后,三类难题被 SDK 内部接管,社区轮子的工程价值下降。

下面这张表把两套机制对齐:

维度CyanPlayerObjectPoolVRCPlayerObject
对象生成场景预先放 N 份预制体,运行时分配SDK 在玩家加入时按场景模板克隆
Owner 设置调用 SetOwner 把对象交给玩家SDK 在克隆时锁定 Owner,不可变
数量限制必须预先设定 N(一般 80)跟随实例最大人数
join race 处理仓库内自维护请求队列SDK 内部处理
Master crash 回收新 Master 扫描所有对象SDK 自动销毁离开玩家的对象
持久化不直接支持VRCEnablePersistence 集成
当前状态归档(2024-10),仍可用于读懂老世界推荐方案

新项目应当直接用 VRCPlayerObject。线上仍然在跑的旧世界没必要立刻迁移,迁移成本超过收益。要迁移,可以参照下面的对照表分阶段切。


读老世界源码的查表。Pool 一侧列的是常见公开接口(具体名以仓库版本为准)。

Pool 一侧PlayerObject 一侧
_GetPlayerPooledObject(VRCPlayerApi player)Networking.GetPlayerObjects(player) 返回 GameObject[],再 GetComponentInChildren<T>()
_OnLocalPlayerAssigned()该玩家的 PlayerLobbyState.Start(Owner 是本地玩家时)
_OnPlayerAssigned(VRCPlayerApi)监听 OnPlayerJoined + 等 PlayerObject Start 完成
_OnPlayerUnassigned(VRCPlayerApi)OnPlayerLeft(PlayerObject 此时已经在销毁过程中)
池容量配置跟随实例最大人数,无需配置
池预制体引用场景里挂 VRCPlayerObject 组件的模板 GameObject

老代码里如果出现「某位玩家的对象槽位 index」(如 pooledObjects[i]),它的含义是「这位玩家在池数组里的下标」,迁移时整段下标维护可以删除,PlayerObject 不需要这套。


线上世界什么情况下值得迁移:

情况决策
旧世界跑得好,没新功能开发不迁移。运行良好的代码不是技术债
计划做每位玩家一份对象级持久化迁移。VRCEnablePersistence 是 PlayerObject 持久化路径的一部分;简单键值存档则另走 PlayerData
现有玩家数已经接近 Pool 容量上限迁移。Pool 容量需要手动调整,PlayerObject 跟随实例
想加新的 per-player 字段,且改动量小不迁移,沿用旧 Pool 加字段就行
新世界 / 重构直接用 PlayerObject,不再走 Pool

迁移本身要分两步:先把字段接口从 Pool 切到 PlayerObject,再删除 Pool 自维护的请求队列、Master crash 扫描、Owner 设置等逻辑。第一步不删旧代码、只补新接口,保证两套并行能跑通;第二步再清理。


线上世界改造决策不只看技术指标。

  • 大型成熟世界(已上线一年以上、玩家社群稳定):除非要做持久化,迁移收益低。把它当作技术谱系遗物保留,新功能用其他方式扩展。
  • 小型实验世界(朋友圈跑,不到一个月):直接迁移。改一次代码相当于把旧 Pool 的所有概念过一遍,下一个项目就不再走旧路。
  • 计划合作的混合项目(多位作者,部分人熟悉 Pool):在 README 里写清楚现在用的是哪一套,新加的代码统一用 PlayerObject。两套并存的项目最容易在文档缺失时被新人误读。

  • 找一个开源的、用 CyanPlayerObjectPool 的 VRChat 世界(GitHub 搜 CyanPlayerObjectPool 关键字),读一份 _OnPlayerAssigned 的实现,画出它的「分配 → SetOwner → 字段重置 → 通知 UI」时序。再用 VRCPlayerObject 写一份等价代码,对比两边代码量。
  • 把第 9 章的 PlayerLobbyState 故意改成「场景里手动放 8 份预制体,由 Master 用 SetOwner 分配」。跑两个客户端,观察 join race 的具体表现(两位玩家几乎同时加入会发生什么)。看完恢复回来。

本章不引入新代码,复用第 9 章的 PlayerLobbyState 测试矩阵即可。如果在迁移老世界,加一行专门项:

场景预期实际
旧 Pool 与新 PlayerObject 并存运行两套代码读到的 isReady 在同一帧内一致

迁移过程中只要这一行短暂出现不一致,立刻回退到全量旧 Pool 模式,再按字段排查。


  • 能说出 CyanPlayerObjectPool 解决过的三类问题
  • 能复述当前 SDK 下三类问题分别由谁接管
  • 能说出迁移决策表里至少两条「值得迁移」和两条「不值得迁移」的情况
  • 能识别老代码里池下标维护逻辑(pooledObjects[i]assigned[i] 等)