粒子对象池复用后,旧完成事件不能回收新特效
受击火花刚播放完,同一个粒子发射器立刻被对象池复用来显示治疗光效。随后,火花所属的延迟完成回调才进入主线程,它只知道“这个发射器结束了”,于是把正在播放治疗光效的对象重新放回池中。画面上的表现是新特效突然消失,但池的空闲数量、对象引用和回调本身看起来都合法。
问题不在对象池是否复用实例,而在实例引用已经不能唯一表示一次播放。一个发射器对象可以经历多段生命周期;完成事件如果只携带对象或槽位,就无法回答自己属于哪一段。

槽位是存储位置,代次才补全身份
探针把池容量固定为 1。第一次播放 HitSpark 得到句柄 (slot=0, generation=1),完成后槽位回到空闲;第二次播放 HealBurst 仍使用槽位 0,但代次递增为 2。此时旧火花的回调若只比较槽位,两次播放完全相同;把代次放入句柄后,两段生命周期才有不同身份。
核心实现位于研究探针 Program.cs 的 VfxEmitterPool。池拥有槽位状态,调用者只持有不可变的 EmitterHandle。Play 在复用槽位时递增代次,Complete 先比较代次,再决定是否释放:
readonly record struct EmitterHandle(int Slot, int Generation);
enum CompleteResult
{
Released,
StaleGeneration,
AlreadyIdle,
UnknownSlot,
}
sealed class EmitterSlot
{
public int Generation { get; set; }
public bool IsPlaying { get; set; }
public string Effect { get; set; } = "";
}
sealed class VfxEmitterPool
{
private readonly EmitterSlot[] slots;
public VfxEmitterPool(int capacity)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
slots = Enumerable.Range(0, capacity)
.Select(_ => new EmitterSlot())
.ToArray();
}
public int PlayingCount => slots.Count(slot => slot.IsPlaying);
public EmitterHandle Play(string effect)
{
if (string.IsNullOrWhiteSpace(effect))
throw new ArgumentException("effect is required", nameof(effect));
var slotIndex = Array.FindIndex(slots, slot => !slot.IsPlaying);
if (slotIndex < 0)
throw new InvalidOperationException("pool exhausted");
var slot = slots[slotIndex];
slot.Generation++;
slot.Effect = effect;
slot.IsPlaying = true;
return new EmitterHandle(slotIndex, slot.Generation);
}
public CompleteResult Complete(EmitterHandle handle)
{
if (handle.Slot < 0 || handle.Slot >= slots.Length)
return CompleteResult.UnknownSlot;
var slot = slots[handle.Slot];
if (handle.Generation != slot.Generation)
return CompleteResult.StaleGeneration;
if (!slot.IsPlaying)
return CompleteResult.AlreadyIdle;
slot.IsPlaying = false;
slot.Effect = "";
return CompleteResult.Released;
}
}
代次检查必须发生在修改 IsPlaying 和清空效果之前。若先执行回收再检查,返回值即使报告陈旧,当前生命周期也已经被破坏。这里的状态所有者不是粒子组件的完成回调,而是 VfxEmitterPool;回调只能提交带身份的释放请求。
迟到回调应退化成无副作用结果
正常路径先完成代次 1,再创建代次 2。失败样本随后故意再次提交旧句柄 (0,1)。ProbeSuite.StaleCompletionPreservesNewEffect 同时断言返回 StaleGeneration、当前效果仍为 HealBurst、活动数量仍为 1。它检查的不是“检测到了错误”这一件事,而是错误没有越过状态边界。

在 $PROJECT_ROOT 执行 Release 构建与固定样本:
dotnet build $LAB_ROOT/VfxEmitterGenerationLab.csproj -c Release --nologo
dotnet run --project $LAB_ROOT/VfxEmitterGenerationLab.csproj -c Release --no-build
本次构建退出码为 0,0 个警告、0 个错误,耗时 10.00 秒;运行退出码为 0,8 个测试通过、0 个失败、0 个跳过,测试计时 37.33 毫秒。决定性输出为:
normal first=0:1 complete=Released second=0:2 active=HealBurst playing=1
failure late=0:1 result=StaleGeneration active=HealBurst playing=1
tests pass=8 fail=0 skip=0 duration_ms=37.33
重复完成同一代次返回 AlreadyIdle,越界槽位返回 UnknownSlot,池耗尽则拒绝新播放并保留当前效果。这些边界让调用者能区分重复通知、陈旧通知和非法身份,而不是把所有失败压成一个布尔值。
代次解决的是复用身份,不是线程安全
这套合同适用于完成通知可能延迟、排队或跨帧抵达,而槽位会在通知到达前被复用的客户端特效系统。实际引擎中,句柄还可以由粒子停止回调、时间轴通知或异步资源完成事件携带;无论来源是什么,都应回到池的同一释放入口核对身份。
探针刻意没有宣称并发安全。若 Play 与 Complete 能在不同线程同时修改同一槽位,还需要主线程串行化、锁或原子状态转换;代次只能识别旧生命周期,不能替代同步。代次整数也有回绕上限,长期运行系统应选择足够宽的无符号字段,并在回绕风险可达时引入更完整的会话身份。
开篇的治疗光效之所以能继续存在,不是因为旧回调被取消,而是因为迟到被设计成可接受的输入:它到达释放入口,身份核对失败,随后无副作用地结束。对象池可以继续复用物理实例,同时每次播放仍拥有不可混淆的逻辑生命周期。