본문 바로가기

Unity 개발 공부

[내배캠] 본캠 63 쉐이더공부를 위한 기본이론공부

페인트 물감칠 로직을 가져와서 구조물에 칠할때
구조물의 팀별 점유율을 비교해 어느팀이 우세한지 나타낼 예정이다. 그리고 점유율이 높은 팀이
1포인트를 가져가 
구조물당 1포인트씩 더하여 최종결과에서 어느팀이 더많은 포인트를 가져갔냐를 승리 조건으로 만들 예정이다.

그러기위해선, 팀원이 짜놓은
1. 페인트 로직에대한 이해 ( 셰이더, uv, 텍스쳐2d, 메테리얼 관련 api 이용 방법, 이론이해)
2. 구조물 설계를 위해 UV를 어떻게 펴야할건지.
3. 해당 구조물 제작에있어서 효율적이고, 최적화 되는 방향은 없을지
4. 구조물 점수계산 로직

이렇게 절차가 필요하다.
기존의 테스트에서는 정육면체의 모델의 UV전개도로 펼쳤을때 이어져있는 UV맵을 mark seam을 통해
블렌더에서 6면으로 나누어놓은상태다. 그러면 uv 맵 상의 겹치는 구간이 사라져, 한면에 칠해도 다른면이 같이 칠해지는
문제를 해결했다.
문제는 이렇게 일일이 작업하기에는 구조물의 복잡도가 올라가면 모델러의 노가다가 심해지고 시간이 많이걸렸다.
따라서 최소규격의 블럭 하나만 해당 정육면체처럼 uv를 markseam을 통해 나누어 놓고 언렙한뒤에 
엔진상의 코드로 제어해서 빌더를 만든뒤에 조립하는 방식으로 가보려고했다.
몇가지 간단하게 알아본 결과 이러면 드로우콜이 높아질 수 있다는 문제가 보고되었다.

드로우 콜이란 CPU가 GPU에게 이제 이 메쉬를 이 메터리얼과 셰이더로 렌더링해달라고 명령을 내리는 호출을 말한다.
CPU 쪽에서 그래픽 API(OpenGL, DirectX, Vulkan, Metal 등)를 호출 →
내부적으로 GPU 드라이버에 “내려보내는” 명령 버퍼(Command Buffer)에 추가
이 과정을 흔히 “드로우 콜을 한다”라 부른다.

같은말이다. 또다른 같은말로는
CPU가 드로우 콜을하여 GPU에게 셰이더 파이프라인에 메쉬+메터리얼 정보를 전달하는것이다.

그렇다면 앞선방식을 그냥진행한다면 드로우콜 비용이 높아지는 이유는 무엇인가?

CPU가 GPU에 명령을 보낼때마다 버퍼를 플러시하거나 동기화 포인트가생긴다.
이때 외부라이브러리(그래픽드라이버)를 반복해서 호출하는 비용이 커지는건데
머티리얼이 바뀌면 셰이더, 텍스쳐, 블렌드 상태등도 바뀌는데 그때마다 GPU내 상태를 재설정해야해서 비용이 또 커진다.
결과적으로 이 드로우콜이 많으면 병목이 생기면서 프레임레이트가 떨어져버린다.

각 큐브마다 MeshRenderer가 붙어있고, 동일한 머터리얼을 쓰더라도 기본적으로 하나의 MeshRenderer는 하나의
드로우콜인셈이다.
큐브를 조립해서 하나의 구조물을 만들었다쳐도 그안에 각 큐브가 하나의 형태로 meshRenderer를 가지고있다면 
프레임 레이트가 떨어질수밖에없다. UV분리는 드로우콜과는 물론 별개의 사항이다. 단지 그림을 잘 찍기위해 자르는것이다.
UV를 어떻게 잘라놓든 드로우콜 수는 전혀 영향을 주지않는다.
메시 또는 submesh의 개수가 드로우콜 수를 결정한다.

따라서, 메시를 결합해주는걸 생각해보자.
메시결합은 Mesh.CombineMeshes 나 Static Batching 로 수십~수백개의 블록을 한덩어리 메시로 합칠수있다고한다.
말고도 GPU Instancing 이라고 동일메시, 머터리얼을 다수 인스턴스화 하여 한번의 드로우콜로 여거래 그릴수도있고
Dynamic Batching으로 작은 메시끼리 런타임에 묶어서 보내기도 있다고한다.

아래의 예제를 잠깐 보자
// CombineInstances 배열에 개별 큐브의 메시+변환 정보를 담은 뒤…
var mf = gameObject.AddComponent<MeshFilter>();
mf.mesh = new Mesh();
mf.mesh.CombineMeshes(combineInstances, mergeSubMeshes: true, useMatrices: true);

