잡의 시그니처가 곧 쿼리다
1만 마리를 플레이어 쪽으로 걷게 합니다. 잡 하나면 됩니다.
[BurstCompile]
partial struct MoveJob : IJobEntity
{
public float3 Target; // 1만 개가 공유하는 값
public float DeltaTime;
void Execute(ref LocalTransform transform, in MoveStats stats)
{
var diff = Target - transform.Position;
var distance = math.length(diff);
if (distance < stats.StopDistance) return;
var direction = math.normalize(diff);
transform.Position += direction * stats.Speed * DeltaTime;
}
}
여기서 쿼리를 어디에도 안 적었습니다. 적을 필요가 없습니다.
시그니처가 쿼리다
Execute 의 파라미터가 곧 “이런 컴포넌트를 가진 엔티티” 입니다.
위 코드는 LocalTransform 과 MoveStats 를 둘 다 가진 엔티티만 돕니다.
소스 제너레이터가 그 쿼리와 순회 코드를 컴파일 시점에 만들어 줍니다.
한정자가 접근 권한을 정합니다.
| 뜻 | 언제 | |
|---|---|---|
ref |
읽고 쓴다 | 갱신할 컴포넌트 |
in |
읽기만 한다 | 참조만 하는 컴포넌트 |
in 을 쓰는 것이 성능 습관이 아니라 안전 장치입니다.
읽기 전용이라고 선언해 두면 잡 스케줄러가 “이건 동시에 읽어도 된다” 를 알고,
다른 잡과 병렬로 돌릴 수 있는지 판단합니다.
전부 ref 로 두면 안 되는 게 아니라 직렬화됩니다.
[BurstCompile] 은 둘 다
잡 구조체에도, 그 잡을 스케줄하는 시스템에도 붙여야 합니다. 한쪽만 붙이면 조용히 반쪽만 최적화됩니다.
그리고 잡 안에서는 Time.deltaTime 이 아니라 SystemAPI.Time.DeltaTime 입니다.
Time 은 유니티 엔진 쪽 정적 클래스라 Burst 에서 터집니다.
값을 시스템에서 미리 읽어 잡 필드에 넣어 두는 방식이라 위 코드처럼 됩니다.
필드와 파라미터가 다른 것
이게 이 글에서 제일 중요한 부분입니다.
- 필드 = 1만 개가 공유하는 값. 플레이어 위치,
DeltaTime, 결과를 담을 큐 Execute파라미터 = 1만 번 반복되는 대상. 이 엔티티의 위치, 이 엔티티의 스탯
그리고 잡 구조체는 워커 스레드마다 복사됩니다.
그래서 ref 필드를 만들 수가 없습니다. 복사본에 참조를 담을 방법이 없습니다.
그런데 네이티브 컨테이너는 다릅니다.
NativeQueue 같은 것은 구조체 안에 포인터 하나만 들고 있어서,
복사가 되더라도 같은 메모리를 가리킵니다.
그래서 필드로 넣어 두면 워커 넷이 같은 큐에 씁니다.
이게 이 시리즈 뒤쪽에서 계속 돌아옵니다. “같은 싱글톤인데 왜 하나는 되돌려 써야 하고 하나는 안 그래도 되나” 의 답이 여기 있습니다. 값이냐 포인터냐 입니다.
스케줄
new MoveJob
{
Target = player.Position,
DeltaTime = SystemAPI.Time.DeltaTime,
}.ScheduleParallel();
ScheduleParallel 이 청크 단위로 쪼개서 워커에 뿌립니다.
Schedule 은 워커 스레드 하나에서 순서대로, Run 은 메인 스레드에서 즉시입니다.
디버깅할 때 Run 으로 바꾸면 Debug.Log 를 넣을 수 있는데,
그러면 Burst 도 꺼지고 병렬도 없어져서 성능이 전혀 다른 코드가 됩니다.
성능을 재면서 Run 을 쓰면 안 됩니다.
컴파일을 통과한 버그 셋
이 잡 하나에서 나온 것들입니다. 전부 에러 0개였습니다.
하나. math.normalize 를 안 썼습니다.
transform.Position += diff * stats.Speed * DeltaTime; // diff 를 정규화 안 함
diff 의 길이가 거리라서, 멀리 있을수록 빨리 움직입니다.
등속이 아닙니다. 화면으로 보면 멀리 있던 적이 순식간에 붙습니다.
둘. 그리고 프레임레이트 의존 버그가 딸려 옵니다.
Speed * DeltaTime 이 1을 넘으면 목표를 지나쳐 버리고,
다음 프레임에 반대 방향으로 더 크게 지나칩니다. 발산합니다.
프레임이 낮은 기기에서만 터지는 버그입니다.
셋. 멈출 때 위치를 스냅했습니다.
if (distance < stats.StopDistance) { transform.Position = Target; return; }
이러면 사거리에 들어온 1만 마리가 전부 한 점에 겹칩니다.
return 만 해서 제자리에 서게 하는 게 맞습니다.
검증은 눈이 아니라 숫자로
셋 다 화면만 봐서는 애매합니다. 그래서 이렇게 확인했습니다.
- 최대 거리가 폴링할 때마다 일정하게 감소하는가. 등속이면 그렇습니다
- 최소 거리가 0이 아닌 값에서 고정되는가. 스냅이 없으면 그렇습니다
Speed 를 3으로 뒀으니 초당 3씩 줄어야 하고, 실제로 그렇게 줄었습니다.
숫자로 확인할 수 있는 형태로 만들어 두는 것이 검증의 절반입니다.