跳转到内容

第 36 章 · 对象池与生成系统

约 6 分钟 难度:4 动手章

这一章解决:子弹、敌人、卡牌、掉落物如何生成、复用、归还,并让迟入玩家看到正确激活状态。

先看一下

轻实时玩法会不断生成东西:子弹飞出、敌人刷出、掉落物出现、卡牌被翻开。普通 Unity 项目可以频繁 InstantiateDestroy,但 VRChat 世界更适合预放置和复用。生成系统一旦失控,性能、Owner、迟入恢复和网络预算会一起出问题。

这一章会拿到什么

  • 对象池的生命周期:预放置、取出、初始化、归还
  • VRCObjectPool 的 Owner 边界
  • 生成请求走 GameState 裁决的最小模式
  • 池耗尽时的降级策略
  • 一份对象池测试矩阵

依赖前面

  • 第 5 章的池化兜底
  • 第 12 章的请求式架构
  • 第 20 章的幂等处理
  • 第 26 章的对象池接口边界

这是 difficulty 4 章节。只想先完成最小主项目时,可以先跳到第八部的闭环,再回来看本章的高风险系统。


对象池(Object Pool)把「创建 / 销毁」改成「取出 / 归还」。对象在场景里预先存在,平时 inactive,需要时激活,用完再 inactive。

阶段做什么谁负责
预放置在场景里放好固定数量对象制作者
登记把对象放进 VRCObjectPool 数组制作者
取出TryToSpawn() 激活一个空闲对象pool Owner
初始化设置位置、朝向、HP、生命周期对象 Owner 或生成器
使用执行移动、碰撞、表现对象自身
归还Return(obj) 回到池里pool Owner

第 5 章已经把 VRCObjectPool 放进迟入恢复的档③:迟入玩家会看到池里哪些对象当前 active。注意它只同步激活状态。对象自己的 HP、目标、倒计时仍然要靠对象上的字段或本地初始化规则恢复。


VRCObjectPool 是网络组件。调用 TryToSpawn()Return() 的一方必须处理 Owner 边界。更稳的做法是:生成请求交给 GameState Owner 或指定生成器 Owner,由它统一取对象。

public VRCObjectPool enemyPool;
public GameObject TrySpawnEnemy(Vector3 position, Quaternion rotation)
{
if (!Networking.IsOwner(enemyPool.gameObject)) return null;
GameObject obj = enemyPool.TryToSpawn();
if (obj == null) return null;
obj.transform.SetPositionAndRotation(position, rotation);
return obj;
}

如果每个玩家按按钮时都抢 pool Owner,再立刻取对象,所有权切换会变成额外网络成本。合作防守里的敌人刷出应由 GameState Owner 按波次执行;玩家技能生成物可以先走 PlayerObject 请求,再由 GameState 裁决是否生成。


对象池最容易出重复生成:玩家连点,回执丢失后重发,Owner 转移前后同一请求被处理两次。第 20 章的 requestId 继续使用。

private bool AcceptSpawnRequest(int playerId, int requestId, byte spawnType)
{
int slot = GetPlayerSlot(playerId);
if (slot < 0) return false;
if (requestId <= lastProcessedRequestId[slot]) return false;
if (phase != PHASE_INGAME) return false;
if (!HasSpawnBudget(spawnType)) return false;
lastProcessedRequestId[slot] = requestId;
return true;
}

这里继续沿用第 12 章的防御写法:先把 playerId 转成受控槽位,再访问数组。教程片段里的数组长度只是示意,项目里要按房间容量和离开清理策略确定。

处理通过后再从池里取对象。取出失败不应该让请求无限重试。池满时要给明确回执:冷却中、对象不足、当前阶段不允许生成。


池化对象不会重新执行构造流程。每次从池里取出时,要重置运行状态:HP 回到默认值,状态回到 IDLE,目标清空,生命周期计时重新安排,必要时再 RequestSerialization()

初始化入口最好是显式方法,例如 OnSpawned,而不是依赖 Start()Start() 只适合场景加载时做一次缓存。对象反复取出时,HP、状态、计时器、目标、视觉残留都要在 spawn 入口重置。归还入口同理:先把对象状态写成不可交互,再交回 pool。


对象池只能告诉迟入玩家「这个对象此刻 active」。它不会自动告诉迟入玩家「这只怪还剩多少 HP、正在追谁、属于第几波」。所以池化生成物要拆两层恢复。

层级恢复来源例子
是否存在VRCObjectPool active 状态第 3 只敌人此刻激活
当前状态对象自己的同步字段HP、状态、目标、所属波次
全局进度GameState当前波次、剩余敌人数、阶段

如果某个对象只是一段持续特效,例如 3 秒火焰圈,迟入玩家看不见过去两秒也可以接受,它可以不进入同步对象池。影响规则的对象才需要池化兜底。


池耗尽时,TryToSpawn() 会失败。这个失败要可见、可控、可测试。

常见处理有三种。第一种是拒绝生成:玩家技能进入失败回执,UI 显示资源不足。第二种是复用最旧对象:适合纯视觉特效,不适合敌人和掉落物。第三种是降级生成:不刷实体,只播本地特效或增加聚合计数。

合作防守敌人池建议拒绝或延迟,不要偷偷复用未死亡敌人。子弹和短特效可以复用最旧对象,但要保证不影响分数和 HP。


情况更稳的做法
物体只出现一次场景里直接放置,切 active 即可
特效很短且不影响规则本地播放,不进网络池
池大小难以估计先做最大同时存在数量测试
对象状态多到难以重置拆对象类型,减少复用范围
每次生成都要抢 Owner改成集中生成器或 GameState 裁决

对象池不是越通用越好。一个「万能生成器」同时生成敌人、卡牌、子弹、掉落物,会把初始化和归还规则混在一起。更稳的结构是按对象类型拆池:EnemyPoolProjectilePoolDropPool


挑一条试。

  • 把敌人池大小从 20 改成 5。第 6 个敌人应该被拒绝、延迟,还是替换最旧敌人?
  • 给池化子弹加一个「命中后归还」入口。哪些状态要在下一次 OnSpawned 里清掉?
场景预期实际
1. 正常生成敌人pool Owner 取出对象,对象执行初始化并同步状态
2. 玩家连点生成请求requestId 阻止重复生成
3. 池耗尽返回明确失败,不无限重试,不创建新对象
4. 对象死亡归还后再次取出HP、状态、目标、速度和特效残留被重置
5. 迟入玩家进入active 对象存在,HP / 状态 / 波次可恢复
  • 能解释 VRCObjectPool 只同步 active 状态
  • 能说出 pool Owner 为什么重要
  • 能用 requestId 防止重复生成
  • 能列出池化对象每次取出必须重置的字段
  • 能给池耗尽设计一个降级策略