#

Shader Variable Value Accumulation System / Previous Frame Data Painting

/ email – joohangi1@naver.com / tel – 010.7249.2623

  • 쉐이더 단계에서 선언된 변수의 값에 매 프레임 값을 누적하는 시스템.
  • 프레임 간 랜더 텍스처를 이용한 컴퓨트 쉐이더 작업.
  • 시뮬레이션 대규모 연산의 새로운 GPU 파이프라인의 초기 기획.

//

오랜 시간 게임을 제작하면서 GPU로 대량의 충돌 연산이나 시뮬레이션을 할 수 있는 방법을 고민하였다. 좀 더 자세히 서술해 보자면 충돌 연산과 같은 대규모 연산이 CPU에서는 가능하지만 GPU에서는 어려운 이유가 바로 쉐이더에서 선언된 변수는 매 프레임마다 값을 누적할 수 없기 때문이다. 만약 값을 계속 누적해 나갈 수 있는 시스템이 쉐이더에 마련된다면 충돌 연산 뿐만 아니라 더 많은 연산들이 최적화될 것이다. 4년전부터 연구를 시도했던 이 기획은 최근에 단서를 찾아 개발에 성공했다.

– DirectX를 이용하여 랜더링 최적화에 관해 실제 작업해보았던 랜더 텍스처 예시.

단서는 텍스처에서 찾을 수 있었다. DirectX로 랜더링에 대한 개발경험을 쌓은 이후 텍스처에 대해서도 계속 많은 연구를 해왔다. 게임 제작에 있어서 텍스처란 다양한 파이프라인를 넘나들며 가장 대용량의 정보를 전달할 수 있는 효과적인 수단이다. 또한 GPU에서 CPU로 정보를 전달할 수 있는 특수성을 가지고 있다.

– Depth Texture 실제 예시.
– Depth Texture를 이용한 그림자 구현 예시.

하지만 텍스처는 0-1 사이의 값만 저장할 수 있기 때문에 텍스처는 저장할 때 몇가지 추가 처리 과정이 동반된다. 디퍼드 쉐이더를 예시로 들어보겠다. Depth Texture의 경우 실제 카메라와 해당 픽셀의 월드 스페이스 좌표를 구한 뒤에 그 값을 원평면의 수치만큼 나누게 되면 1을 벗어나지 않는 수치로 변환된다. 물론 그 과정에서 데이터 손실이 일어나기 때문에 그림자를 구현할 때 Bias라는 수치를 고려하게 될 것이다.

– Unity Document에 정의된 Texture Format List.

결국 텍스처를 통한 정보 전달의 단점이 있다고 한다면 수치의 정확도를 고려해야 한다는 것이다. 그래서 필자는 텍스처를 이용해 정보를 전달을 할 때마다 이 부분을 가장 많이 고려하여 개발을 기획을 한다. 그간 경험을 토대로 보자면 텍스처의 정확도를 높이기 위해서는 저장할 객체의 최대 값과 최소 값을 꽤나 아슬아슬 할 때까지 정의해야 할 것이다.

– Unity Document에 정의된 C#에서 Pixel Data를 수정하는 방법.

추가적으로 고려되어야 하는 것은 수 많은 픽셀을 가진 텍스처에 어떻게 정보를 작성하는 게 성능 저하를 덜 가져올지 감안하여야 한다. 예를들어 C# 코드로 1024 X 1024 텍스처의 픽셀에 데이터를 저장한다면 한 프레임당 100만번 연산되어 심각한 성능저하를 동반하게 될 것이다. 유니티에서 텍스처를 사용할 때 다양한 고민들과 문제점들이 발생하게 되는데 문서를 작성해나가면서 해결방법을 제시해나가고자 한다.

