← 개념 · 데모 가이드 | 구현 허브 | 8장. 잔디 렌더링 | 지오메트리 잔디
IMPLEMENTATION · CHAPTER 8 · 1/10

지오메트리 잔디 — GPU 인스턴싱 + 버텍스 바람

가장 정직한 방법입니다. 잔디 한 포기(blade)를 진짜 삼각형 스트립 메시로 만들고, 똑같은 그 메시를 위치·키·방향만 바꿔 수만 개 인스턴싱드로우콜 한 번으로 그립니다. 바람은 텍스처를 읽지 않고 버텍스 셰이더에서 sin으로 계산해 픽셀 부하 없이 흔듭니다. 품질이 가장 높지만 정점 수가 곧 비용이라 근거리에 적합합니다. 이 페이지에서 blade 메시 구성부터 인스턴스 정점 버퍼, 입력 레이아웃의 per-instance 분류, 바람 수식까지 성분 하나까지 풀어봅니다.

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

▶ 실시간 데모

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

초기화 중…
💡 개수를 12만까지 올려도 드로우콜은 1. 거리 컬링을 낮추면 먼 잔디를 안 그림(=GPU 컬링 흉내).

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

  1. blade 메시 — 잔디 한 포기를 (좌/우, 높이비율) 정점 쌍의 삼각형 스트립으로 만듭니다.
  2. 인스턴스 데이터 — 포기마다 위치·방향·키·위상을 담은 두 번째 정점 버퍼를 만듭니다.
  3. 입력 레이아웃 — 정점당 데이터와 인스턴스당 데이터를 두 슬롯으로 나눠 GPU에 알려줍니다.
  4. 버텍스 바람 — 뿌리는 고정, 끝으로 갈수록 크게 흔들리도록 가중 sin을 더합니다.
  5. 단일 드로우콜DrawInstanced 한 번으로 수만 포기를 그리고, 거리 컬링으로 개수를 줄입니다.

1. blade 한 포기 = 삼각형 스트립

잔디 한 포기는 아래가 넓고 위가 뾰족한 길쭉한 삼각형 띠입니다. 이걸 삼각형 스트립으로 만들면 정점이 최소로 듭니다. 세로로 SEG단을 나누고, 각 단마다 좌·우 두 정점을 찍습니다. 정점에는 실제 위치가 아니라 “좌(-1)냐 우(+1)냐”“밑(0)에서 끝(1)까지의 높이비율 t” 두 값만 넣습니다. 실제 3D 위치는 버텍스 셰이더에서 인스턴스 정보와 합쳐 계산합니다. 이렇게 하면 메시는 딱 하나고, 포기마다 다른 건 전부 인스턴스 데이터로 빠집니다.

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

// 정점당 데이터: (side, t)
//  side = -1(왼쪽) / +1(오른쪽),  t = 0(뿌리) .. 1(끝)
struct BladeVertex { float side; float t; };

// blade 한 포기의 삼각형 스트립 정점을 만든다.
// SEG단이면 정점은 (SEG+1)*2개. 좌·우를 번갈아 찍으면 스트립이 지그재그로 채워진다.
std::vector<BladeVertex> makeBlade(int SEG) {
    std::vector<BladeVertex> v;
    v.reserve((SEG + 1) * 2);
    for (int i = 0; i <= SEG; ++i) {
        float t = (float)i / SEG;
        v.push_back({ -1.0f, t });   // 왼쪽
        v.push_back({ +1.0f, t });   // 오른쪽
    }
    return v;                        // 그릴 때 TRIANGLESTRIP 토폴로지로 그린다
}
왜 스트립인가 삼각형 리스트라면 단마다 삼각형 2개 × 정점 3개가 필요하지만, 스트립은 새 정점 하나가 이전 두 정점과 자동으로 삼각형을 이룹니다. SEG=6이면 정점 14개로 blade 하나 완성 — 인스턴싱으로 수만 번 재생될 메시라 정점 하나가 아깝습니다.

2. 포기마다 다른 것 = 인스턴스 데이터

