TECHARTNOMAD | TECHARTFLOWIO.COM

TECH.ART.FLOW.IO

[번역] 클립맵 기반 정적 섀도우맵

jplee 2025. 11. 26. 21:00

 

역자의 말

이 글은 오픈 월드 규모의 씬에서 나무와 지형 같은 정적 오브젝트의 그림자를 어떻게 처리할 것인가에 대한, 매우 실무적인 연구 기록입니다. 단순히 “클립맵이 좋다”는 아이디어 수준이 아니라, 정적 섀도우맵을 전제로 했을 때 어떤 문제를 해결해야 하고, 그 과정에서 어떤 데이터 구조와 알고리즘이 필요한지를 끝까지 밀어붙인 사례라고 볼 수 있습니다.

저자가 집중하는 지점은 크게 세 가지입니다.

첫째, 멀리 있는 그림자의 품질을 어떻게 안정적으로 유지할 것인가.

둘째, 라이트맵·섀도우마스크·라이트프로브가 섞여 있는 베이킹 워크플로우의 피로도를 어떻게 줄일 것인가.

셋째, 숲처럼 폴리곤 수가 많은 환경에서 실시간 섀도우 캐스팅이 만들어내는 드로우콜·폴리곤 비용을 어떻게 구조적으로 줄일 것인가입니다.

이를 위해 글에서는 평행광 방향을 고정한다는 전제 아래, 거대한 정적 섀도우맵을 클립맵 구조로 잘게 나누어 텍스처 아틀라스에 배치하고, 카메라 주변 타일만 스트리밍하는 방식을 제안합니다. 3×3, 5×5, 7×7 링 구조와 9×9 타일링, 인덱스 설계, UV 오프셋·스케일 관리, LOD·AABB 기반 클리핑 등은 실제 게임 엔진에 바로 가져다 넣을 수 있을 정도로 구체적입니다. 마지막 성능 비교에서도, 더 넓은 거리(약 2000m)를 커버하면서도 폴리곤 수와 배치 수를 모두 줄일 수 있다는 점을 수치로 보여 줍니다.

이 문서가 대규모 환경에서 정적 섀도우 시스템을 직접 구현해 보고자 하는 그래픽스 프로그래머·TA 분들께 작은 실마리가 되기를 바랍니다.


저자: jackie 偶尔不帅

프로젝트 요구 사항

이 R&D의 목적은 크게 세 가지 문제를 해결하는 것입니다.

  1. 화면 품질 향상멀리 있는 오브젝트가 여전히 평행광 기준의 실시간 계산을 받다 보니, 실제로는 어두워야 할 영역에서도 그림자가 떠 보이거나 어색하게 표현됩니다.
  2. 나무나 일부 도로 파츠가 라이트맵 베이킹에 참여하지 않아, 실시간 Shadow Map 거리 설정(예: 70m)을 넘어서도 그림자가 매우 부정확하게 보이는 문제가 있습니다.
  3. 워크플로 개선예를 들어 오브젝트를 조금만 수정해도 Shadow Map과 Shadow Mask의 위치나 각도가 어긋나 다시 구워야 합니다.이 때문에 원래도 부족한 조명 작업의 반복 효율이 더욱 떨어집니다.
  4. 동적 오브젝트는 Light Probe와 Enlighten 베이킹으로 Occlusion 정보를 기록할 수 있지만, GPU 가속을 활용하는 Bakery 베이킹에서 Light Probe Occlusion 모드를 켜면 Unity 기본 베이킹으로 되돌아갑니다.
  5. 다른 정적인 씬 오브젝트들은 Shadow Mask 베이킹으로 해결할 수 있지만, 베이킹 시간이 길고 반복 작업이 잦습니다.
  6. 런타임 성능 향상이는 곧 더 많은 차폐 관계와, 빛이 투과될 때의 그림자 계산량을 의미합니다. 나뭇잎을 한 장짜리 폴리곤으로 단순화하지 않는 이상, Shadow Map 렌더링 시 Shadow Caster 패스로 그 모든 면을 다시 그려야 합니다.이런 구조를 적용하면, 빽빽한 숲 근처에서 약 200만 폴리곤 정도의 Shadow Caster를 줄일 수 있어, 성능 이득이 상당합니다.
  7. 분석해 보면, 일부 경쟁작들은 나무에 대해서는 거리와 상관없이 동일한 정적 그림자를 사용하고, 나무 이외의 씬에만 ‘가까운 곳은 실시간 Shadow Map, 먼 곳은 정적 그림자’ 조합을 사용합니다.
  8. 게임 안에는 수많은 나무가 존재하며, 볼륨감을 표현하기 위해 비교적 폴리곤 수가 높은 메쉬를 사용합니다.

