← 개념 · 데모 가이드 | 구현 허브 | 8장. 잔디 렌더링 | 셸 텍스처링
IMPLEMENTATION · CHAPTER 8 · 3/10

셸 텍스처링 — 반투명 레이어 적층

셸 텍스처링은 지면을 살짝씩 띄운 여러 겹(shell)으로 반복해 그립니다. 각 겹의 픽셀에서 노이즈 임계값으로 strand(잔디 가닥)를 discard하면, 위로 갈수록 성기게 남아 옆에서 보면 진짜 잔디처럼 층층이 쌓여 보입니다. 정점은 지면 쿼드 하나 × 레이어 수뿐이라 거의 없지만, 같은 픽셀을 레이어 수만큼 덧그리므로 겹 수가 곧 오버드로우 배수가 됩니다. 이 페이지에서는 SV_InstanceID로 레이어를 쌓고, 해시 노이즈로 원뿔형 strand를 깎아내는 과정을 D3D11로 구현합니다.

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

▶ 실시간 데모

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

초기화 중…
💡 레이어를 줄이면 층이 눈에 띄고, 늘리면 촘촘하지만 같은 픽셀을 레이어 수만큼 덧그림.

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

  1. 지면 쿼드 — 단 하나의 평면 쿼드(4정점)를 만듭니다. 이것을 여러 겹 쌓습니다.
  2. 레이어 인스턴싱SV_InstanceID로 겹 인덱스를 받아 높이 y = ln·1.4로 띄웁니다.
  3. 해시 노이즈 strand — 각 셀에서 밀도 임계값과 키·원뿔 반경으로 strand를 discard합니다.
  4. 바람 sway — 위 레이어일수록 크게 흔들리도록 ln² 가중을 줍니다.
  5. 단일 드로우콜DrawInstanced 한 번으로 모든 겹을 그립니다.

1. 왜 “겹”인가 — 셸 텍스처링의 원리

풀을 실제 가닥으로 모델링하는 대신, 셸 텍스처링은 같은 지면을 조금씩 높여 여러 번 그립니다. 가장 아래 겹은 잔디 가닥의 밑동, 조금 위 겹은 중간, 맨 위 겹은 끝을 나타냅니다. 각 겹에서 “이 위치에 이 높이의 가닥이 존재하는가?”를 노이즈로 판정해, 존재하지 않으면 픽셀을 버립니다. 위로 갈수록 살아남는 가닥이 줄어들어 아래는 빽빽하고 위는 성긴 자연스러운 실루엣이 만들어집니다.

지오메트리·빌보드가 “객체(포기/카드)마다 인스턴스”였다면, 셸은 겹마다 인스턴스입니다. 그래서 별도의 인스턴스 정점 버퍼가 필요 없습니다 — 겹 인덱스는 GPU가 자동으로 주는 SV_InstanceID로 충분합니다. 쿼드 하나만 만들고 인스턴스 수를 레이어 수로 넘기면 됩니다.

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

// 정점당 데이터: 지면 평면의 정규화 좌표 (x, z) ∈ [-1, 1]
struct GroundVertex { float x, z; };

// 지면 쿼드 하나(4정점 삼각형 스트립). 이 한 장을 레이어 수만큼 인스턴싱한다.
std::vector<GroundVertex> makeGround() {
    return {
        { -1.0f, -1.0f },
        { +1.0f, -1.0f },
        { -1.0f, +1.0f },
        { +1.0f, +1.0f },
    };
}

ComPtr<ID3D11Buffer> g_groundVB;

void createGroundBuffer(ID3D11Device* dev) {
    auto quad = makeGround();
    D3D11_BUFFER_DESC bd = {};
    bd.Usage = D3D11_USAGE_IMMUTABLE;
    bd.ByteWidth = (UINT)(quad.size() * sizeof(GroundVertex));
    bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
    D3D11_SUBRESOURCE_DATA sd = { quad.data(), 0, 0 };
    dev->CreateBuffer(&bd, &sd, g_groundVB.GetAddressOf());
}

2. 상수 버퍼 — 레이어·밀도·바람

셸 렌더링을 제어하는 값들을 하나의 cbuffer로 묶습니다. uLayers(겹 수), uDensity(밀도 임계값), uWind(바람 세기), uTime(시간), 그리고 뷰-투영 uVP입니다. 이 중 uLayersuDensity가 셸의 인상을 결정합니다 — 겹이 많을수록 촘촘하지만 오버드로우가 늘고, 밀도가 높을수록 살아남는 셀이 많아집니다.

상수 버퍼 (C++ 측)cpp
// HLSL cbuffer와 16바이트 정렬을 맞춘 구조체
struct ShellCB {
    XMFLOAT4X4 uVP;      // view * proj (row_major로 그대로 업로드)
    float uTime;
    float uWind;
    int   uLayers;       // 겹 수 (4 .. 48)
    float uDensity;      // 밀도 임계값 (0.2 .. 0.9)
};
static_assert(sizeof(ShellCB) % 16 == 0, "cbuffer는 16바이트 배수여야 함");

