TL;DR
- 귤과 레몬 왁뿌볼에서 크랙이 구멍처럼 보이는 문제가 있었다.
- 알파, 밝기, 변위 진폭, normal map 통계만으로는 원인이 나오지 않았다.
- 처음 만든 seam 지표는 같은 위치의 중복 정점만 봤고, 인접 삼각형 변형을 놓쳤다.
- 화면 증상과 가장 같이 움직인 값은 삼각형 뒤집힘 수가 아니라 면적 팽창률이었다.
왁뿌볼 뿌수기에는 여러 종류의 볼이 있다. 청사과, 수박, 망고스틴, 딸기, 귤, 레몬, 도넛이 같은 렌더링 구조 위에서 동작한다. 볼을 누르면 겉면의 왁스층이 갈라지고, 깨진 부분에서는 안쪽의 밝은 속살이 보여야 한다.
문제는 귤과 레몬에서만 나왔다. 깨진 면이 밝게 열리는 대신 검은 구멍처럼 보였다. 코드는 같고 에셋만 다르다면 먼저 에셋을 봐야 한다. 다만 이 버그는 헤드리스 렌더에서는 약하게만 보였고, 실제 기기의 토스 앱 안에서 더 뚜렷했다. 그래서 육안 판정 대신 에셋과 지오메트리 지표를 만들어 문제를 좁혔다.
텍스처 통계
먼저 구멍처럼 보일 수 있는 텍스처 조건을 확인했다.
| 가설 | 확인값 | 판정 |
|---|---|---|
| cracked basecolor에 알파가 있다 | *_cracked_basecolor.webp가 RGB, 알파 없음 |
제외 |
| 크랙 영역이 어둡다 | 귤·레몬의 크랙 영역 평균 명도가 더 높음 | 제외 |
| 변위 진폭이 과하다 | 귤·레몬의 displacement 편차가 더 작음 | 제외 |
| normal map이 깨졌다 | roughness와 normal 범위가 정상 | 제외 |
이 단계에서 중요한 값 하나가 남았다. 정상 볼의 텍스처는 2048²였고, 귤과 레몬은 1024²였다. 이 차이만으로 원인을 확정할 수는 없지만, 이후 지표가 계속 같은 방향을 가리켰다.
검은 틈의 의미
완파 상태를 강제로 만들고 확대해 보면, 문제 지점은 배경색이 아니라 검정에 가까웠다. 볼이 완전히 뚫려서 뒤의 배경이 보이는 상태라면 배경색이 나와야 한다. 검정에 가까운 틈은 셸의 내부면 또는 그려지지 않은 면이 드러나는 증상에 가깝다. 그래서 원인 후보를 텍스처 색에서 지오메트리 변형으로 옮겼다. GLB를 파싱해 크기와 정점 수를 비교했다.
| 볼 | 크기 | 정점 |
|---|---|---|
| 청사과 | 2.000 × 2.010 × 2.000 | 50,147 |
| 딸기 | 1.775 × 1.888 × 1.775 | 50,062 |
| 귤 | 2.000 × 1.686 × 2.000 | 51,679 |
| 레몬 | 1.583 × 1.812 × 1.560 | 51,679 |
셰이더의 변위 상수는 DISPLACE_AMP = 0.12였다. 레몬처럼 반지름이 작은 모델에서는 같은 절대 변위가 더 큰 상대 변위가 된다. 하지만 딸기도 작은 편인데 정상적으로 보였기 때문에 크기만으로는 설명이 부족했다.
seam 지표
다음으로 UV seam을 확인했다. 같은 3D 위치를 공유하지만 UV가 다른 정점들이 있으면, displacement texture를 서로 다른 위치에서 샘플링한다. 같은 점에 붙어 있어야 할 정점들이 다른 방향으로 밀리면 seam이 벌어진다. 정점 위치를 양자화해 같은 위치의 정점을 묶고 UV 차이를 계산했다.
| 볼 | UV가 갈라진 위치 | UV 간격 중앙값 |
|---|---|---|
| 청사과 | 1,233 (2.52%) | 0.5162 |
| 딸기 | 1,156 (2.36%) | 0.6028 |
| 귤 | 161 (0.32%) | 1 |
| 레몬 | 161 (0.32%) | 1 |
귤과 레몬의 UV 간격 중앙값은 정확히 1.0이었다. equirect UV에서 좌우 끝이 만나는 seam의 전형적인 모양이다. 셰이더의 정점 변형을 JS로 재구현해 같은 위치의 정점들이 완파 상태에서 얼마나 벌어지는지도 계산했다.
| 볼 | 국소 반지름 대비 벌어짐 |
|---|---|
| 청사과 | 1.08% |
| 딸기 | 0.78% |
| 귤 | 5.86% |
| 레몬 | 4.66% |
이 지표만 보면 귤과 레몬이 문제처럼 보였다. 그래서 displacement texture의 seam 주변을 정리했다. 좌우 끝 열을 맞추고, 극점 행을 평균화했다. 지표는 정상권으로 내려왔다. 하지만 실제 화면 증상은 충분히 사라지지 않았다.
여기서 seam 지표의 한계가 드러났다. 이 지표는 “같은 위치인데 UV만 다른 정점”만 센다. 서로 다른 위치의 이웃 정점이 반대 방향으로 밀리며 삼각형 자체가 늘어나거나 뒤집히는 경우는 세지 않는다.
삼각형 지표
렌더링에서 구멍처럼 보이는 직접 경로는 삼각형 단위에 있다.
displacement texture의 인접 픽셀 차이
-> 이웃 정점이 서로 다른 방향으로 이동
-> 삼각형 면적이 급격히 증가하거나 법선이 뒤집힘
-> backface culling 또는 어두운 내부면 노출
-> 화면에서 검은 틈처럼 보임
그래서 변위 전후의 삼각형을 비교했다.
const n0 = cross(e1, e2);
const n1 = cross(f1, f2);
const flipped = dot(n0, n1) < 0;
const areaRatio = length(n1) / length(n0);
새 지표는 더 분명했다.
| 볼 | 면적 4배 초과 | 면적 10배 초과 | 정점당 텍셀 |
|---|---|---|---|
| 청사과 | 213 | 27 | 83.6 |
| 딸기 | 128 | 7 | 83.8 |
| 귤 | 1,545 | 224 | 20.3 |
| 레몬 | 1,593 | 115 | 20.3 |
귤과 레몬은 면적이 크게 늘어나는 삼각형이 정상 볼보다 훨씬 많았다. 동시에 정점당 텍셀 수는 4분의 1 수준이었다. 정점 수가 부족한 게 아니라 displacement texture 해상도와 압축 품질이 부족한 쪽이었다.
압축률도 같은 방향이었다. 픽셀당 바이트가 청사과는 0.233인데, 귤은 0.044, 레몬은 0.039였다. 고압축 lossy WebP의 작은 노이즈가 displacement로 확대되고, 낮은 해상도 때문에 이웃 정점 간 변위 차이가 크게 나타나는 구조였다.
움직이는 지표와 고정된 지표
텍스처를 부드럽게 만들기 위해 grad_p99를 만들었다. 인접 텍셀 차이의 p99를 재는 값이다.
| 텍스처 설정 | grad_p99 |
정점당 텍셀 |
|---|---|---|
| 1024² + blur | 0.0160 | 20.3 |
| 2048² + blur | 0.0094 | 81.2 |
2048²로 업스케일하자 grad_p99가 정상 볼 기준에 가까워졌다. 숫자만 보면 개선이었다. 하지만 삼각형 지표는 나빠졌다.
| 텍스처 설정 | 귤 (4배 / 10배) | 레몬 (4배 / 10배) |
|---|---|---|
| 1024² + blur | 725 / 143 | 351 / 1 |
| 2048² + blur | 1,224 / 239 | 1,155 / 40 |
grad_p99는 해상도에 종속된 지표였다. 업스케일하면 인접 텍셀 사이의 물리적 거리가 줄어든다. 같은 패턴이라도 바로 옆 픽셀과의 차이는 작아질 수 있다. 텍스처가 실제로 좋아진 것이 아니라 측정 단위가 바뀐 것이다.
반면 면적 팽창률은 삼각형의 변위 전후 면적 비다. 텍스처 해상도가 바뀌어도 “삼각형이 얼마나 비정상적으로 늘어났는가”라는 의미가 유지된다. 이 버그에서는 grad_p99보다 면적 팽창률이 더 좋은 판정량이었다.
위도 보정 블러와 진폭 조정
문제 텍스처는 equirect 형태였다. 이 방식에서는 극점 근처로 갈수록 같은 u 방향 픽셀이 3D 표면에서 더 짧은 거리를 차지한다. 단순 2D 블러를 균일하게 적용하면 표면 기준으로는 균일한 필터가 아니다. 그래서 행마다 u 방향 blur sigma를 조정했다.
lat = (np.arange(h, dtype=np.float64) + 0.5) / h
cosphi = np.maximum(np.cos((lat - 0.5) * np.pi), cos_floor)
sig_u = np.clip(sigma / cosphi, sigma, w / 4.0)
가로는 wrap, 세로는 reflect로 처리했다. 결과는 삼각형 지표에서 바로 나타났다.
| 텍스처 처리 | 귤 (4배 / 10배) | 레몬 (4배 / 10배) |
|---|---|---|
| 원본 | 1,532 / 219 | 1,661 / 117 |
| 균일 blur sigma 4.0 | 725 / 143 | 348 / 14 |
| 위도 보정 sigma 5.0 | 570 / 100 | 85 / 1 |
레몬은 텍스처 정리만으로 정상권에 가까워졌다. 귤은 여전히 높았다. 귤은 Y축이 짧은 편구에 가까워 같은 displacement가 특정 구간에서 더 크게 먹는 구조였다. 그래서 귤은 텍스처 보정에 더해 displacement 진폭을 낮췄다.
| 귤 진폭 | 면적 4배 초과 | 면적 10배 초과 |
|---|---|---|
| 0.12 | 570 | 100 |
| 0.09 | 357 | 50 |
| 0.08 | 291 | 29 |
최종적으로 귤만 배율을 낮춰 정상 볼의 지표 범위에 맞췄다.
최종 판정
최종 지표는 이렇게 움직였다.
| 볼 | 면적 4배 초과 | 면적 10배 초과 |
|---|---|---|
| 청사과 | 213 | 27 |
| 귤 | 1,545 → 292 | 224 → 29 |
| 레몬 | 1,593 → 85 | 115 → 1 |
삼각형 뒤집힘 수는 최종 판정량으로 쓰지 않았다. 정상인 청사과도 완파 상태에서는 뒤집힌 삼각형이 많았다. 뒤집힘 자체는 메커니즘이고, 화면 증상과 더 가깝게 움직인 값은 크게 늘어난 삼각형의 수였다.
화면 픽셀 기준에서도 같은 방향을 확인했다. 레몬의 어두운 픽셀은 줄었고, 청사과는 같은 지표에서 변화가 없어 회귀가 없었다.
정리
이 버그에서 가장 큰 실수는 “그럴듯한 지표”를 너무 빨리 판정 기준으로 삼은 것이다. seam 지표는 범위가 좁았다. 같은 위치의 중복 정점만 봤고, 이웃 삼각형의 변형을 보지 않았다. grad_p99는 정의가 움직였다. 해상도를 올리면 실제 개선 없이도 숫자가 좋아질 수 있었다.
판정 지표는 두 조건을 만족해야 한다.
- 화면 증상과 같은 방향으로 움직여야 한다.
- 해상도, 샘플링 밀도, 측정 단위 변경만으로 좋아지면 안 된다.
3D 버그는 화면으로만 잡기 어렵다. 하지만 숫자로 내려올 때도 아무 숫자나 믿으면 안 된다. 이 문제에서는 텍스처의 평균값보다, 정점의 seam보다, displacement 이후 삼각형 면적이 더 좋은 판정 기준이었다.