수신자가 살아 있으면 버퍼, 사라지면 큐
Esc

    수신자가 살아 있으면 버퍼, 사라지면 큐

    –UnityDOTS

    공격 판정이 끝나면 “이 적이 10 맞았다” 를 어딘가에 적어야 합니다. 그리고 체력이 0이 되면 “이 적이 죽었다” 를 또 어딘가에 적어야 합니다.

    둘 다 사건인데 담는 곳이 다릅니다.

    데미지는 버퍼에

    public struct DamageEvent : IBufferElementData
    {
        public int Amount;
        public float3 SourcePos;
        public float KnockbackScale;
    }

    IBufferElementData 는 엔티티마다 붙는 가변 길이 배열입니다. 한 프레임에 세 명이 때렸으면 그 적의 버퍼에 세 개가 쌓입니다.

    데미지는 “이 적에게 일어난 일” 이라 그 적이 들고 있으면 됩니다. 그리고 적용할 때 합산하고 비웁니다.

    int total = 0;
    foreach (var d in buffer) total += d.Amount;
    if (total > 0) health.Value -= total;
    buffer.Clear();                      // 안 비우면 다음 프레임에 또 맞습니다

    Clear() 를 빼먹으면 유령 데미지 가 계속 들어갑니다. 한 대 맞고 계속 죽습니다.

    사망은 큐에

    사망도 버퍼에 담고 싶어지는데, 안 됩니다.

    주인공이 DestroyEntity 로 사라지기 때문입니다. 죽은 엔티티의 버퍼에 사망 이벤트를 담으면 엔티티와 함께 그 버퍼도 사라집니다. 받아 갈 사람이 읽기 전에요.

    그래서 엔티티 바깥의 전역 수집처 가 필요합니다.

    public struct DeathEventQueue : IComponentData
    {
        public NativeQueue<DeathEvent> Value;
    }

    기준을 한 문장으로 하면 이렇습니다.

    사건이 끝난 뒤에도 수신자가 살아 있으면 그 엔티티의 버퍼, 사라지면 월드 전역 큐.

    그리고 둘 다 안 비우면 문제가 생기는데 증상이 다릅니다. 버퍼는 유령 데미지, 큐는 메모리 누수입니다. 같은 병에 다른 증상입니다.

    파괴 전에 스냅샷

    사망 이벤트에는 이펙트를 터뜨릴 위치가 필요합니다. 그런데 적은 곧 사라집니다.

    foreach (var (transform, entity) in SystemAPI.Query<RefRO<LocalTransform>>().WithEntityAccess())
    {
        queue.Enqueue(new DeathEvent { Position = transform.ValueRO.Position });  // 값을 복사
        ecb.DestroyEntity(entity);
    }

    ecb.DestroyEntity 는 예약일 뿐 이라 실제 파괴는 Playback 에서 일어납니다. 그래서 foreach 안에서는 위치가 아직 유효합니다. 거기서 값을 떠 옵니다.

    소비는 LateUpdate 에서

    큐를 비우는 쪽은 MonoBehaviour 입니다. 그런데 Update 에서 받으면 1프레임 밀립니다.

    SimulationSystemGroup 이 유니티 플레이어 루프의 Update 페이즈 안에 있어서, MonoBehaviour.Update 와 순서가 뒤섞입니다. 어떤 프레임은 시스템이 먼저 돌고 어떤 프레임은 브릿지가 먼저 돕니다.

    LateUpdate 는 그 페이즈 뒤라 항상 시스템 다음 입니다.

    void LateUpdate()
    {
        while (_queue.TryDequeue(out var e)) SpawnDeathEffect(e.Position);
    }

    여기서는 JobHandle 이 필요 없습니다. 생산하는 DeathSystem 도 메인 스레드고 소비도 메인 스레드니까요. DestroyEntity 자체가 메인 스레드 전용이라 병렬로 만들 이유도 없습니다.

    반면 적이 플레이어를 때리는 큐는 다릅니다. 1만 마리가 병렬로 씁니다.

    Queue = queue.AsParallelWriter()       // 잡에는 ParallelWriter 로 넘깁니다

    그리고 소비하는 쪽에서 WriteHandle.Complete() 를 먼저 불러야 합니다. 같은 NativeQueue 인데 쓰는 쪽이 병렬이냐 아니냐로 다뤄지는 법이 갈립니다.

    검증

    DeadTag 를 정확히 7마리 에만 켜고 카운터를 봤습니다.

    20273 → 20280      정확히 +7
    큐 잔여 0
    유휴 상태 3회 연속 샘플 고정 (유령 사망 없음)

    +7 이 나오면 누락도 중복도 없다는 뜻이고, 유휴 상태에서 카운터가 안 움직이면 큐가 제대로 비워졌다는 뜻입니다. 둘 다 봐야 합니다. 하나만 보면 반쪽입니다.

    값을 담을까 참조를 담을까

    나중에 넉백을 넣으면서 DamageEvent 에 필드를 추가해야 했습니다. 세기는 이미 Amount 로 있었는데 방향이 없었습니다.

    세 가지 안이 있었습니다.

    안 문제
    float3 Direction (정규화) 거리 정보가 사라집니다. “가까울수록 세게” 를 나중에 넣으려면 스키마를 또 고쳐야 합니다
    Entity Attacker 넉백을 계산할 시점에 그 적이 이미 죽어 사라졌을 수 있습니다
    float3 SourcePos 방향과 거리를 둘 다 품고, 값이라 안전합니다

    Entity 가 위험한 이유가 앞의 “죽기 전에 스냅샷” 과 정확히 같습니다. 참조는 대상이 사라지면 무효가 되고, 값은 복사해 왔으니 안전합니다. 같은 원리가 다른 자리에서 두 번 나왔습니다.

    스키마를 바꾸면 정책이 하나 필요해진다

    Amount 는 그냥 더하면 됐습니다. 교환법칙이 성립하니까요. 그런데 SourcePos 는 여러 개일 때 무엇을 고를지 정해야 합니다.

    한 프레임에 세 명이 사방에서 때리면 방향이 셋입니다.

    방식 결과
    가중 평균 사방에서 맞으면 상쇄돼서 안 밀립니다. 물리적으로는 맞지만 무쌍답지 않습니다
    첫 번째 것만 병렬로 쓰니 순서가 비결정적입니다. 매번 다르게 밀립니다
    최대 데미지 한 건 “제일 센 놈한테 밀린다”. 무쌍답고 결정론적입니다

    그래서 루프는 합산과 최대 건 기억만 하고, 넉백은 루프 밖에서 한 번 적용합니다.

    처음에는 루프 안에서 밀었는데, 그러면 세 명한테 맞을 때 세 번 밀리고 밀린 위치에서 다음 방향을 다시 재서 결과가 버퍼 순서에 의존하게 됩니다. “최대 한 건만” 의 의미가 거기서 사라집니다.

    DamageEvent 를 합산하고 한 번에 적용한 것과 같은 발상입니다. 필드 하나를 추가하면 정책이 하나 필요해집니다.