Search

유리 공예 프로젝트 일지 #2 - Depth Peeling 구현

프로젝트 개요

지난 일지에서 유리 두께 계산의 필요성을 깨달은 후, 본격적으로 Depth Peeling 시스템 구현에 착수했습니다. 목표는 Unity URP의 Render Graph API를 활용한 6-layer depth peeling 시스템 구축이었습니다.

첫 번째 시도: 기본 아키텍처 구성

초기 설계

처음에는 단순하게 3개 패스로 구성했습니다:
DepthPeelingRendererFeature ├── DepthPeelingPass (6-layer progressive extraction) ├── ThicknessCalculationPass (layer difference analysis) └── GlassRenderingPass (final compositing)
Plain Text
복사
DepthPeelingPass에서 각 레이어를 뽑아내고, ThicknessCalculationPass에서 두께를 계산한 다음, GlassRenderingPass에서 최종 렌더링하는 구조였습니다.

첫 번째 벽: Render Graph Builder 오류

구현을 시작하자마자 이런 에러가 떴습니다:
Exception: Please finish building the previous pass first by disposing the pass builder object before adding a new pass.
Plain Text
복사
정확히 왜 이렇게 됐는지는 기억이 안 나는데, 아마 builder 사용 방식에 문제가 있었던 것 같습니다. 각 pass를 순차적으로 생성하니까 해결됐습니다.

두 번째 벽: XR 매크로 호환성

별 생각 없이 Blit을 사용하려고 했는데 이런 에러가 났습니다:
Shader error: unrecognized identifier 'TEXTURE2D_X'
Plain Text
복사
VR 프로젝트라서 XR 관련 매크로가 문제를 일으킨 것 같았습니다. Blit.hlsl을 포기하고 그냥 직접 fullscreen triangle을 구현했습니다:
// fullscreen triangle float2 uv = float2((vertexID << 1) & 2, vertexID & 2); output.positionHCS = float4(uv * 2.0 - 1.0, 0.0, 1.0);
Plain Text
복사

첫 번째 성과

Frame Debugger와 Render Graph Viewer에서 6개 패스가 순서대로 실행되는 것을 확인했습니다:
Frame Debugger
Render Graph Viewer
기본 구조는 작동했지만, 결과물이 이상했습니다.

두 번째 시도: Depth Peeling 로직 문제

예상과 다른 결과

Depth peeling이 작동하는 것처럼 보였지만, 결과가 완전히 비정상적이었습니다:
모든 레이어에서 거의 동일한 결과 출력
점진적 감소 패턴이 전혀 보이지 않음
자글자글하게 찢어진 듯한 모자이크 패턴

로직 문제 발견

기존 로직을 다시 살펴봤습니다:
// 문제가 있던 로직 float previousDepth = SAMPLE_TEXTURE2D(_PreviousDepthLayer, screenUV).r; clip(input.linearDepth - previousDepth - 0.001);
Plain Text
복사
각 레이어가 직전 1개 레이어와만 비교하고 있었습니다. 이게 완전히 틀린 접근이었습니다:
Layer 2는 Layer 1과만 비교 → Layer 0은 무시
Layer 3은 Layer 2와만 비교 → Layer 0, 1은 무시
결과: 이미 렌더링된 depth가 다시 렌더링됨

누적 vs 멀티텍스쳐 고민

제대로 하려면 "지금까지 렌더링된 가장 가까운 depth"와 비교하는 누적 방식이 맞다는 걸 깨달았습니다. 참고했던 Three.js 레퍼런스에서도 이런 식으로 구현되어 있었습니다:
// Three.js 방식 if(DepthRenderedIndex>0 && depth>=renderdDepth-0.000001) discard;
JavaScript
복사
하지만 ping-pong 렌더링으로 누적하는 건 Render Graph에서 어떻게 해야 할지 확신이 없었습니다. 자원 의존성 관리나 순차적 처리 보장 부분에서 문제가 발생할 것 같았습니다.
그래서 일단 안전한 방법인 멀티텍스쳐 방식을 선택했습니다. 각 레이어를 독립적으로 저장하고, 모든 이전 레이어와 비교하는 방식이었습니다:
// 멀티텍스쳐 방식 for(int i = 0; i < currentLayerIndex; i++) { if(input.linearDepth <= layerDepth[i] + epsilon) discard; }
Plain Text
복사