이제 “어디에, 어느 방향으로, 얼마나 크게, 언제 흔들릴지”를 포기마다 정합니다. 이 값들은 정점마다가 아니라 포기(인스턴스)마다 하나씩 있으면 됩니다. 그래서 두 번째 정점 버퍼에 인스턴스당 한 개씩 담고, GPU에게 “이건 정점이 바뀔 때가 아니라 인스턴스가 바뀔 때 넘어가는 데이터”라고 알려줄 겁니다(3절).

필드는 결정론적 난수로 원판 안에 흩뿌립니다. 같은 시드면 늘 같은 배치라 프레임마다 안 흔들립니다. 극좌표에서 반지름을 sqrt(rand)로 뽑는 이유는, 그냥 rand를 쓰면 중심에 몰리기 때문입니다 (면적은 반지름 제곱에 비례하므로 균일 분포를 원하면 제곱근을 취해야 합니다).

grass_geometry.hpp (이어서)cpp
// 인스턴스당 데이터 (6 float = 24바이트). HLSL 입력과 바이트 배치를 맞춘다.
//  posXZ : 지면 위 위치(x, z)
//  angle : blade가 향한 방위각
//  height: 키
//  phase : 바람 sin의 위상(포기마다 다르게 흔들리도록)
//  colVar: 색 변주
struct GrassInstance {
    float posX, posZ;   // offset  0 : float4 앞 2개
    float angle;        // offset  8
    float height;       // offset 12
    float phase;        // offset 16 : float2 앞
    float colVar;       // offset 20
};
static_assert(sizeof(GrassInstance) == 24, "stride 24로 맞춰야 입력 레이아웃과 일치");

std::vector<GrassInstance> scatterField(int count, float radius, uint32_t seed) {
    // 해시 기반 결정론적 난수 (WebGL 데모의 field()와 동일한 감각)
    float s = (float)seed;
    auto rnd = [&]() {
        s = std::sin(s * 12.9898f + 78.233f) * 43758.5453f;
        return s - std::floor(s);               // 0..1
    };
    std::vector<GrassInstance> out(count);
    for (int i = 0; i < count; ++i) {
        float a = rnd() * 6.2831853f;            // 방위
        float r = std::sqrt(rnd()) * radius;     // 균일 분포용 sqrt
        out[i].posX   = std::cos(a) * r;
        out[i].posZ   = std::sin(a) * r;
        out[i].angle  = rnd() * 6.2831853f;
        out[i].height = 0.8f + rnd() * 0.9f;     // 0.8 .. 1.7
        out[i].phase  = rnd() * 6.2831853f;
        out[i].colVar = rnd();
    }
    return out;
}

두 버퍼(정점·인스턴스)를 만드는 코드입니다. 둘 다 IMMUTABLE로 한 번만 올립니다.

버퍼 생성cpp
ComPtr<ID3D11Buffer> g_bladeVB;    // 슬롯 0: 정점당
ComPtr<ID3D11Buffer> g_instVB;     // 슬롯 1: 인스턴스당
UINT g_bladeVertexCount = 0;
UINT g_instanceCount = 0;

void createGrassBuffers(ID3D11Device* dev) {
    auto blade = makeBlade(6);                 // SEG=6 → 14정점
    auto field = scatterField(120000, 30.0f, 1234);  // 최대 12만 포기
    g_bladeVertexCount = (UINT)blade.size();
    g_instanceCount    = (UINT)field.size();

    auto makeVB = [&](const void* data, UINT bytes, ComPtr<ID3D11Buffer>& out) {
        D3D11_BUFFER_DESC bd = {};
        bd.Usage = D3D11_USAGE_IMMUTABLE;
        bd.ByteWidth = bytes;
        bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
        D3D11_SUBRESOURCE_DATA sd = { data, 0, 0 };
        dev->CreateBuffer(&bd, &sd, out.GetAddressOf());
    };
    makeVB(blade.data(), (UINT)(blade.size() * sizeof(BladeVertex)), g_bladeVB);
    makeVB(field.data(), (UINT)(field.size() * sizeof(GrassInstance)), g_instVB);
}

3. 입력 레이아웃 — 정점당 vs 인스턴스당

