Version: 5.3
렌더링 최적화
레거시 항목(Legacy Topics)

스크립트 최적화

이 섹션에서는 게임에서 사용하는 실제 스크립트와 메서드를 최적화하려 할 때 어떻게 착수하면 될지 설명합니다. 또한 최적화가 가능한 이유 및 특정 상황에서 이러한 최적화를 적용했을 때 얻는 장점에 대해 자세하게 설명합니다.

프로파일러가 최고!

프로젝트가 원활히 돌아가도록 보장해주는 체크리스트 같은 것은 없습니다. 느린 프로젝트를 최적화하기 위해서는 지나치게 시간을 잡아먹는 특정 원인을 프로파일링해야 합니다. 프로파일링을 하지 않거나 프로파일러에서 얻은 결과물을 철저히 이해하지 않고서 최적화를 하려는 것은 마치 눈을 가리고 최적화하려는 것과 마찬가지입니다.

내부 모바일 프로파일러

내부 프로파일러를 사용하면 물리, 스크립트, 렌더링 중 어떤 프로세스가 게임 속도를 저하시키는지 알아낼 수 있으나, 실제로 어디가 원인인지를 찾아내기 위해 특정 스크립트 및 메서드 내부까지 파고들 수는 없습니다. 그러나 특정 기능을 활성화/비활성화하는 스위치를 게임에서 만들어 둠으로써, 가장 문제가 되는 부분이 어디인지 그 범위를 상당히 좁힐 수 있습니다. 예를 들어, 적 캐릭터의 AI 스크립트를 제거하고 나서 프레임 속도가 두 배가 되었다면, 해당 스크립트 또는 스크립트가 게임에 가져온 무엇인가를 최적화해야 한다는 것을 알 수 있습니다. 여기서 유일한 문제는 문제점을 찾아낼 때까지 여러 가지 다른 것을 시도해 보아야 한다는 점입니다.

모바일 디바이스에서의 프로파일링에 대한 자세한 정보는 프로파일링 섹션을 참조하십시오.

디자인에 의한 최적화

처음부터 빨리 실행되도록 구현하려고 하면 어느 정도 위험이 따릅니다. 너무 느려서 나중에 삭제하거나 다른 것으로 대체하는 일에 많은 시간을 낭비하는 것도 문제이지만 최적화하지 않아도 충분히 빠르게 만드느라 시간을 낭비하는 것도 문제이기 때문입니다. 이러한 측면에서 결정을 잘 내리려면 직관과 하드웨어에 대한 풍부한 지식이 필요합니다. 특히 모든 게임은 서로 다르고, 어떤 게임에서 매우 중요한 최적화가 다른 게임에서는 아무런 효과가 없을 수도 있기 때문입니다.

오브젝트 풀링

오브젝트 풀링(Object Pooling)은 스크립트 최적화 방법론 소개에서 좋은 게임플레이와 좋은 코드 디자인 사이의 교차점의 예로 든 바 있습니다. 잠깐만 사용하는 오브젝트에 오브젝트 풀링을 사용하면 오브젝트를 생성했다가 삭제하는 것에 비해 빠른데, 메모리 할당을 더 간단하게 할 수 있으며 동적 메모리 할당 오버헤드와 가비지 컬렉션(GC)을 없앨 수 있기 때문입니다.

메모리 할당

자동 메모리 관리에 대한 간단한 설명

대부분의 스크립트 언어가 그러하듯, Unity에서 작성하는 스크립트는 자동 메모리 관리를 사용합니다. 이와 대조적으로, C 및 C++와 같은 로우 레벨 언어는 수동 메모리 할당을 사용하며, 프로그래머가 메모리 주소에서 직접 읽고 쓰는 것을 허용하고 결과적으로 생성한 모든 오브젝트를 삭제하는 것은 프로그래머의 책임입니다. 예를 들어 C++에서 오브젝트를 생성한다면, 사용이 끝난 후에는 오브젝트가 차지하고 있던 메모리를 수동으로 해제해야 합니다. 스크립팅 언어에서는 단지 objectReference = null;이라고만 명시하면 됩니다.

참고: GameObject myGameObject; 또는 var myGameObject : GameObject;와 같은 게임 오브젝트 변수가 있다고 할 때, myGameObject = null;이라고 하면 왜 오브젝트가 삭제되지 않을까요?

  • 게임 오브젝트는 여전히 Unity가 레퍼런스합니다. 게임 오브젝트를 그리거나 업데이트하는 등의 동작을 위해 오브젝트에 대한 레퍼런스를 Unity가 유지해야 하기 때문입니다. 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);

