Unity 검색

DOTS를 이용한 3인칭 좀비 슈팅 게임 제작

2019년 11월 27일 게임 | 12 분 소요
공유

Is this article helpful for you?

Thank you for your feedback!

유니티는 DOTS(Data-Oriented Tech Stack, 데이터 기반 기술 스택)를 통해 Unity 엔진의 핵심을 새롭게 구축하고 있습니다. 이미 많은 게임 스튜디오들이 ECS(Entity Component System, 엔티티 컴포넌트 시스템), C# 잡 시스템 및 버스트 컴파일러를 사용하며 성능 측면에서 큰 이점을 얻고 있습니다. 유나이트 코펜하겐에서는 Far North Entertainment가 기존 Unity 프로젝트에 DOTS를 도입한 방법을 심층적으로 알아보았습니다.

Far North Entertainment는 엔지니어링 학부 동창생 5명이 공동 소유한 스웨덴의 스튜디오로, 2018년 초 Gear VR용 다운 투 던전(Down to Dungeon)을 출시한 이후 고전 PC 게임 장르인 세기말 좀비 서바이벌 게임을 개발하고 있습니다. 이 게임의 특징은 주인공을 뒤쫓는 수많은 좀비들입니다. Far North Entertainment 팀은 수천 마리의 굶주린 좀비들이 거대한 무리를 이루어 주인공을 향해 움직이는 장면을 연출해야 했습니다.

하지만 아이디어를 프로토타이핑하기 시작하면서 수많은 성능 관련 문제에 부딪혔습니다. 모든 좀비를 생성, 소멸, 업데이트 및 애니메이션화하는 동작 등으로 인해 병목 현상이 발생했고, 오브젝트 풀링 및 애니메이션 인스턴싱과 같은 조치를 취했음에도 동일한 문제가 지속되었습니다.

Far North Entertainment의 CTO 앤더스 에릭슨(Anders Eriksson)은 DOTS를 검토하며 오브젝트 기반 사고방식에서 데이터 기반 사고방식으로 전환하는 방법을 찾아보게 되었습니다. 앤더스는 "사고방식을 전환하기 위해 오브젝트와 그 구조를 생각하기보다는 데이터의 변환 및 접근 방식을 생각했습니다.”라고 밝혔습니다. 즉, 현실을 반영하여 코드를 모델링하지 않아도 되며, 코드가 대부분의 사례에 적용 가능해야 할 필요도 없습니다. 앤더스는 데이터 기반 사고방식으로 전향하고자 하는 개발자들을 위해 다양한 조언을 건넵니다.

"문제가 무엇인지, 구체적인 솔루션에 어떤 데이터가 관련이 있는지 자문해보세요. 동일한 데이터 세트를 반복적으로 동일하게 전환할 계획인가요? CPU 캐시 라인에 관련 데이터를 얼마나 패킹할 수 있나요? 만약 기존 코드를 전환하고 싶다면 캐시 라인을 차지하는 가비지 데이터(garbage data)의 양을 확인해야 합니다. 또한 계산을 멀티 스레드에 나누어 할당하거나 SIMD 명령어를 활용할 수 있는지 등도 확인해야 합니다."

이 콘텐츠는 Targeting Cookies 카테고리를 수락해야만 동영상을 시청할 수 있도록 허용하는 타사 제공업체에서 호스팅합니다. 이러한 제공업체의 비디오를 보려면 쿠키 환경 설정에서 Targeting Cookies 카테고리를 수락하시기 바랍니다.

Far North Entertainment 팀은 Unity 컴포넌트 시스템의 엔티티가 여러 컴포넌트 스트림에 대한 룩업 ID라는 점을 깨달았습니다. 컴포넌트는 단지 데이터에 불과하지만, 시스템은 모든 로직을 포함하며 아키타입(Archetype)이라고 불리는 특정 컴포넌트 시그니처로 엔티티를 필터링합니다. 앤더스는 "ECS를 하나의 SQL 데이터베이스로 취급하면서 이러한 요소들을 시각화했습니다. 각 아키타입은 컴포넌트로 구성된 열과 고유의 엔티티로 구성된 행이 존재하는 테이블로, 엔티티에 대한 동작을 수행할 때 시스템을 통해 이러한 아키타입 테이블을 사용하게 됩니다."라고 설명했습니다.

