TECHARTNOMAD | MAZELINE.TECH

TECH.ART.FLOW.IO

[번역] 텐센트 델타포스 대형 월드 RVT 지형 렌더링 분석과 재현

jplee 2026. 6. 18. 18:34

저작: Lamp

꽤 오래전에 만든 거라, 그냥 물 타듯이 한 편 써 본다.

먼저 기술 포인트를 간단히 요약하면 이렇다.

  • 10km급 대형 월드에서 RVT를 사용하고, 1m당 512 texel을 쓴다.
  • 지형 머티리얼 32종을 지원한다. 전통적인 모바일 게임의 4~8레이어 제한을 훨씬 넘어선다.
  • 절벽 최적화, 대량 데칼, Tessellation, 습도 변화 같은 고급 효과도 구현했다.
  • 고화질 설정에서도 프레임, 발열, 대역폭 같은 핵심 지표가 CODM의 저화질 설정보다 오히려 더 좋다.

원본 영상: 삼각주 대형 월드 지형 렌더링 방안

먼저 지오메트리 쪽부터 보자. CDLOD(Continuous Distance-based Level of Detail)를 사용해 SVT로 로드한 Height Map을 샘플링한다. 이건 딱히 할 말이 없다. 넘어가자.

지표면 쪽은 런타임에 bake하는 RVT를 사용한다. 한 블록은 256x256이고, LOD0은 월드 공간에서 0.5m에 대응한다. GPU Readback인지 CPU 방식인지는 말하지 않았으니, 여기서는 일단 CPU 구현을 사용했다. 구현은 어느 고수님의 단순화 RVT 방식을 참고했다.

대형 지형을 위한 단순화 RVT

11레벨까지 가도 1024m밖에 안 되기 때문에, Indirection Texture는 Adaptive Virtual Texture 방식으로 할당해야 한다. 나는 귀찮아서 그냥 Clipmap으로 굴렸다. 더 높은 mip의 전체 맵도 2K가 채 안 되니, 오프라인에서 미리 bake해 두고 로드하면 된다.

지표면 블렌딩

ID 블렌딩

지표면 블렌딩은 2ID + 1Weight 방식을 사용한다. 이 방식으로 32레이어 머티리얼 블렌딩을 지원한다. 두 개의 ID는 각각 5bit, Weight는 6bit다. PPT에는 3bit라고 적혀 있었지만, R16에는 아직 6bit가 남아 있으니 여기서는 6bit로 본다. 해상도는 1m당 1픽셀이다.

방식은 1m 삼각형을 단위로 삼아, 현재 삼각형에 해당하는 6개의 머티리얼 ID와 대응 Weight를 샘플링한다. 그다음 중복을 제거하고, Weight가 가장 큰 3개만 골라 블렌딩한다. 실제로는 제작 단계에서 이미 1m 안에 최대 3레이어까지만 들어가도록 제한했기 때문에, 코드도 조금 줄일 수 있다.

다만 아직 이해가 안 되는 부분이 있다. 더 높은 mip에서 한 픽셀이 2m를 덮을 때, 이걸 대체 어떻게 계산해야 하느냐는 것이다. 그래서 일단은 오프라인에서 전체 맵을 1m 1픽셀로 bake한 뒤, mip을 계산하고 런타임에는 SVT로 로드하는 방식으로 처리했다. 그런데 이렇게 하면 전역 텍스처가 좀 너무 많다……

동적 적응형 텍스처 배열

32장의 머티리얼 레이어 텍스처는 공간을 매우 많이 차지한다. 그래서 동적 적응형 텍스처 배열을 사용한다. 각 영역에서 동시에 존재할 수 있는 머티리얼을 최대 12레이어로 제한하고, bake할 때 필요한 텍스처만 동적으로 업로드해 VRAM을 절약한다. 그런데 이미 영역별 레이어 수를 제어하고 동적 로딩까지 한다면, 전체 32레이어 제한은 또 무슨 의미인지 잘 모르겠다.

그리고 가까운 곳은 Height Blend를 사용하고, 먼 곳은 Linear Blend를 사용한다. 다만 제공된 블렌딩 방식은 세 가지 머티리얼을 어떻게 섞는 건지 잘 이해가 안 돼서, 일단은 전통적인 방식으로 섞었다.

구현

