지오메트리 잔디 — GPU 인스턴싱 + 버텍스 바람
가장 정직한 방법입니다. 잔디 한 포기(blade)를 진짜 삼각형 스트립 메시로 만들고,
똑같은 그 메시를 위치·키·방향만 바꿔 수만 개 인스턴싱해 드로우콜 한 번으로 그립니다.
바람은 텍스처를 읽지 않고 버텍스 셰이더에서 sin으로 계산해 픽셀 부하 없이 흔듭니다.
품질이 가장 높지만 정점 수가 곧 비용이라 근거리에 적합합니다. 이 페이지에서 blade 메시 구성부터
인스턴스 정점 버퍼, 입력 레이아웃의 per-instance 분류, 바람 수식까지 성분 하나까지 풀어봅니다.
v' = v * M(HLSL mul(v, M)), 행렬 행 우선 저장,
왼손 좌표계, 클립 z 범위 [0, 1]. 뷰-투영은 DirectXMath 관례대로
view * proj 순서로 곱합니다. 아래 코드는 원리 확인용 학습 예제입니다.
▶ 실시간 데모
드래그로 회전, 휠로 줌. 아래에서 설명하는 코드가 실제로 이렇게 동작합니다. (WebGL2)
이 페이지에서 만들 것 (큰 그림)
- blade 메시 — 잔디 한 포기를 (좌/우, 높이비율) 정점 쌍의 삼각형 스트립으로 만듭니다.
- 인스턴스 데이터 — 포기마다 위치·방향·키·위상을 담은 두 번째 정점 버퍼를 만듭니다.
- 입력 레이아웃 — 정점당 데이터와 인스턴스당 데이터를 두 슬롯으로 나눠 GPU에 알려줍니다.
- 버텍스 바람 — 뿌리는 고정, 끝으로 갈수록 크게 흔들리도록
t²가중sin을 더합니다. - 단일 드로우콜 —
DrawInstanced한 번으로 수만 포기를 그리고, 거리 컬링으로 개수를 줄입니다.
1. blade 한 포기 = 삼각형 스트립
잔디 한 포기는 아래가 넓고 위가 뾰족한 길쭉한 삼각형 띠입니다. 이걸 삼각형 스트립으로 만들면
정점이 최소로 듭니다. 세로로 SEG단을 나누고, 각 단마다 좌·우 두 정점을 찍습니다.
정점에는 실제 위치가 아니라 “좌(-1)냐 우(+1)냐”와 “밑(0)에서 끝(1)까지의 높이비율 t”
두 값만 넣습니다. 실제 3D 위치는 버텍스 셰이더에서 인스턴스 정보와 합쳐 계산합니다. 이렇게 하면 메시는 딱 하나고,
포기마다 다른 건 전부 인스턴스 데이터로 빠집니다.
#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 토폴로지로 그린다
}
SEG=6이면 정점 14개로 blade 하나 완성 — 인스턴싱으로 수만 번 재생될
메시라 정점 하나가 아깝습니다.
2. 포기마다 다른 것 = 인스턴스 데이터
이제 “어디에, 어느 방향으로, 얼마나 크게, 언제 흔들릴지”를 포기마다 정합니다. 이 값들은 정점마다가 아니라 포기(인스턴스)마다 하나씩 있으면 됩니다. 그래서 두 번째 정점 버퍼에 인스턴스당 한 개씩 담고, GPU에게 “이건 정점이 바뀔 때가 아니라 인스턴스가 바뀔 때 넘어가는 데이터”라고 알려줄 겁니다(3절).
필드는 결정론적 난수로 원판 안에 흩뿌립니다. 같은 시드면 늘 같은 배치라 프레임마다 안 흔들립니다.
극좌표에서 반지름을 sqrt(rand)로 뽑는 이유는, 그냥 rand를 쓰면 중심에 몰리기 때문입니다
(면적은 반지름 제곱에 비례하므로 균일 분포를 원하면 제곱근을 취해야 합니다).
// 인스턴스당 데이터 (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로 한 번만 올립니다.
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=인스턴스)이고,
InputSlotClass가 PER_VERTEX_DATA냐 PER_INSTANCE_DATA냐를 가릅니다.
per-instance로 지정하고 InstanceDataStepRate=1로 두면 인스턴스가 하나 넘어갈 때마다
포인터가 24바이트 전진합니다. 정점이 바뀔 땐 그대로죠. 이게 인스턴싱의 전부입니다.
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());
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² 가중이 포인트입니다. 뿌리(t=0)는 흔들리면 안 되고 끝으로 갈수록 크게
휘어야 자연스럽습니다. 서로 다른 두 주파수의 sin을 섞고 phase로 포기마다 어긋나게 해
“밭 전체가 한 박자로 출렁이는” 인공적인 느낌을 없앱니다.
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;
}
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);
}
mul(v, M)입니다(열벡터 GL 자료의 M*v와 반대). cbuffer 행렬에
row_major를 붙이고 C++에서 XMMatrixTranspose 없이 그대로 올리거나, 기본
column_major를 쓸 거면 업로드 전에 전치해야 합니다. 이 시리즈는 row_major로 통일합니다.
5. 드로우 — 단일 콜 + 거리 컬링
두 정점 버퍼를 한 번에 바인딩합니다. IASetVertexBuffers에 배열로 넘기면 슬롯 0·1에
각각 꽂힙니다. 토폴로지는 TRIANGLESTRIP. 그리고 DrawInstanced 한 번이면 끝 — 인스턴스가
12만 개여도 드로우콜은 1입니다. 스트립은 인스턴스 경계를 넘어 이어지지 않으니(인스턴스마다 정점
범위를 새로 재생) 포기들이 붙지 않습니다.
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);
}
AppendStructuredBuffer에 모으고, 그 개수를
DrawInstancedIndirect로 넘깁니다(CPU가 개수를 몰라도 됨). 프러스텀 평면 추출은
7장 LOD·컬링에서 다뤘습니다. 저사양 기기는 버텍스 SSBO가 불안정하니
RenderTexture(UAV) 우회를 씁니다.
OMSetDepthStencilState 기본 또는 depth-write ON). 잔디는 불투명이라
블렌딩이 필요 없고 Z버퍼로 앞뒤를 정리하는 게 정답입니다. 반대로 빌보드(2번 문서)는
알파 카드라 이야기가 달라집니다.
정리
- 메시는 하나(blade 스트립), 포기마다 다른 건 전부 인스턴스 버퍼로.
- 입력 레이아웃의
PER_INSTANCE_DATA+ StepRate 1이 인스턴싱의 심장. - 바람은 버텍스에서
t²·sin— 픽셀 부하 0, 뿌리 고정. - 불투명이므로 Z버퍼 ON, 대량 컬링은 컴퓨트 + Indirect로.