← 개념 · 데모 가이드 | 구현 허브 | 8장. 잔디 렌더링 | LOD 통합 씬
IMPLEMENTATION · CHAPTER 8 · 10/10

LOD 통합 씬 — 근:지오메트리 · 중:빌보드 · 원:텍스처

8장의 마지막이자, 실제 게임이 실제로 하는 방식입니다. 앞서 따로 만든 세 기법을 한 씬에서 거리별로 동시에 씁니다. 카메라에서 가까우면 진짜 blade(지오메트리), 중간이면 빌보드 카드(빌보드), 멀면 지형 텍스처(지형 텍스처)로 자동 전환합니다. 핵심은 경계에서의 페이드입니다. 거리 구간별로 알파를 smoothstep으로 넘겨 지오→빌보드→텍스처가 겹치는 구간에서 부드럽게 교대해 팝핑(툭 튀는 전환)을 없앱니다. 이 페이지가 8장의 마지막이므로 끝에 10가지 기법 전체를 총정리합니다.

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

▶ 실시간 데모

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

초기화 중…
경계 색표시를 켜면 근(초록끼)·중(노란끼)·원(텍스처) 영역이 구분돼 보여.
💡 휠로 줌인/아웃하면 같은 잔디가 지오메트리↔빌보드↔텍스처로 바뀌는 걸 볼 수 있어.

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

  1. 거리 링 정의NEAR/FAR 두 경계로 근·중·원 세 구간을 나눕니다.
  2. 원거리 텍스처 지면 — 노이즈로 칠한 지형을 깔아 멀리서 잔디처럼 보이게 합니다.
  3. 중거리 빌보드NEAR~FAR에서만 보이도록 페이드인/아웃합니다.
  4. 근거리 지오메트리NEAR 안쪽에서만 blade를 세우고 페이드 아웃합니다.
  5. 렌더 순서 · 팝핑 방지 — 세 패스를 겹쳐 그리고 경계 페이드로 자연스럽게 잇습니다.

1. 거리 링 — 하나의 진실

모든 페이드의 기준은 딱 두 숫자, NEARFAR입니다. 카메라에서 각 잔디까지의 수평 거리 distNEAR보다 가까우면 지오메트리, NEAR~FAR이면 빌보드, FAR보다 멀면 텍스처 지면으로 처리합니다. 이 값을 C++에서 한 곳에 정의하고 세 셰이더에 cbuffer로 넘겨야 경계가 어긋나지 않습니다.

lod_scene.hppcpp
#include <DirectXMath.h>
using namespace DirectX;

// 거리 링 경계(월드 단위). 세 패스가 공유하는 유일한 진실.
constexpr float NEAR_R = 18.0f;   // 이 안쪽: 지오메트리
constexpr float FAR_R  = 34.0f;   // 이 바깥: 텍스처만

// 세 패스가 공유하는 프레임 상수
struct LodFrameCB {
    XMFLOAT4X4 vp;      // view * proj (row_major)
    XMFLOAT3   eye;     // 카메라 월드 위치(거리 계산용)
    float      time;
    float      nearR;   // = NEAR_R
    float      farR;    // = FAR_R
    int        rings;   // LOD 경계 색표시 토글(디버그)
    float      _pad;
};
static_assert(sizeof(LodFrameCB) % 16 == 0, "cbuffer는 16바이트 배수");
거리 구간별로 인스턴스를 나눈다 학습 예제는 세 패스가 같은 필드를 전부 그리되 셰이더에서 페이드로 걸러냅니다(간단함). 실전에서는 CPU/컴퓨트가 프레임마다 거리 링별로 인스턴스를 분류해, 근거리 인스턴스만 지오메트리 버퍼에, 중거리만 빌보드 버퍼에 Append합니다. 그러면 멀리 있는 blade의 버텍스를 아예 실행하지 않아 낭비가 사라집니다(7장 LOD·컬링 참고).

2. 원거리 — 노이즈 텍스처 지면

가장 멀리는 지형 텍스처입니다. 커다란 쿼드 하나에 절차적 노이즈로 잔디색을 칠해, 개별 blade 없이도 멀리서 초원처럼 보이게 합니다. 여러 옥타브의 값 노이즈를 섞어 얼룩덜룩한 톤을 만듭니다. 디버그 토글(uRings)이 켜지면 FAR 바깥 영역에 파랑끼를 더해 "여기는 원경 텍스처 구역"임을 보여줍니다.

ground_vs.hlsl / ground_ps.hlslhlsl
// ── VS: 40x40 지면 쿼드 ──
cbuffer Frame : register(b0) {
    row_major float4x4 uVP;
    float3 uEye; float uTime;
    float uNear; float uFar; int uRings; float _pad;
};
struct GVSIn  { float2 p : APOS0; };            // -1..1 쿼드
struct GVSOut { float4 pos : SV_Position; float2 v : TEXCOORD0; };

