통합 바람 그리드 / vorticles — Ghost of Tsushima 바람 시뮬
지금까지 바람은 포기마다 sin 하나였습니다. 이제 월드 전체가 공유하는 바람장을
만듭니다. 월드를 셀 그리드로 보고 셀마다 방향/세기를 정한 뒤(여기선 절차적 필드), 거기에
움직이는 소용돌이(vorticle)를 더해 회오리 돌풍을 만듭니다. 잔디는 자기 발밑의 바람 벡터를
샘플해 그 방향으로 눕고, 방향성 돌풍 파동이 밭을 가로질러 퍼집니다.
바람장을 반환하는 HLSL 함수 하나를 잔디 셰이더와 화살표 시각화 셰이더가 공유하는 게 핵심이며,
실전에서는 이 함수를 flow map 텍스처로 구워 씁니다.
v' = v * M(HLSL mul(v, M)), 행렬 행 우선 저장,
왼손 좌표계, 클립 z 범위 [0, 1]. 뷰-투영은 DirectXMath 관례대로
view * proj 순서로 곱합니다. 아래 코드는 원리 확인용 학습 예제입니다.
▶ 실시간 데모
드래그로 회전, 휠로 줌. 아래에서 설명하는 코드가 실제로 이렇게 동작합니다. (WebGL2)
이 페이지에서 만들 것 (큰 그림)
- 바람장 함수 — 위치·시간으로 2D 바람 벡터를 반환하는
windAt()를 HLSL로 만듭니다. - 돌풍 파동 — 방향을 따라 흐르는
sin게이트로 직진성 돌풍을 만듭니다. - vorticle — 원을 그리며 이동하는 소용돌이 중심의 접선 회전을 더합니다.
- 잔디 눕히기 — 잔디가 발밑 바람 벡터를 샘플해
t²가중으로 눕습니다. - 화살표 시각화 — 셀마다 화살표를 인스턴싱해 바람장을 눈으로 봅니다.
1. 바람장 함수 — 잔디와 화살표가 공유
바람장의 진짜 핵심은 같은 함수를 두 셰이더가 공유한다는 점입니다. 잔디는 눕기 위해,
화살표는 방향을 그리기 위해 정확히 같은 windAt()를 호출해야 둘이 일치합니다.
HLSL은 #include를 지원하므로 바람장 함수를 별도 파일에 두고 양쪽에서 포함합니다.
함수는 방향성 돌풍(base)과 회전하는 vorticle 두 항의 합을 반환합니다.
// 잔디 셰이더와 화살표 셰이더가 #include 로 공유하는 바람장.
// pos : 월드 XZ, t : 시간, wS : 바람 세기, vS : 소용돌이 세기
// 반환 : 그 지점의 2D 바람 벡터(방향 × 세기)
float2 windAt(float2 pos, float t, float wS, float vS) {
// ── ① 직진성 돌풍 파동 ──
float2 dirW = float2(0.8, 0.35); // 밭을 흐르는 큰 바람 방향
float2 nd = normalize(dirW);
// 방향축을 따라 흐르는 sin 게이트(0..1). 시간이 지나면 파동이 전진한다.
float gust = sin(dot(pos, nd) * 0.25 - t * 1.6) * 0.5 + 0.5;
float2 base = nd * gust * wS;
// ── ② 이동하는 소용돌이(vorticle) ──
float2 vc = float2(cos(t * 0.5), sin(t * 0.5)) * 10.0; // 중심이 원을 그림
float2 rel = pos - vc;
float r = length(rel);
float2 tang = float2(-rel.y, rel.x) / (r + 1.0); // 접선 방향(회전)
float2 vort = tang * exp(-r * 0.08) * vS * 2.0; // 가까울수록 강함
return base + vort;
}
sin·
exp 몇 번이라 버텍스 셰이더에서 감당됩니다. 실전 규모(수십만~수백만)에서는 5절 팁대로
flow map으로 구워 텍스처 샘플로 대체합니다.
2. 셀 그리드와 cbuffer
"월드를 셀 그리드로 본다"는 개념을 CPU 쪽에서 준비합니다. 화살표 시각화(5절)는 셀마다 화살표 하나를 그리므로 셀 중심 좌표 목록이 필요합니다. 잔디 자체는 절차적 함수를 쓰므로 그리드 데이터가 필요 없지만, "이 필드가 어떤 해상도의 그리드로 이해되는지"를 셀 목록으로 명시해 둡니다.
#include <DirectXMath.h>
#include <vector>
using namespace DirectX;
// 프레임 상수: 바람장 파라미터를 잔디·화살표 셰이더에 동일하게 전달
struct WindFrameCB {
XMFLOAT4X4 vp; // view * proj (row_major)
float time;
float windStrength; // wS
float vortStrength; // vS
float _pad;
};
static_assert(sizeof(WindFrameCB) % 16 == 0, "cbuffer는 16바이트 배수");
// 바람장 화살표를 그릴 셀 중심 목록 (4단위 격자, -24..24)
std::vector<XMFLOAT2> makeWindCells() {
std::vector<XMFLOAT2> cells;
for (int z = -24; z <= 24; z += 4)
for (int x = -24; x <= 24; x += 4)
cells.push_back(XMFLOAT2((float)x, (float)z));
return cells; // 13x13 = 169개 셀
}
3. 버텍스 셰이더 — 잔디를 바람 방향으로 눕히기
잔디는 지오메트리 잔디의 인스턴싱을 그대로 쓰되, 흔드는 방식만
바람장으로 바꿉니다. 각 blade가 자기 뿌리 위치 p.xz를 windAt()에 넣어
발밑 바람 벡터 wv를 얻고, 그 방향으로 t² 가중해 눕힙니다.
바람이 셀수록 끝을 살짝 눌러(p.y 감소) 실제로 휘어 눕는 느낌을 냅니다.
바람이 강한 곳은 색을 하이라이트해 돌풍의 흐름이 눈에 보이게 합니다.
#include "wind_field.hlsli" // windAt() 공유
cbuffer Frame : register(b0) {
row_major float4x4 uVP;
float uTime;
float uWs; // 바람 세기
float uVs; // 소용돌이 세기
float _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 side = i.blade.x;
float t = i.blade.y;
float ang = i.ia.z;
float h = i.ia.w;
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);
// ── 발밑 바람 벡터를 샘플 ──
float2 wv = windAt(p.xz, uTime, uWs, uVs);
float mag = length(wv);
p += dir * side * w; // 좌/우 폭
p.y += t * h; // 세우기
// 바람 방향으로 눕히기(위로 갈수록, t² 가중)
p.xz += wv * t * t * 0.35;
p.y -= min(mag, 2.0) * t * h * 0.12; // 셀수록 끝을 눌러 눕힘
float3 c = lerp(float3(0.09, 0.22, 0.08), float3(0.36, 0.62, 0.20), t);
// 바람 센 곳 하이라이트 → 돌풍 흐름이 보인다
c = lerp(c, float3(0.6, 0.75, 0.3), clamp(mag * 0.15, 0.0, 0.4));
VSOut o;
o.col = c * lerp(0.4, 1.0, t); // 아래쪽 AO
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 {
return float4(i.col, 1.0);
}
p.xz += wv로 선형하게 주면 바람이 세질 때 blade가 무한정 늘어나
찢어집니다. t*t 가중으로 뿌리를 고정하고, min(mag, 2.0)으로 세기를
클램프해야 폭풍에도 blade가 지면 안에 붙어 자연스럽게 눕습니다.
4. 화살표 지오메트리 — 셀마다 하나 인스턴싱
바람장을 눈으로 보려면 셀마다 바람 방향을 가리키는 화살표를 그립니다. 화살표 한 개는
자루+머리를 LINESTRIP 5정점으로 만들고(밑→끝→머리왼쪽, 끝→머리오른쪽), 셀 좌표를
per-instance로 넘겨 지오메트리 잔디와 같은 인스턴싱으로
169개 셀을 한 번에 그립니다. 정점 데이터 aP.x는 자루를 따라 0(밑)~1(끝),
aP.y는 머리의 좌우 벌어짐입니다.
// 화살표 지오메트리: 자루(밑→끝) + 머리 두 선. LINESTRIP 5정점.
// aP.x : 0(base)..1(tip) 자루 축, aP.y : 머리 좌우
const float arrowGeo[] = {
0.0f, 0.0f, 1.0f, 0.0f, 0.75f, 1.0f, 1.0f, 0.0f, 0.75f, -1.0f
};
ComPtr<ID3D11Buffer> g_arrowVB; // 슬롯 0: 정점당(aP)
ComPtr<ID3D11Buffer> g_cellVB; // 슬롯 1: 인스턴스당(셀 중심 XZ)
UINT g_cellCount = 0;
void createArrowBuffers(ID3D11Device* dev) {
auto cells = makeWindCells();
g_cellCount = (UINT)cells.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(arrowGeo, sizeof(arrowGeo), g_arrowVB);
makeVB(cells.data(), (UINT)(cells.size() * sizeof(XMFLOAT2)), g_cellVB);
}
// 입력 레이아웃: 자루 정점(슬롯0) + 셀 중심(슬롯1, per-instance)
D3D11_INPUT_ELEMENT_DESC arrowLayout[] = {
{ "APOS", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 0,
D3D11_INPUT_PER_VERTEX_DATA, 0 },
{ "CELL", 0, DXGI_FORMAT_R32G32_FLOAT, 1, 0,
D3D11_INPUT_PER_INSTANCE_DATA, 1 }, // StepRate=1
};
화살표 버텍스 셰이더도 같은 windAt()를 호출해 셀 중심의 바람 벡터를 얻고,
그 방향으로 자루를 회전·스케일합니다. 머리 색을 자루 축(vHead)에 따라 파랑→노랑으로 바꿔
방향이 잘 보이게 합니다.
#include "wind_field.hlsli"
cbuffer Frame : register(b0) {
row_major float4x4 uVP;
float uTime; float uWs; float uVs; float _pad;
};
struct VSIn {
float2 aP : APOS0; // (자루축 0..1, 머리 좌우)
float2 cell : CELL0; // 셀 중심 XZ (per-instance)
};
struct VSOut {
float4 pos : SV_Position;
float head : TEXCOORD0;
};
VSOut main(VSIn i) {
float2 wv = windAt(i.cell, uTime, uWs, uVs);
float mag = length(wv);
float2 d = mag > 0.001 ? normalize(wv) : float2(1.0, 0.0);
float2 n = float2(-d.y, d.x); // 화살표 폭 방향
float len = clamp(mag * 0.5, 0.2, 1.6); // 세기 → 길이
// 셀 중심에서 방향 d로 뻗고, 머리는 n으로 벌린다
float2 xz = i.cell + d * i.aP.x * len + n * i.aP.y * 0.18 * len;
VSOut o;
o.head = i.aP.x;
o.pos = mul(float4(xz.x, 0.05, xz.y, 1.0), uVP); // 지면 살짝 위
return o;
}
struct PSIn {
float4 pos : SV_Position;
float head : TEXCOORD0;
};
float4 main(PSIn i) : SV_Target {
// 밑(파랑) → 끝(노랑)으로 방향을 강조
float3 c = lerp(float3(0.2, 0.5, 1.0), float3(1.0, 0.9, 0.3), i.head);
return float4(c, 1.0);
}
5. 드로우 — 잔디 + (옵션)화살표
한 프레임에 먼저 잔디를 TRIANGLESTRIP으로 인스턴싱하고, 시각화 토글이 켜져 있으면
화살표를 LINESTRIP으로 인스턴싱합니다. 둘 다 같은 프레임 cbuffer(b0)를 쓰므로
바람 파라미터가 자동으로 일치합니다. 화살표는 5정점씩 셀 개수만큼 인스턴싱합니다.
void drawWindScene(ID3D11DeviceContext* ctx, const XMMATRIX& viewProj,
float time, float wS, float vS,
UINT grassCount, bool showArrows) {
// 프레임 cbuffer 갱신(잔디·화살표가 공유)
WindFrameCB cb;
XMStoreFloat4x4(&cb.vp, viewProj); // row_major, 전치 없음
cb.time = time; cb.windStrength = wS; cb.vortStrength = vS; cb._pad = 0;
ctx->UpdateSubresource(g_frameCB.Get(), 0, nullptr, &cb, 0, 0);
ctx->VSSetConstantBuffers(0, 1, g_frameCB.GetAddressOf());
// ── 잔디 (지오메트리 인스턴싱) ──
UINT gStr[2] = { sizeof(BladeVertex), sizeof(GrassInstance) };
UINT gOff[2] = { 0, 0 };
ID3D11Buffer* gvbs[2] = { g_bladeVB.Get(), g_instVB.Get() };
ctx->IASetInputLayout(g_grassLayout.Get());
ctx->IASetVertexBuffers(0, 2, gvbs, gStr, gOff);
ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
ctx->VSSetShader(g_windVS.Get(), nullptr, 0);
ctx->PSSetShader(g_windPS.Get(), nullptr, 0);
ctx->DrawInstanced(g_bladeVertexCount, grassCount, 0, 0);
// ── 바람장 화살표 (옵션) ──
if (showArrows) {
UINT aStr[2] = { sizeof(float) * 2, sizeof(XMFLOAT2) };
UINT aOff[2] = { 0, 0 };
ID3D11Buffer* avbs[2] = { g_arrowVB.Get(), g_cellVB.Get() };
ctx->IASetInputLayout(g_arrowLayout.Get());
ctx->IASetVertexBuffers(0, 2, avbs, aStr, aOff);
ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_LINESTRIP);
ctx->VSSetShader(g_arrowVS.Get(), nullptr, 0);
ctx->PSSetShader(g_arrowPS.Get(), nullptr, 0);
ctx->DrawInstanced(5, g_cellCount, 0, 0); // 5정점 × 셀 개수
}
}
windAt()를 RenderTexture(RG16F)에
한 번 렌더링해 flow map으로 굽습니다. 잔디 셰이더는 그 텍스처를 Sample 한 번으로
읽으므로 sin/exp 비용이 사라지고, 아티스트가 flow map을 손으로 칠해 바람을
디자인할 수도 있습니다. Ghost of Tsushima의 바람은 이렇게 텍스처 기반 필드 + 동적 vorticle의
조합입니다. 저사양 기기는 버텍스에서 텍스처 샘플이 느릴 수 있으니 셀 그리드 해상도로 타협합니다.
LINESTRIP으로 그릴 때 5정점이 인스턴스 경계를 넘어 이어지지 않도록
DrawInstanced가 인스턴스마다 정점 범위를 재시작한다는 점에 의존합니다(잔디 스트립과 동일 원리).
만약 한 버퍼에 모든 화살표를 이어 담아 비인스턴싱으로 그리면 셀과 셀 사이에 잘못된 선분이
생깁니다.
정리
- 바람은
windAt()함수 하나로 정의하고 잔디·화살표가#include로 공유. - 바람장 = 방향성 돌풍 파동(base) + 이동하는 vorticle(회전)의 합.
- 잔디는 발밑 바람을 샘플해
t²가중 + 세기 클램프로 눕힘. - 셀 그리드를 화살표 인스턴싱으로 시각화해 방향 필드를 눈으로 확인.
- 실전은 flow map(RenderTexture)으로 구워 텍스처 샘플로 대체.