← 개념 · 데모 가이드 | 구현 허브 | 8장. 잔디 렌더링 | 볼류메트릭 레이마칭
IMPLEMENTATION · CHAPTER 8 · 5/10

볼류메트릭 레이마칭 — 메시 0, 픽셀마다 광선 행진

이번엔 정반대의 접근입니다. 정점도 인스턴스도 하나 없습니다. 화면을 덮는 풀스크린 삼각형 하나만 그리고, 나머지는 전부 픽셀 셰이더가 합니다. 픽셀마다 카메라에서 광선을 쏘고, 3D 셀(Worley류) 노이즈로 잔디 밀도를 판정하며 볼륨 안을 스텝 행진해 색을 쌓습니다. 위로 갈수록 가늘어지는 taper로 잎 실루엣을 만들고, 잔디 슬랩(높이 0~1) 에서만 정밀하게 행진해 밴딩을 줄입니다. 비용은 정직하게 픽셀 수 × 스텝 수 — 이 페이지에서 풀스크린 삼각형 생성부터 광선 basis, 밀도 함수, 행진 루프까지 성분 하나까지 D3D11로 풀어봅니다.

규약 행벡터 v' = v * M(HLSL mul(v, M)), 행렬 행 우선 저장, 왼손 좌표계, 클립 z 범위 [0, 1]. 이 데모는 지오메트리를 변환하지 않고 픽셀 셰이더에서 광선을 직접 만들기 때문에 뷰-투영 행렬 대신 카메라 basis(eye/forward/right/up)를 cbuffer로 넘깁니다. 아래 코드는 원리 확인용 학습 예제입니다.

▶ 실시간 데모

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

초기화 중…
💡 메시 0인데 잔디밭이 나옴 = 순수 프래그먼트 셰이더. 스텝을 낮추면 빨라지지만 층이 보임.

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

  1. 풀스크린 삼각형 — 정점 버퍼 없이 SV_VertexID 3개로 화면 전체를 덮습니다.
  2. 카메라 basis cbuffer — eye·forward·right·up과 시간·바람·주파수·스텝수를 넘깁니다.
  3. 광선 생성 — NDC 좌표와 basis로 픽셀마다 광선 방향 rd를 만듭니다.
  4. 3D 셀 노이즈 밀도 — 해시로 셀마다 점을 흩뿌리고 최근접 거리로 잔디 유무를 판정합니다.
  5. 슬랩 안 행진 — 잔디 층 진입/바닥 지점을 해석적으로 구해 그 사이만 스텝 행진합니다.
  6. 드로우 1콜 — 깊이 테스트를 끄고 Draw(3, 0) 한 번으로 화면을 채웁니다.

1. 풀스크린 삼각형 — 정점 버퍼가 아예 없다

레이마칭은 화면의 모든 픽셀에서 픽셀 셰이더를 한 번씩 돌리는 게 목적입니다. 화면을 덮는 가장 싼 방법은 사각형(2삼각형·6정점)이 아니라 화면보다 큰 삼각형 하나입니다. 그런데 그 삼각형조차 정점 버퍼로 올릴 필요가 없습니다. 버텍스 셰이더에서 SV_VertexID(0,1,2)만 보고 클립 좌표를 계산하면 됩니다. 입력 레이아웃도, 정점 버퍼도 nullptr로 두고 Draw(3, 0)만 부릅니다.

fullscreen_vs.hlslhlsl
struct VSOut {
    float4 pos : SV_Position;
    float2 uv  : TEXCOORD0;   // NDC [-1,1] (광선 생성에 그대로 씀)
};