GVSOut vs_main(GVSIn i) {
    GVSOut o;
    o.v   = i.p;
    o.pos = mul(float4(i.p.x * 40.0, 0.0, i.p.y * 40.0, 1.0), uVP);
    return o;
}

// ── PS: 옥타브 노이즈로 초원 톤 ──
float h(float2 c) { return frac(sin(dot(c, float2(41.3, 289.1))) * 43758.5453); }
float noise(float2 p) {
    float2 i = floor(p), f = frac(p);
    f = f * f * (3.0 - 2.0 * f);
    return lerp(lerp(h(i),               h(i + float2(1,0)), f.x),
                lerp(h(i + float2(0,1)),  h(i + float2(1,1)), f.x), f.y);
}
float4 ps_main(GVSOut i) : SV_Target {
    float2 uv = i.v * 44.0;
    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.5, 0.18), n);
    if (uRings == 1) {                           // 디버그: 원경 구역 표시
        float r = length(i.v * 40.0);
        if (r > 34.0) c *= float3(0.8, 0.8, 1.0);
    }
    return float4(c, 1.0);
}

3. 중거리 — 빌보드 카드 (페이드 인/아웃)

빌보드는 카메라를 향하는 알파 카드입니다. 여기서는 두 개의 smoothstep으로 가시성을 만듭니다. finNEAR 근처에서 0→1로 켜지고(근경에선 지오메트리에 양보해 숨음), foutFAR 근처에서 1→0으로 꺼집니다(원경에선 텍스처에 양보). 둘을 곱한 vis = fin*fout이 곧 알파이고, 너무 작으면 discard해 오버드로우를 줄입니다.

billboard_vs.hlslhlsl
cbuffer Frame : register(b0) {
    row_major float4x4 uVP;
    float3 uEye; float uTime;
    float uNear; float uFar; int uRings; float _pad;
};
cbuffer Cam : register(b1) { float3 uRight; float _p2; };  // 빌보드 우측축

struct VSIn {
    float2 aQ : APOS0;      // 쿼드 (-0.5..0.5, 0..1)
    float4 iA : INSTA0;     // (posX, posZ, angle, height)
    float2 iC : INSTB0;     // (phase, colVar)
};
struct VSOut {
    float4 pos  : SV_Position;
    float2 uv   : TEXCOORD0;
    float  vis  : TEXCOORD1;   // 알파(페이드)
    float  shade: TEXCOORD2;
};

VSOut main(VSIn i) {
    float dist = length(i.iA.xy - uEye.xz);
    float fin  = smoothstep(uNear * 0.7, uNear, dist);       // 근경에선 숨음
    float fout = 1.0 - smoothstep(uFar * 0.9, uFar, dist);   // 원경에선 숨음
    float vis  = fin * fout;

    float h = i.iA.w * 1.5;
    float3 c = float3(i.iA.x, 0.0, i.iA.y);
    float sway = sin(uTime * 1.5 + i.iC.x) * 0.3 * i.aQ.y * i.aQ.y;
    float3 p = c + uRight * i.aQ.x * 0.9 + float3(0.0, i.aQ.y * h, 0.0);
    p.x += sway;

    VSOut o;
    o.uv    = float2(i.aQ.x + 0.5, 1.0 - i.aQ.y);
    o.vis   = vis;
    o.shade = lerp(0.5, 1.0, i.aQ.y);
    o.pos   = mul(float4(p, 1.0), uVP);
    return o;
}
billboard_ps.hlslhlsl
Texture2D    uTex : register(t0);
SamplerState uSmp : register(s0);
cbuffer Frame : register(b0) {
    row_major float4x4 uVP;
    float3 uEye; float uTime;
    float uNear; float uFar; int uRings; float _pad;
};

struct PSIn {
    float4 pos  : SV_Position;
    float2 uv   : TEXCOORD0;
    float  vis  : TEXCOORD1;
    float  shade: TEXCOORD2;
};

float4 main(PSIn i) : SV_Target {
    if (i.vis < 0.02) discard;              // 링 밖: 아예 안 그림
    float4 t = uTex.Sample(uSmp, i.uv);
    if (t.a < 0.3) discard;                 // 알파 테스트(마스크)
    float3 c = t.rgb * i.shade;
    if (uRings == 1) c *= float3(1.2, 1.1, 0.7);  // 디버그: 중경 노랑끼
    return float4(c, 1.0);
}
함정 타일드 모바일 GPU에서 알파 블렌드는 오버드로우로 프레임을 죽입니다. 위처럼 discard 기반 알파 테스트(마스크)를 쓰고 OMSetBlendState는 끄세요(불투명 취급). 페이드는 블렌딩이 아니라 discard 문턱을 넘나드는 방식으로 근사하거나, 부드러움이 꼭 필요하면 디더(dither) 알파로 처리합니다.