ComPtr<ID3D11Buffer> g_shellCB;

void createShellCB(ID3D11Device* dev) {
    D3D11_BUFFER_DESC bd = {};
    bd.Usage = D3D11_USAGE_DYNAMIC;                 // 매 프레임 갱신
    bd.ByteWidth = sizeof(ShellCB);
    bd.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
    bd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
    dev->CreateBuffer(&bd, nullptr, g_shellCB.GetAddressOf());
}

void updateShellCB(ID3D11DeviceContext* ctx, const XMMATRIX& vp,
                   float t, float wind, int layers, float density) {
    D3D11_MAPPED_SUBRESOURCE m;
    ctx->Map(g_shellCB.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &m);
    auto* cb = (ShellCB*)m.pData;
    XMStoreFloat4x4(&cb->uVP, vp);   // row_major이므로 전치하지 않음
    cb->uTime = t; cb->uWind = wind;
    cb->uLayers = layers; cb->uDensity = density;
    ctx->Unmap(g_shellCB.Get(), 0);
}

3. 버텍스 셰이더 — SV_InstanceID로 겹 쌓기

핵심은 SV_InstanceID입니다. GPU는 인스턴스마다 0, 1, 2, … 로 증가하는 이 값을 자동으로 넘겨줍니다. 이걸 겹 인덱스 layer로 받아 정규화 높이 ln = layer / uLayers를 구하고, 월드 y를 ln · 1.4로 띄웁니다(잔디 최대 높이 1.4). 지면 쿼드는 30배 스케일해 넓은 밭으로 만듭니다.

바람은 위 레이어일수록 크게 흔들려야 하므로 ln² 가중을 씁니다(밑동 ln=0은 거의 안 흔들림). 월드 xz 위치를 sin의 위상에 섞어 밭 전체가 한 박자로 출렁이는 인공적인 느낌을 없앱니다. UV는 지면 좌표를 55배 확대해 셀 격자를 촘촘히 만듭니다 — 이 격자 한 칸이 잔디 가닥 하나의 영역입니다.

shell_vs.hlslhlsl
cbuffer Frame : register(b0) {
    row_major float4x4 uVP;   // view * proj (행벡터 관례)
    float uTime;
    float uWind;
    int   uLayers;
    float uDensity;           // (VS에서는 미사용, PS와 공유)
};

struct VSIn {
    float2 p : GROUND0;       // 지면 정규화 좌표 (x, z)
    uint   iid : SV_InstanceID;
};
struct VSOut {
    float4 pos : SV_Position;
    float2 uv  : TEXCOORD0;
    float  h   : TEXCOORD1;   // 이 겹의 정규화 높이 ln
};

VSOut main(VSIn i) {
    int   layer = (int)i.iid;
    float ln    = (float)layer / (float)uLayers;   // 0 .. ~1
    float y     = ln * 1.4;                         // 겹 높이

    float3 wp = float3(i.p.x * 30.0, y, i.p.y * 30.0);

    // ln² 가중 sway: 위 레이어일수록 크게 흔들림
    float sway = sin(uTime * 1.3 + wp.x * 0.15 + wp.z * 0.12)
               * uWind * ln * ln * 0.7;
    wp.x += sway;
    wp.z += sway * 0.4;

    VSOut o;
    o.uv  = i.p * 55.0;         // 셀 격자(잔디 가닥 영역)
    o.h   = ln;
    o.pos = mul(float4(wp, 1.0), uVP);   // 행벡터 → mul(v, M)
    return o;
}
SV_InstanceID는 공짜 인스턴스 데이터 빌보드·지오메트리 편에서는 인스턴스마다 다른 값을 두 번째 정점 버퍼에 담아 넘겼습니다. 셸에서는 겹마다 필요한 정보가 “몇 번째 겹인가” 하나뿐이라, 별도 버퍼 없이 SV_InstanceID만으로 끝납니다. DrawInstanced에 인스턴스 수(=레이어 수)만 넘기면 GPU가 알아서 0..N-1을 배분합니다.

4. 픽셀 셰이더 — 해시 노이즈로 strand 깎기

이제 각 겹의 픽셀에서 “여기에 이 높이의 잔디 가닥이 있는가?”를 판정합니다. 절차는 세 단계의 discard로 이루어집니다.

  1. 밀도 판정 — 셀의 해시값 r1uDensity보다 크면 이 셀엔 잔디가 없음 → 버림.
  2. 키 판정 — 이 셀 잔디의 끝 높이 maxH보다 현재 겹 높이가 위면 → 버림(가닥 끝 위는 비어 있음).
  3. 원뿔 반경 판정 — 셀 안 위치가 위로 갈수록 좁아지는 원뿔 밖이면 → 버림(뾰족한 가닥 모양).

해시는 격자 셀 좌표를 입력으로 받아 결정론적 난수를 뽑습니다(같은 셀은 늘 같은 잔디). 원뿔 반경 0.30·(1-t)는 뿌리에서 넓고 끝(t=1)에서 0이 되어, 옆에서 보면 위로 갈수록 가늘어지는 자연스러운 실루엣을 만듭니다. 살짝 기울임(t에 비례해 한쪽으로) 항을 더해 눕는 느낌도 줍니다.

