← 개념 · 데모 가이드 | 구현 허브 | 8장. 잔디 렌더링 | 인터랙티브 밟힘
IMPLEMENTATION · CHAPTER 8 · 6/10

인터랙티브 밟힘 — 잔디 눕히기 + 바람

캐릭터가 지나가면 그 주변 잔디가 바깥으로 휘고 납작해집니다. 원리는 단순합니다. 버텍스 셰이더에서 각 blade 뿌리와 플레이어 위치의 거리를 재고, 밟힘 반경 안이면 바깥 방향으로 눕히고 y를 깎습니다. 이 페이지는 두 버전을 다룹니다. 먼저 uniform 하나로 플레이어 위치를 넘기는 직접 계산 버전, 그다음 실전 게임이 쓰는 밟힘맵(RenderTexture) 버전 — 컴퓨트 셰이더가 밟힘 방향/세기를 텍스처에 그려두고 버텍스가 그걸 Vertex Texture Fetch(SampleLevel)로 읽는 구조입니다. 후자는 발자국이 서서히 펴지는 잔상, 여러 캐릭터 동시 처리까지 자연스럽게 확장됩니다.

규약 행벡터 v' = v * M(HLSL mul(v, M)), 행렬 행 우선 저장, 왼손 좌표계, 클립 z 범위 [0, 1]. 뷰-투영은 DirectXMath 관례대로 view * proj 순서로 곱합니다. blade 메시·인스턴스 버퍼·입력 레이아웃은 지오메트리 잔디와 동일하다고 가정합니다.

▶ 실시간 데모

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

초기화 중…
💡 마우스를 캔버스에 올리면 그 지점이 밟힘 중심. 안 올리면 자동으로 원을 그리며 돎. 드래그=카메라 회전.

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

  1. 플레이어 위치 cbufferuPlayerPos·uTrampleRadius·바람·시간·VP를 넘깁니다.
  2. 거리 → 휨 세기 — 뿌리~플레이어 거리로 smoothstep 영향력을 만듭니다.
  3. 눕히기 + 납작 — 바깥 방향으로 밀고(xz) 높이를 깎습니다(y).
  4. 밟힘맵 컴퓨트 — 캐릭터들을 밟힘 텍스처(UAV)에 그리고 시간에 따라 회복시킵니다.
  5. Vertex Texture Fetch — 버텍스가 SampleLevel로 밟힘맵을 읽어 적용합니다.

1. 플레이어 위치를 cbuffer로

가장 단순한 버전은 밟힘 중심 한 점을 uniform으로 넘기는 것입니다. 마우스 호버 지점을 지면 평면에 투영해 월드 좌표 uPlayerPos(xz)로 만들고, 반경 uTrampleRadius와 함께 cbuffer에 담습니다. 바람 관련 값도 같이 넣어 밟힘과 바람이 한 버텍스 셰이더에서 합쳐지게 합니다.

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

// HLSL cbuffer와 16바이트 정렬을 맞춘다.
struct TrampleCB {
    XMFLOAT4X4 vp;          // view * proj (row_major)
    XMFLOAT2  playerPos;    // 밟힘 중심 (월드 xz)
    float     radius;       // 밟힘 반경
    float     wind;         // 바람 세기
    float     time;         // uTime
    float     _pad[3];
};
static_assert(sizeof(TrampleCB) % 16 == 0, "cbuffer는 16바이트 배수");

// 마우스 호버(NDC)를 지면 y=0 평면에 광선 교차시켜 월드 xz를 구한다.
XMFLOAT2 pickGround(XMFLOAT3 eye, XMFLOAT3 rayDir) {
    // eye.y + t*rayDir.y = 0  →  t = -eye.y / rayDir.y
    float t = -eye.y / rayDir.y;
    return { eye.x + rayDir.x * t, eye.z + rayDir.z * t };
}
자동 데모 모드 마우스 입력이 없을 땐 원본 데모처럼 밟힘 중심을 원 궤도로 돌립니다: playerPos = (cos(t·0.8)·7, sin(t·0.8)·7). 입력이 들어오면 그 지점으로 스냅합니다. C++ 쪽에서 매 프레임 이 분기만 해주면 됩니다.

