본문으로 건너뛰기

© 2026 Molayo

YouTube요약2026. 06. 10. 13:17

120 FPS로 200만 개의 오브젝트를 렌더링하는 방법

요약

Unity에서 200만 개의 오브젝트를 120 FPS로 렌더링하기 위한 최적화 기법을 다룹니다. 개별 MonoBehaviour를 사용하는 객체 지향 방식의 한계를 지적하고, 매니저 스크립트를 통한 데이터 지향적 접근 방식의 성능 이점을 설명합니다.

핵심 포인트

  • 개별 MonoBehaviour 사용 시 오브젝트 수가 늘어날수록 성능이 급격히 저하됨
  • 매니저 스크립트 하나로 배열을 루프 돌리는 방식이 더 효율적임
  • 데이터 지향적 접근 방식(Data-oriented approach)의 중요성 강조
  • Unity Mathematics와 Burst Compiler 활용을 위한 준비 과정 언급

비디오: 120 FPS로 200만 개의 오브젝트를 렌더링하는 방법
채널: Tarodev
재생 시간: 14분 56초
출처: 자막 (자동 생성, 영어)

자, 안녕하세요 친구들, Unity를 어디까지 밀어붙일 수 있는지 한번 봅시다. 이것은 저의 테스트 장면입니다. Perlin Noise (펄린 노이즈)에 맞춰 춤을 추고 있는 수많은 Cube (큐브)들입니다. 더 가까이 다가가 보면, 이들이 각각의 오프셋 높이(offset height)를 기반으로 회전하고 있다는 것을 알 수 있습니다. 그리고 이 장면에서는 구체적으로 개별적인 MonoBehavior (모노비헤이비어)를 사용하고 있습니다. 즉, 이 큐브들 하나하나가 각자의 스크립트를 가지고 있으며, 자신의 위치와 회전을 스스로 관리하고 있다는 뜻입니다. 이것이 어떻게 설정되었는지 보여드리겠습니다.

저의 Level 1 스크립트에서는—이게 좀 이상해 보일 수도 있지만, 매 레벨마다 이 중첩 루프(nested loop)를 계속 타이핑하지 않아도 되도록 만든 함수일 뿐입니다—각 그리드 위치(grid position)마다 큐브를 인스턴스화(instantiating)하고, Level 1 Cube 스크립트를 추가하며, 이를 초기화합니다. 당연히 모든 큐브에 붙어 있는 Level 1 Cube 스크립트에서 매 프레임마다 새로운 위치와 회전을 설정하고 있습니다. 여기서 저는 높이에 따라 Inverse Lerp (역선형 보간)와 Quaternion Slerp (쿼터니언 구면 선형 보간)를 수행하는 작은 헬퍼 함수를 사용하고 있으며, Y 위치를 계산하는 작업도 수행합니다. 이때 Burst Compiler (버스트 컴파일러)를 사용하는지 여부에 따라 Mathf (매스 에프) 대신 Unity Mathematics (유니티 매스매틱스)를 사용하고 있는데, 최적의 컴파일러를 위한 약간의 스포일러라고 할 수 있겠네요.

다시 빌드 화면으로 돌아와서 보면, 10,000개의 큐브일 때는 약 75 FPS가 나옵니다. 만약 이를 60,000개로 조금 더 늘리면 성능이 눈에 띄게 저하되기 시작합니다. 실제 게임에서는 절대 이렇게 하지 않을 것입니다. 90,000개까지 늘리면 8 FPS 정도를 보게 됩니다.

음, 당연히 게임을 실행하는 좋은 방법은 아니죠. 그렇다면 어떻게 개선할 수 있을까요? 쉬운 방법 중 하나는 매니저 스크립트(manager script)를 사용하는 것입니다. 각 큐브마다 스크립트를 두는 대신, 단 하나의 스크립트인 매니저 스크립트를 두어 배열(array)에 있는 모든 큐브를 루프(loop) 돌며 대신 위치를 업데이트하게 만드는 것이죠. 코드를 한번 살펴보겠습니다. 우리는 이전과 똑같이 그것들을 생성하고 있습니다.

