최근 고객사의 요청이 있던 부분과 관련 된 Edge Fusion은 Unity URP에서 오브젝트 간 경계를 자연스럽게 블렌딩하는 포스트 프로세싱 효과입니다. 블렌더에서 구현 된 것을 이 전에 본적이 있어서 구현 준비를 했습니다만 Kronnect 에서 어제 릴리스를 했습니다. 본 글에서는 이를 구성하는 3가지 핵심 알고리즘에 대해 살펴보겠습니다.
일반적인 렌더링 과정에서는 최종 화면에 색상 정보만 남게 되며 오브젝트 정보는 소실됩니다. Edge Fusion이 엣지를 정확하게 찾기 위해서는 각 픽셀이 어떤 오브젝트에 속하는지 식별할 수 있어야 합니다.
EdgeFusionRenderPass는 별도의 렌더 패스를 통해 모든 오브젝트를 고유 ID로 렌더링하는 방식을 사용합니다.
// 1단계: 오브젝트 위치를 기반으로 고유값 생성
float3 p = TransformObjectToWorld(float3(1, 1, 1));
float objectID = dot(p, 1.0); // x + y + z
// 2단계: Instancing ID 추가
#if UNITY_ANY_INSTANCING_ENABLED
objectID += unity_InstanceID;
#endif
// 3단계: 커스텀 ID가 있으면 덮어쓰기
if (_CustomObjectId > 0.5)
objectID = _CustomObjectId;
// 4단계: 정수로 변환
output.objectID = floor(objectID);
단순히 ID만 저장하는 것이 아니라, 4개 채널에 여러 정보를 패킹하여 저장합니다.
return float4(packedR, rawDepth, normalVS.xy);
채널별 의미:
오브젝트 A의 World Position: (5, 10, 3)
→ objectID = 5 + 10 + 3 = 18
오브젝트 B의 World Position: (2, 7, 1)
→ objectID = 2 + 7 + 1 = 10
같은 위치의 인스턴스:
→ objectID = 18 + InstanceID(1) = 19
이러한 방식을 통해 셰이더는 픽셀 단위로 인접한 픽셀이 동일한 오브젝트에 속하는지 여부를 판단할 수 있으며, 이를 통해 엣지를 감지할 수 있습니다.
// Blend Pass에서 사용 예시
float myObjectID = UnpackObjectId(objectIDTexture[currentPixel].r);
float neighborObjectID = UnpackObjectId(objectIDTexture[neighborPixel].r);
if (myObjectID != neighborObjectID) {
// 다른 오브젝트 = 엣지 발견
}
상하좌우 4방향만 체크하는 단순한 방식으로는 대각선 방향의 엣지를 놓칠 수 있습니다.
현재 픽셀을 중심으로 원형으로 여러 방향을 샘플링하는 방식을 사용합니다.
// EdgeFusionBlendPass.hlsl (의사코드)
for (int i = 0; i < sampleCount; i++) {
// 360도를 sampleCount로 나눔
float angle = (i / sampleCount) * TWO_PI;
// 방향 벡터 계산
float2 direction = float2(cos(angle), sin(angle));
// 현재 픽셀로부터 radius만큼 떨어진 위치 샘플링
float2 sampleUV = currentUV + direction * radius;
// 해당 위치의 ObjectID 확인
float neighborObjectID = SampleObjectID(sampleUV);
if (neighborObjectID != myObjectID) {
// 엣지 발견
}
}
sampleCount = 8인 경우:
7
6 ↑ 0
\|/
5 ←--[나]--→ 1
/|\
4 ↓ 2
3
8방향으로 샘플링
각도: 0°, 45°, 90°, 135°, 180°, 225°, 270°, 315°
방사형 샘플링을 통해 엣지가 존재하는 방향을 파악할 수 있지만, 정확한 거리는 알 수 없습니다. 이를 해결하기 위해 이진 탐색 기법을 적용합니다.
// 1. 초기 범위 설정
float minDist = 0.0; // 내 위치
float maxDist = radius; // 최대 샘플링 거리
// 2. 이진 탐색 반복 (binarySearchSteps 횟수만큼)
for (int step = 0; step < binarySearchSteps; step++) {
// 중간 지점 샘플링
float midDist = (minDist + maxDist) * 0.5;
float2 midUV = currentUV + direction * midDist;
float midObjectID = SampleObjectID(midUV);
if (midObjectID == myObjectID) {
// 아직 내 오브젝트 영역
minDist = midDist;
} else {
// 다른 오브젝트 영역
maxDist = midDist;
}
}
// 3. 최종 엣지 거리
float edgeDistance = (minDist + maxDist) * 0.5;
binarySearchSteps = 3 예시:
Step 0: 초기 범위
[나:5]================================[이웃:12]
0m 0.1m
↓ 중간 체크 (0.05m)
[나:5]================|===============[이웃:12]
(ID=5, 아직 내 영역)
→ minDist = 0.05m
Step 1: 범위 좁히기
[나:5]========|========[이웃:12]
0.05m 0.075m 0.1m
↓ 중간 체크
[나:5]====|=====[이웃:12]
(ID=12, 지나침)
→ maxDist = 0.075m
Step 2: 더 좁히기
[나:5]==|==[이웃:12]
0.05 0.0625 0.075
↓ 중간 체크
[나:5]=|=[이웃:12]
(ID=5, 아직 내 영역)
→ minDist = 0.0625m
최종: 엣지는 약 0.0625m ~ 0.075m 사이
→ 평균: 0.06875m
모든 방향을 검사하지 않고 충분한 수의 엣지를 발견하면 조기 종료하는 최적화 기법을 사용합니다.
int edgeHitCount = 0;
for (int i = 0; i < sampleCount; i++) {
// 샘플링 과정
if (foundEdge) {
edgeHitCount++;
// 충분한 엣지를 발견하면 중단
if (edgeHitCount >= earlyExitHits) {
break;
}
}
}
효과:
단순히 50:50 비율로 색상을 혼합할 경우 부자연스러운 결과가 발생합니다. 거리에 따라 부드럽게 감쇠(falloff)시키는 처리가 필요합니다.
// 엣지까지의 거리 정규화
float normalizedDistance = edgeDistance / radius;
// 0.0 = 엣지 바로 위
// 1.0 = 최대 블렌딩 거리
// 감쇠 곡선 계산 (smoothstep)
float falloff = 1.0 - normalizedDistance;
falloff = smoothstep(0.0, 1.0, falloff);
// 0에 가까울수록 블렌딩 약함
// 1에 가까울수록 블렌딩 강함

