three.js 12분 읽기

3D 엔진을 갈아끼우고 나서야 UV를 공부했다

검은 메시, 흰 메시, 사라지는 텍스처… 두더지잡기처럼 끝없이 튀어나오던 버그의 진짜 정체는 "개념 구멍"이었습니다.

검은 메시, 흰 메시, 사라지는 텍스처… 두더지잡기처럼 끝없이 튀어나오던 버그. 그 진짜 정체는 버그가 많은 게 아니라 개념 구멍이었습니다.


🕳️ 두더지를 잡으면 옆에서 또 튀어나왔다

한동안 3D 뷰어와 export 기능의 렌더링 엔진을 갈아끼우는 일을 했어요. 오래된 네이티브 엔진(VTK 계열)으로 돌아가던 부분을 웹 표준인 three.js로 옮기는 작업이었습니다. “기존이랑 똑같이 보이게 만들면 되는” 일처럼 보여서, 저는 기존 구현을 정답지로 놓고 결과 화면이 비슷해 보일 때까지 값을 맞춰나갔습니다.

그러고 나서 버그가 쏟아졌어요. 어떤 스캔의 일부 영역이 검게 보이고, 그걸 고치면 다른 파일은 화면 전체가 검게 나오고, 썸네일에 잔상이 끼고, export하면 텍스처가 통째로 사라졌습니다. 하나를 고치면 옆에서 또 튀어나왔어요. 분명 닫았던 버그가 다른 수정 때문에 다시 열리기도 했습니다.

증상이 다 비슷했어요. “검다”, “색이 이상하다”, “텍스처가 안 보인다”. 그래서 매번 같은 식으로 때려 맞췄습니다 — 검게 나오는 케이스에서만 안 검게 되도록 값을 조정하는, 전형적인 브루트포스. 근본 원인을 모르니 한 군데를 누르면 다른 데가 부풀었어요. 어느 순간 깨달았습니다. 이건 버그가 많은 게 아니라 전체 그림 없이 결과만 맞추고 있어서 생기는 일이라는 걸요. 그래서 멈추고 기본부터 공부했습니다. 이 글은 그 정리입니다.

🎨 메시에 색을 입히는 두 가지 방법

모든 혼란의 출발점은 이 둘을 구분 못 한 것이었어요.

vertex color와 UV+텍스처 두 방식 비교

  • Vertex color: 메시의 각 정점이 자기 색(RGB)을 직접 들고 있습니다. 삼각형 내부는 꼭짓점 색을 보간해 채웁니다. 파일 하나만 불러오면 색까지 보여요.
  • UV + 텍스처 이미지: 메시엔 색이 없습니다. 사진을 따로 두고, 각 정점이 “나는 이 사진의 여기”라고 가리키는 좌표만 듭니다. 이 좌표가 UV예요.

코드엔 그냥 “색을 입힌다”는 하나의 처리만 있었고, 들어온 파일이 둘 중 어느 방식인지 제대로 분기하지 않고 있었습니다.

⚫ UV, 그리고 검정의 정체

UV는 2D 텍스처 좌표입니다. 3D 좌표를 X·Y·Z로 쓰니 헷갈리지 않으려고 2D는 U·V를 빌려 쓴 관습이에요. 핵심은 방향인데, 처음에 저는 “사진의 픽셀이 어느 정점에 가는지”로 생각했지만 정확히는 반대였습니다. 각 정점이 사진(0~1 좌표계)의 어느 지점을 가리키는지가 UV예요.

그래서 UV가 없으면 셰이더는 어디서 색을 떠와야 할지 모릅니다. three.js는 UV가 없으면 좌표가 기본값 (0,0) 으로 떨어져요 — 사진의 왼쪽 아래 모서리 한 점. 그런데 우리가 다루는 치과 스캔 텍스처는 여러 조각을 한 장에 모은 아틀라스(atlas)라, 그 모서리는 보통 빈 영역 = 검정입니다. 결과적으로 메시 전체가 그 검은 픽셀로 칠해지는 거죠.

UV가 없으면 (0,0) 모서리의 검은 픽셀만 샘플되어 전체가 검정

“일부 검정”, “전체 검정”으로 헤매던 검정의 정체가 이거였어요. 증상은 다 “검정”이지만 원인은 UV 부재였습니다.

📦 포맷마다 색을 담는 자리가 다르다 (혹은 없다)

우리가 다루는 확장자는 넷이었고, 색/텍스처를 담는 방식이 전부 달랐어요.

포맷vertex colorUV텍스처 이미지 참조
STL담을 자리 자체가 없음
OBJvt짝꿍 .mtlmap_Kd 파일명 (표준)
PLY표준 없음 — 비표준 주석으로 우회
벤더 독자 포맷한 파일에 메시+UV+이미지 전부 내장

