Z-파이팅
고친 사람 github-actions[bot]
Z-파이팅은 거의 같은 깊이에 놓인 두 면이 화면에서 서로 앞에 나서려고 다투는 렌더링 오류입니다. 두 면의 색이 픽셀마다 엇갈려 얼룩무늬가 생깁니다. 카메라가 움직이면 그 무늬가 깜박입니다. 컴퓨터가 두 면 가운데 어느 쪽이 앞인지 가리지 못해서 생깁니다.
쉽고 빠른 이해
Z-파이팅은 두 면이 「누가 앞이냐」를 놓고 픽셀마다 승부가 갈리는 현상입니다. 바닥에 딱 붙여 그린 차선이 줄무늬로 깨져 보이는 것이 흔한 예입니다.
왜 생기나 — 컴퓨터는 픽셀마다 「지금까지 그린 면 가운데 가장 가까운 면까지의 거리」를 적어 둡니다. 그리고 그보다 가까운 면만 덧칠합니다. 이 거리는 칸 수가 정해진 눈금으로만 적힙니다. 두 면의 거리 차이가 눈금 한 칸보다 작으면 같은 칸에 들어가 앞뒤를 못 가립니다.
어떻게 도나:
- 두 면이 화면의 같은 픽셀들을 덮습니다
- 두 면의 거리가 눈금 한 칸 안쪽으로 붙어 있습니다
- 픽셀마다 계산의 반올림 결과가 조금씩 갈려서 어느 픽셀은 이쪽이, 어느 픽셀은 저쪽이 이깁니다
- 카메라가 움직이면 반올림 결과가 갈리는 곳이 다시 바뀌어 무늬가 꿈틀댑니다
무엇이 나빠지나 — 오류 메시지 없이 화면만 지저분해집니다. 멀리 있는 물체일수록 눈금이 성겨서 더 잘 생깁니다.
어떻게 줄이나 — 두 면을 살짝 떼어 놓으면 됩니다. 카메라가 그리는 가장 가까운 경계를 뒤로 미는 방법도 있습니다. 떼어 놓으면 가까이서 틈이 보일 수 있습니다. 경계를 밀면 그보다 가까운 물체가 잘려 나갑니다.
상세
이 절은 깊이 버퍼에서 출발해 깊이가 눈금으로 끊기는 까닭과 그 끊김이 무늬가 되는 조건을 차례로 봅니다.
앞뒤를 가리는 깊이 버퍼
3차원 장면을 그리면 여러 물체가 같은 픽셀을 놓고 겹칩니다. 앞에 있는 물체가 뒤의 물체를 가려야 그림이 맞습니다. 그런데 물체를 그리는 순서는 앞뒤와 상관없이 뒤섞이기 쉽습니다. 그래서 순서에 기대지 않고 앞뒤를 가리는 방법이 필요합니다.
카메라에서 면까지의 거리를 깊이라고 부릅니다. 화면의 가로를 x 축, 세로를 y 축으로 놓으면 깊이는 화면 안쪽으로 들어가는 z 축의 값입니다. 그래서 깊이를 흔히 z 값이라고도 합니다. 표제어의 Z 가 이 축에서 왔습니다.
널리 쓰이는 방법은 깊이 버퍼입니다. 깊이 버퍼는 픽셀마다 깊이 하나를 적어 둡니다. 그 값은 그 픽셀에 지금까지 그린 면 가운데 카메라에 가장 가까운 면의 깊이입니다.
새 면을 칠할 때마다 깊이 테스트가 새 면의 깊이를 그 픽셀에 적힌 값과 견줍니다. 더 가까우면 색을 칠합니다. 깊이도 새 값으로 적습니다. 더 멀면 버립니다. 이렇게 하면 그리는 순서가 어떻든 가장 가까운 면만 남습니다.
깊이는 눈금으로 적힌다
카메라가 그리는 범위에는 앞뒤 끝이 있습니다. 카메라에서 가장 가까운 경계를 근평면, 가장 먼 경계를 원평면이라고 부릅니다. 깊이 버퍼는 흔히 근평면을 0, 원평면을 1 로 놓고 그 사이의 값으로 깊이를 적습니다. 이 값은 거리에 정비례하지 않습니다. 어떻게 이어지는지는 아래 「먼 곳일수록 잘 생기는 까닭」에서 봅니다.
이 값은 무한히 잘게 적히지 않습니다. 정해진 비트 수의 정수 눈금으로 적힙니다. 24비트 깊이 버퍼라면 0 부터 1 까지를 약 1677만 칸으로 나눕니다. 깊이는 그 가운데 가장 가까운 칸으로 반올림됩니다.
초 단위로만 시각을 적는 로그를 떠올리면 됩니다. 같은 초 안에 찍힌 두 줄은 시각만 보고는 어느 쪽이 먼저인지 모릅니다. 깊이 버퍼도 같은 칸에 든 두 면의 앞뒤를 모릅니다.
아래 코드는 0 과 1 사이의 깊이를 24비트 눈금에 적는 계산을 흉내 냅니다. 각 줄이 내는 값을 오른쪽 주석에 적었습니다.
const STEPS = 2 ** 24; // 16777216
const toBuf = d => Math.round(d * STEPS);
toBuf(0.5); // 8388608
toBuf(0.50000002); // 8388608
toBuf(0.5000001); // 8388610
위 코드의 toBuf 세 줄 가운데 앞의 두 줄은 깊이가 0.00000002 다른 두 면입니다. 눈금 한 칸이 약 0.00000006 이라서 둘은 같은 칸에 적힙니다. 깊이 테스트에게 두 면은 같은 거리입니다. 마지막 줄처럼 한 칸보다 더 떨어져야 앞뒤가 갈립니다.
픽셀마다 이기는 면이 갈리는 까닭
두 면이 같은 칸에 들어가면 한쪽이 늘 이길 것 같지만 그렇지 않습니다. 면의 깊이는 픽셀마다 따로 계산되기 때문입니다.
면은 대개 삼각형 여러 개로 쪼개 그립니다. 화면을 그리는 GPU(Graphics Processing Unit, 그래픽 처리 장치)는 삼각형의 꼭짓점 셋에서만 깊이를 계산합니다. 삼각형 안쪽 픽셀의 깊이는 따로 정해지지 않습니다.
그 빈 곳은 꼭짓점 셋의 값을 섞어서 메웁니다. 픽셀이 어느 꼭짓점에 가까운지에 따라 섞는 비율이 달라집니다. 이 계산을 보간이라고 합니다.
겹친 두 면은 꼭짓점이 서로 다릅니다. 그래서 같은 평면 위에 있어도 같은 픽셀의 깊이를 서로 다른 꼭짓점 값에서 섞어 냅니다. 섞는 계산의 중간값도 정해진 자릿수로 끊깁니다. 그래서 참값이 같아도 두 면의 반올림 결과가 조금씩 갈립니다.
어느 픽셀에서는 면 A 가 한 칸 앞에 적힙니다. 바로 옆 픽셀에서는 면 B 가 한 칸 앞에 적힙니다. 결과로 두 면의 색이 픽셀 단위로 엇갈려 줄무늬나 얼룩이 됩니다.
카메라나 물체가 조금만 움직여도 반올림 결과가 갈리는 곳이 바뀝니다. 그래서 무늬가 프레임마다 달라져 깜박이거나 물결치듯 보입니다. 두 면이 앞을 차지하려고 싸우는 듯 보여서 Z-파이팅이라는 이름이 붙었습니다.
먼 곳일수록 잘 생기는 까닭
눈금 한 칸이 거리로 얼마인지는 장면 어디서나 같지 않습니다. 사람 눈처럼 멀수록 작게 그리는 원근 투영은 깊이를 거리 그대로 적지 않습니다. 거리의 역수에 가까운 값으로 바꿔 적습니다.
역수는 가까운 곳에서 크게 변합니다. 먼 곳에서는 거의 변하지 않습니다. 그래서 0 과 1 사이 눈금의 대부분이 근평면 근처 거리에 쓰입니다. 카메라 가까운 곳은 아주 촘촘합니다. 먼 곳은 성깁니다.
그래서 카메라 앞에서 멀쩡하던 두 면이 멀리 가면 같은 칸에 들어갑니다. 거리마다 달라지는 이 촘촘함을 깊이 정밀도라고 부릅니다.
원평면이 충분히 멀면 눈금의 절반이 근평면과 그 두 배 거리 사이에 다 쓰입니다. 근평면이 카메라에 붙을수록 그 구간이 짧아집니다. 그만큼 먼 곳에 남는 칸이 줄어듭니다. 반대로 근평면을 조금만 뒤로 밀어도 먼 곳의 칸이 크게 좁아집니다.
직교 투영은 거리와 상관없이 같은 크기로 그리는 방식입니다. 이 방식에서는 눈금이 거리 전체에 고르게 놓입니다. 그래도 칸의 폭보다 가까이 붙은 두 면은 똑같이 다툽니다.
재현되는 조건
셋이 모두 성립하면 Z-파이팅이 납니다.
- 두 면이 화면에서 같은 픽셀들을 덮습니다
- 그 픽셀들에서 두 면의 깊이 차이가 반올림에 뒤집힐 만큼 작습니다. 대략 눈금 한 칸 안쪽입니다
- 두 면 모두 깊이 테스트를 거쳐 그려집니다
아래 그림은 조건이 하나씩 빠지면 무엇이 대신 일어나는지 보여 줍니다.
flowchart TD
A["두 면이 같은 픽셀을 덮는다"] --> B{"깊이 차이가 눈금 한 칸 안쪽인가"}
B -->|아니다| C["가까운 면이 늘 이긴다"]
B -->|그렇다| D{"두 면 모두 깊이 테스트를 거치나"}
D -->|아니다| E["나중에 그린 면이 덮는다"]
D -->|그렇다| F["픽셀마다 이기는 면이 갈린다"]
깊이 테스트를 끄고 그린 면은 앞뒤를 견주지 않습니다. 이때는 나중에 그린 면이 먼저 그린 면을 덮습니다. 세 조건이 다 성립해야 픽셀마다 승부가 갈립니다.
두 번째 조건은 같은 평면 위에 두 면을 겹쳐 놓으면 늘 성립합니다. 깊이 차이가 0 이기 때문입니다. 바닥 위에 차선이나 얼룩 무늬를 얇은 판으로 덧붙이면 이 조건에 그대로 걸립니다. 큰 면 위에 이렇게 덧붙이는 얇은 판을 데칼이라고 부릅니다.
같은 면을 실수로 두 번 만든 모델에서도 똑같이 납니다. 두 면이 겹치지 않고 조금 떨어져 있어도 카메라에서 멀리 있으면 두 번째 조건이 성립합니다. 눈금이 성긴 먼 산비탈과 그 위에 바짝 붙여 깐 길이 흔한 예입니다.
줄이는 방법
Z-파이팅을 없애려면 위 조건 가운데 하나를 깨면 됩니다. 대개 두 번째 조건을 깹니다. 두 면의 깊이를 떼어 놓거나 눈금을 촘촘하게 만드는 것입니다.
이름이 붙은 방법이 둘 있습니다. 폴리곤 오프셋은 한 면의 깊이를 아주 조금 앞으로 당겨 적게 하는 설정입니다. 깊이 바이어스라고도 부릅니다.
다음 방법을 보려면 수를 담는 형식 하나를 먼저 알아야 합니다. 부동소수점은 소수를 담는 흔한 형식입니다. 이 형식은 0 근처의 값을 촘촘하게 담습니다. 큰 값일수록 성기게 담습니다.
리버스 Z는 근평면에 1, 원평면에 0 을 놓는 배치입니다. 깊이는 정수 눈금 대신 부동소수점으로 담습니다. 원근 투영에서 먼 곳의 깊이는 값의 좁은 구간에 몰려 놓입니다. 리버스 Z 에서는 그 구간이 0 근처입니다. 0 근처는 부동소수점이 촘촘한 구간이라 먼 곳에서 모자라던 칸이 메워집니다.
흔히 쓰는 방법마다 바뀌는 것과 치르는 것을 아래 표에 모았습니다.
| 방법 | 바뀌는 것 | 치르는 것 |
|---|---|---|
| 두 면을 조금 떼어 놓는다 | 깊이 차이가 한 칸보다 커진다 | 가까이서 보면 틈이 보일 수 있다 |
| 근평면을 뒤로 민다 | 먼 곳의 눈금이 좁아진다 | 근평면보다 가까운 물체가 잘린다 |
| 폴리곤 오프셋을 건다 | 덧붙인 면이 늘 한 칸 넘게 앞에 적힌다 | 당기는 양을 장면에 맞춰 골라야 한다 |
| 깊이 비트 수를 늘린다 | 눈금 칸 수가 늘어난다 | 픽셀마다 메모리를 더 쓴다 |
| 리버스 Z 로 담는다 | 먼 곳까지 눈금이 고르게 촘촘해진다 | 깊이를 부동소수점으로 담고 비교 방향도 뒤집어야 한다 |
관련 항목
Z-파이팅이 속하는 상위 분류
그래픽스 · 3차원 그래픽스 · 렌더링 · 실시간 렌더링 · 렌더 파이프라인 · 래스터화
Z-파이팅을 일으키는 깊이 처리 장치
깊이 버퍼 · 깊이 테스트 · 깊이 정밀도 · 보간 · 픽셀 · 프래그먼트 · 프레임버퍼
Z-파이팅의 정도를 좌우하는 카메라 설정값
카메라 · 원근 투영 · 직교 투영 · 투영 행렬 · 원근 나눗셈 · 근평면 · 원평면 · 뷰 절두체
Z-파이팅을 줄이는 수단
폴리곤 오프셋 · 깊이 바이어스 · 리버스 Z · 로그 깊이 버퍼 · 부동소수점 · 스텐실 버퍼 · 데칼
Z-파이팅과 뿌리가 같은 렌더링 오류
깊이 정밀도 · 깊이 비교 오류 · 섀도 애크니 · 피터 패닝 · 에일리어싱
깊이 버퍼와 같은 앞뒤 판정을 맡는 기법
은면 제거 · 화가 알고리즘 · 뒷면 제거 · 레이 트레이싱
다른 이름: Z-fighting · Z fighting · z-fighting · Z 파이팅 · Z파이팅 · 깊이 파이팅 · depth fighting