DOTS 시작하기

앤더스는 나아가 유니티가 Nordeus와 함께 개발하고 유나이트 오스틴에서 공개했던 엔티티 컴포넌트 시스템 기술 자료, ECS 샘플샘플 자료를 참고했으며, 데이터 기반 설계에 관한 일반적인 자료 역시 팀에 큰 도움이 되었습니다. "CppCon 2014에서 마이크 액튼(Mike Acton)이 데이터 기반 설계에 대해 진행한 세션을 통해 데이터 기반 프로그래밍에 눈을 뜨게 되었습니다."

Far North 팀은 새롭게 알게 된 내용을 자사 개발자 블로그에 게시했고, 2019년 9월에는 유나이트 코펜하겐에 참가하여 데이터 기반 사고방식으로 전환한 경험을 공유했습니다.

이 콘텐츠는 Targeting Cookies 카테고리를 수락해야만 동영상을 시청할 수 있도록 허용하는 타사 제공업체에서 호스팅합니다. 이러한 제공업체의 비디오를 보려면 쿠키 환경 설정에서 Targeting Cookies 카테고리를 수락하시기 바랍니다.

이번 블로그 포스팅에서는 위 프레젠테이션의 내용을 기반으로 Far North 팀이 ECS, C# 잡 시스템 및 버스트 컴파일러를 활용한 사례를 더 구체적으로 알아보고, 프로젝트 예시를 통해 다양한 코드를 살펴봅니다.

좀비 데이터 정리

먼저 수많은 엔티티의 이동과 회전을 클라이언트 측에서 보간하는 데 문제가 있었습니다. 팀은 일반적인 EntityView 부모 클래스를 상속한 ZombieView 스크립트를 추상화하는 작업에서 오브젝트 기반의 접근 방식을 활용했습니다. EntityView는 게임 오브젝트에 연결된 MonoBehaviour로, 게임 모델을 시각화하여 표현합니다. 모든 ZombieView는 Update 함수를 통해 각각의 이동 및 회전 보간을 처리했습니다.

하지만 이 방법은 모든 엔티티가 메모리 내 임의의 위치에 무작위로 할당된다는 문제가 있습니다. 수천 개의 엔티티에 액세스하는 경우 CPU가 메모리에 할당된 엔티티 하나하나를 개별적으로 포착해야 하기 때문에 처리 시간이 매우 오래 걸립니다. 데이터를 깔끔하게 정리된 연속적인 블록으로 배치하면 CPU가 모든 데이터를 한꺼번에 캐시 처리할 수 있습니다. 현재 대부분의 CPU는 캐시 사이클당 128비트 또는 256비트를 처리할 수 있습니다.

팀은 병목 현상으로 인한 클라이언트측 성능 저하를 막기 위해 적 캐릭터들을 DOTS로 전환하기로 결정했습니다. 먼저 ZombieView의 Update 함수에서 어떤 부분이 별개의 시스템에 할당되어야 하며 어떤 데이터가 필요한지 식별했습니다. 게임 월드가 2D 그리드로 구성되어 있기 때문에 위치와 회전의 보간이 가장 중요했습니다. 두 개의 float 함수는 좀비의 진행 방향을 나타내고, 마지막 컴포넌트는 적의 서버 위치를 추적하는 TargetPosition 컴포넌트입니다.

[Serializable]
public struct PositionData2D : IComponentData
{
    public float2 Position;
}


[Serializable]
public struct HeadingData2D : IComponentData
{
    public float2 Heading;
}

[Serializable]
public struct TargetPositionData : IComponentData
{
    public float2 TargetPosition;
}

다음으로는 적의 아키타입을 만들었습니다. 아키타입은 간단히 말해 특정 엔티티에 속하는 컴포넌트 세트, 즉 컴포넌트 시그니처입니다.

