빌보드 / 크로스쿼드 — 카메라를 향하는 텍스처 카드
잔디 한 포기를 삼각형 스트립 메시로 만들었던 지오메트리 방식은 정점이 곧 비용이었습니다.
빌보드는 반대로 갑니다. 잔디 다발을 쿼드 한 장(4정점)에 텍스처로 압축해 정점을 극단적으로 줄이고,
대신 카메라를 향해 회전(빌보드)시켜 언제나 정면으로 보이게 합니다.
정점은 적지만 알파 카드가 서로 겹치는 오버드로우가 모바일에서 진짜 비용이 됩니다.
이 페이지에서는 실린드리컬 빌보드 수식, 절차적 카드 텍스처 생성, 알파 테스트(clip), 그리고 오버드로우를
눈으로 확인하는 히트맵까지 D3D11로 구현합니다.
v' = v * M(HLSL mul(v, M)), 행렬 행 우선 저장,
왼손 좌표계, 클립 z 범위 [0, 1]. 뷰-투영은 DirectXMath 관례대로
view * proj 순서로 곱합니다. 아래 코드는 원리 확인용 학습 예제입니다.
▶ 실시간 데모
드래그로 회전, 휠로 줌. 아래에서 설명하는 코드가 실제로 이렇게 동작합니다. (WebGL2)
이 페이지에서 만들 것 (큰 그림)
- 쿼드 메시 — 잔디 카드 한 장을 x∈[-0.5,0.5], y∈[0,1]의 4정점 스트립으로 만듭니다.
- 인스턴스 데이터 — 카드마다 위치·키·위상·색변주를 담은 두 번째 정점 버퍼를 만듭니다.
- 절차적 카드 텍스처 — CPU에서 잔디 다발 이미지를 그려
CreateTexture2D로 업로드합니다. - 실린드리컬 빌보드 — 카메라 right 벡터로 좌우만 확장하고 y는 월드업을 유지합니다.
- 알파 테스트 + 히트맵 —
clip(a-0.3)으로 오버드로우를 줄이고, additive 블렌드로 겹침을 시각화합니다.
1. 카드 = 4정점 쿼드
빌보드의 메시는 지극히 단순합니다. 폭 방향은 x ∈ [-0.5, 0.5], 높이 방향은 y ∈ [0, 1]인
정사각 쿼드 한 장이면 끝입니다. 이 두 값은 실제 3D 위치가 아니라 “카드 안에서의 정규화 좌표”일 뿐입니다.
실제 월드 위치는 버텍스 셰이더에서 인스턴스의 위치·카메라 방향과 합쳐 계산합니다. 정점을 TRIANGLESTRIP으로
그리면 4정점으로 삼각형 2개(=사각형 한 장)가 채워집니다.
#include <d3d11.h>
#include <DirectXMath.h>
#include <wrl/client.h>
#include <vector>
#include <cmath>
using Microsoft::WRL::ComPtr;
using namespace DirectX;
// 정점당 데이터: 카드 내부 정규화 좌표 (x, y)
// x = -0.5(왼쪽) .. +0.5(오른쪽), y = 0(뿌리) .. 1(끝)
struct QuadVertex { float x, y; };
// 쿼드 한 장: 4정점 삼각형 스트립. WebGL 데모의 q 배열과 동일한 순서.
std::vector<QuadVertex> makeQuad() {
return {
{ -0.5f, 0.0f }, // 좌하
{ +0.5f, 0.0f }, // 우하
{ -0.5f, 1.0f }, // 좌상
{ +0.5f, 1.0f }, // 우상
}; // TRIANGLESTRIP → 삼각형 2개
}
2. 카드마다 다른 것 = 인스턴스 데이터
카드 하나가 서 있을 위치, 키, 흔들림 위상, 색 변주를 카드(인스턴스)마다 정합니다.
지오메트리 방식과 바이트 배치를 똑같이 맞춰 두면 입력 레이아웃 코드를 그대로 재사용할 수 있습니다.
빌보드는 카메라를 향해 자동으로 회전하므로 angle(방위각)이 필요 없지만, 24바이트 정렬을 유지하려고
같은 float4 + float2 배치를 씁니다.
필드는 지오메트리 편과 동일한 결정론적 해시 난수로 원판 안에 흩뿌립니다. 반지름을
sqrt(rand)로 뽑아 중심 쏠림을 막는 것도 같습니다(면적이 반지름 제곱에 비례하므로).
// 인스턴스당 데이터 (6 float = 24바이트).
// posXZ : 지면 위 위치(x, z)
// _angle: 빌보드는 미사용(정렬 유지용). height: 키 계수
// phase : 바람 sin 위상, colVar: 색 변주
struct CardInstance {
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(CardInstance) == 24, "stride 24로 입력 레이아웃과 일치");
std::vector<CardInstance> scatterCards(int count, float radius, uint32_t seed) {
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<CardInstance> 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 = 0.0f; // 빌보드는 카메라가 회전을 담당
out[i].height = 0.8f + rnd() * 0.9f;
out[i].phase = rnd() * 6.2831853f;
out[i].colVar = rnd();
}
return out;
}
두 버퍼(쿼드·인스턴스)를 IMMUTABLE로 한 번만 올립니다.
ComPtr<ID3D11Buffer> g_quadVB; // 슬롯 0: 정점당
ComPtr<ID3D11Buffer> g_cardVB; // 슬롯 1: 인스턴스당
UINT g_cardInstances = 0;
void createCardBuffers(ID3D11Device* dev) {
auto quad = makeQuad();
auto cards = scatterCards(40000, 30.0f, 77); // 최대 4만 카드
g_cardInstances = (UINT)cards.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(quad.data(), (UINT)(quad.size() * sizeof(QuadVertex)), g_quadVB);
makeVB(cards.data(), (UINT)(cards.size() * sizeof(CardInstance)), g_cardVB);
}
3. 절차적 카드 텍스처 — CPU에서 그려 업로드
카드 한 장에는 잔디 여러 포기가 그려진 이미지를 붙입니다. WebGL 데모는 캔버스 2D로 그렸지만,
D3D11에서는 CPU 배열(RGBA8)에 직접 픽셀을 채워 CreateTexture2D로 올리면 됩니다.
각 blade는 아래가 넓고 위가 좁은 세로 스트로크로, 아래는 어둡고(#173a12) 위는 밝게(#6bbf3a)
그라디언트를 줍니다. blade가 없는 픽셀은 알파 0으로 남겨 두는 것이 핵심입니다 — 이 알파가 뒤에서
clip의 판정 기준이 됩니다.
// 128×128 RGBA8 카드 텍스처를 CPU에서 절차적으로 그린다.
// 한 카드에 blade 여러 개를 채워 화면에서 빈틈이 덜 보이게 한다.
ComPtr<ID3D11ShaderResourceView> makeGrassCardSRV(ID3D11Device* dev) {
const int N = 128;
std::vector<uint32_t> px(N * N, 0x00000000u); // 전부 투명(알파 0)
float s = 12345.0f;
auto rnd = [&]() { s = std::sin(s*12.9898f+78.233f)*43758.5453f;
return s - std::floor(s); };
// blade 16개를 세로 곡선 스트로크로 채운다.
for (int b = 0; b < 16; ++b) {
float bx = 8.0f + rnd() * 112.0f; // 밑동 x
float bw = 6.0f + rnd() * 7.0f; // 반폭
float bend = (rnd() - 0.5f) * 36.0f; // 휘어짐
for (int y = 0; y < N; ++y) {
float ty = 1.0f - (float)y / (N - 1); // 0(밑)..1(위)
float cx = bx + bend * ty; // 위로 갈수록 휘어짐
float w = bw * (1.0f - 0.6f * ty); // 위로 갈수록 좁아짐
// 아래(어두움)→위(밝음) 그라디언트
int R = (int)std::lerp(0x17, 0x6b, ty);
int G = (int)std::lerp(0x3a, 0xbf, ty);
int B = (int)std::lerp(0x12, 0x3a, ty);
for (int x = (int)(cx - w); x <= (int)(cx + w); ++x) {
if (x < 0 || x >= N) continue;
px[y*N + x] = 0xFF000000u | (B<<16) | (G<<8) | R; // ABGR
}
}
}
D3D11_TEXTURE2D_DESC td = {};
td.Width = td.Height = N;
td.MipLevels = 1; // 밉맵 없음(이유는 아래 callout)
td.ArraySize = 1;
td.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
td.SampleDesc.Count = 1;
td.Usage = D3D11_USAGE_IMMUTABLE;
td.BindFlags = D3D11_BIND_SHADER_RESOURCE;
D3D11_SUBRESOURCE_DATA sd = { px.data(), (UINT)(N * 4), 0 };
ComPtr<ID3D11Texture2D> tex;
dev->CreateTexture2D(&td, &sd, tex.GetAddressOf());
ComPtr<ID3D11ShaderResourceView> srv;
dev->CreateShaderResourceView(tex.Get(), nullptr, srv.GetAddressOf());
return srv;
}
샘플러는 LINEAR + CLAMP로, 밉맵 없이 만듭니다.
ComPtr<ID3D11SamplerState> makeCardSampler(ID3D11Device* dev) {
D3D11_SAMPLER_DESC sd = {};
sd.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; // 선형 보간
sd.AddressU = D3D11_TEXTURE_ADDRESS_CLAMP; // 가장자리 반복 금지
sd.AddressV = D3D11_TEXTURE_ADDRESS_CLAMP;
sd.MinLOD = 0; sd.MaxLOD = 0; // 밉맵 사용 안 함
ComPtr<ID3D11SamplerState> s;
dev->CreateSamplerState(&sd, s.GetAddressOf());
return s;
}
clip(a-0.3) 임계값 아래로 떨어져
카드가 통째로 사라집니다(잔디밭이 멀리서 증발). 그래서 이 텍스처는 밉맵 없이 LINEAR로 씁니다.
실전에서 밉맵이 필요하면 알파-투-커버리지나 알파 임계 보정(mip마다 임계값 스케일)을 함께 써야 합니다.
4. 버텍스 셰이더 — 실린드리컬 빌보드
빌보드의 심장은 여기입니다. 카드를 카메라 쪽으로 돌리되, 좌우로만 회전시키고 y축은 월드업을 유지하는 것이 “실린드리컬(원기둥) 빌보드”입니다. 잔디는 위로 서 있어야 자연스럽기 때문에, 완전히 카메라를 바라보는 스페리컬 빌보드는 잔디가 눕는 것처럼 보여 부적합합니다.
좌우 확장은 CPU가 넘겨주는 카메라 right 벡터로 합니다. 카드의 x(-0.5~0.5)에
uRight를 곱해 좌우로 펼치고, y는 순수하게 월드 y로 올립니다. 바람은 지오메트리 편과 같은
y² 가중 sin으로 끝만 크게 휘게 합니다.
cbuffer Frame : register(b0) {
row_major float4x4 uVP; // view * proj (행벡터 관례)
float3 uRight; // 카메라 right (월드공간, 정규화됨)
float uTime;
float uWind;
float3 _pad;
};
struct VSIn {
float2 quad : QUAD0; // 카드 내부 (x, y)
float4 ia : INSTA0; // (posX, posZ, angle, height)
float2 ib : INSTB0; // (phase, colVar)
};
struct VSOut {
float4 pos : SV_Position;
float2 uv : TEXCOORD0;
float shade : TEXCOORD1;
};
VSOut main(VSIn i) {
float2 root = i.ia.xy;
float hgt = i.ia.w * 1.6; // 카드 높이
float phase = i.ib.x;
float colVar= i.ib.y;
// 카드 밑동 월드 위치
float3 c = float3(root.x, 0.0, root.y);
// y²(=quad.y²) 가중 sway: 뿌리 고정, 끝만 크게 흔들림
float sway = (sin(uTime * 1.5 + phase)
+ 0.4 * sin(uTime * 3.0 + phase * 1.7))
* 0.5 * uWind * i.quad.y * i.quad.y;
// 실린드리컬 빌보드: 좌우는 카메라 right, 위는 월드업 고정
float3 p = c + uRight * i.quad.x * 0.9 + float3(0.0, i.quad.y * hgt, 0.0);
p.x += sway;
VSOut o;
// UV: 카드 x[-0.5,0.5]→[0,1], y는 위가 0이 되도록 뒤집음(텍스처 좌표계)
o.uv = float2(i.quad.x + 0.5, 1.0 - i.quad.y);
o.shade = lerp(0.5, 1.0, i.quad.y) * lerp(0.85, 1.1, colVar);
o.pos = mul(float4(p, 1.0), uVP); // 행벡터 → mul(v, M)
return o;
}
eye로부터 실린드리컬 right를 구합니다. 시선을 xz 평면에 투영한 뒤 월드업과
외적: right = normalize(cross(up, normalize(float3(eye.x, 0, eye.z)))). 이 값을 매 프레임 cbuffer의
uRight에 채워 넣습니다. 카메라가 원점을 바라본다는 전제의 데모용 근사이며, 자유 카메라에서는
시선 방향(forward)의 xz 성분으로 right를 뽑으세요.
5. 픽셀 셰이더 — 알파 테스트와 히트맵
카드는 대부분이 투명한 알파 이미지입니다. 반투명 블렌딩으로 그리면 정렬(sorting) 문제와 오버드로우가 폭발하므로,
잔디는 보통 알파 테스트를 씁니다. 알파가 임계값(0.3) 미만인 픽셀을 clip으로 버리면
그 픽셀은 아예 기록되지 않아 깊이 버퍼가 정상 동작하고 블렌딩도 필요 없습니다.
HLSL에서 clip(a - 0.3)은 인자가 음수면 픽셀을 폐기하며, if (a < 0.3) discard;와 같습니다.
두 번째 모드는 오버드로우 히트맵입니다. 픽셀이 몇 번 덧그려졌는지를 눈으로 보려고, 각 카드를 붉은색 상수로 칠하고 additive 블렌드(ONE, ONE)로 누적합니다. 같은 자리에 카드가 많이 겹칠수록 빨강이 포화되어 밝아집니다 — 그 밝은 영역이 모바일에서 프레임을 잡아먹는 곳입니다.
Texture2D uTex : register(t0);
SamplerState uSmp : register(s0);
cbuffer Mode : register(b1) {
int uHeat; // 0 = 일반, 1 = 오버드로우 히트맵
int3 _pad;
};
struct PSIn {
float4 pos : SV_Position;
float2 uv : TEXCOORD0;
float shade : TEXCOORD1;
};
float4 main(PSIn i) : SV_Target {
float4 t = uTex.Sample(uSmp, i.uv);
// 알파 테스트: 임계값 미만이면 픽셀 폐기(오버드로우/블렌딩 회피)
clip(t.a - 0.3); // == if (t.a < 0.3) discard;
if (uHeat == 1) {
// 히트맵: 살아남은 픽셀마다 붉은 상수. additive로 누적됨.
return float4(0.9, 0.15, 0.05, 1.0);
}
return float4(t.rgb * i.shade, 1.0);
}
모드에 따라 블렌드 상태를 갈아 끼웁니다. 일반은 블렌드 OFF, 히트맵만 additive입니다.
ComPtr<ID3D11BlendState> g_blendOff; // 불투명 알파테스트용
ComPtr<ID3D11BlendState> g_blendAdd; // 히트맵용 (ONE, ONE)
void createBlendStates(ID3D11Device* dev) {
// 블렌드 OFF: 알파테스트로 폐기, 통과 픽셀은 그대로 덮어쓰기
D3D11_BLEND_DESC off = {};
off.RenderTarget[0].BlendEnable = FALSE;
off.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
dev->CreateBlendState(&off, g_blendOff.GetAddressOf());
// ADDITIVE: dst = src*1 + dst*1 (겹칠수록 누적 → 히트맵)
D3D11_BLEND_DESC add = {};
auto& rt = add.RenderTarget[0];
rt.BlendEnable = TRUE;
rt.SrcBlend = D3D11_BLEND_ONE;
rt.DestBlend = D3D11_BLEND_ONE;
rt.BlendOp = D3D11_BLEND_OP_ADD;
rt.SrcBlendAlpha = D3D11_BLEND_ONE;
rt.DestBlendAlpha = D3D11_BLEND_ONE;
rt.BlendOpAlpha = D3D11_BLEND_OP_ADD;
rt.RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
dev->CreateBlendState(&add, g_blendAdd.GetAddressOf());
}
6. 드로우 — 단일 콜, 모드 전환
두 정점 버퍼를 슬롯 0·1에 함께 바인딩하고 DrawInstanced 한 번으로 그립니다.
일반 모드는 깊이 테스트 ON + 블렌드 OFF, 히트맵 모드는 깊이 테스트 OFF + additive로
상태를 바꿉니다(히트맵은 앞뒤 관계 없이 전부 누적해야 겹침을 볼 수 있으므로 깊이를 끕니다).
void drawBillboards(ID3D11DeviceContext* ctx, UINT count, bool heat) {
UINT strides[2] = { sizeof(QuadVertex), sizeof(CardInstance) };
UINT offsets[2] = { 0, 0 };
ID3D11Buffer* vbs[2] = { g_quadVB.Get(), g_cardVB.Get() };
ctx->IASetInputLayout(g_layout.Get());
ctx->IASetVertexBuffers(0, 2, vbs, strides, offsets);
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());
ctx->PSSetConstantBuffers(1, 1, g_modeCB.GetAddressOf());
ctx->PSSetShaderResources(0, 1, g_cardSRV.GetAddressOf());
ctx->PSSetSamplers(0, 1, g_cardSmp.GetAddressOf());
float bf[4] = { 0, 0, 0, 0 };
if (heat) {
ctx->OMSetDepthStencilState(g_depthOff.Get(), 0); // 깊이 OFF
ctx->OMSetBlendState(g_blendAdd.Get(), bf, 0xffffffff);
} else {
ctx->OMSetDepthStencilState(g_depthOn.Get(), 0); // 깊이 ON
ctx->OMSetBlendState(g_blendOff.Get(), bf, 0xffffffff);
}
ctx->DrawInstanced(4, count, 0, 0); // 4정점 × count 카드, 드로우콜 1
ctx->OMSetBlendState(nullptr, bf, 0xffffffff); // 상태 원복
}
정리
- 메시는 4정점 쿼드 하나, 나머지는 전부 인스턴스 버퍼 + 텍스처로.
- 잔디는 실린드리컬 빌보드 — 좌우만 카메라 right로 확장, y는 월드업 고정.
- 반투명 대신 알파 테스트
clip(a-0.3)로 오버드로우와 정렬 문제를 줄인다. - 모바일의 진짜 비용은 픽셀 오버드로우 — additive 히트맵으로 겹침을 눈으로 확인하고 거리 컬링으로 카드 수를 줄인다.
- 텍스처는 밉맵 없이 LINEAR — 밉맵은 알파를 평균해 원경 잔디를 증발시킨다.