브릿지는 표면적이 좁을수록 좋다
Esc

    브릿지는 표면적이 좁을수록 좋다

    –UnityDOTS

    적이 플레이어를 때리게 만들면서 질문이 하나 생겼습니다. 플레이어 체력을 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 쪽입니다

    넘기는 것이 적을수록 어긋날 자리가 적습니다.