Graphics/filament

filament 렌더링 엔진 여행기 #2 - Renderer와 RenderPass

J'Heel; 2025. 4. 21. 22:03

렌더링 엔진에서 가장 중요한 요소 하나를 꼽으라면 뭘 꼽아야 할까?

 

 

실질적인 렌더링 파이프라인을 시작시키는 Draw Call? 아니면 메쉬 렌더링을 위한 기본 중의 기본인 Vertex Buffer? 혹은 메쉬의 표면에 대해 정의하는 Material?

 

 

아마 모두 중요할 것이다. 그래픽스를 공부한 사람이라면 뭐 하나 빠져선 안 되는 중요한 개념들이고, 렌더링 품질과 성능 모두를 만족하기 위해선 무엇보다 잘 이해하고 있어야할 개념들일 것이다.

 

 

그러나 렌더링 엔진을 처음 뜯어보고자 하는 상황에서 가장 먼저 살펴볼만한 게 있다면 그건 뭘까?

 

 

필자의 의견을 말해보자면 렌더러가 아닐까 생각한다.

 

 

렌더러에선 렌더링할 데이터들을 받아 쉐이더 프로그램을 설정하고, 리소스 바인딩을 진행하며 드로우 콜을 호출하는... 일종의 렌더링 작업의 총관리자라고 볼 수 있다. 그러니 렌더러에 대한 이해 없이 전체 렌더링 작업의 과정을 제대로 이해할 수 있을까?

 

 

무엇보다 렌더러에선 렌더링과 관련된 다양한 작업을 수행하기 때문에 렌더링 엔진에서 사용되는 핵심 기능들이 어떤 식으로 사용되고, 어떤 것이 존재하는 지 파악하기 용이할 수 밖에 없다.

 

 

그렇기에 렌더링 엔진을 공부하고자 한다면 우선 렌더러부터 한 번 봐보는 것이 좋지 않겠는가?

 

 

filament에서 렌더러 클래스 코드를 찾아보고자 한다면 'FRenderer'로 검색하면 쉽게 찾을 수 있다.

그림 1 filament FRenderer 클래스 다이어그램

 

FRenderer의 상속 구조를 살펴보면 위 그림 1과 같다. 

 

 

FilamentAPI를 상속받아 Renderer 클래스를 정의하고, Renderer 클래스를 상속받아 최종적으로 FRenderer 클래스를 정의한다. 이 중에서 FilamentAPI는 크게 중요한 부분은 아니다. 그저 복사 생성과 이동 생성을 막고, new와 delete 를 오버라이딩한 인터페이스 클래스라 보면 된다.

 

 

Renderer 클래스도 마찬가지로 인터페이스만 선언한 클래스로 실질적인 모든 세부 구현은 FRenderer 클래스에 정의되어 있다. 편의상 모든 클래스 멤버와 메소드를 들고 오기엔 무리가 있었으므로... 더 자세한 클래스 구현을 보고자 한다면 아래 링크를 참고바란다.

https://github.com/google/filament/blob/main/filament/src/details/Renderer.h

 

filament/filament/src/details/Renderer.h at main · google/filament

Filament is a real-time physically based rendering engine for Android, iOS, Windows, Linux, macOS, and WebGL2 - google/filament

github.com

 

 

이 중에서 유심하게 봐볼만한 부분은 FRenderer::renderJob()이 되겠다. 다른 부분도 아예 볼 필요가 없다고 할 순 없겠으나 우리의 목적인 렌더러의 렌더링 작업 분석과 구조에 대해 알아보고자 한다면 여기만 봐도 많은 것들을 이해할 수 있다.

 

 

beginFrame()이나 renderInternal()도 전체 렌더링 과정에서 중요한 부분이라 볼 수 있는데... 여기까지 살피게 되면 알아야 할 게 너무 많다... renderJob()을 소화하는 것만으로도 현재는 벅차다.

 

 

FRenderer::renderJob()의 기능을 간략하게 요약하면 전체 한 프레임에 대한 렌더링 작업 호출이다.

 

 

렌더링한 씬의 메쉬들과 각 재질들을 렌더링하고, AA나 Bloom 등의 다양한 후처리 효과 적용, 그림자 매핑을 위한 그림자 맵 생성 작업 등이 이 함수 내에서 이뤄진다. 이는 달리 말해 씬에서 일어나는 모든 렌더링 과정을 이 함수 하나로 그 흐름을 알아볼 수 있다는 것이다.

 

 

렌더링 엔진 분석의 첫 출발점으로 이만한 것이 없다! 다만.. 그만큼 내부에서 처리하는 것들이 많아 코드를 보는 것도 쉽지 않다. 그러니 우선 전체적인 흐름을 플로우 차트를 통해 살펴보자.

 

 

 

