← 개념 · 데모 가이드 | 구현 허브 | 8장. 잔디 렌더링 | 지형 텍스처
IMPLEMENTATION · CHAPTER 8 · 4/10

지형 텍스처 — 원경 LOD, 사실상 공짜

카메라에서 아주 멀어지면 잔디 한 포기는 화면에서 한 픽셀도 채우지 못합니다. 그런 원경에까지 지오메트리을 쓰는 건 낭비입니다. 지형 텍스처는 먼 거리를 평면에 잔디 텍스처 한 장으로 처리합니다. 정점도, 오버드로우도 거의 0이라 LOD 스택의 가장 값싼 마지막 단계입니다. 입체감은 없지만 멀면 어차피 안 보입니다. 이 페이지에서는 픽셀 셰이더에서 절차적 value noise로 잔디 색을 변주해 텍스처를 만들고, 근경에만 실제 blade를 겹치는 하이브리드까지 D3D11로 구현합니다.

규약 행벡터 v' = v * M(HLSL mul(v, M)), 행렬 행 우선 저장, 왼손 좌표계, 클립 z 범위 [0, 1]. 뷰-투영은 DirectXMath 관례대로 view * proj 순서로 곱합니다. 아래 코드는 원리 확인용 학습 예제입니다.

▶ 실시간 데모

드래그로 회전, 휠로 줌. 아래에서 설명하는 코드가 실제로 이렇게 동작합니다. (WebGL2)

초기화 중…
근경은 지오메트리, 원경은 텍스처 — 실제 게임의 LOD 조합이 이런 모습.
💡 blade 겹치기를 끄면 순수 텍스처 지형만 남음(가장 쌈). 켜면 가까운 곳만 실제 blade로 보강하는 하이브리드.

이 페이지에서 만들 것 (큰 그림)

  1. 지형 평면 — 크게 스케일한 지면 쿼드 하나를 그립니다.
  2. 절차적 잔디 텍스처 — 픽셀 셰이더에서 value noise로 잔디 색을 생성합니다(텍스처 파일 불필요).
  3. value noise — 해시 + 격자 보간으로 부드러운 노이즈를, 여러 옥타브로 합성합니다.
  4. 근경 blade 하이브리드 — 카메라 가까운 곳에만 지오메트리 blade를 소량 겹칩니다.
  5. LOD 스택 — 근경=지오메트리, 원경=텍스처의 실제 게임식 조합을 정리합니다.

1. 지형 평면 — 정점은 4개면 충분

원경 지형은 그냥 큰 평면 한 장입니다. 정규화 좌표 쿼드(4정점)를 만들어 셰이더에서 34배 스케일해 넓게 폅니다. 잔디의 “무늬”는 정점이 아니라 픽셀 셰이더에서 절차적으로 만들 것이므로, 지오메트리 부담은 사실상 없습니다.

grass_terrain.hppcpp
#include <d3d11.h>
#include <DirectXMath.h>
#include <wrl/client.h>
#include <vector>
#include <cmath>
using Microsoft::WRL::ComPtr;
using namespace DirectX;

// 지형 평면: 정규화 좌표 (x, z) ∈ [-1, 1], 4정점 삼각형 스트립
struct GroundVertex { float x, z; };

std::vector<GroundVertex> makeGround() {
    return { { -1,-1 }, { +1,-1 }, { -1,+1 }, { +1,+1 } };
}

ComPtr<ID3D11Buffer> g_groundVB;

void createGroundBuffer(ID3D11Device* dev) {
    auto quad = makeGround();
    D3D11_BUFFER_DESC bd = {};
    bd.Usage = D3D11_USAGE_IMMUTABLE;
    bd.ByteWidth = (UINT)(quad.size() * sizeof(GroundVertex));
    bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
    D3D11_SUBRESOURCE_DATA sd = { quad.data(), 0, 0 };
    dev->CreateBuffer(&bd, &sd, g_groundVB.GetAddressOf());
}
지형 버텍스 셰이더hlsl
cbuffer Frame : register(b0) {
    row_major float4x4 uVP;   // view * proj
};

struct VSIn  { float2 p : GROUND0; };
struct VSOut { float4 pos : SV_Position; float2 v : TEXCOORD0; };

VSOut main(VSIn i) {
    VSOut o;
    o.v   = i.p;                                    // 노이즈 좌표로 전달
    // 넓게 펴진 평면(34배). y=0 지면.
    float3 wp = float3(i.p.x * 34.0, 0.0, i.p.y * 34.0);
    o.pos = mul(float4(wp, 1.0), uVP);              // 행벡터 → mul(v, M)
    return o;
}

2. value noise — 해시에서 부드러운 노이즈로

