유니티 엔진에서 포스트 프로세싱 직접 쉐이더로 구현 원리 - Whitmem
유니티 엔진에서 포스트 프로세싱 직접 쉐이더로 구현 원리
게임 개발 및 엔진
2025-12-28 21:28 게시 666bf4a4e9b4426c517c

0
0
4
이 페이지는 외부 공간에 무단 복제할 수 없으며 오직 있는 그대로 게시되며 부정확한 내용을 포함할 수 있습니다. 법률이 허용하는 한 가이드 라인에 맞춰 게시 내용을 인용하거나 출처로 표기할 수 있습니다.
This page is not to be distributed to external services; it is provided as is and may contain inaccuracies.
유니티 엔진에서 포스트 프로세싱을 구현하게 되었는데 기존 사용하던 다른 엔진에 비해서 많이 어려웠습니다. 이 말은 즉슨 파이프라인을 자유롭게 삽입하고 사용할 수 있도록 설계가 잘 돼 있다는 것을 증명하는 것이기도 합니다.
이 게시글은 추후 제가 다시 이 게시글을 보면 기억해낼 수 있을 정도로... 요약 정리 하는 것을 바탕으로, 특히나 헷갈렸던 것을 바탕으로 기록합니다... 그래서 방문자를 고려해서 작성된 게시글이 아닙니다...
우선 유니티의 렌더 파이프라인을 조금 손 볼 수 있도록 제공되는 세팅이 있는데, Universal Render Pipeline Asset 이다. 이 파이프라인이 기본 파이프라인으로 정의되어 있는데, 프로젝트의 설정 -> Graphics -> Default Render Pipeline에서 볼 수 있다. 기본 에셋으로 지정되어 있는데, 이 에셋의 인스펙터를 보면 Renderer List가 있다. Renderer List 내부를 보면 Universal Renderer Data 을 여러 개 넘버로 추가할 수 있게 되어있는데, 한 묶음 단위라고 보면 되는 것 같다. 즉 이 것이 패스를 의미하는 것이 아니라, 렌더러를 필요에 따라 여러개 만들어놓고(Universal Renderer Data) 이 렌더러를 카메라마다 적용하는 등 다양한 활용이 가능한 것으로 보인다.
Universal Renderer Data는 해당 렌더러의 여러 설정을 추가할 수 있는 총 묶음이라고 보면 된다. 실질적으로 Universal Renderer Data 가 하나의 카메라에 대해 어떻게 출력 파이프라인을 거칠지... 렌더링 파이프라인을 거칠지 나타낸다고 볼 수 있을 듯 싶다. 이 옵션에는 필요에 따라 다양한 단위의 프로세싱들을 켜거나 끌 수 있는데, 그것이 Renderer Feature 이다. 화면 블러 효과, 화면 흔들림 효과 등을 하나의 Renderer Feature으로 나타내는 것이다. 즉 필요할 때 켜고 끄고 할 수 있는 세팅을 제공해야하는데, 여기에 등록해두고 외부 요인에 따라 온 오프할 수 있을 것 으로 보인다.
즉 이 기능들에 직접 내가 필요에 따라 어떤 Pass를 추가하거나 제거할 수 있고 이를 묶어서 하나의 기능이나 효과를 완성하는데 이를 Renderer Feature라고 부르는 것이다.
Renderer Feature 는 Universal Renderer Data의 우측 인스펙터에 등록하는데, 직접 내가 하나의 Renderer Feature을 만들기 위해서는 에셋 폴더에서 우클릭 -> Create -> Scripting -> URP Renderer Feature Script 를 추가하면 아래와 같은 소스 코드를 가진 파일이 하나 만들어진다.
이 파일은 이제 하나의 기능을 구현하기 위한 스크립트 코드이다. 즉 유니티에서는 내가 구현하고자 하는 기능을 하나 만들기 위해 관련된 패스를 이미 있던 패스에 모조리 추가하는 방식이 아니라 일단 하나의 기능 단위로 묶고, 기능 단위 안에 해당 기능을 구현하기 위한 패스를 여러개 추가할 수 있는 식으로 (패스는 코드로 추가해야한다 버튼 UI로 딸깍 추가하는 느낌이 아니다.) 구현되어 있다고 볼 수 있다.
이미 생성된 베이직 코드를 보면 일단 ScriptableRendererFeature 을 상속받는 메인 클래스,
다음으로 이 클래스 안에 존재하는 패스 클래스가 존재한다.
여기서 알 수 있듯이 패스는 내가 원하는 시점에 추가하는 것이다. 다만 엔진의 코드를 직접 건드는 것이 아니므로 제공되는 시점에 간접적으로 추가할 수 있는 구조이다.
패스가 일단 인스턴스화되어 있는데, 이는 Create() 시점 즉 그래픽이 최초 초기화되는 시점에 (매 프레임이 아니다.) 해당 패스 정보와 언제 이 패스를 삽입할 것인지를 정의한다.
여기까지는 기본 Initialization 부분이고, 이 스크립트를 생성하고 실제 Universal Renderer Data 의 Renderer Feature 로 이 스크립트를 추가하면 해당 Universal Renderer 가 렌더링될 매 프레임 시작 전마다 해당 스크립트 내에 존재하는 AddRenderPasses 메서드가 호출된다.
이 때 유니티 엔진 내부에서 처리하기 위해 제공되는 ScriptableRenderer 객체와 RenderingData 레퍼런스가 제공되는데, 이 시점에 실제 패스를 등록할 수 있게 된다.
즉 이 엔진에서 패스라는 게임이 초기화될 때 딱 한 번만 패스를 등록하는 것이 아니라, 일단 스크립트 가능한 클래스를 정의하여 패스 정보를 초기화해두고,
매 프레임의 실제 렌더 직전 마다 Pass 를 등록해주는 것이다.
근데 여기서, Pass 클래스를 등록하는 느낌이고, Pass 클래스 내에서도 여러 패스를 직접 정의해줄 수 있기 때문에, 아무래도 리소스를 밀접하게 관리해야하는 여러개의 Pass를 추가하는 것이라면 하나의 Pass 클래스 안에서 직접 그래픽스에 Pass를 생성해주는 것이 맞고, 단순히 Pass 하나 만들어둔 기능을 여러번 실행해야한다면 이 ScriptablePass 를 여러번 인스턴스화 해서 실행할 수도 있다.
하나의 기능을 만들어서 내부에서 밀접하게 연결되어야 하는 패스2개를 처리해야한다면 (텍스처를 주고받는) ScriptableRenderPass 안에서 2개의 패스를 실제 엔진 렌더러에 생성하는게 맞아보인다.
이제 ScriptableRenderPass 를 상속하는 클래스 내부를 보면 다음과 같다.
이 클래스에는 실질적으로 렌더 파이프라인에 렌더패스를 등록하는 부분이다. PassData 라는 클래스와 ExecutePass 메서드, RecordRenderGraph 메서드가 눈에 보이는데. 이 클래스가 어떤 역할을 하는지를 제대로 이해해야 각 객체와 메서드의 필요 이유를 알 수 있다. 일단 이 클래스는 아까 부모 클래스의 AddRenderPasses 에 의해 이 패스 처리 클래스가 등록되면, 그 후에야 RecordRenderGraph 가 호출된다.
즉 처리 흐름을 보면 아까 부모의 AddRenderPasses 가 호출되지마자 이 클래스 내부의 RecordRenderGraph가 호출되는 것이 아니라, 일단 엔진 내부의 메모리에 등록되는 과정을 거친다. 이는 이 렌더러에 추가된 모든 Renderer Feature 에 대해서 일단 엔진 내부에 EnqueuePass가 될 것이다. 즉 이 작업이 일괄적으로 완료되고, 이제 엔진 내부에서 어떤 알고리즘과 로직, 최적화 과정을 거쳐 등록된 패스 스크립트를 실행해야 할 때, 비로소 등록한 ScriptableRenderPass 내의 RecordRenderGraph 가 실행될 것으로 보인다. 즉 이 작업이 실행되는 것 역시 바로 GPU의 명령이 실행되는 것이 아니라, 내부 최적화 알고리즘의 순서에 등록해두는 것으로 보인다.
아무튼 RecordRenderGraph 내에는 이제 실질적인 메시 생성 및 렌더링을 요청하는 부분이다. 정확히는 요청을 직접한다기 보다는 엔진 내부에 실제 Pass를 추가하는 과정이다. (매 프레임 수행된다.) 기본적인 RasterRenderPass를 추가하면서 passName과 그에 대한 passData을 반환, builder을 통해서 실제 이 패스에 대한 작업을 정의할 수 있다.
하나의 렌더 패스를 추가하면 이 렌더 패스에 대해서 결과가 어디에 렌더링될 것이고, 이 렌더링을 위해서 어떤 메시 등의 처리가 필요한지 정의하는 공간이라고 볼 수 있다.
즉 정의하는 공간이지, 이 시점에 바로 메시와 대상 텍스처의 렌더가 수행되는 것이 아니다.
특히 위 메서드가 눈에 보이는데 이 메서드는 실제 렌더 과정에서 사용자 정의 GPU 명령을 정의할 수 있는 공간이다. 이 메서드 자체가 GPU에서 실행되는 것이 아니라, 해당 패스를 실행할 시점 당시에 필요한 메시 데이터 등을 직접 호출할 수 있는 공간을 정의하는 것이다. 보면 엔진 내부에서 (PassData data, RasterGraphContext context) 라는 두 개의 값을 던져주고 이를 활용해서 어떤 함수를 실행하는데 ExecutePass 는 위에 정의된 static 메서드이다.
즉 엔진이 실제 패스를 수행하기 직전 필요한 메시 데이터나 버텍스 등을 여기서 그려주면 된다. 보면 RasterGraphContext 라는 변수가 보이는데
이 내부에 context.cmd.draw ... 와 같은 명령을 수행할 수 있다. 즉 저수준 그래픽스로 예를 들자면 Pass 렌더 직전 렌더 타겟과 소스 텍스처를 아래 공간에서 지정할 수 있는 것이고,
실제 렌더링 DrawCall을 ExecutePass에서 수행하는 것이다. 이 때 혼용하면 안될 것이 ExecutePass 는 단순히 패스를 렌더링하기 위해 필요한 QuadMesh를 DrawCall 하는 영역이지 세계에 존재하는 모든 객체를 렌더링하는 공간이 아님에 유의해야 한다.
Post Processing 을 구현하는 단계에서는 Draw 해야할 객체는 오로지 QuadMesh 밖에 없다.
예를 들어 일단 세계에 존재하는 모든 객체를 렌더링할 때 내부적으로 여러 패스를 거칠 것이고, 블러 효과를 내기 위해서 내부적으로 화면 영역 QuadMesh 를 차지할 버텍스 4개 점과 DrawCall 한 개, 쉐이더 하나만으로 Pass가 구현된다. 그리고 텍스처 왜곡 효과를 구현하기 위해서도 역시 이전 텍스처(이전 패스의 아웃풋)을 받아서 어떤 쉐이더와 함께 하나의 QuadMesh 를 통해 Draw Call 하면 끝난다. 즉 중간 PostProcessing을 위한 Pass 에는 단순히 평면 하나만 렌더링하면 되는 것이다.
그러기 위해서 일단 QuadMesh 하나가 필요하고, 이에 대한 쉐이더, 그리고 출처로 넣어줄 텍스처가 필요하다. 그런데 ExecutePass 영역은 static 영역이기 때문에 현재 클래스 내부에서 사용할 수 있는 공유 변수나 멤버 객체를 사용할 수 없다. 성능 상의 이유로 static 으로 제한한 것 같아보이기도 한다. 파라메터를 잘 보면 PassData 로 엔진이 넘겨주는데, 우리가 실제 사용할 Material이나 Mesh 등의 정점 데이터를 PassData 에 담아서 전달하면 되는 것이다.
그리고 ExecutePass 내에서 해당 Draw 인스턴스의 쉐이더(Material)에만 텍스처를 넘겨줘야 하는데, 이는 MaterialPropertyBlock을 통해서 넘길 수 있다.
ExecutePass가 실행되는 시점은 RecordRenderGraph가 실행되는 시점이 아니다. RecordRenderGraph 에서 ExecutePass 가 실행되기를 예약하고, Pass 등록 당시 passData 를 건네받는데 이 공간에 실제 데이터를 넣어주면 되는 것이다.
DrawCall을 위한 ExecutePass 영역을 등록하는 시점으로 돌아가본다. 이 시점에 실제 엔진에 Pass 를 등록하는데, 이 때 passData 를 건네준다. 여기 passData 에 src, mesh, material 을 담아주면 실제 DrawCall 당시의 passData에 해당 데이터를 담아서 넘겨준다.
그럼 다시 RecordRenderGraph 으로 돌아가서,
passData에 넘겨주기 위한 src, mesh, material 은 각각 어떻게 넘겨야 하는 것인가, src 는 당연히 쉐이더에 대해서 이전 패스 (이전 렌더가 아니다.) 현재 렌더의 이전 패스 결과물인 텍스처를 넘겨주는 것이기 때문에 sourceTexture 을 그대로 넘겨주면 된다.
이 때 sourceTexture 는 기본적으로 resourceData.activeColorTexture이다. 아무런 설정도 하지 않으면 resourceData.activeColorTexture가 입력이고 resourceData.activeColorTexture으로 출력, 즉 activeColorTexture을 읽어들이지 않고 카메라에 드로우 콜한다. 다만 지금 나는 현재 렌더의 이전 패스 결과물(sourceTexture)을 읽어 들여서 어떤 처리 하여 처리 결과를 다시 새로운 destination에 내보내어 이 destination을 화면에 보내고자 하는 것이므로 임의의 destination을 만들고 현재 sourceTexture을 읽기 전용으로 만들어야 한다. 직접 렌더 패스를 구현해봤으면 알겠지만 같은 텍스처를 인풋, 아웃풋으로 쓰는 것은 불가능하다. 입력, 출력간의 메모리 공간이 공유될 수는 없다. 따라서 출력할 메모리 공간을 만들어 번갈아 가든가 복제 공간 영역을 만들어야 한다.
이 같은 과정을 여기서 정의하는 것이므로, 위 builder.UseTexture(sourceTexture) 이 기본적으로 주석 처리 되어 있는 것이다. 출력이 sourceTexture 즉 resourceData.activeColorTexture이라면 입력을 sourceTexture 로 다른 텍스처에 전달할 수 없다. UseTexture 메서드를 활성화해야 해당 텍스처를 입력으로 쉐이더에 넘길 수 있다.
한편, 이외 mesh 는 QuadMesh를 전달하면 되는데, 현재 이 Pass가 등록되는 시점은 RecordRenderGraph 로 매 프레임마다 이므로 매 프레임 마다 메시 데이터를 생성하기에는 매우 비효율적이다.
한편 위 ScriptableRenderPass 도 매 프레임 마다 추가되기 때문에 애초에 렌더 초기화 처리시 한번만 처리되는 ScriptableRendererFeature 의 Create() 메서드에서 메시를 임의로 처리할 수 있다.
또는 SerializeField를 통해 Mesh를 받아 클래스 내부로 넘겨 PassData에 넘길 수도 있다.
마지막으로 DrawCall 시에 사용할 쉐이더를 정의해야하는데, 제공되는 Draw 함수들을 보면 모두 Material을 인자로 받고 있다. 즉 엔진에서는 Shader HLSL을 직접 작성하는 것이 아니라 이를 표현할 수 있는 표현식인 쉐이더 객체 또는 에셋 단위로 받고 있으므로, 코드 쉐이더를 처리하는 하나의 Material을 만들어 인스펙터로 넘겨 PassData로 넘기면 된다.
이 때 기본 생성된 코드를 보면 Settings 라는 클래스가 있다. 이 공간은 Serializable 되어 있고 인스펙트에 바로 뜨게끔 되어 있으므로 외부에서 받을 Material (Shader)를 받아 PassData로 넘겨주면 되는 것이다. 이 Test2Settings는 ScriptableRenderPass를 생성할 때 인자로 넘겨지게끔 되어 있고, 인스펙터에서 받을 수 있게끔 기본 정의 되어있다.
근데 이렇게만 해서는 화면에 출력되지 않는다. 이는 가상의 텍스처 공간에만 렌더링되었기 때문이다. 이를 그대로 화면에 출력해주기 위해서는 카메라 색상 텍스처에 다시 반영해줘야 한다.
근데 위 resourceData.cameraColor = destination 는 다시 보니 제법 위험한 코드 같아보인다. builder는 모두 콜백 형식으로 실행될 때 처리하는데, 이 다음 cameraColor 을 지정하는 부분은 즉시 교체한다. 엔진 내부 처리 과정 순서에 따라 문제가 발생할 수 있을 것으로 보이는데.. executePass 내부에서 cameraColor을 변경하지 않고 텍스처를 복사하는 방법으로 찾아봐야할 것 같다.
댓글 0개
댓글을 작성하는 경우 댓글 처리 방침에 동의하는 것으로 간주됩니다. 댓글을 작성하면 일회용 인증키가 발급되며, 해당 키를 분실하는 경우 댓글을 제거할 수 없습니다. 댓글을 작성하면 사용자 IP가 영구적으로 기록 및 부분 공개됩니다.
확인
Whitmemit 개인 일지 블로그는 개인이 운영하는 정보 공유 공간으로 사용자의 민감한 개인 정보를 직접 요구하거나 요청하지 않습니다. 기본적인 사이트 방문시 처리되는 처리 정보에 대해서는 '사이트 처리 방침'을 참고하십시오. 추가적인 기능의 제공을 위하여 쿠키 정보를 사용하고 있습니다. Whitmemit 에서 처리하는 정보는 식별 용도로 사용되며 기타 글꼴 및 폰트 라이브러리에서 쿠키 정보를 사용할 수 있습니다.
이 자료는 모두 필수 자료로 간주되며, 사이트 이용을 하거나, 탐색하는 경우 동의로 간주합니다.