2026.03.24삽질 일지
Claude Code게임개발BabylonJSZ-fighting3D렌더링9-sliceKayKit삽질기

Z-Fighting 지옥에서 9-Slice 발견까지 — 7가지 시도가 전부 실패한 이유

시작은 단순했다

3D 전술 게임 myDGC를 개발하고 있습니다. KayKit Forest Nature Pack 에셋을 사용해서 언덕 지형을 구현했는데, 어느 날 언덕 위 타일이 반짝거리기 시작했습니다.

처음엔 경로 표시 기능을 넣다가 발견했습니다. 이동 경로가 지형에 묻혀서 안 보이는 문제를 고치고 있었는데, 경로 문제를 해결하니 지형 자체가 깜빡이더군요.

이건 경로 문제가 아니라 지형 자체의 Z-fighting입니다. 베이스 블록의 윗면과 탑 피스 모델이 같은 Y에 겹쳐서 생기는 현상입니다.

Z-fighting. 3D 그래픽스를 다뤄본 사람이라면 이름만 들어도 한숨이 나오는 그 문제입니다. 두 폴리곤이 거의 같은 깊이에 있으면 GPU가 프레임마다 어느 쪽을 앞에 그릴지 결정을 번복하면서 화면이 반짝이는 현상이죠.

Z-fighting 현상이 발생하는 3D 지형

시도 1: Y 오프셋 — 물리적으로 띄우자

가장 직관적인 접근입니다. 탑 피스를 살짝 위로 올리면 되지 않을까?

typescript
// 탑 피스 Y를 미세하게 올림
const topPieceY = topY + 0.02;

결과: 실패. 0.02로는 부족합니다. 0.05로 올려봤고, 0.1로도 올려봤습니다. Z-fighting은 줄어들지만 이번엔 엠보싱 현상 — 탑 피스와 cliff 사이에 단차가 눈에 보입니다.

두 문제가 서로 반대입니다:

  • 오프셋 크면 → 엠보싱(단차 보임)
  • 오프셋 작으면 → Z-fighting(반짝임)

딜레마에 빠졌습니다. 올리면 단차, 안 올리면 반짝임. 중간값? 그런 건 없었습니다.

시도 2: renderingGroupId로 렌더링 순서 분리

Babylon.js의 렌더링 그룹 기능을 사용해봤습니다. 베이스 블록은 그룹 0, 탑/cliff는 그룹 1로 분리하고 깊이 버퍼를 클리어하는 방식입니다.

typescript
// 렌더링 그룹 분리 + 깊이 버퍼 클리어
scene.setRenderingAutoClearDepthStencil(1, true, true, false);
// 탑/cliff 메시를 그룹 1로
mesh.renderingGroupId = 1;

결과: 인스턴스 메시에는 renderingGroupId를 설정할 수 없습니다. Babylon.js 콘솔에 에러가 쏟아졌습니다.

원인 명확합니다. Babylon.js에서 인스턴스 메시에는 renderingGroupId를 설정할 수 없습니다. 인스턴스는 원본 메시의 렌더링 그룹을 따릅니다.

KayKit 에셋은 성능을 위해 인스턴싱으로 렌더링하고 있었거든요.

시도 3: 원본 소스 메시에 renderingGroupId 설정

인스턴스가 안 되면 원본에 설정하면 되잖아? AssetContainer에서 원본 메시를 찾아서 설정했습니다.

typescript
// 원본 소스 메시에 renderingGroupId 설정
container.meshes.forEach(mesh => {
  if (mesh.name.includes('Hill_Top_') || mesh.name.includes('Hill_Cliff_')) {
    mesh.renderingGroupId = 1;
  }
});

결과: Z-fighting은 사라졌지만, 이번엔 베이스 블록 안쪽이 비쳐 보이는 현상이 생겼습니다. 깊이 클리어가 베이스의 깊이 정보를 지워서, 탑이 베이스 뒤에 있는 부분도 그려버린 겁니다.

탑/cliff가 렌더링 그룹 1에서 깊이 클리어 후 그려지면서 베이스 블록 안쪽이 비쳐 보이는 현상이 생겼네요.

한 문제를 고치면 다른 문제가 터집니다. 뫼비우스의 띠에 갇힌 기분이었습니다.

시도 4: 반투명(Alpha) 재질로 변경

경로 표시 문제를 풀 때 시도했던 방법인데, 지형에도 적용해봤습니다.

결과: 반투명 메시는 Babylon.js에서 별도의 알파 렌더 큐로 처리됩니다. 깊이 관련 설정과의 상호작용이 복잡해져서 오히려 Z-fighting이 악화됐습니다.

