← 개념 · 데모 가이드 | 구현 허브 | 8장. 잔디 렌더링 | 통합 바람 그리드 / vorticles
IMPLEMENTATION · CHAPTER 8 · 9/10

통합 바람 그리드 / vorticles — Ghost of Tsushima 바람 시뮬

지금까지 바람은 포기마다 sin 하나였습니다. 이제 월드 전체가 공유하는 바람장을 만듭니다. 월드를 셀 그리드로 보고 셀마다 방향/세기를 정한 뒤(여기선 절차적 필드), 거기에 움직이는 소용돌이(vorticle)를 더해 회오리 돌풍을 만듭니다. 잔디는 자기 발밑의 바람 벡터를 샘플해 그 방향으로 눕고, 방향성 돌풍 파동이 밭을 가로질러 퍼집니다. 바람장을 반환하는 HLSL 함수 하나를 잔디 셰이더와 화살표 시각화 셰이더가 공유하는 게 핵심이며, 실전에서는 이 함수를 flow map 텍스처로 구워 씁니다.

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

▶ 실시간 데모

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

초기화 중…
💡 소용돌이가 지나가면 잔디가 회오리처럼 눕고, 돌풍 파동이 밭을 가로질러 퍼져. "바람장 화살표"로 방향 필드를 봐.

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

  1. 바람장 함수 — 위치·시간으로 2D 바람 벡터를 반환하는 windAt()를 HLSL로 만듭니다.
  2. 돌풍 파동 — 방향을 따라 흐르는 sin 게이트로 직진성 돌풍을 만듭니다.
  3. vorticle — 원을 그리며 이동하는 소용돌이 중심의 접선 회전을 더합니다.
  4. 잔디 눕히기 — 잔디가 발밑 바람 벡터를 샘플해 가중으로 눕습니다.
  5. 화살표 시각화 — 셀마다 화살표를 인스턴싱해 바람장을 눈으로 봅니다.

1. 바람장 함수 — 잔디와 화살표가 공유

바람장의 진짜 핵심은 같은 함수를 두 셰이더가 공유한다는 점입니다. 잔디는 눕기 위해, 화살표는 방향을 그리기 위해 정확히 같은 windAt()를 호출해야 둘이 일치합니다. HLSL은 #include를 지원하므로 바람장 함수를 별도 파일에 두고 양쪽에서 포함합니다. 함수는 방향성 돌풍(base)회전하는 vorticle 두 항의 합을 반환합니다.

wind_field.hlslihlsl
// 잔디 셰이더와 화살표 셰이더가 #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절)는 셀마다 화살표 하나를 그리므로 셀 중심 좌표 목록이 필요합니다. 잔디 자체는 절차적 함수를 쓰므로 그리드 데이터가 필요 없지만, "이 필드가 어떤 해상도의 그리드로 이해되는지"를 셀 목록으로 명시해 둡니다.

wind_grid.hppcpp
#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.xzwindAt()에 넣어 발밑 바람 벡터 wv를 얻고, 그 방향으로 가중해 눕힙니다. 바람이 셀수록 끝을 살짝 눌러(p.y 감소) 실제로 휘어 눕는 느낌을 냅니다. 바람이 강한 곳은 색을 하이라이트해 돌풍의 흐름이 눈에 보이게 합니다.

wind_vs.hlslhlsl
#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;
}
wind_ps.hlslhlsl
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는 머리의 좌우 벌어짐입니다.

화살표 버퍼 생성cpp
// 화살표 지오메트리: 자루(밑→끝) + 머리 두 선. 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)에 따라 파랑→노랑으로 바꿔 방향이 잘 보이게 합니다.

arrow_vs.hlslhlsl
#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;
}
arrow_ps.hlslhlsl
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정점씩 셀 개수만큼 인스턴싱합니다.

렌더 루프cpp
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정점 × 셀 개수
    }
}
실전: flow map으로 굽기 수식으로 매 프레임 계산하는 대신, 실전에서는 windAt()RenderTexture(RG16F)에 한 번 렌더링해 flow map으로 굽습니다. 잔디 셰이더는 그 텍스처를 Sample 한 번으로 읽으므로 sin/exp 비용이 사라지고, 아티스트가 flow map을 손으로 칠해 바람을 디자인할 수도 있습니다. Ghost of Tsushima의 바람은 이렇게 텍스처 기반 필드 + 동적 vorticle의 조합입니다. 저사양 기기는 버텍스에서 텍스처 샘플이 느릴 수 있으니 셀 그리드 해상도로 타협합니다.
함정 화살표를 LINESTRIP으로 그릴 때 5정점이 인스턴스 경계를 넘어 이어지지 않도록 DrawInstanced가 인스턴스마다 정점 범위를 재시작한다는 점에 의존합니다(잔디 스트립과 동일 원리). 만약 한 버퍼에 모든 화살표를 이어 담아 비인스턴싱으로 그리면 셀과 셀 사이에 잘못된 선분이 생깁니다.

정리