브라우저에서 오픈 월드 만들기, 20부: 평평한 면 위에서 깊이를 흉내내기
글쓴이 Oleg Sidorkin, Cinevva의 CTO이자 공동 창업자
처음 오셨나요? 시리즈 가이드를 보세요. spike가 무엇인지 설명하고 모든 부를 링크해 둡니다.
19부는 평평한 쿼드 하나로 멀리 있는 나무 전체를 흉내냈습니다. 이번 부는 평평한 쿼드 하나로 가까이에서 깊이를 흉내냅니다. 시차 차폐 매핑(parallax occlusion mapping), 추가 정점을 단 하나도 쓰지 않고 자갈길에 5cm 깊이로 들어간 줄눈이 있는 것처럼 보이게 만드는 기법입니다. 목표는 이걸 프로덕션 스택(Three.js r184, WebGPU, TSL)에 안착시켜서, 지형 디테일 머티리얼이 깊이가 중요한 곳에서는 그 깊이 착시를 가져가고 나머지 모든 곳에서는 평평한 텍스처 비용만 내도록 하는 것이었습니다.
깊이를 흉내내는 세 가지 방법, 나란히
이 spike는 평평한 5×5m 평면 세 개를 나란히 놓습니다. 모두 같은 머티리얼 골격을 쓰고, 다른 점은 샘플러에 들어가는 UV뿐입니다. Flat은 텍스처를 그대로 샘플링하는, 기준선이 되는 베이스라인입니다. 단일 샘플 시차는 그 지점의 높이만큼 시선 방향을 따라 UV를 한 번 밀어내는데, 저렴하고 낮은 진폭에서는 괜찮지만 비스듬한 각도에서는 출렁입니다. POM은 접선 공간에서 광선 행진(ray-march)을 합니다. 시선 광선을 따라 한 단계씩 나아가며 광선이 높이장 아래로 내려가는 첫 번째 층을 찾고, 그 교차점을 정교하게 다듬습니다. 모든 테스트 평면이 축에 정렬되어 있어서 접선 공간은 단순하게 유지됩니다. 그래서 시선 방향은 정점마다 필요한 완전한 TBN 행렬 대신 부호 몇 개만 뒤집으면 접선 공간으로 패킹됩니다. 텍스처 세트는 Polyhaven 파일 API에서 실시간으로 가져오는데, 17부의 모델 검색이 쓴 것과 같은 경로입니다.
WebGPU의 두 벽, 그리고 분기 없는 광선 행진
교과서적인 POM 루프는 첫 교차에서 검색을 빠져나옵니다. r184에서는 이게 통하지 않는데, 서로 별개인 두 가지 이유 때문입니다. If(...).and(...)는 오류 없이 컴파일됐지만 루프 본문이 절대 실행되지 않는 WGSL을 만들어내서, 루프 뒤의 정교화가 쓰레기 데이터 위에서 돌았고 평면이 거의 흰색으로 렌더링됐습니다. 그리고 독립 노드로서의 Break()는 r184 빌드에 아예 들어오지 않아서, If가 제대로 동작하더라도 "첫 교차에서 멈춰라"를 표현할 방법이 없었습니다. 둘 다 이 버전 구간에서 TSL 제어 흐름이 If와 Loop 경계를 넘어 과하게 최적화하는, three.js의 알려진 이슈들로 거슬러 올라갑니다.
다시 쓴 버전은 분기가 없습니다. 매 반복마다 무조건 텍스처를 샘플링하는데, 이렇게 하면 WGSL 명세가 원하는 대로 텍스처 접근을 균일한 제어 흐름 안에 유지하게 되고, 그런 다음 float으로 들고 있는 done 플래그를 통해 새 상태를 접어 넣습니다. done이 1로 뒤집히면 반복마다의 mix 호출은 "상태를 그대로 유지"로 퇴화하는데, 이것이 break의 분기 없는 등가물입니다. done 플래그는 0.5 + 0.5 × sign(x + ε)로 구현한 step 헬퍼로 만듭니다. 불리언에서 float으로의 강제 변환이 r18x 계열 전반에서 들쭉날쭉했던 반면 sign()은 어디서나 안전하기 때문입니다. 비용은 프래그먼트가 실제로 어디서 교차하든 모든 프래그먼트가 64회 반복을 전부 돈다는 것이지만, 프래그먼트 규모에서는 이게 맞는 거래입니다. 런타임이 어차피 최대 단계 수로 제한을 걸고, 진짜 GPU도 "진짜" break를 만나면 그것을 넘어 추측 실행을 했을 테니까요. 여기서는 깔끔한 폴백이 중요합니다. 마지막 mix(baseUV, refined, done) 덕분에, 진폭이 0일 때(거리 페이드의 먼 끝) 어떤 프래그먼트도 교차하지 않고 done은 0으로 남으며 POM 머티리얼은 flat과 비트 단위로 동일해집니다. 이것이 바로 거리-LOD 트릭의 핵심입니다. 효과가 어차피 서브픽셀인 곳에서는 비용을 평평한 비용으로 무너뜨리는 것이죠.
버그는 수학의 실패가 아니라 규율의 실패였다
분기 없는 버전은 돌긴 했지만 왜곡되어 보였습니다. 중간 진폭에서는 줄무늬 같은 수평 아티팩트가, 낮은 진폭에서는 "미묘하게 틀렸지만 또렷하지는 않은" 결과가 나왔습니다. 해결책은 한 줄짜리 프롬프트에서 나왔습니다. 정본 참조를 가서 읽어라. 이걸 떠올리게 한 LlamAcademy 튜토리얼은 그저 Unity ShaderGraph 노드 하나라서, 진짜 구현은 Unity의 PerPixelDisplacement.hlsl에 들어 있습니다. 한 줄 한 줄 읽으면서, 제가 모르고 들여놓은 세 가지 의미 차이가 드러났습니다. 광선 높이 베이스라인의 하나 차이 오류(Unity는 루프 전에 초기 전진을 한 번 해서, 제 기준 틀이 한 단계 통째로 위상이 어긋나 있었고 그래서 약 절반의 경우 교차점이 잘못된 층에 떨어졌습니다), 정교화 단계가 의존하는 최대 오프셋의 부호 관례, 그리고 제 정교화 수학을 더 힘들게 만들고 부호를 엉키게 한 누적 오프셋 대 누적 UV의 장부 기록 선택이었습니다.
근본 원인은 어느 한 가지 실수가 아니라 두 참조를 섞은 것이었습니다. 저는 LearnOpenGL의 POM 튜토리얼을 길잡이로 삼았는데, 거기는 비슷하지만 다른 부호 관례와 다른 정교화 공식을 씁니다. 결국 수학의 3분의 2는 한 출처와 맞고 3분의 1은 다른 출처와 맞는 잡종 상태에 빠졌습니다. 다시 쓴 버전은 Unity의 HLSL을 거의 한 글자 한 글자 TSL로 옮긴 것으로, 같은 변수 이름, 같은 초기 전진, 같은 정교화에 분기 없는 done 플래그를 위에 얹었습니다. 새겨둘 만한 교훈입니다. 다른 스택에서 검증된 셰이더를 이식할 때는, 먼저 같은 이름으로 한 줄 한 줄 옮긴 다음에 로컬 스타일에 맞게 리팩터링하세요. 이식 중간에 두 번째 참조에 대고 다시 유도하지 마세요.
거짓말을 할 수 없는 기준 평면
나란히 놓은 비교에는 가장 뻔한 것이 빠져 있었습니다. 실제 지오메트리 평면 말입니다. 그게 없으면 "POM이 꽤 괜찮아 보인다"는 반증할 수 없습니다. 꽤 괜찮다는 건 무엇과 비교해서요? 그래서 spike는 네 번째 평면을 추가했습니다. 같은 높이 맵을 실제 정점 위치로 밀어낸 것입니다. WebGPU에는 하드웨어 테셀레이션이 없어서(그냥 명세에 들어 있지 않고, Metal 호환성 때문에 잘려나갔습니다), 그 대체물은 정점 단계에서 정점 변위를 적용한 촘촘하게 분할된 평면(256×256 세그먼트, 131,072개 삼각형)입니다. 같은 진폭 uniform이 POM과 지오메트리 평면을 둘 다 구동하므로, 둘은 함께 페이드되고 비교는 모든 거리에서 같은 것끼리의 비교로 유지됩니다.
화면에 정답이 떠 있게 되자 정성적인 주장들이 측정 가능해졌습니다. 아래를 내려다보는 16° 궤도에서 POM과 테셀레이션된 평면은 내부 음영에서 일치합니다. 비스듬한 각도에서는 둘이 갈라져야 할 바로 그 지점에서 갈라집니다. POM은 지오메트리의 완벽하게 곧은 직사각형 가장자리에 클램프되는 반면, 진짜 메시는 빛을 받는 실제 봉우리와 골짜기들로 이루어진 울퉁불퉁한 지평선 윤곽을 보여줍니다. 그래서 POM의 가장자리 "출렁임"은 이제 텍스처나 조명의 아티팩트가 아니라 알고리즘에 본질적으로 내재한 것임이 증명됩니다. 두 가지 비용 형태도 분명해집니다. POM은 프래그먼트에 묶여 있고(비용이 덮인 픽셀에 비례), 테셀레이션된 평면은 정점에 묶여 있습니다(비용이 커버리지와 무관하게 메시 밀도에 비례). 이미 높이 맵으로 구동되는 평면의 정점 비용을 내고 있는 지형 청크에게는, 메시보다 작은 디테일에 대해서는 POM이 정답입니다.
기준 평면은 미묘한 UX 버그도 잡아냈습니다. 사용자는 진폭이 커질수록 표면이 가라앉는 것처럼 보인다는 걸 알아챘습니다. 이는 기하 평면을 높이장의 꼭대기로 취급하는 Unity의 관례로 거슬러 올라갑니다. 봉우리들이 딱 붙어 고정되고 나머지 모든 것이 아래로 시차 처리되면서, 평균 표면을 평평한 베이스라인 아래로 (1 − mean_h) × amplitude만큼 끌어내린 것이죠. 해결책은 관례를 다시 중앙에 맞춰 h = 0.5가 평면이 되고, 봉우리는 카메라 쪽으로 솟고 골짜기는 안으로 들어가게 한 것입니다. 알고리즘은 Unity가 규정한 그대로 돌아갑니다. spike는 그저 슬라이더를 끄는 사람에게 "진폭"이 의미해야 할 바에 맞추기 위해 출력을 절반 오프셋만큼 후처리할 뿐입니다.
기준 평면이 정리해 준 게 하나 더 있습니다. "Steps" 슬라이더가 아무것도 안 하는 것처럼 보였는데, 배관 버그처럼 읽혔지만 아니었습니다. 선형 검색 뒤의 세 번 할선법(secant) 정교화가 너무 좋아서(Tatarchuk의 2006년 POM 논문은 4단계 검색에 3단계 할선법을 더하면 64단계 검색과 시각적으로 구별할 수 없다고 적습니다), 매끄러운 높이 맵에서는 4부터 64까지 모든 단계 수가 같은 서브텍셀 UV로 수렴합니다. 해결책은 다시 배관하는 게 아니라 토글이었습니다. 할선법을 끄면 단계 슬라이더가 교차 정밀도를 다루는 유일한 컨트롤이 되어서, 4로 떨어뜨리면 자갈이 눈에 띄게 계단처럼 끊기고 64로 끌어올리면 다시 매끈해집니다. 이 토글은 0/1 uniform으로, 꺼져 있을 때 매 할선법 상태 갱신을 no-op으로 mix하므로, 토글해도 머티리얼을 절대 다시 만들지 않고 절대 끊기지 않습니다.
이 장에서 다룬 기술
TSL 안의 시차 차폐 매핑. POM은 접선 공간에서 높이장을 따라 시선 방향으로 광선 행진을 하고, 광선이 표면 아래로 떨어지는 첫 번째 층을 찾아 교차점을 정교화하여, 추가 지오메트리 없이 평평한 쿼드 위에 들어간 줄눈의 깊이를 만들어냅니다. 마지막 mix(baseUV, refined, done)은 어떤 프래그먼트도 교차하지 않을 때 머티리얼을 flat과 비트 단위로 동일하게 만들고, 이것이 거리-LOD 진폭 감쇠가 멀리서 비용을 평평한 텍스처 비용으로 무너뜨리게 해주는 핵심입니다. 지형 머티리얼을 보세요.
WebGPU 제어 흐름을 위한 분기 없는 루프. Three.js r184에서 TSL If(...).and(...)는 루프 본문이 절대 돌지 않는 WGSL로 컴파일될 수 있고, 독립 Break()는 쓸 수 없습니다. 이식 가능한 패턴은 반복마다 무조건 텍스처를 한 번 샘플링하고(WGSL 명세에 따라 텍스처 접근을 균일한 제어 흐름에 유지), float으로 들고 있는 done 플래그를 더해 한 번 설정되면 매 상태 갱신을 no-op으로 mix하는 것입니다. sign(x + ε)로 만든 step 헬퍼는 신뢰할 수 없는 불리언-float 강제 변환을 피합니다. 비용은 조기 종료 지점이 어디든 최대 반복 수를 도는 것이고, 이는 프래그먼트 규모에서 올바른 거래입니다.
한 글자 그대로 셰이더 이식. 다른 엔진에서 검증된 셰이더를 이식할 때는, 먼저 원본의 변수 이름으로 한 줄 한 줄 옮기고 그다음에 로컬 스타일에 맞게 리팩터링해야 합니다. 두 참조(Unity의 PerPixelDisplacement.hlsl과 LearnOpenGL 튜토리얼)를 섞으면 잡종이 나왔습니다. 광선 베이스라인의 하나 차이, 뒤집힌 오프셋 부호, 그리고 클램프가 범위 밖 가중치를 공간적 불연속으로 가려버린 정교화 공식이 나온 것이죠. 다시 유도하는 게 아니라 하나의 정본 정답이 답입니다.
정점 변위로 만든 정답 기준. WebGPU에 하드웨어 테셀레이션이 없으니, 정점 단계에서 변위시킨 촘촘하게 분할된 평면(256² 세그먼트)이 진짜 지오메트리를 대신해 프래그먼트 단계의 흉내를 검증합니다. 같은 진폭 uniform으로 둘 다 구동하면 비교가 거리 전반에서 정직하게 유지됩니다. POM은 프래그먼트에 묶여 있고(덮인 픽셀에 비례), 지오메트리 평면은 정점에 묶여 있어서(메시 밀도에 비례), 둘은 정확히 실루엣 가장자리에서 갈라지며, POM의 가장자리 출렁임이 아티팩트가 아니라 본질적인 것임을 증명합니다.
29부 중 20부. 이전: 19부 - 숲에서 살아남아야 하는 임포스터 다음: 21부 - 더 빠르지 않았던 더 빠른 렌더러 시리즈 가이드: /ko/blog/2026-02-25-open-world-browser-series-guide