적에 대한 컴포넌트가 다수 필요하며 그중 일부는 게임 오브젝트에 대한 레퍼런스가 필요하기 때문에, 이 프로젝트에서는 프리팹을 사용하여 아키타입을 정의합니다. ComponentDataProxy에 컴포넌트 데이터를 래핑하면 해당 데이터는 프리팹에 연결될 수 있는 MonoBehaviour로 변환됩니다. EntityManager로 Instantiate를 호출하고 프리팹을 전달하면 프리팹에 연결된 모든 컴포넌트 데이터를 포함하는 엔티티가 생성됩니다. 모든 컴포넌트 데이터는 ArchetypeChunks라고 불리는 16KB의 메모리 청크(chunk)로 보관됩니다.

다음은 아키타입 청크의 컴포넌트 스트림이 정리되는 형태를 시각화한 결과물입니다.

앤더스는 "아키타입 청크는 메모리가 먼저 할당되기 때문에 신규 엔티티를 생성할 때 보통 힙을 새로 할당하지 않아도 된다는 장점이 있습니다. 따라서 엔티티를 생성하려면 아키타입 청크 내 컴포넌트 스트림 측에서 데이터를 작성하기만 하면 됩니다. 청크의 한계를 벗어나는 엔티티를 생성할 때만 새로운 힙 할당을 수행하면 됩니다. 그러면 16KB의 새로운 아키타입 청크가 할당되거나, 동일한 아키타입의 빈 청크가 있는 경우에는 해당 청크가 재사용될 수 있습니다. 그런 다음 새로운 엔티티를 위한 데이터가 새로운 청크의 컴포넌트 스트림에 작성됩니다."라고 설명했습니다.

좀비 멀티스레딩

데이터는 촘촘히 패킹되고 메모리에서 캐시 친화적인 방식으로 배치되었기 때문에, 팀은 C# 잡 시스템의 이점을 손쉽게 활용하여 다중 CPU 코어에서 코드를 병렬로 실행할 수 있었습니다.

다음 단계에서는 PositionData2D, HeadingData2D 및 TargetPositionData 컴포넌트가 있는 모든 아키타입 청크로부터 모든 엔티티를 필터링하는 시스템을 만들었습니다.

이를 위해 앤더스의 팀은 JobComponentSystem을 만들고 OnCreate 함수에서 쿼리를 구성했습니다. 코드는 다음과 같습니다.

private EntityQuery m_Group;

protected override void OnCreate()
{
	base.OnCreate();

	var query = new EntityQueryDesc
	{
		All = new []
		{
			ComponentType.ReadWrite<PositionData2D>(),
			ComponentType.ReadWrite<HeadingData2D>(),
			ComponentType.ReadOnly<TargetPositionData>()
		},
	};

	m_Group = GetEntityQuery(query);
}

이 코드는 위치, 방향 및 타겟이 있는 월드 내 모든 엔티티를 필터링하는 쿼리가 있음을 선언합니다. 다음으로는 멀티 워커 스레드에 골고루 계산을 배포하기 위해 C# 잡 시스템으로 각 프레임에 잡 일정을 예약합니다.

앤더스는 "C# 잡 시스템은 Unity가 코드 단에서 사용하는 시스템과 동일한 방식이기 때문에 실행 스레드가 동일한 CPU 코어를 사용하여 상호 중단을 유발하고 성능 문제를 일으킬 걱정이 없습니다."라고 말했습니다.

수많은 좀비들로 인해 런타임 동안 수많은 아키타입 청크가 쿼리에 매칭되기 떄문에 팀은 IJobChunk를 사용하기로 결정했습니다. IJobChunk는 여러 워커 스레드에 걸쳐 적합한 청크를 배포합니다.

프레임마다 UpdatePositionAndHeadingJob이라고 불리는 새로운 잡이 게임 내 적 캐릭터의 위치 및 회전에 대한 보간을 담당합니다.

잡 예약 코드는 아래와 같습니다.