세 번째 문제: Material Property 바인딩 지옥

레이어 인덱스가 모두 동일한 문제

로직을 수정했는데도 여전히 문제가 있었습니다. Frame Debugger에서 확인해보니 모든 패스의 LayerIndex가 3으로 동일했습니다. 딱 봐도 MaxLayer - 1 값이 유지되는 것 같았습니다.
Frame Debugger

바인딩 타이밍 함정

코드를 다시 살펴보니 어이없는 문제였습니다:
// 문제가 있던 코드 for (int layerIndex = 0; layerIndex < maxLayers; layerIndex++) { using (var builder = renderGraph.AddRenderPass<PassData>(...)) { settings.depthPeelingMaterial.SetInt("_LayerIndex", layerIndex); builder.SetRenderFunc((PassData data, RasterGraphContext context) => { // 실행 시점에는 layerIndex가 이미 마지막 값! context.cmd.DrawRendererList(data.rendererList); }); } }
C#
복사
1.
for loop가 먼저 완전히 실행됨 (layerIndex = 0,1,2,3,4,5)
2.
나중에 RenderFunc들이 실행됨
3.
모든 RenderFunc가 같은 Material 인스턴스를 참조
4.
마지막 설정인 layerIndex = 5가 모든 pass에 적용됨

Material 인스턴스 분리 시도