레벨 1에서 생성하지만, 이번에는 그것들을 배열 (array)에 추가하고, 그 다음 업데이트 함수 (update function)에서 배열을 루프 (loop) 돌며 그들의 위치를 직접 설정해 줍니다. 이제 이 오브젝트들 중 그 어떤 것에도 스크립트 (script)가 달려 있지 않습니다. 오직 하나의 매니저 스크립트 (manager script)만 존재할 뿐이죠. 두 방식을 비교해 보면, 75 FPS와 90 FPS가 나오는데, 이것만으로도 이 작업을 해야 할 이유는 충분하지만, 좋은 테스트 방법은 수치를 90까지 최대한 높여보는 것입니다. 이 큐브 (cubes)들로부터 시선을 돌려, 즉 렌더링 (rendering)을 제외하고 개별 스크립트 (individual scripts)에 대한 성능만 본다면, 90,000개의 업데이트 루프 (update loops)에서 약 20 FPS가 나옵니다. 반면 매니저 (managed) 방식을 사용하면 31, 32 FPS 정도가 나옵니다. 네, 확실히 차이가 있습니다. 기본적으로 이는 더 데이터 지향적인 접근 방식 (data-oriented approach)으로, 모든 데이터를 한곳에 모아두고 하나의 매니저에서 루프를 돌리는 방식입니다. 반대로 모든 오브젝트가 스스로를 처리하게 하는 것은 더 객체 지향적인 접근 방식 (object-oriented approach)이죠. 그리고 당연히, 그 모든 업데이트 호출 (update calls)을 단 하나로 줄임으로써 이점을 얻게 됩니다.

좋습니다, 다음 단계는 외부 호출 (external call)을 피하는 것입니다. 이것은 저의 개인적인 실험에 가깝습니다. 매 프레임마다 트랜스폼 위치 (transform position)를 가져오는 것과, 위치를 캐싱 (caching)하여 실제로 그 외부 호출을 하지 않는 것 사이에 차이가 있는지 확인하고 싶었습니다. 이 방식에서는 오브젝트들을 생성(spawning)하면서 동시에 그들의 위치를 이 lastPositions 배열 (array)에 저장하는 것을 볼 수 있습니다. 그리고 업데이트 함수 (update function)에서는 transform.position 대신 단순히 배열 (array)을 가져옵니다. 또한 배열에 값을 써주어야 하므로, 우리는 외부 호출 (external call)을 배열의 읽기 및 쓰기 연산 (read and write operation)으로 교체하는 셈입니다. 실제로 차이가 있는지 확인해 봅시다. 10,000개의 큐브 (cubes)를 대상으로 했을 때, 외부 호출 방식은 약 89 FPS가 나옵니다. 3단계로 넘어가면 몇 FPS 정도 차이가 나지만, 어쨌든 이 규모에서는 확실히 할 가치가 없습니다. 그럼 렌더링 (rendering)을 제외하고 외부 호출 (external call) 방식의 2단계 성능을 보면 약 69, 70 FPS, 즉 70 FPS 정도가 나옵니다. 3단계로 가면 약 75 FPS 정도가 나옵니다.

하지만 성능 테스트를 계속 진행해 보겠습니다. 어떻게 하면 성능을 더 끌어올릴 수 있을까요? 바로 GPU 인스턴싱 (GPU instancing)을 사용하는 것입니다. 보시다시피 10,000개의 큐브를 사용할 때 90 FPS였던 성능이 3배 이상으로 엄청나게 상승했습니다. 만약 Transform 컴포넌트나 콜라이더 (colliders) 등이 포함된 실제 게임 오브젝트 (game object)가 필요하지 않고, 단지 시각적인 표현만 필요하다면, 게임 오브젝트의 오버헤드 (overhead)를 건너뛰고 GPU가 직접 렌더링하도록 할 수 있습니다. 코드를 보여드리겠습니다. 여기 우리의 Level 4 스크립트가 있습니다. 루프 (Loop) 안에서 우리는 단순히 몇 가지 위치 (positions)를 설정하고 있습니다. 이제 우리는 아무것도 인스턴스화 (instantiating) 하지 않습니다. 그리고 Update 함수에서 우리는 모든 위치를 순회하며, 새로운 위치, 회전, 그리고 스케일 (scale)을 포함한 matrices 배열을 업데이트하고 있습니다. matrices는 세 가지 구성 요소로 이루어져 있습니다. 그런 다음 매 프레임마다 render mesh instance를 호출하며, 여기에 머티리얼 (material)을 전달합니다. 이 머티리얼에는 다른 여러 속성들이 포함될 수도 있습니다. 우리가 렌더링하고자 하는 실제 메시 (mesh)를 전달합니다. 이것은 서브메시 인덱스 (submesh index)입니다. 메시가 실제로 가지고 있는 머티리얼의 개수에 따라 이 숫자를 조절해야 합니다. 제 것은 하나뿐이라 0으로 남겨두었습니다. 그리고 당연히 매 프레임마다 업데이트된 matrices를 전달하면, GPU가 이를 직접 렌더링하게 됩니다. 이것은 정말 확실한 성능 향상입니다. 그럼 수치를 조금 더 높여보겠습니다. 여전히 70~75 FPS 정도가 나오는데, 90까지 가보겠습니다.