여기까지가 전체적인 FRenderer의 renderJob의 흐름이다..

 

 

플로우 차트를 통해서만 봐도 renderJob 내부에서 얼마나 많고 복잡한 렌더링 명령 처리를 하고 있는지 볼 수 있다. 이마저도 사실 필자가 일부분 생략해서 작성한 상태다..

 

 

그래도 전반적인 흐름을 봤을 때, 실시간 렌더링에 대해 공부해본 적이 있는 사람이라면 익숙한 흐름일 것이다. 기하학 데이터가 필요한 그림자, picking, ssao 등을 먼저 처리하고 이어서 씬에 배치된 오브젝트를 Color Pass에서 렌더링을 한다.

 

 

당연히 이 Color Pass 렌더링 과정에서 앞서 계산한 Shadow Map과 SSAO 데이터 등을 사용하여 조명 연산을 수행한다.

 

 

여기까지 완료되면 조명 연산이 끝난 이미지에 후처리 효과를 적용해 최종 렌더링 이미지를 완성하는 전형적인 렌더링 흐름이라 볼 수 있다. 물론 이 분야에 관해 많이 접해보지 않았다면 이게 무슨 의미고, 플로우 차트의 각 노드들이 어떤 기능을 수행하는 지 알기 어려울 것이다.

 

 

이런 부분도 가능한 상세히 설명할 수 있다면 좋겠으나 그랬다간 렌더링 엔진 탐구라는 주제에서 너무 벗어나게 된다. 당장 이해하진 못해도 지금은 그저 이런 것들이 있다고 알고만 넘어가도 충분할 것이다.

 

 

무엇보다 이 플로우 차트를 따라 처음부터 각 기능이 어떻게 구현되고, 어떻게 동작하는 지 알아볼 생각도 없다. 그랬다간 FRenderer 클래스에 대한 포스팅을 연달아 몇 개는 써야할 것이고... 이건 엔진 분석의 첫 시작으로 하기엔 너무 무겁다.

 

 

그래서 그나마 모두에게 친숙하고 그래픽스를 조금이라도 접해봤다면 친숙하게 느껴질 오브젝트 렌더링 부분을 집중적으로 살펴볼 생각이다. 위 플로우 차트 기준으로 Color Pass에 해당하는 부분이 월드 내에 존재하는 오브젝트를 렌더링하는 부분이 되겠다.

 

 

그럼 한 번 Color Pass에서 오브젝트를 렌더링하는 부분의 코드 일부를 살펴보자

 

 				
                // set samplers and uniforms
                view.prepareSSAO(data.ssao ?
                        resources.getTexture(data.ssao) : engine.getOneTextureArray());

                view.prepareShadowMapping(view.getVsmShadowOptions().highPrecision);

                // set shadow sampler
                view.prepareShadow(data.shadows ?
                        resources.getTexture(data.shadows) : engine.getOneTextureArray());

                // set structure sampler
                view.prepareStructure(data.structure ?
                        resources.getTexture(data.structure) : engine.getOneTexture());

                // set screen-space reflections and screen-space refractions
                TextureHandle const ssr = data.ssr ?
                        resources.getTexture(data.ssr) : engine.getOneTextureArray();

                view.prepareSSR(ssr, config.screenSpaceReflectionHistoryNotReady,
                        config.ssrLodOffset, view.getScreenSpaceReflectionsOptions());

                // Note: here we can't use data.color's descriptor for the viewport because
                // the actual viewport might be offset when the target is the swapchain.
                // However, the width/height should be the same.
                assert_invariant(
                        out.params.viewport.width == resources.getDescriptor(data.color).width);
                assert_invariant(
                        out.params.viewport.height == resources.getDescriptor(data.color).height);

                view.prepareViewport(static_cast<filament::Viewport&>(out.params.viewport),
                        config.logicalViewport);

                view.commitUniforms(driver);

                // TODO: this should be a parameter of FrameGraphRenderPass::Descriptor
                out.params.clearStencil = config.clearStencil;
                if (view.getBlendMode() == BlendMode::TRANSLUCENT) {
                    if (any(out.params.flags.discardStart & TargetBufferFlags::COLOR0)) {
                        // if the buffer is discarded (e.g. it's new) and we're blending,
                        // then clear it to transparent
                        out.params.flags.clear |= TargetBufferFlags::COLOR;
                        out.params.clearColor = {};
                    }
                }

                if (colorGradingConfig.asSubpass || colorGradingConfig.customResolve) {
                    out.params.subpassMask = 1;
                }

                driver.beginRenderPass(out.target, out.params);
                passExecutor.execute(engine, resources.getPassName());
                driver.endRenderPass();

                // color pass is typically heavy, and we don't have much CPU work left after
                // this point, so flushing now allows us to start the GPU earlier and reduce
                // latency, without creating bubbles.
                driver.flush();

 