// 한 번의 MeshRenderer 드로우콜로 모든 큐브를 렌더링
gameObject.AddComponent<MeshRenderer>().sharedMaterial = cubeMaterial;
static batching 설정을켜두면 ,에디터 빌드시 자동으로 더 결합되고
occlusion Culling 과 함꼐쓰면 보이지않는 구조물은 GPu에서 아예 생략시킨다
머티리얼 아틀라스로 여러종류 블록 머터리얼을 하나로 묶어 머터리얼 변경도 최소화 가능하다고한다.

이렇게 가능하다고한다. 대충 방법이 있다는것은 알았으니 페인트 로직의 기초적인 이론공부부터 진행하자.


1. 페인트로직 이해
CPU와 GPU의 역할분담에 대해서 먼저 알아보자.

cpu는 
게임로직: 물리계산, ai, 입력처리, 네트워크 동기화
씬관리: 어느 오브젝트를 그릴지 결정, 카메라 뷰결정
드로우콜 : 이제 이 메쉬를 이 머터리얼로 그려주세요 라는 호출을 그래픽 api에 전달 한다면

gpu는 
Shaer 셰이더 실행: 버텍스, 프래그먼트 셰이더로 실제 픽셀 색 계산
Resterize 래스터라이징: 버텍스들이 합쳐진 삼각형 -> 픽셀분해, 픽셸 단위 컬러처리를 한다.
Render Target 렌더타깃: 최종 화면을 버퍼(화면에 보이는 이미지)에 그린다.

명령버퍼(Command Buffer)
: cpu 가 이 순서로 이렇게 그려달라고 미리 쌓아 놓는 큐를 의미한다.
여러 드로우 콜을 한데 모아 최적화 한다.
순서제어: 먼저 그려야할것/ 나중에 그려야 할것을 관리한다.

unity api로는
var cmd = new CommandBuffer();
cmd.name = "Paint Commands";
cmd.DrawRenderer(renderer, material, 0);  // 특정 렌더러(메쉬)와 머터리얼을 그려달라고 추가하는것
cmd.Blit(srcRT, dstRT, material, pass);  // 텍스처 -> 텍스처복사 및 셰이더 적용(우리는 페인트 스탬프 누적이되겠다)
Graphics.ExecuteCommandBuffer(cmd);      // Gpu에게 지금까지 쌓은 명령 전부 실행 요청하는것
cmd.Clear();                             // 다음 페인트를 위해 버퍼(큐)를 비워 다시 쌓을 준비를 하는것.

왜 먼저 CPU와 GPU를 이해해야할까?
페인트 로직 전부가 이 명령 버퍼 위에서
어디에 그릴지
어떤 색, 셰이더로 그릴지
얼마나 크게 그릴지를 API 호출 형태로 GPU 에게 전달하고
GPU 가 그걸 픽셀로 계싼해서 최종화면인 렌더타겟(RenderTarget)에 찍어주기 때문이다.

CPU가 게임흐름 제어하다가 구조물에대해서 그려달라고 드로우콜을 생성한다
GPU가 드로우콜로 받은 메시지를 보고 셰이더를 실행하여 화면(RT)에 픽셀로 츌력하는 흐름이며
그사이에 CommandBuffer가 여러 드로우콜을 모아 순서대로 GPU에 실행 요청을 한다고하면된다.

1-1 페인트로직이해 렌더링 파이프라인
렌더링 파이프라인 단계에는 기본적인 수학적 지식을 복습하고, 버텍스, UV에대해서 먼저 알아보자.
모델(3D 오브젝트) 내부의 한점을 버텍스라하는데
각 버텍스는 (x,y,z) 라고 위치 좌표를 가지고있다.
 Vector3 v0 = position[0]; 이라면
v0.x, v0.y , v0.z가 각각 그 첫번째 버텍스의 x,y,z 위치를 뜻한다.
이 mesh.vertices[0]은 모델파일 내에서 정의된 첫번재 버텍스일뿐이고 
이해를 조금 돕자면 예를들어 정육면체 큐브 프리팹이라면 원점 0,0,0에 잇다할때 
보통 (-0.5,-0.5,-0.5) .... (0.5,0.5,0.5)사이 어딘가의 값들이 배열에 들어있다.
그래서 모델링 툴같은데서 만든경우에도 그 버텍스 버퍼 순서 그대로 배열에 로드되어있고
공간상의 정렬과는 무관하긴하다.. 반드시 왼쪽아래 첫번째 점이 배열의 0번째라던가 그런건 아니란뜻.