여전히 40 FPS가 나오고 있는데, 아시다시피 이 정도 양의 큐브들이 각각 자체적인 연산을 수행하고 있다는 점을 고려하면 나쁘지 않은 수치입니다. 음, 그럼 이것을 어떻게 더 밀어붙일 수 있을까요? 현재 우리는 하나의 업데이트 루프 (Update Loop) 내에서 각 큐브를 하나씩 순차적으로 돌며 행렬 (Matrices)을 업데이트하는 동기적 (Synchronous) 방식을 사용하고 있으며, 이는 느립니다. 그러니 우리의 멀티스레드 (Multi-threaded) 시스템을 활용해 보는 건 어떨까요? 보시는 바와 같이 여기서 엄청난 성능 향상이 일어납니다. 이것은 Unity DOTS의 두 가지 구성 요소를 활용하고 있습니다. 바로 Jobs와 Burst입니다. Jobs는 우리가 이전에 했던 것과 같은 단일 스레드 (Single-threaded) 연산과 달리, 현대 시스템의 전체 멀티스레드 능력을 해제할 수 있게 해줍니다. 그리고 Burst는 IL 또는 .NET 바이트코드 (Bytecode)를 가져와 실행 속도가 매우 매우 빠른 네이티브 코드 (Native Code)로 변환해 줍니다. 그럼 이것이 6만 개의 큐브까지 어떻게 확장되는지 보겠습니다. 여전히 100 FPS 이상이 나옵니다. 9만 개의 큐브까지 올려봐도 여전히 60 FPS 이상을 유지하고 있는데, 이는 정말 말도 안 되는 수치입니다. 그럼 Jobs와 Burst를 사용하여 코드가 어떻게 구성되는지 보여드리겠습니다. 평소와 같이 초기 위치를 설정하고, 여기서 Job을 생성합니다. 그리고 아래의 Update 함수에서는, 이전에 했던 것처럼 모든 것을 직접 동기적으로 설정하는 대신, 이 Job을 스케줄링 (Scheduling)합니다. 네이티브 행렬 (Native Matrices)을 전달하면, Job이 모든 스레드를 포화시키며 이 모든 계산을 병렬 (Parallel)로 수행합니다. 그리고 여기서 Burst 컴파일 (Burst Compile)을 사용하기 때문에, mathf 라이브러리 대신 Unity.Mathematics 라이브러리를 사용해야 합니다. 어떤 이유에서인지 에디터 (Editor)에서는 mathf가 작동하겠지만, 일단 컴파일을 하면 깨지게 됩니다. 따라서 반드시 Unity.Mathematics 라이브러리를 사용해야 합니다. 보시는 것처럼 엄청난 성능 향상이 있습니다. 그럼 여기서 더 나아가려면 어떻게 해야 할까요? 그것은 DOTS의 마지막 구성 요소인 ECS, 죄송합니다, Entity Component System을 추가하는 것입니다. 이제 이것은 순수한 DOTS입니다. 보시는 것처럼 성능이 상당히 올라가고 있으며, 확장성 (Scalability)도 매우 좋습니다. 이제 9만 개의 큐브가 150~160 FPS 이상으로 돌아가고 있는데, 정말 미친 수준이죠, 그렇죠? 또한 이것은 왜냐하면...

