인터랙티브 밟힘 — 잔디 눕히기 + 바람
캐릭터가 지나가면 그 주변 잔디가 바깥으로 휘고 납작해집니다. 원리는 단순합니다.
버텍스 셰이더에서 각 blade 뿌리와 플레이어 위치의 거리를 재고, 밟힘 반경 안이면
바깥 방향으로 눕히고 y를 깎습니다. 이 페이지는 두 버전을 다룹니다. 먼저 uniform 하나로
플레이어 위치를 넘기는 직접 계산 버전, 그다음 실전 게임이 쓰는
밟힘맵(RenderTexture) 버전 — 컴퓨트 셰이더가 밟힘 방향/세기를 텍스처에 그려두고
버텍스가 그걸 Vertex Texture Fetch(SampleLevel)로 읽는 구조입니다. 후자는
발자국이 서서히 펴지는 잔상, 여러 캐릭터 동시 처리까지 자연스럽게 확장됩니다.
v' = v * M(HLSL mul(v, M)), 행렬 행 우선 저장,
왼손 좌표계, 클립 z 범위 [0, 1]. 뷰-투영은 DirectXMath 관례대로
view * proj 순서로 곱합니다. blade 메시·인스턴스 버퍼·입력 레이아웃은
지오메트리 잔디와 동일하다고 가정합니다.
▶ 실시간 데모
드래그로 회전, 휠로 줌. 아래에서 설명하는 코드가 실제로 이렇게 동작합니다. (WebGL2)
이 페이지에서 만들 것 (큰 그림)
- 플레이어 위치 cbuffer —
uPlayerPos·uTrampleRadius·바람·시간·VP를 넘깁니다. - 거리 → 휨 세기 — 뿌리~플레이어 거리로
smoothstep영향력을 만듭니다. - 눕히기 + 납작 — 바깥 방향으로 밀고(
xz) 높이를 깎습니다(y). - 밟힘맵 컴퓨트 — 캐릭터들을 밟힘 텍스처(UAV)에 그리고 시간에 따라 회복시킵니다.
- Vertex Texture Fetch — 버텍스가
SampleLevel로 밟힘맵을 읽어 적용합니다.
1. 플레이어 위치를 cbuffer로
가장 단순한 버전은 밟힘 중심 한 점을 uniform으로 넘기는 것입니다. 마우스 호버 지점을 지면 평면에 투영해
월드 좌표 uPlayerPos(xz)로 만들고, 반경 uTrampleRadius와 함께 cbuffer에 담습니다.
바람 관련 값도 같이 넣어 밟힘과 바람이 한 버텍스 셰이더에서 합쳐지게 합니다.
#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 공식입니다.
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;
}
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로 변환해
한 번 샘플하기만 하면 됩니다 — 거리 계산이 텍스처 조회로 바뀌어 캐릭터 수와 무관해집니다.
// 밟힘맵: 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의 핵심 규칙입니다.
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;
}
VSSetShaderResources / VSSetSamplers. 그리고 밉이 없어도
SampleLevel(..., 0)로 LOD를 명시하세요. Sample()은 픽셀 미분이 필요해
버텍스에서 컴파일되지 않습니다.
4. 밟힘맵 리소스 만들고 파이프라인에 걸기
밟힘맵은 UAV(컴퓨트가 쓰기)와 SRV(버텍스가 읽기) 두 뷰를 함께 가진
RGBA16F 2D 텍스처입니다. 컴퓨트로 갱신한 뒤 SRV로 다시 묶어 잔디를 그립니다. 같은 텍스처를
한 드로우에서 UAV와 SRV로 동시에 걸면 안 되므로, 컴퓨트 후 UAV를 풀고 SRV를 바인딩하는 순서가 중요합니다.
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로 지나간 자취(발자국)가 공짜로 남고,
바람·물결 같은 다른 변형과 같은 맵에 합성할 수도 있습니다.
정리
- 밟힘 = 뿌리~플레이어 거리로 영향력(
smoothstep) → 바깥으로 눕히고 y 깎기. - 단순 버전은 uPlayerPos uniform 하나면 충분하지만 캐릭터 수에 비례해 비싸짐.
- 실전은 컴퓨트가 밟힘맵(UAV)에 스탬프 + decay, 버텍스가 SRV를 SampleLevel.
- Vertex Texture Fetch는 VSSetShaderResources/Samplers +
SampleLevel(...,0). - 같은 텍스처를 UAV·SRV로 동시에 걸지 말 것 — 컴퓨트 후 UAV 해제 순서 준수.