// 정점 버퍼 없이 SV_VertexID로 화면을 덮는 큰 삼각형을 만든다.
//  id=0 → (-1,-1), id=1 → (3,-1), id=2 → (-1,3)
//  이 세 점이 이루는 삼각형이 [-1,1]² 화면을 완전히 덮는다.
VSOut main(uint id : SV_VertexID) {
    VSOut o;
    float2 p = float2((id == 1) ? 3.0 : -1.0,
                      (id == 2) ? 3.0 : -1.0);
    o.uv  = p;                       // NDC 그대로 픽셀로 넘김
    o.pos = float4(p, 0.0, 1.0);     // 이미 클립 좌표
    return o;
}
왜 사각형이 아니라 삼각형인가 사각형은 대각선에서 두 삼각형이 맞닿아 그 라인의 픽셀이 두 번 셰이딩되고, 쿼드 단위 래스터라이저에서 캐시·미분 효율도 떨어집니다. 화면보다 큰 삼각형 하나는 대각선 봉합이 없어 픽셀당 정확히 한 번만 돕니다. 클리핑으로 잘려 나가는 바깥 부분은 래스터 단계에서 공짜로 버려집니다.

2. 카메라 basis를 cbuffer로 넘긴다

보통은 정점을 view * proj로 변환하지만, 여기선 반대로 픽셀에서 월드로 광선을 쏩니다. 그러려면 카메라의 위치(eye)와 세 축(forward/right/up)이 필요합니다. C++에서 카메라 파라미터로부터 이 basis를 만들어 cbuffer로 올립니다. 원본 WebGL 데모의 camBasis()와 같은 값을 DirectXMath로 계산하는 것뿐입니다.

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

// HLSL cbuffer와 바이트 배치를 정확히 맞춘다 (16바이트 정렬).
struct RayCB {
    XMFLOAT3 eye;     float aspect;   // ro + 화면 종횡비
    XMFLOAT3 fwd;     float time;     // forward + uTime
    XMFLOAT3 right;   float freq;     // right   + 밀도 주파수
    XMFLOAT3 up;      float wind;     // up      + 바람 세기
    int   steps;      float _pad[3];  // 스텝 수
};
static_assert(sizeof(RayCB) % 16 == 0, "cbuffer는 16바이트 배수여야 함");

// 카메라 각도(yaw/pitch)와 거리로 basis를 만든다 (좌표계: 왼손, y-up).
RayCB makeRayCB(float yaw, float pitch, float dist,
                XMFLOAT3 target, float aspect,
                float time, float freq, float wind, int steps) {
    // 궤도 카메라: target을 중심으로 yaw/pitch/dist로 eye를 배치
    XMVECTOR tgt = XMLoadFloat3(&target);
    XMVECTOR off = XMVectorSet(
        cosf(pitch) * sinf(yaw),
        sinf(pitch),
        cosf(pitch) * cosf(yaw), 0.0f);
    XMVECTOR eye = XMVectorAdd(tgt, XMVectorScale(off, dist));

    XMVECTOR fwd   = XMVector3Normalize(XMVectorSubtract(tgt, eye));
    XMVECTOR wUp   = XMVectorSet(0, 1, 0, 0);
    XMVECTOR right = XMVector3Normalize(XMVector3Cross(wUp, fwd)); // 왼손: up × fwd
    XMVECTOR up    = XMVector3Cross(fwd, right);

    RayCB cb = {};
    XMStoreFloat3(&cb.eye, eye);   cb.aspect = aspect;
    XMStoreFloat3(&cb.fwd, fwd);   cb.time   = time;
    XMStoreFloat3(&cb.right, right); cb.freq = freq;
    XMStoreFloat3(&cb.up, up);     cb.wind   = wind;
    cb.steps = steps;
    return cb;
}
함정 HLSL cbuffer는 float3 뒤에 float 하나를 붙이면 딱 16바이트로 맞아 떨어집니다(위 구조체가 그 배치). 무심코 float3 뒤에 또 float3를 두면 패킹 규칙 때문에 두 번째가 다음 16바이트 경계로 밀려 값이 어긋납니다. C++ 구조체와 HLSL cbuffer 선언의 필드 순서·크기를 한 몸으로 관리하세요.

3. 광선 생성 + 3D 셀 노이즈 밀도 함수