2. 버텍스에서 거리로 휨 세기 계산

이제 blade 뿌리 p.xz에서 플레이어까지 벡터 toB를 구합니다. 그 길이 d가 반경보다 작을수록 영향력이 커야 하므로 smoothstep(radius, 0, d)0~1 영향력을 만듭니다(반경에서 0, 중심에서 1). 바깥 방향 away = normalize(toB)로 잎을 밀고, 동시에 높이를 깎아 납작하게 만듭니다. 밟힌 부분은 색을 살짝 바래게 해 시각적으로도 구분합니다. 바람 sway지오메트리 잔디와 같은 t²·sin 공식입니다.

trample_vs.hlsl (직접 계산 버전)hlsl
cbuffer TrampleCB : register(b0) {
    row_major float4x4 uVP;
    float2 uPlayer;      // 밟힘 중심 (xz)
    float  uRad;         // 밟힘 반경
    float  uW;           // 바람
    float  uT;           // 시간
    float3 _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 t = i.blade.y, side = i.blade.x;
    float h = i.ia.w,    ang  = i.ia.z;
    float w = (1.0 - t) * 0.06;

    float3 dir = float3(cos(ang), 0.0, sin(ang));
    float3 p   = float3(i.ia.x, 0.0, i.ia.y);

    // 바람 (뿌리 고정, 끝일수록 크게)
    float sway = (sin(uT * 1.6 + i.ib.x)
                + 0.4 * sin(uT * 3.1 + i.ib.x * 1.7)) * 0.5 * uW * t * t;

    // ── 밟힘: 뿌리~플레이어 거리로 영향력 ──
    float2 toB  = p.xz - uPlayer;
    float  d    = length(toB);
    float  infl = smoothstep(uRad, 0.0, d);           // 중심=1, 반경=0
    float2 away = normalize(toB + float2(0.0001, 0.0)); // 바깥 방향

    // blade 세우기 + 폭 + 바람
    p += dir * side * w;
    p.y += t * h;
    p.x += sway * 0.9;
    p.z += sway * 0.5;

    // 밟힘 적용: 바깥으로 눕히고(xz) 납작하게(y↓)
    p.xz += away * infl * t * 1.0;
    p.y  -= infl * t * h * 0.8;

    // 색: 밟힌 곳은 살짝 바래게
    float3 c = lerp(float3(0.09, 0.22, 0.08),
                    float3(0.35, 0.62, 0.20), t) * lerp(0.8, 1.15, i.ib.y);
    c = lerp(c, c * float3(1.15, 0.98, 0.7), infl * 0.6);

    VSOut o;
    o.col = c * lerp(0.4, 1.0, t);
    o.t   = t;
    o.pos = mul(float4(p, 1.0), uVP);   // 행벡터
    return o;
}
trample_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);
}
함정 normalize(toB)d==0(뿌리가 플레이어와 정확히 겹침)에서 0으로 나눠 NaN이 됩니다. 위처럼 +float2(0.0001,0)을 더해 방어하세요. 또 smoothstep(uRad, 0, d)는 인자 순서가 거꾸로(큰 값이 앞)라 반경에서 0, 중심에서 1로 뒤집힌 곡선을 줍니다 — 의도한 방향입니다.

3. 실전 구조 — 밟힘맵(RenderTexture)

캐릭터가 여럿이거나, 지나간 자리에 발자국이 서서히 펴지는 잔상을 남기려면 버텍스에서 매번 거리를 재는 방식으론 한계가 있습니다. 실전 게임(예: Ghost of Tsushima)은 지면을 위에서 내려다본 밟힘맵이라는 텍스처를 하나 두고, 컴퓨트 셰이더가 매 프레임 캐릭터들의 밟힘을 그 텍스처에 스탬프합니다. 각 텍셀은 밟힘 방향(RG)과 세기(B)를 담고, 세기는 프레임마다 조금씩 회복(decay)돼 자연스럽게 펴집니다. 버텍스는 자기 뿌리 위치를 이 맵의 UV로 변환해 한 번 샘플하기만 하면 됩니다 — 거리 계산이 텍스처 조회로 바뀌어 캐릭터 수와 무관해집니다.