MyFunctionfoo의 복사본을 넘겨받게 됩니다. foo는 절대로 힙에 할당되지 않으며 가비지 컬렉션의 대상이 되지도 않습니다. MyFunction이 넘겨받은 foo 복사본을 수정한다고 하더라도, 다른 foo에는 영향을 주지 않습니다.

  • 장시간 유지되어야 하는 오브젝트는 클래스여야 하고, 단시간만 사용할 오브젝트는 구조체여야 합니다. Vector3가 아마도 가장 유명한 구조체일 것입니다. Vector3가 클래스였다면 모든 것이 훨씬 느렸을 것입니다.

오브젝트 풀링이 더 빠른 이유

결론적으로 인스턴스화 및 제거를 여러 차례 사용하면 가비지 컬렉터가 해야 할 일이 많아집니다. 그리고 이렇게 하면 게임플레이에 “장애”가 생기게 됩니다. 자동 메모리 관리 페이지에서 설명하듯이, 인스턴스화 및 제거와 관련된 공통 성능 장애 요소를 피할 수 있는 다른 방법이 있습니다. 예를 들어 아무 일도 진행되지 않을 때 가비지 컬렉터를 수동으로 작동시키는 방법이 있고, 또는 가비지 컬렉터를 매우 자주 작동시켜서, 사용하지 않는 메모리가 대량으로 쌓이지 않도록 방지하는 방법도 있습니다.

또 다른 이유는, 특정 프리팹이 최초로 인스턴스화될 때 때로는 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 파일이 포함되어 있습니다. 파일은 시스템이 동작하는 방법과 원리를 설명하고, 필요한 기능 및 구현 방법을 결정하는 데 사용된 프로세스를 서술합니다. 이것이 해당 파일입니다.

여기서의 문제는 “화면 속에서 수백 개의 동전이 회전하고 동적으로 빛나며 수집 가능한 상태를 한번에 구현”하는 것이라고 정의할 수 있습니다.

단순히 접근하자면 동전 프리팹을 여러 개 인스턴스화하면 되겠지만, 그 대신 파티클을 사용하여 동전을 렌더링하려 합니다. 그러나 이를 위해서는 넘어야 할 여러 과제가 있습니다.

  • 파티클에는 시야 각도가 없기 때문에 이 점이 문제가 됩니다.
    • 카메라는 오른쪽을 위로 간주하고 동전은 Y축 주변을 돈다고 간주합니다.
    • 스프라이트 패커를 사용하여 묶은 애니메이션 텍스처를 가지고 동전이 회전하는 것처럼 보이는 효과를 만들어냅니다.
      • 이렇게 하면 새로운 문제가 발생합니다. 모든 동전이 동일한 속도, 같은 방향으로 회전하므로 단조로워집니다.
      • 이 현상을 고치기 위해, 회전과 수명 주기를 직접 추적하고 파티클 수명 주기에 맞춰 회전을 “렌더링”하는 방식을 사용합니다.
  • 실시간 조명을 사용해야하지만 파티클에는 노멀이 없기 때문에 문제가 생깁니다.
    • 스프라이트 패커에 의해 만들어진 각 애니메이션 프레임마다 동전 표면에 단일 노멀 벡터를 생성합니다.
    • 위 리스트에서 얻어낸 노멀 벡터에 기반하여, 스크립트의 각 파티클에 대해 블린-퐁 라이팅을 적용합니다.
    • 결과물을 컬러로 파티클에 적용합니다.
    • 동전 표면과 동전 가장자리 부분을 셰이더에서 별도로 처리합니다. 새로운 문제가 발생합니다: 어디가 가장자리인지, 그리고 가장자리의 어느 위치에 위치해야 하는지 셰이더가 어떻게 알 수 있을까요?
      • UV는 이미 애니메이션에 사용되기 때문에 여기에 사용할 수 없습니다.
      • 텍스처 맵을 사용합니다.
        • 동전에 상대적인 Y 포지션이 필요합니다.
        • “동전 표면 위” vs “가장자리 위”에 바이너리가 필요합니다.
      • 텍스처를 추가로 도입하면 읽어야 하는 텍스처가 많아지고 텍스처 메모리도 많아지기 때문에 이 방법은 피하려 합니다.
      • 필요한 정보를 하나의 채널로 합쳐서 텍스처 컬러 채널 중에 하나를 정보 채널로 대체합니다.
        • 이제는 동전 컬러가 잘못되어 버렸습니다. 어떻게 하면 될까요?
        • 남아 있는 두 개의 채널을 합쳐서 사라진 채널을 다시 복구할 수 있도록 셰이더를 사용합니다.
  • 동전의 반짝이는 광원으로부터 글로우 효과를 얻고자 한다고 생각해 봅시다. 포스트 프로세싱은 모바일 디바이스에서는 너무 비용이 많이 듭니다.
    • 파티클 시스템을 하나 더 만들어서, 부드럽고 반짝이는 효과를 내는 동전 애니메이션을 만듭니다.
    • 해당하는 동전의 컬러가 아주 밝은 경우에만 글로우 효과를 입힙니다.
    • 매 프레임마다 모든 동전에 글로우 효과를 렌더링하면 필레이트가 너무 떨어져 버리므로 이렇게는 처리할 수 없습니다.
      • 밝기가 0보다 큰 포지션의 경우에만 글로우 효과를 매 프레임마다 초기화합니다.
  • 파티클이 잘 충돌하지 않기 때문에 물리를 구현하고 동전을 모으는 것이 문제가 됩니다.
    • 빌트인 파티클 충돌을 사용할 수 있습니까?
    • 그 대신, 충돌을 스크립트로 작성합니다.
  • 마지막으로 문제가 하나 더 남았습니다. 스크립트가 하는 일이 너무 많아서 느려져 버렸습니다.
    • 퍼포먼스는 활성화된 동전의 수에 비례하여 변합니다.
      • 최대 동전 수를 제한합니다. 목표를 충분히 달성할 수 있을 정도로 효과적인 100개의 동전, 2개의 광원을 적용하자, 모바일 디바이스에서 매우 빨리 실행됩니다.
  • 다음과 같이 좀더 최적화할 수 있습니다.
    • 모든 동전에 각각 광원을 계산하는 대신, 월드를 몇 부분으로 나누어서 각 부분에서의 모든 회전 프레임에 대한 광원 조건을 산출합니다.
      • 동전의 포지션과 동전 회전을 인덱스로 하는 룩업 테이블을 사용합니다.
      • 포지션에 대한 쌍선형 보간을 사용하여 정확도를 향상시킵니다.
      • 룩업 테이블 업데이트를 최소한으로 하거나, 아예 정적 룩업 테이블을 사용합니다.
      • 라이트 프로브를 사용합니까? *스크립트에서 광원을 계산하는 대신, 노멀맵 파티클을 사용합니까?
      • 노멀의 프레임 애니메이션을 베이크하기 위해 “디스플레이 노멀” 셰이더를 사용합니다.
      • 광원의 수를 제한합니다.
      • 느린 스크립트 문제를 해결합니다.