가장 먼저 박힌 사실은 이거였어요. STL은 텍스처를 입힐 수 없습니다. UV도 vertex color도 담을 자리가 없어요. 어떤 스캐너가 “STL + JPG”를 따로 주면 둘을 이어 붙일 UV가 어디에도 없습니다 — 우리 버그가 아니라 포맷의 원천적 한계예요. 반면 OBJ는 UV를 vt에 적고 어떤 이미지를 쓸지 .mtl에 표준화돼 있어, 어디서 열어도 텍스처가 보입니다. 실제로 처음부터 멀쩡하던 유일한 케이스였어요.

문제는 PLY였습니다.

🔌 PLY와 “비표준” 텍스처 참조

PLY는 UV도 vertex color도 담을 수 있는데, 어떤 이미지를 입힐지 적는 표준이 없습니다. 그래서 기존 네이티브 엔진은 헤더에 비표준 주석 comment TextureFile 사진.jpg 한 줄을 박는 우회를 썼고, 우리도 그 관습을 그대로 가져왔어요. 여기서 둘이 걸렸습니다.

(1) 외부 뷰어에선 안 보입니다. comment는 비표준 주석이라 표준만 따르는 외부 뷰어는 무시해요. 외부 호환이 필요하면 OBJ를 쓰면 되니 트레이드오프로 받아들였습니다.

(2) 정작 우리 네이티브 엔진으로 다시 불러와도 안 보였어요. 이게 오래 걸렸는데, 원인은 어이없게도 UV 속성 이름이 엔진마다 달랐던 것이었습니다. three.js의 PLY exporter는 UV를 s/t로 쓰고, 네이티브 파서는 texture_u/texture_v로 읽어요. 같은 UV인데 이름이 달라 파서가 “모르는 속성”으로 버린 거죠.

덧붙이면, 모르는 속성을 만나도 파서는 안전하게 건너뛰지 깨지진 않습니다. 한동안 “PLY에 UV를 넣으면 파서가 깨진다”고 잘못 알려져 있었는데, 진짜 문제는 이름 불일치였어요.

결국 export 시 UV 이름을 texture_u/texture_v로 바꿔 써주는 것으로 해결했습니다.

👁️ “뷰어에선 보이는데 export는 왜 다르지?”

여기서 또 한참 헤맸어요. 뷰어에선 텍스처가 멀쩡한데 같은 데이터를 export하면 사라집니다. 같은 three.js인데 왜?

외부 뷰어는 파서, 자체 뷰어는 렌더러 — 데이터 모델이 짝을 지어준다

답은 “뷰어에서의 three.js는 파서가 아니라 렌더러일 뿐” 이었어요. 뷰어는 PLY 파일을 열어 파싱하지 않습니다. 서버가 메시와 텍스처를 애초에 별도 파일로 내려주고, “어느 메시에 어느 이미지”는 파일 내부 주석이 아니라 서버의 데이터 모델이 둘을 짝지어 알려줘요. three.js는 이미 짝지어진 둘을 받아 그리기만 합니다. 즉 뷰어는 포맷의 표준 부재를 데이터 모델로 우회하고 있었던 거죠.

여기서 중요한 교훈을 얻었습니다. “뷰어에서 텍스처가 보인다”가 “export 파일에 텍스처가 보존됐다”를 증명하지 못한다. 뷰어는 짝짓기로 보여주고, export 파일은 혼자 표준을 들고 나가야 합니다. 저는 이 둘을 같은 것으로 착각하고 “뷰어에서 되니 export도 되겠지” 가정하다 시간을 날렸어요.

⚪ Vertex color 정규화 — 흰색의 정체

UV를 정리하고 vertex color로 넘어가니, export하면 메시가 전부 흰색으로 나왔습니다. 처음엔 인과를 거꾸로 짚었어요 — “값이 0~1로 줄어서 흰색인가?” 정반대였습니다.

raw 0~255는 흰색, 채널별 min-max는 색 왜곡, 균일 ÷255만 정답

vertex color는 보통 0~255(uint8)로 저장되는데, three.js의 메시 로더가 이 값을 그대로 부동소수점에 담아 넘깁니다. three.js 색 속성은 01을 기대하니, 255 같은 큰 값을 안 줄이면 전부 1로 잘려(clamp) 흰색이 돼요. 흰색은 “값이 작아서”가 아니라 “0255인데 안 줄여서”였습니다. 올바른 처리는 단순해요 — 모든 채널을 똑같이 255로 나눕니다(균일 ÷255).