픽셀 셰이더가 받은 NDC 좌표 uv와 basis로 광선 방향을 만듭니다. rd = normalize(fwd + (uv.x·aspect·right + uv.y·up)·0.55) — 0.55는 시야각(FOV)을 정하는 상수입니다. 밀도 함수 dens()는 월드 점 wp가 잔디 안인지(1)/밖인지(0) 판정합니다. 높이 h = wp.y가 0~1을 벗어나면 잔디가 없고, 그 안에서는 3×3 이웃 셀을 훑어 해시로 흩뿌린 점까지의 최근접 거리(Worley)를 재고, 그 거리가 0.42·(1-h)보다 작으면 잔디로 칩니다. (1-h)가 곱해져 위로 갈수록 임계가 작아져 가늘어지는 taper가 자연히 생깁니다. 바람은 샘플 좌표를 시간에 따라 흔들어 넣습니다.

raymarch_ps.hlsl (밀도)hlsl
cbuffer RayCB : register(b0) {
    float3 uRo;  float uAsp;   // eye + 종횡비
    float3 uF;   float uT;     // forward + time
    float3 uR;   float uFreq;  // right + 밀도 주파수
    float3 uU;   float uWind;  // up + 바람
    int    uSteps; float3 _pad;
};

// 2D 해시: 셀 좌표 → [0,1)² 난수점 (WebGL 데모 h2()와 동일 상수)
float2 h2(float2 p) {
    p = float2(dot(p, float2(127.1, 311.7)),
               dot(p, float2(269.5, 183.3)));
    return frac(sin(p) * 43758.5453);
}

// 월드 점 wp가 잔디 볼륨 안이면 1, 아니면 0.
float dens(float3 wp) {
    float h = wp.y;
    if (h < 0.0 || h > 1.0) return 0.0;          // 슬랩 밖

    // 바람: 높이²에 비례해 위쪽일수록 크게 흔든다
    float2 wind = float2(sin(uT * 1.2 + wp.z * 0.6),
                         cos(uT * 1.0 + wp.x * 0.6)) * 0.12 * h * h * uWind;

    float2 xz   = (wp.xz + wind) * uFreq;         // 샘플 좌표(주파수 스케일)
    float2 cell = floor(xz);
    float  d    = 9.0;
    // 3×3 이웃 셀에서 최근접 점까지 거리 (Worley)
    [unroll] for (int j = -1; j <= 1; ++j)
    [unroll] for (int i = -1; i <= 1; ++i) {
        float2 g   = float2(i, j);
        float2 o   = h2(cell + g);
        float2 pos = cell + g + o;
        d = min(d, length(xz - pos));
    }
    // 위로 갈수록 임계가 작아져(=가늘어져) 잎 taper가 생긴다
    return d < 0.42 * (1.0 - h) ? 1.0 : 0.0;
}

4. 슬랩 안에서만 정밀 행진

핵심 최적화입니다. 잔디 볼륨은 y ∈ [0,1]의 얇은 슬랩입니다. 광선이 아래를 향할 때 (rd.y < 0) 이 슬랩에 언제 들어가고(tE) 언제 바닥에 닿는지(tG)해석적으로 풀 수 있습니다. 그 두 지점 사이만 균등 스텝으로 행진하면, 허공을 낭비 없이 건너뛰고 잔디가 있을 법한 구간만 촘촘히 볼 수 있어 밴딩이 크게 줄어듭니다. 밀도가 1인 첫 지점에서 높이 h로 색을 정하고 멈춥니다.

raymarch_ps.hlsl (행진 + main)hlsl
struct PSIn {
    float4 pos : SV_Position;
    float2 uv  : TEXCOORD0;
};

