프로젝트 시작
이번에 유리 공예 프로젝트를 시작했습니다. 유리라는 재료가 얼마나 복잡한지 제대로 몰랐는데, 막상 해보니 투명성과 굴절, 반사 등 고려해야 할 요소가 생각보다 많았습니다. 특히 굴절과 반사, 색상까지 모든 요소가 유리의 두께에 영향을 심각하게 받는 다는 사실을 깨달았습니다.
그래서 일단 첫 번째 목표는 유리 오브젝트의 두께를 실시간으로 정확하게 계산하는 것으로 잡았습니다. 처음에는 간단할 줄 알았는데..
첫 번째 시도 "뒷면에서 앞면 빼면 되는 거 아닌가?"
가장 먼저 든 생각은 정말 단순했습니다. 각 픽셀에서 뒷면 깊이에서 앞면 깊이를 빼면 두께가 나올 것이라고 생각했습니다. 이론적으로는 맞는 말이니까요.
Multi-pass 셰이더로 구현해봤습니다:
// 첫 번째 패스: 뒷면만 렌더링해서 깊이 저장
Pass
{
Cull Front // 앞면 숨기고 뒷면만
ZWrite On // 깊이 버퍼에 저장
ColorMask 0 // 색상은 안 그림
}
// 두 번째 패스: 앞면 그리면서 두께 계산
Pass
{
Cull Back // 뒷면 숨기고 앞면만
float thickness = abs(linearBackDepth - linearFrontDepth);
}
Plain Text
복사
테스트 결과 1
결과를 보고 바로 이상하다는 생각이 들었습니다. 아니 3개 오브젝트 두께 차이가 이렇게까지 날리가 없는데? 카메라를 움직이니까 같은 유리인데 두께가 계속 바뀌네?
첫 번째 삽질의 교훈: 뷰 스페이스 vs 월드 스페이스
한참 고민하다가 깨달았습니다. LinearEyeDepth가 반환하는 건 뷰 스페이스에서의 Z 거리인데, 제가 원하는 건 실제 3D 공간에서의 물리적 두께였습니다. 카메라 각도가 바뀌면 같은 물체라도 투영된 깊이가 달라지니까, 당연히 이상한 결과가 나올 수밖에 없었습니다.
정말 간단한 원리인데 왜 놓쳤을까요. 아마 "깊이 = 두께"라는 생각에 갇혀 있었던 것 같습니다. 실제로는 월드 스페이스 위치를 재구성해서 실제 거리를 계산해야 했는데 말입니다.
// 이게 맞는 방법이었겠죠
float3 worldPosFront = ComputeWorldSpacePosition(screenUV, frontDepth, UNITY_MATRIX_I_VP);
float3 worldPosBack = ComputeWorldSpacePosition(screenUV, backDepth, UNITY_MATRIX_I_VP);
float actualThickness = length(worldPosBack - worldPosFront);
Plain Text
복사
두 번째 시도: Render Feature로 업그레이드
어쨌든 문제를 파악했으니 이제 제대로 해보자 싶어서 Render Feature를 만들어봤습니다. Render Feature는 URP에서 몇 번 시도만 해봤는데 매번 문서와 코드를 들고 Claude와 씨름하게 됩니다. 이게 버전마다 API 차이가 심한건지, 항상 참고한 코드를 쓰지 못하고.. 이번에도 어김없이 deprecated의 연속.
public class GlassThicknessRendererFeature : ScriptableRendererFeature
{
class BackDepthRenderPass : ScriptableRenderPass
{
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
// R32_SFloat 포맷으로 백 깊이 저장
var backDepthDescriptor = renderGraph.GetTextureDesc(resourceData.activeColorTexture);
backDepthDescriptor.colorFormat = GraphicsFormat.R32_SFloat;
backDepthDescriptor.depthBufferBits = 0; // 여기가 좀 애매했음
// Glass layer만 필터링
drawingSettings.overrideMaterial = backDepthMaterial;
}
}
}
C#
복사
여기서 한 가지 고민이 있었는데, depthBufferBits = 0으로 설정한 게 맞나 싶었습니다. 백 페이스들끼리도 깊이 테스트가 필요할 텐데 깊이 버퍼를 아예 꺼버리면 겹친 백 페이스들 사이의 정렬이 제대로 안 될 수 있지 않을까 생각했습니다. 일단 테스트해보니 단순한 케이스에서는 괜찮았는데, 복잡한 구조에서는 어떨지 모르겠습니다.
결과 자체는 만족스러웠습니다. 각도 변화에 따른 두께 변화도 자연스럽고, 외각은 얇게 중앙은 두껍게 나오니까 이제 됐나 싶었습니다.
세 번째 벽: "겹치면 어떻게 하지?"
그런데 Frame Debugger를 보다가 문득 생각이 들었습니다. "이거 유리가 겹치면 어떻게 되지?"
Frame Debugger 결과 - Back Depth
생각해보니 현재 방식은 화면의 각 픽셀당 하나의 백 깊이만 저장합니다. 그러면 이런 상황에서:
카메라 → [유리A 앞면] → [유리A 뒷면] → [유리B 앞면] → [유리B 뒷면]
Plain Text
복사
유리A의 뒷면만 백 깊이 텍스처에 저장되고, 유리B를 그릴 때는 "유리A 뒷면 - 유리B 앞면"이라는 말도 안 되는 계산을 하게 됩니다. 이건 완전히 잘못된 것이었습니다.
더 복잡한 건, 실제 유리는 단순한 앞뒤면이 아니라는 점입니다. 유리잔 같은 경우를 생각해보면:
•
바깥쪽 앞면
•
바깥쪽 뒷면
•
안쪽 앞면
•
안쪽 뒷면
이렇게 4개의 페이스가 있는데, 현재 방식으로는 이런 복잡한 구조를 전혀 처리할 수 없습니다.
네 번째 시도: Depth Peeling의 함정
"그럼 여러 레이어로 나눠서 처리하면 되지 않을까?" 싶어서 Depth Peeling 방식도 시도해봤습니다. 이론적으로는 첫 번째 레이어를 처리하고, 그 다음 레이어, 그 다음 레이어... 이런 식으로 하면 될 것 같았습니다.
Frame Debugger 결과 - Depth Peel Layer
하지만 막상 구현해보니 문제가 한두 개가 아니었습니다. 일단 레이어 수만큼 렌더 패스가 2배씩 늘어나니까 성능이 걱정되고, 더 중요한 건 투명 객체의 특성상 "어떤 부분을 제거하고 다음 계산에서 빼야 하는지"가 명확하지 않다는 점이었습니다.
투명한 유리니까 뒤쪽도 어느 정도 보이잖아요? 그럼 어디까지가 "처리 완료"이고 어디서부터 "다음 레이어"인지 애매했습니다.
현재 상황 정리와 다음 계획
여기까지 하면서 느낀 건, 처음에 "간단할 줄 알았는데"라고 생각했던 게 얼마나 순진한 생각이었는지 깨달았습니다. 유리 두께 계산이라는 게 단순히 앞뒤 깊이 차이가 아니라, 복잡한 3D 구조와 여러 교차점을 고려해야 하는 문제였습니다.
특히 단일 메시에서 다중 교차점이 있는 경우는 기존 래스터화 방식으로는 한계가 명확해 보입니다. 결국 픽셀별로 정확한 교차점을 계산하는 Depth Peeling 방식을 고려해야겠다는 결론에 도달했습니다.
조사하며 얻은 Depth Peeling의 장점은 분명합니다:
•
픽셀별로 정확한 교차점 계산이 가능하고
•
복잡한 유리 내부 구조도 처리할 수 있고
물론 직접 구현을 해야하지만, 정확한 시뮬레이션을 위해서는 피할 수 없는 단계인 것 같습니다.
오늘의 총평
오늘 하루 종일 이것저것 시도해보면서 느낀 건, 역시 실패해봐야 감이 잡힌다는 점입니다. 각각의 접근 방식이 왜 안 되는지 명확해졌으니까, 다음에 Depth Peeling을 제대로 구현할 때도 어떤 부분을 조심해야 할지 감이 잡혔습니다.
특히 Unity의 렌더링 파이프라인을 더 깊이 이해하게 된 것도 좋았습니다. Render Feature 구현하면서 RenderGraph 시스템도 좀 더 익숙해졌고요.
다음 단계에서는 Depth Peeling 프로토타입을 만들어볼 예정입니다. 일단 간단한 케이스부터 시작해서 성능과 정확도를 검증해보고, 실용적인지 판단해보려고 합니다. 또 삽질하게 될 것 같지만, 그래도 방향성은 확실해졌으니까 전보다는 나을 것 같습니다.