이 장에서는 "Unity에서의 3D 캐릭터 애니메이션 기능 최적화 방법"을 에셋 설정과 구현의 여러 관점에서 소개합니다.
이 장의 구현과 계측은 다음 환경에서 수행했습니다.
표 1.1: 계측 환경
| 항목 | 값 |
|---|---|
| Unity | 6000.5.7f1 |
| 플랫폼 | macOS Standalone (StandaloneOSX) |
| Scripting Backend | IL2CPP / IL2CPP Compiler Configuration = Release / Code Generation = OptimizeSpeed |
| Graphics API | Metal / GPU Skinning = 활성화 |
| Quality Level | PC / Skin Weights = Four Bones |
계측에 사용하는 캐릭터는 unity-chan을 기반으로 Generic 리그로 다시 임포트한 것입니다. 후술하는 Optimize Game Objects를 적용하기 전후의 사양은 다음과 같습니다.
표 1.2: 계측에 사용하는 프리팹의 Transform 수와 버텍스 수
| 프리팹 | Transform 수 | 버텍스 수 |
|---|---|---|
| Base | 166 | 13,391 |
| Optimized(Optimize Game Objects) | 24 | 13,391 |
먼저 Unity에서 애니메이션을 제어하는 세 가지 방법을 정리합니다.
Animator에 RuntimeAnimatorController를 할당하는 가장 일반적인 방법입니다. 스테이트 머신, 트랜지션, 블렌드 트리, 파라미터 등의 구조가 갖추어져 있어 Mecanim 워크플로를 그대로 사용할 수 있습니다.
리스트 1.1: Animator 컴포넌트를 이용한 제어
1 2 3 | var animator = GetComponent<Animator>(); animator.runtimeAnimatorController = controller; // 스테이트 머신을 할당한다 animator.SetFloat("Blend", 0.5f); // 파라미터로 제어한다 |
중요한 점은 Animator가 내부적으로 Playable Graph를 구성하여 실행한다는 것입니다. 스테이트 머신의 평가도 클립 샘플링도 내부에서는 그래프의 노드로 처리됩니다. 다음 두 방법은 이 내부 그래프를 직접 구성하는 방법에 해당합니다.
PlayableGraph를 직접 구성하는 방법입니다. 스테이트 머신을 거치지 않고 블렌드 구조를 완전히 제어할 수 있으며, 후술하는 Animation Job(AnimationScriptPlayable)을 그래프의 임의 위치에 삽입할 수 있습니다.
리스트 1.2: Playable API를 이용한 그래프 구성(자동 업데이트)
1 2 3 4 5 6 7 8 9 10 11 12 13 | var graph = PlayableGraph.Create("MyGraph"); var output = AnimationPlayableOutput.Create(graph, "Animation", animator); var clipA = AnimationClipPlayable.Create(graph, waitClip); var clipB = AnimationClipPlayable.Create(graph, walkClip); var mixer = AnimationMixerPlayable.Create(graph, 2); graph.Connect(clipA, 0, mixer, 0); graph.Connect(clipB, 0, mixer, 1); mixer.SetInputWeight(0, 0.5f); mixer.SetInputWeight(1, 0.5f); output.SetSourcePlayable(mixer); graph.Play(); |
업데이트 모드의 기본값은 DirectorUpdateMode.GameTime입니다. 이 모드에서는 Unity의 라이프사이클(Update와 LateUpdate 사이에 있는 Animation 단계)에서 Animator 컴포넌트와 같은 타이밍에 자동으로 평가됩니다. 호출 측에서 매 프레임 별도로 처리할 필요는 없습니다.
같은 그래프에 DirectorUpdateMode.Manual를 설정하면 Unity는 자동 평가를 전혀 하지 않게 되고, Evaluate()를 호출했을 때만 그래프가 평가됩니다.
리스트 1.3: 수동 업데이트에서의 그래프 평가
1 2 3 4 5 6 7 | graph.SetTimeUpdateMode(DirectorUpdateMode.Manual); graph.Play(); void Update() { graph.Evaluate(Time.deltaTime); // 호출했을 때만 평가된다 } |
평가의 타이밍과 빈도를 완전히 제어할 수 있으므로 "특정 캐릭터(그래프)만 업데이트를 멈춘다", "몇 프레임에 한 번만 업데이트한다"와 같은 제어를 그대로 구현할 수 있습니다.
세 방법의 관계를 정리하면 다음과 같습니다.
표 1.3: 애니메이션 제어 3가지 방법 비교
| 방법 | 그래프 구성 | 평가 타이밍 |
|---|---|---|
| Animator 컴포넌트 | Unity(내부) | Unity의 Animation 단계 |
| Playable API + 자동 업데이트 | 직접 | Unity의 Animation 단계 |
| Playable API + 수동 업데이트 | 직접 | Evaluate()를 호출했을 때 |
코드 측 최적화를 소개하기 전에, 에셋 측 설정으로 적용할 수 있는 최적화부터 살펴보겠습니다. 이 절의 주제는 본의 Transform 계층을 없애는 것입니다. 같은 결과를 얻는 방법은 두 가지입니다.
FBX 임포트 설정(Rig 탭)의 Optimize Game Objects를 활성화하면 실행 시 본의 GameObject / Transform이 통째로 생성되지 않으며, Animator가 내부 스켈레톤만 가지게 됩니다. 이번 캐릭터에서는 Transform이 166개에서 24개(루트 + Renderer 23개)로 줄었습니다.
※본이 GameObject로 존재하지 않으므로 계층을 따라가 본을 다루는 코드는 동작하지 않게 됩니다. 무기의 어태치 포인트처럼 특정 본만 필요한 경우에는 Extra Transforms to Expose로 개별적으로 남깁니다.
같은 작업을 런타임에 수행하는 API가 AnimatorUtility.OptimizeTransformHierarchy입니다.
리스트 1.4: 런타임에서의 계층 평탄화
1 2 3 4 5 | // go는 Animator(와 유효한 Avatar)를 가진 루트이다. 두 번째 인자는 노출할 본 이름의 배열이다 AnimatorUtility.OptimizeTransformHierarchy(go, null); // 역변환도 있다(에디터 확장이나 디버그에서 계층을 일시적으로 되돌리고 싶을 때) AnimatorUtility.DeoptimizeTransformHierarchy(go); |
임포트 설정을 변경할 수 없는 에셋이라도 생성 시점에 실행하면 같은 구조가 됩니다. 실제로 확인한 결과, Base 프리팹(Transform 166개)에 적용하면 Transform 24개·hasTransformHierarchy == false가 되었으며, 임포트 시 Optimize Game Objects를 적용한 프리팹과 완전히 같은 구조가 되었습니다.
그림 1.1: 최적화 전의 Hierarchy. Character1_Hips 아래에 본의 Transform 계층이 166개 이어진다.
그림 1.2: AnimatorUtility.OptimizeTransformHierarchy 적용 후. 본 계층이 사라지고 루트 바로 아래에 SkinnedMeshRenderer 23개만 나열된다(Transform 24개).
렌더링 부하를 두 단계로 바꾸어 계측했습니다. 첫 번째는 Renderer를 모두 활성화한 구성(300체 × 23 Renderer = 6,900 Renderer), 두 번째는 각 캐릭터에서 버텍스 수가 가장 많은 SkinnedMeshRenderer 1개만 활성화하여 렌더링 부하를 낮춘 구성입니다. 프레임 시간은 중앙값입니다.
표 1.4: 계층 평탄화의 효과(300체·전체 Renderer와 600체·Renderer 1개)
| 구성 | 베리언트 | 프레임 시간 | MeshSkinning.Update (main) | MeshSkinning.CalcMatrices (all) | Semaphore.WaitForSignal |
|---|---|---|---|---|---|
| 300체·전체 Renderer | Base | 18.59 ms | 6.23 ms | 36.633 ms | 5.33 ms |
| 300체·전체 Renderer | Optimize Game Objects | 18.497 ms (±0) | 1.537 ms (−75.7 %) | 0 ms | 10.255 ms |
| 300체·전체 Renderer | Optimize Hierarchy | 18.307 ms (±0) | 1.504 ms (−76.2 %) | 0 ms | 9.977 ms |
| 600체·1 Renderer | Base | 6.797 ms | 0.735 ms | 4.385 ms | 5.741 ms |
| 600체·1 Renderer | Optimize Game Objects | 5.991 ms (−11.9 %) | 0.207 ms | 0 ms | 4.901 ms |
| 600체·1 Renderer | Optimize Hierarchy | 6.009 ms (−11.6 %) | 0.208 ms | 0 ms | 5.010 ms |
CPU 측 처리는 어느 구성에서나 같은 비율로 줄었습니다. 메인 스레드의 스키닝 처리가 6.321 → 약 1.5 ms(전체 Renderer), 0.735 → 약 0.21 ms(Renderer 1개)로, 모두 70% 넘게 절감되었습니다. 스키닝 행렬 계산(MeshSkinning.CalcMatrices)은 전체 스레드 합계 36.6 ms / 4.4 ms가 완전히 사라졌습니다.
다만 "전체 Renderer 구성"에서는 렌더 스레드의 처리가 프레임 시간의 크리티컬 패스이므로 프레임 시간의 감소는 나타나지 않습니다.
렌더링 부하를 낮춘 Renderer 1개 구성에서는 같은 절감이 그대로 12 % 전후의 프레임 시간 단축으로 나타납니다. 300체에서도 같은 경향으로, 3.312 → 2.899 / 2.901 ms(−12.5 % / −12.4 %)였습니다. 임포트 시 Optimize Game Objects와 런타임 Optimize Hierarchy의 차이는 0.1 % 미만으로, 구조가 같아지는 이상 효과도 같다는 것을 알 수 있습니다.
Profiler의 Timeline에서도 같은 현상이 나타납니다. 둘 다 300체·전체 Renderer이며, 프레임 시간의 중앙값 부근 프레임입니다.
그림 1.3: Base(Transform 166개). 프레임 시간 18.59 ms 중 메인 스레드에서 MeshSkinning.Update가 6.23 ms를 차지하고, Gfx.WaitForPresentOnGfxThread / Semaphore.WaitForSignal가 5.33 ms.
그림 1.4: AnimatorUtility.OptimizeTransformHierarchy 적용 후. MeshSkinning.Update 블록은 라벨을 읽을 수 없을 정도로 가늘어지는 반면, Semaphore.WaitForSignal가 5.33 → 10.44 ms로 늘어났고 PlayerLoop는 18.58 → 18.91 ms로 거의 변하지 않았다. 줄어든 CPU 시간이 대기 시간으로 바뀌었다.
렌더링과 스키닝을 완전히 제외하고 애니메이션 평가 자체에 대한 기여를 확인합니다. 300체입니다.
표 1.5: Renderer를 전체 비활성화했을 때의 프레임 시간
| 베리언트 | 프레임 시간 |
|---|---|
| Base | 2.702 ms |
| Optimize Game Objects | 2.687 ms (−0.6 %) |
| Optimize Hierarchy | 2.496 ms (−7.6 %) |
클립 샘플링이나 블렌딩 같은 평가 처리는 Transform 계층의 유무와 관계없이 실행되므로, 이쪽에 대한 기여는 거의 없다고 할 수 있습니다. 이로써 이 최적화의 목적이 주로 스키닝 행렬 계산과 Transform으로의 쓰기 반영에 대한 것이며 애니메이션 평가 자체가 아니라는 점을 알 수 있습니다.
MeshSkinning.CalcMatrices) 절감으로 이어진다AnimatorUtility.OptimizeTransformHierarchy)의 효과는 동등하다Animator 컴포넌트와 자동 업데이트 Playable Graph는 Unity가 모든 그래프를 한꺼번에 스케줄하여 Animation Job Thread에 분산합니다.
문제는 수동 업데이트입니다. PlayableGraph.Evaluate()는 해당 그래프 내부의 평가 처리를 병렬화하는 것이며 그래프 간에는 병렬화하지 않습니다. 따라서 "1캐릭터 = 1 Playable Graph"라는 직관적인 구성으로 N체를 제어하면 Evaluate()를 N번 호출하게 되어 N개의 그래프가 직렬로 처리됩니다.
이는 Unity Playable Graph의 피할 수 없는 사양이지만, 대책은 단순하며 N체를 하나의 그래프로 모으는 것입니다.
Renderer는 전체 비활성화하여 애니메이션 평가 비용만 추출했습니다. 프레임 시간의 중앙값입니다.
표 1.6: 제어 방법과 캐릭터 수별 프레임 시간
| 제어 방법 | 100체 | 300체 | 600체 |
|---|---|---|---|
| Animator 컴포넌트 | 0.899 ms | 2.413 ms | 5.004 ms |
| Playable API + 자동 업데이트 | 0.899 ms | 2.498 ms | 5.093 ms |
| Playable API + 수동 업데이트(1체 1그래프) | 1.395 ms | 3.925 ms | 8.396 ms |
| Playable API + 수동 업데이트(전체 1그래프) | 0.800 ms | 2.115 ms | 4.500 ms |
Animator와 Playable 자동 업데이트는 거의 같은 비용입니다. "Playable API가 더 가볍다"라는 주장은 적어도 "단순한 애니메이션 제어" 조건에서는 성립하지 않습니다. Animator의 스테이트 머신 평가(AnimatorControllerPlayable.PrepareFrame)는 600체에서도 전체 스레드 합계 2.624 ms이고 그중 메인 스레드는 0.161 ms에 불과해, 이 처리가 병목이 될 가능성은 매우 낮다고 할 수 있습니다. (다만 2 ms를 크다고 볼지 작다고 볼지는 게임에 따라 다르다고 생각합니다.)
수동 업데이트는 그래프 구조에 따라 1.87배 차이가 납니다. 같은 600체라도 그래프를 묶는 방식에 따라 자동 업데이트보다 빠르기도 하고 느리기도 합니다.
600체, Renderer 비활성화. Unity Profiler 캡처에서 스레드별로 집계한 결과입니다.
표 1.7: 그래프 단위에 따른 스레드 사용 방식의 차이(600체)
| 1체 1그래프(600그래프) | 600체 1그래프 | |
|---|---|---|
| 프레임 시간 | 9.490 ms | 4.928 ms |
메인 스레드의 Evaluate | 9.028 ms | 4.556 ms |
| 애니메이션 처리를 담당한 워커 스레드 수 | 5 | 15 |
Animator.ProcessGraph 전체 스레드 합계 | 0.920 ms | 19.034 ms |
워커 1개당 Animator.ProcessGraph | 0.32 / 0.15 / 0.03 / 0.02 / 0.002 ms | 각 약 1.19 ms(15개 거의 균등) |
1체 1그래프일 때 애니메이션 처리는 거의 전부 메인 스레드 위에 있습니다. 600번의 Evaluate()가 순서대로 실행될 뿐이며, 워커 스레드는 거의 일하지 않습니다.
600체 1그래프일 때 15개 워커 스레드 모두가 거의 균등하게 Animator.ProcessGraph를 실행합니다.
그림 1.5: 1체 1그래프(600그래프)의 Timeline. 메인 스레드의 ATB.ManualEvaluate가 약 8.6 ms를 차지하고, Job 워커 대부분이 "Idle" 상태로 남아 있다.
그림 1.6: 600체 1그래프의 Timeline. 메인 스레드의 ATB.ManualEvaluate는 약 4.3 ms로 절반이 되었고, 모든 워커가 약 1.2 ms씩의 EngineJob를 동시에 실행하고 있다.
수치를 읽을 때 한 가지 보충할 점이 있습니다.
이 표의 수치는 Unity Profiler를 연결한 상태의 캡처에서 가져왔기 때문에, Profiler 없이 실행한 계측(4.500 ms / 8.396 ms)보다 약간 무겁게 나옵니다. 장 끝의 정리 표는 Profiler 비연결 수치를 사용합니다.
표 1.8: 600체에서의 프레임 시간 정리
| 구성 | 600체의 프레임 시간 |
|---|---|
| 1체 1그래프 + 수동 업데이트 | 8.396 ms |
| Animator(자동) | 5.004 ms |
| 600체 1그래프 + 수동 업데이트 | 4.500 ms |
'프로시저럴하게 애니메이션을 제어하고 싶다'는 요구는 게임 개발 중에 반드시 발생합니다. 예를 들어 '캐릭터의 발을 지면 형상에 맞게 접지하도록 움직이고 싶다'와 같은 경우입니다.
이 경우 취할 수 있는 수단으로 Two-Bone IK라는 기법이 있습니다.
실제로 Two-Bone IK와 같은 프로시저럴 애니메이션 제어를 구현할 때 선택지는 세 가지입니다.
LateUpdate에서 Transform을 직접 덮어쓴다AnimationScriptPlayable에 IAnimationJob를 삽입한다이번에는 이 방법들의 퍼포먼스에 주목합니다.
세 가지 모두 완전히 동일한 연산을 수행하도록 구현했습니다. 해법은 하나의 static 메서드로 분리했고 LateUpdate 버전·Job 버전·Burst 버전이 이를 공유하므로, 다른 점은 실행되는 위치뿐입니다.
Animation C# Jobs는 AnimationScriptPlayable와 IAnimationJob를 통해 애니메이션 평가 파이프라인 안에 자체 처리를 삽입하는 구조입니다. 잡은 클립 평가나 블렌딩과 같은 흐름 안에서 호출되며, 본에는 AnimationStream와 TransformStreamHandle를 통해 접근합니다.
LateUpdate와의 차이는 코드가 동작하는 위치입니다. LateUpdate는 메인 스레드 위에서 애니메이션 평가가 끝난 뒤의 Transform을 직접 덮어씁니다. 처리는 캐릭터 수만큼 메인 스레드에 직렬로 쌓입니다. Animation Job은 평가 파이프라인 내부(Animation Job Thread)에서 호출되므로, Unity는 다른 애니메이션 평가와 마찬가지로 워커 스레드에 분산할 수 있습니다. 같은 연산이라도 메인 스레드의 부담이 되는지 여부가 달라집니다.
AnimationStream를 경유해야 한다는 접근 제약은 그 대가입니다. 잡에서 보이는 것은 사전에 묶어 둔 핸들뿐이며, 임의의 Transform이나 GameObject는 다룰 수 없습니다.
잡 본체는 ProcessAnimation에서 해법을 호출할 뿐입니다.
리스트 1.6: Two-Bone IK의 Animation Job(Burst 대응)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | [BurstCompile] public struct TwoBoneIkJobBurst : IAnimationJob { public TransformStreamHandle Root; public TransformStreamHandle Mid; public TransformStreamHandle Tip; public NativeArray<float3> Target; // 길이 1. 매 프레임 메인 스레드에서 기록한다 public float Weight; public int Iterations; public void ProcessRootMotion(AnimationStream stream) { } [BurstCompile] public void ProcessAnimation(AnimationStream stream) { TwoBoneIkCore.SolveStreamIterations( ref stream, Root, Mid, Tip, Target[0], Weight, Iterations); } } |
본의 읽기·쓰기는 TransformStreamHandle와 AnimationStream의 조합으로 수행합니다.
리스트 1.7: AnimationStream에서 본을 읽고 쓰기
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | static void SolveStream( ref AnimationStream stream, TransformStreamHandle root, TransformStreamHandle mid, TransformStreamHandle tip, float3 target, float weight) { var rootPosition = (float3)root.GetPosition(stream); var midPosition = (float3)mid.GetPosition(stream); var tipPosition = (float3)tip.GetPosition(stream); var rootRotation = (quaternion)root.GetRotation(stream); var midRotation = (quaternion)mid.GetRotation(stream); Solve(rootPosition, midPosition, tipPosition, target, rootRotation, midRotation, weight, out var newRootRotation, out var newMidRotation); mid.SetRotation(stream, newMidRotation); root.SetRotation(stream, newRootRotation); } |
핸들은 Animator.BindStreamTransform로 만들고, 잡은 클립의 후단으로 그래프에 삽입합니다.
리스트 1.8: Animation Job을 그래프에 삽입하기
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | var job = new TwoBoneIkJobBurst { Root = animator.BindStreamTransform(rootTransform), Mid = animator.BindStreamTransform(midTransform), Tip = animator.BindStreamTransform(tipTransform), Target = ikTarget, // NativeArray<float3> Weight = 1f, Iterations = iterations, }; var ikPlayable = AnimationScriptPlayable.Create(graph, job, 1); graph.Connect(clipSource, 0, ikPlayable, 0); // clipSource = 클립 쪽 믹서 ikPlayable.SetInputWeight(0, 1f); output.SetSourcePlayable(ikPlayable); |
IK 타깃처럼 매 프레임 바뀌는 입력은 NativeArray에 두고 메인 스레드에서 씁니다.
Burst는 클래스에 BurstCompile 속성을 부여하면 적용됩니다.
Burst Compiler에 의한 최적화가 실제로 적용되고 있는지는 Burst Inspector 등의 도구로 확인할 수 있습니다.
300체, Renderer 비활성화. 1체당 IK 해법 호출 횟수를 1 / 16 / 64로 바꾸었습니다.
표 1.9: IK 해법 호출 횟수별 프레임 시간(300체·Renderer 비활성화)
| 구현 | 1회 | 16회 | 64회 |
|---|---|---|---|
| IK 없음(기준) | 2.501 ms | 2.601 ms | 2.596 ms |
| LateUpdate | 2.699 ms | 4.400 ms | 9.094 ms |
| Animation Job | 2.411 ms | 2.496 ms | 2.795 ms |
| Animation Job + Burst | 2.494 ms | 2.496 ms | 2.792 ms |
CPU 시간 내역(64회일 때):
표 1.10: IK 구현별 CPU 시간 내역(해법 64회)
| 구현 | IK의 CPU 시간(전체 스레드) | 그중 메인 스레드 | 프레임 시간에 대한 가산분 |
|---|---|---|---|
| LateUpdate | 6.910 ms | 6.910 ms(100 %) | +6.498 ms |
| Animation Job | 7.790 ms | 0.487 ms(6.3 %) | +0.199 ms |
| Animation Job + Burst | 6.932 ms | 0.436 ms(6.3 %) | +0.196 ms |
같은 연산이 어느 스레드에서 실행되는지는 Unity Editor의 Profiler에서 확인할 수 있습니다. 아래는 해법 64회·300체의 캡처를 Profiler 창에서 열고 프레임 시간의 중앙값 부근 프레임을 선택한 것입니다. Profiler 연결 상태의 캡처이므로 위 표의 수치와는 완전히 일치하지 않습니다.
그림 1.7: LateUpdate 구현. 프레임 시간 8.698 ms 중 LateBehaviourUpdate가 6.63 ms를 차지한다. 워커 스레드 15개는 프레임의 대부분(약 6.8~7.7 ms)이 Idle 상태이다.
그림 1.8: Animation Job 구현. 같은 연산이 AnimationScriptPlayable.ProcessAnimation로서 워커 스레드 15개에 분산되어 1개당 약 0.5 ms씩 실행되고 있다. 프레임 시간은 2.796 ms.
LateUpdate 구현에서는 300체 분량의 ATB.TwoBoneIk.LateUpdate(합계 6.577 ms)가 모두 메인 스레드의 LateBehaviourUpdate 안에 있습니다. 이때 워커 스레드가 담당하는 것은 기본 애니메이션 평가(Animator.ProcessGraph가 1개당 0.12~0.29 ms)뿐이며, IK 계산은 하나도 실려 있지 않습니다.
Animation Job 구현에서는 AnimationScriptPlayable.ProcessAnimation의 전체 스레드 합계가 7.891 ms이고, 그중 메인 스레드는 0.498 ms입니다. 나머지 7.393 ms는 워커 스레드 15개에 거의 균등하게(1개당 0.47~0.51 ms) 분산되어 있습니다. 총 CPU 시간은 LateUpdate 구현보다 많지만 이것이 16개 스레드로 나뉘므로 프레임 시간은 8.698 → 2.796 ms로 줄어듭니다.
이 사례의 최적화에서 가장 효과적인 것은 Burst가 아니라 실행 스레드를 옮기는 것입니다. LateUpdate 구현은 IK의 CPU 시간을 통째로 메인 스레드에서 부담하므로, 64회에서는 프레임 시간이 2.596 → 9.094 ms로 늘어납니다. 같은 연산을 Animation Job으로 옮기면 메인 스레드의 부담이 거의 사라지며, 프레임 시간에 대한 가산분에서 33배의 차이가 납니다.
한편 총 CPU 시간에서는 LateUpdate 버전이 더 적습니다. Animation Job에는 AnimationScriptPlayable 노드당 고정 비용이 있으며, 300체 기준 약 2.16 ms입니다. 그래도 병렬화의 이득이 훨씬 큽니다.
실제 운용에 가까운 "1체 1회" 조건에서는 세 구현의 차이가 거의 계측 노이즈 범위입니다. 300체 정도의 Two-Bone IK라면 어느 구현을 선택해도 체감 차이는 없습니다.
[BurstCompile]를 붙이면 SIMD가 적용되어 몇 배 빨라진다는 이해는 잘못된 것입니다. 이번 실측에서 Burst에 의한 총 CPU 시간 절감은 11.0 %입니다. 노드 고정 비용을 뺀 "해법 1회당 한계 비용"으로 보면 다음과 같습니다.
Burst가 수행하는 것은 LLVM에 의한 네이티브 코드 생성이며, SIMD 명령이 생성되는지는 작성된 코드의 형태로 결정됩니다. 적절하게 SIMD화되는 코드를 구현하지 않으면 큰 이점을 얻을 수 없습니다.
Animation C# Jobs에는 SIMD화를 어렵게 하는 성질이 두 가지 있습니다.
AnimationStream의 읽기·쓰기가 처리 안에 빈번하게 나타납니다. 해법 1회마다 GetPosition·GetRotation로 본을 읽고 SetRotation로 다시 씁니다. 반복할 때마다 스트림의 상태가 바뀌므로 반복 간에 의존 관계가 남아, 여러 회분을 묶어 하나의 벡터 명령으로 만들 수 없습니다. 게다가 IAnimationJob가 받는 AnimationStream는 1체 분량이므로, "4체 분량을 동시에 계산"하기 위한 데이터가 애초에 잡 안에 없습니다.
초월 함수 사용이 빈번합니다. 코사인 법칙으로 굽힘 각도를 구하는 acos, quaternion.AxisAngle가 내부에서 호출하는 삼각함수, math.length의 제곱근. 이들은 레인을 가로지르는 계산이나 스칼라 라이브러리 호출이 되어 벡터화를 막습니다.
Burst의 LLVM도 해법을 반복 호출하는 루프를 벡터화할 수 없다고 명시적으로 보고합니다(최적화 리마크 loop not vectorized: instruction return type cannot be vectorized).
Burst의 이점을 최대한 활용하려면 C# Job System를 사용하여 AnimationStream를 거치지 않고 Transform을 읽고 쓰는 구성이 후보가 됩니다. Transform의 읽기·쓰기는 TransformAccessArray와 IJobParallelForTransform로 잡 측에 모을 수 있고, 본 수집과 쓰기 반영만 Animation Job에 맡기고 무거운 계산은 N체 분량을 모은 배치 잡으로 분리하는 형태도 가능합니다. 어느 쪽이든 캐릭터 단위가 아니라 N체 분량의 데이터가 하나의 잡 안에 갖춰지므로 SIMD화의 전제가 성립합니다.
그 위에서 배치 잡의 내용은 다음과 같이 정리해야 합니다.
math.select나 math.max로 치환하여 루프 본체를 한 줄기의 직선으로 만든다float3가 아니라 float4로 "4체 분량을 1레인씩" 작성하여 자동 벡터화의 판단에 의존하지 않는다대가는 설계의 복잡함입니다. IAnimationJob는 그래프에 노드를 하나 삽입하는 것만으로 평가 파이프라인의 올바른 타이밍에 본의 최신 상태를 다룰 수 있다는 간편함이 가장 큰 장점인데, 이 방안은 그것을 포기하게 됩니다. 이 글에서는 이 구성의 실측을 수행하지 않았으므로 얼마나 빨라지는지는 여기서 제시하지 않습니다. Burst에 몇 배의 효과를 기대한다면 이 방향의 설계 변경이 전제가 된다는 제안으로 받아들여 주십시오.
AnimationScriptPlayable의 노드 고정 비용이 더 크다. 단순하게 LateUpdate에서 작성하는 편이 총 CPU 시간이 적다[BurstCompile]는 붙이는 것만으로는 SIMD가 되지 않는다. SIMD가 적용될 것을 염두에 둔 데이터·처리 설계가 필요하다게임 사양에 따라 다르지만 기본적으로 먼 캐릭터까지 매 프레임 업데이트할 필요는 없습니다. N프레임에 한 번만 업데이트하면 그 캐릭터의 평균 부하는 1/N이 됩니다. 이것이 "애니메이션 LOD"입니다. 메시 LOD가 렌더링 비용을 거리에 따라 단계적으로 제어하는 것과 같은 발상을 애니메이션의 업데이트 빈도에 적용합니다.
효과적인 상황은 다음과 같습니다.
반대로 근거리에서 카메라에 크게 잡히는 캐릭터에 적용하면 끊김이 바로 눈에 띕니다. 거리나 화면 점유율에 따라 솎아내기 단계를 전환하는 것이 상정되는 실제 운용일 것입니다.
Animator.Update()를 직접 호출한다animator.enabled = false로 설정하여 Unity의 자동 업데이트를 멈추고, 필요한 프레임에서만 Animator.Update()를 명시적으로 호출합니다. 건너뛴 프레임 분량의 시간은 누적하여 전달합니다.
이 구현에는 지금까지 설명한 Animation Job Thread에서의 병렬성을 잃는다는 부작용이 있습니다.
리스트 1.9: Animator.Update()를 직접 호출하는 솎아내기 구현
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | animator.enabled = false; // 초기화 시. Unity의 자동 업데이트를 중지한다 void Update() { foreach (var character in _characters) { character.PendingDeltaTime += Time.deltaTime; if (_frameIndex % interval != character.Slot) continue; // Slot = index % interval character.Animator.Update(character.PendingDeltaTime); // 누적한 시간으로 명시적으로 업데이트한다 character.PendingDeltaTime = 0f; } _frameIndex++; } |
수동 업데이트(DirectorUpdateMode.Manual)의 Playable Graph라면 Evaluate() 호출 자체를 솎아내기만 하면 됩니다.
리스트 1.10: 수동 업데이트 그래프의 Evaluate()를 솎아내는 구현
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | graph.SetTimeUpdateMode(DirectorUpdateMode.Manual); graph.Play(); void Update() { foreach (var character in _characters) { character.PendingDeltaTime += Time.deltaTime; if (_frameIndex % interval != character.Slot) continue; character.Graph.Evaluate(character.PendingDeltaTime); character.PendingDeltaTime = 0f; } _frameIndex++; } |
한 가지 주의할 점이 있습니다. 앞 장에서 권장한 "모든 캐릭터를 1개의 그래프로 집약한다"는 구성에서는 그래프 전체를 한 번의 Evaluate()로 평가하므로 캐릭터 단위의 솎아내기를 표현할 수 없습니다. 위 코드처럼 1체 1그래프로 하면 솎아낼 수 있지만, 그만큼 집약에 의한 병렬화를 잃게 됩니다.
어느 구현에서든 중요한 것이 Slot(위상)입니다. 모든 캐릭터를 같은 프레임에 업데이트하면 그 프레임에만 부하가 집중됩니다. index % interval로 위상을 분산하면 매 프레임 1/N체씩 업데이트되는 형태가 됩니다.
600체, Renderer 비활성화. 먼저 Animator에서의 구현입니다.
평균값은 12초 동안의 총 부하를, 중앙값과 p95의 차이는 프레임 시간의 변동 정도를 나타냅니다.
표 1.11: 솎아내기와 위상 분산의 프레임 시간 통계(Animator·600체)
| 방식 | 평균 | 중앙값 | p95 | p99 | 최대 | 표준편차 |
|---|---|---|---|---|---|---|
| 매 프레임 업데이트(Animator 자동) | 5.037 ms | 5.000 ms | 5.303 ms | 5.707 ms | 16.901 ms | 0.433 |
| 2프레임에 1회·위상 없음 | 4.781 ms | 0.648 ms | 9.607 ms | 9.816 ms | 12.194 ms | 4.544 |
| 2프레임에 1회·위상 분산 | 4.831 ms | 4.803 ms | 5.099 ms | 5.212 ms | 8.804 ms | 0.171 |
| 4프레임에 1회·위상 없음 | 2.613 ms | 0.382 ms | 9.470 ms | 9.658 ms | 11.171 ms | 3.863 |
| 4프레임에 1회·위상 분산 | 2.578 ms | 2.593 ms | 2.707 ms | 2.908 ms | 11.722 ms | 0.185 |
Playable API(1체 1그래프·수동 업데이트)에서도 같은 경향입니다.
표 1.12: 솎아내기와 위상 분산의 프레임 시간 통계(Playable API·1체 1그래프)
| 방식 | 평균 | 중앙값 | p95 | 표준편차 |
|---|---|---|---|---|
| 매 프레임 업데이트 | 8.496 ms | 8.493 ms | 9.001 ms | 0.337 |
| 4프레임에 1회·위상 없음 | 2.438 ms | 0.369 ms | 8.855 ms | 3.570 |
| 4프레임에 1회·위상 분산 | 2.486 ms | 2.495 ms | 2.613 ms | 0.173 |
4분의 1로 솎아냈을 때의 평균값은 위상 유무에 따라 거의 변하지 않으며, 총 부하는 거의 같습니다. 위상 분산은 작업을 줄이는 것이 아닙니다.
한편 프레임 시간의 분포에는 다음과 같은 차이가 나타납니다.
중앙값 0.382 ms라는 수치만 보고 "1/13이 되었다"고 기뻐하면, 실제로는 9.5 ms의 스파이크가 4프레임에 한 번 발생하는 게임이 만들어집니다. 위상 분산은 체감 품질을 위한 대책이며, 표준편차 0.185는 매 프레임 업데이트의 0.433보다 안정적입니다.
이 분포의 차이는 Profiler의 CPU 차트에 그대로 나타납니다(모두 Profiler 연결 상태의 캡처이므로 위 표의 비연결 수치보다 무겁게 나옵니다. 또한 차트의 세로축 스케일은 자동 조정이라 두 장이 서로 다릅니다).
그림 1.9: 4프레임에 1회·위상 없음. CPU 차트가 "거의 0 ms인 골짜기 3프레임 + 약 10 ms 스파이크 1프레임"의 빗살 모양이 된다. 아래 Timeline에서는 선택 중인 빈 프레임(0.47 ms)의 양옆에 600체 분량을 한꺼번에 처리하는 ATB.ManualAnimatorUpdate(10.37 ms) 프레임이 보인다.
그림 1.10: 4프레임에 1회·위상 분산. 매 프레임 150체씩 처리하므로 파형은 평탄하고(약 3.5 ms) 스파이크가 사라졌다.
2프레임에 1회 솎아내기는 거의 아무것도 줄이지 못했습니다. 업데이트 횟수를 절반으로 줄였는데도 평균 프레임 시간 절감은 겨우 4.1 %입니다.
이유는 Profiler 마커의 내역을 보면 알 수 있습니다.
표 1.13: 단순한 솎아내기 구현에서 잃게 되는 병렬성
| 방식 | 1프레임에 업데이트하는 체 수 | 메인 스레드의 애니메이션 업데이트 시간 | Animator.ProcessGraph 전체 스레드 합계 | 그중 메인 스레드 |
|---|---|---|---|---|
| 매 프레임 업데이트(Unity의 배치 업데이트) | 600 | Animators.Update 4.046 ms | 20.104 ms | 1.257 ms |
| 2프레임에 1회·위상 분산 | 300 | ATB.ManualAnimatorUpdate 4.532 ms | 0.549 ms | 0.317 ms |
| 4프레임에 1회·위상 분산 | 150 | ATB.ManualAnimatorUpdate 2.316 ms | 0.278 ms | 0.180 ms |
매 프레임 업데이트에서는 작업 중 메인 스레드에 남는 것이 6 %뿐이고 나머지는 워커 스레드에 분산되어 있습니다. 반면 Animator.Update()를 명시적으로 호출하면 그 자리에서 동기적으로 평가되므로 워커 스레드가 거의 사용되지 않습니다. Unity의 배치 업데이트가 가지고 있던 병렬화를 솎아내기와 맞바꾸어 포기한 것입니다.
1체당 메인 스레드 비용으로 환산하면 다음과 같습니다.
직렬화된 순간 1체당 비용이 약 2.3배가 되고, 그 후에는 솎아내기 배율과 관계없이 일정합니다. 즉 2배 솎아내기로 얻는 2배 절감이 직렬화로 인한 2.3배 악화로 거의 상쇄됩니다. 실측 4.1 %는 그 잔차에 불과합니다.
단순한 구현 그대로는 4배 솎아내기(−48.8 %)에서야 비로소 실질적인 효과가 나타납니다. 다만 이것은 피할 수 있는 손실입니다.
Animator.Update()가 동기적인 것은 피할 수 없습니다. 하지만 "Unity의 배치 업데이트에 참여시킬 체를 매 프레임 선택한다"는 형태로 하면 솎아내면서도 병렬성을 유지할 수 있습니다. 두 가지 구현이 있으며, 둘 다 Animator(와 그 스테이트 머신)를 그대로 사용합니다.
Animator.enabled를 전환한다Unity의 애니메이션 업데이트는 그 시점에 활성화된 Animator를 모아 하나의 배치로 만들고 Animation Job Thread에 분산합니다. 따라서 Animator.Update()를 호출하는 대신 해당 프레임에 업데이트하고 싶은 체만 enabled = true로 해 두면 됩니다. 평가는 Unity가 수행하므로 병렬화가 그대로 유지됩니다.
리스트 1.11: Animator.enabled를 전환하는 솎아내기 구현
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // 초기화 시. 건너뛴 프레임만큼은 speed로 만회한다 foreach (var character in _characters) { var animator = character.Animator; animator.keepAnimatorStateOnDisable = true; // 비활성화로 스테이트를 되감지 않는다(필수) animator.speed = interval; // interval 프레임에 한 번만 업데이트되는 만큼을 보정한다 } void Update() { foreach (var character in _characters) { var scheduled = _frameIndex % interval == character.Slot; if (character.Animator.enabled != scheduled) { character.Animator.enabled = scheduled; // Unity의 배치 업데이트에 참여하는 대상을 선택할 뿐이다 } } _frameIndex++; } |
keepAnimatorStateOnDisable = true를 잊어서는 안 됩니다. 기본값인 false에서는 Animator를 비활성화할 때마다 컨트롤러의 스테이트가 되감겨 애니메이션이 진행되지 않습니다.
제약은 Animator.speed가 레이트라는 점입니다. Animator.Update(pendingDeltaTime)처럼 "누적한 Δt를 정확히 전달"할 수 없고, 업데이트 프레임의 Time.deltaTime × interval만큼만 진행됩니다. 프레임 시간이 흔들리는 환경에서는 진행 방식도 흔들리지만 평균 재생 속도는 유지됩니다.
앞 장의 결론은 "N체를 하나의 수동 그래프로 모으면 병렬화된다"였습니다. 이를 솎아내기와 조합합니다. 위상 수만큼 그래프를 만들고 1프레임에 1개만 Evaluate()합니다. 1개의 그래프에는 N/interval체가 들어 있으므로 그 1회의 Evaluate() 안에서 병렬화가 작동합니다.
스테이트 머신을 AnimatorControllerPlayable로서 그래프에 가지게 하면 Animator 컴포넌트에 할당했을 때와 같은 평가가 실행됩니다.
리스트 1.12: 위상별로 그래프를 나누는 솎아내기 구현
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | // 초기화 시: 위상 버킷마다 수동 그래프를 하나씩 만든다 for (var bucket = 0; bucket < interval; bucket++) { var graph = PlayableGraph.Create($"Bucket_{bucket}"); graph.SetTimeUpdateMode(DirectorUpdateMode.Manual); _bucketGraphs[bucket] = graph; } foreach (var character in _characters) { var graph = _bucketGraphs[character.Slot]; character.Animator.runtimeAnimatorController = null; // 평가는 그래프 쪽에 맡긴다 var output = AnimationPlayableOutput.Create(graph, "Animation", character.Animator); output.SetSourcePlayable(AnimatorControllerPlayable.Create(graph, controller)); } foreach (var graph in _bucketGraphs) graph.Play(); void Update() { for (var bucket = 0; bucket < interval; bucket++) _pendingDeltaTime[bucket] += Time.deltaTime; var scheduled = _frameIndex % interval; _bucketGraphs[scheduled].Evaluate(_pendingDeltaTime[scheduled]); // 한 프레임에 한 버킷만 _pendingDeltaTime[scheduled] = 0f; _frameIndex++; } |
이쪽은 Evaluate()에 누적한 Δt를 그대로 전달할 수 있으므로 진행이 정확합니다. 대신 그래프를 솎아내기 단계 수만큼 가지게 되며, LOD 단계를 동적으로 바꾸려면 캐릭터를 그래프 사이에서 옮겨야 합니다(출력을 다시 만듦).
600체, Renderer 비활성화. 평균 프레임 시간과 매 프레임 업데이트(5.037 ms) 대비 절감률입니다.
표 1.14: 세 가지 솎아내기 구현의 프레임 시간 비교(600체)
| 구현 | 1/2 솎아내기 | 1/4 솎아내기 |
|---|---|---|
Animator.Update()를 명시적으로 호출(위상 분산) | 4.831 ms(−4.1 %) | 2.578 ms(−48.8 %) |
Animator.enabled 전환 | 3.350 ms(−33.5 %) | 1.835 ms(−63.6 %) |
위상별 수동 그래프(AnimatorControllerPlayable) | 3.082 ms(−38.8 %) | 1.541 ms(−69.4 %) |
단순한 구현이 가장 수지가 맞지 않습니다. 특히 1/2 솎아내기에서의 차이가 커서, −4.1 %가 −33.5 % / −38.8 %가 되었습니다. "2배 솎아내기는 병렬화 손실에 잠식된다"는 앞 절의 결론은 구현을 바꾸면 성립하지 않습니다.
병렬성이 유지되고 있다는 것은 Profiler 마커로 확인할 수 있습니다.
표 1.15: 세 가지 솎아내기 구현의 스레드별 내역
| 방식 | Animator.ProcessGraph 전체 스레드 합계 | 그중 메인 스레드 | 메인 스레드 측 업데이트 시간 |
|---|---|---|---|
| 매 프레임 업데이트(600체/프레임) | 20.104 ms | 1.257 ms | Animators.Update 4.046 ms |
Animator.Update()·1/4(150체/프레임) | 0.278 ms | 0.180 ms | ATB.ManualAnimatorUpdate 2.316 ms |
Animator.enabled·1/4(150체/프레임) | 4.424 ms | 0.288 ms | Animators.Update 1.104 ms + 전환 0.125 ms |
| 위상별 수동 그래프·1/4(150체/프레임) | 4.253 ms | 0.279 ms | ATB.BucketEvaluate 1.278 ms |
새로운 두 구현에서는 Animator.ProcessGraph의 작업량이 매 프레임 업데이트의 정확히 1/4이 되며(1프레임에 150체만 평가하므로 당연합니다), 그 작업은 여전히 워커 스레드에 분산되어 있습니다. 반면 Animator.Update() 버전은 이 마커에 거의 계상되지 않고, 작업의 본체는 메인 스레드의 ATB.ManualAnimatorUpdate 안에 있습니다.
1체당 메인 스레드 비용으로 나열하면 차이가 분명해집니다.
Animator.enabled 전환·1/4: 1.229 ms / 150체 = 8.2 µs/체Animator.Update()·1/4: 2.316 ms / 150체 = 15.4 µs/체새로운 두 구현은 1체당 비용이 거의 매 프레임 업데이트 그대로입니다(차이는 전환이나 Evaluate 호출 오버헤드). 그래서 솎아내기 배율이 그대로 절감률에 가까워집니다.
재생 속도가 유지되고 있다는 점도 확인했습니다. 마지막에 스폰한 캐릭터(위상 슬롯이 가장 큰 체)의 손가락 본이 1초당 움직이는 거리를 계측하면, 매 프레임 업데이트 0.509 m/s에 대해 Animator.enabled 전환 0.496 m/s, 위상별 수동 그래프 0.517 m/s입니다. 솎아내기로 움직임이 직선으로 근사되는 만큼의 오차 범위 내에서, 어느 쪽도 속도가 변하지 않았습니다.
분포에 대해서는 한 가지 주의할 점이 있습니다. 1/2 솎아내기의 수동 그래프 버전은 p99가 9.102 ms, 표준편차가 1.086으로, enabled 전환 버전(p99 4.100 ms, 표준편차 0.562)보다 꼬리가 길게 나왔습니다. 1/4에서는 반대로 수동 그래프 버전이 더 안정적입니다(표준편차 0.306 대 0.340). 그래프 개수와 1개당 체 수의 균형에 따라 동작이 달라지므로, 채택한다면 평균뿐 아니라 p99도 확인하십시오.
어느 쪽을 택할지는 요구 사항에 따라 다릅니다. Animator의 스테이트 머신과 인스펙터 워크플로를 그대로 계속 사용하고 싶다면 Animator.enabled 전환이 압도적으로 간편합니다(몇 줄이면 되고 기존 코드에 대한 영향도 거의 없습니다). 진행의 정확성이나 Animation Job 삽입과 같은 그래프 측 제어가 필요하다면 위상별 수동 그래프입니다. 어느 쪽이든 Animator.Update()를 직접 호출하는 구현을 선택할 이유는 없습니다.
Animator.Update()를 직접 호출하는 구현은 선택하지 않는다. 그 자리에서 동기 평가되므로 잡 병렬화를 잃고, 1체당 메인 스레드 비용이 6.7 → 15.4 µs로 약 2.3배가 된다Animator.enabled 전환(keepAnimatorStateOnDisable = true와 Animator.speed에서의 보정이 필요)이나, 위상별로 수동 그래프를 나누어 AnimatorControllerPlayable를 올리는 형태이다. 1체당 8.2~8.5 µs로 수렴하며 병렬화도 남는다각 장의 실측 결과입니다.
표 1.16: 각 장의 실측 결과 정리
| 장 | 시책 | 조건 | 결과 |
|---|---|---|---|
| 에셋 설정 | Optimize Game Objects | 600체·Renderer 1개 | 6.797 → 5.991 ms(−11.9 %) |
| 에셋 설정 | Optimize Hierarchy(런타임) | 600체·Renderer 1개 | 6.797 → 6.009 ms(−11.6 %) |
| 에셋 설정 | 위 두 방법 | 300체·전체 Renderer | 18.205 → 18.3~18.5 ms(개선 없음·렌더링 병목) |
| Job Thread | 수동 업데이트 그래프를 1개로 집약 | 600체 | 8.396 → 4.500 ms(−46.4 %) |
| Animation C# Jobs | Two-Bone IK를 Animation Job화 | 300체·64회 해법 | 9.094 → 2.795 ms(−69.3 %) |
| Animation C# Jobs | 추가로 Burst로 컴파일 | 300체·64회 해법 | 2.795 → 2.792 ms(프레임 시간은 차이 없음) |
| 애니메이션 LOD | 1/4 솎아내기·Animator.Update()를 명시적으로 호출 | 600체 | 평균 5.037 → 2.578 ms(−48.8 %) |
| 애니메이션 LOD | 1/4 솎아내기·위상별 수동 그래프 | 600체 | 평균 5.037 → 1.541 ms(−69.4 %) |
| 애니메이션 LOD | 1/2 솎아내기·Animator.Update()를 명시적으로 호출 | 600체 | 평균 5.037 → 4.831 ms(−4.1 %) |
| 애니메이션 LOD | 1/2 솎아내기·위상별 수동 그래프 | 600체 | 평균 5.037 → 3.082 ms(−38.8 %) |
이 글의 실측을 바탕으로 한, 대량 캐릭터 애니메이션 최적화의 진행 방법입니다.
1. 에셋 설정을 최적화한다
우선 모델 자체의 버텍스 수와 본 수 사양을 적절한 값으로 정합시다. 기준이 되는 지원 기기에서 벤치마크를 계측하고 적절한 모델 레귤레이션을 정하는 것이 우선 이상적입니다. 그 위에서 Unity 측에서 Optimize Game Objects 적용을 검토합시다. 3D 타이틀에서 상류에 위치하는 모델 제작을 양산이 진행된 프로젝트 후반에 크게 수정하기는 어려우므로, 프로젝트 초반에 수행하는 것이 가장 좋습니다.
2. Animation Job Thread에서의 처리 병렬성을 높인다
Animator와 자동 업데이트 Playable Graph는 Unity가 자동으로 병렬화하지만, 수동 업데이트는 그래프 단위로만 병렬화됩니다. 캐릭터 제어 설계를 하는 시점에서 "Animation Job Thread를 효과적으로 활용할 수 있는가"를 염두에 둡시다.
3. Animation Job의 Burst 적용을 포함한 최적화를 수행한다
IK 등의 후처리는 우선 메인 스레드에서 Animation Job으로 옮깁니다.
더 많은 퍼포먼스가 필요한 경우에는 SIMD화를 적용할 수 있는 데이터·처리 설계를 적용합시다.
4. 애니메이션 LOD 등 애플리케이션 차원의 궁리를 한다
에셋·엔진 설정·코드 최적화를 다 했다면, 마지막은 "애초에 애니메이션을 처리하지 않는다"는 궁리입니다.
프로젝트 요구 사항과 상의해야 하지만, 게임 경험상 허용할 수 있는 선을 탐색하면서 처리를 억제하는 구조를 검토합시다.
DirectorUpdateModeAnimationScriptPlayable에 삽입하는 잡AnimationStream를 거치지 않고 병렬 처리를 작성하기 위한 구조