수신자가 살아 있으면 버퍼, 사라지면 큐
공격 판정이 끝나면 “이 적이 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 를 합산하고 한 번에 적용한 것과 같은 발상입니다.
필드 하나를 추가하면 정책이 하나 필요해집니다.