protected override JobHandle OnUpdate(JobHandle inputDeps)
{
	var positionDataType       = GetArchetypeChunkComponentType<PositionData2D>();
	var headingDataType        = GetArchetypeChunkComponentType<HeadingData2D>();
	var targetPositionDataType = GetArchetypeChunkComponentType<TargetPositionData>(true);

	var updatePosAndHeadingJob = new UpdatePositionAndHeadingJob
	{
		PositionDataType = positionDataType,
		HeadingDataType = headingDataType,
		TargetPositionDataType = targetPositionDataType,
		DeltaTime = Time.deltaTime,
		RotationLerpSpeed = 2.0f,
		MovementLerpSpeed = 4.0f,
	};

	return updatePosAndHeadingJob.Schedule(m_Group, inputDeps);
}

다음은 잡의 선언입니다.

public struct UpdatePositionAndHeadingJob : IJobChunk
{
    public ArchetypeChunkComponentType<PositionData2D> PositionDataType;
    public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType;

    [ReadOnly]
    public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType;

    [ReadOnly] public float DeltaTime;
    [ReadOnly] public float RotationLerpSpeed;
    [ReadOnly] public float MovementLerpSpeed;
}

워커 스레드가 대기열에서 잡을 가져오면 해당 잡의 실행 커널을 호출합니다.

다음은 실행 커널 코드입니다.

public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
{
	var chunkPositionData       = chunk.GetNativeArray(PositionDataType);
	var chunkHeadingData        = chunk.GetNativeArray(HeadingDataType);
	var chunkTargetPositionData = chunk.GetNativeArray(TargetPositionDataType);

	for (int i = 0; i < chunk.Count; i++)
	{
		var target       = chunkTargetPositionData[i];
		var positionData = chunkPositionData[i];
		var headingData  = chunkHeadingData[i];

		float2 toTarget = target.TargetPosition - positionData.Position;
		float distance  = math.length(toTarget);

		headingData.Heading = math.select(
			headingData.Heading,
			math.lerp(headingData.Heading,
					math.normalize(toTarget),
					math.mul(DeltaTime, RotationLerpSpeed)),
			distance > 0.008
		);

		positionData.Position = math.select(
			target.TargetPosition,
			math.lerp(
				positionData.Position,
				target.TargetPosition,
				math.mul(DeltaTime, MovementLerpSpeed)),
			distance <= 1
		);

		chunkPositionData[i] = positionData;
		chunkHeadingData[i]  = headingData;
	}
}

앤더스는 "‘브랜치 예측 실패'라고 하는 현상을 방지하기 위해 브랜치 대신 선택자를 사용했습니다. select 함수가 두 정규식을 평가하여 조건에 부합하는 정규식을 선택하므로, 정규식이 매우 복잡하지 않다면 선택자를 사용하는 것을 추천드립니다. CPU가 브랜치 예측 실패 현상으로부터 복구하기까지 기다리는 것보다 보통 비용이 적게 들기 때문입니다."라고 강조했습니다.

성능 극대화

DOTS 변환 마지막 단계에서는 적 위치 및 방향 보간을 위해 버스트 컴파일러를 활성화했습니다. 앤더스는 이 과정을 매우 쉽게 진행했습니다. "데이터가 서로 인접하여 배열되어 있고, Unity의 새로운 Mathematics 라이브러리를 사용했기 때문에 잡에 BurstCompile 속성을 추가하기만 하면 됐습니다."

[BurstCompile]
public struct UpdatePositionAndHeadingJob : IJobChunk
{
    public ArchetypeChunkComponentType<PositionData2D> PositionDataType;
    public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType;

    [ReadOnly]
    public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType;

    [ReadOnly] public float DeltaTime;
    [ReadOnly] public float RotationLerpSpeed;
    [ReadOnly] public float MovementLerpSpeed;
}

