Parallax Occlusion Mapping(POM)으로 벽면에 실제 같은 세밀 깊이감 만들기

Parallax Occlusion Mapping(POM)으로 벽면에 실제 같은 세밀 깊이감 만들기

평면 벽 텍스처가 밋밋하게 보이는 문제를 Parallax Occlusion Mapping(POM)으로 해결한다. 높이맵 레이 마칭, UV 보정, 품질·성능 튜닝과 HLSL 구현 절차를 정리한다.

TL;DR

Parallax Occlusion Mapping(POM)은 높이맵을 따라 시선 벡터를 UV 공간에서 레이 마칭(Ray Marching)해 평면 폴리곤 위에도 벽돌 줄눈·균열·석재 틈의 가려짐(차폐: Self-occlusion)과 깊이감을 만드는 기법이다. 일반 노멀맵보다 깊이 착시가 강하지만 경사 시야에서 샘플 수가 늘고 실루엣은 바꾸지 못하므로 벽면처럼 넓고 비교적 평평한 표면에 선택적으로 적용하는 것이 좋다.

벽면 노멀맵만으로 깊이감이 부족한 이유는 무엇인가?

노멀맵은 픽셀의 법선 방향만 바꾼다. 빛의 반응은 요철처럼 보이지만 텍스처를 읽는 UV 좌표는 그대로다. 따라서 카메라가 벽을 비스듬히 볼 때 벽돌 사이 줄눈이 뒤로 밀려나거나 앞쪽 벽돌에 가려지는 현상이 나타나지 않는다.

POM은 뷰 방향에 따라 UV를 이동하고 높이맵 표면과 시선 레이의 교차점을 찾는다. 그 교차점에서 알베도·노멀·러프니스를 다시 샘플링하면 벽돌, 타일, 석재가 더 설득력 있게 겹쳐 보인다.

기법깊이 표현 방식장점한계
노멀맵법선만 변경저렴하고 안정적시차와 가려짐 없음
Parallax Mapping높이값 1회 기반 UV 오프셋구현이 간단함깊은 홈에서 왜곡이 큼
Steep Parallax Mapping고정 단계 레이 마칭깊이 표현 향상교차점이 계단처럼 보일 수 있음
POM레이 마칭 후 선형 보간깊은 줄눈과 가려짐 표현이 좋음텍스처 샘플 비용 증가
실제 디스플레이스먼트정점 위치 변경실루엣까지 변화메시 밀도와 렌더링 비용 필요

벽돌 높이맵과 POM 레이 마칭이 UV 교차점을 찾는 과정을 보여주는 단면 설명

POM은 어떤 원리로 UV 좌표를 찾는가?

탄젠트 공간 시선 벡터를 Vt=(Vx,Vy,Vz)V_t=(V_x,V_y,V_z) 라고 하고 높이 스케일을 hsh_s 라고 하자. 시선이 표면을 따라 이동하는 기본 오프셋 방향은 다음과 같다.

ΔUV=(Vx,Vy)max(Vz,ϵ)hs\Delta UV = \frac{(V_x,V_y)}{\max(V_z, \epsilon)} \cdot h_s

POM은 이 값을 한 번 적용하지 않는다. 높이맵에서 일정 간격으로 UV를 전진시키며 현재 레이어 깊이와 샘플링한 높이가 역전되는 지점을 찾는다. 마지막으로 교차점 전후 두 UV를 선형 보간해 계단 현상을 줄인다.

높이맵의 의미를 머티리얼 전체에서 통일해야 한다. 아래 예제는 흰색 1.0을 표면 상단, 검은색 0.0을 홈 바닥으로 사용한다. 반대 규칙의 소스를 쓴다면 height = 1.0 - height로 변환한다.

POM 벽면 셰이더는 어떻게 구현할까?

1. 탄젠트 공간 데이터와 높이맵을 준비한다

정점 셰이더 또는 엔진의 머티리얼 그래프에서 탄젠트 공간 뷰 방향 viewDirTS를 전달한다. heightMap은 선형 색 공간으로 샘플링해야 하며 sRGB 플래그를 끈다. 색상 텍스처의 알파 채널에 높이맵을 넣는 방식도 가능하지만 압축 손실과 아티스트 작업 흐름을 확인해야 한다.