잔디 텍스처를 파일로 준비하는 대신, 픽셀 셰이더에서 value noise로 즉석에서 만듭니다. 먼저 셀 좌표를 결정론적 해시 h로 난수화하고, 격자의 네 모서리 값을 부드러운 보간 (f·f·(3-2f), smoothstep)으로 섞습니다. 이렇게 하면 격자 티가 안 나는 매끈한 노이즈가 됩니다.

한 옥타브만으로는 밋밋하므로, 주파수를 3배·9배로 키운 노이즈를 0.6 : 0.3 : 0.1 가중으로 더해 큰 얼룩 위에 잔무늬가 얹힌 자연스러운 지면을 만듭니다(프랙탈 합성, fBm의 축약형).

terrain_ps.hlsl (노이즈 함수)hlsl
// 셀 좌표 → 결정론적 해시 (WebGL 데모와 동일 상수)
float h(float2 c) {
    return frac(sin(dot(c, float2(41.3, 289.1))) * 43758.5453);
}

// value noise: 격자 네 모서리 해시를 smoothstep 보간
float noise(float2 p) {
    float2 i = floor(p);
    float2 f = frac(p);
    f = f * f * (3.0 - 2.0 * f);         // smoothstep 가중
    float a = h(i);
    float b = h(i + float2(1, 0));
    float c = h(i + float2(0, 1));
    float d = h(i + float2(1, 1));
    return lerp(lerp(a, b, f.x), lerp(c, d, f.x), f.y);
}

3. 픽셀 셰이더 — 절차적 잔디 색

이제 노이즈로 잔디 색을 만듭니다. 세 옥타브를 합성한 값 n으로 어두운 녹색과 밝은 녹색을 보간하고, 더 잔 주파수(20배) 노이즈로 미세한 밝기 변화를 곱해 얼룩을 줍니다. 텍스처 파일이 전혀 없이, 지형 전체 색이 픽셀 셰이더 한 번으로 계산됩니다.

terrain_ps.hlsl (본체)hlsl
struct PSIn { float4 pos : SV_Position; float2 v : TEXCOORD0; };

float4 main(PSIn i) : SV_Target {
    float2 uv = i.v * 40.0;               // 노이즈 좌표 스케일

    // 3옥타브 합성(큰 얼룩 0.6 + 중간 0.3 + 잔무늬 0.1)
    float n = noise(uv)       * 0.6
            + noise(uv * 3.0) * 0.3
            + noise(uv * 9.0) * 0.1;

    // 어두운 녹색 ↔ 밝은 녹색
    float3 c = lerp(float3(0.10, 0.20, 0.08),
                    float3(0.30, 0.50, 0.18), n);

    // 아주 잔 노이즈로 미세 밝기 변주(얼룩)
    c *= 0.8 + 0.2 * noise(uv * 20.0);
    return float4(c, 1.0);
}
사실상 공짜 지형 텍스처의 정점은 4개뿐이고, 픽셀 셰이더는 화면을 덮는 각 픽셀에 딱 한 번씩만 실행됩니다(오버드로우 없음, 겹치지 않는 단일 평면). 셸이 겪던 “레이어 수만큼의 오버드로우”도, 빌보드가 겪던 “카드 겹침”도 여기엔 없습니다. 그래서 LOD 스택에서 가장 먼 거리를 이걸로 채우면 대부분의 화면 면적을 최저 비용으로 그릴 수 있습니다. 절차적 노이즈 대신 미리 구운 텍스처 + 밉맵을 쓰면 원경 앨리어싱까지 잡을 수 있습니다.

4. 근경 blade 하이브리드

순수 텍스처 지형은 입체감이 없어 가까이서 보면 납작합니다. 그래서 실제 게임은 카메라 가까운 영역에만 실제 지오메트리 blade를 소량 겹칩니다. 원경은 텍스처(공짜), 근경은 blade(품질) — 이 조합이 “근경에 blade 겹치기”의 정체입니다. blade는 지오메트리 잔디의 메시·인스턴싱을 그대로 재사용하되, 필드 반경을 작게(예: 9) 잡아 가까운 곳에만 소량 뿌립니다.

근경 blade 필드 (지오메트리 편 재사용)cpp
// 지오메트리 편의 BladeVertex / GrassInstance / scatterField를 그대로 사용.
// 차이는 "반경을 작게" 잡아 카메라 근처에만 소량 뿌리는 것.
struct BladeVertex  { float side, t; };
struct GrassInstance{ float posX, posZ, angle, height, phase, colVar; };

ComPtr<ID3D11Buffer> g_bladeVB, g_nearVB;
UINT g_bladeVerts = 0, g_nearCount = 0;