우리가 GPU 인스턴싱 (GPU instancing)이 아닌 ACS를 사용하고 있기 때문입니다.
이 방식은 이제 우리 오브젝트에 대해 더 많은 제어권을 가질 수 있는 이점을 제공합니다. 당연히 여기에 충돌 (collisions)을 추가할 수 있는데, 어떤 종류의 물리나 충돌을 추가하든 FPS는 약간 떨어지게 될 것입니다. 하지만 좋습니다. 만약 여러분이 Unity DOTS에 대한 제 의견을 물으신다면, 저는 그것을 사랑한다고 말할 것입니다. Jobs와 Burst를 즉시 사용해 보라고 말씀드리고 싶습니다. 왜냐하면 이를 통해 얻을 수 있는 성능 향상이 정말 엄청나기 때문입니다. 병렬 (parallel)로 처리할 수 있다고 생각되는 작업이 있다면, 그것을 Job으로 만드는 것이 매우 쉽습니다.

CS (Compute Shader) 측면에서 보자면, 특히 지난 2년 동안 발생하는 파괴적 변경 사항 (breaking changes)의 양은 정말 엄청납니다. 기본적으로 거의 모든 아티클이 지원 중단 (deprecated) 되었고, 무언가를 어떻게 하는지 알아내기 위해 모호한 출처들을 뒤져야 합니다. 방금 1.0 버전이 출시되었으니 올해에는 안정화될 것이라고 말하고 싶지만, 세상에, 제대로 된 테스트를 구성할 수 있을 만큼 충분히 배우려고 노력하는 과정 자체가 정말 험난한 여정이었습니다.

어쨌든, 이제 여기서 어디로 가야 할까요? 어떻게 하면 이를 훨씬 더 개선할 수 있을까요? 그리고 제가 지금까지 보여드린 것이 우리가 밀어붙일 수 있는 성능에 근처에도 가지 못했다고 말한다면 여러분은 저를 믿으시겠습니까? 다음 단계는 정말로 경이롭습니다. 그럼 바로 들어가 보죠. 좋습니다, 이제 GPU 인스턴싱 인디렉트 (GPU instancing indirect)입니다. 이것이 여러분이 계속 보고 있던 Purling Cubes가 아니라는 점은 알고 있습니다만, 세상에, 저는 큐브를 수백만 개씩 뽑아내고 있었고 여러분도 분명 그랬을 것입니다. 그래서 이것으로 변경했습니다. 또한 이것은 이전의 Purling Cubes보다 어떻게 더 잘 확장 (scale)되는지 보여줄 수 있게 해줍니다.

음, 인디렉트 (indirect)와 다이렉트 (direct) GPU 인스턴싱 사이에는 한 가지 주요한 차이점이 있습니다. 우리는 이전에 다이렉트를 사용해 왔는데, 다이렉트 방식에서는 매 프레임마다 메쉬 (mesh) 데이터를 GPU로 보내야 하며, 이는 엄청난 오버헤드 (overhead)를 발생시킵니다. 반면 인디렉트 방식에서는 시작할 때 딱 한 번만 보내면 되고, 그러면 GPU가 그 메쉬 데이터를 캐시 (cache)하여 매 프레임 재사용할 수 있습니다. 어떻게 하는지 보여드리겠습니다. 기본적으로 우리는 이 uint 배열을 약간의 메쉬 데이터로 채운 다음, 그것을 args 버퍼 (args buffer)로 설정할 것입니다.

그다음 렌더링할 때 저는 그것을 그냥 바로 보내기만 하면 됩니다. 그러면 GPU가 실제로 이것을 캐싱(cache)하여 매 프레임마다 재사용할 것이므로, 이는 분명히 많은 오버헤드(overhead)를 제거해 줍니다. 하지만 이전 데모와 이번 데모 사이의 가장 중요한 변화는 모든 연산을 GPU로 오프로딩(offloading)했다는 점입니다. 이전에는 CPU에서 모든 행렬(matrices)을 계산한 다음 그 데이터를 GPU로 전송했었지만, 이번에는 이 모든 작업을 셰이더(Shader) 내에서 수행하고 있습니다.

