← 개념 · 데모 가이드 | 구현 허브 | 8장. 잔디 렌더링 | 빌보드 / 크로스쿼드
IMPLEMENTATION · CHAPTER 8 · 2/10

빌보드 / 크로스쿼드 — 카메라를 향하는 텍스처 카드

잔디 한 포기를 삼각형 스트립 메시로 만들었던 지오메트리 방식은 정점이 곧 비용이었습니다. 빌보드는 반대로 갑니다. 잔디 다발을 쿼드 한 장(4정점)에 텍스처로 압축해 정점을 극단적으로 줄이고, 대신 카메라를 향해 회전(빌보드)시켜 언제나 정면으로 보이게 합니다. 정점은 적지만 알파 카드가 서로 겹치는 오버드로우가 모바일에서 진짜 비용이 됩니다. 이 페이지에서는 실린드리컬 빌보드 수식, 절차적 카드 텍스처 생성, 알파 테스트(clip), 그리고 오버드로우를 눈으로 확인하는 히트맵까지 D3D11로 구현합니다.

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

▶ 실시간 데모

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

초기화 중…
💡 히트맵을 켜면 픽셀이 겹칠수록 빨개져 → 빨간 영역 = 프레임 잡아먹는 곳. 알파 블렌드 대신 알파 테스트를 쓰는 이유.

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

  1. 쿼드 메시 — 잔디 카드 한 장을 x∈[-0.5,0.5], y∈[0,1]의 4정점 스트립으로 만듭니다.
  2. 인스턴스 데이터 — 카드마다 위치·키·위상·색변주를 담은 두 번째 정점 버퍼를 만듭니다.
  3. 절차적 카드 텍스처 — CPU에서 잔디 다발 이미지를 그려 CreateTexture2D로 업로드합니다.
  4. 실린드리컬 빌보드 — 카메라 right 벡터로 좌우만 확장하고 y는 월드업을 유지합니다.
  5. 알파 테스트 + 히트맵clip(a-0.3)으로 오버드로우를 줄이고, additive 블렌드로 겹침을 시각화합니다.

1. 카드 = 4정점 쿼드

빌보드의 메시는 지극히 단순합니다. 폭 방향은 x ∈ [-0.5, 0.5], 높이 방향은 y ∈ [0, 1]인 정사각 쿼드 한 장이면 끝입니다. 이 두 값은 실제 3D 위치가 아니라 “카드 안에서의 정규화 좌표”일 뿐입니다. 실제 월드 위치는 버텍스 셰이더에서 인스턴스의 위치·카메라 방향과 합쳐 계산합니다. 정점을 TRIANGLESTRIP으로 그리면 4정점으로 삼각형 2개(=사각형 한 장)가 채워집니다.

grass_billboard.hppcpp
#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)로 뽑아 중심 쏠림을 막는 것도 같습니다(면적이 반지름 제곱에 비례하므로).

grass_billboard.hpp (이어서)cpp
// 인스턴스당 데이터 (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로 한 번만 올립니다.

버퍼 생성cpp
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의 판정 기준이 됩니다.

grass_card_tex.cppcpp
// 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로, 밉맵 없이 만듭니다.

샘플러 상태cpp
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로 올립니다. 바람은 지오메트리 편과 같은 가중 sin으로 끝만 크게 휘게 합니다.

billboard_vs.hlslhlsl
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;
}
카메라 right 벡터 만들기 CPU에서 카메라 눈 위치 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)로 누적합니다. 같은 자리에 카드가 많이 겹칠수록 빨강이 포화되어 밝아집니다 — 그 밝은 영역이 모바일에서 프레임을 잡아먹는 곳입니다.

billboard_ps.hlslhlsl
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입니다.

블렌드 상태 두 종cpp
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());
}
오버드로우가 모바일의 진짜 비용 빌보드는 정점이 카드당 4개로 극히 적어 버텍스 비용은 무시할 수준입니다. 문제는 픽셀입니다. 화면 한 점을 여러 카드가 앞뒤로 덮으면 그 픽셀 셰이더가 겹친 횟수만큼 실행됩니다(오버드로우). 타일 기반 모바일 GPU는 이 반복 픽셀 셰이딩과 대역폭에서 프레임을 잃습니다. 그래서 반투명 블렌딩 대신 알파 테스트로 조기 폐기하고, 카드 밀도를 거리로 줄이는 것이 정석입니다.

6. 드로우 — 단일 콜, 모드 전환

두 정점 버퍼를 슬롯 0·1에 함께 바인딩하고 DrawInstanced 한 번으로 그립니다. 일반 모드는 깊이 테스트 ON + 블렌드 OFF, 히트맵 모드는 깊이 테스트 OFF + additive로 상태를 바꿉니다(히트맵은 앞뒤 관계 없이 전부 누적해야 겹침을 볼 수 있으므로 깊이를 끕니다).

렌더 루프cpp
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);          // 상태 원복
}
함정 빌보드는 지오메트리 잔디와 달리 알파 카드라 “불투명이니 Z버퍼만 켜면 된다”가 그대로 통하지 않습니다. 알파 테스트로 폐기하면 통과 픽셀만 깊이에 기록되므로 대체로 깨끗하지만, 카드끼리 반쯤 겹쳐 알파 경계가 만나는 곳에서 정렬 아티팩트가 남을 수 있습니다. 부드러운 경계를 원하면 알파-투-커버리지(MSAA 필요)를 검토하세요.

정리