float4 main(PSIn i) : SV_Target {
    // 픽셀 NDC + basis로 광선 생성 (0.55 = FOV 상수)
    float3 rd = normalize(uF + (i.uv.x * uAsp * uR + i.uv.y * uU) * 0.55);
    float3 ro = uRo;

    // 하늘(위쪽 밝고 지평선 어둡게)
    float3 sky = lerp(float3(0.55, 0.72, 0.85),
                      float3(0.20, 0.36, 0.52), saturate(rd.y));
    float3 col  = sky;
    float  tHit = 34.0;

    if (rd.y < -0.0001) {
        // 잔디 슬랩(y:1→0) 진입/바닥 t를 해석적으로 구해 그 사이만 행진
        float tE = max((1.0 - ro.y) / rd.y, 0.0);   // y=1 진입
        float tG = (0.0 - ro.y) / rd.y;             // y=0 바닥
        float step = clamp((tG - tE) / (float)uSteps, 0.008, 0.35);
        float t = tE;
        bool found = false;

        [loop] for (int s = 0; s < 200; ++s) {
            if (s >= uSteps) break;
            float3 p = ro + rd * t;
            if (dens(p) > 0.5) {
                float h = p.y;
                col  = lerp(float3(0.05, 0.16, 0.04),
                            float3(0.5, 0.78, 0.28), h)
                     * lerp(0.3, 1.0, h) * (0.6 + 0.4 * h);
                tHit = t; found = true; break;
            }
            if (p.y <= 0.0) break;
            t += step;
        }
        if (!found) {
            float3 p = ro + rd * t;
            if (p.y <= 0.03) { col = float3(0.10, 0.14, 0.07); tHit = tG; } // 사이로 바닥
            else             { tHit = t; }
        }
    }

    // 거리 안개로 먼 잔디를 하늘색에 녹인다
    float fog = 1.0 - exp(-tHit * 0.05);
    col = lerp(col, sky, fog * 0.7);
    return float4(pow(max(col, 0.0), 0.9.xxx), 1.0);  // 감마 살짝
}
비용 = 픽셀 수 × 스텝 수 이 기법의 값은 오직 두 수의 곱입니다. 스텝을 140→40으로 낮추면 픽셀 셰이더 부하가 3배 이상 가벼워지지만, 슬랩을 성기게 훑어 잔디 표면에 층(밴딩)이 보이기 시작합니다. 반대로 스텝을 올리면 부드럽지만 4K·저사양에서 급격히 느려집니다. 해상도(픽셀 수)를 0.7배로 낮춰 렌더 타깃을 쓰고 업스케일하면 스텝을 유지하면서도 큰 이득을 봅니다.

5. 드로우 — 깊이 끄고 삼각형 하나

렌더 루프는 허무할 정도로 짧습니다. 정점 버퍼도 입력 레이아웃도 없으니 IASetVertexBuffers조차 부르지 않습니다. cbuffer만 갱신하고, 깊이 테스트를 끄고(레이마칭이 깊이를 직접 다루므로), 토폴로지를 TRIANGLELIST로 두고 Draw(3, 0) 한 번이면 끝입니다.

렌더 루프cpp
void drawRaymarchGrass(ID3D11DeviceContext* ctx, const RayCB& cbData) {
    // 1) cbuffer 갱신
    D3D11_MAPPED_SUBRESOURCE m;
    ctx->Map(g_rayCB.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &m);
    memcpy(m.pData, &cbData, sizeof(RayCB));
    ctx->Unmap(g_rayCB.Get(), 0);

    // 2) 입력 조립 없음 — 정점 버퍼/레이아웃 모두 미설정
    ctx->IASetInputLayout(nullptr);
    ctx->IASetVertexBuffers(0, 0, nullptr, nullptr, nullptr);
    ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

    // 3) 셰이더 + cbuffer(픽셀 셰이더에도 바인딩)
    ctx->VSSetShader(g_fsVS.Get(), nullptr, 0);
    ctx->PSSetShader(g_rayPS.Get(), nullptr, 0);
    ctx->PSSetConstantBuffers(0, 1, g_rayCB.GetAddressOf());

    // 4) 깊이 테스트 OFF — 레이마칭이 픽셀별로 깊이를 스스로 결정
    ctx->OMSetDepthStencilState(g_dssNoDepth.Get(), 0);

    // 5) 삼각형 하나로 화면 전체
    ctx->Draw(3, 0);
}
함정 다른 불투명 지오메트리와 함께 합성하려면 위 maintHit를 이미 계산하므로 이를 클립 z로 변환해 SV_Depth로 출력해야 깊이 버퍼와 올바르게 섞입니다. 이 예제는 배경 전용이라 깊이를 끄지만, 씬에 얹을 때는 깊이 출력을 반드시 추가하세요.

정리