void createNearBlades(ID3D11Device* dev) {
    // SEG=5 blade 스트립
    std::vector<BladeVertex> blade;
    const int SEG = 5;
    for (int i = 0; i <= SEG; ++i) {
        float t = (float)i / SEG;
        blade.push_back({ -1.0f, t });
        blade.push_back({ +1.0f, t });
    }
    // 반경 9로 좁게 → 근경만. 개수도 9000개로 소량.
    auto field = scatterField(9000, 9.0f, 55);   // 지오메트리 편 함수 재사용
    g_bladeVerts = (UINT)blade.size();
    g_nearCount  = (UINT)field.size();
    /* ... 지오메트리 편과 동일하게 두 VB 생성 ... */
}
근경 blade 셰이더 (경량판)hlsl
// 지오메트리 편의 간소화 버전(색만, 알파 없음). 근경이라 SEG=5로 충분.
cbuffer Frame : register(b0) {
    row_major float4x4 uVP;
    float uTime; float3 _pad;
};
struct VSIn  { float2 aB : BLADE0; float4 iA : INSTA0; float2 iC : INSTB0; };
struct VSOut { float4 pos : SV_Position; float3 col : COLOR0; };

VSOut main(VSIn i) {
    float t = i.aB.y, side = i.aB.x, h = i.iA.w, ang = i.iA.z;
    float w = (1.0 - t) * 0.06;
    float sway = sin(uTime * 1.6 + i.iC.x) * 0.3 * t * t;
    float3 dir = float3(cos(ang), 0.0, sin(ang));
    float3 p = float3(i.iA.x, 0.0, i.iA.y);
    p += dir * side * w; p.y += t * h; p.x += sway;

    VSOut o;
    o.col = lerp(float3(0.09, 0.22, 0.08),
                 float3(0.35, 0.62, 0.20), t) * lerp(0.4, 1.0, t);
    o.pos = mul(float4(p, 1.0), uVP);
    return o;
}
함정 하이브리드에서 근경 blade와 지형 텍스처의 색 톤이 어긋나면 경계가 눈에 띕니다(가까운 곳만 갑자기 색이 다른 띠). 두 셰이더의 녹색 팔레트를 같은 값으로 맞추고(위 코드는 지형·blade 모두 유사 녹색 사용), blade가 사라지는 거리 근처에서 알파나 스케일로 부드럽게 페이드아웃시키면 전환이 자연스럽습니다.

5. 드로우 — 지형 먼저, 근경 blade는 옵션

항상 지형 평면을 먼저 그리고, LOD가 켜져 있으면 그 위에 근경 blade를 얹습니다. 둘 다 불투명이므로 깊이 테스트 ON · 블렌드 OFF. 지형이 먼저 깊이를 채워 두면 blade가 지형 뒤로 잠기는 일 없이 자연스럽게 위로 서 있습니다.

렌더 루프cpp
void drawTerrain(ID3D11DeviceContext* ctx, bool lodNearBlades) {
    // ① 지형 평면(원경 텍스처) — 항상
    UINT gs = sizeof(GroundVertex), go = 0;
    ctx->IASetInputLayout(g_terrLayout.Get());
    ctx->IASetVertexBuffers(0, 1, g_groundVB.GetAddressOf(), &gs, &go);
    ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
    ctx->VSSetShader(g_terrVS.Get(), nullptr, 0);
    ctx->PSSetShader(g_terrPS.Get(), nullptr, 0);
    ctx->VSSetConstantBuffers(0, 1, g_frameCB.GetAddressOf());
    ctx->PSSetConstantBuffers(0, 1, g_frameCB.GetAddressOf());
    ctx->DrawInstanced(4, 1, 0, 0);           // 평면 한 장

    // ② 근경 blade(하이브리드) — 옵션
    if (lodNearBlades) {
        UINT strides[2] = { sizeof(BladeVertex), sizeof(GrassInstance) };
        UINT offsets[2] = { 0, 0 };
        ID3D11Buffer* vbs[2] = { g_bladeVB.Get(), g_nearVB.Get() };
        ctx->IASetInputLayout(g_bladeLayout.Get());
        ctx->IASetVertexBuffers(0, 2, vbs, strides, offsets);
        ctx->VSSetShader(g_bladeVS.Get(), nullptr, 0);
        ctx->PSSetShader(g_bladePS.Get(), nullptr, 0);
        ctx->DrawInstanced(g_bladeVerts, g_nearCount, 0, 0);   // 근경만 소량
    }
}
LOD 스택의 마지막 단계 실제 게임은 거리에 따라 기법을 갈아 끼웁니다. 가장 가까운 곳은 지오메트리 blade (최고 품질), 중거리는 빌보드, 그리고 원경은 이 지형 텍스처(최저 비용)입니다. 화면 면적의 대부분을 차지하는 원경을 가장 싼 기법으로 덮고, 비싼 지오메트리는 눈앞의 좁은 영역에만 쓰는 것 — 이것이 잔디 LOD 스택의 핵심 아이디어입니다. 거리 밴드 전환과 페이드는 7장 LOD·컬링의 원리를 그대로 적용합니다.

정리