시도 5: depthFunction = ALWAYS

깊이 테스트를 항상 통과시키면 어떨까?

typescript
material.depthFunction = Constants.ALWAYS;

결과: 프레임마다 앞뒤가 바뀌면서 더 심하게 반짝입니다. 깊이 테스트를 무시하니 오히려 Z-fighting이 극대화된 겁니다. 이건 뭔소리야 같은데 ㅋㅋㅋ

시도 6: 불투명 + emissive 재질

반투명이 문제라면 아예 불투명으로 가자.

typescript
// 반투명 완전 제거, emissive만 사용
material.emissiveColor = new Color3(0.2, 0.6, 1.0);
material.diffuseColor = Color3.Black();
material.specularColor = Color3.Black();
material.alpha = 1.0;

결과: 인접 타일의 절벽(cliff) 메시가 위에서 아래로 내려오는 구조라, Y 오프셋만으로는 경로가 절벽 안에 들어갑니다. 그리고 지형 Z-fighting은 여전합니다.

시도 7: zOffset — GPU 폴리곤 오프셋

마지막으로 Babylon.js 머티리얼의 zOffset 속성을 시도했습니다. GPU 레벨에서 폴리곤의 깊이값을 오프셋하는 표준적인 Z-fighting 해결법입니다.

typescript
// 베이스 블록 소스 메시에 zOffset 적용
baseMesh.material.zOffset = -2;

이 방법은... 부분적으로 동작했습니다. 하지만 카메라 각도에 따라 여전히 깜빡이는 구간이 있었습니다.

게임 개발 중 디버깅하는 모습

진짜 원인: 9-Slice를 모르고 있었다

7가지 이상의 렌더링 트릭을 시도한 끝에, 에셋 원본을 다시 들여다봤습니다. KayKit Forest Nature Pack의 모듈러 터레인 구조를 제대로 분석한 거죠.

그리고 발견했습니다. 9-slice 방식.

KayKit의 언덕 에셋은 UI의 9-slice(나인 슬라이스)처럼 설계되어 있었습니다. 코너, 엣지, 센터 피스가 별도로 존재하고, 이들을 조합해서 다양한 크기의 언덕을 만드는 구조입니다. 각 피스는 서로 겹치지 않도록 정확한 위치에 배치되어야 합니다.

그런데 저는 이 구조를 무시하고 베이스 블록 위에 탑 피스를 단순 반복 배치하고 있었습니다. 같은 위치에 베이스의 윗면과 탑 피스의 아랫면이 정확히 겹치니, Z-fighting은 구조적으로 피할 수 없는 문제였던 겁니다.

렌더링 트릭으로 아무리 해봐야 근본 원인인 잘못된 에셋 배치를 고치지 않으면 해결이 안 됩니다. 올바른 9-slice 배치 방식으로 전환하니, Y 오프셋도 renderingGroupId도 zOffset도 필요 없이 Z-fighting이 깨끗하게 사라졌습니다.

교훈

1. 렌더링 트릭보다 에셋 이해가 먼저

7가지 렌더링 해결법을 시도하느라 반나절을 쓴 뒤에야 에셋 구조를 분석했습니다. 처음부터 KayKit 에셋의 설계 의도를 파악했다면 30분이면 끝났을 문제입니다.

2. Z-fighting은 대부분 구조 문제

Z-fighting이 발생하면 렌더링 설정을 만지기 전에 먼저 물어봐야 합니다: "왜 두 폴리곤이 같은 위치에 있지?" 대부분은 메시 배치가 잘못된 것이고, 렌더링 트릭은 미봉책입니다.

3. 한 문제를 풀면 다른 문제가 터지면 방향이 틀렸다

오프셋 올리면 엠보싱, 깊이 클리어하면 투시, 반투명 쓰면 렌더 큐 충돌... 해결책이 새 문제를 만들어낸다면, 근본 원인을 잘못 짚고 있다는 신호입니다.

4. Claude도 삽질한다 (같이)

죄송합니다. 처음부터 깊이 버퍼 문제를 제대로 파악했어야 했는데, 단순히 Y 오프셋만 올리는 미봉책을 시도했네요.

AI 어시스턴트와 함께 개발해도 삽질은 합니다. 다만 삽질의 속도가 빨라질 뿐이죠. 중요한 건 삽질 루프를 빨리 인식하고 방향을 전환하는 것입니다. 7번째 시도에서 "잠깐, 이 에셋이 원래 어떻게 쓰이는 거지?"라고 물은 게 전환점이었습니다.