결과 예시

위와 같이 Shadow Mask 설정을 잘못하면 베이킹된 그림자가 사라져 다시 굽는 일이 자주 발생합니다.

Clipmap 기반 정적 그림자는 해상도와 선명도는 다소 낮지만, 전체적으로 더 올바른 결과를 제공합니다.

전 장면에 대한 깊이 맵을 계산하고 저장하는 데도 약 1분 정도면 충분합니다.


전체적인 아이디어

실시간 Shadow Map은 크게 두 단계로 나눌 수 있습니다.

  1. 캐스팅 단계: 라이트 카메라 관점에서 깊이 맵(Shadow Map)을 렌더링합니다.
  2. 샘플링 단계: 실제 렌더링 시, 해당 라이트 카메라의 행렬(Matrix)로 좌표계를 변환한 뒤, 깊이 맵을 샘플링하여 깊이 비교를 수행합니다.

Static Shadow Map은 고정된 방향의 평행광을 전제로, 이 깊이 맵을 미리 오프라인에서 생성해 두어, 런타임에서의 Shadow Cast 연산(막대한 정점 처리)을 제거하는 방식입니다.

다만 오픈 월드 같은 대규모 월드에서는, 이 깊이 맵 한 장의 메모리 사용량이 매우 크기 때문에, 전체 지형을 Mip 0 해상도로 한 번에 로딩하는 것은 현실적이지 않습니다.

그래서 Clipmap 기반 로딩 방식을 적용하게 되었습니다.


Clipmap 개념

Clipmap은 크게 두 가지 방식으로 나눌 수 있습니다.

  1. 같은 해상도의 텍스처로 서로 다른 월드 범위를 커버하는 방식동일한 크기의 텍스처를 사용하되, 월드 상에서 커버하는 면적을 바꾸어 여러 레벨을 구성합니다.
  2. 같은 월드 범위를, 서로 다른 해상도로 표현하는 방식중앙 3×3 영역은 가장 높은 해상도, 바깥 5×5는 그보다 낮은 해상도, 7×7은 더 낮은 해상도로 구성하는, 일종의 계단식 구조입니다.

두 번째 방식 안에서도 다시 두 가지 접근이 있습니다.

  • 레이어 방식이때 5×5 레벨에서는 중앙 3×3 부분을 비워 중복 로딩을 막아야 합니다.
  • 이 접근의 장점은 TextureArray를 활용하기 쉽다는 점입니다. 실제로 이 글을 쓰기 며칠 전까지는 이 방식을 사용했습니다.
  • 예를 들어 3×3 영역은 각 타일이 1024 해상도, 5×5 영역은 각 타일이 512 해상도 등으로 레벨을 나누고, 레이어별로 깊이 맵을 따로 계산합니다.
  • 아틀라스 방식이번 구현에서는 최종적으로 이 아틀라스 방식을 채택했기 때문에, 이후 설명은 이 방법을 기준으로 합니다.
  • 만약 모든 타일을 하나의 텍스처 아틀라스에 수용할 수 있다면, 서로 다른 해상도의 타일들을 한 장에 모아 관리할 수 있습니다.