예제의 최종 목표 또는 “이야기의 교훈”은, 게임에 정말 필요한 요소가 있으나 기존의 방식으로 요소를 성취하고자 할 때 지연이 발생한다면, 할 수 없다는 의미가 아니라 그저 더 빨리 실행될 수 있도록 시스템에 조작을 더 가해야 합니다.

수천 개의 오브젝트를 관리하는 기법

수백 개 또는 수천 개의 동적 오브젝트가 있는 상황에 적용할 수 있는 특정한 스크립트 최적화 기법이 있습니다. 이 기법을 게임에서 모든 스크립트에 적용하겠다는 것은 끔찍한 생각입니다. 기법은 런타임에 굉장히 많은 수의 오브젝트를 다루는 대규모 스크립트용 툴 및 디자인 가이드라인에 사용해야 합니다.

대규모 데이터 세트에서 O(n2) 연산 방지 또는 최소화

컴퓨터학에서 연산의 순서는 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회 실행됩니다. i1에서 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 연산을 통해 역을 먼저 구해야 합니다.

Physics.Raycast() 같은 비용이 큰 연산은 최대한 지양

비용이 크게 드는 처리를 해야 할 경우, 처리를 덜 하는 방향으로 최적화하고 결과를 캐싱하는 방법이 있습니다. 예를 들어, 레이캐스트를 사용하는 발사체 스크립트를 생각해 봅시다.

 // 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 물리 엔진은 모바일에서도 사용 가능하지만, 모바일 플랫폼에서는 데스크톱에 비해 하드웨어의 성능 제한에 도달하기가 더 쉽습니다.

물리 엔진을 튜닝하여 모바일에서 더 나은 성능을 얻기 위한 몇 가지 팁을 살펴보겠습니다.

  • 타임 관리자고정 타임스텝 설정을 조정하여 물리 업데이트에 드는 시간을 줄일 수 있습니다. 타임스텝을 늘리면 물리 정확도가 떨어지는 대신 CPU 오버헤드가 줄어듭니다. 때로는 정확도를 낮추어 속도 향상을 도모하는 것이 더 나은 경우도 있습니다.
  • 타임 관리자최대 허용 타임스텝을 810fps 범위로 설정하여 최악의 시나리오에서 물리 연산에 드는 시간을 제한해야 합니다.
  • 메시 콜라이더는 기본 콜라이더에 비해 퍼포먼스 오버헤드가 훨씬 큽니다. 그러므로 꼭 필요한 경우에만 사용해야 합니다. 기본 콜라이더를 포함하는 자식 오브젝트를 사용하여 대략적으로 메시 형태를 만들 수도 있습니다. 부모 리지드바디는 자식 콜라이더를 단일 복합 콜라이더로 한꺼번에 제어합니다.
  • 휠 콜라이더는 솔리드 오브젝트라는 점에서 엄밀히 콜라이더는 아니지만 높은 CPU 오버헤드를 유발합니다.
렌더링 최적화
레거시 항목(Legacy Topics)