– 실제 고안해 개발했던 텍스처 인스턴싱 초기화 과정.
– 모바일에서 1000개의 메쉬의 애니메이션을 구현하기 위한 인스턴싱 세팅.
– 인스턴싱 텍스처를 매 프레임 업데이트.
– Unity 환경에서 텍스처를 이용한 1000개 인스턴싱의 실제 성능 결과.
– Unity 환경에서 1000개 이상 객체들의 애니메이션을 인스턴싱 실험했던 실제 영상.

본론으로 돌아와서 이 문서에서 제시할 메인 기획은 쉐이더 내부에서 선언된 변수가 매 프레임마다 값을 누적해 나가는 방법을 고안하는 것이다. 이것을 구현하기 위해 처음 생각했던 방법은 매 프레임 C#으로 픽셀을 작성하고 나서 그 텍스처를 쉐이더 내부에서 사용하는 것이다. 그리고 이것을 모바일과 PC 모두에서 사용해보았다. C# 내부에서 픽셀을 작성하고 텍스처를 매 프레임 갱신해나가는 것은 그 자체만으로 성능 저하를 발생시킨다. 하지만 텍스처를 사용하지 않는 기존 방식보다는 훨씬 최적화가 잘 되지만 근본적인 해결책은 방법은 아니었다.

– 실제 VR로 제작해 보았던 지형 메쉬를 쉐이더로 페인팅하는 방식.

그래서 텍스처의 정보 작성조차 쉐이더로 하는 것을 목표로 개발을 하게 되었다. 텍스처를 쉐이더로 작성하는 방식은 두가지가 존재한다고 분류할 수 있다. 첫번째로는 메쉬에 접근하여 텍스처를 쉐이더로 페이팅하는 것인데 이렇게 제작한 텍스처는 버텍스 쉐이더 내부에서 선언된 변수 값을 누적하기 위한 용도로 사용하기 어렵다. 왜냐하면 화면에서 가려진 정점에 대해서는 픽셀 쉐이더 단계에서 정보를 저장할 수 없기 때문이다. 그리고 객체마다 1개의 텍스처를 가지고 있어야 하며 1개의 텍스처를 정보를 갱신할 때마다 사용할 함수나 작업들은 성능을 심하게 소모하는 문제점도 발생하게 된다.

– 레스터 라이즈 단계에서 가려진 정점들의 픽셀들이 탈락하는 과정.

두번째 방식으로는 여러 오브젝트들을 포워드 쉐이더 단계에서 하나의 랜더 텍스처에 정보를 저장하는 방식이다. 이 방식은 위에 제시하였던 Depth Texture를 생성하는 방식과 닮아있다. 1장의 텍스처에 모든 정보를 저장하여 갱신할 수 있다는 장점이 있지만 첫번째 방식과 마찬가지로 가려진 버텍스에 대해서는 픽셀 쉐이더 단계에서 정보를 저장할 수 없다.

– 랜더링 파이프라인 연산 과정.

이 문제는 3D 공간 좌표에서 당연히 일어나는 과정이다. 애초에 카메라 시야각을 기반으로 보이는 부분만 화면에 출력하는 과정이 랜더링 파이프라인 과정 중 하나이기 때문이다. 그렇기 때문에 기존의 랜더링 파이프라인을 사용하지 않고 새로운 공간 좌표를 생성할 필요가 있었다.

– 흔히 마야에서 ColorMap을 매핑하기 위한 UV좌표 기획 예시.

새로운 공간 좌표는 폴리곤이나 버텍스들이 겹치지 않도록 계산하여 탈락되는 픽셀이 없도록 기획하여야 한다. 그래서 처음에는 2D 좌표로서 모델링 단계서부터 겹치지 않도록 기획되는 UV를 사용하고자 하였으나 몇가지 문제점들이 발생했다. 1장의 텍스처에 여러 오브젝트를 출력할 때는 UV는 반드시 겹치기 마련이며 UV란 ColorMap을 매핑하기 위한 용도로써 기획된 것인데 데이터 저장 용도로 쓰게되다면 너무 넓은 공간에 대해서 픽셀 연산을 하게 된다. 그래서 텍스처에 데이터를 저장하기 위한 용도의 새로운 공간좌표를 기획하게 되었다. 이 부분에 대해서는 아래에서 그림 자료 및 코드와 함께 해결책 제시해보도록 하겠다.