먼저 중복 제거부터 보자. 제작팀이 동적 적응형 텍스처 배열을 사용한다고 했고, 동시에 최대 12개 layer가 존재한다고 했으니, 가장 간단한 방법은 리매핑 후 길이 12짜리 배열로 중복 제거를 하는 것이다. 또는 먼저 정렬한 뒤 중복 제거를 해도 된다. GatherRed를 사용하면 샘플링 횟수도 줄일 수 있다. 구현 코드는 아래와 같다.

struct Element {
    int id;
    float weight;
};

void UnpackData(float input, float lrp, out Element e1, out Element e2)
{
    const int data = floor(input * 65535 + 0.5);
    e1.id = (data >> 11) & 31;
    e2.id = (data >> 6) & 31;
    const float weight = (data & 63) / 63.0 * 0.5 + 0.5;
    float2 weights = float2(weight, 1 - weight) * lrp;
    e1.weight = weights.x;
    e2.weight = weights.y;
}

inline void Swap(inout Element a, inout Element b)
{
    const Element temp = a;
    a = b;
    b = temp;
}

inline void CompareSwap(inout Element arr[8], int i, int j)
{
    if (arr[i].id > arr[j].id) Swap(arr[i], arr[j]);
}

void Sort6(inout Element arr[6])
{
    CompareSwap(arr, 0, 1);
    CompareSwap(arr, 2, 3);
    CompareSwap(arr, 4, 5);
    CompareSwap(arr, 0, 2);
    CompareSwap(arr, 1, 3);
    CompareSwap(arr, 1, 2);
    CompareSwap(arr, 0, 4);
    CompareSwap(arr, 1, 5);
    CompareSwap(arr, 1, 4);
    CompareSwap(arr, 2, 4);
    CompareSwap(arr, 3, 5);
    CompareSwap(arr, 3, 4);
}

void SortAndMerge(float2 uv, inout Element elements[6])
{
    Sort6(elements);

    int lastID = -1;
    int ptr = 0;
    int i;

    for (i = 0; i < 6; i++)
    {
        const int curID = elements[i].id;
        if (curID != lastID) elements[ptr++] = elements[i];
        else elements[ptr - 1].weight += elements[i].weight;
        lastID = curID;
    }

    float splatHeight[3];
    splatHeight[0] = elements[0].weight * SampleHeight(uv, elements[0].id);
    float maxHeight = splatHeight[0];

    splatHeight[1] = elements[1].weight * SampleHeight(uv, elements[1].id);
    if (splatHeight[1] > maxHeight) maxHeight = splatHeight[1];

    splatHeight[2] = elements[2].weight * SampleHeight(uv, elements[2].id);
    if (splatHeight[2] > maxHeight) maxHeight = splatHeight[2];

    float transition = max(1e-3, _HeightBlendSharpness * _HeightBlendSharpness);
    elements[0].weight *= max(1e-6, splatHeight[0] + transition - maxHeight);
    elements[1].weight *= max(1e-6, splatHeight[1] + transition - maxHeight);
    elements[2].weight *= max(1e-6, splatHeight[2] + transition - maxHeight);
}

void MixTerrainLayersTri(float2 uv, float2 tilling, int lod, out float4 col, out float3 normal)
{
    const float2 texUV = uv * tilling;
    const float2 uv0 = uv * (_IdMap_TexelSize.zw - 1);
    int2 base = int2(floor(uv0));

    float2 cornerUV = frac(uv0);
    int2 corner = 0;

    if (cornerUV.x + cornerUV.y > 1)
    {
        cornerUV = 1.0 - cornerUV.yx;
        corner = 1;
    }

    float c10 = LOAD_TEXTURE2D_LOD(_IdMap, base + int2(1, 0), 0).r;
    float c01 = LOAD_TEXTURE2D_LOD(_IdMap, base + int2(0, 1), 0).r;
    float c00 = LOAD_TEXTURE2D_LOD(_IdMap, base + corner, 0).r;

    Element elements[6];
    UnpackData(c00, 1.0 - cornerUV.x - cornerUV.y, elements[0], elements[1]);
    UnpackData(c10, cornerUV.x, elements[2], elements[3]);
    UnpackData(c01, cornerUV.y, elements[4], elements[5]);

    SortAndMerge(texUV, elements);

    const float weightSum = elements[0].weight + elements[1].weight + elements[2].weight;

    col = 0;
    normal = 0;

    float4 data0;
    float4 data1;

    UNITY_UNROLL
    for (int idx = 0; idx < 3; idx++)
    {
        const Element e = elements[idx];
        SampleTerrainTexture(texUV, e.id, lod, data00, data11);
        data0 += data00 * e.weight / weightSum;
        data1 += data11 * e.weight / weightSum;
    }
}

