이전에 FRenderer 코드를 보면서 filament의 렌더링이 RenderPass라는 객체 단위로 동작한다는 것을 추론할 수 있었다.
물론 모든 코드를 다 본 것은 아니기엔 예외는 존재할 수 있으나 RenderPass가 적어도 filament 렌더링 동작의 핵심적인 역할을 하는 클래스인 것까진 틀림없을 것이다.
filament의 RenderPass는 그럼 어떻게 동작할까? 코드를 보기 전 클래스 다이어그램부터 보자
위 클래스 다이어그램이 RenderPass와 관련된 클래스 다이어그램이다. 클래스 간의 관계까지 표현해두진 않았다. 그리고 당연하지만 모든 멤버가 포함된 다이어그램은 아니다. 거기에 처음보는 클래스도 꽤 있는데... 이런 부분은 당장 알아야할 건 아니므로 넘어가자.
디자인 패턴에 대해 공부해본 사람이라면 위 클래스들이 어떤 관계를 가지고 동작하는 지 대략적으로 짐작해볼 수 있을 것이다.
우선 RenderPassBuilder라는 클래스가 있는데, 이 클래스는 RenderableSoa, Camera, CommandType 등 RenderPass와 관련성이 높아보이는 멤버 변수를 다수 보유하고 있다.
그리고 public 메소드로 build()를 가진 것을 보면 이 클래스들은 빌더 패턴으로 구현되었다는 걸 추측할 수 있다.(빌더 패턴에 대한 설명은 생략하겠다.. 워낙 유명하기도 하고 이미 관련 자료도 상당히 많다.)
그렇다면 RenderPass를 사용하려면 RenderPassBuilder를 통해 Command Type, 카메라 정보, 렌더링할 대상 등을 설정하고, build()를 호출하는 것으로 RenderPass 객체를 얻어야만 할 것이다.(빌더 패턴을 쓰고 있으니 당연히 생성자는 감춰져 있을 것이므로 builder 없이는 객체를 생성할 수 없다)
RenderPass::appendCommand() private 멤버 함수의 시그니처를 보면 각 인자들이 모두 RenderPassBuilder가 가진 것들로 구성되어 있는 것을 알 수 있다. appendCommand가 private이 것을 보면 RenderPassBuilder 없이는 RenderPass에 커맨드를 추가할 수 없을 것이며 아마도 build() 시 이 함수를 호출하여 RenderPass에 새로운 커맨드를 추가할 것이다.
생성된 RenderPass는 멤버 함수 목록을 통해 미루어보아 Command 라는 객체 단위로 동작할 것으로 보인다.
begin()과 end()라는 public 멤버 함수가 있으니 이를 통해 실행한 명령들을 범위로 내보내 execute()를 호출하지 않을까? 라고 추측할 수 있다.
RenderPass::Executer라는 클래스가 있기 때문에 실제 각 커맨드의 실행은 이 객체가 주도할 것으로 보이는데, Executer가 execute() 함수를 가지고 있는 것과 별개로 RenderPass도 static 멤버 함수로 execute() 를 소유한 것을 알 수 있다.
그렇다면 RenderPass::Executer::execute()의 함수 시그니처를 미루어볼 때, RenderPass로부터 Command Range를 받아서 Executer::execute(engine, begin, end)를 호출하여 RenderPass에 저장된 커맨드를 실행하게 되지 않을까?
그렇다면 static 멤버 함수인 RenderPass::execute()는 언제 사용하는 걸까?
시그니처를 다시 보면 3번째와 4번째 인자를 renderTarget이라는 인자가 있으며 RenderPassParams가 여기서 사용되고 있다.
렌더 타겟은 렌더링 동작에서 드로우 콜이 일어나기 이전에 반드시 설정되야 한다는 점과 RenderPassParams의 멤버에는 렌더 타겟 설정과 매우 관련성이 높은 viewport, clearValue 등이 있다는 것을 고려하면 이 execute() 함수는 RenderPass::Executer::execute()보다 더 상위 단계에서 호출되어야하는 함수로 보인다.
이야기가 상당히 길어졌는데... 클래스 다이어그램만으로 코드의 모든 구조를 파악하기엔 힘이 부친다. 그럼에도 이 정도의 적은 정보만으로도 이만한 추론을 할 수 있다는 게 디자인 패턴의 묘미 아닐까? 상세한 구조는 코드를 보면서 차차 알아보자.
우선 RenderPassBuilder::build()가 호출되는 곳부터 찾아보자. RenderPass를 사용하기 위해선 반드시 builder를 통해 객체를 생성해야만 하므로 이 build()가 호출되는 위치를 찾아가서 하나씩 따라가보면 RenderPass의 전체적인 사용법에 대해 추적해볼 수 있을 것이다.
if (colorGradingConfigForColor.asSubpass) {
// append color grading subpass after all other passes
passBuilder.customCommand(engine, 3,
RenderPass::Pass::BLENDED,
RenderPass::CustomCommand::EPILOG,
0, [&ppm, &driver, colorGradingConfigForColor]() {
ppm.colorGradingSubpass(driver, colorGradingConfigForColor);
});
} else if (colorGradingConfig.customResolve) {
// append custom resolve subpass after all other passes
passBuilder.customCommand(engine, 3,
RenderPass::Pass::BLENDED,
RenderPass::CustomCommand::EPILOG,
0, [&ppm, &driver]() {
ppm.customResolveSubpass(driver);
});
}
passBuilder.commandTypeFlags(RenderPass::CommandTypeFlags::COLOR);
// RenderPass::IS_INSTANCED_STEREOSCOPIC only applies to the color pass
if (view.hasStereo() &&
engine.getConfig().stereoscopicType == backend::StereoscopicType::INSTANCED) {
renderFlags |= RenderPass::IS_INSTANCED_STEREOSCOPIC;
passBuilder.renderFlags(renderFlags);
}
RenderPass const pass{ passBuilder.build(engine) };
위 코드는 FRenderer::renderJob()의 일부분으로 RenderPass::Builder의 build()를 호출하여 RenderPass 객체를 만드는 부분이 포함되어 있다.
이 코드부분 위로 builder 객체의 함수를 호출하여 렌더 패스에서 요구하는 여러 데이터를 넘겨주는 작업을 하고 있으나 결국 데이터를 구성해서 넘겨주는 반복 작업이다보니 넘어가도록 하자.
RenderPass를 생성하는 마지막 줄 코드를 보면 이 RenderPass가 어떻게 쓰이게 될 지 대략적으로 짐작이 된다.
우선 RenderPass 객체의 선언을 보면 const가 붙어있는 것을 볼 수 있다. const가 붙어있다는 것은 이 코드를 작성한 사람은 RenderPass가 절대 변하지 않기를 원하는 것으로 보인다.
그래서 실제로 RenderPass의 클래스 선언을 살펴보면 public 멤버 함수들은 모두 const로 선언되어 있다. const가 아닌 멤버 함수는 appendCommand 같은 내부 private 함수나 static 멤버 함수 뿐이다.
아마도 filament에선 RenderPass가 한 번 정의된 순간 완전히 불변하는 객체로 사용되길 원하는 게 아닐까? 그러면서 RenderPass는 생성하여 계속 두고 있는게 아니라 휘발성으로 보인다.
build()의 반환값이 저 문법대로면 포인터가 아닌 생성된 객체 그 자체인데, 저 RenderPass pass라는 변수가 선언된 위치는 renderJob()의 로컬 영역이다. 이는 renderJob()이 끝나는 순간 저 RenderPass도 반드시 사라질 것을 암시한다.
RenderPass의 기본 생성자 목록에서도 오직 builder에서 사용할 사용자 지정 생성자 RenderPass(FEngine& engine, RenderPassBuilder const& builder)와 이동 생성자 RenderPass(RenderPass&& rhs)만이 허용하도록 선언되어 있다.(이동 대입도 안된다...! 그러나 만일 필요하다면 지원할 수도 있다는 코멘트가 주석으로 적혀있다)
// RenderPass can only be moved
RenderPass(RenderPass&& rhs) = default;
RenderPass& operator=(RenderPass&& rhs) = delete; // could be supported if needed
// RenderPass can't be copied
RenderPass(RenderPass const& rhs) = delete;
RenderPass& operator=(RenderPass const& rhs) = delete;
아무튼 대충 개발자의 의도는 알겠다. 그런데 여기까진 대체로 디자인 패턴에 관한 이야기만 주구장창이었다. 포스팅의 주제에 걸맞게 이제 저 build() 함수의 흐름을 따라가보자.
RenderPass RenderPassBuilder::build(FEngine& engine) {
FILAMENT_CHECK_POSTCONDITION(mRenderableSoa)
<< "RenderPassBuilder::geometry() hasn't been called";
assert_invariant(mScissorViewport.width <= std::numeric_limits<int32_t>::max());
assert_invariant(mScissorViewport.height <= std::numeric_limits<int32_t>::max());
return RenderPass{ engine, *this };
}
build() 함수 자체는 보다시피 별 게 없다. 빌더 패턴을 좀 써봤다면 당연하게 보일만한 코드다. 빌더 자체는 필요한 데이터를 잠깐 담아두고 있을 뿐 중요한 로직은 모두 RenderPass에서 일어난다.
RenderPass::RenderPass(FEngine& engine, RenderPassBuilder const& builder) noexcept
: mRenderableSoa(*builder.mRenderableSoa),
mScissorViewport(builder.mScissorViewport),
mCustomCommands(engine.getPerRenderPassArena()) {
// compute the number of commands we need
updateSummedPrimitiveCounts(
const_cast<FScene::RenderableSoa&>(mRenderableSoa), builder.mVisibleRenderables);
uint32_t commandCount =
FScene::getPrimitiveCount(mRenderableSoa, builder.mVisibleRenderables.last);
const bool colorPass = bool(builder.mCommandTypeFlags & CommandTypeFlags::COLOR);
const bool depthPass = bool(builder.mCommandTypeFlags & CommandTypeFlags::DEPTH);
commandCount *= uint32_t(colorPass * 2 + depthPass);
commandCount += 1; // for the sentinel
uint32_t const customCommandCount =
builder.mCustomCommands.has_value() ? builder.mCustomCommands->size() : 0;
Command* const commandBegin = builder.mArena.alloc<Command>(commandCount + customCommandCount);
Command* commandEnd = commandBegin + (commandCount + customCommandCount);
assert_invariant(commandBegin);
if (UTILS_UNLIKELY(builder.mArena.getAllocator().isHeapAllocation(commandBegin))) {
static bool sLogOnce = true;
if (UTILS_UNLIKELY(sLogOnce)) {
sLogOnce = false;
PANIC_LOG("RenderPass arena is full, using slower system heap. Please increase "
"the appropriate constant (e.g. FILAMENT_PER_RENDER_PASS_ARENA_SIZE_IN_MB).");
}
}
appendCommands(engine, { commandBegin, commandCount },
builder.mUboHandle,
builder.mVisibleRenderables,
builder.mCommandTypeFlags,
builder.mFlags,
builder.mVisibilityMask,
builder.mVariant,
builder.mCameraPosition,
builder.mCameraForwardVector);
if (builder.mCustomCommands.has_value()) {
Command* p = commandBegin + commandCount;
for (auto [channel, passId, command, order, fn]: builder.mCustomCommands.value()) {
appendCustomCommand(p++, channel, passId, command, order, fn);
}
}
// sort commands once we're done adding commands
commandEnd = resize(builder.mArena,
RenderPass::sortCommands(commandBegin, commandEnd));
if (engine.isAutomaticInstancingEnabled()) {
int32_t stereoscopicEyeCount = 1;
if (builder.mFlags & IS_INSTANCED_STEREOSCOPIC) {
stereoscopicEyeCount *= engine.getConfig().stereoscopicEyeCount;
}
commandEnd = resize(builder.mArena,
instanceify(engine, commandBegin, commandEnd, stereoscopicEyeCount));
}
// these are `const` from this point on...
mCommandBegin = commandBegin;
mCommandEnd = commandEnd;
}
RenderPass 생성자는 꽤 볼만한 게 있어보인다.
updateSummedPrimitiveCounts() 부터 차례대로 살펴보면, 우선 이 함수는 단순히 mRenderableSoa을 순회하며 Primitive라는 것들의 개수를 세어 저장하는 함수다.
void RenderPass::updateSummedPrimitiveCounts(
FScene::RenderableSoa& renderableData, Range<uint32_t> vr) noexcept {
auto const* const UTILS_RESTRICT primitives = renderableData.data<FScene::PRIMITIVES>();
uint32_t* const UTILS_RESTRICT summedPrimitiveCount = renderableData.data<FScene::SUMMED_PRIMITIVE_COUNT>();
uint32_t count = 0;
for (uint32_t const i : vr) {
summedPrimitiveCount[i] = count;
count += primitives[i].size();
}
// we're guaranteed to have enough space at the end of vr
summedPrimitiveCount[vr.last] = count;
}
RenderableSoa에서 primitive 목록과 summedPrimitiveCount 목록을 받아와 vr를 통해 루프를 돌며 개수를 세고 있다.
Range<uint32_t> vr은 first와 last를 멤버로 가져 단순히 범위만을 표현하는 객체다. uint_32 템플릿 인자는 first와 last의 형식이다. 그러니 vr은 uint32_t first와 uint32_t last를 멤버로 가지고 있다.
primitives는 auto const*로 되어 있어 코드만 봐선 뭔지 알기 힘들다. 이 변수의 형식을 적어보면 const utils::Slice<filament::FRenderPrimitive>* const restrict 다.
처음보는 게 많아서 피곤하겠지만... 별로 대단한 건 없다. slice는 그냥 벡터고, FRenderPrimitive는 다른 상용 엔진으로 치면 SubMesh로 볼 수 있다.
FRenderPrimitive의 멤버를 잘 따라가서 살펴보면 VertexBuffer, IndexBuffer 와 같은 기하학 정보와 MaterialInstance 를 들고 있는데, 이러한 형태는 다른 상용 엔진에서의 SubMesh와 동일한 구성이다.
즉 primitives는 렌더링할 서브 메쉬에 대한 배열이라 보면 된다.
혹시라도 SubMesh에 대해 모르는 사람이 있을 수 있으니 간략하게 설명하자면 SubMesh는 물체의 형상이 어떻게 생겼는지와 그리고 그 재질(금속인지 털 혹은 천인지 등) 분류하는 단위다.
예를 들면 사람의 머리카락, 팔, 손톱 등은 모두 인체의 구성요소지만 각 부위의 재질은 다르지 않는가? 머리카락은 얇은 털같은 형상에 윤기가 나기도 하고, 피부는 대게 부드러우며 손톱은 단단하다.
Material 단위로 동작하는 현대의 렌더링 구조에선 이러한 다양한 재질의 표현을 위해 한 모델을 여러 개의 서브 메쉬단위로 분류한다.
위 예시대로라면 사람에겐 머리카락, 몸, 손톱이라는 서브 메쉬가 있다고 볼 수 있다. 물론 실제로는 더 다양하고 복잡하게 분류될 수 있다.
그런데 변수의 형식을 자세히 보면 이 변수는 사실상 utils::Slice<T>* 형인 것을 볼 수 있다. 고로 이는 달리 말하자면 이차원 벡터다.
그렇다면 updateSummedPrimitiveCounts()는 렌더링이 되어야할 데이터를 서브메쉬 단위로 개수를 세어 저장하는 일을 한다고 말할 수 있다.
이러한 동작을 미리 하는 이유는 아마 filament가 메모리 관리를 다소 타이트하게 관리하기 때문일 것이다. 모바일 환경에 최적화하기 위해 filament의 여러 클래스 구조는 매우 데이터 지향적이거나 동적 할당을 최소화하기 위한 구조로 되어 있다.
매 프레임 Command를 담아두고 처리하는 저장 공간을 미리 할당된 큰 배열을 이용하고 있는데, 이 배열이 확장성이 없는 사실상 정적 배열에 가까운 구조다. 런타임에서 이 Command 배열의 크기를 늘릴 순 없고, 사전에 사용자가 얼만큼의 공간을 할당하여 사용할 지 지정해줘야만 한다.
그래서 일부 코드를 보다보면 미리 배열의 데이터 개수를 세어 예상되는 요구 메모리양을 확인하고 예외를 거는 식의 코드가 눈에 보인다. 아마 이러한 개수 세기 작업도 이런 이유 때문일 것이다.
아무튼 간에, 이어서 코드를 보면 Command를 생성하는 코드인 것으로 보이는데... 여기까지만 쓰는데도 글이 충분히 길어진 관계로 다음 포스팅부터 이어서 살펴보겠다.
이 글은 Apache License 2.0으로 공개된 Google의 Filament 프로젝트를 참고하여 작성되었습니다. 원본 라이센스 보기
'Graphics > filament' 카테고리의 다른 글
filament 렌더링 엔진 여행기 #4 - RenderPass (2) (0) | 2025.05.22 |
---|---|
filament 렌더링 엔진 여행기 #2 - Renderer와 RenderPass (0) | 2025.04.21 |
filament 렌더링 엔진 여행기 #1 - 구글의 오픈소스 엔진은 렌더링을 어떻게 할까? (2) | 2025.04.13 |