브릿지는 표면적이 좁을수록 좋다
적이 플레이어를 때리게 만들면서 질문이 하나 생겼습니다. 플레이어 체력을 ECS 쪽에도 둬야 하나.
적 1만 마리가 각자 “플레이어가 아직 살아 있나” 를 알아야 하니까,
PlayerState 싱글톤에 Health 를 넣으면 될 것 같았습니다.
안 넣었습니다
체력을 양쪽에 두면 진실의 출처가 둘 이 됩니다.
GameObject 쪽 Hp 와 ECS 쪽 복사본을 매 프레임 동기화해야 하고,
한 프레임이라도 어긋나면 “UI 는 500인데 ECS 는 300” 이 됩니다.
그래서 그때는 아예 안 넣었습니다. 기준은 이거였습니다.
브릿지에 필드를 추가하는 기준은 “반대편이 그걸 읽느냐” 다.
그때는 플레이어 체력을 읽는 ECS 시스템이 0개 였습니다. 그러니 넘길 이유가 없었습니다.
나중에 읽는 쪽이 생겼다
플레이어 사망을 넣으면서 EnemyAttackSystem 이 “죽었나” 를 알아야 하게 됐습니다.
읽는 쪽이 생겼으니 이제 넘겨야 합니다.
그래도 Health 가 아니라 bool IsDead 한 비트 만 넣었습니다.
public struct PlayerState : IComponentData
{
public float3 Position;
public bool IsDead; // Health 가 아닙니다
}
적은 “몇 HP 남았나” 를 알 필요가 없습니다. 때릴지 말지만 정하면 됩니다. 숫자를 넘기면 동기화 문제가 따라오지만, 비트 하나는 그렇지 않습니다.
미룬 것이 옳았습니다. 미리 넣었으면 몇 주 동안 쓰지도 않는 값을 매 프레임 동기화하고 있었을 것입니다.
참고로
bool은 unmanaged 컴포넌트 필드로 명시적으로 허용 됩니다. 재미있는 건 문서가 이걸 blittable 타입과 따로 나열한다는 점입니다. C# 에서bool은 마샬링 크기가 정해져 있지 않아 엄밀히는 blittable 이 아닌데, 유니티가 예외로 허용해 준 것입니다.
부분 갱신을 빠뜨렸다
여기서 조용히 틀렸습니다.
브릿지가 둘인데 같은 싱글톤을 각자 씁니다.
PlayerStateBridge 는 매 프레임 위치를 쓰고, PlayerHealthBridge 는 죽을 때 IsDead 를 씁니다.
// PlayerStateBridge, 매 프레임
_em.SetComponentData(e, new PlayerState { Position = transform.position });
// ↑ new 라서 IsDead 가 default(false) 로 리셋됩니다
PlayerHealthBridge 가 아무리 IsDead = true 로 세팅해도 다음 프레임에 지워집니다.
그리고 에러는 한 줄도 안 납니다. 죽은 플레이어를 적이 계속 때릴 뿐입니다.
고치는 방법은 읽고, 내 필드만 고치고, 되돌려 쓰는 것입니다.
var s = _em.GetComponentData<PlayerState>(e);
s.Position = transform.position; // 내 필드만
_em.SetComponentData(e, s);
GetSingleton 이 복사본을 준다는 사실이 여기서 다시 걸립니다.
복사본이라서 되돌려 써야 하고, 되돌려 쓰기 때문에 나머지 필드가 딸려 갑니다.
싱글톤을 여러 곳에서 쓰는 순간 이 함정이 생깁니다.
잡이 아니라 시스템에서 나간다
플레이어가 죽으면 적은 공격을 멈춰야 합니다. 어디서 나가느냐가 성능을 가릅니다.
// 잡 안에서 나가기
void Execute(ref EnemyAttack attack)
{
if (PlayerIsDead) return; // 1만 번 순회는 그대로 합니다
}
// 시스템에서 나가기
var player = SystemAPI.GetSingleton<PlayerState>();
if (player.IsDead) return; // 잡 스케줄 자체가 없습니다
잡 안에서 조기 반환하면 1만 마리를 다 돌면서 매번 조건만 확인합니다. 시스템에서 나가면 잡이 아예 안 만들어집니다. 사망 후 이 시스템은 말 그대로 공짜입니다.
같은 조건문인데 두는 위치로 비용이 갈립니다. 1만 개짜리 순회에서는 “어디서 판단하는가” 가 “무엇을 판단하는가” 만큼 중요합니다.
검증
부분 갱신이 제대로 도는지를 이렇게 확인했습니다.
t = 6.6에 체력이 0이 되고IsDead = True로 전환- 적의 공격
Timer를 6초 동안 4회 샘플링했는데 전부1.000고정 IsDead가 수십 프레임 유지됨
두 번째가 핵심입니다. Baker 초기값이 0이라서 1.000 은
한 번 때리고 쿨다운으로 리셋된 직후에만 나오는 값입니다.
그게 고정돼 있다는 것은 시스템이 아예 안 돈다는 뜻입니다. 때리다가 딱 멈췄습니다.
세 번째는 부분 갱신이 Position 에 안 밟혔다는 증거입니다.
앞의 버그가 남아 있었다면 다음 프레임에 False 로 돌아갔을 것입니다.
“안 도는 것” 을 확인하려면 안 도는 것의 흔적을 찾아야 합니다. 로그가 안 찍히는 것만으로는 시스템이 멈춘 건지 로그를 안 넣은 건지 모릅니다.
여기까지의 경계
브릿지 넷을 만들고 나서 경계가 이렇게 정리됐습니다.
| 무엇 | 어디 | 왜 |
|---|---|---|
| 플레이어 위치 | GO 가 소유, ECS 가 읽음 | 적이 추적해야 합니다 |
| 플레이어 체력 | GO 만 | 적은 알 필요가 없습니다 |
| 플레이어 생사 | GO 가 소유, ECS 가 읽음 (bool 하나) |
때릴지 말지 |
| 적 체력 | ECS 만 | GO 는 알 필요가 없습니다 |
| 적 사망 사건 | ECS → GO (큐) | 연출은 GO 쪽입니다 |
넘기는 것이 적을수록 어긋날 자리가 적습니다.