시작할 때 저는 초기 위치들을 생성합니다. 여기 두 개의 위치 버퍼(position buffers), 즉 position 1과 position 2가 있습니다. 기본적으로 각 큐브(Cube)에 대해 구(sphere)의 중심에 가까운 지점 하나와, 그보다 더 멀리 떨어진 지점 하나를 선택한 다음, 이 모든 데이터를 셰이더로 보냅니다. 여기서 셰이더 데이터를 가져온 뒤, 모든 계산을 셰이더에서 직접 수행합니다. 저는 가까운 위치와 먼 위치 사이를 보간(lerping)할 뿐만 아니라, 중심에 얼마나 가까운지에 따라 색상을 변경하여 마치 타오르는 태양 같은 시각 효과(visual)를 주고 있습니다.

이 두 가지 변화만으로 제 컴퓨터가 완전히 뻗어버리지 않고 화면에 얼마나 많은 오브젝트를 렌더링할 수 있는지 실제로 확인해 보겠습니다.

5만 개, 9만 개... 그리고 가까이서 보면 꽤 엄청나 보입니다. 정말 몽환적(trippy)이네요. 마치 90년대 배경화면 같은 느낌입니다.

음, 하지만 이것만으로는 충분하지 않았기 때문에 여기에 승수(multiplier)를 추가했습니다. 이 값은 30까지 올라가는데, 30까지 끝까지 올려보겠습니다.

270만 개의 베벨 큐브(beveled cubes)가 모두 안팎으로 몰려들며 그 위치와 색상을 계산하고 있는데도 여전히 20 FPS에 머물러 있습니다. 만약 제가 이것을 녹화하고 있지 않았다면 아마 30~35 FPS 정도였을 겁니다. 제 말을 믿으셔야 하겠지만, 이건 정말 말도 안 되는 수준입니다. 컬링(culling)이 전혀 이루어지지 않고 있어요. 뒤쪽에 있는 것들을 포함해 이 중 단 하나도 빠짐없이 모두 렌더링(rendering)되고 있습니다.

그런데도 여전히 20 FPS입니다. 정말 미친 듯한 성능입니다. 정말 엄청난 퍼포먼스죠. 이제 여러분은 '좋아, 시각적인 효과로는 멋지지만, 만약 내가 이 큐브들과 상호작용하고 싶다면 어떻게 해야 하지?'라고 생각하실 수도 있습니다. 그게 바로 다음 테스트의 주제입니다. 수치를 8로 설정하면, 모든 것이 회전하고 있고 여기에 작은 푸셔(Pusher)가 있는 것을 볼 수 있습니다. 그리고 제 컨트롤러 보드로 이를 제어할 수 있습니다. 작동 방식은 여전히 GPU 인스턴싱 인디렉트(GPU instancing indirect)를 사용하고 있지만, 이 셰이더(Shader)는 실제로는 훨씬 더 단순합니다. 저는 기본 위치(base positions)와 함께 일부 메쉬 데이터(mesh data)를 보내고 있습니다.

그리고 이 셰이더에서 제가 하는 일은 이 데이터를 바탕으로 위치와 색상을 설정하는 것뿐입니다. 하지만 여기 컴퓨트 셰이더(compute Shader)도 가지고 있습니다. 이 컴퓨트 셰이더는 큐브당 동일한 데이터를 받지만, 여기 이 작은 구체인 푸셔(Pusher)의 위치 정보도 함께 받습니다. 그리고 이 푸셔의 위치는 매 프레임마다 업데이트됩니다. 즉, 매 프레임마다 저는 해당 오브젝트의 위치를 컴퓨트 셰이더로 보내고 있는 것입니다. 이제 큐브들이 그 푸셔로부터 얼마나 멀리 떨어져 있는지에 따라, 얼마나 멀리 밀어내야 할지를 결정합니다.

그런 다음 저는 실제로 이 메쉬 데이터의 데이터를 덮어씁니다. 위치를 포함한 새로운 행렬(Matrix)을 설정하고, 우리가 얼마나 밀어냈는지(0에서 1 사이의 값)도 결정합니다. 그러면 이 셰이더에서, 이 셰이더는 실제로...

AI 자동 생성 콘텐츠

본 콘텐츠는 YouTube Tarodev (Unity 팁)의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0