툰 / 스타일라이즈드 잔디 — Breath of the Wild 풍
사실적 라이팅 대신 납작한 툰 셰이딩을 얹습니다. 연속적인 밝기 값을 픽셀에서
2~3단계로 밴딩(floor(x*bands)/bands)해 만화 같은 계단 톤을 만들고,
뿌리는 어둡게(AO 그라디언트), 끝은 밝게 하이라이트를 줍니다. blade는 사실적 잔디보다
넓적한 잎으로 만들어 실루엣을 강조하고, 바람은 여전히 버텍스에서 sin으로 흔듭니다.
앞서 만든 지오메트리 잔디의 인스턴싱 파이프라인을 그대로 재사용하고,
blade 폭과 픽셀 셰이더의 밴딩만 갈아끼우면 되는 게 이 기법의 매력입니다.
v' = v * M(HLSL mul(v, M)), 행렬 행 우선 저장,
왼손 좌표계, 클립 z 범위 [0, 1]. 뷰-투영은 DirectXMath 관례대로
view * proj 순서로 곱합니다. 아래 코드는 원리 확인용 학습 예제입니다.
▶ 실시간 데모
드래그로 회전, 휠로 줌. 아래에서 설명하는 코드가 실제로 이렇게 동작합니다. (WebGL2)
이 페이지에서 만들 것 (큰 그림)
- 넓적한 blade — 지오메트리 blade를 그대로 쓰되 버텍스에서 폭을 키워 넓은 잎을 만듭니다.
- 밴딩용 값 전달 — 픽셀 셰이더가 밴딩할 밝기
t와 색 변주colVar를 넘깁니다. - 색 밴딩 — 픽셀에서
floor(g*bands)/(bands-1)로 계단 톤을 만듭니다. - AO · 하이라이트 — 뿌리는 어둡게, 끝 근처는 하이라이트 색을 섞습니다.
- 밴드 수 uniform — cbuffer로 밴드 개수를 넘겨 실시간으로 만화↔부드러움을 조절합니다.
1. 넓적한 잎 — 폭만 키운 blade
툰 스타일의 실루엣은 넓적한 잎에서 나옵니다. 메시 자체는 지오메트리
잔디에서 만든 삼각형 스트립 blade를 그대로 재사용합니다. 정점당 데이터는 여전히
(side, t) 두 값뿐이고, 인스턴스 버퍼도 동일합니다. 달라지는 건 버텍스 셰이더에서 폭을 계산하는
수식 하나입니다. 사실적 blade는 (1-t)*0.06으로 위로 갈수록 급히 좁아졌지만, 툰은
(1 - t*0.6)*0.11로 더 넓게, 덜 좁아지게 만들어 잎처럼 보이게 합니다.
#include <d3d11.h>
#include <DirectXMath.h>
#include <wrl/client.h>
#include <vector>
#include <cmath>
using Microsoft::WRL::ComPtr;
using namespace DirectX;
// 08-geometry.html 과 동일한 blade / 인스턴스 구조를 재사용한다.
struct BladeVertex { float side; float t; }; // side=-1/+1, t=0..1
// 인스턴스당 데이터 (6 float = 24바이트)
struct GrassInstance {
float posX, posZ; // offset 0
float angle; // offset 8
float height; // offset 12
float phase; // offset 16
float colVar; // offset 20
};
static_assert(sizeof(GrassInstance) == 24, "stride 24로 입력 레이아웃과 일치");
// 넓적한 잎이라도 정점 수는 그대로. SEG=4면 충분(툰은 디테일보다 실루엣).
std::vector<BladeVertex> makeBlade(int SEG) {
std::vector<BladeVertex> v;
v.reserve((SEG + 1) * 2);
for (int i = 0; i <= SEG; ++i) {
float t = (float)i / SEG;
v.push_back({ -1.0f, t });
v.push_back({ +1.0f, t });
}
return v; // TRIANGLESTRIP 로 그린다
}
SEG=6을 썼지만, 툰은 색이 계단이라
구부러짐이 잘 안 보입니다. SEG=4(정점 10개)로 낮춰 대량 인스턴스의
정점 부하를 줄여도 실루엣 손해가 거의 없습니다.
2. 필드 흩뿌리기 (지오메트리와 동일)
포기의 위치·방향·키·위상은 지오메트리 잔디와 같은 방식으로
결정론적 해시 난수로 원판 안에 흩뿌립니다. 반지름을 sqrt(rand)로 뽑아
중심 쏠림을 막는 것도 동일합니다. 재사용이므로 짧게만 싣습니다.
std::vector<GrassInstance> scatterField(int count, float radius, uint32_t seed) {
float s = (float)seed;
auto rnd = [&]() {
s = std::sin(s * 12.9898f + 78.233f) * 43758.5453f;
return s - std::floor(s); // 0..1
};
std::vector<GrassInstance> out(count);
for (int i = 0; i < count; ++i) {
float a = rnd() * 6.2831853f;
float r = std::sqrt(rnd()) * radius; // 균일 분포
out[i].posX = std::cos(a) * r;
out[i].posZ = std::sin(a) * r;
out[i].angle = rnd() * 6.2831853f;
out[i].height = 0.8f + rnd() * 0.9f;
out[i].phase = rnd() * 6.2831853f;
out[i].colVar = rnd(); // 툰에서 잎마다 밝기 변주로 씀
}
return out;
}
3. cbuffer — 밴드 수를 uniform으로
툰의 핵심 조절 값은 색 밴드 수입니다. 밴드를 2로 줄이면 극단적인 만화가 되고, 6으로 늘리면
거의 부드러운 그라디언트가 됩니다. 이 값을 픽셀 셰이더에서 써야 하므로 프레임 상수 버퍼에 담아 넘깁니다.
바람 세기 uWind도 같이 넣습니다. 행렬은 규약대로 row_major로 저장합니다.
// HLSL cbuffer 와 바이트 레이아웃을 맞춘다(16바이트 정렬 주의).
struct ToonFrameCB {
XMFLOAT4X4 vp; // view * proj (row_major)
float time; // 애니메이션 시간
float wind; // 바람 세기
float bands; // 색 밴드 수(2..6). float로 넘겨 셰이더에서 나눗셈에 씀
float _pad; // 16바이트 정렬용
};
static_assert(sizeof(ToonFrameCB) % 16 == 0, "cbuffer는 16바이트 배수여야 함");
void updateToonCB(ID3D11DeviceContext* ctx, ID3D11Buffer* cb,
const XMMATRIX& viewProj, float time, float wind, int bands) {
ToonFrameCB data;
XMStoreFloat4x4(&data.vp, viewProj); // 전치 없이 그대로(row_major 규약)
data.time = time;
data.wind = wind;
data.bands = (float)bands;
data._pad = 0.0f;
ctx->UpdateSubresource(cb, 0, nullptr, &data, 0, 0);
}
4. 버텍스 셰이더 — 넓은 잎 + 바람
구조는 지오메트리 잔디와 같지만 두 군데가 다릅니다. 첫째, 폭을 (1 - t*0.6)*0.11로 키워
넓적한 잎을 만듭니다. 둘째, 픽셀에서 밴딩할 것이므로 여기서는 색을 거의 계산하지 않고
밝기용 t와 색 변주 colVar만 픽셀 셰이더로 넘깁니다.
바람은 두 주파수 sin을 t² 가중으로 섞어 뿌리는 고정, 끝은 크게 흔듭니다.
cbuffer Frame : register(b0) {
row_major float4x4 uVP; // view * proj
float uTime;
float uWind;
float uBands; // 픽셀에서 쓸 밴드 수(여기선 미사용)
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;
float t : TEXCOORD0; // 밝기 밴딩용
float v : TEXCOORD1; // 잎별 색 변주
};
VSOut main(VSIn i) {
float side = i.blade.x;
float t = i.blade.y;
float2 root = i.ia.xy;
float ang = i.ia.z;
float h = i.ia.w * 1.15; // 툰은 살짝 더 키를 준다
float phase = i.ib.x;
float w = (1.0 - t * 0.6) * 0.11; // 넓적한 잎 (덜 좁아짐)
// 두 주파수 sin, 끝(t²)일수록 크게
float sway = (sin(uTime * 1.4 + phase)
+ 0.4 * sin(uTime * 2.8 + phase * 1.7)) * 0.5 * uWind * t * t;
float3 dir = float3(cos(ang), 0.0, sin(ang));
float3 p = float3(root.x, 0.0, root.y);
p += dir * side * w; // 넓은 좌/우 폭
p.y += t * h; // 세우기
p.x += sway; // 바람으로 휘기
p.z += sway * 0.4;
VSOut o;
o.t = t;
o.v = i.ib.y; // colVar
o.pos = mul(float4(p, 1.0), uVP); // 행벡터 → mul(v, M)
return o;
}
t만 넘기고
밴딩은 반드시 픽셀 셰이더에서 해야 선명한 계단이 나옵니다.
5. 픽셀 셰이더 — 색 밴딩 + AO + 하이라이트
툰의 심장입니다. 연속적인 밝기 g(= t)를 floor(g*bands)/(bands-1)로
계단화합니다. bands가 3이면 밝기가 0 · 0.5 · 1 세 단계로만 튑니다.
그 계단 값으로 어두운 뿌리색과 밝은 끝색을 lerp해 AO 그라디언트를 만들고,
끝(g>0.95) 근처에는 step으로 하이라이트를 얹습니다.
마지막으로 잎별 colVar로 밝기를 살짝 흔들어 밭이 단조롭지 않게 합니다.
cbuffer Frame : register(b0) {
row_major float4x4 uVP;
float uTime;
float uWind;
float uBands; // 색 밴드 수
float _pad;
};
struct PSIn {
float4 pos : SV_Position;
float t : TEXCOORD0; // 밝기(0=뿌리, 1=끝)
float v : TEXCOORD1; // 잎별 변주
};
float4 main(PSIn i) : SV_Target {
float g = i.t;
// ── 핵심: 밝기를 bands 단계로 계단화 ──
float band = clamp(floor(g * uBands) / (uBands - 1.0), 0.0, 1.0);
float3 dark = float3(0.06, 0.20, 0.05); // 뿌리(어둡게 = AO)
float3 lite = float3(0.55, 0.82, 0.32); // 끝(밝게)
float3 c = lerp(dark, lite, band);
// 끝 근처만 하이라이트: step(0.95, g)가 1이면 밝은 톤으로
c = lerp(c, c * float3(1.05, 1.02, 0.85), step(0.95, g));
// 잎마다 밝기 변주로 단조로움 제거
c *= lerp(0.9, 1.1, i.v);
return float4(c, 1.0);
}
uBands가 1이면 (uBands - 1.0)이 0이 되어 0으로 나눔이 생깁니다.
슬라이더 최소값을 2로 막거나 셰이더에서 max(uBands, 2.0)로 방어하세요. 예제 UI는
밴드 범위를 2~6으로 제한합니다.
6. 드로우 — 지오메트리와 동일한 단일 콜
렌더 경로는 지오메트리 잔디와 완전히 같습니다. 두 정점 버퍼(blade·인스턴스)를
슬롯 0·1에 함께 바인딩하고, TRIANGLESTRIP으로 DrawInstanced 한 번.
셰이더만 툰 버전으로 바꾸고, 매 프레임 밴드 수를 cbuffer로 갱신하면 됩니다.
void drawToonGrass(ID3D11DeviceContext* ctx, const XMMATRIX& viewProj,
float time, float wind, int bands, UINT instanceCount) {
updateToonCB(ctx, g_frameCB.Get(), viewProj, time, wind, bands);
UINT strides[2] = { sizeof(BladeVertex), sizeof(GrassInstance) };
UINT offsets[2] = { 0, 0 };
ID3D11Buffer* vbs[2] = { g_bladeVB.Get(), g_instVB.Get() };
ctx->IASetInputLayout(g_layout.Get());
ctx->IASetVertexBuffers(0, 2, vbs, strides, offsets);
ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
ctx->VSSetShader(g_toonVS.Get(), nullptr, 0);
ctx->PSSetShader(g_toonPS.Get(), nullptr, 0);
ctx->VSSetConstantBuffers(0, 1, g_frameCB.GetAddressOf());
ctx->PSSetConstantBuffers(0, 1, g_frameCB.GetAddressOf()); // PS도 cbuffer 필요!
// blade 정점수, 인스턴스수, 시작정점, 시작인스턴스
ctx->DrawInstanced(g_bladeVertexCount, instanceCount, 0, 0);
}
VSSetConstantBuffers만 하고 PSSetConstantBuffers를 빠뜨리면 밴딩이 먹통이
되거나 쓰레기 값으로 색이 깨집니다. 슬롯 번호(b0)를 VS·PS 양쪽에서 맞춰 바인딩하세요.
정리
- 메시·인스턴싱은 지오메트리 잔디 그대로 재사용, 폭 수식만 넓은 잎으로.
- 밴딩은 반드시 픽셀 셰이더에서
floor(g*bands)/(bands-1). - 뿌리는 어둡게(AO), 끝
>0.95는step으로 하이라이트. - 밴드 수를 cbuffer uniform으로 넘겨 실시간 조절,
bands≥2방어 필수. - PS도 cbuffer가 필요하므로
PSSetConstantBuffers를 잊지 말 것.