뒤쪽에서는 tile마다 머티리얼 개수를 오프라인에서 미리 계산해 두고, 개수에 따라 다른 variant를 쓰는 방법도 언급했다. 예를 들어 한 레이어뿐인 tile이라면 중복 제거와 블렌딩이 필요 없고, 그냥 바로 샘플링하면 된다.

표현력 향상

전역 텍스처

이미 SVT, 제작팀 기준으로는 Clipmap을 사용해 Height와 ID Map을 로드하고 있으니, 여기에 같은 크기의 Color, Roughness, AO 같은 전역 텍스처를 추가해 지표면 표현을 풍부하게 만든다. 지형 AO, 강·호수·절벽의 색 입히기, 물웅덩이 같은 디테일 표현을 할 수 있다. 동시에 원경에서는 전역 Normal을 샘플링해 낮은 폴리곤 수 때문에 사라지는 디테일을 보완할 수도 있다.

식생도 마찬가지로 Color Map을 샘플링해 변화를 줄 수 있다.

절벽 처리

절벽 텍스처가 늘어나는 문제는 Triplanar Mapping 방식으로 VT의 UV를 다시 매핑해 처리했다. 서로 다른 방향 사이의 전환은 dither로 블렌딩한다.

간단한 구현 코드는 아래와 같다.

half3 blendWeights = pow(abs(i.worldNormal), _TripBlendSharpness);
blendWeights = blendWeights / (blendWeights.x + blendWeights.y + blendWeights.z);

float rnd = Random(i.vertex.xy);
float2 uv;

if (rnd < blendWeights.x) {
    uv = i.worldPos.zy;
}
else if (rnd < blendWeights.x + blendWeights.y) {
    uv = i.worldPos.xz;
}
else {
    uv = i.worldPos.xy;
}

// mix terrain layers use new uv
// ...

하지만 이건 결국 텍스처가 늘어나는 것만 처리한 것이다. VT 위에서 차지하는 면적 자체는 변하지 않기 때문에, 조금 흐릿해 보일 수 있다. 절벽 각도가 크지 않다면 괜찮지만, 사실상 급경사의 UV 늘어남을 처리하는 정도다. 진짜 절벽은 결국 mesh를 배치해야 한다……

데칼

RVT는 태생적으로 대량 지표면 데칼을 잘 지원한다. 지형 위에 도로, 파손 흔적, 자갈, 물웅덩이 같은 디테일을 찍어 넣을 수 있다. 이건 딱히 설명할 것도 없다. 기본적으로 다들 쓰는 방식이다.

그다음은 따옴표가 붙은 “Tessellation”이다. VT 기반 입체 데칼이라고 보면 된다. 실제로 지형을 세분화하는 것은 아니고, 특정 위치에 mesh를 몇 개 배치한 뒤 지형과 섞어서 마치 세분화된 것처럼 보이게 만드는 방식이다.

구체적인 방식은 이렇다. 데칼은 Color와 Normal을 VT에 찍어 넣을 뿐 아니라, mesh 자체도 가까운 거리에서는 렌더링된다. 그리고 이 mesh가 VT를 샘플링해 지형과 섞인다. 처음 로드 거리 안으로 들어올 때 z scale은 0이라 완전히 지면에 붙어 있고, 없는 것과 같다. 가까워질수록 z scale이 점점 1이 되면서 입체적인 형태가 된다. 낮은 폴리곤 지형에 약간의 굴곡을 추가하는 셈이다.

아래는 간단한 구현 예시다. 사실상 지형 VT만 완전히 샘플링하고 블렌딩은 하지 않은 mesh를 하나 올려둔 것이다……

VHFM과의 차이는, 여기서는 mesh 데칼이 있는 곳에만 입체 높이가 있고 지형 머티리얼 자체는 여전히 평평하다는 점이다.

VHFM은 지형 머티리얼 레이어 자체에도 높이가 있다.

성능 최적화