첫 번째 링은 3×3 범위, 두 번째 링은 5×5 범위, 일반화하면 n번째 링의 전체 타일 수는 (2n+1)×(2n+1)입니다.

실제로 필요한 타일 수는, 가운데가 이미 채워져 있으므로 (2n+1)×4 - 4 = 8n이 됩니다.

즉 첫 번째 링만 1개가 더 많고, 각 링에서 필요한 타일 수는 다음과 같습니다.

  • 1링: 9개
  • 2링: 16개
  • 3링: 24개
  • 4링: 32개

여기에 추가로, 가장 낮은 해상도의 전역 Shadow Map을 1장 더 사용합니다.

본 예시에서는 총 4레벨의 Clipmap을 구성했습니다.

다음으로, 이 타일들을 어떻게 배치하면 전체 텍스처 아틀라스의 면적을 최소화할 수 있을지 고민합니다.

가장 큰 단일 타일이 1024 해상도라면, 4096×4096 크기의 아틀라스에 모든 타일을 배치할 수 있고, 이후 Mipmap 확장을 위한 여유 공간도 조금 남습니다.

이 정도면 텍스처 공간 활용률이 꽤 괜찮은 편입니다.

직접 배치하는 것이 귀찮다면 Unity에서 제공하는 Texture2D.PackTextures API를 사용할 수도 있습니다.

다만 파라미터에 따라 스케일이 달라지기 때문에, 원하는 배치를 얻기가 쉽지 않습니다.

그래서 이번에는 다수의 텍스처를 직접 만드는 대신, Unity에서 제공하는 순수 데이터 기반 아틀라스 계산 API인 Texture2D.GenerateAtlas를 사용했습니다.

(아래 그림을 보면 거의 사분트리(Quadtree)와 비슷한 구조입니다.)


핵심 알고리즘 개요

세부 구현은 상당히 복잡하고 설명도 장황해지기 때문에, 여기서는 핵심 포인트만 정리합니다.

  • 9×9 타일에 대해 고유한 정렬 인덱스를 부여해, Clipmap이 스크롤될 때 가장자리에 새로 들어오는 타일만 다시 로딩되도록 합니다.
  • 각 아틀라스마다 Rect 배열을 정의해, 인덱스를 통해 UV 오프셋과 스케일을 조회할 수 있게 합니다.
  • 타일별 라이트 카메라 행렬과 시작 기준 행렬을 저장하는 Matrix 배열을 둡니다.
  • 고정된 size / near / far를 사용해 오프셋만 조정하는 방식도 가능하며, 이 경우 시작 기준 행렬만 저장하면 됩니다.
  • 각 월드 좌표 wpos에 대해, 기준 라이트 카메라의 행렬을 이용해 전역 Clipmap 영역에서 어느 타일에 속하는지 인덱스를 계산합니다. 이 인덱스로 해당 타일의 라이트 카메라 행렬을 조회합니다.
  • wpos만으로는 현재 타일 내부의 위치만 알 수 있을 뿐, 아틀라스 상의 최종 UV는 알 수 없습니다.그 인덱스와 미리 정의된 아틀라스 UV 배열을 조합해, 최종적인 샘플링 UV를 얻습니다.
  • 스크롤 과정에서 “타일 ↔ 아틀라스 UV” 매핑이 계속 바뀌기 때문에, 각 지형 타일이 현재 프레임에 어떤 아틀라스 인덱스를 사용 중인지 를 별도로 저장해야 합니다.
  • 로딩과 언로딩의 경우, 각 LOD 레벨별 타일 수가 딱 맞게 할당되어 있으면, 항상 먼저 언로딩 후 로딩을 해야 합니다.또한 한 프레임에 한 타일만 로딩하는 등, 프레임 단위로 로딩량을 분배해야 합니다.
  • 이런 방식은 전 화면이 한 번씩 깜빡이는 현상을 유발하기 때문에, 각 LOD에 여유 타일을 1개 이상 확보하고, 로딩 순서를 엄격히 제어해야 합니다.
  • 그림자의 즉시성이 아주 중요하지 않다면, 2프레임에 한 번 로딩하는 식으로 조절할 수도 있습니다.
  • 2프레임 주기로 바꾼다고 해서 성능이 눈에 띄게 좋아지는 것은 아니지만, 예를 들어 Clipmap 로딩은 홀수 프레임에만, 비슷한 수준의 다른 무거운 기능은 짝수 프레임에만 실행하도록 조합하면, 성능 피크가 겹치는 것을 완화할 수 있습니다.
  • 위의 모든 계산은 라이트 공간(Light Space) 에서 처리하는 것이 핵심입니다.

