Unity DOTS를 통해 개발자는 최신 프로세서의 잠재력을 최대한 활용하고 고도로 최적화되고 효율적인 게임을 제공할 수 있습니다. 이에 주목할 가치가 있다고 생각합니다.
Unity가 처음으로 데이터 지향 기술 스택(DOTS) 개발을 발표한 지 5년이 넘었습니다. 이제 Unity 2023.3.0f1이 출시되면서 드디어 공식 출시를 보게 되었습니다. 그런데 Unity DOTS가 게임 개발 산업에 그토록 중요한 이유는 무엇이며, 이 기술이 제공하는 이점은 무엇입니까?
여러분, 안녕하세요! 제 이름은 Denis Kondratev이고 MY.GAMES의 Unity 개발자입니다. Unity DOTS가 무엇인지, 탐색할 가치가 있는지 알고 싶다면 지금이 이 흥미로운 주제를 자세히 알아볼 수 있는 절호의 기회입니다. 이 기사에서는 그 내용을 다루겠습니다.
DOTS는 기본적으로 ECS(엔티티 구성 요소 시스템) 아키텍처 패턴을 구현합니다. 이 개념을 단순화하기 위해 다음과 같이 설명하겠습니다. ECS는 엔터티, 구성 요소 및 시스템이라는 세 가지 기본 요소를 기반으로 구축됩니다.
엔터티 자체에는 고유한 기능이나 설명이 없습니다. 대신, 게임 로직, 객체 렌더링, 음향 효과 등에 대한 특정 특성을 부여하는 다양한 구성 요소에 대한 컨테이너 역할을 합니다.
구성 요소 는 다양한 유형으로 제공되며 자체 처리 기능 없이 데이터만 저장합니다.
ECS 프레임워크를 완성하는 것은 구성 요소를 처리하고, 엔터티 생성 및 삭제를 처리하고, 구성 요소 추가 또는 제거를 관리하는 시스템 입니다.
예를 들어, "Space Shooter" 게임을 만들 때 놀이터에는 플레이어의 우주선, 적, 소행성, 전리품 등 여러 개체가 포함됩니다.
이러한 모든 개체는 고유한 기능이 없는 그 자체로 개체로 간주됩니다. 그러나 서로 다른 구성요소를 할당함으로써 고유한 속성을 부여할 수 있습니다.
시연하기 위해 이러한 모든 객체가 게임 필드의 위치를 가지고 있다는 점을 고려하여 객체의 좌표를 유지하는 위치 구성 요소를 만들 수 있습니다. 게다가 플레이어의 우주선, 적, 소행성에 대해 체력 구성요소를 통합할 수 있습니다. 객체 충돌 처리를 담당하는 시스템이 이러한 엔터티의 상태를 관리합니다. 또한 적 유형 구성 요소를 적에 연결하여 적 제어 시스템이 할당된 유형에 따라 적의 행동을 제어할 수 있도록 할 수 있습니다.
이 설명은 단순하고 기초적인 개요를 제공하지만 현실은 다소 더 복잡합니다. 그럼에도 불구하고 나는 ECS의 기본 개념이 명확하다고 믿습니다. 이제 이 접근 방식의 장점을 살펴보겠습니다.
ECS(엔티티 구성 요소 시스템) 접근 방식의 주요 장점 중 하나는 이것이 촉진하는 아키텍처 설계입니다. 객체 지향 프로그래밍 (OOP)은 상속 및 캡슐화와 같은 패턴을 통해 중요한 유산을 전달하며 숙련된 프로그래머라도 개발이 진행되는 동안 구조적 실수를 저지를 수 있으며 이로 인해 장기 프로젝트에서 리팩터링이나 복잡한 논리가 발생할 수 있습니다.
이와 대조적으로 ECS는 간단하고 직관적인 아키텍처를 제공합니다. 모든 것이 자연스럽게 격리된 구성 요소와 시스템에 속하므로 이 접근 방식을 사용하면 더 쉽게 이해하고 개발할 수 있습니다. 초보 개발자라도 오류를 최소화하면서 이 접근 방식을 빠르게 이해할 수 있습니다.
ECS는 복잡한 상속 계층 대신 격리된 구성 요소와 동작 시스템이 생성되는 복합 접근 방식을 따릅니다. 이러한 구성 요소와 시스템은 쉽게 추가하거나 제거할 수 있으므로 엔터티 특성과 동작을 유연하게 변경할 수 있습니다. 이 접근 방식은 코드 재사용성을 크게 향상시킵니다.
ECS의 또 다른 주요 이점은 성능 최적화입니다. ECS에서 데이터는 동일한 데이터 유형이 서로 가깝게 배치되어 연속적이고 최적화된 방식으로 메모리에 저장됩니다. 이는 데이터 액세스를 최적화하고, 캐시 누락을 줄이며, 메모리 액세스 패턴을 개선합니다. 또한, 별도의 데이터 블록으로 구성된 시스템은 다양한 프로세스에서 병렬화하기가 더 쉽기 때문에 기존 접근 방식에 비해 탁월한 성능 향상을 가져옵니다.
Unity DOTS는 Unity에서 ECS 개념을 구현하는 Unity Technologies에서 제공하는 기술 세트를 포함합니다. 여기에는 게임 개발의 다양한 측면을 향상시키기 위해 설계된 여러 패키지가 포함되어 있습니다. 이제 그 중 몇 가지를 다루겠습니다.
DOTS의 핵심은 익숙한 MonoBehaviour 및 GameObject에서 엔터티 구성 요소 시스템 접근 방식으로의 전환을 용이하게 하는 엔터티 패키지입니다. 이 패키지는 DOTS 기반 개발의 기초를 형성합니다.
Unity Physics 패키지는 게임에서 물리를 처리하는 새로운 접근 방식을 도입하여 병렬 계산을 통해 놀라운 속도를 달성합니다.
또한 Unity용 Havok Physics 패키지를 사용하면 최신 Havok Physics 엔진과 통합할 수 있습니다. 이 엔진은 고성능 충돌 감지 및 물리적 시뮬레이션을 제공하여 The Legend of Zelda: Breath of the Wild, Doom Eternal, Death Stranding, Mortal Kombat 11 등과 같은 인기 게임을 구동합니다.
Entities Graphics 패키지는 DOTS 렌더링에 중점을 둡니다. 이를 통해 렌더링 데이터를 효율적으로 수집할 수 있으며 URP(유니버설 렌더 파이프라인) 또는 HDRP(고화질 렌더 파이프라인)와 같은 기존 렌더 파이프라인과 원활하게 작동합니다.
한 가지 더, Unity는 Netcode라는 네트워킹 기술도 적극적으로 개발해 왔습니다. 여기에는 낮은 수준의 멀티플레이어 게임 개발을 위한 Unity Transport, 기존 접근 방식을 위한 GameObject용 Netcode, 그리고 DOTS 원칙에 부합하는 주목할 만한 엔터티용 Unity Netcode 패키지가 포함되어 있습니다. 이러한 패키지는 비교적 새로운 패키지이며 앞으로도 계속 발전할 것입니다.
DOTS와 밀접하게 관련된 여러 기술을 DOTS 프레임워크 내외에서 사용할 수 있습니다. Job System 패키지는 병렬 계산을 통해 코드를 작성하는 편리한 방법을 제공합니다. 이는 작업을 작업이라는 작은 덩어리로 나누어 자체 데이터에 대해 계산을 수행하는 것을 중심으로 이루어집니다. 작업 시스템은 효율적인 실행을 위해 이러한 작업을 스레드 전체에 균등하게 배포합니다.
코드 안전성을 보장하기 위해 잡 시스템은 블릿 가능한 데이터 유형의 처리를 지원합니다. Blittable 데이터 형식은 관리되는 메모리와 관리되지 않는 메모리에서 동일한 표현을 가지며 관리 코드와 비관리 코드 간에 전달될 때 변환이 필요하지 않습니다. blittable 유형의 예로는 byte, sbyte, short, ushort, int, uint, long, ulong, float, double, IntPtr 및 UIntPtr이 있습니다. blittable 기본 유형의 1차원 배열과 blittable 유형만 포함하는 구조도 blittable로 간주됩니다.
그러나 blittable 유형의 변수 배열을 포함하는 유형은 blittable 자체로 간주되지 않습니다. 이러한 제한 사항을 해결하기 위해 Unity는 작업에 사용할 수 있는 관리되지 않는 데이터 구조 세트를 제공하는 컬렉션 패키지를 개발했습니다. 이러한 컬렉션은 구조화되어 있으며 Unity 메커니즘을 사용하여 관리되지 않는 메모리에 데이터를 저장합니다. Disposal() 메서드를 사용하여 이러한 컬렉션의 할당을 취소하는 것은 개발자의 책임입니다.
또 다른 중요한 패키지는 버스트 컴파일러(Burst Compiler) 입니다. 이 패키지는 작업 시스템과 함께 사용하여 고도로 최적화된 코드를 생성할 수 있습니다. 특정 코드 사용 제한이 있기는 하지만 버스트 컴파일러는 상당한 성능 향상을 제공합니다.
앞서 언급한 것처럼 잡 시스템과 버스트 컴파일러는 DOTS의 직접적인 구성 요소는 아니지만 효율적이고 빠른 병렬 계산을 프로그래밍하는 데 귀중한 지원을 제공합니다. 실제 예를 사용하여 기능을 테스트해 보겠습니다.
private void SimulateStep() { Profiler.BeginSample(nameof(SimulateStep)); for (var i = 0; i < width; i++) { for (var j = 0; j < height; j++) { var aliveNeighbours = CountAliveNeighbours(i, j); var index = i * height + j; var isAlive = aliveNeighbours switch { 2 => _cellStates[index], 3 => true, _ => false }; _tempResults[index] = isAlive; } } _tempResults.CopyTo(_cellStates); Profiler.EndSample(); } private int CountAliveNeighbours(int x, int y) { var count = 0; for (var i = x - 1; i <= x + 1; i++) { if (i < 0 || i >= width) continue; for (var j = y - 1; j <= y + 1; j++) { if (j < 0 || j >= height) continue; if (_cellStates[i * width + j]) { count++; } } } return count; }
계산에 소요되는 시간을 측정하기 위해 Profiler에 마커를 추가했습니다. 셀의 상태는 _cellStates 라는 1차원 배열에 저장됩니다. 처음에는 임시 결과를 _tempResults 에 쓴 다음 계산이 완료되면 _cellStates 에 다시 복사합니다. 최종 결과를 _cellStates 에 직접 기록하면 후속 계산에 영향을 미치기 때문에 이 접근 방식이 필요합니다.
1000x1000 셀의 필드를 생성하고 프로그램을 실행하여 성능을 측정했습니다. 결과는 다음과 같습니다.
결과에서 볼 수 있듯이 계산에는 380ms가 걸렸습니다.
이제 성능 향상을 위해 잡 시스템과 버스트 컴파일러를 적용해 보겠습니다. 먼저 Conway의 Game of Life 알고리즘을 실행하는 작업을 생성합니다.
public struct SimulationJob : IJobParallelFor { public int Width; public int Height; [ReadOnly] public NativeArray<bool> CellStates; [WriteOnly] public NativeArray<bool> TempResults; public void Execute(int index) { var i = index / Height; var j = index % Height; var aliveNeighbours = CountAliveNeighbours(i, j); var isAlive = aliveNeighbours switch { 2 => CellStates[index], 3 => true, _ => false }; TempResults[index] = isAlive; } private int CountAliveNeighbours(int x, int y) { var count = 0; for (var i = x - 1; i <= x + 1; i++) { if (i < 0 || i >= Width) continue; for (var j = y - 1; j <= y + 1; j++) { if (j < 0 || j >= Height) continue; if (CellStates[i * Width + j]) { count++; } } } return count; } }
CellStates 필드에 [ReadOnly] 속성을 할당하여 모든 스레드에서 배열의 모든 값에 대한 무제한 액세스를 허용했습니다. 단, [WriteOnly] 속성을 갖는 TempResults 필드의 경우 Execute(int index) 메서드에 지정된 인덱스를 통해서만 쓰기가 가능합니다. 다른 인덱스에 값을 쓰려고 하면 경고가 생성됩니다. 이는 다중 스레드 모드에서 작업할 때 데이터 안전을 보장합니다.
이제 일반 코드에서 작업을 실행해 보겠습니다.
private void SimulateStepWithJob() { Profiler.BeginSample(nameof(SimulateStepWithJob)); var job = new SimulationJob { Width = width, Height = height, CellStates = _cellStates, TempResults = _tempResults }; var jobHandler = job.Schedule(width * height, 4); jobHandler.Complete(); job.TempResults.CopyTo(_cellStates); Profiler.EndSample(); }
필요한 모든 데이터를 복사한 후 Schedule() 메서드를 사용하여 작업 실행을 예약합니다. 이 예약은 계산을 즉시 실행하지 않는다는 점에 유의하는 것이 중요합니다. 이러한 작업은 기본 스레드에서 시작되고 실행은 여러 스레드에 분산된 작업자를 통해 발생합니다. 작업이 완료될 때까지 기다리려면 jobHandler.Complete() 를 사용합니다. 그런 다음에만 얻은 결과를 _cellStates 에 다시 복사할 수 있습니다.
속도를 측정해 보겠습니다.
실행 속도가 거의 10배 증가했으며 실행 시간은 이제 약 42ms입니다. 프로파일러 창에서 작업 부하가 17명의 작업자에게 분산된 것을 볼 수 있습니다. 이 숫자는 10개 코어와 20개 스레드를 갖춘 Intel® Core™ i9-10900인 테스트 환경의 프로세서 스레드 수보다 약간 적습니다. 코어 수가 적은 프로세서에 따라 결과가 다를 수 있지만 프로세서 성능을 최대한 활용할 수 있습니다.
하지만 이것이 전부가 아닙니다. 이제 상당한 코드 최적화를 제공하지만 특정 제한 사항이 있는 버스트 컴파일러를 활용할 때입니다. 버스트 컴파일러를 활성화하려면 SimulationJob 에 [BurstCompile] 속성을 추가하기만 하면 됩니다.
[BurstCompile] public struct SimulationJob : IJobParallelFor { ... }
다시 측정해 보겠습니다.
결과는 가장 낙관적인 기대조차 뛰어 넘었습니다. 속도는 초기 결과에 비해 거의 200배 증가했습니다. 이제 1백만 개의 셀에 대한 계산 시간은 2ms를 넘지 않습니다. Profiler에서는 버스트 컴파일러로 컴파일된 코드에 의해 실행되는 부분이 녹색으로 강조 표시됩니다.
멀티스레드 계산의 사용이 항상 필요한 것은 아니며 버스트 컴파일러의 활용이 항상 가능한 것은 아니지만 멀티 코어 아키텍처를 향한 프로세서 개발의 일반적인 추세를 관찰할 수 있습니다. 이는 우리가 그들의 모든 힘을 활용할 준비가 되어 있어야 함을 의미합니다. ECS, 특히 Unity DOTS는 이 패러다임과 완벽하게 일치합니다.
저는 Unity DOTS가 최소한 주목할 만하다고 생각합니다. 모든 경우에 가장 적합한 솔루션은 아닐 수 있지만 ECS는 많은 게임에서 그 가치를 입증할 수 있습니다.
데이터 중심의 멀티스레드 접근 방식을 갖춘 Unity DOTS 프레임워크는 Unity 게임의 성능을 최적화할 수 있는 엄청난 잠재력을 제공합니다. Entity Component System 아키텍처를 채택하고 Job System 및 Burst Compiler와 같은 기술을 활용함으로써 개발자는 새로운 수준의 성능과 확장성을 실현할 수 있습니다.
게임 개발이 계속 발전하고 하드웨어가 발전함에 따라 Unity DOTS를 수용하는 것이 점점 더 중요해지고 있습니다. 이를 통해 개발자는 최신 프로세서의 잠재력을 최대한 활용하고 고도로 최적화되고 효율적인 게임을 제공할 수 있습니다. Unity DOTS가 모든 프로젝트에 이상적인 솔루션은 아닐 수 있지만, 성능 중심 개발과 확장성을 추구하는 사람들에게는 의심의 여지 없이 엄청난 가능성을 제공합니다.
Unity DOTS는 성능을 향상하고 병렬 계산을 지원하며 멀티 코어 처리의 미래를 수용함으로써 게임 개발자에게 큰 이점을 줄 수 있는 강력한 프레임워크입니다. 최신 하드웨어를 최대한 활용하고 Unity 게임의 성능을 최적화하기 위해 채택을 살펴보고 고려해 볼 가치가 있습니다.