마지막으로 성능 최적화도 몇 가지 있다.

  • 원거리/근거리의 서로 다른 tiling은 dither로 블렌딩한다. 이건 딱히 할 말이 없다. 나는 CPU로 제어했다. VT에는 부모 레벨에서 복사된 mipmap이 세 레이어 있기 때문에, 샘플링할 때 하드웨어 trilinear가 자동으로 전환해 준다.
  • Bake Shader variant를 사용한다. 삼각주에서는 tile 중 13%만 세 레이어 텍스처 블렌딩을 가득 사용한다고 한다. 그러면 각 tile의 블렌딩 상태를 미리 계산해 두고, 상황에 따라 다른 variant를 쓰면 된다. 예를 들어 tile 전체가 한 가지 머티리얼뿐이라면 복잡한 중복 제거와 블렌딩 없이 그냥 샘플링하면 끝이다. 삼각주의 지형 머티리얼은 PCG로 생성되어 복잡한 블렌딩이 적고, 1m에 512픽셀, VT page 크기는 256이므로 단순 variant를 쓸 수 있는 블록이 꽤 많다.
  • 데칼의 반투명 대신 dither를 사용한다. 이렇게 하면 RT에 alpha blending이 필요 없어지고, 3장의 RT를 2장으로 줄일 수 있다. 동시에 기존 블렌딩 경로도 남겨 둔다. 렌더링 후 FBF로 3장을 2장으로 합성한 뒤 압축해서 Texture Array에 넣는다. 즉 bake 시점에는 3장 경로와 2장 경로를 선택할 수 있고, 최종 압축은 모두 2장이다.
  • 각 tile의 데칼 상태를 미리 계산한다. 전체가 데칼로 덮여 있다면 데칼만 그리면 되고, 데칼이 없다면 Depth Buffer가 필요 없다. 나머지 경우에는 먼저 데칼을 그려 overdraw를 줄인다.

정리

뒤쪽 최적화, 예를 들어 전역 Normal Color, 절벽 늘어남 처리, 입체 데칼, 대역폭 최적화 같은 방식은 블렌딩과 무관하므로 모두 적용할 수 있다.

블렌딩 방식은 사용할지 말지 아직 논의가 필요하다. 글에서 언급한 것처럼 각 영역의 레이어 수를 제어할 수 있다면, 예를 들어 영역마다 8레이어로 제한하고 동적 텍스처 배열로 서로 다른 머티리얼 레이어 텍스처를 로드하면, 사실상 영역별 전체 레이어 수 제한은 존재하지 않는다. CPU에서 매핑만 잘 만들어 주면 된다.

그러면 두 방식을 비교해 보자.

  • 삼각주 방식: 두 개의 3bit ID + 2bit Weight 방식으로 바꾼다고 가정하면, 10K의 8bit 비압축 맵은 100MB다. mip은 없다. 1m당 3레이어만 존재하도록 제어해야 하고, Weight 정밀도도 낮다. 샘플링은 3ID + 3Albedo + 3Normal + 3Mask, 총 12회다. 런타임에는 블록 내부 블렌딩 상태에 따라 단순화할 수 있다.
  • 전체 Weight 방식: 두 장의 RGBA32를 ASTC 8x8로 압축하면 50MB다. 머티리얼 텍스처와 VT texel을 정렬한 뒤, 둘씩 묶어 hardware bilinear로 블렌딩한다. Weight 정밀도가 더 높고, 영역 안에서 8레이어 임의 블렌딩도 지원한다. 샘플링은 2Weights + 4Albedo + 4Normal + 4Mask, 총 14회다.

실제로는 프로젝트의 아트 요구에 따라 삼각주식 블렌딩 방식을 쓸지 말지 선택해야 할 것 같다. 픽셀마다 ID를 두 개만 저장하기 때문에, 세 레이어 사이의 전환이 이상하게 깨지지 않는 것까지만 보장할 수 있다. 하지만 진짜 세 레이어 동시 블렌딩은 할 수 없다.

여러 레이어의 부드러운 전환과 복잡한 블렌딩이 필요하다면 삼각주의 방법은 감당하기 어렵다. 반대로 그런 요구가 없다면 삼각주의 방법이 더 낫다. 어차피 툭툭 몇 번 칠해서 ID Map으로 내보내면 결과가 안 맞을 수도 있으니, 결국 아트팀이 받아들일 수 있느냐를 봐야 한다……


원문
(73 封私信 / 57 条消息) 三角洲大世界RVT地形渲染解析复现 - 知乎