개인적인 소감으로는, 이 부분은 정말 복잡합니다.

가능한 케이스를 전부 고려하며 구현했는데도, 결국 반나절을 통째로 투자해 겨우 안정적으로 동작시키는 수준에 도달했습니다.

중간에 여러 번 포기하고, 차라리 전역 Shadow Map을 한 번에 VRAM에 올린 뒤, 원신에서 사용한 것 같은 강력한 압축 기법을 적용할까 고민하기도 했습니다.

하지만 그 이후에는 더 거대한 씬도 다뤄야 하므로, 스트리밍 인·아웃을 안정적으로 처리할 수 있는 기술은 결국 반드시 넘어야 할 산이라고 판단했습니다.


성능 및 품질 비교

가시 거리 약 400m 구간에서는, Mip 0–3까지 네 단계의 Shadow Map을 사용해 9×9 타일을 구성하고, 각 타일은 가로세로 100m 영역을 담당합니다.

이 9×9 영역 밖은, 더 낮은 해상도의 전역 Shadow Map 한 장으로 처리합니다.

타일을 잘라 쓰는 이유는 단순합니다.

XZ 평면 상의 해상도는 나누기 전이나 후나 달라지지 않지만, 깊이 방향(Z) 에서는, 각 타일이 담당하는 AABB 범위가 작아지기 때문에, near / far clip 값을 꽤 촘촘하게 설정할 수 있습니다.

그 결과, 깊이 버퍼의 표현력이 크게 향상되어, 그림자 품질이 눈에 띄게 좋아집니다.

9×9 영역 바깥을 담당하는 저해상도 전역 Shadow Map 예시입니다.

  • 기존 엔진 Shadow Map (약 400m 범위)
    • 약 400만 폴리곤
    • 386 Batches

  • Clipmap 기반 정적 그림자 (약 2000m 범위)
    • 약 260만 폴리곤
    • 284 Batches

요약하면, 훨씬 더 넓은 거리(2000m)를 커버하면서도, 폴리곤 수와 드로우 콜이 모두 크게 줄어드는 결과를 얻을 수 있습니다.


용어 설명 (추가)