– Unity 내부에서 랜더 택스처를 생성.

일단 여러 오브젝트를 새로운 랜더 텍스처에 URP 환경에서 출력할 수 있도록 유니티를 세팅하는 과정이 필요할 것이다. 그러려면 일단 랜더 텍스처를 1개 생성해준다. 그리고 저장되는 데이터의 정밀도를 올리기 위해 Color Format을 다음과 같이 생성해준다. FilterMode도 Point로 변경해 주어야 인접 픽셀에 대한 블러 과정이 일어나지 않아 데이터를 안전하게 보관할 수 있다.

– ScriptableRenderPass를 상속받는 스크립트 생성.

랜더링을 위한 추가 패스도 생성해준다. ScriptableRenderPass 클래스를 상속받는 스크립트를 추가해준다. 그리고 방금 유니티에 추가한 랜더 텍스처를 변수로 선언해 준 후 할당해준다.

– ScriptableRenderPass 제작에 관한 추가 세팅 과정.

추가적으로 랜더 텍스처에 픽셀을 채우고 비우기 위한 기능을 세팅해준다. 이는 마치 DirectX에서 전면 버퍼와 후면 버퍼를 교체해가면서 출력하는 랜더링 과정을 떠올리면 이해하기 편할 것이다.

– ScriptableRendererFeature에서 상속받는 스크립트를 작성.

방금 제작한 RenderPass를 Unity RenderPass 목록에 추가하기 위해서 ScriptableRendererFeature 클래스를 상속받아 작성하여야 한다. 랜더 패스의 쉐이더 연산이 진행되는 순서는 출력이 끝난 이후로 설정해둔다.

– Universal Renderer Data에 추가된 Custom RendererFeature.
– Frame Debugger에서도 확인할 수 있는 Custom RenderPass.

아까 제작한 두 스크립트는 MonoBehaviour처럼 따로 Hierarchy에 추가해 줄 필요는 없다. 다음과 같이 Add Renderer Feature 기능을 통해서 아까 제작한 Renderer Feature를 추가해주기만 하면 된다.

– Unity 내부에서 추가 랜더 택스처를 생성.
– 텍스처 정보를 복사하기 위한 기능을 스크립트에 작성.

그리고 버퍼가 지워지는 시점에 대해서 자유로워지기 위해 데이터를 복사할 텍스처를 하나 더 생성한다. 그리고 Blit 함수를 이용하여 텍스처 데이터를 복사하기 위해 다음 스크립트를 작성하여 Hierarchy에 추가해준다.

– 테스트를 위한 목적으로 제작한 메쉬를 초기화 과정.

간단하게 삼각형을 출력하여 테스트해 볼 예정이다. Unity Shader 에서 TEXCOORD1를 인자로 받을 수 있는 추가 시멘틱을 작성해 UV2에 추가해준다. UV2는 정점 쉐이더 연산시 최종 공간 좌표에서 픽셀이 겹치지 않도록 기획되는 정보이다. 컴퓨트 쉐이더를 위한 목적으로 전달하는 값이기 때문에 실제 텍스처 매핑을 위해 사용되는 UV와는 전혀 상관없는 수치이다.

– 스크립트로 직접 작성하여 유니티에서 출력한 실제 삼각형 메쉬.

유니티 에디터에서 삼각형이 제대로 출력됨을 확인할 수 있다. 이제 여러 오브젝트를 하나의 렌더 텍스처에 출력하기 위한 유니티 세팅은 마무리 되었다. 최종 목적인 쉐이더 내부에서 선언된 변수의 값을 누적하는 기능을 구현해 볼 것이다. 그리고 가려진 버텍스에 대해서도 픽셀값을 기록할 수 있도록 할 것이다. 쉐이더 내부에서 픽셀이 겹치지 않게 하기 위한 정점 연산들을 조금 설명이 길어질 수도 있다.