이 문제를 해결하기 위해 각 레이어마다 별도 Material 인스턴스를 만드는 방법을 시도했습니다. 사실 깔끔한 방법은 아니라고 생각했는데, 일단 빠르게 결과가 나올 것 같아서 해봤습니다:
// 찝찝하지만 시도해본 방법 settings.layerMaterials = new Material[maxLayers]; for (int i = 0; i < maxLayers; i++) { settings.layerMaterials[i] = new Material(settings.depthPeelingMaterial); settings.layerMaterials[i].SetInt("_LayerIndex", i); settings.layerMaterials[i].name = $"DepthPeeling_Layer{i}"; }
C#
복사
예상했던 대로 문제가 생겼습니다. Material을 분리하니까 각 패스에서 이전 레이어 텍스처들을 참조하는 부분이 제대로 안 됐습니다. 각 Material이 서로 다른 인스턴스가 되면서 텍스처 바인딩이 꼬인 것 같았습니다.
if (_LayerIndex == 1) { float prevDepth = SampleDepthFromLayer(0, screenUV); // 이전 레이어가 검은색(0)이면 녹색, 아니면 빨간색 return prevDepth < 0.001 ? float2(0, 1) : float2(1, 0); }
C#
복사
역시 무리수였습니다.

Global State로 어쩔 수 없이 해결

결국 AllowGlobalStateModification(true)를 사용해서 해결했습니다:
builder.AllowGlobalStateModification(true); builder.SetRenderFunc((PassData data, RasterGraphContext context) => { context.cmd.SetGlobalInt("_CurrentDepthPeelingLayer", data.layerIndex); context.cmd.DrawRendererList(data.rendererList); });
C#
복사
공식 문서에서는 이 방법이 최적화를 어렵게 만들어서 성능 이슈가 있을 수 있다고 했는데, 다른 방법이 생각나지 않아서 어쩔 수 없이 선택했습니다.

네 번째 문제: Z-Fighting과 Culling

모자이크 패턴 문제

레이어 인덱스 문제를 해결했지만, 여전히 Layer 2에서 모자이크 패턴이 나타났습니다:
깨진 layer 2

Z-Fighting으로 추정

패턴을 보니 Z-fighting의 전형적인 증상 같았습니다. Front/Back face가 거의 붙어있는 영역에서 epsilon 오차 범위 내 경합이 발생하는 것으로 보였습니다.
해결책으로 Culling을 분리했습니다:
짝수 레이어 (0,2,4): Front face only (Cull Back)
홀수 레이어 (1,3,5): Back face only (Cull Front)

조건부 비교 로직 완화

각 레이어별로 비교 대상을 제한했습니다:
Layer 0 (Front): 무조건 허용 Layer 1 (Back): 무조건 허용 Layer 2 (Front): Layer 0보다 뒤에만 Layer 3 (Back): Layer 1보다 뒤에만 Layer 4 (Front): Layer 0,2보다 뒤에만 Layer 5 (Back): Layer 1,3보다 뒤에만
Plain Text
복사
layer 1
layer 2

최종 구현: 무식하지만 작동하는 두께 계산

드디어 성공한 Depth Peeling

마침내 각 레이어가 점진적으로 감소하는 올바른 결과를 얻었습니다:
layer 1
layer 2
두께 디버깅

ThicknessCalculationPass 제거

원래 계획은 별도 패스에서 두께를 계산하는 것이었는데, 더 간단한 방법이 생각났습니다. GlassRenderingPass에서 직접 계산하는 것이었습니다.
무식하지만 확실한 방법으로 구현했습니다:
float CalculateThickness(float currentDepth, float2 screenUV) { // 현재 fragment가 어느 front layer에 속하는지 찾기 for(int i = 0; i < 6; i += 2) { float frontDepth = SampleDepthLayer(i, screenUV); // 현재 depth와 front layer depth가 비슷하면 매칭 if(frontDepth > 0.001 && abs(currentDepth - frontDepth) < _DepthEpsilon) { // 다음 back layer와의 거리가 두께 if(i + 1 < 6) { float backDepth = SampleDepthLayer(i + 1, screenUV); if(backDepth > 0.001) { return backDepth - frontDepth; } } break; } } return 0.0; }
Plain Text
복사
이렇게 하니 ThicknessCalculationPass가 필요 없어져서 아키텍처가 깔끔해졌습니다:
DepthPeelingRendererFeature ├── DepthPeelingPass (6-layer progressive extraction) └── GlassRenderingPass (final compositing with thickness)
Plain Text
복사
성능상으로는 별 차이 없을 것 같긴 한데, 구조가 단순해진 건 확실하니까 기분은 좋습니다.

드디어 얻은 정확한 두께값

마침내 원하던 결과를 얻었습니다. 이제 각 픽셀에서 실제 유리의 두께를 정확히 계산할 수 있게 되었습니다. 이 두께값으로 할 수 있는 것들이 정말 많습니다:
Beer's Law 기반 흡수 효과: 두께가 두꺼울수록 색상이 진해지고, 얇을수록 투명해지는 현실적인 유리 표현이 가능합니다.
굴절률 조절: 두께에 따라 굴절 강도를 달리해서 더 사실적인 왜곡 효과를 만들 수 있습니다.
코스틱 효과: 두께 정보를 바탕으로 빛이 집중되는 영역을 계산해서 바닥에 투영할 수 있습니다. 방법은 좀 더 고민해야겠지만..
물리 기반 렌더링: 실제 유리의 광학적 특성을 정확히 시뮬레이션할 수 있는 기반이 마련되었습니다.
3일 동안 정말 많이 헤맸지만, 결국 제대로 된 시스템을 구축할 수 있었습니다. 이제 진짜 유리 같은 렌더링을 만들어볼 수 있을 것 같아서 기대됩니다.

결과와 반성

시행착오 총정리

3일 동안 겪은 주요 문제들:
1.
Render Graph Builder 중첩: 순차적 생성으로 해결
2.
XR 매크로 충돌: Fullscreen triangle 직접 구현
3.
Depth Peeling 로직: 멀티텍스쳐 방식으로 우회
4.
Material Property 바인딩: Global State로 어쩔 수 없이 해결
5.
Z-Fighting: Culling 분리로 우회
6.
두께 계산: 무식한 방법이지만 작동함

현실적인 교훈

Render Graph API는 생각보다 까다롭습니다
기술이 부족하면 차라리 무식한 방법이 더 안정적입니다
Frame Debugger 없이는 절대 불가능했을 것 같습니다

성능 특성

메모리 사용량: 6 × RenderTexture (해상도 의존적)
GPU 연산: Layer N에서 최대 N번의 texture read
총 texture reads: 0+1+2+3+4+5 = 15 reads per pixel maximum
PC VR에서는 충분히 돌아가고, 생각보다 아주 가벼워서 VR 단독에서도 문제 없을 것 같습니다.

다음 계획

이제 정확한 두께 값을 얻었으니 다음 단계로 두께를 활용한 유리 특성 재현을 해봐야 겠습니다. glass absorption 부터 테스트해보면 딱 느낌이 올 것 같습니다.
정말 오래 걸렸지만, 마침내 작동하는 시스템을 만들었습니다. 다음엔 좀 더 매끄럽게 진행되길 바라면서...

참고