trample_cs.hlsl (밟힘맵 갱신)hlsl
// 밟힘맵: RGBA16F.  RG=밟힘 방향(월드 xz), B=세기(0..1)
RWTexture2D<float4> uTrampleMap : register(u0);

cbuffer StampCB : register(b0) {
    float2 uArea;        // 밟힘맵이 덮는 월드 크기(예: 64 x 64)
    float  uDecay;       // 프레임당 회복량(예: 0.02)
    int    uCount;       // 이번 프레임 캐릭터 수
    float4 uActors[16];  // xy=월드 위치, z=반경, w=미사용
};

[numthreads(8, 8, 1)]
void main(uint3 id : SV_DispatchThreadID) {
    uint W, H; uTrampleMap.GetDimensions(W, H);
    if (id.x >= W || id.y >= H) return;

    // 텍셀 → 월드 xz (맵 중심을 원점으로 가정)
    float2 uv = (float2(id.xy) + 0.5) / float2(W, H);     // 0..1
    float2 wp = (uv - 0.5) * uArea;                       // 월드 xz

    float4 cur = uTrampleMap[id.xy];
    cur.b = max(cur.b - uDecay, 0.0);                     // 서서히 회복

    // 각 캐릭터의 밟힘을 스탬프 (가장 센 것으로 갱신)
    [loop] for (int a = 0; a < uCount; ++a) {
        float2 toB = wp - uActors[a].xy;
        float  d   = length(toB);
        float  infl = smoothstep(uActors[a].z, 0.0, d);
        if (infl > cur.b) {
            cur.rg = normalize(toB + float2(0.0001, 0.0));
            cur.b  = infl;
        }
    }
    uTrampleMap[id.xy] = cur;
}

버텍스 셰이더는 이제 거리 루프 대신 밟힘맵을 한 번 샘플합니다. 버텍스 단계에서는 자동 밉맵 미분이 없으므로 반드시 SampleLevel(명시적 LOD 0)을 씁니다 — 이것이 Vertex Texture Fetch의 핵심 규칙입니다.

trample_vs.hlsl (밟힘맵 샘플 버전 · 발췌)hlsl
Texture2D<float4> uTrampleMap : register(t0);
SamplerState      uSamp       : register(s0);

cbuffer TrampleCB : register(b0) {
    row_major float4x4 uVP;
    float2 uArea;    // 밟힘맵이 덮는 월드 크기
    float  uW;       // 바람
    float  uT;       // 시간
};

VSOut main(VSIn i) {
    float t = i.blade.y, side = i.blade.x;
    float h = i.ia.w, ang = i.ia.z;
    float w = (1.0 - t) * 0.06;
    float3 dir = float3(cos(ang), 0.0, sin(ang));
    float3 p   = float3(i.ia.x, 0.0, i.ia.y);

    // 뿌리 월드 xz → 밟힘맵 UV
    float2 uv = p.xz / uArea + 0.5;

    // ★ 버텍스에서는 SampleLevel (명시적 LOD) — Sample()은 VS에서 불가
    float4 tr   = uTrampleMap.SampleLevel(uSamp, uv, 0);
    float2 away = tr.rg;     // 밟힘 방향
    float  infl = tr.b;      // 밟힘 세기

    float sway = (sin(uT * 1.6 + i.ib.x)
                + 0.4 * sin(uT * 3.1 + i.ib.x * 1.7)) * 0.5 * uW * t * t;

    p += dir * side * w;
    p.y += t * h;
    p.x += sway * 0.9; p.z += sway * 0.5;

    // 맵에서 읽은 방향/세기로 눕히기 + 납작
    p.xz += away * infl * t * 1.0;
    p.y  -= infl * t * h * 0.8;

    float3 c = lerp(float3(0.09, 0.22, 0.08),
                    float3(0.35, 0.62, 0.20), t) * lerp(0.8, 1.15, i.ib.y);
    c = lerp(c, c * float3(1.15, 0.98, 0.7), infl * 0.6);

    VSOut o;
    o.col = c * lerp(0.4, 1.0, t);
    o.t   = t;
    o.pos = mul(float4(p, 1.0), uVP);
    return o;
}
함정 — 샘플러는 VS에도 바인딩 Vertex Texture Fetch를 쓰려면 SRV와 샘플러를 픽셀 셰이더가 아니라 버텍스 셰이더 슬롯에 바인딩해야 합니다: VSSetShaderResources / VSSetSamplers. 그리고 밉이 없어도 SampleLevel(..., 0)로 LOD를 명시하세요. Sample()은 픽셀 미분이 필요해 버텍스에서 컴파일되지 않습니다.