원문 코드 보기

 

이게 Color Pass 에서 오브젝트 렌더링을 수행하는 코드 단락이다. 생각보다 별로 길지도 않고 내용도 없어보이는가?

 

 

그렇게 느낀다면 그게 맞다! 왜냐하면 진짜로 이 코드 단락에선 정말 별 게 없는게 맞기 때문이다. 이 단락에서 하고 있는 일을 순서대로 나열하면

 

 

SSAO 데이터 준비 -> 그림자 맵 준비 -> Structure 데이터(contact shadow) 준비 -> SSR(reflection and refraction) 데이터 준비 -> 뷰포트 설정 -> 렌더링에 필요한 데이터 업로드(commitUniforms) -> 렌더 타겟 클리어를 위한 플래그 처리 -> passExecutor.execute() 호출 -> flush로 GPU 처리 명령

 

 

이와 같다. 보면 알겠지만 호출하는 함수 및 동작 들이 대부분 렌더링을 위한 데이터 준비들 뿐이다. 필자가 몇 개 생략한 게 있긴 하나 결국  드로우 명령 호출 전 데이터 준비를 위한 함수 호출 혹은 분기 처리들이다.

 

 

그럼 중요한 드로우 콜은 대체 어디서 하는가? 눈치 빠른 사람이라면 이미 어딘지 알 것이다. 

 

 

passExecutor.execute(engine, resources.getPassName());

 

 

바로 이 함수 내부에서 실질적인 드로우 콜이 일어난다. 그런데 이름이 특이하다. pass executor 가 대체 뭔가? 

 

 

passExecuter의 데이터 형식부터 미리 말하자면 RenderPass::Executer라는 클래스이다. pass는 앞에서 몇 번 언급된 단어다. 당장 Color Pass에도 Pass라는 단어가 들어간다.

 

 

그런데 RenderPass란 뭘까?

 

 

RenderPass에 대한 명확한 정의를 찾아보려고 했지만... 필자는 명쾌한 정의를 찾지 못했다. 다만, 일반적으로 사용되는 방식을 통해 간단하게 말하자면 한 프레임에서 렌더링 명령을 분류하는 하나의 단위라 말할 수 있지 않을까 싶다.

 

 

Shadow Pass는 그림자만을 처리하는 렌더링 작업 모음. SSAO는 Screen Space Ambient Occlusion을 수행하는 렌더링작업 모음 등등... 이와 같이 어떠한 목적을 위해 해야할 렌더링 작업을 모아서 분류한 단위를 RenderPass라 볼 수 있다.

 

 

물론 이게 RenderPass의 정의라 이해하지는 말자. 그저 이해를 돕기 위한 접근 방식 중 하나로 받아들이면 되겠다.

 

 

그런 의미에서 Color Pass라는 것은 Color를 결정하는 계산을 수행하는 렌더링 작업 모음이라 말할 수 있을 것이다. 이 Color를 결정하기 위해선 결국 조명 연산을 수행해야할 것이고, 그 과정에서 SSAO, Shadow Map 등과 같은 조명 연산을 위한 전처리 데이터도 당연히 필요할 것이다.

 

 

그렇다면 executer는 뭔가? 이건 이름 그대로 받아들이면 된다. RenderPass를 수행하는 실행의 주체다.

 

 

passExecuter가 사전에 렌더링에 필요한 데이터(오브젝트의 기하학 형상 정보, 재질, 월드 공간 위치를 나타내는 행렬 등) 미리 입력받아 들고 있다가 Color Pass를 수행할 때 execute()를 호출하여 이 데이터를 이용해 렌더 타겟에 결과를 출력하는 것이다.

 

 

결국 RenderPass와 Pass Executer는 렌더링 명령을 수행하는 기본 단위라고도 생각해볼 수 있다. 달리 말하자면 모든 렌더링 동작은 일단 Pass 단위로 동작할 것이며, 이를 위해 RenderPass를 정의하고, Pass Executer 객체를 만들어내야만 한다는 것도 이해할 수 있다!

 

 

그러니 다음 포스팅부턴 FRenderer는 잠시 놔두고 이 RenderPass의 구조와 동작 방식에 대해 상세하게 알아보자

 

 

이 글은 Apache License 2.0으로 공개된 Google의 Filament 프로젝트를 참고하여 작성되었습니다. 원본 라이센스 보기