뷰어 쪽엔 또 다른 변종이 있었어요. 흰색은 아닌데 기준 엔진보다 색이 탁했습니다. 까보니 채널별 min-max 정규화를 하고 있었어요. R·G·B를 각자의 최댓값으로 나누니 채널마다 나누는 값이 달라 R:G:B 비율이 깨지고 색이 틀어진 겁니다. 예전의 저라면 “보이니까 됐다”고 넘겼을 거고 실제로 한동안 그랬어요. 하지만 min-max는 색을 보이게는 했어도 틀리게 만들었습니다. 개념(uint8은 균일 ÷255)을 알아야 “보이는 것”과 “맞는 것”을 구분할 수 있었어요.

이 min-max의 출처도 의외였습니다. 누가 의도한 게 아니라 다른 화면 코드를 옮기는 큰 작업에 섞여 든 잔재였고, 커밋 메시지에 색 얘긴 없었어요. 원래는 비정상적으로 큰 값이 들어오던 특수 케이스를 막으려던 코드였는데 그 케이스는 이미 해소됐고, min-max만 남아 정상 색까지 왜곡하고 있었습니다. 결국 뷰어와 export가 같은 정규화(균일 ÷255)를 공유하게 한 곳으로 모았어요.

🧩 엔진을 바꿔도 포맷 능력은 따로 온다

마지막 하나. 벤더 독자 포맷(편의상 .dcm이라 부를게요)은 한 파일에 메시+UV+텍스처를 내장하는데, export 시엔 무조건 STL로 변환되도록 돼 있어 텍스처가 날아갔어요. “export가 three.js를 쓰는데, 그럼 three.js로 그 포맷을 그대로 다시 쓰면 안 되나?” 싶었습니다. 파보니 답은 “안 된다” 였고 이유가 흥미로웠어요.

  • three.js엔 그 포맷용 exporter가 아예 없습니다. 기본 제공 export는 OBJ/PLY/STL/glTF 같은 웹 표준뿐이에요. 치과 전용 컨테이너는 three.js가 존재 자체를 모릅니다.
  • 네이티브 엔진은 그 포맷을 읽을 순 있어도 쓸 순 없어요. 파서는 있고 writer가 없습니다.
  • 게다가 서버는 원본을 보관하지도 않습니다. 업로드 시 압축 포맷으로 재인코딩해 저장하니, 되돌릴 원본 바이트가 없어요.

정리하면 그 포맷은 시스템 전체에서 입력 전용(input-only) 입니다. 어떤 엔진으로도 출력으로 만들 수 없으니 export는 표준 포맷으로 떨어질 수밖에 없어요. 배운 것: “어떤 엔진을 쓰느냐”와 “어떤 포맷을 만들 수 있느냐”는 별개의 축이라는 겁니다. 엔진을 바꿨다고 없던 exporter가 생기진 않아요. 그리고 포맷 논쟁은 항상 “지금 디스크에 실제로 뭐가 있나” 에서 출발해야 합니다.

🪞 회고 — “보인다”는 “맞다”가 아니다

돌아보면 문제는 버그 하나하나가 아니라 기본 개념 없이 결과만 맞추려 한 접근이었어요. 기존 구현을 정답지로 놓고 화면을 흉내 내니 개념을 우회한 채 값만 미세 조정하게 됩니다. 그 케이스는 통과하지만 옆 케이스는 메커니즘이 다르니 또 깨져요. vertex color 검정과 UV 부재 검정은 증상이 똑같이 “검정”이어도 원인이 완전히 다른데, 개념을 모르면 같은 버그로 보고 같은 처방을 내립니다 — 그게 두더지잡기의 정체였어요.

개념을 알고 나니 흩어진 버그들이 묶음으로 보였습니다. “일부/전체/재import 검정”은 전부 UV 부재 한 묶음, “흰색 export·탁한 색 뷰어”는 정규화 한 묶음, “텍스처 사라진 export”는 포맷별 참조 방식 한 묶음. 따로 노는 열 개의 버그가 아니라 세 개의 개념 구멍이었어요.

가장 마음에 남는 한 줄. “보인다”는 “맞다”가 아니다. min-max로 정규화한 색은 보였지만 틀렸고, 뷰어에 뜬 텍스처는 export 파일의 보존을 증명하지 못했습니다. 결과가 그럴듯하다고 검증이 끝난 게 아니에요. 그 그럴듯함이 어떤 메커니즘으로 나왔는지를 알아야 진짜 맞는 건지 우연히 맞아 보이는 건지를 구분할 수 있습니다. 엔진은 도구일 뿐이고, 도구를 바꾸기 전에 그 도구가 다루는 데이터가 무엇이고 어떻게 저장되는지를 먼저 알았어야 했어요.