4. 밟힘맵 리소스 만들고 파이프라인에 걸기

밟힘맵은 UAV(컴퓨트가 쓰기)SRV(버텍스가 읽기) 두 뷰를 함께 가진 RGBA16F 2D 텍스처입니다. 컴퓨트로 갱신한 뒤 SRV로 다시 묶어 잔디를 그립니다. 같은 텍스처를 한 드로우에서 UAV와 SRV로 동시에 걸면 안 되므로, 컴퓨트 후 UAV를 풀고 SRV를 바인딩하는 순서가 중요합니다.

trample_map.cppcpp
ComPtr<ID3D11Texture2D>           g_trTex;
ComPtr<ID3D11UnorderedAccessView> g_trUAV;   // 컴퓨트 쓰기
ComPtr<ID3D11ShaderResourceView>  g_trSRV;   // 버텍스 읽기

void createTrampleMap(ID3D11Device* dev, UINT size) {
    D3D11_TEXTURE2D_DESC td = {};
    td.Width = td.Height = size;             // 예: 256×256
    td.MipLevels = 1; td.ArraySize = 1;
    td.Format = DXGI_FORMAT_R16G16B16A16_FLOAT;
    td.SampleDesc.Count = 1;
    td.Usage = D3D11_USAGE_DEFAULT;
    td.BindFlags = D3D11_BIND_UNORDERED_ACCESS | D3D11_BIND_SHADER_RESOURCE;
    dev->CreateTexture2D(&td, nullptr, g_trTex.GetAddressOf());
    dev->CreateUnorderedAccessView(g_trTex.Get(), nullptr, g_trUAV.GetAddressOf());
    dev->CreateShaderResourceView(g_trTex.Get(), nullptr, g_trSRV.GetAddressOf());
}

void updateAndDraw(ID3D11DeviceContext* ctx, UINT mapSize) {
    // 1) 컴퓨트로 밟힘맵 갱신
    ctx->CSSetShader(g_trampleCS.Get(), nullptr, 0);
    ctx->CSSetConstantBuffers(0, 1, g_stampCB.GetAddressOf());
    ID3D11UnorderedAccessView* uavs[] = { g_trUAV.Get() };
    ctx->CSSetUnorderedAccessViews(0, 1, uavs, nullptr);
    ctx->Dispatch((mapSize + 7) / 8, (mapSize + 7) / 8, 1);

    // 2) UAV 해제 (읽기/쓰기 충돌 방지)
    ID3D11UnorderedAccessView* nullUAV[] = { nullptr };
    ctx->CSSetUnorderedAccessViews(0, 1, nullUAV, nullptr);

    // 3) 버텍스 셰이더에 SRV+샘플러 바인딩 후 잔디 인스턴싱 드로우
    ctx->VSSetShaderResources(0, 1, g_trSRV.GetAddressOf());
    ctx->VSSetSamplers(0, 1, g_clampSamp.GetAddressOf());
    ctx->VSSetShader(g_grassVS.Get(), nullptr, 0);
    ctx->PSSetShader(g_grassPS.Get(), nullptr, 0);
    ctx->DrawInstanced(g_bladeVertexCount, g_instanceCount, 0, 0);
}
왜 맵이 이기는가 직접 계산 버전은 캐릭터가 K명이면 버텍스마다 K번 거리 계산을 합니다(정점 수 × K). 밟힘맵은 버텍스당 텍스처 조회 1번으로 고정이고, 캐릭터 처리 비용은 컴퓨트에서 맵 해상도에만 의존합니다. 게다가 decay지나간 자취(발자국)가 공짜로 남고, 바람·물결 같은 다른 변형과 같은 맵에 합성할 수도 있습니다.

정리