권장 시작값은 다음과 같다.

파라미터시작값용도
heightScale0.03UV 공간의 깊이 강도
minLayers8정면 시야의 최소 샘플 수
maxLayers32경사 시야의 최대 샘플 수
uvPadding0.002가장자리 클램프 여유

2. 높이맵을 레이 마칭해 교차 구간을 찾는다

다음 HLSL 함수는 고정 최대 반복을 사용한다. viewDirTS는 정규화된 탄젠트 공간 카메라 방향이며 표면 밖을 향하는 Z가 양수라는 좌표계 기준이다.

Texture2D heightMap : register(t0);
SamplerState linearSampler : register(s0);

float2 ParallaxOcclusionUV(
    float2 baseUV,
    float3 viewDirTS,
    float heightScale,
    float minLayers,
    float maxLayers)
{
    float facing = saturate(abs(viewDirTS.z));
    float layerCount = lerp(maxLayers, minLayers, facing);
    float layerDepth = 1.0 / layerCount;

    float2 rayOffset = (viewDirTS.xy / max(viewDirTS.z, 0.001))
                     * heightScale;
    float2 uvStep = rayOffset / layerCount;

    float2 currentUV = baseUV;
    float2 previousUV = baseUV;
    float currentLayerDepth = 0.0;
    float sampledHeight = heightMap.Sample(linearSampler, currentUV).r;

    [loop]
    for (int i = 0; i < 64; ++i)
    {
        if (i >= (int)layerCount || currentLayerDepth >= sampledHeight)
            break;

        previousUV = currentUV;
        currentUV -= uvStep;
        currentLayerDepth += layerDepth;
        sampledHeight = heightMap.Sample(linearSampler, currentUV).r;
    }

    float previousDepth = currentLayerDepth - layerDepth;
    float afterSurface = sampledHeight - currentLayerDepth;
    float beforeHeight = heightMap.Sample(linearSampler, previousUV).r;
    float beforeSurface = beforeHeight - previousDepth;
    float weight = saturate(afterSurface / (afterSurface - beforeSurface));

    return lerp(currentUV, previousUV, weight);
}

3. 보정된 UV로 모든 표면 텍스처를 샘플링한다

POM 결과를 알베도에만 적용하면 노멀과 러프니스가 원래 위치에 남아 표면이 분리돼 보인다. 같은 pomUV를 사용해 해당 벽 머티리얼의 모든 상세 텍스처를 샘플링한다.

float2 pomUV = ParallaxOcclusionUV(
    input.uv,
    normalize(input.viewDirTS),
    _HeightScale,
    _MinLayers,
    _MaxLayers);

float4 baseColor = baseColorMap.Sample(linearSampler, pomUV);
float3 normalTS = UnpackNormal(normalMap.Sample(linearSampler, pomUV));
float roughness = roughnessMap.Sample(linearSampler, pomUV).r;

UV가 0~1 범위를 벗어난 픽셀은 검은 테두리나 반복 경계 오류를 만들 수 있다. 타일링 벽 재질은 wrap 샘플러를 쓸 수 있지만 개별 벽 데칼이나 아틀라스는 범위 밖 픽셀을 clip 처리하거나 일반 노멀맵 경로로 되돌리는 편이 안전하다.

if (any(pomUV < 0.002) || any(pomUV > 0.998))
{
    clip(-1);
}

정면과 경사 시야에서 일반 노멀맵과 POM 벽돌 줄눈의 시차 차이를 비교하는 이미지

POM 품질과 성능은 어떻게 조절해야 할까?

레이어 수는 항상 최대로 둘 필요가 없다. 정면에서는 UV 이동량이 작아 적은 샘플로 충분하고 표면을 비스듬히 볼수록 더 많은 단계가 필요하다. 위 예제의 lerp(maxLayers, minLayers, abs(viewDirTS.z))가 이 적응형 샘플링을 수행한다.