핵심은 D3D11_INPUT_ELEMENT_DESC두 필드입니다. InputSlot은 “이 데이터가 몇 번 정점 버퍼에서 오는가”(0=blade, 1=인스턴스)이고, InputSlotClassPER_VERTEX_DATAPER_INSTANCE_DATA냐를 가릅니다. per-instance로 지정하고 InstanceDataStepRate=1로 두면 인스턴스가 하나 넘어갈 때마다 포인터가 24바이트 전진합니다. 정점이 바뀔 땐 그대로죠. 이게 인스턴싱의 전부입니다.

입력 레이아웃cpp
D3D11_INPUT_ELEMENT_DESC layout[] = {
    // ── 슬롯 0: blade 정점당 (side, t) ──
    { "BLADE",    0, DXGI_FORMAT_R32G32_FLOAT,       0, 0,
      D3D11_INPUT_PER_VERTEX_DATA,   0 },
    // ── 슬롯 1: 인스턴스당 ──
    // posXZ + angle + height 를 float4 하나로 (offset 0)
    { "INSTA",    0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 0,
      D3D11_INPUT_PER_INSTANCE_DATA, 1 },     // StepRate=1
    // phase + colVar 를 float2 로 (offset 16)
    { "INSTB",    0, DXGI_FORMAT_R32G32_FLOAT,       1, 16,
      D3D11_INPUT_PER_INSTANCE_DATA, 1 },
};
dev->CreateInputLayout(layout, 3, vsBlob->GetBufferPointer(),
                       vsBlob->GetBufferSize(), g_layout.GetAddressOf());
함정 per-instance 원소의 AlignedByteOffset같은 슬롯 안에서의 바이트 위치입니다. INSTA는 0, INSTB는 16. 이 숫자가 GrassInstance 구조체의 실제 오프셋과 어긋나면 잔디가 엉뚱한 곳에 서거나 색이 깨집니다. static_assert(sizeof==24)로 못 박아 둔 이유입니다.

4. 버텍스 셰이더 — blade를 세우고 바람을 넣는다

이제 정점당 (side, t)와 인스턴스당 (pos, angle, height, phase, colVar)를 받아 실제 월드 위치를 만듭니다. 폭은 (1-t)*0.06으로 위로 갈수록 좁혀 뾰족하게, 높이는 t*height로 세웁니다. 방위각 angle로 좌우 오프셋을 회전시켜 포기마다 방향을 다르게 합니다.

바람은 가중이 포인트입니다. 뿌리(t=0)는 흔들리면 안 되고 끝으로 갈수록 크게 휘어야 자연스럽습니다. 서로 다른 두 주파수의 sin을 섞고 phase로 포기마다 어긋나게 해 “밭 전체가 한 박자로 출렁이는” 인공적인 느낌을 없앱니다.

grass_vs.hlslhlsl
cbuffer Frame : register(b0) {
    row_major float4x4 uVP;   // view * proj (행벡터 관례)
    float uTime;
    float uWind;              // 바람 세기
    float2 _pad;
};

struct VSIn {
    float2 blade : BLADE0;    // (side, t)
    float4 ia    : INSTA0;    // (posX, posZ, angle, height)
    float2 ib    : INSTB0;    // (phase, colVar)
};
struct VSOut {
    float4 pos : SV_Position;
    float3 col : COLOR0;
    float  t   : TEXCOORD0;
};

VSOut main(VSIn i) {
    float side = i.blade.x;
    float t    = i.blade.y;
    float2 root = i.ia.xy;
    float  ang  = i.ia.z;
    float  h    = i.ia.w;
    float  phase = i.ib.x;
    float  colVar = i.ib.y;

    float w = (1.0 - t) * 0.06;              // 위로 갈수록 좁아짐

    // 두 주파수 sin을 섞고, 끝(t²)일수록 크게 흔든다
    float sway = (sin(uTime * 1.6 + phase + root.x * 0.3)
                + 0.4 * sin(uTime * 3.1 + phase * 1.7)) * 0.5 * uWind * t * t;

    float3 dir = float3(cos(ang), 0.0, sin(ang));
    float3 p = float3(root.x, 0.0, root.y);
    p += dir * side * w;                      // 좌/우 폭
    p.y += t * h;                             // 세우기
    p.x += sway * 0.9;                        // 바람으로 휘기
    p.z += sway * 0.5;

    // 뿌리는 어둡고 끝은 밝게 + 포기별 색 변주
    float3 c = lerp(float3(0.09, 0.22, 0.08), float3(0.35, 0.62, 0.20), t)
             * lerp(0.8, 1.15, colVar);
    VSOut o;
    o.col = c * lerp(0.4, 1.0, t);           // 아래쪽 AO 느낌
    o.t   = t;
    o.pos = mul(float4(p, 1.0), uVP);        // 행벡터 → mul(v, M)
    return o;
}
grass_ps.hlslhlsl
struct PSIn {
    float4 pos : SV_Position;
    float3 col : COLOR0;
    float  t   : TEXCOORD0;
};