4. 근거리 — 지오메트리 blade (페이드 아웃)

가장 가까이는 진짜 blade입니다. 각 blade가 카메라까지 거리를 재고 fade = 1 - smoothstep(NEAR*0.7, NEAR, dist)NEAR를 넘어가면 사라지게 합니다. 페이드가 0에 가까워지면 키를 줄여(p.y += t*h*fade) 지면으로 가라앉히고, 완전히 0이면 픽셀에서 discard합니다. 이렇게 하면 blade가 툭 사라지는 대신 땅속으로 스르르 들어가 팝핑이 눈에 안 띕니다.

geo_vs.hlslhlsl
cbuffer Frame : register(b0) {
    row_major float4x4 uVP;
    float3 uEye; float uTime;
    float uNear; float uFar; int uRings; float _pad;
};

struct VSIn {
    float2 blade : BLADE0;   // (side, t)
    float4 iA    : INSTA0;   // (posX, posZ, angle, height)
    float2 iC    : INSTB0;   // (phase, colVar)
};
struct VSOut {
    float4 pos : SV_Position;
    float3 col : COLOR0;
    float  t   : TEXCOORD0;
    float  kill: TEXCOORD1;  // 페이드(0이면 버림)
};

VSOut main(VSIn i) {
    float t = i.blade.y, side = i.blade.x;
    float h = i.iA.w, ang = i.iA.z;
    float w = (1.0 - t) * 0.06;

    float dist = length(i.iA.xy - uEye.xz);
    float fade = 1.0 - smoothstep(uNear * 0.7, uNear, dist);  // 근거리만

    float3 dir = float3(cos(ang), 0.0, sin(ang));
    float3 p = float3(i.iA.x, 0.0, i.iA.y);
    float sway = sin(uTime * 1.6 + i.iC.x) * 0.3 * t * t;
    p += dir * side * w;
    p.y += t * h * fade;     // 페이드가 줄면 키가 줄어 땅속으로 가라앉음
    p.x += sway;

    float3 c = lerp(float3(0.09, 0.22, 0.08), float3(0.35, 0.62, 0.20), t)
             * lerp(0.4, 1.0, t);
    if (uRings == 1) c *= float3(0.8, 1.15, 0.8);   // 디버그: 근경 초록끼

    VSOut o;
    o.col  = c;
    o.t    = t;
    o.kill = fade;
    o.pos  = mul(float4(p, 1.0), uVP);
    return o;
}
geo_ps.hlslhlsl
struct PSIn {
    float4 pos : SV_Position;
    float3 col : COLOR0;
    float  t   : TEXCOORD0;
    float  kill: TEXCOORD1;
};

float4 main(PSIn i) : SV_Target {
    if (i.kill < 0.02) discard;      // NEAR 밖의 blade는 버림
    return float4(i.col, 1.0);
}

5. 렌더 순서 — 세 패스를 겹쳐 그린다

한 프레임에서 원경 지면 → 중경 빌보드 → 근경 지오메트리 순으로 그립니다. 셋 다 불투명 취급(깊이 테스트 ON, 블렌드 OFF)이라 순서에 민감하지 않지만, 지면을 먼저 깔면 그 위에 빌보드·blade가 자연스럽게 얹힙니다. 카메라 위치 eye와 빌보드용 right 벡터를 매 프레임 갱신해 세 패스에 넘깁니다. 각 패스는 자기 인스턴스 버퍼로 DrawInstanced 한 번씩입니다.