실무에서는 다음 순서로 튜닝한다.

  1. heightScale을 먼저 조절한다. 벽돌 줄눈이 과장되기 시작하는 지점보다 조금 낮게 설정한다. 실제 깊이를 재현하기보다 텍스처 타일 크기와 카메라 거리에 맞춘 시각적 깊이가 중요하다.
  2. 정면과 약 45도 시야에서 minLayers=8, maxLayers=24~32로 시작해 확인한다. 계단 현상이나 교차점 흔들림이 남을 때만 maxLayers를 올린다.
  3. 화면 공간 크기로 적용 범위를 제한한다. 멀리 있는 벽, 그림자 맵 패스, 모바일 품질 단계에서는 노멀맵 또는 단순 Parallax Mapping으로 폴백한다.
상황권장 설정
실내 근접 벽돌·석재heightScale 0.02~0.05, 8~32 레이어
넓은 외벽 또는 중거리heightScale 0.01~0.03, 8~24 레이어
모바일·VR의 엄격한 예산단순 Parallax 또는 노멀맵 폴백
실루엣이 중요한 파손 벽테셀레이션·지오메트리·메시 디스플레이스먼트 검토

벽면 POM에서 자주 생기는 아티팩트는 왜 발생하는가?

경사 시야에서 텍스처가 늘어나는 문제

viewDirTS.z가 0에 가까워지면 UV 오프셋이 매우 커진다. 분모를 max(viewDirTS.z, 0.001)로 보호하고 매우 얕은 각도에서는 POM을 완화하거나 비활성화한다. 카메라가 벽을 거의 평행하게 보는 구간은 원래도 메시 실루엣 한계가 드러나는 구간이다.

벽돌 줄눈이 떠 보이거나 반대로 들어가는 문제

대부분 높이맵 방향 또는 탄젠트 공간 뷰 벡터 방향이 뒤집힌 경우다. 먼저 높이맵의 흰색과 검은색이 각각 상단·바닥 중 무엇을 뜻하는지 확인한다. 그다음 currentUV -= uvStep의 부호를 바꾸어 비교하고 엔진의 탄젠트 공간 Z 축 정의도 점검한다.

타일 경계가 끊어지는 문제

POM은 UV를 이동시키므로 인접 타일의 높이맵 가장자리가 연속적이지 않으면 경계가 더욱 눈에 띈다. 벽돌 텍스처는 좌우·상하 가장자리의 높이가 자연스럽게 이어지는 타일러블 높이맵으로 제작한다. 서로 다른 머티리얼이 만나는 모서리는 POM보다 메시 분할이나 트림 시트로 해결하는 편이 낫다.

자주 묻는 질문 (FAQ)

POM은 노멀맵을 대체하는가?

아니다. POM은 주로 샘플링 UV를 보정하고 노멀맵은 미세한 광원 반응을 제공한다. 일반적으로 같은 보정 UV로 노멀맵을 다시 샘플링해 함께 사용한다.

POM이 실제 실루엣도 바꾸는가?

바꾸지 않는다. POM은 폴리곤 내부의 깊이 착시만 만든다. 벽 가장자리, 큰 파손부, 카메라에 매우 가까운 돌출부는 실제 지오메트리가 필요하다.

POM에 자체 그림자를 추가해야 하는가?

필수는 아니다. 라이트 방향으로 높이맵을 한 번 더 행진시키는 self-shadowing은 홈의 대비를 높이지만 텍스처 샘플 비용이 커진다. 먼저 엔진의 일반 그림자와 AO로 확인한 뒤 근접 시점의 핵심 머티리얼에만 적용한다.

정리

벽면 POM의 핵심은 높이맵에서 교차점을 찾은 pomUV를 모든 표면 텍스처에 일관되게 적용하는 것이다. 높이 스케일을 절제하고 시야각 기반 레이어 수와 거리 폴백을 조합하면 폴리곤 수를 크게 늘리지 않고도 벽돌과 석재의 깊은 줄눈을 효과적으로 표현할 수 있다.

#Parallax Occlusion Mapping#POM 셰이더#높이맵#벽면 머티리얼#HLSL#실시간 렌더링

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs