이번 포스팅에서는 유니티 최적화 중 병렬처리 기법에 대해 다루고자 한다.
유니티의 약점 중 하나가 싱글 스레드 라는 점인데, (엄밀히 말하면 듀얼 스레드다)
씬 로딩화면을 구현할때 ResourceManager.LoadSceneAsync 같은 비동기 함수를 쓰더라도 로딩 중 화면이 끊기는 현상을 자주 접해봤을 것이다.
하지만 로딩화면이야 뭐 사용자가 조작도 할 수 없고 현상도 도드라져 보이지 않게끔 움직이는 UI를 최대한 안쓴다던지 하는 방법으로 타협이 가능한데, 조작 중 끊기는 건 그냥 넘어갈 수가 없다.
FPS 나 액션 게임같은 경우 렉 한방에 파티가 전멸하거나 내 케릭터가 사망하는 경험을 한번이라도 해보았다면, 그 중요성을 실감할 수 있을 것이다.
그래서 우리는 멀티 스레딩을 대신할 방법을 동원해 이를 해결해야 하는데,
첫번째가 Job System, 두번째는 Compute Shader 이다.
이 둘 중 어느 것을 써야 하는지는 병렬 처리 특성에 따라 다르다.
Job System 은 Main Thread 가 아닌 별도의 Job Thread ( 유니티에서의 명칭은 WorkerThread 이므로 이후 WorkerThread라고 명명 ) 를 이용해 CPU가 할일을 다른 CPU 코어한테 나눠주는 것이고, (최신 CPU는 코어 갯수가 최소 3~8개이다) Compute Shader 는 GPU한테 그 역할을 맡기는 것이다.
만약 자신의 프로젝트가 CPU는 바쁜데 GPU는 비교적 널널할 경우 우선 Compute Shader 도입을 고려해야 한다.
GPU는 비용이 작은 스레드를 동시에 처리하는 방식에 특화되어 있기 때문에 스레드 간 데이터 종속성이 없고, 하나의 스레드에 무거운 연산을 하지 않는다면 Job System 보다 효과적인 결과를 얻을 수 있다.
이에 반해 어? 우리는 CPU도 바쁘고 GPU도 바쁜데...이러면 선택의 여지없이 Job System 을 도입해야 한다.
또한 스레드 갯수는 상대적으로 적고 하나의 스레드에서 무거운 연산을 해야 한다면 이 또한 Job System을 도입하는게 유리하다.
개념 설명은 여기까지 하고 우리는 프로그래머니까 코드로 설명하는게 확 와닿을 것이다.
먼저 병목 현상을 인위적으로 만들어 낸 후 이를 Compute Shader 와 Job System 으로 해결하면서 최적화 된 결과를 도출해내는 과정을 설명하겠다.
먼저 단순히 40000 개의 큐브를 월드에 뿌려보자
using UnityEngine;
public class GeneralInstancing : MonoBehaviour
{
[SerializeField] private GameObject instancingTarget;
[SerializeField] private int count = 200;
private void Start()
{
for (int i = 0; i < count; ++i)
{
for (int j = 0; j < count; ++j)
{
GameObject instanced = Instantiate(instancingTarget);
instanced.gameObject.SetActive(true);
float x = -(count * 0.5f) + i;
float z = -(count * 0.5f) + j;
instanced.transform.position = new Vector3(x * 2, 0, z * 2);
}
}
}
}
실제 실행하면 다음과 같이 FPS가 확연히 떨어짐을 체감할 수 있다. (수치값은 PC마다 다를 수 있음)
프로파일러로 확인해보면 RenderLoop.ScheduleDraw() 에서 많은 시간을 할애하고 있는데, 이는 CPU가 GPU에게 40000 번의 드로우콜을 요청하는데 이만큼의 시간이 걸리고 있다는 것을 의미한다. ( = CPU 병목)
그렇다면, CPU를 거치지 않고 우리가 직접 GPU한테 그리라고 명령을 내리면 이 시간이 걸리지 않을테니, 병렬처리 기법에 대해 알아보기 전에 이 작업을 GPU 에게 넘겨보자.
일단 GPU한테 작업을 넘겨야 그 뒤에 GPU한테 병렬처리를 맡길 수 있으니 일단 해보자
using UnityEngine;
public class InstancedProcedural : MonoBehaviour
{
[SerializeField] private Material material;
[SerializeField] private Mesh mesh;
[SerializeField] private int count;
private ComputeBuffer positionBuffer;
private Vector3[] positions;
private static readonly int Positions = Shader.PropertyToID("_Positions");
private void Start()
{
positions = new Vector3[count * count];
}
private void Update()
{
positionBuffer?.Release();
positionBuffer = new ComputeBuffer(count * count, 12);
for (int i = 0; i < count; ++i)
{
for (int j = 0; j < count; ++j)
{
float x = (-(count * 0.5f) + i) * 2;
float z = (-(count * 0.5f) + j) * 2;
positions[(count * i) + j] = new Vector3(x, 0f, z);
}
}
positionBuffer.SetData(positions);
material.SetBuffer(Positions, positionBuffer);
Graphics.DrawMeshInstancedProcedural(
mesh, 0, material, new Bounds(Vector3.zero, Vector3.one), count * count
);
}
private void OnDisable()
{
positionBuffer?.Release();
positionBuffer = null;
}
}
개념은 4만개의 큐브의 위치를 미리 설정해놓고, Graphics.DrawMeshInstancedProcedural 함수를 통해 GPU한테 직접 그리란 명령을 실행하는 것이다.
해당 함수의 매개변수를 보면 Mesh, SubMeshIndex, Material, Bounds, Count 로서 포지션을 셋팅할 수 있는 부분이 없다.
아니 그럼 어떻게 여기에 각각의 큐브의 포지션을 전달합니까?
이를 위해 ComputeBuffer가 필요하다.
ComputeBuffer 생성시 매개변수로, 생성할 갯수와 버퍼의 사이즈를 셋팅해줘야 하는데
4만개의 큐브의 포지션 값을 저장할 것이므로 Count는 4만이고, 사이즈는 바이트 값으로서, float 값 3개 (x, y, z) 를 전달하면 되니까
float (= 4byte) * 3개 = 12 byte
즉 12 를 셋팅해 주면 된다.
이 버퍼를 통해 포지션값을 전달해주면, 쉐이더 내부에서 이 포지션 값을 참조해서 정점에다 셋팅해주면 끝
코드를 보면서 설명하자 (참고로 해당 쉐이더 코드는 URP 기준입니다)
쉐이더 파일을 하나 생성후 다음 코드를 붙여넣자
(CubeShader.shader)
Shader "Custom/CubeShader"
{
SubShader
{
Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "UniversalMaterialType" = "SimpleLit" "IgnoreProjector" = "True" "ShaderModel"="4.5"}
LOD 300
Pass
{
Name "ForwardLit"
Tags { "LightMode" = "UniversalForward" }
Blend One Zero
ZWrite On
Cull Back
HLSLPROGRAM
#pragma prefer_hlslcc gles
#pragma exclude_renderers d3d11_9x
#pragma target 4.5
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Input.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#pragma vertex vert;
#pragma fragment frag;
StructuredBuffer<float3> _Positions;
struct Input
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 normal : NORMAL;
};
v2f vert (Input v, uint instanceID : SV_InstanceID)
{
float3 data = _Positions[instanceID];
float3 localPos = v.positionOS.xyz;
float3 worldPos = data.xyz + localPos;
v2f o;
o.pos = mul(unity_MatrixVP, float4(worldPos, 1.0f));
o.normal = TransformObjectToWorldNormal(v.normalOS);
return o;
}
half4 frag(v2f i) : SV_TARGET
{
Light mainLight = GetMainLight();
i.normal = normalize(i.normal);
float3 lambert = LightingLambert(mainLight.color, mainLight.direction, i.normal);
half3 ambient = SampleSH(i.normal);
lambert *= mainLight.distanceAttenuation * mainLight.shadowAttenuation;
lambert += ambient;
return half4(lambert, 1);
}
ENDHLSL
}
}
}
fragment 쉐이더는 일반적인 램버트 라이팅과 앰비언트를 적용한 코드므로 중요치 않고, vertex 쉐이더 부분을 주의깊게 보자
중간쯤 StructuredBuffer<float3> 이 보인다. 스크립트의 material.SetBuffer() 를 통해 전달된 ComputeBuffer 가 여기에 저장되어 있고,
vertex shader 함수의 SV_InstanceID 를 통해 나의 포지션을 받아 이를 정점위치에 적용시키고 있다.
StructuredBuffer는 일종의 배열이므로 _Positions[index] 문법을 사용할 수 있고, SV_InstanceID 는 각 메시별 고유한 인스턴싱 아이디로서 0 부터 시작하는 int 값이다.
코드 작업이 끝났다면, 유니티에서 메테리얼을 하나 생성 후 해당 쉐이더를 설정해준뒤, Serialize Field를 통해 메시와, 그 메테리얼을 설정해준다.
이제 다시 실행하고 통계창을 확인해보면 FPS가 확연히 높아짐을 확인할 수 있다.
프로파일러 에서도 해당 작업이 시간이 거의 걸리지 않음을 확인할 수 있다.
여기까지만 해도 사실 병목현상은 해결했기 때문에 병렬처리와 무관한 부분이다.
하지만 이제 여기에 큐브가 회전하는 작업을 넣고 싶다면?
일반적인 상황이라면, Update() 함수에서 다음 한줄만 실행하면 된다.
transform.Rotate(Vector3.up, Time.deltaTime * 50f);
그런데 우린 정점 처리를 GPU에다 직접 넣고 있기 때문에 회전 정보도 직접 GPU에 전달해줘야 한다.
위 셰이더 코드 중 vert 함수를 아래 코드로 대체하자
float4x4 RotateYMatrix(float r)
{
float sina, cosa;
sincos(r, sina, cosa);
float4x4 m;
m[0] = float4(cosa, 0, -sina, 0);
m[1] = float4(0, 1, 0, 0);
m[2] = float4(sina, 0, cosa, 0);
m[3] = float4(0, 0, 0, 1);
return m;
}
float4x4 PositionMatrix(float3 pos)
{
float4x4 m;
m[0] = float4(1,0,0,pos.x);
m[1] = float4(0,1,0,pos.y);
m[2] = float4(0,0,1,pos.z);
m[3] = float4(0,0,0,1);
return m;
}
v2f vert (Input v, uint instanceID : SV_InstanceID)
{
float4x4 tfM = mul(PositionMatrix(_Positions[instanceID]), RotateYMatrix(_Time.y));
float4 worldPos = mul(tfM, v.positionOS);
v2f o;
o.pos = mul(unity_MatrixVP, worldPos);
o.normal = mul(v.normalOS, (float3x3)Inverse(tfM));
return o;
}
코드에 대해 설명하자면,
정점의 위치 행렬과 회전 행렬을 구한 후 이를 통해 Model 행렬 (로컬 공간에서 월드 공간으로 가기 위한 행렬) 을 도출해서 fragment 쉐이더에 전달하고 있다.
여기서 3D 그래픽에 대해 간단히 짚고 넘어가야 할 것 같다.
물체의 정점은 vertex shader 에서 여러 변환(MVP 행렬을 곱함) 을 거쳐 fragment shader 로 전달되고 fragment shader 에서 픽셀 연산을 거친 후 (주로 라이팅 연산) 최종적으로 모니터 화면에 뿌려진다.
우리는 현재 shader 내부에서 회전을 해줘야 하기 때문에 직접 회전행렬을 구해야 하고, 위치 행렬또한 직접 구해서 Model 행렬을 도출해내야 한다.
회전 행렬은 어떤 축을 기준으로 회전시키냐에 따라 다른데 우리는 Y축을 기준으로 회전하고 싶으니 아래 행렬을 써야 한다.
또한 위치 행렬은 아래 행렬을 쓴다
이를 이용해 RotateYMatrix 함수에서는
- sincos(r, sina, cosa) 에서 해당 r값(라디안값) 을 사인코사인 값으로 변환
- float4x4 로 4x4 행렬을 생성
- 4개의 행에 회전행렬 요솟값들을 설정
PositionMatrix 함수에서는
- 4x4 행렬 생성
- 포지션 값을 설정
이 두 행렬을 구해서 동일하게 넘겨주면 shader 에서 회전을 연산하는 코드가 완성된다.
실행을 해보면 Update() 에서 실행한 것관 다르게 프레임 저하없이 부드럽게 회전하고 있는 큐브를 확인할 수 있다.
'아 이 사람 뭐지 병렬처리 설명해준담서 이상한 것만 하고 있네'
이제 기반환경이 모두 마련되었으니 Compute Shader 를 어떤때 쓰는지 확인할 시간이다.
이후는 다음 블로그에서 다루기로 하겠습니다.
여기까지의 코드는 아래 링크에서 다운받을 수 있으니 혹여 실행이 안된다면 참고용으로 확인해 보시기 바랍니다.
https://github.com/SeonHwan/ComputeShaderSample/tree/3f2dac345239c940db67b02fe6b7f97132c2ab4d
'Unity > 최적화' 카테고리의 다른 글
Batch 개념 및 종류 (0) | 2021.02.16 |
---|---|
병렬처리 기법 2 (0) | 2021.02.05 |