– 작성한 쉐이더의 1Pass의 구현 내용.

작성하게 될 쉐이더는 총 2Pass로 작성될 것이다. 1Pass 에서는 기존과 동일한 렌더링 파이프라인을 거치고 누적 연산이 완료된 랜더 텍스처의 색깔을 출력할 것이다. 누적 연산은 2Pass에서 진행되어 랜더 텍스처를 작성할 예정이다. 1Pass는 단순히 누적연산이 잘 마무리 되었는지 디버깅을 위한 용도로 이해해주면 된다.

– 작성한 쉐이더의 2Pass에서 선언된 구조체 형식.

2Pass에서는 아까랑 다르게 vVtxPosForDraw라는 시멘틱이 추가되었다. 아까 정의한 UV2의 정보를 임포트 하기 위해 선언한 것인데 저 정보를 이용하여 화면내에서 버텍스가 겹치지 않는 좌표를 생성할 것이다.

– 직교 투영 행렬 관련 참고자료.

화면 내에서 정점 정보가 겹치지 않게 하기 위한 방식으로 직교 투영 행렬을 변형하여 사용할 것이다. 정점 정보를 겹치지 않게 하기 위해 3D 좌표계를 2D 좌표계로 변환할 것이고 이 과정에서 직교 투영 행렬을 사용할 것이다. 애초부터 3D좌표계를 사용하지 않고 2D 좌표계를 사용하면 되는 것 아니냐고 생각할 수도 있는데 이것에 대해서 이해하려면 쉐이더 파이프라인에 대해 먼저 이해하여야 한다.

– Z 나누기 직전까지 투영을 진행해야 하는 버텍스 쉐이더 공간 좌표 예시.

기본적으로 버텍스 쉐이더를 작성 후 픽셀 쉐이더를 작성하게 된다. 보통 버텍스 쉐이더에서 작성하는 공간 좌표는 투영행렬을 곱한 좌표까지이다. 정확히 말하면 투영행렬을 곱한 상태까지이며 Z 나누기를 하기 이전까지의 공간 좌표라고 이해하면 된다. 그래서 클라이언트에서 뷰포트 좌표까지 연산하는 것과는 차이가 있다. 만약 버텍스 쉐이더에서 2D 공간 좌표만 작성했다고 한다면 쉐이더에서 강제적으로 Z나누기와 뷰포트를 진행한 후에 픽셀 쉐이더로 이행되기 때문에 원하는 화면이 출력되지 않을 것이다. 그래서 Z나누기와 이후의 연산까지 고려한 직교 투영 행렬이 필요한 것이다.

– 버텍스 쉐이더에서 사용하게 되는 직교 투영 제작 과정.

보통 사용되는 직교 투영 행렬을 버텍스 쉐이더 내부에 구현해 보았다. 여기서 몇가지 공식을 변경 해줄 필요가 있다.

일단 카메라는 유니티에 있는 카메라를 사용할 필요가 없다. 월드 상에 존재하는 카메라를 무시하고 새로운 공간좌표를 생성했기 때문에 데이터를 저장하기 위한 용도의 새로운 카메라 좌표를 생성해 줄 것이다. 주의해야 할 것은 뷰 스페이스 행렬의 3행3열과 3행4열의 정보가 DirectX로 작업하는 좌표계랑 다르게 거꾸로 뒤집힌다는 것이다. 또한 가로, 세로 크기도 1로 고정해 버리는 것이 좋다. 당연히 월드 스페이스로의 공간 좌표 변환도 필요없다.

– 쉐이더로 전달할 vVtxPosForDraw 정보.