아무튼 (x,y,z)라고 위치좌표를 가지고있는데 동차좌표로 (x,y,z,1)로 가지고있을수도있다.
행렬 변화을 수학적으로 깔끔하게 다루기 위해 도입되었고.
3D위치를 (x,y,z,1)로 쓰면 이동, 회전, 스케일을 모두 하나의 4x4 행렬 곱으로 처리할 수 있다.
유니티 셰이더 코드에서 
float4 posH = mul(UNITY_MATRIX_MVP , float4(x,y,z1)); 처럼 쓰고있다고한다.
3x3 행렬만으로는 이동을 표현할 수 없다.

4x4행렬과 (x,y,z,1) 동차 좌표를 쓰면
모델공간 -> 월드공간
월드 -> 카메라(뷰)공간
뷰-> 클립(투영)공간이 모든 변환을 한줄의 매트릭스 곱으로 처리 할 수 있다.

직접 셰이더를 커스터마이즈할때 
페인트스탬프를 정확하게 해당 위치에 찍으려면 
월드좌표 -> 클립좌표-> 스크린좌표->UV 좌표 변환과정을 직접 구현하거나 이해해야하는데 따라서동차좌표
4x4행렬에 대한 이해가 필요하다. 물론 Unity가 대부분 매트릭스 연산을 감춰서 방식만 이해하면 
어느정도 실제계산은 api를 이용할 수 있다.

먼저 기본적인 수학인,
행렬에 대해서 공부해보자.

우선 내적에 대해서 복습이다.
두 백터 a, b의 내적은 크게 두가지의 공식이 있는데, 먼저
a의길이 곱하기 b의길이(|b|) 곱하기 cos세타다. 
그리고 이 내적은 = a, b벡터의 성분이 x,y,z가 있을때
axbx + ayby + azbz 다. 이렇게 두가지 공식이있다.

초점을 a의길이 곱하기 b의길이(|b|) 곱하기 cos세타라는것에 맞춰보면..
a와 b가 단위벡터라 길이가 1이라면 두벡터의 내적은 cos세타다
두벡터가 같은방향을 보고있다면 내적값은 1, 직교한다면 0, 정반대라면 내적값은 -1이다.

그래서 예를들면 재자리서 회전중인 물체가있는데 앞에 방어막이 달려있다고치자. 해당 방어막이 달린것은
회전중인 물체의 앞부분이다.
나는 총을 쏘고있고, 총이 방어막에 막히지만 회전중인 물체가 뒤를 바라보는순간에는 피격효과를 낸다고 가정하자.

[SerializeField]
GameObject spark;

void OnTriggerEnter2D(Collider2D col)
{ SimpleBullet bullet = col.gameObject.GetComponent<SimpleBullte>();
if(bullt = null) {
if(Vector3.Dot ( bullet.velo.noramlized, transform.up) >0) {
if(spark != null) {
Instantiate(spark, gameObject.transform);}
}
}
}
}

Vector3.Dot을(내적을) 진행했는데 해당 총알의 발사방향과 해당 회전물체의 전방(여기선 up) 과의 내적이
0보다 크다면 즉, 총알방향과 회전물체의 정면 방향이 같거나 직각보다 미만이면(회전물체는 마주보는게아니라 뒤돌아있는셈) 맞은걸로 처리해서 불꽃을 나타내는
것을 할수있다. 

백터의 내적의 응용2에 대해서 또 알아보자
여전히 단위벡타일때 나오는 이 cos세타의 경우
빛 처리에서 핵심적인 처리를 해줄 수 있다.

빛을 바라본 방향은 밝고, 반대면은 어둡다.
빛과 평면이 있다고 가정한다면, 우측에 있는 평면이 빛을 받으면 밝아질텐데
빛의 양을 1이라고 가정해보자.  즉 간격(폭)이 1이라고 가정한다.
평면이 수직으로 세워져있다면, 빛이 닿는부분은 그 폭만큼 1일것이다.
근데 평면이 45도 각도라면 빛이 닿는 면이 커질텐데 ... 그게 얼마나 커진지 살펴보자.

벡터간의 투영길이를 알아보는것이다.
먼저 밑변의 길이가 1(빛의 크기), 빗변의 기울어진 x가 있다.
근데 또 추가로 단위원을 상상하면 반지름이 1이다, 이걸 빗변이라고 치자면 2차좌표계에서
밑변의 길이가 cos세타, 빗변이 1인 삼각형을 상상해보는것이다.