shell_ps.hlslhlsl
cbuffer Frame : register(b0) {
    row_major float4x4 uVP;
    float uTime;
    float uWind;
    int   uLayers;
    float uDensity;           // 밀도 임계값
};

struct PSIn {
    float4 pos : SV_Position;
    float2 uv  : TEXCOORD0;
    float  h   : TEXCOORD1;   // 이 겹의 정규화 높이 ln
};

// 셀 좌표 → 결정론적 해시 (WebGL 데모와 동일한 상수)
float hash(float2 c) {
    return frac(sin(dot(c, float2(41.3, 289.1))) * 43758.5453);
}

float4 main(PSIn i) : SV_Target {
    float2 cell = floor(i.uv);
    float r1 = hash(cell);
    float r2 = hash(cell + 3.7);
    float r3 = hash(cell + 7.1);

    // ① 밀도: 임계값보다 성긴 셀은 잔디 자체가 없음
    if (r1 > uDensity) discard;

    // ② 키: 이 셀 잔디의 끝 높이 위는 비어 있음
    float maxH = 0.45 + 0.55 * r2;      // 가닥별 키
    if (i.h > maxH) discard;
    float t = i.h / maxH;               // 0=뿌리 .. 1=끝

    // ③ 원뿔 반경: 셀 안 위치가 (위로 갈수록 좁아지는) 원뿔 밖이면 버림
    float2 center = float2(0.3 + 0.4 * r2, 0.3 + 0.4 * r3);
    float2 f = frac(i.uv) - center;
    f -= float2(r2 - 0.5, r3 - 0.5) * t * 0.7;   // 위로 갈수록 살짝 기움
    float radius = 0.30 * (1.0 - t);             // 끝에서 0 → 뾰족
    if (length(f) > radius) discard;

    // 뿌리(어두움)→끝(밝음) + 셀별 색 변주
    float3 c = lerp(float3(0.07, 0.19, 0.06),
                    float3(0.46, 0.74, 0.27), t) * lerp(0.7, 1.1, r1);
    return float4(c, 1.0);
}
함정 frac(sin(...) * 43758.5453) 해시는 격자 셀 좌표를 정수로 받아야 셀마다 값이 고정됩니다. 그래서 floor(uv)로 셀 인덱스를 만들고, 셀 안 위치는 frac(uv)로 따로 구합니다. UV 스케일(55)을 바꾸면 셀 크기와 잔디 밀도 체감이 함께 달라지니, 밀도 슬라이더와 별개의 “가닥 굵기” 튜닝 지점임을 기억하세요.

5. 드로우 — 레이어 수만큼 인스턴싱

지면 쿼드 하나를 바인딩하고 DrawInstanced(4, layers, 0, 0)로 그립니다. 인스턴스 수가 곧 겹 수입니다. 잔디는 알파 테스트(discard)로 통과 픽셀만 남기는 불투명 렌더이므로 블렌드는 끄고 깊이 테스트는 켭니다. 아래 겹부터 위 겹 순으로 그려지면 깊이 버퍼가 앞뒤를 정리해 줍니다.

렌더 루프cpp
void drawShell(ID3D11DeviceContext* ctx, const XMMATRIX& vp,
               float t, float wind, int layers, float density) {
    updateShellCB(ctx, vp, t, wind, layers, density);

    UINT stride = sizeof(GroundVertex), offset = 0;
    ctx->IASetInputLayout(g_layout.Get());
    ctx->IASetVertexBuffers(0, 1, g_groundVB.GetAddressOf(), &stride, &offset);
    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_shellCB.GetAddressOf());
    ctx->PSSetConstantBuffers(0, 1, g_shellCB.GetAddressOf());   // VS/PS 공유

    ctx->OMSetDepthStencilState(g_depthOn.Get(), 0);            // 깊이 ON
    float bf[4] = { 0, 0, 0, 0 };
    ctx->OMSetBlendState(nullptr, bf, 0xffffffff);              // 블렌드 OFF

    // 4정점 × layers 겹, 드로우콜 1
    ctx->DrawInstanced(4, (UINT)layers, 0, 0);
}
레이어 수 = 오버드로우 배수 셸의 비용 모델은 단순합니다. 화면을 덮는 지면 픽셀 하나를 레이어 수만큼 픽셀 셰이더가 처리합니다. 24겹이면 같은 픽셀에 셰이더가 최대 24번 실행됩니다(discard로 조기 종료되긴 하지만 조기 discard는 초반 판정까지는 실행됩니다). 레이어를 줄이면 층이 계단처럼 보이고(측면 아티팩트), 늘리면 촘촘하지만 오버드로우가 선형으로 증가합니다. 그래서 셸은 평지 근·중거리에 적합하고, 카메라가 낮게 깔리는 장면(수평 시선)에서는 층 사이가 벌어져 보이는 단점이 있습니다.

정리