Vertex Shader — 한 줄 한 줄 직접 구현
버텍스 셰이더는 GPU 파이프라인에서 정점(vertex) 하나마다 한 번씩 실행되는 작은 프로그램입니다.
맡은 일은 딱 하나, 정점을 모델 공간 → 월드 → 카메라(뷰) → 클립 공간으로 밀어 넣어
래스터라이저가 삼각형을 화면에 찍을 수 있도록 SV_Position을 내보내는 것입니다.
이 페이지에서는 기본 변환 셰이더와 정점 변위(vertex displacement) 셰이더를,
시맨틱·상수 버퍼·mul 곱셈 순서·성분 하나까지 전부 뜯어봅니다.
수학 원리 → 기하 직관 → 코드 매핑의 3단으로, 입문자도 한 줄도 놓치지 않게 설명합니다.
버텍스 셰이더가 파이프라인에서 하는 일
삼각형 하나를 화면에 그린다고 해봅시다. GPU에게 필요한 것은 그 삼각형 세 꼭짓점의 클립 공간(clip space) 좌표입니다. 그런데 우리가 CPU에서 준비하는 정점은 보통 모델 공간(model space) 좌표입니다. 예를 들어 주전자 모델은 원점 근처에 “주전자 모양”으로 놓여 있을 뿐, 아직 씬 어디에 놓일지·카메라가 어디서 볼지·화면에 어떻게 투영될지는 정해지지 않았죠. 버텍스 셰이더는 이 “아직 정해지지 않은 좌표”를 여러 행렬로 연쇄 변환해서, 최종적으로 래스터라이저가 곧바로 쓸 수 있는 클립 공간 좌표로 바꿔줍니다.
- 입력을 받는다. 입력 레이아웃(
ID3D11InputLayout)이 정점 버퍼의 바이트들을POSITION·NORMAL·TEXCOORD0같은 시맨틱으로 매핑해 구조체 필드에 꽂아줍니다. - 변환한다. 상수 버퍼로 넘어온 모델·뷰·투영 행렬을 곱해 좌표를 클립 공간까지 밀어 넣습니다.
- 보간할 값을 정한다. 픽셀 셰이더에서 쓸 월드 위치·법선·UV를 출력 구조체에 담습니다. 이 값들은 삼각형 표면을 따라 자동 보간(interpolate)되어 픽셀 셰이더로 넘어갑니다.
- SV_Position을 낸다. 딱 하나 필수 출력. 이게 없으면 래스터화 자체가 불가능합니다.
v' = v * M, HLSL로는 mul(v, M)),
행 우선(row-major) 저장, 왼손 좌표계, 클립 z 범위 [0, 1],
텍스처 V축은 위→아래. DirectXMath(XMMATRIX)가 바로 이 행벡터 규약이라 궁합이 좋습니다.
아래 코드가 mul(matrix, vector)(열벡터처럼 보이는) 순서를 쓰는 이유는 뒤에서 정확히 설명합니다 — 결론만 미리 말하면
CPU에서 한 번 transpose 해서 올려보내기 때문입니다.
1. 기본 변환 버텍스 셰이더
무엇을 만드나: 모델 공간 정점을 받아 월드·뷰·투영을 차례로 곱해 클립 공간 좌표를 만들고, 픽셀 셰이더가 조명 계산에 쓸 월드 위치·법선·UV를 함께 넘기는 “표준” 버텍스 셰이더입니다. 왜 필요한가: 거의 모든 3D 렌더링의 출발점입니다. 여기서 좌표계를 한 번 잘 잡아 두면 이후 조명·그림자·텍스처가 전부 이 위에서 돌아갑니다.
// ── 정점 입력 (입력 레이아웃의 시맨틱과 일치해야 한다) ──
struct VSIn {
float3 pos : POSITION; // 위치
float3 normal : NORMAL; // 법선
float2 uv : TEXCOORD0; // 텍스처 좌표
};
// ── 픽셀 셰이더로 넘길 값 (표면을 따라 보간됨) ──
struct VSOut {
float4 clipPos : SV_Position; // 필수: 클립 공간 좌표
float3 worldPos : TEXCOORD0;
float3 normal : TEXCOORD1;
float2 uv : TEXCOORD2;
};
// ── 상수 버퍼 (모든 정점이 공유하는 상수) ──
// CPU에서 이미 transpose 해 넣으므로 셰이더에서는 mul(matrix, vector) 관례를 쓴다.
cbuffer Transforms : register(b0) {
float4x4 uModel;
float4x4 uView;
float4x4 uProj;
};
VSOut VSMain(VSIn i) {
VSOut o;
float4 world = mul(uModel, float4(i.pos, 1.0));
o.worldPos = world.xyz;
// 법선은 모델의 역전치 행렬로 변환해야 정확하지만,
// 여기서는 균등 스케일 가정하에 (float3x3)uModel 로 근사한다.
o.normal = mul((float3x3)uModel, i.normal);
o.uv = i.uv;
// 최종 출력: 클립 공간 좌표 (필수)
o.clipPos = mul(uProj, mul(uView, world));
return o;
}
입력 구조체 VSIn — 시맨틱이 “배선도”다
struct VSIn은 이 셰이더가 정점 하나에서 무엇을 받을지를 선언합니다.
핵심은 필드 뒤에 붙은 콜론 뒤의 이름, 즉 시맨틱(semantic)입니다.
HLSL은 필드 이름(pos, normal)으로 데이터를 찾지 않습니다.
대신 시맨틱(POSITION, NORMAL, TEXCOORD0)으로 찾습니다.
수학/그래픽스 원리: 정점 버퍼는 GPU 입장에서 그냥 바이트 덩어리입니다.
“이 32바이트 중 앞 12바이트는 위치, 다음 12바이트는 법선, 마지막 8바이트는 UV”라는 해석 규칙이 필요한데,
그 규칙이 CPU 쪽의 입력 레이아웃(ID3D11InputLayout)입니다.
입력 레이아웃의 각 원소도 시맨틱 문자열("POSITION" 등)을 갖고 있고, 셰이더의 시맨틱과 문자열이 일치할 때
비로소 배선이 연결됩니다.
기하 직관(그림글): 시맨틱을 “전선에 붙인 이름표”라고 상상해 보세요.
CPU 쪽 정점 버퍼에서 나온 여러 가닥의 전선(위치선·법선선·UV선)이 있고,
셰이더 입력에도 같은 이름표가 붙은 소켓이 있습니다. GPU는 이름표가 같은 것끼리 꽂습니다.
필드 이름을 p로 바꾸든 position으로 바꾸든 상관없지만, : POSITION 이름표가 어긋나면
엉뚱한 데이터가 들어오거나 링크가 깨집니다.
코드 매핑:
float3 pos : POSITION— 모델 공간 위치(x, y, z). 3개 float라float3.float3 normal : NORMAL— 표면 법선. 조명 계산의 핵심 재료입니다.float2 uv : TEXCOORD0— 텍스처 좌표. 뒤의0은 “0번 텍스처 좌표 세트”라는 인덱스입니다. UV 세트를 여러 개 쓰면TEXCOORD1,TEXCOORD2로 늘립니다.
DXGI_FORMAT이 셰이더 VSIn과 어긋나면,
최선의 경우 화면이 깨지고 최악의 경우 CreateInputLayout이 E_INVALIDARG로 실패합니다.
특히 float3 pos에는 DXGI_FORMAT_R32G32B32_FLOAT를,
float2 uv에는 DXGI_FORMAT_R32G32_FLOAT를 정확히 맞춰야 합니다.
바이트 오프셋을 손으로 계산하기 싫으면 D3D11_APPEND_ALIGNED_ELEMENT를 오프셋에 넣으면 자동 누적됩니다.
출력 구조체 VSOut — 무엇이 필수이고 무엇이 보간되나
버텍스 셰이더는 정점당 한 번 실행되어 VSOut을 하나씩 뱉습니다.
삼각형은 꼭짓점 3개이므로 VSOut이 3개 나오고, 그 사이의 픽셀들은 래스터라이저가
세 값을 무게중심 보간(barycentric interpolation)으로 섞어 채웁니다.
그래서 VSOut에 담는 값은 “픽셀 셰이더가 표면 위 각 지점에서 알고 싶어 하는 것”이어야 합니다.
수학/그래픽스 원리: 삼각형 내부의 한 픽셀 위치는 세 꼭짓점의 가중 평균
α·v0 + β·v1 + γ·v2(α+β+γ=1)로 표현됩니다.
래스터라이저는 이 (α, β, γ)를 픽셀마다 구해서, VSOut의 각 필드에
똑같은 가중치로 섞어 픽셀 셰이더 입력을 만듭니다. 실제로는 원근 보정(perspective-correct)까지 들어가지만,
개념은 “세 꼭짓점 값의 부드러운 섞기”입니다.
기하 직관(그림글): 삼각형 세 모서리에 각각 빨강·초록·파랑 물감을 묻히고,
안쪽을 물로 번지게 하면 가운데는 세 색이 섞인 갈색이 됩니다. VSOut의 normal이나 uv도
이런 식으로 표면 위에서 매끄럽게 번져서 각 픽셀에 도착합니다.
코드 매핑:
float4 clipPos : SV_Position— 유일한 필수 출력.SV_접두사는 “System-Value”, 즉 파이프라인이 특별하게 다루는 시스템 값이라는 뜻입니다. 래스터라이저는 이 값으로 원근 나눗셈과 뷰포트 변환을 수행합니다.float4인 이유는 클립 공간이 동차좌표(x, y, z, w)이기 때문입니다.float3 worldPos : TEXCOORD0— 월드 공간 위치. 스페큘러·포인트 라이트·거리 감쇠처럼 “픽셀이 씬에서 어디 있나”가 필요한 계산에 씁니다. 사용자 정의 보간 값이라TEXCOORD시맨틱을 빌려 씁니다.float3 normal : TEXCOORD1— 보간될 법선. (보간 후에는 길이가 1이 아닐 수 있어 픽셀 셰이더에서 다시normalize합니다.)float2 uv : TEXCOORD2— 텍스처 좌표.
SV_Position은 반드시 float4여야 하고, 반드시 하나 내보내야 합니다.
또한 사용자 보간 값에 TEXCOORD0부터 이어서 번호를 붙이는 관례를 쓰는데, 픽셀 셰이더의 입력 구조체와
시맨틱이 정확히 짝이 맞아야 합니다. VSOut의 TEXCOORD1(법선)을 픽셀 셰이더에서
TEXCOORD2로 받으면 UV와 법선이 뒤바뀝니다.
상수 버퍼 cbuffer Transforms — 모든 정점이 공유하는 값
cbuffer(constant buffer)는 한 드로우콜 동안 모든 정점이 똑같이 보는 상수 묶음입니다.
정점마다 달라지는 값은 VSIn으로 오지만, 카메라 행렬처럼 “이번 프레임 내내 동일한” 값은 상수 버퍼로 올립니다.
매 정점 실행마다 GPU 상수 캐시에서 빠르게 읽히므로 성능상 유리합니다.
코드 매핑:
register(b0)— 이 상수 버퍼를 b(buffer) 슬롯 0번에 바인딩합니다. CPU에서VSSetConstantBuffers(0, 1, &cb)로 꽂은 버퍼와 이 번호가 일치해야 합니다.float4x4 uModel / uView / uProj— 각각 모델·뷰·투영 행렬.float4x4는 4×4 = 16개 float, 64바이트짜리 정사각 행렬입니다.
float4 하나) 경계에 정렬됩니다.
float4x4는 정확히 4개의 16바이트 행이라 자연스럽게 맞지만, float 한 개만 넣으면 뒤에 12바이트가 낭비되며
float3 뒤에 float을 이어 붙이면 경계를 걸치지 않도록 패딩 규칙에 주의해야 합니다.
자주 바뀌는 값(프레임마다 변하는 uView)과 거의 안 바뀌는 값을 별도 cbuffer로 분리하면
업로드 대역폭을 아낄 수 있습니다.
VSMain 본문 — 좌표 변환의 심장부
VSOut VSMain(VSIn i)는 이 셰이더의 진입점(entry point)입니다.
반환 타입이 VSOut이고 인자가 VSIn i 하나입니다.
CPU에서 ID3D11Device::CreateVertexShader로 컴파일할 때 이 함수 이름(VSMain)과
셰이더 모델(vs_5_0 등)을 지정합니다.
① 월드 변환: float4 world = mul(uModel, float4(i.pos, 1.0));
수학/그래픽스 원리: 위치 벡터를 행렬로 변환하려면 이동(translation)까지 표현하기 위해
동차좌표(homogeneous coordinate) w = 1을 붙여 4차원으로 올려야 합니다.
그래서 float4(i.pos, 1.0) — 즉 (x, y, z, 1)을 만듭니다.
만약 w를 0으로 두면 그건 “위치”가 아니라 “방향”이라 이동 성분이 무시됩니다(법선이 바로 그 경우입니다).
왜 1.0인가: 4×4 변환 행렬의 마지막 열(또는 우리 규약에선 이동을 담는 부분)이
w와 곱해집니다. w = 1이어야 “행렬에 들어 있는 이동량 × 1 = 그대로 더해짐”이 됩니다.
방향 벡터라면 이동되면 안 되므로 w = 0을 씁니다.
기하 직관(그림글): 모델 공간의 주전자를 씬 안 “탁자 위”로 옮기고, 필요하면 회전·크기 조절까지 하는 단계입니다.
uModel이 그 “놓기” 정보 전체(이동+회전+스케일)를 담고 있습니다.
코드 매핑 — mul의 순서 문제: 여기서 많이 헷갈리는 지점이 있습니다.
우리 시리즈 규약은 행벡터 v' = v * M인데, 코드는 mul(uModel, v) 즉 행렬이 앞입니다.
이게 모순처럼 보이지만 아닙니다. 비밀은 CPU에서 행렬을 미리 transpose 해서 올린다는 데 있습니다.
행벡터 수식 v * M은 열벡터 수식 MT * v와 완전히 같은 결과를 냅니다.
그래서 CPU에서 MT(transpose)를 cbuffer에 넣어두면, 셰이더에서 mul(uModel, v)
(즉 MT * v)를 계산했을 때 결국 우리가 원하는 v * M과 같아집니다.
코드 주석의 “CPU에서 이미 transpose 해 넣으므로”가 바로 이 뜻입니다.
XMMatrixTranspose로
전치해 올리고 셰이더는 mul(matrix, vector). (B) 전치하지 않고 올린 뒤 셰이더에서 mul(vector, matrix).
둘은 수학적으로 동치입니다. 절대 하면 안 되는 것은 전치는 했는데 셰이더에서 mul(vector, matrix)를 쓰는 것 —
그러면 두 번 뒤집혀 원본 행렬로 계산되어 물체가 엉뚱하게 찌그러집니다.
② 월드 위치 저장: o.worldPos = world.xyz;
변환 결과 world는 float4인데, 픽셀 셰이더가 필요한 것은 3차원 월드 좌표입니다.
.xyz는 HLSL의 스위즐(swizzle) 문법으로, 앞의 세 성분만 뽑아 float3로 만듭니다.
w는 이 시점에서 아직 1(월드 변환은 아핀 변환이라 w를 건드리지 않음)이라 버려도 안전합니다.
기하 직관: 픽셀 셰이더에서 “이 표면 지점이 씬의 정확히 어느 위치인가”를 알아야 광원까지의 방향, 카메라까지의 방향, 거리 감쇠 등을 계산할 수 있습니다. 그 재료를 여기서 넘겨주는 겁니다.
③ 법선 변환: o.normal = mul((float3x3)uModel, i.normal);
수학/그래픽스 원리: 법선은 위치가 아니라 방향이라 이동이 적용되면 안 됩니다.
그래서 4×4 행렬의 왼쪽 위 3×3 부분만 떼어 씁니다 — 그게 (float3x3)uModel 캐스팅입니다.
이 3×3 블록에는 회전과 스케일만 들어 있고 이동은 빠집니다.
왜 “근사”인가 — 역전치(inverse-transpose): 엄밀히 말하면 법선은 모델 행렬의
역행렬의 전치((M-1)T)로 변환해야 정확합니다.
이유는 직관적으로 이렇습니다. 물체를 한 축으로만 길게 늘이면(비균등 스케일) 표면은 늘어나지만
법선은 “표면에 수직”을 유지해야 하므로 반대 방향으로 눌려야 합니다.
일반 모델 행렬로 법선을 변환하면 이 눌림이 반영되지 않아 법선이 표면과 어긋납니다.
이 어긋남을 정확히 바로잡는 것이 역전치 행렬입니다.
왜 코드는 근사로 충분한가: 스케일이 모든 축에 동일한 균등 스케일(uniform scale)이면
(M-1)T는 원래 행렬의 3×3 부분과 상수배만 다릅니다.
법선은 어차피 픽셀 셰이더에서 normalize로 다시 길이 1로 맞추므로, 상수배는 결과에 영향이 없습니다.
그래서 균등 스케일 가정에서는 (float3x3)uModel만으로 충분합니다.
비균등 스케일을 쓸 거라면 CPU에서 역전치 행렬을 따로 계산해 별도 cbuffer로 넘겨야 합니다.
Scale(1, 3, 1))을 쓰는데 이 근사를 그대로 두면, 조명 하이라이트가 표면과 어긋나
“젖은 것처럼” 번들거리거나 그림자가 이상하게 집니다. 눈에 확 띄는 버그이니 스케일을 비균등으로 바꾸는 순간
이 줄부터 의심하세요.
④ UV 통과: o.uv = i.uv;
텍스처 좌표는 정점에 이미 박혀 있고 좌표 변환의 영향을 받지 않으므로, 그대로 다음 단계로 흘려보냅니다. 이렇게 “계산 없이 넘기기만” 하는 것을 패스스루(pass-through)라고 부릅니다. 래스터라이저가 이 UV를 삼각형 표면을 따라 보간해서, 각 픽셀이 텍스처의 어느 지점을 샘플할지 알게 됩니다.
v=0이 맨 위)입니다.
OpenGL/GLSL 습관대로 만든 UV나 뒤집힌 텍스처를 그대로 쓰면 상하가 뒤집혀 보입니다.
필요하면 o.uv = float2(i.uv.x, 1.0 - i.uv.y);처럼 V를 뒤집어 맞추세요.
⑤ 클립 공간 좌표: o.clipPos = mul(uProj, mul(uView, world));
수학/그래픽스 원리: 안쪽부터 밖으로 읽습니다. mul(uView, world)로 월드 좌표를
뷰(카메라) 공간으로 옮긴 뒤, 그 결과에 uProj를 곱해 클립 공간으로 투영합니다.
이미 world = mul(uModel, ...)가 앞에서 계산됐으니, 전체 사슬은 결국
uProj · uView · uModel · v(전치된 행렬 기준의 좌곱)가 됩니다.
왜 이 순서(안→밖)인가: 좌표계 변환은 “가장 먼저 벗어나야 할 공간”을 가장 안쪽에 둡니다.
정점은 모델 공간에서 출발하므로 모델 → 뷰 → 투영 순서로 벗어나야 하고,
(전치 규약의) 좌곱 표기에서는 이 순서가 uProj(uView(uModel · v))로 안쪽에서부터 나타납니다.
기하 직관(그림글):
- 모델 → 월드: 주전자를 씬의 탁자 위에 놓습니다.
- 월드 → 뷰: 카메라를 원점에 두고 앞을 보도록, 온 씬을 통째로 돌려 “카메라 기준 좌표”로 바꿉니다.
- 뷰 → 클립: 원근을 적용해 먼 것은 작게, 가까운 것은 크게. 화면에 나올 범위를 정규화된 상자 안으로 밀어 넣습니다.
클립 공간과 w: 투영 행렬을 곱하면 결과 clipPos.w에 “카메라로부터의 깊이”가 담깁니다.
이후 래스터라이저가 자동으로 xyz / w(원근 나눗셈, perspective divide)를 수행해
정규화 장치 좌표(NDC)로 만듭니다. 우리 규약의 왼손 좌표계·클립 z 범위 [0, 1]은
이 투영 행렬(XMMatrixPerspectiveFovLH)이 만들어 줍니다. 이 나눗셈은 셰이더가 아니라 파이프라인이 하므로,
버텍스 셰이더는 xyzw를 그대로 SV_Position에 내보내기만 하면 됩니다.
o.clipPos = world;처럼 뷰·투영을 빼먹고 내보내면, 원근도 카메라도 없이 월드 좌표가 곧장 클립 좌표로 해석되어
물체가 화면 밖으로 날아가거나 납작하게 보입니다. SV_Position에는 반드시 투영까지 끝난 값을 넣어야 합니다.
⑥ 반환: return o;
완성한 VSOut을 반환하면 GPU가 SV_Position은 래스터라이저로,
나머지 시맨틱 값들은 보간기(interpolator)로 보냅니다. 정점 하나에 대한 버텍스 셰이더 실행은 여기서 끝납니다.
손으로 따라가는 작은 수치 예제
아주 단순한 경우로 감을 잡아봅시다. uModel이 “x축으로 +10 이동”만 하는 행렬이라고 합시다.
정점 i.pos = (2, 0, 0)이면:
- 동차좌표로 올리면
(2, 0, 0, 1). - 이동 행렬을 곱하면
world = (2 + 10, 0, 0, 1) = (12, 0, 0, 1). →o.worldPos = (12, 0, 0). - 법선이
(0, 1, 0)이면, 이동은 3×3 블록에 없으므로o.normal = (0, 1, 0)그대로. (이동이 법선을 건드리지 않는다는 게 이 예제로 눈에 보입니다.)
만약 w를 실수로 0으로 뒀다면 world = (2, 0, 0, 0)이 되어 이동 +10이 통째로 사라집니다.
w = 1이 이동을 살리는 스위치라는 걸 이 대비로 확인할 수 있습니다.
DirectXMath / D3D API 대응 (CPU 쪽)
셰이더가 mul(matrix, vector)로 잘 돌아가려면 CPU에서 행렬을 전치해서 올려야 합니다.
아래는 상수 버퍼를 채워 업로드하는 전형적인 코드입니다.
// ── CPU 쪽 cbuffer와 동일한 레이아웃 (16바이트 정렬에 유의) ──
struct TransformsCB {
DirectX::XMFLOAT4X4 model;
DirectX::XMFLOAT4X4 view;
DirectX::XMFLOAT4X4 proj;
};
void UploadTransforms(ID3D11DeviceContext* ctx,
ID3D11Buffer* cb,
DirectX::FXMMATRIX model,
DirectX::FXMMATRIX view,
DirectX::FXMMATRIX proj)
{
using namespace DirectX;
// 핵심: 셰이더의 mul(matrix, vector) 관례에 맞추려면 전치해서 넣는다.
XMMATRIX mT = XMMatrixTranspose(model);
XMMATRIX vT = XMMatrixTranspose(view);
XMMATRIX pT = XMMatrixTranspose(proj);
// 매핑해서 GPU 메모리에 직접 기록 (매 프레임 갱신용 DYNAMIC 버퍼 가정)
D3D11_MAPPED_SUBRESOURCE mapped;
ctx->Map(cb, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped);
auto* dst = reinterpret_cast<TransformsCB*>(mapped.pData);
XMStoreFloat4x4(&dst->model, mT);
XMStoreFloat4x4(&dst->view, vT);
XMStoreFloat4x4(&dst->proj, pT);
ctx->Unmap(cb, 0);
// b0 슬롯에 바인딩 (register(b0) 와 일치)
ctx->VSSetConstantBuffers(0, 1, &cb);
}
줄 단위 대응:
| 셰이더(HLSL) | CPU(DirectXMath / D3D11) | 의미 |
|---|---|---|
cbuffer Transforms : register(b0) | VSSetConstantBuffers(0, 1, &cb) | b0 슬롯 바인딩 일치 |
float4x4 uModel (전치본) | XMMatrixTranspose(model) → XMStoreFloat4x4 | 행벡터↔좌곱 관례 맞춤 |
mul(uModel, v) | 수학적으로 v * model과 동치 | 전치 덕분에 결과 동일 |
| 투영 행렬 | XMMatrixPerspectiveFovLH(...) | 왼손 좌표계, 클립 z [0, 1] |
각 함수 설명:
FXMMATRIX—XMMATRIX를 함수 인자로 효율적으로 전달하기 위한 호출 규약 타입입니다. 처음 몇 개의 행렬 인자에 씁니다(그 뒤로는CXMMATRIX).XMMatrixTranspose— 행렬을 전치합니다. 이 한 줄이 “행벡터 규약을 셰이더의 좌곱과 맞추는” 핵심입니다.XMStoreFloat4x4— SIMD 정렬된XMMATRIX를 정렬 제약이 없는 저장용XMFLOAT4X4로 씁니다. cbuffer 메모리에 안전하게 기록하려면 이 저장 함수가 필요합니다.Map/Unmap+D3D11_MAP_WRITE_DISCARD— 매 프레임 바뀌는 상수 버퍼를 GPU와 충돌 없이 갱신하는 표준 패턴입니다.XMMatrixPerspectiveFovLH— 왼손(LH) 원근 투영을 만듭니다. 우리 규약의 왼손 좌표계·클립 z [0, 1]이 여기서 결정됩니다. (RH 버전이나 GL식 z [-1, 1] 함수를 쓰면 깊이 판정이 뒤집힙니다.)
2. 정점 변위(vertex displacement): sin 파도
무엇을 만드나: 정점 위치를 셰이더 안에서 시간에 따라 직접 흔들어, CPU 재계산 없이 GPU만으로 물결 애니메이션을 만드는 버텍스 셰이더입니다. 왜 필요한가: 물결·깃발·바람에 흔들리는 풀처럼 “위치가 규칙적으로 변하는” 것들은, 매 프레임 CPU에서 정점을 다시 계산해 업로드하면 대역폭이 낭비됩니다. 위치 변화를 수식으로 표현할 수 있다면 GPU에서 즉석으로 계산하는 편이 훨씬 쌉니다.
struct VSIn {
float3 pos : POSITION;
float2 uv : TEXCOORD0;
};
struct VSOut {
float4 clipPos : SV_Position;
float2 uv : TEXCOORD0;
float height : TEXCOORD1; // 색에 쓰라고 파도 높이도 넘긴다
};
cbuffer Transforms : register(b0) {
float4x4 uModel;
float4x4 uView;
float4x4 uProj;
float uTime;
float3 _pad; // cbuffer 16바이트 정렬 채움
};
VSOut VSMain(VSIn i) {
VSOut o;
float3 p = i.pos;
// 두 방향의 사인파를 합쳐 자연스러운 물결을 만든다
float w = sin(p.x * 3.0 + uTime * 2.0) * 0.15
+ sin(p.z * 2.0 + uTime * 1.3) * 0.10;
p.y += w;
o.height = w;
o.uv = i.uv;
o.clipPos = mul(uProj, mul(uView, mul(uModel, float4(p, 1.0))));
return o;
}
왜 위치 하나만 받는가
이 VSIn에는 법선이 없습니다. 파도로 표면 모양이 실시간으로 바뀌므로,
원래 정점에 박혀 있던 법선은 어차피 못 씁니다(뒤 함정 참고). 그래서 위치와 UV만 받습니다.
VSOut에는 특이하게 float height : TEXCOORD1이 있는데,
이건 “파도가 얼마나 밀려 올라갔는지”를 픽셀 셰이더로 넘겨 높이에 따라 색을 다르게 칠하려는 재료입니다.
cbuffer에 추가된 uTime과 _pad
코드 매핑: float uTime은 애니메이션 시간(초)입니다. CPU에서 매 프레임 흐른 시간을 넣어 줍니다.
그 뒤의 float3 _pad가 핵심 함정 예방책입니다.
수학/규칙: 상수 버퍼는 16바이트(float4) 단위로 정렬됩니다.
float4x4 3개는 각각 정확히 4개의 16바이트 행이라 딱 맞습니다.
그다음 float uTime은 4바이트인데, HLSL 패킹 규칙상 하나의 16바이트 슬롯을 차지하되 4바이트만 쓰고
12바이트가 남습니다. float3 _pad가 바로 그 남는 12바이트를 “의도적으로” 채워,
CPU 구조체와 GPU cbuffer의 바이트 배치가 어긋나지 않게 합니다.
_pad를 빼먹고 CPU 구조체를 { ...3개 행렬..., float time; }로만 두면,
컴파일은 되는데 런타임에 값이 밀려 uTime이 엉뚱한 바이트를 읽습니다.
파도가 안 움직이거나 미친 듯이 진동하면 십중팔구 정렬/패딩 불일치입니다.
CPU 구조체에도 float pad[3];를 똑같이 넣어 크기를 맞추세요.
float3 p = i.pos; — 원본을 건드리지 않는 복사
입력 i.pos를 지역 변수 p로 복사한 뒤 이 p만 수정합니다.
i는 개념적으로 입력이라 직접 바꾸기보다 복사본을 다루는 편이 읽기 좋고 실수를 줄입니다.
이후 p.y에만 파도 높이를 더할 것이므로, x·z는 그대로 두고 y만 위아래로 흔들립니다.
파도 함수: 두 사인파의 합
수학적 의미:
w = 0.15 · sin(3·x + 2·t) + 0.10 · sin(2·z + 1.3·t)
각 sin(k·좌표 + ω·t)는 진행파(traveling wave) 하나입니다.
- 좌표 앞의 계수(3.0, 2.0): 공간 주파수(wavenumber). 값이 클수록 파장이 짧아 파도가 촘촘해집니다. x방향은 3.0이라 z방향(2.0)보다 촘촘합니다.
uTime앞의 계수(2.0, 1.3): 시간 주파수. 클수록 빠르게 흐릅니다. x파는 z파보다 빠르게 진행합니다.- 바깥 곱(0.15, 0.10): 진폭(amplitude). 파도의 최대 높이입니다.
왜 두 개를 더하나 — 기하 직관(그림글): 사인파 하나만 쓰면 규칙적인 “빨래판” 무늬라 인위적입니다. 서로 방향(x, z)·주파수·속도가 다른 파를 겹치면 마루와 골이 서로 어긋나며 불규칙해 보이는 자연스러운 물결이 됩니다. 바다 표면이 여러 파의 중첩(superposition)인 것과 같은 원리입니다.
손계산 예: t = 0, 정점 (x, z) = (0, 0)이면
w = 0.15·sin(0) + 0.10·sin(0) = 0 — 초기엔 평평합니다.
시간이 흘러 3·x + 2·t = π/2가 되는 순간 첫 번째 파는 최대치 0.15에 도달합니다.
두 파의 진폭 합의 최댓값은 0.15 + 0.10 = 0.25이므로, 파도는 최대 ±0.25 범위에서 출렁입니다.
코드 매핑: p.y += w; — 계산한 높이를 y축에만 더합니다.
x·z는 그대로이므로 정점이 제자리에서 위아래로만 움직여 “수면” 느낌이 납니다.
(좌우로도 흔들고 싶다면 x·z에도 다른 사인파를 더하는 Gerstner 파도로 확장할 수 있습니다.)
출력: 변위된 위치로 전체 변환
o.clipPos = mul(uProj, mul(uView, mul(uModel, float4(p, 1.0))));
기본 셰이더와 똑같은 모델 → 뷰 → 투영 사슬이지만, 입력이 원래 i.pos가 아니라
흔들린 뒤의 p라는 점이 다릅니다. 여기서도 w = 1로 동차좌표를 만들어
이동이 반영되게 합니다. 이 한 줄에 세 행렬 곱이 다 들어 있어, 기본 셰이더에서 나눠 썼던
world 중간 변수를 생략하고 안쪽부터 쭉 곱한 형태입니다(결과는 동일).
o.height = w;로 파도 높이를 픽셀 셰이더에 넘기면, 픽셀 셰이더에서 예컨대
“마루는 하얀 거품, 골은 짙은 파랑”처럼 높이 기반 색을 칠할 수 있습니다.
normal 속성은 더 이상 표면과 맞지 않습니다.
조명을 쓴다면 파도 함수를 미분해 새 법선을 셰이더 안에서 다시 계산해야
그림자·하이라이트가 자연스럽습니다. 구체적으로는 w를 x·z로 편미분한
기울기 벡터로 접선을 구하고 외적으로 법선을 만듭니다. 또 변위 정도에 비해 정점 밀도가 낮으면
파도가 계단처럼 각지므로, 촘촘한 메시(subdivision)가 필요합니다.
sin은 GPU에서 비교적 비싼 초월 함수입니다. 정점 수가 수백만이면 사인파 개수를 늘릴수록 비용이 곱해집니다.
하지만 CPU에서 매 프레임 정점을 다시 계산해 업로드하는 것에 비하면 여전히 압도적으로 저렴합니다 —
정점 데이터는 GPU에 한 번만 올려두고, 변화하는 것은 uTime 상수 하나뿐이기 때문입니다.
mul(matrix, vector) 관례를 쓰는 이 코드가 올바르게 동작하려면,
앞서 본 대로 CPU에서 XMMatrixTranspose로 행렬을 전치해 cbuffer에 올려야 합니다.
DirectXMath는 행벡터라서, 이렇게 전치해 넣으면 셰이더의 좌곱 mul(uProj, mul(uView, mul(uModel, v)))이
우리 규약 v · uModel · uView · uProj와 정확히 같은 결과를 냅니다.
(참고로 이 곱셈 순서는 GLM/OpenGL의 proj * view * model과 표기가 반대이니, 다른 자료와 섞어 쓸 때 헷갈리지 마세요.)