삼각비에 따라서, x:1 = 1: cos세타 가된다
두 삼각형은 서로 크기는 다르지만 삼각형 모양이같기때문에(세각이 동일하기때문에) 삼각비가 적용이된다.
x/1 = 1/cos세타
x= 1/cos세타
그렇다면 면이 45도 기울어지게되면 빛의 양이 1이라고할때, 칠해지는 경사면은 1/cos45도 = 1.4이다.

에너지보존의 법칙- 외계에 접촉이없을때 고립계에서 에너지의 총합은 일정하다.
에너지를 받는 면적이 넓어지면 그만큼 넓어진다는 뜻이다. 즉 빛이 닿는 면적이 1/cos세타,, 수직일때는 각도가 0 , cos세타의 세타값이 0이므로
 1/1 즉 칠해지는 면은 1이된다.
빛벡터의 방향과 수직으로 세워진 면의 노말벡터가 서로 마주보면 빛의 세기는 1로 간주된다. (0~1)
서로 마주본다는것은 즉 두벡터의 방향이 일치한다는 의미고 두벡터가 이루는 각도가 0도일테니 코사인값이 1인것이다.
여기서 살짝 헷갈릴 수 있다. 나는 광원에서 물체로 향하는 방향이 빛벡터의 방향으로 인식하고 생각했더니 반대의 값이 나오는것이다.
그런데 그래픽스 조명모델에서의 빛벡터란, 램버트 조명식에서는 표면 점에서 광원쪽으로 향하는 단위벡터로 정의한다. 즉 입사각으로 들어가는 광원의 빛과 반대다.
즉 실제 광자가 날아오는 방향을 뒤집어서 쓰는것이다. 쉽게말해서 그냥 광원에서 날아오는 빛과 반대방향이 빛벡터다.
그러면 빛벡터와 면의 노멀벡터의 방향이 같아지면 (광원에서 쏜 빛이 표면에 정면으로 닿은것이니 ) 빛의 세기가 최대로 나오는것이다.


빛이 닿는 면적의 밝기 = 1/ 빛이 닿는 면적 = 1/ (1/Cos세타) = Cos세타

즉 면이 수직이면서 면적이 빛의양(폭)이 1일때, 닿는 면적의 빛의 밝기는 cos 90 = 0 이라면
수직면보다 45도 바깥으로 기울어진 면은
cos45 = 0.7이다.

그래서 빛의 연산은 내적을 이용해서 빛이닿는 면적과 빛의 세기를 다 계산해준다.
빛 벡터와 해당 면의 닿는 점의 노멀벡터와의 각도를 계산해서 라이팅 결과로 보여준다.

빛을 완전히 바라보면 1,1,1 
빛의 방향과 노말벡터가 수직인 부분은 cos90 = 0인데 그러면 0,0,0 이된다

쉐이더 코드중 내적이 쓰인 부분을보자면
half NdotL = dot(s.Normal, lightDir); // s의 노멀벡터, 빛벡터를 내적해서 NdotL에 담아둔다. 
hlaf4 c; 가 있다면
c.rgb = s.Albedo * _LightColor0.rtb * (NdotL * atten); // 알베도 곱하기 빛의 세기 곱하기 내적값해서 rgb에 넣어준다.
c.a = s.Alpha;
return c;


다음은 행렬에 대해서 복습해보자.
행렬이 필요한 이유는 transform이 내부적으로 행렬식으로 다뤄지고있기때문이다.
행렬의 곱셈은 내적으로 이루어져있다고 보면된다.
2x2 행렬의 각 행을 a벡터, b벡터라고 하자. 행벡터라고할수있다.
a벡터 [a11 a12]  
b벡터 [a21 a22]
그리고 이 2x2행렬과 곱해줄 2x2행렬의 각 열을 c벡터, d벡터라고 해보자. 열벡터라고도한다.

c벡터   d벡터
[ b11   b12]
[ b21   b22]

이 곱셈은 행렬 곱셈식에따라서 1행을 1열과 곱한값이 1행1열에, 1행을 2열과 곱한것을 1행 2열에
2행을 1열과 곱한것을 2행 1열에 2행을 2행과 곱한것을 2행 2열에 그 값을주는데
곱셈식은 각 행과 각열의 성분을 곱한것을 더해주는것이기때문에

= [a11b11 + a12b21  a11b12 + a12b22]
   [a21b11 + a22b21  a21b12 + a22b22]
인데 이 2x2행렬의 각 성분들은 각각 a와 c의 내적값, a와 d의 내적값
2행부터는
b와c의 내적값, b와d의 내적값이다.

 즉 행렬의 곱셈은 각벡터의 내적의 값들의 조합이다.
다음 공부는 행렬로 회전,이동,스케일을 간단하게 해볼예정입니다.