粒子对象池复用后,旧完成事件不能回收新特效

受击火花刚播放完,同一个粒子发射器立刻被对象池复用来显示治疗光效。随后,火花所属的延迟完成回调才进入主线程,它只知道“这个发射器结束了”,于是把正在播放治疗光效的对象重新放回池中。画面上的表现是新特效突然消失,但池的空闲数量、对象引用和回调本身看起来都合法。

问题不在对象池是否复用实例,而在实例引用已经不能唯一表示一次播放。一个发射器对象可以经历多段生命周期;完成事件如果只携带对象或槽位,就无法回答自己属于哪一段。

同一发射器上的旧生命周期被隔离,新生命周期继续播放

槽位是存储位置,代次才补全身份

探针把池容量固定为 1。第一次播放 HitSpark 得到句柄 (slot=0, generation=1),完成后槽位回到空闲;第二次播放 HealBurst 仍使用槽位 0,但代次递增为 2。此时旧火花的回调若只比较槽位,两次播放完全相同;把代次放入句柄后,两段生命周期才有不同身份。

核心实现位于研究探针 Program.csVfxEmitterPool。池拥有槽位状态,调用者只持有不可变的 EmitterHandlePlay 在复用槽位时递增代次,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。它检查的不是“检测到了错误”这一件事,而是错误没有越过状态边界。

槽位 0 从代次 1 复用为代次 2,迟到完成因代次不匹配被拒绝

$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,池耗尽则拒绝新播放并保留当前效果。这些边界让调用者能区分重复通知、陈旧通知和非法身份,而不是把所有失败压成一个布尔值。

代次解决的是复用身份,不是线程安全

这套合同适用于完成通知可能延迟、排队或跨帧抵达,而槽位会在通知到达前被复用的客户端特效系统。实际引擎中,句柄还可以由粒子停止回调、时间轴通知或异步资源完成事件携带;无论来源是什么,都应回到池的同一释放入口核对身份。

探针刻意没有宣称并发安全。若 PlayComplete 能在不同线程同时修改同一槽位,还需要主线程串行化、锁或原子状态转换;代次只能识别旧生命周期,不能替代同步。代次整数也有回绕上限,长期运行系统应选择足够宽的无符号字段,并在回绕风险可达时引入更完整的会话身份。

开篇的治疗光效之所以能继续存在,不是因为旧回调被取消,而是因为迟到被设计成可接受的输入:它到达释放入口,身份核对失败,随后无副作用地结束。对象池可以继续复用物理实例,同时每次播放仍拥有不可混淆的逻辑生命周期。