이 섹션에서는 게임에서 사용하는 실제 스크립트와 메서드를 최적화하려 할 때 어떻게 착수하면 될지 설명합니다. 또한 최적화가 가능한 이유 및 특정 상황에서 이러한 최적화를 적용했을 때 얻는 장점에 대해 자세하게 설명합니다.
프로젝트가 원활히 돌아가도록 보장해주는 체크리스트 같은 것은 없습니다. 느린 프로젝트를 최적화하기 위해서는 지나치게 시간을 잡아먹는 특정 원인을 프로파일링해야 합니다. 프로파일링을 하지 않거나 프로파일러에서 얻은 결과물을 철저히 이해하지 않고서 최적화를 하려는 것은 마치 눈을 가리고 최적화하려는 것과 마찬가지입니다.
내부 프로파일러를 사용하면 물리, 스크립트, 렌더링 중 어떤 프로세스가 게임 속도를 저하시키는지 알아낼 수 있으나, 실제로 어디가 원인인지를 찾아내기 위해 특정 스크립트 및 메서드 내부까지 파고들 수는 없습니다. 그러나 특정 기능을 활성화/비활성화하는 스위치를 게임에서 만들어 둠으로써, 가장 문제가 되는 부분이 어디인지 그 범위를 상당히 좁힐 수 있습니다. 예를 들어, 적 캐릭터의 AI 스크립트를 제거하고 나서 프레임 속도가 두 배가 되었다면, 해당 스크립트 또는 스크립트가 게임에 가져온 무엇인가를 최적화해야 한다는 것을 알 수 있습니다. 여기서 유일한 문제는 문제점을 찾아낼 때까지 여러 가지 다른 것을 시도해 보아야 한다는 점입니다.
모바일 디바이스에서의 프로파일링에 대한 자세한 정보는 프로파일링 섹션을 참조하십시오.
처음부터 빨리 실행되도록 구현하려고 하면 어느 정도 위험이 따릅니다. 너무 느려서 나중에 삭제하거나 다른 것으로 대체하는 일에 많은 시간을 낭비하는 것도 문제이지만 최적화하지 않아도 충분히 빠르게 만드느라 시간을 낭비하는 것도 문제이기 때문입니다. 이러한 측면에서 결정을 잘 내리려면 직관과 하드웨어에 대한 풍부한 지식이 필요합니다. 특히 모든 게임은 서로 다르고, 어떤 게임에서 매우 중요한 최적화가 다른 게임에서는 아무런 효과가 없을 수도 있기 때문입니다.
오브젝트 풀링(Object Pooling)은 스크립트 최적화 방법론 소개에서 좋은 게임플레이와 좋은 코드 디자인 사이의 교차점의 예로 든 바 있습니다. 잠깐만 사용하는 오브젝트에 오브젝트 풀링을 사용하면 오브젝트를 생성했다가 삭제하는 것에 비해 빠른데, 메모리 할당을 더 간단하게 할 수 있으며 동적 메모리 할당 오버헤드와 가비지 컬렉션(GC)을 없앨 수 있기 때문입니다.
대부분의 스크립트 언어가 그러하듯, Unity에서 작성하는 스크립트는 자동 메모리 관리를 사용합니다. 이와 대조적으로, C 및 C++와 같은 로우 레벨 언어는 수동 메모리 할당을 사용하며, 프로그래머가 메모리 주소에서 직접 읽고 쓰는 것을 허용하고 결과적으로 생성한 모든 오브젝트를 삭제하는 것은 프로그래머의 책임입니다. 예를 들어 C++에서 오브젝트를 생성한다면, 사용이 끝난 후에는 오브젝트가 차지하고 있던 메모리를 수동으로 해제해야 합니다. 스크립팅 언어에서는 단지 objectReference = null;
이라고만 명시하면 됩니다.
참고: GameObject myGameObject;
또는 var myGameObject : GameObject;
와 같은 게임 오브젝트 변수가 있다고 할 때, myGameObject = null;
이라고 하면 왜 오브젝트가 삭제되지 않을까요?
Destroy(myGameObject);
를 호출하면 해당 레퍼런스를 제거하고 오브젝트를 삭제합니다.그러나 Unity가 알지 못하는 오브젝트, 예를 들어 어느 것으로부터도 상속받지 않는 클래스의 인스턴스(이에 반해 대부분의 클래스 또는 “스크립트 컴포넌트”는 MonoBehaviour로부터 상속받음)를 생성한 다음 오브젝트를 레퍼런스하는 변수를 null로 설정하면, 해당 스크립트와 Unity에 관한 오브젝트를 잃어버리게 됩니다. 해당 오브젝트에 접근하거나 다시 볼 수는 없지만 메모리에는 남아 있습니다. 시간이 지나면 가비지 컬렉터가 실행되어 메모리에 남아 있지만 어디서도 레퍼런스되지 않는 오브젝트를 제거합니다. 이렇게 할 수 있는 이유는 메모리의 각 블록에 대한 레퍼런스의 수가 내부적으로 계속 추적되고 있기 때문입니다. 스크립팅 언어가 C++보다 느린 이유 중 하나가 바로 이것 때문입니다.
어떤 오브젝트가 생성될 때마다 메모리가 할당됩니다. 하지만 코드에서는 대부분 이러한 사실을 인지하지 못한 채 오브젝트를 생성합니다.
Debug.Log("boo" + "hoo");
는 오브젝트를 생성합니다.
""
대신 System.String.Empty
을 사용해야 합니다.즉시 모드 GUI(UnityGUI)는 느리므로 성능이 문제가 될 때에는 절대 사용해서는 안 됩니다.
클래스와 구조체의 차이:
클래스는 오브젝트이며 레퍼런스로서 작동합니다. Foo가 클래스이고 코드가 다음과 같다면
Foo foo = new Foo();
MyFunction(foo);
MyFunction은 힙에 할당된 원본 Foo 오브젝트를 가리키는 레퍼런스를 넘겨받게 됩니다. MyFunction에서 foo에 변경이 가해질 경우 foo가 레퍼런스되는 모든 곳에서 이 변경된 내용을 볼 수 있게 됩니다.
클래스는 데이터이며 데이터로서 작용합니다. Foo가 구조체이고 코드가 다음과 같다면
Foo foo = new Foo();
MyFunction(foo);
MyFunction은 foo의 복사본을 넘겨받게 됩니다. foo는 절대로 힙에 할당되지 않으며 가비지 컬렉션의 대상이 되지도 않습니다. MyFunction이 넘겨받은 foo 복사본을 수정한다고 하더라도, 다른 foo에는 영향을 주지 않습니다.
결론적으로 인스턴스화 및 제거를 여러 차례 사용하면 가비지 컬렉터가 해야 할 일이 많아집니다. 그리고 이렇게 하면 게임플레이에 “장애”가 생기게 됩니다. 자동 메모리 관리 페이지에서 설명하듯이, 인스턴스화 및 제거와 관련된 공통 성능 장애 요소를 피할 수 있는 다른 방법이 있습니다. 예를 들어 아무 일도 진행되지 않을 때 가비지 컬렉터를 수동으로 작동시키는 방법이 있고, 또는 가비지 컬렉터를 매우 자주 작동시켜서, 사용하지 않는 메모리가 대량으로 쌓이지 않도록 방지하는 방법도 있습니다.
또 다른 이유는, 특정 프리팹이 최초로 인스턴스화될 때 때로는 RAM에 추가적인 것이 로드되어야 하며, GPU에 텍스처 및 메시가 업로드되어야 한다는 점입니다. 또한 장애를 유발할 수 있으며, 오브젝트 풀링을 사용하면 게임플레이 도중이 아니라 레벨 로드 중에 일이 이루어집니다.
인형을 조종하는 캐릭터가 하나 있고, 캐릭터가 꼭두각시 인형이 무한대로 나오는 상자를 들고 있다고 상상해 봅시다. 스크립트에서 캐릭터가 나타나도록 할 때마다 캐릭터는 상자에서 새 인형을 꺼냅니다. 그리고 캐릭터가 무대를 떠날 때마다 캐릭터는 현재의 인형을 던집니다. 오브젝트 풀링을 사용하면, 쇼가 시작되기 전에 모든 인형을 즉시 꺼내는 것과 인형이 보이면 안 되는 순간마다 인형을 무대 뒤의 탁자에 놓아두는 것과 같습니다.
한 가지 문제는 풀을 생성하면 다른 목적으로 사용할 가용 힙 메모리의 양이 줄어든다는 점입니다. 따라서 현재 막 생성한 풀 외에도 메모리를 계속 할당한다면, 가비지 컬렉션이 더욱 자주 실행될 수 있습니다. 뿐만 아니라 가비지 컬렉션에 걸리는 시간은 살아있는 오브젝트의 수에 비례하여 증가하기 때문에 매번 더 느려질 수 있습니다. 이 문제를 고려해 볼 때, 너무 큰 풀을 할당하거나 또는 풀에 있는 오브젝트가 한동안 필요가 없는 상황에서 풀을 활성화하여 유지한다면 성능에 지장이 생기게 됩니다. 게다가, 오브젝트 중에는 오브젝트 풀링에 비협조적인 오브젝트 타입이 여러 가지 있습니다. 예를 들어, 상당한 시간 동안 지속되는 주문 효과를 포함하는 게임, 또는 많은 수의 적이 나타나지만 게임 진행에 따라 서서히 죽는 게임이 있을 수 있습니다. 이러한 경우 오브젝트 풀의 성능 오버헤드는 다른 이점을 뛰어넘기 때문에, 오브젝트 풀을 사용해서는 안 됩니다.
다음과 같이 간단한 발사체용의 스크립트를 나란히 비교해 보겠습니다. 하나는 인스턴스화를, 하나는 오브젝트 풀링을 사용합니다.
// GunWithInstantiate.js // GunWithObjectPooling.js
#pragma strict #pragma strict
var prefab : ProjectileWithInstantiate; var prefab : ProjectileWithObjectPooling;
var maximumInstanceCount = 10;
var power = 10.0; var power = 10.0;
private var instances : ProjectileWithObjectPooling[];
static var stackPosition = Vector3(-9999, -9999, -9999);
function Start () {
instances = new ProjectileWithObjectPooling[maximumInstanceCount];
for(var i = 0; i < maximumInstanceCount; i++) {
// place the pile of unused objects somewhere far off the map
instances[i] = Instantiate(prefab, stackPosition, Quaternion.identity);
// disable by default, these objects are not active yet.
instances[i].enabled = false;
}
}
function Update () { function Update () {
if(Input.GetButtonDown("Fire1")) { if(Input.GetButtonDown("Fire1")) {
var instance : ProjectileWithInstantiate = var instance : ProjectileWithObjectPooling = GetNextAvailiableInstance();
Instantiate(prefab, transform.position, transform.rotation); if(instance != null) {
instance.velocity = transform.forward * power; instance.Initialize(transform, power);
} }
} }
}
function GetNextAvailiableInstance () : ProjectileWithObjectPooling {
for(var i = 0; i < maximumInstanceCount; i++) {
if(!instances[i].enabled) return instances[i];
}
return null;
}
// ProjectileWithInstantiate.js // ProjectileWithObjectPooling.js
#pragma strict #pragma strict
var gravity = 10.0; var gravity = 10.0;
var drag = 0.01; var drag = 0.01;
var lifetime = 10.0; var lifetime = 10.0;
var velocity : Vector3; var velocity : Vector3;
private var timer = 0.0; private var timer = 0.0;
function Initialize(parent : Transform, speed : float) {
transform.position = parent.position;
transform.rotation = parent.rotation;
velocity = parent.forward * speed;
timer = 0;
enabled = true;
}
function Update () { function Update () {
velocity -= velocity * drag * Time.deltaTime; velocity -= velocity * drag * Time.deltaTime;
velocity -= Vector3.up * gravity * Time.deltaTime; velocity -= Vector3.up * gravity * Time.deltaTime;
transform.position += velocity * Time.deltaTime; transform.position += velocity * Time.deltaTime;
timer += Time.deltaTime; timer += Time.deltaTime;
if(timer > lifetime) { if(timer > lifetime) {
transform.position = GunWithObjectPooling.stackPosition;
Destroy(gameObject); enabled = false;
} }
} }
크고 복잡한 게임이라면 모든 프리팹에서 동작하는 일반적인 해결책을 원할 것입니다.
“수집 가능한 수백 개의 동전이 회전하면서 동적으로 빛나고 화면에 동시에 나타나는 것”의 예제가 스크립트 방법론 섹션에서 제공되었습니다. 예제를 통해 스크립트 코드, 파티클 시스템과 같은 Unity 컴포넌트, 커스텀 셰이더를 사용하여 저사양 모바일 하드웨어에 부담을 주지 않으면서도 놀라울 정도로 멋진 효과를 만들어내는 방법을 보여줄 것입니다.
수많은 동전이 떨어지고 튕기고 회전하는 2D 횡스크롤 게임에서 효과가 나타난다고 상상해 봅시다. 동전은 점 광원에 의해 동적으로 빛납니다. 게임을 훨씬 인상적으로 만들기 위해 동전을 반짝이게 하는 광원을 만들어내려 할 것입니다.
하드웨어가 고성능이라면 이 문제를 해결하는 표준 접근 방식을 취할 수 있을 것입니다. 모든 동전을 오브젝트로 만들고, 버텍스 릿, 포워드 또는 디퍼드 라이팅 중에 하나를 가지고 셰이드하고, 이미지 이펙트로 상단에 글로우 효과를 추가해서 밝게 반사하는 동전이 주변에 광원을 뿌리도록 하면 됩니다.
그러나 모바일 하드웨어에서 이렇게 많은 오브젝트를 만들면 크게 부담이 되며, 글로우 효과는 거의 불가능합니다. 그러면 어떻게 해야 합니까?
모두 비슷한 방식으로 움직이며 플레이어가 주의 깊게 살펴볼 일이 절대로 없는 오브젝트를 여러 개 표시하려면, 파티클 시스템을 사용하여 순식간에 대량의 오브젝트를 렌더링할 수도 있습니다. 이러한 기법을 적용할 수 있는 전형적인 몇 가지 사례는 다음과 같습니다.
애니메이션 스프라이트 파티클 시스템의 생성을 돕는 스프라이트 패커 무료 에디터 확장 프로그램이 있습니다. 스프라이트 패커는 오브젝트의 프레임을 텍스처에 렌더링하며, 그 후 이를 파티클 시스템에서 애니메이션 스프라이트 시트로 사용할 수 있습니다. 예제에서는 회전하는 동전에 스프라이트 패커를 사용할 것입니다.
스프라이트 패커 프로젝트에는 바로 이 문제를 해결하기 위한 해결책을 보여주는 예제가 포함되어 있습니다.
컴퓨팅 성능이 낮은 상황에서 눈부신 효과를 얻기 위해 여러 종류의 에셋을 사용합니다.
예제에는 readme 파일이 포함되어 있습니다. 파일은 시스템이 동작하는 방법과 원리를 설명하고, 필요한 기능 및 구현 방법을 결정하는 데 사용된 프로세스를 서술합니다. 이것이 해당 파일입니다.
여기서의 문제는 “화면 속에서 수백 개의 동전이 회전하고 동적으로 빛나며 수집 가능한 상태를 한번에 구현”하는 것이라고 정의할 수 있습니다.
단순히 접근하자면 동전 프리팹을 여러 개 인스턴스화하면 되겠지만, 그 대신 파티클을 사용하여 동전을 렌더링하려 합니다. 그러나 이를 위해서는 넘어야 할 여러 과제가 있습니다.
예제의 최종 목표 또는 “이야기의 교훈”은, 게임에 정말 필요한 요소가 있으나 기존의 방식으로 요소를 성취하고자 할 때 지연이 발생한다면, 할 수 없다는 의미가 아니라 그저 더 빨리 실행될 수 있도록 시스템에 조작을 더 가해야 합니다.
수백 개 또는 수천 개의 동적 오브젝트가 있는 상황에 적용할 수 있는 특정한 스크립트 최적화 기법이 있습니다. 이 기법을 게임에서 모든 스크립트에 적용하겠다는 것은 끔찍한 생각입니다. 기법은 런타임에 굉장히 많은 수의 오브젝트를 다루는 대규모 스크립트용 툴 및 디자인 가이드라인에 사용해야 합니다.
컴퓨터학에서 연산의 순서는 O(n)으로 표기하며, 어떤 연산이 적용되는 오브젝트의 수(n)가 증가함에 따라 연산이 계산되어야 하는 횟수가 증가하는 방식을 나타냅니다.
예를 들어, 기본 정렬 알고리즘을 생각해 봅시다. n개의 숫자가 있고 이를 오름차순으로 정렬하고자 합니다.
void sort(int[] arr) {
int i, j, newValue;
for (i = 1; i < arr.Length; i++) {
// record
newValue = arr[i];
//shift everything that is larger to the right
j = i;
while (j > 0 && arr[j - 1] > newValue) {
arr[j] = arr[j - 1];
j--;
}
// place recorded value to the left of large values
arr[j] = newValue;
}
}
중요한 점은 이 때 두 개의 루프가 필요하며, 한 루프가 다른 루프 안에 중첩된다는 점입니다.
for (i = 1; i < arr.Length; i++) {
...
j = i;
while (j > 0 && arr[j - 1] > newValue) {
...
j--;
}
}
알고리즘을 실행할 때 가장 최악의 경우를 상정해 봅시다. 바로 내림차순으로 정렬된 숫자가 입력된 경우입니다. 이 경우, 안쪽 루프는 j회 실행됩니다. i가 1에서 arr.Length–1까지 변할 때, j는 평균적으로 arr.Length/2가 됩니다. 이 경우에는 O(n)에 있어서 arr.Length가 바로 n이므로, 전체적으로 내부 루프는 *nn/2회, 또는 n1/2회 돌게 됩니다. 그러나 O(n)을 논할 때 1/2와 같은 상수는 제외합니다. 여기서는 실제 연산 횟수를 따지자는 것이 아니라, 연산 횟수가 어떤 식으로 증가하는지에 대해 논의하고자 하는 것이기 때문입니다. 따라서 알고리즘은 O(n1)**입니다. 데이터 세트가 큰 경우 연산 횟수가 기하급수적으로 증가할 수 있기 때문에 연산의 차수는 매우 중요한 의미를 가집니다.
게임에서 O(n2) 연산이 일어날 수 있는 예로, 100명의 적이 있고 각각의 적 AI가 다른 모든 적의 움직임을 고려하는 상황이 있습니다. 아마도 더 빠른 방법은 맵을 셀로 나누고, 각 적의 움직임을 최근접 셀로 기록하고, 가장 근접한 몇 개의 셀을 각 적이 샘플링하도록 합니다. 이렇게 하면 O(n) 연산이 됩니다.
게임에 100명의 적이 있고 모두 플레이어를 향해 움직인다고 생각해 봅시다.
// EnemyAI.js
var speed = 5.0;
function Update () {
transform.LookAt(GameObject.FindWithTag("Player").transform);
// this would be even worse:
//transform.LookAt(FindObjectOfType(Player).transform);
transform.position += transform.forward * speed * Time.deltaTime;
}
동시에 달려드는 적의 수가 충분히 많으면 느려질 수도 있습니다. 알려져 있지 않은 사실은, MonoBehaviour(transform, renderer, audio 등)에 있는 모든 컴포넌트 액세서는 각자 해당되는 GetComponent(Transform)와 동일한 동작을 하며 실제로는 약간 더 느리다는 점입니다. GameObject.FindWithTag는 최적화되어 왔으나, 일부 경우에는(예를 들어 내부 루프에서 또는 많은 수의 인스턴스를 실행하는 스크립트에서는) 스크립트가 조금 느릴 수도 있습니다.
더 개선된 스크립트 버전은 다음과 같습니다.
// EnemyAI.js
var speed = 5.0;
private var myTransform : Transform;
private var playerTransform : Transform;
function Start () {
myTransform = transform;
playerTransform = GameObject.FindWithTag("Player").transform;
}
function Update () {
myTransform.LookAt(playerTransform);
myTransform.position += myTransform.forward * speed * Time.deltaTime;
}
초월 함수(Mathf.Sin, Mathf.Pow 등), 나눗셈, 제곱근 연산 등은 곱셈 연산 대비 100배 정도의 시간을 소모합니다. 거시적으로 보았을 때는 거의 시간이 걸리지 않으나, 이러한 함수를 프레임당 수천 번씩 호출한다면 누적 시간이 상당해집니다.
가장 일반적인 경우가 바로 벡터 정규화입니다. 동일 벡터를 반복해서 계속 정규화하는 대신, 한 번만 정규화를 하고 그 결과를 추후 사용할 수 있도록 캐싱하는 것을 고려해 볼 수 있습니다.
벡터의 길이도 사용하고 정규화도 해야 한다면, .normalized 프로퍼티를 사용하는 것보다는 벡터에 길이의 역을 곱해서 정규화 벡터를 얻는 것이 더 빠릅니다.
거리를 비교한다면, 실제 거리를 비교할 필요가 없습니다. 대신 .sqrMagnitude 프로퍼티를 사용하여 거리의 제곱을 비교하고 제곱근을 저장하면 됩니다.
또 다른 예제로 상수 c로 반복해서 나눗셈 연산을 해야 할 경우, 대신 그 역을 곱하면 됩니다. 1.1.0/c 연산을 통해 역을 먼저 구해야 합니다.
비용이 크게 드는 처리를 해야 할 경우, 처리를 덜 하는 방향으로 최적화하고 결과를 캐싱하는 방법이 있습니다. 예를 들어, 레이캐스트를 사용하는 발사체 스크립트를 생각해 봅시다.
// Bullet.js
var speed = 5.0;
function FixedUpdate () {
var distanceThisFrame = speed * Time.fixedDeltaTime;
var hit : RaycastHit;
// every frame, we cast a ray forward from where we are to where we will be next frame
if(Physics.Raycast(transform.position, transform.forward, hit, distanceThisFrame)) {
// Do hit
} else {
transform.position += transform.forward * distanceThisFrame;
}
}
즉시 개선할 수 있는 부분이 있습니다. 스크립트에서 FixedUpdate를 Update로, fixedDeltaTime을 deltaTime으로 대체합니다. FixedUpdate는 물리 업데이트를 나타내며, 프레임 업데이트보다 더 자주 일어납니다. 더 나아가서, 레이캐스팅이 오직 매 n초마다 일어나도록 만들어 봅시다. n이 작아지면 일시적으로 해상도가 좋아지며, n이 커지면 성능이 향상됩니다. 타겟이 크고 느릴수록, 일시적인 앨리어싱이 발생하기 전까지 n 값을 크게 할 수 있습니다(플레이어가 타겟을 맞춘 지점에서 지연이 나타나지만 폭발은 n초 전에 타겟이 있던 곳에 일어나거나, 또는 플레이어가 타겟을 맞추지만 발사체가 뚫고 지나갑니다).
// BulletOptimized.js
var speed = 5.0;
var interval = 0.4; // this is 'n', in seconds.
private var begin : Vector3;
private var timer = 0.0;
private var hasHit = false;
private var timeTillImpact = 0.0;
private var hit : RaycastHit;
// set up initial interval
function Start () {
begin = transform.position;
timer = interval+1;
}
function Update () {
// don't allow an interval smaller than the frame.
var usedInterval = interval;
if(Time.deltaTime > usedInterval) usedInterval = Time.deltaTime;
// every interval, we cast a ray forward from where we were at the start of this interval
// to where we will be at the start of the next interval
if(!hasHit && timer >= usedInterval) {
timer = 0;
var distanceThisInterval = speed * usedInterval;
if(Physics.Raycast(begin, transform.forward, hit, distanceThisInterval)) {
hasHit = true;
if(speed != 0) timeTillImpact = hit.distance / speed;
}
begin += transform.forward * distanceThisInterval;
}
timer += Time.deltaTime;
// after the Raycast hit something, wait until the bullet has traveled
// about as far as the ray traveled to do the actual hit
if(hasHit && timer > timeTillImpact) {
// Do hit
} else {
transform.position += transform.forward * speed * Time.deltaTime;
}
}
함수를 호출하는 것만으로도 그 자체에서 약간의 오버헤드가 발생합니다. x = Mathf.Abs(x)와 같은 함수를 프레임당 수천 번씩 호출한다면, x = (x > 0 ? x : -x);와 같이 처리하는 편이 더 낫습니다.
Unity가 사용하는 NVIDIA PhysX 물리 엔진은 모바일에서도 사용 가능하지만, 모바일 플랫폼에서는 데스크톱에 비해 하드웨어의 성능 제한에 도달하기가 더 쉽습니다.
물리 엔진을 튜닝하여 모바일에서 더 나은 성능을 얻기 위한 몇 가지 팁을 살펴보겠습니다.