float4 main(PSIn i) : SV_Target {
    // 끝부분에 살짝 노란기를 더해 하이라이트
    float3 tip = float3(0.04, 0.07, 0.02) * pow(i.t, 3.0);
    return float4(i.col + tip, 1.0);
}
SV_Position과 mul 순서 행벡터 규약이므로 mul(v, M)입니다(열벡터 GL 자료의 M*v와 반대). cbuffer 행렬에 row_major를 붙이고 C++에서 XMMatrixTranspose 없이 그대로 올리거나, 기본 column_major를 쓸 거면 업로드 전에 전치해야 합니다. 이 시리즈는 row_major로 통일합니다.

5. 드로우 — 단일 콜 + 거리 컬링

두 정점 버퍼를 한 번에 바인딩합니다. IASetVertexBuffers배열로 넘기면 슬롯 0·1에 각각 꽂힙니다. 토폴로지는 TRIANGLESTRIP. 그리고 DrawInstanced 한 번이면 끝 — 인스턴스가 12만 개여도 드로우콜은 1입니다. 스트립은 인스턴스 경계를 넘어 이어지지 않으니(인스턴스마다 정점 범위를 새로 재생) 포기들이 붙지 않습니다.

렌더 루프cpp
void drawGrass(ID3D11DeviceContext* ctx, float wind, float cullRatio) {
    UINT strides[2] = { sizeof(BladeVertex), sizeof(GrassInstance) };
    UINT offsets[2] = { 0, 0 };
    ID3D11Buffer* vbs[2] = { g_bladeVB.Get(), g_instVB.Get() };

    ctx->IASetInputLayout(g_layout.Get());
    ctx->IASetVertexBuffers(0, 2, vbs, strides, offsets);   // 슬롯 0,1 동시
    ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
    ctx->VSSetShader(g_vs.Get(), nullptr, 0);
    ctx->PSSetShader(g_ps.Get(), nullptr, 0);
    ctx->VSSetConstantBuffers(0, 1, g_frameCB.GetAddressOf());

    // 거리 컬링: 먼 잔디는 안 그린다(여기선 개수만 줄이는 근사).
    UINT n = (UINT)(g_instanceCount * cullRatio);

    // DrawInstanced(정점수/인스턴스, 인스턴스수, 시작정점, 시작인스턴스)
    ctx->DrawInstanced(g_bladeVertexCount, n, 0, 0);
}
진짜 컬링은 GPU에서 위 예제는 “앞에서부터 n개만” 그리는 근사입니다. 실전에서는 컴퓨트 셰이더가 프러스텀·거리 테스트를 돌려 살아남은 인스턴스만 AppendStructuredBuffer에 모으고, 그 개수를 DrawInstancedIndirect로 넘깁니다(CPU가 개수를 몰라도 됨). 프러스텀 평면 추출은 7장 LOD·컬링에서 다뤘습니다. 저사양 기기는 버텍스 SSBO가 불안정하니 RenderTexture(UAV) 우회를 씁니다.
함정 깊이 테스트를 켜세요(OMSetDepthStencilState 기본 또는 depth-write ON). 잔디는 불투명이라 블렌딩이 필요 없고 Z버퍼로 앞뒤를 정리하는 게 정답입니다. 반대로 빌보드(2번 문서)는 알파 카드라 이야기가 달라집니다.

정리