버스트 컴파일러는 SIMD(Single Instruction Multiple Data, 단일 명령 다중 데이터)를 제공합니다. SIMD는 여러 세트의 입력 데이터에 대해 작동하며 단일 명령어로 여러 세트의 출력 데이터를 생산하는 기계 명령어입니다. 이로 인해 128비트 캐시 버스에 적합한 데이터를 채울 공간을 추가로 확보할 수 있습니다.

Far North 팀은 버스트 컴파일러, 캐시 친화적 데이터 레이아웃, 잡 시스템을 함께 사용하여 작업 속도를 크게 향상했습니다. 다음은 전환의 각 단계에서 차이를 측정한 이후에 작성한 성능 비교표입니다.

표에서 알 수 있듯 Far North는 클라이언트측의 좀비 위치 및 방향 보간에 관련된 병목 현상을 완전히 제거했습니다. 이제 데이터가 캐시 친화적인 방식으로 배치되고 캐시 라인이 관련 데이터로만 채워집니다. CPU의 모든 코어가 작동하며 BurstCompiler가 SIMD 명령어를 포함하는 고도로 최적화된 기계어 코드를 출력합니다.

DOTS: 유용한 팁

  • ECS에서 엔티티는 컴포넌트 데이터의 병렬 스트림에 대한 룩업 인덱스에 불과하기 때문에 데이터 스트림을 중심으로 사고해야 합니다.
  • ECS를 관계형 데이터베이스로, 아키타입이라는 테이블 안에 컴포넌트라는 열과 엔티티라는 인덱스(행)가 있다고 생각하면 됩니다.
  • 인접한 배열로 데이터를 정리하여 CPU 캐시와 하드웨어 프리페처(hardware prefetcher)를 활용할 수 있도록 합니다.
  • 문제를 정확히 이해하기 전까지는 반사적으로 오브젝트 계층 구조를 만들거나 일반적인 해결책을 사용하지 않습니다.
  • 가비지 컬렉션을 고려하여 성능이 중요한 영역에 과도한 힙 할당이 발생하지 않도록 하는 대신 Unity의 새로운 네이티브 컨테이너를 사용합니다. 클린업을 수동으로 처리해야 한다는 점에 유의합니다.
  • 추상화에 필요한 비용을 파악하고 가상 함수 호출 오버헤드에 유의합니다.
  • C# 잡 시스템을 통해 CPU의 모든 코어를 활용하도록 합니다.
  • 하드웨어를 더 자세히 이해해야 합니다. 버스트 컴파일러가 SIMD 명령어를 생성하는지 Burst 인스펙터를 사용하여 분석합니다.
  • 캐시 라인을 낭비하지 않도록 합니다. 캐시 라인에 데이터를 패킹할 때, UDP 패킷에 데이터를 패킹하는 모습을 떠올려 봅니다.

앤더스 에릭슨은 이미 프로젝트를 제작 중인 이들을 위해 중요한 팁을 공유합니다. "게임 내에서 성능 문제가 있는 구체적인 영역을 식별하고 DOTS를 적용할 수 있을지 확인해보세요. 전체 코드 베이스를 변환하지 않아도 됩니다!”

향후 계획

앤더스는 "유나이트 세션 중 DOTS 애니메이션, Unity 피직스 및 라이브 링크에 관한 내용이 꽤 인상적이었습니다. 저희는 DOTS를 게임 내 더 많은 영역에 도입할 계획입니다. 더 많은 게임 오브젝트를 ECS 엔티티로 전환할 수 있기를 바라며, 유니티가 기대를 저버리지 않을 거라 믿습니다."라며 마무리했습니다.

Far North Entertainment 팀에 문의사항이 있는 경우 Discord 서버에 접속해보시기 바랍니다.

유나이트 코펜하겐 DOTS 세션을 참고하여 혁신적인 게임 스튜디오들이 DOTS를 활용하는 방법을 살펴보세요. 앞으로 출시될 DOTS 물리, 새로운 전환 워크플로 및 버스트 컴파일러와 같은 DOTS 기반 컴포넌트에도 많은 관심 부탁드립니다.

2019년 11월 27일 게임 | 12 분 소요

Is this article helpful for you?

Thank you for your feedback!