LOD 통합 씬 — 근:지오메트리 · 중:빌보드 · 원:텍스처
8장의 마지막이자, 실제 게임이 실제로 하는 방식입니다. 앞서 따로 만든 세 기법을 한 씬에서
거리별로 동시에 씁니다. 카메라에서 가까우면 진짜 blade(지오메트리),
중간이면 빌보드 카드(빌보드),
멀면 지형 텍스처(지형 텍스처)로 자동 전환합니다.
핵심은 경계에서의 페이드입니다. 거리 구간별로 알파를 smoothstep으로 넘겨
지오→빌보드→텍스처가 겹치는 구간에서 부드럽게 교대해 팝핑(툭 튀는 전환)을 없앱니다.
이 페이지가 8장의 마지막이므로 끝에 10가지 기법 전체를 총정리합니다.
v' = v * M(HLSL mul(v, M)), 행렬 행 우선 저장,
왼손 좌표계, 클립 z 범위 [0, 1]. 뷰-투영은 DirectXMath 관례대로
view * proj 순서로 곱합니다. 아래 코드는 원리 확인용 학습 예제입니다.
▶ 실시간 데모
드래그로 회전, 휠로 줌. 아래에서 설명하는 코드가 실제로 이렇게 동작합니다. (WebGL2)
이 페이지에서 만들 것 (큰 그림)
- 거리 링 정의 —
NEAR/FAR두 경계로 근·중·원 세 구간을 나눕니다. - 원거리 텍스처 지면 — 노이즈로 칠한 지형을 깔아 멀리서 잔디처럼 보이게 합니다.
- 중거리 빌보드 —
NEAR~FAR에서만 보이도록 페이드인/아웃합니다. - 근거리 지오메트리 —
NEAR안쪽에서만 blade를 세우고 페이드 아웃합니다. - 렌더 순서 · 팝핑 방지 — 세 패스를 겹쳐 그리고 경계 페이드로 자연스럽게 잇습니다.
1. 거리 링 — 하나의 진실
모든 페이드의 기준은 딱 두 숫자, NEAR와 FAR입니다.
카메라에서 각 잔디까지의 수평 거리 dist가 NEAR보다 가까우면 지오메트리,
NEAR~FAR이면 빌보드, FAR보다 멀면 텍스처 지면으로 처리합니다.
이 값을 C++에서 한 곳에 정의하고 세 셰이더에 cbuffer로 넘겨야 경계가 어긋나지 않습니다.
#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바이트 배수");
Append합니다. 그러면 멀리 있는 blade의
버텍스를 아예 실행하지 않아 낭비가 사라집니다(7장 LOD·컬링 참고).
2. 원거리 — 노이즈 텍스처 지면
가장 멀리는 지형 텍스처입니다. 커다란 쿼드 하나에 절차적 노이즈로
잔디색을 칠해, 개별 blade 없이도 멀리서 초원처럼 보이게 합니다. 여러 옥타브의 값 노이즈를
섞어 얼룩덜룩한 톤을 만듭니다. 디버그 토글(uRings)이 켜지면 FAR 바깥 영역에
파랑끼를 더해 "여기는 원경 텍스처 구역"임을 보여줍니다.
// ── 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으로 가시성을 만듭니다. fin은 NEAR 근처에서
0→1로 켜지고(근경에선 지오메트리에 양보해 숨음), fout은 FAR 근처에서 1→0으로
꺼집니다(원경에선 텍스처에 양보). 둘을 곱한 vis = fin*fout이 곧 알파이고, 너무 작으면
discard해 오버드로우를 줄입니다.
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;
}
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);
}
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가 툭 사라지는 대신 땅속으로 스르르 들어가 팝핑이 눈에 안 띕니다.
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;
}
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 한 번씩입니다.
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);
}
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로 밭 전체가 함께 눕음 | 대규모 야외 · 분위기 |
| 10 | LOD 통합 씬 (이 페이지) | 근:지오 · 중:빌보드 · 원:텍스처를 거리 페이드로 결합 | 실전 프로덕션 |
정리
- 모든 페이드의 기준은
NEAR/FAR두 숫자 — cbuffer로 세 패스에 공유. - 원경 텍스처 지면 → 중경 빌보드(페이드 인/아웃) → 근경 지오메트리(페이드 아웃) 순으로 그림.
- 근거리 blade는 페이드 시 키를 줄여 땅속으로 가라앉혀 팝핑을 숨김.
- LOD 페이드 구간은 서로 겹치게 잡아야 빈틈 없는 전환.
- 실전은 세 기법을 거리 링별 인스턴스 분류 + Indirect로 나눠 낭비 제거.
- 10가지 기법은 배타적 선택이 아니라 조합 — 거리·플랫폼·아트에 맞춰 섞는다.