아래 용어 설명은 이해를 돕기 위한 요약이며, 보다 자세한 내용은 위키피디아 등 표준 자료를 함께 참고하는 것이 좋습니다.

  • Shadow Map실제 렌더링 시에는 월드 좌표를 라이트 공간으로 투영한 뒤, 해당 위치의 깊이를 Shadow Map과 비교하여 빛이 닿는지 판단합니다. (참고: "Shadow mapping" – 위키피디아)
  • 광원(보통 카메라처럼 동작하는 가상의 라이트) 시점에서 씬을 렌더링하여 얻은 깊이 텍스처로, 픽셀이 그림자에 있는지 여부를 판정할 때 사용하는 기법입니다.
  • Static Shadow Map런타임에서는 이 미리 계산된 깊이 정보를 재사용하므로, 실시간으로 거대한 지오메트리를 계속 Shadow Caster로 렌더링하지 않아도 되어 성능 상 이점을 얻습니다.
  • 라이트 방향과 씬의 정적 지오메트리가 변하지 않는다는 가정하에, Shadow Map을 미리 오프라인에서 계산해 둔 텍스처입니다.
  • Clipmap / Clipmapping가까운 영역은 고해상도 타일, 먼 영역은 저해상도 타일로 구성하며, 카메라가 이동하면 해당 "레벨"의 일부 타일만 갱신합니다.
  • 원래는 지형 텍스처·고도맵 스트리밍에 많이 쓰이지만, 이 글에서는 정적 Shadow Map 깊이 데이터를 Clipmap 구조로 관리하는 데 응용하고 있습니다. (참고: "Clipmap" – 위키피디아)
  • 매우 큰 텍스처나 높이 맵을 전부 GPU 메모리에 올리지 않고, 카메라 주변에서 실제로 사용되는 일부 영역만 계층적으로 캐시하는 기법입니다.
  • Light Space (라이트 공간)Shadow Map을 사용할 때는, 월드 좌표를 이 라이트 공간으로 변환해 Shadow Map의 텍스처 좌표와 깊이 비교에 사용합니다. (참고: 일반적인 실시간 렌더링 교재 또는 OpenGL/DirectX 조명 파이프라인 문서)
  • 조명 계산을 위해 정의된 좌표계로, 보통 광원 기준의 뷰·프로젝션 행렬을 곱한 결과입니다.
  • Texture Atlas (텍스처 아틀라스)각각의 서브 텍스처는 아틀라스 안에서 고유한 UV 오프셋 + 스케일을 갖고, 셰이더에서는 이 정보를 사용해 한 장의 큰 텍스처에서 필요한 영역만 샘플링합니다. (참고: "Texture atlas" – 위키피디아)
  • 여러 장의 작은 텍스처를 하나의 큰 텍스처 안에 배치해 관리하는 방식입니다.
  • Texture2D.PackTextures / Texture2D.GenerateAtlas (Unity)PackTextures는 실제 텍스처를 아틀라스로 합성하고, GenerateAtlas는 주어진 사각형(크기 정보)을 기준으로 아틀라스 레이아웃만 계산하는 식으로 사용할 수 있습니다.
  • 이 글에서는 Clipmap 타일들을 효율적으로 배치하기 위해 GenerateAtlas를 활용했습니다.
  • Unity에서 여러 텍스처를 한 장의 아틀라스에 자동으로 배치해 주는 API입니다.
  • Light Probe / Enlighten / Shadow Mask (Unity 베이킹 관련)
    • Light Probe: 씬 곳곳에 점(Probe)을 배치해 간접광 정보를 샘플링·보간하여, 정점/오브젝트 단위로 간단한 GI 효과를 주는 Unity 기능입니다.
    • Enlighten: Unity에서 사용되던 실시간/베이크드 글로벌 일루미네이션 솔루션으로, Light Probe나 라이트맵 계산에 활용되었습니다.
    • Shadow Mask: 실시간 조명과 라이트맵·베이크드 그림자를 혼합하는 모드로, 멀리 있는 정적 오브젝트의 그림자를 라이트맵(마스크)로 대체해 성능을 확보하는 방식입니다. (관련 정리: Unity 문서 및 자체 정리 페이지들[1])
  • LOD (Level of Detail) / AABB (Axis-Aligned Bounding Box)
    • LOD: 거리에 따라 다른 해상도의 지오메트리·텍스처를 사용하는 최적화 기법입니다. Clipmap도 일종의 텍스처 LOD 체계로 볼 수 있습니다.
    • AABB: 축에 정렬된 박스 형태의 바운딩 볼륨으로, 충돌 판정·프러스텀 컬링·Shadow Map 카메라 클리핑 범위 계산 등에 널리 사용됩니다.
    이 글에서는 타일별 Shadow Map을 찍을 때, 각 타일이 담당하는 AABB가 작아지므로 near/far clip을 더 촘촘하게 잡을 수 있고, 그 덕분에 깊이 정밀도가 높아진다는 점을 강조합니다.

원문

(51 封私信 / 29 条消息) 基于clipmap的静态shadowmap - 知乎