아까 메쉬를 제작할 때 작성했던 이 좌표에 직접 제작한 임시 뷰 행렬과 직교 투영 행렬이 곱해지는 것이다. 즉 위에 작성한 좌표는 이미 월드 행렬이 곱해진 상태로 연산을 줄이기 위해 기획한 것이다. 직교 투영을 사용한 파이프라인 과정에서는 월드 좌표 이후에 별로 XY 좌표가 변경되지 않는데 저 좌표 자체가 투영행렬이 끝난 이후의 좌표로 보면 된다. 그래서 최대값이 1, 최소값이 -1인 상태가 투영행렬이 곱해진 상태와 닮아 있는지 테스트 해보기 위해 비슷하게 값을 설정해 디버깅하였다.

– 버텍스 쉐이더에서 진행한 직교 투영 연산에 대한 결과.

계획대로 삼각형이 꽉 채워져 나오기 때문에 클라이언트에서 시멘틱을 좀 더 2D 형태로 쉽게 기획할 수 있을 것이다. 이 부분에 대한 추가 구현은 누적 연산을 작성한 후에 서술할 것이다.

– 2Pass 픽셀 쉐이더 내부에서 값을 누적하는 기능을 구현.

픽셀 쉐이더에서 작성한 누적 값이 다음 프레임의 쉐이더 단계에서 누적하여 사용할 수 있는지 체크해 볼 것이다. 간단히 시간을 누적하는 기능을 픽셀 쉐이더에 작성했다. unity_DeltaTime.x는 _Time.y와 다르게 누적된 값이 아니라 프레임 사이의 시간 간극이므로 unity_DeltaTime.x를 누적해 시간에 따른 누적값을 체크해보는 것이 목적이다.

– 디거깅 용도로 제작한 1Pass의 픽셀쉐이더 구현 내용.
– 쉐이더 내부 누적값 기능 구현하여 색의 변화 디버깅.

Frac 함수를 사용하였기 때문에 검은 색에서 빨간색이 루프됨을 볼 수 있다. 즉 _Time.y을 사용하지 않고 시간 누적 값을 텍스처를 통해 구현하였다. 이로써 텍스처를 이용한 변수값 누적의 기본 골자는 완성되었다. 지금은 단순히 1개의 폴리곤을 테스트 했지만 다수의 폴리곤일 경우 정점에 대한 정보들을 누적하려면 어떻게 구현하여야 할까?

– 쉐이더로 전달할 vVtxPosForDraw 정보.
– vVtxPosForDraw 정보를 버텍스 쉐이더에서 진행한 직교 투영 연산에 대한 결과.
– vVtxPosForDraw 정보를 변형하여 버텍스 쉐이더에서 다수 폴리곤의 누적값을 기획.

작성한 vVtxPosForDraw 좌표를 해당 메쉬의 폴리곤 수만큼 변형하여 값을 저장하면 된다. 예를 들어 폴리곤이 9개인 경우 다음과 같이 작성해 주면 된다. 지금은 간단히 시간을 누적했지만 충돌에 따른 메쉬의 월드 포지션 값을 매 프레임마다 저장한다면 쉐이더에서 대량의 파티클들도 GPU에서 충돌 연산을 구현할 수 있게 될 것이다.

결론적으로 과정들을 요약해 보자면 vVtxPosForDraw 정보를 이용해 버텍스 쉐이더 과정에서 정점들의 데이터 저장을 위한 공간 좌표로 변환한다. 그리고 픽셀 쉐이더 과정에서는 데이터를 누적하는 시스템을 만든다. 그리고 모든 메쉬들을 한 렌더 텍스처에 랜더링 한 후 다음 프레임에 누적된 데이터를 로드하여 사용하는 것이다. 이때 버퍼가 지워지는 시점에서 자유로워지기 위해 랜더 택스처를 다른 텍스처에 복사한다. 이 복사된 텍스처는 매 프레임 쉐이더에서 선언된 변수 값을 지속적으로 누적하기 위한 수단으로써 사용한다.

(이 시스템을 기반으로한 시뮬레이션 시스템 구현 예정)