렌더 루프cpp
void drawLodScene(ID3D11DeviceContext* ctx, const XMMATRIX& viewProj,
                  const XMFLOAT3& eye, float time, bool ringsDebug) {
    // 공유 프레임 cbuffer
    LodFrameCB cb;
    XMStoreFloat4x4(&cb.vp, viewProj);   // row_major, 전치 없음
    cb.eye = eye; cb.time = time;
    cb.nearR = NEAR_R; cb.farR = FAR_R;
    cb.rings = ringsDebug ? 1 : 0; cb._pad = 0;
    ctx->UpdateSubresource(g_frameCB.Get(), 0, nullptr, &cb, 0, 0);

    ctx->OMSetDepthStencilState(g_depthOn.Get(), 0);
    ctx->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF);   // 블렌드 OFF(불투명)
    ID3D11Buffer* frameCB = g_frameCB.Get();
    ctx->VSSetConstantBuffers(0, 1, &frameCB);
    ctx->PSSetConstantBuffers(0, 1, &frameCB);

    // ── ① 원경: 텍스처 지면 ──
    ctx->IASetInputLayout(g_groundLayout.Get());
    UINT gs = sizeof(float) * 2, go = 0;
    ID3D11Buffer* gvb = g_groundVB.Get();
    ctx->IASetVertexBuffers(0, 1, &gvb, &gs, &go);
    ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
    ctx->VSSetShader(g_groundVS.Get(), nullptr, 0);
    ctx->PSSetShader(g_groundPS.Get(), nullptr, 0);
    ctx->Draw(4, 0);

    // ── ② 중경: 빌보드 카드 ──
    XMFLOAT3 right = computeBillboardRight(eye);   // 카메라 우측축
    updateCamCB(ctx, g_camCB.Get(), right);
    ctx->VSSetConstantBuffers(1, 1, g_camCB.GetAddressOf());
    UINT bStr[2] = { sizeof(float) * 2, sizeof(GrassInstance) };
    UINT bOff[2] = { 0, 0 };
    ID3D11Buffer* bvbs[2] = { g_quadVB.Get(), g_instVB.Get() };
    ctx->IASetInputLayout(g_billLayout.Get());
    ctx->IASetVertexBuffers(0, 2, bvbs, bStr, bOff);
    ctx->VSSetShader(g_billVS.Get(), nullptr, 0);
    ctx->PSSetShader(g_billPS.Get(), nullptr, 0);
    ctx->PSSetShaderResources(0, 1, g_cardSRV.GetAddressOf());
    ctx->PSSetSamplers(0, 1, g_samp.GetAddressOf());
    ctx->DrawInstanced(4, g_billCount, 0, 0);

    // ── ③ 근경: 지오메트리 blade ──
    UINT nStr[2] = { sizeof(BladeVertex), sizeof(GrassInstance) };
    UINT nOff[2] = { 0, 0 };
    ID3D11Buffer* nvbs[2] = { g_bladeVB.Get(), g_instVB.Get() };
    ctx->IASetInputLayout(g_geoLayout.Get());
    ctx->IASetVertexBuffers(0, 2, nvbs, nStr, nOff);
    ctx->VSSetShader(g_geoVS.Get(), nullptr, 0);
    ctx->PSSetShader(g_geoPS.Get(), nullptr, 0);
    ctx->DrawInstanced(g_bladeVertexCount, g_geoCount, 0, 0);
}
함정 — 팝핑 세 LOD의 페이드 구간이 겹치도록 경계를 잡아야 합니다. 빌보드의 fin이 지오메트리의 fade가 끝나는 지점과 만나야 전환이 매끄럽습니다. 구간을 딱 붙여 (겹침 0) 두면 한 프레임에 한 LOD도 안 그려지는 빈틈이 생겨 오히려 더 심한 팝핑이 됩니다. smoothstep의 시작/끝을 NEAR*0.7~NEAR처럼 여유 구간을 두는 이유입니다.

6. 10가지 잔디 렌더링 기법 총정리

8장에서 다룬 열 가지 기법을 한눈에 회고합니다. 실제 게임은 이들을 배타적으로 고르는 게 아니라, 이 페이지처럼 거리·플랫폼·아트 방향에 맞춰 조합합니다.

#기법한 줄 요약적합한 거리 / 상황
1지오메트리 인스턴싱진짜 blade 메시를 수만 개 인스턴싱, 버텍스 sin 바람근거리 · 최고 품질
2빌보드 / 크로스쿼드카메라 향하는 알파 카드, 알파 테스트로 오버드로우 억제중거리 · 밀도 확보
3셸 텍스처링여러 층을 쌓아 털처럼, 층마다 알파 컷짧은 잔디 · 카펫
4지형 텍스처지면에 잔디색을 직접 칠해 blade 없이 초원원거리 · 배경
5레이마칭 잔디픽셀에서 볼륨을 행진해 잔디 밀도를 적분스타일 · 셰이더 아트
6밟힘 / 인터랙션플레이어 접촉으로 잔디를 눕히는 상호작용 맵게임플레이 피드백
7베지어 곡선 blade제어점 곡선으로 자연스러운 휘어짐근거리 · 고급 실루엣
8툰 / 스타일라이즈드밝기를 밴딩해 만화 톤, 넓은 잎스타일 게임 · 모바일
9통합 바람 그리드바람장 + 이동 vorticle로 밭 전체가 함께 눕음대규모 야외 · 분위기
10LOD 통합 씬 (이 페이지)근:지오 · 중:빌보드 · 원:텍스처를 거리 페이드로 결합실전 프로덕션
모바일 프로덕션 체크리스트GPU 인스턴싱은 필수(1 드로우콜). ② 컬링을 GPU로(컴퓨트 프러스텀·거리, 저사양은 RenderTexture(UAV) 우회). ③ LOD 스택으로 근·중·원을 나눠 오버드로우 억제. ④ 바람은 버텍스에서. ⑤ 알파는 블렌드 대신 마스크(테스트) + 타이트한 카드 메시.

정리