여러 방향에서 발견한 엣지들을 가중 평균 방식으로 블렌딩합니다.
// 1. 각 방향의 가중치 계산
float totalWeight = 0.0;
float3 blendedColor = float3(0, 0, 0);
for (each edgeDirection) {
float dist = edgeDistances[i];
float weight = CalculateFalloff(dist, radius);
// 엣지 너머의 색상 샘플링
float3 neighborColor = SampleColor(edgePositions[i]);
// 가중치 누적
blendedColor += neighborColor * weight;
totalWeight += weight;
}
// 2. 정규화
if (totalWeight > 0) {
blendedColor /= totalWeight;
}
// 3. 원본 색상과 블렌딩
float3 originalColor = SampleColor(currentPixel);
float3 finalColor = lerp(originalColor, blendedColor, intensity * globalFalloff);
상황: 빨간 큐브와 파란 구가 맞닿아 있는 경계선 근처 픽셀 분석
1. ObjectID 확인
→ 내 ID = 5 (빨간 큐브)
2. 방사형 샘플링 (8방향)
- 0° (→): ID=5 (동일, 엣지 없음)
- 45° (↗): ID=5 (동일)
- 90° (↑): ID=12 (상이, 엣지 발견, 거리=0.03m)
- 135° (↖): ID=12 (상이, 엣지 발견, 거리=0.04m)
- 180° (←): ID=5 (동일)
- 나머지 방향도 검사
3. Binary Search로 정밀화
- 90° 방향 엣지: 정확히 0.028m
- 135° 방향 엣지: 정확히 0.037m
4. 가중치 계산 (radius=0.05m)
- 90° 가중치: 1.0 - (0.028/0.05) = 0.44
- 135° 가중치: 1.0 - (0.037/0.05) = 0.26
5. 블렌딩
- 90° 위치의 파란색: RGB(0, 0, 255) * 0.44
- 135° 위치의 파란색: RGB(0, 0, 255) * 0.26
- 가중 평균 계산
- 원본 빨간색과 혼합
6. 최종 색상
RGB(255, 0, 0) → RGB(180, 0, 75) (약간 보라빛)
→ 경계가 부드럽게 처리됨
// 그림자 영역 감지
float shadow = 1.0 - saturate(luminance(originalColor) / threshold);
// 그림자에서는 블렌딩 약화
blendStrength *= (1.0 - shadow * shadowProtection);
적용 이유: 그림자 경계는 실제 오브젝트 경계가 아니므로, 블렌딩 시 부자연스러운 결과가 발생할 수 있습니다.
// 3D 노이즈 텍스처 샘플링
float noise = tex3D(noiseTex, worldPos * noiseScale);
// 블렌딩 반경에 노이즈 추가
float adjustedRadius = radius * (1.0 + noise * noiseIntensity);
효과: 기계적이지 않은 자연스러운 변화를 연출합니다.
// 화면 공간에서 radius 계산
float screenRadius = WorldRadiusToScreenRadius(radius, depth);
// 최대값 제한
screenRadius = min(screenRadius, maxScreenRadius * screenHeight);
적용 이유: 먼 거리의 오브젝트에서 과도한 블렌딩이 발생하는 것을 방지합니다.
| Preset | Sample Count | Binary Search | Early Exit | 권장 용도 |
|---|---|---|---|---|
| Very Low | 4 | 2 | 1 | 모바일, 저사양 환경 |
| Low | 8 | 4 | 2 | 일반 게임 |
| Medium | 16 | 5 | 3 | 균형잡힌 설정 |
| High | 24 | 7 | 4 | 고품질 게임 |
| Very High | 32 | 8 | 5 | 시네마틱, 스크린샷 |
Edge Fusion은 다음 3단계의 알고리즘을 통해 자연스러운 엣지 블렌딩을 구현합니다.
이러한 기술들의 조합을 통해 디테일을 유지하면서도 경계를 부드럽게 처리하는 고품질 렌더링 효과를 달성할 수 있습니다.