서비스
교육 철학

10.11 HLSL 예제 (HLSL Examples)

이 절에서는 앞 절들의 여러 기법을 보여 주는 HLSL 정점·픽셀 셰이더 예제를 제시합니다. 코드를 읽으며 원리를 이해할 수 있도록 주석을 충분히 달았습니다. HLSL로 예제를 드는 이유는 C 코드 예제를 사용하는 이유와 같습니다. 많은 독자에게 적용 가능할 것으로 기대하기 때문입니다. 모든 독자가 이 언어를 쓰지는 않겠지만, 거의 누구나 기본 원리를 전달받고 이해할 수 있을 만큼 충분히 고수준인 언어라고 생각합니다.
HLSL은 본질적으로 NVIDIA가 개발한 ‘Cg’ 셰이더 언어와 같다. OpenGL의 셰이딩 언어인 GLSL과도 동일하지는 않지만 매우 비슷하다.
실시간 렌더링에 관심이 없는 독자에게 정점 셰이더와 픽셀 셰이더의 역할 분담은 원치 않는 장애물이 될 수 있음을 알고 있습니다(원주 10-24). 안타깝지만 이 부분에서는 복잡한 내부 사정을 완전히 감출 수 없습니다. 이 책은 HLSL 교재가 아니므로 세부 사항을 모두 설명하지는 않으며, HLSL을 조금 접해 보았다면 도움이 됩니다.
그래도 C 문법을 쓰므로 비교적 접근하기 쉽고 예제를 읽는 데 큰 어려움은 없을 것입니다. HLSL에 익숙하지 않은 독자를 위해 언어 고유의 사항은 등장할 때마다 코드 주석으로 소개합니다.
예제가 모두 매우 기본적이므로 셰이더 모델 2.0을 대상으로 작성했습니다.
원주 10-24: 예를 들어 RenderMan 셰이딩 언어에는 이런 구분이 없습니다.

10.11.1 데칼 셰이딩과 HLSL의 기초

준비 운동으로 아주 단순한 예부터 시작하여 상수를 선언하고 보간된 인자를 전달하는 HLSL의 기본 메커니즘을 살펴보겠습니다. 가장 단순한 셰이딩은 아마 조명을 전혀 계산하지 않고 텍스처 맵의 색을 바로 출력하는 것입니다. 이를 데칼 셰이딩(decal shading)이라고 부르기도 합니다. 코드 10.11의 정점 셰이더는 주석에서 설명하듯이 HLSL의 여러 기본 메커니즘을 보여 줍니다.
// 이 구조체는 메시에서 받는 입력을 선언한다. // 선언 순서는 중요하지 않다. 입력은 콜론 오른쪽에 붙인 // '시맨틱(semantic)'으로 식별한다. 정점 목록을 렌더러에 // 보낼 때 각 정점 요소의 시맨틱을 지정해야 한다. struct Input { float4 pos : POSITION; // 모델링 공간 위치 float2 uv : TEXCOORD0; // 텍스처 좌표 }; // 정점 셰이더의 출력 데이터다. // 시맨틱에 따라 픽셀 셰이더의 입력과 연결된다. struct Output { float4 pos : POSITION; // 클립 공간 위치 float2 uv : TEXCOORD0; // 텍스처 좌표 }; // 전역 변수를 선언한다. 모델→클립 변환 행렬을 담는 셰이더 상수다. uniform float4x4 modelToClip; // 정점 셰이더 본체 Output main(Input input) { Output output; // 정점 위치를 클립 공간으로 변환한다. // 내장 함수 mul()은 행렬 곱셈을 수행한다. // 첫 번째 피연산자로 받은 벡터는 행벡터로 취급한다. output.pos = mul(input.pos, modelToClip); // 제공된 UV 좌표를 수정 없이 전달한다. output.uv = input.uv; return output; }
Plain Text
복사
코드 10.11. 데칼 렌더링용 정점 셰이더.
이 정점 셰이더는 실제 데칼 셰이딩을 수행하는 코드 10.12의 픽셀 셰이더와 함께 사용할 수 있습니다. 다만 예제를 더 흥미롭게 만들고 픽셀 셰이더 상수도 정점 셰이더 상수와 같은 방식으로 동작함을 보여 주기 위해 전역 상수 색을 추가했습니다. 이를 전역 렌더 문맥의 일부로 생각하겠습니다. 렌더링하는 모든 프리미티브의 색과 불투명도에 곱해지는 이런 상수는 매우 유용합니다.
// 래스터라이저에서 받는 보간된 입력을 선언한다. // 보통 정점 출력과 정확히 일치하지만 클립 공간 위치는 흔히 생략한다. struct Input { float2 uv : TEXCOORD0; // 텍스처 좌표 }; // 픽셀 셰이더 상수의 동작을 보여 주기 위해 전역 상수 색을 선언한다. // 셰이더 출력에 이 RGBA 값을 곱한다. 이런 상수를 두는 가장 흔한 // 이유 중 하나는 렌더 문맥에 불투명도 설정을 추가하는 것이다. // 그렇지만 RGBA 전체를 상수 색으로 두면 매우 편리하다. uniform float4 constantColor; // 텍스처 조회를 수행할 것이다. 이 텍스처를 가리키는 '변수'를 선언하고, // 프리미티브를 그리기 전에 렌더링 코드가 렌더 문맥에 적절한 텍스처를 // 선택할 수 있도록 충분한 정보를 붙인다. sampler2D diffuseMap; // 픽셀 셰이더 본체. 출력은 하나뿐이며 시맨틱은 COLOR다. float4 main(Input input) : COLOR { // 텍셀을 가져온다. float4 texel = tex2D(diffuseMap, input.uv); // 상수 색을 곱한 뒤 출력한다. // 연산자 *는 성분별 곱셈을 수행한다. return texel * constantColor; }
Plain Text
복사
코드 10.12. 데칼 렌더링용 픽셀 셰이더.
당연히 상위 수준의 코드는 셰이더 상수와 프리미티브 데이터를 올바르게 제공해야 합니다. 상위 코드와 셰이더 상수를 연결하는 가장 단순한 방법은 HLSL의 특수 변수 선언 문법으로 상수에 레지스터 번호를 명시적으로 지정하는 것입니다. 이름으로 상수를 찾는 등의 더 정교한 기법도 있습니다. 이런 실용적인 세부 사항은 분명 중요하지만 이 책의 범위를 벗어납니다.
💡
예제의 실행 환경
이 예제는 셰이더 모델 2.0 시대의 HLSL 문법과 실행 환경을 전제로 합니다. 원문 코드와 주석을 따라 읽되, 실제 GPU·FXC 환경에 옮길 때는 아래의 자료형·좌표·정규화 조건을 확인해야 합니다.

10.11.2 기본적인 픽셀별 블린–퐁 조명

이제 실제로 조명을 계산하는 단순한 예를 보겠습니다. 기본적인 픽셀별 조명부터 시작하되 아직 범프 맵은 사용하지 않습니다. 면 전체에 법선을 보간하고 픽셀마다 전체 조명 방정식을 평가하는 퐁 셰이딩 기법을 보여 주는 예입니다. 10.11.3절에서 구로 셰이딩과 비교하고, 10.11.4절에서 노멀 매핑 예를 보입니다.
모든 조명 예제는 표준 블린–퐁 조명 방정식을 사용합니다. 이 예제와 이후 대부분의 예제는 선형으로 감쇠하는 무지향성 광원 하나에 일정한 환경광을 더한 조명 환경을 사용합니다.
첫 예제인 코드 10.13과 10.14에서는 모든 작업을 픽셀 셰이더에서 수행합니다. 정점 셰이더는 픽셀 셰이더에 입력을 그대로 전달하면 되므로 상당히 단순합니다.
// 메시 입력 struct Input { float4 pos : POSITION; // 모델 공간 위치 float3 normal : NORMAL; // 모델 공간 정점 법선 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 }; // 정점 셰이더 출력. POSITION 시맨틱으로 출력하는 위치를 제외하면 // 모두 TEXCOORDx 시맨틱을 사용한다. 이름과 달리 이 시맨틱은 // 텍스처 좌표뿐 아니라 픽셀 셰이더에 전달하려는, 최대 4차원인 // 거의 모든 종류의 보간된 벡터 값에 사용된다. struct Output { float4 clipPos : POSITION; // 클립 공간 위치 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 float3 normal : TEXCOORD1; // 모델 공간 정점 법선 float3 modelPos : TEXCOORD2; // 모델 공간 위치 }; // 모델→클립 변환 행렬 uniform float4x4 modelToClip; // 정점 셰이더 본체 Output main(Input input) { Output output; // 정점 위치를 클립 공간으로 변환한다. output.clipPos = mul(input.pos, modelToClip); // 정점 입력을 수정 없이 전달한다. output.normal = input.normal; output.uv = input.uv; output.modelPos = input.pos; return output; }
Plain Text
복사
코드 10.13. 무지향성 광원 하나와 환경광을 픽셀별로 계산하는 정점 셰이더.
코드 10.14는 이에 대응하는 픽셀 셰이더로, 실제 작업이 모두 여기서 이루어집니다. 난반사 색용과 정반사 색용으로 서로 다른 텍스처 맵 두 개를 사용한다는 점에 주목하겠습니다. 두 맵은 같은 텍스처 매핑 좌표를 사용한다고 가정합니다.
// 정점 셰이더에서 온 보간된 입력 struct Input { float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 float3 normal : TEXCOORD1; // 모델 공간 정점 법선 float3 modelPos : TEXCOORD2; // 조명 계산용 모델 공간 위치 }; // 전역 상수 RGB와 불투명도 uniform float4 constantColor; // 모델 공간에서의 무지향성 광원 위치 uniform float3 omniPos; // 무지향성 광원 반지름의 역수. 빛은 이 반지름에서 0이 될 때까지 // 선형으로 감쇠한다. 각 상수가 보통 4차원 벡터 슬롯 전체를 차지하므로, // 상수 수를 줄이기 위해 흔히 위치의 w 성분에 이 값을 넣는다. uniform float invOmniRad; // 감쇠를 적용하기 전 무지향성 광원 색 uniform float3 omniColor; // 모델 공간 시점 위치 uniform float3 viewPos; // 일정한 환경광 색 uniform float3 ambientLightColor; // 재질의 광택도(퐁 지수) uniform float specExponent; // 확산·정반사 맵 샘플러. 두 맵은 같은 UV 좌표를 쓴다고 가정한다. sampler2D diffuseMap; sampler2D specularMap; // 픽셀 셰이더 본체 float4 main(Input input) : COLOR { // 텍셀을 가져와 재질 색을 구한다. float4 matDiff = tex2D(diffuseMap, input.uv); float4 matSpec = tex2D(specularMap, input.uv); // 보간된 정점 법선을 정규화한다. float3 N = normalize(input.normal); // 광원을 향하는 벡터를 계산한다. float3 L = omniPos - input.modelPos; // 정규화하고, 나중에 감쇠에 쓸 거리를 보관한다. float dist = length(L); L /= dist; // 시선 벡터와 하프웨이 벡터를 계산한다. float3 V = normalize(viewPos - input.modelPos); float3 H = normalize(V + L); // 감쇠를 적용한 광원 색을 계산한다. float3 lightColor = omniColor * max(1 - dist * invOmniRad, 0); // 확산·정반사 계수를 계산한다. float diffFactor = max(dot(N, L), 0); float specFactor = pow(max(dot(N, H), 0), specExponent); // 실효 조명 색을 계산한다. float3 diffColor = lightColor * diffFactor + ambientLightColor; float3 specColor = lightColor * specFactor; // 색을 합한다. HLSL은 벡터 일부를 마치 벡터의 '멤버'처럼 // 접근할 수 있는 매우 유연한 스위즐링 시스템을 제공한다. float4 result = matDiff; // 확산 맵의 RGB와 불투명도 result.rgb *= diffColor; // 확산광+환경광을 곱한다. result.rgb += matSpec.rgb * specColor; // 맵 알파를 무시하고 정반사를 더한다. // 상수를 곱한 뒤 출력한다. return result * constantColor; }
Plain Text
복사
코드 10.14. 무지향성 광원 하나와 환경광을 픽셀별로 계산하는 픽셀 셰이더.
물론 이 계산에 필요한 여러 값을 정점 셰이더에서 구하고, 보간된 결과를 픽셀 셰이더에서 사용할 수도 있습니다. 대부분의 삼각형은 한두 픽셀보다 넓다고 가정하므로, 채울 픽셀 수가 셰이딩할 정점 수보다 훨씬 많아 보통 성능상 유리합니다. 그러나 정확한 분석은 복잡할 수 있습니다.
정점과 픽셀 수뿐 아니라 각각의 셰이딩에 사용할 실행 유닛 수도 중요하기 때문입니다. 어떤 하드웨어는 범용 실행 유닛 풀을 정점·픽셀 셰이딩이 공유합니다. 보간하는 값의 수가 늘어나는 것도 성능에 영향을 줄 수 있습니다. 그래도 대부분의 플랫폼과 상황에서는 정점마다 더 많이 계산하도록 작업을 나누는 편이 빠릅니다.
코드 10.15와 10.16은 작업을 정점 셰이더 쪽으로 옮기는 한 가지 방법입니다.
// 메시 입력 struct Input { float4 pos : POSITION; // 모델 공간 위치 float3 normal : NORMAL; // 모델 공간 정점 법선 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 }; // 정점 셰이더 출력 struct Output { float4 clipPos : POSITION; // 클립 공간 위치 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 float3 normal : TEXCOORD1; // 모델 공간 정점 법선 float3 L : TEXCOORD2; // 광원을 향하는 벡터 float3 H : TEXCOORD3; // 하프웨이 벡터 float3 lightColor: TEXCOORD4; // 광원 색과 감쇠 계수 }; // 모델→클립 변환 행렬 uniform float4x4 modelToClip; // 모델 공간 무지향성 광원 위치 uniform float3 omniPos; // 광원 반지름의 역수. 빛은 이 반지름에서 0이 될 때까지 선형 감쇠한다. // 각 상수는 보통 4차원 벡터 슬롯 전체를 차지하므로, 상수 수를 // 줄이기 위해 흔히 위치의 w 성분에 이 값을 넣는다. uniform float invOmniRad; // 감쇠 전 무지향성 광원 색 uniform float3 omniColor; // 모델 공간 시점 위치 uniform float3 viewPos; // 정점 셰이더 본체 Output main(Input input) { Output output; // 정점 위치를 클립 공간으로 변환한다. output.clipPos = mul(input.pos, modelToClip); // 광원을 향하는 벡터를 계산한다. float3 L = omniPos - input.pos; // 정규화하고, 나중에 감쇠에 쓸 거리를 보관한다. float dist = length(L); output.L = L / dist; // 시선 벡터와 하프웨이 벡터를 계산한다. float3 V = normalize(viewPos - input.pos); output.H = normalize(V + output.L); // 감쇠 계수를 계산한다. 여기서는 0으로 제한하지 않는다. // 픽셀 셰이더에서 제한한다. 큰 다각형의 한가운데서 감쇠값이 // 0에 도달하는 경우에 이 점이 중요하다. float attenFactor = 1 - dist * invOmniRad; output.lightColor = omniColor * attenFactor; // 나머지 정점 입력을 수정 없이 전달한다. output.normal = input.normal; output.uv = input.uv; return output; }
Plain Text
복사
코드 10.15. 무지향성 광원 하나와 환경광을 픽셀별로 계산하는 대안 정점 셰이더.
이제 픽셀 셰이더의 작업이 줄었습니다. DirectX 10의 FXC 컴파일러에 따르면 코드 10.16의 픽셀 셰이더는 약 25개 명령 슬롯으로 컴파일되며, 코드 10.14는 33개였습니다.
// 정점 셰이더에서 온 보간된 입력 struct Input { float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 float3 normal : TEXCOORD1; // 모델 공간 정점 법선 float3 L : TEXCOORD2; // 광원을 향하는 벡터 float3 H : TEXCOORD3; // 하프웨이 벡터 float3 lightColor: TEXCOORD4; // 광원 색과 감쇠 계수 }; // 전역 상수 RGB와 불투명도 uniform float4 constantColor; // 일정한 환경광 색 uniform float3 ambientLightColor; // 재질의 광택도(퐁 지수) uniform float specExponent; // 확산·정반사 맵 샘플러. 두 맵은 같은 UV 좌표를 쓴다고 가정한다. sampler2D diffuseMap; sampler2D specularMap; // 픽셀 셰이더 본체 float4 main(Input input) : COLOR { // 텍셀을 가져와 재질 색을 구한다. float4 matDiff = tex2D(diffuseMap, input.uv); float4 matSpec = tex2D(specularMap, input.uv); // 보간된 벡터들을 정규화한다. float3 N = normalize(input.normal); float3 L = normalize(input.L); float3 H = normalize(input.H); // 확산·정반사 계수를 계산한다. float diffFactor = max(dot(N, L), 0); float specFactor = pow(max(dot(N, H), 0), specExponent); // 광원 색을 제한한다. max는 성분별로 적용된다. float3 lightColor = max(input.lightColor, 0); // 실효 조명 색을 계산한다. float3 diffColor = lightColor * diffFactor + ambientLightColor; float3 specColor = lightColor * specFactor; // 색을 합한다. HLSL은 벡터 일부를 마치 벡터의 '멤버'처럼 // 접근할 수 있는 매우 유연한 스위즐링 시스템을 제공한다. float4 result = matDiff; // 확산 맵의 RGB와 불투명도 result.rgb *= diffColor; // 확산광+환경광을 곱한다. result.rgb += matSpec.rgb * specColor; // 맵 알파를 무시하고 정반사를 더한다. // 상수를 곱한 뒤 출력한다. return result * constantColor; }
Plain Text
복사
코드 10.16. 무지향성 광원 하나와 환경광을 픽셀별로 계산하는 대안 픽셀 셰이더.
마지막으로 이 예제의 변형을 하나 더 보겠습니다. 바로 앞 코드 10.16의 픽셀 셰이더는 특정 좌표 공간에서 조명이 계산된다고 가정하지 않습니다. 지금까지 모델 공간에서 조명을 계산했지만 카메라 공간에서도 흔히 계산합니다. 이 방법은 물체마다 달라지는 모델링 공간으로 조명 값을 지정할 때와 달리, 렌더링하는 물체마다 조명 데이터의 셰이더 상수를 다시 보낼 필요가 없다는 장점이 있습니다. 코드 10.17의 정점 셰이더가 이 기법을 보여 줍니다.
// 메시 입력 struct Input { float4 pos : POSITION; // 모델 공간 위치 float3 normal : NORMAL; // 모델 공간 정점 법선 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 }; // 정점 셰이더 출력 struct Output { float4 clipPos : POSITION; // 클립 공간 위치 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 float3 normal : TEXCOORD1; // 카메라 공간 정점 법선 float3 L : TEXCOORD2; // 카메라 공간에서 광원을 향하는 벡터 float3 H : TEXCOORD3; // 카메라 공간 하프웨이 벡터 float3 lightColor: TEXCOORD4; // 광원 색과 감쇠 계수 }; // 모델→뷰 변환 행렬('모델뷰' 행렬) uniform float4x4 modelToView; // 클립 행렬('투영' 행렬) uniform float4x4 viewToClip; // 뷰 공간 무지향성 광원 위치. w에는 감쇠 반지름의 역수. uniform float4 omniPosAndInvRad; // 감쇠 전 무지향성 광원 색 uniform float3 omniColor; // 정점 셰이더 본체 Output main(Input input) { Output output; // 정점 위치를 뷰 공간으로 변환한다. float4 vPos = mul(input.pos, modelToView); // 그리고 클립 공간으로 변환한다. 클립 행렬의 구조는 흔히 단순하므로, // 이를 활용하여 벡터 연산의 수를 줄일 수 있다. output.clipPos = mul(vPos, viewToClip); // 법선을 카메라 공간으로 변환한다. w를 0으로 설정하여 float4로 // '승격'하므로 평행이동을 받는다. output.normal = mul(float4(input.normal, 0), modelToView); // 광원을 향하는 벡터를 계산한다. float3 L = omniPosAndInvRad.xyz - vPos; // 정규화하고, 나중에 감쇠에 쓸 거리를 보관한다. float dist = length(L); output.L = L / dist; // 시선 벡터와 하프웨이 벡터를 계산한다. // 정의상 뷰 공간에서 시점 위치는 원점이다. float3 V = normalize(-vPos); output.H = normalize(V + output.L); // 감쇠 계수를 계산한다. 여기서는 0으로 제한하지 않고 // 픽셀 셰이더에서 제한한다. 큰 다각형의 한가운데서 감쇠값이 // 0에 도달할 때 이 점이 중요하다. float attenFactor = 1 - dist * omniPosAndInvRad.w; output.lightColor = omniColor * attenFactor; // UV를 수정 없이 전달한다. output.uv = input.uv; return output; }
Plain Text
복사
코드 10.17. 카메라 공간에서 무지향성 광원 하나와 환경광을 픽셀별로 계산하는 정점 셰이더.
여러 상황에서는 월드 공간이나 월드와 축 방향이 같은 ‘직립 공간(upright space)’도 매력적인 조명 계산 공간입니다. 그림자 큐브 맵이나 라이트 프로브를 대개 이 방향으로 렌더링하기 때문입니다. 두 공간은 축 방향을 공유하더라도 원점까지 같은 것은 아닙니다. 월드 공간에서 계산하면 물체마다 모델 기준 좌표계가 달라져도 조명 관련 셰이더 상수를 다시 보낼 필요가 없다는 장점도 있습니다.
💡
HLSL 구현에서 확인할 조건
코드 10.17의 주석은 w=0으로 만든 법선이 평행이동을 ‘받는다’고 적혀 있습니다. 이는 원문의 부정어 누락으로, 정확한 뜻은 평행이동을 받지 않는다입니다. 코드는 w=0을 올바르게 사용합니다.
💡
HLSL 구현에서 확인할 조건
코드 10.18의 float L은 광원 방향 벡터이므로 float3 L이어야 합니다. 원문 그대로의 스칼라 선언은 의도한 3차원 조명 계산과 맞지 않습니다.
💡
HLSL 구현에서 확인할 조건
여러 예제에서 float3 위치와 float4 input.pos 또는 vPos를 섞습니다. 적용할 때는 .xyz를 명시하여 좌표 차원을 맞추는 편이 분명합니다. 특히 코드 10.17의 시선 방향은 normalize(-vPos.xyz)로 3차원 벡터를 먼저 정규화해야 합니다. w=1인 4차원 위치를 정규화한 뒤 잘라 내면 단위 3차원 방향이 되지 않습니다.

10.11.3 구로 셰이딩

당시의 보급형 하드웨어조차 퐁 셰이딩을 수행하기에 충분한 성능을 갖추고 있습니다. 실제로 앞의 예제들은 비교적 저렴한 셰이더입니다. 그래도 구로 셰이딩을 어떻게 구현하는지 살펴보는 것은 매우 유익합니다. 결과가 퐁 셰이딩보다 떨어지고 범프 매핑도 할 수 없지만, PC에서 다른 하드웨어의 결과를 모방하는 데는 여전히 유용할 수 있습니다.
코드 10.18의 정점 셰이더는 10.11.2절에서 보여 준 것과 같은 조명 계산을 정점 단위로 수행합니다. 이 코드를 식 (10.15)와 비교해 보겠습니다.
// 메시 입력 struct Input { float4 pos : POSITION; // 모델 공간 위치 float3 normal : NORMAL; // 모델 공간 정점 법선 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 }; // 정점 셰이더 출력 struct Output { float4 clipPos : POSITION; // 클립 공간 위치 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 float3 diffColor: TEXCOORD1; // 확산 조명 RGB float3 specColor: TEXCOORD2; // 정반사 조명 RGB }; // 모델→클립 변환 행렬 uniform float4x4 modelToClip; // 모델 공간 무지향성 광원 위치. w에는 감쇠 반지름의 역수. uniform float4 omniPosAndInvRad; // 감쇠 전 무지향성 광원 색 uniform float3 omniColor; // 일정한 환경광 색 uniform float3 ambientLightColor; // 모델 공간 시점 위치 uniform float3 viewPos; // 재질의 광택도(퐁 지수) uniform float specExponent; // 정점 셰이더 본체 Output main(Input input) { Output output; // 정점 위치를 클립 공간으로 변환한다. output.clipPos = mul(input.pos, modelToClip); // 광원을 향하는 벡터를 계산한다. float L = omniPosAndInvRad.xyz - input.pos; // 정규화하고, 나중에 감쇠에 쓸 거리를 보관한다. float dist = length(L); L /= dist; // 시선 벡터와 하프웨이 벡터를 계산한다. float3 V = normalize(viewPos - input.pos); float3 H = normalize(V + L); // 감쇠를 적용한 광원 색을 계산한다. float3 lightColor = omniColor * max(1 - dist * omniPosAndInvRad.w, 0); // 확산·정반사 계수를 계산한다. float diffFactor = max(dot(input.normal, L), 0); float specFactor = pow(max(dot(input.normal, H), 0), specExponent); // 실효 조명 색을 계산한다. output.diffColor = lightColor * diffFactor + ambientLightColor; output.specColor = lightColor * specFactor; // 제공된 UV 좌표를 수정 없이 전달한다. output.uv = input.uv; return output; }
Plain Text
복사
코드 10.18. 무지향성 광원 하나와 환경광을 사용하는 구로 셰이딩의 정점 셰이더.
이제 픽셀 셰이더인 코드 10.19는 조명 결과를 받아 텍스처 맵에서 가져온 재질의 난반사 색과 정반사 색을 곱하기만 하면 됩니다.
// 정점 셰이더에서 온 보간된 입력 struct Input { float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 float3 diffColor: TEXCOORD1; // 확산 조명 RGB float3 specColor: TEXCOORD2; // 정반사 조명 RGB }; // 전역 상수 RGB와 불투명도 uniform float4 constantColor; // 확산·정반사 맵 샘플러. 두 맵은 같은 방식으로 매핑되어 // 동일한 UV 좌표를 쓴다고 가정한다. sampler2D diffuseMap; sampler2D specularMap; // 픽셀 셰이더 본체 float4 main(Input input) : COLOR { // 텍셀을 가져와 재질 색을 구한다. float4 materialDiff = tex2D(diffuseMap, input.uv); float4 materialSpec = tex2D(specularMap, input.uv); // 색을 합한다. HLSL은 벡터 일부를 마치 벡터의 '멤버'처럼 // 접근할 수 있는 매우 유연한 스위즐링 시스템을 제공한다. float4 result = materialDiff; // 확산 맵에서 RGB와 불투명도를 모두 가져온다. result.rgb *= input.diffColor; // 확산광+환경광을 곱한다. result.rgb += materialSpec.rgb * input.specColor; // 맵 알파를 무시하고 정반사를 더한다. // 상수를 곱한 뒤 출력한다. return result * constantColor; }
Plain Text
복사
코드 10.19. 어떤 조명 환경에서도 사용할 수 있는 구로 셰이딩의 픽셀 셰이더.
코드 10.19의 캡션이 나타내듯이 이 픽셀 셰이더는 광원 수에도, 심지어 조명 모델에도 의존하지 않습니다. 조명 계산을 전부 정점 셰이더에서 하기 때문입니다. 코드 10.20의 정점 셰이더는 같은 픽셀 셰이더와 함께 사용할 수 있지만, 환경광과 방향성 광원 세 개라는 다른 조명 환경을 구현합니다. 거의 모든 물체에 제법 잘 어울리는 조명 구성을 하나 만들어 놓기 쉬우므로 편집기와 도구에서 매우 유용합니다. 다만 보통은 픽셀별 셰이딩과 함께 사용할 것입니다.
// 메시 입력 struct Input { float4 pos : POSITION; // 모델 공간 위치 float3 normal : NORMAL; // 모델 공간 정점 법선 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 }; // 정점 셰이더 출력 struct Output { float4 clipPos : POSITION; // 클립 공간 위치 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 float3 diffColor: TEXCOORD1; // 확산 조명 RGB float3 specColor: TEXCOORD2; // 정반사 조명 RGB }; // 모델→클립 변환 행렬 uniform float4x4 modelToClip; // 모델 공간의 세 광원 방향. 빛이 진행하는 방향의 반대를 가리킨다. uniform float3 lightDir[3]; // 세 광원의 RGB 색 uniform float3 lightColor[3]; // 일정한 환경광 색 uniform float3 ambientLightColor; // 모델 공간 시점 위치 uniform float3 viewPos; // 재질의 광택도(퐁 지수) uniform float specExponent; // 정점 셰이더 본체 Output main(Input input) { Output output; // 정점 위치를 클립 공간으로 변환한다. output.clipPos = mul(input.pos, modelToClip); // V 벡터를 계산한다. float3 V = normalize(viewPos - input.pos); // 누적값을 초기화한다. output.diffColor = ambientLightColor; output.specColor = 0; // 광원들을 합산한다. 컴파일러는 '보통' 이런 작은 루프를 잘 펼치지만, // 가장 빠른 코드를 보장하려면 컴파일러에 의존하지 않고 // 직접 루프를 펼치는 편이 가장 좋다. for (int i = 0; i < 3; ++i) { // 람베르트 항을 계산하고 확산 기여분을 더한다. float nDotL = dot(input.normal, lightDir[i]); output.diffColor += max(nDotL, 0) * lightColor[i]; // 하프웨이 벡터를 계산한다. float3 H = normalize(V + lightDir[i]); // 정반사 기여분을 더한다. float nDotH = dot(input.normal, H); float s = pow(max(nDotH, 0), specExponent); output.specColor += s * lightColor[i]; } // 제공된 UV 좌표를 수정 없이 전달한다. output.uv = input.uv; return output; }
Plain Text
복사
코드 10.20. 일정한 환경광과 방향성 광원 세 개를 사용하는 구로 셰이딩의 정점 셰이더.
💡
HLSL 구현에서 확인할 조건
정점에서 감쇠나 하프웨이 벡터를 계산한 뒤 보간하는 것은 픽셀에서 원래의 비선형 식을 다시 계산하는 것과 일반적으로 같지 않습니다. 원문의 성능 비교는 그 근사와 당시 실행 환경을 전제로 합니다.

10.11.4 범프 매핑

다음으로 노멀 매핑 예를 보겠습니다. 접선 공간에서 조명을 계산할 것입니다. 예제끼리 쉽게 비교하도록 무지향성 광원 하나와 일정한 환경광이라는 조명 환경을 유지합니다. 코드 10.21의 정점 셰이더에서는 법선과 접선으로 바이노멀을 만듭니다. 그런 다음 평소처럼 모델 공간에서 L과 H를 계산하고, 세 기저 벡터를 사용하여 접선 공간으로 회전시킵니다.
세 번의 내적을 사용하는 부분에 주목하겠습니다. 이는 행렬의 전치행렬을 곱하는 것과 같습니다. 앞 예제처럼 감쇠도 정점 셰이더에서 계산하며, 범위를 제한하지 않은 감쇠 적용 광원 색을 전달합니다.
// 메시 입력 struct Input { float4 pos : POSITION; // 모델 공간 위치 float3 normal : NORMAL; // 모델 공간 정점 법선 float4 tangentDet: TANGENT; // 모델 공간 접선, w에는 행렬식 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 }; // 정점 셰이더 출력 struct Output { float4 clipPos : POSITION; // 클립 공간 위치 float2 uv : TEXCOORD0; // 모든 맵의 텍스처 좌표 float3 L : TEXCOORD1; // 접선 공간에서 광원을 향하는 벡터 float3 H : TEXCOORD2; // 접선 공간 하프웨이 벡터 float3 lightColor: TEXCOORD3; // 광원 색과 감쇠 계수 }; // 모델→클립 변환 행렬 uniform float4x4 modelToClip; // 모델 공간 무지향성 광원 위치. w에는 감쇠 반지름의 역수. uniform float4 omniPosAndInvRad; // 감쇠 전 무지향성 광원 색 uniform float3 omniColor; // 모델 공간 시점 위치 uniform float3 viewPos; // 정점 셰이더 본체 Output main(Input input) { Output output; // 정점 위치를 클립 공간으로 변환한다. output.clipPos = mul(input.pos, modelToClip); // 모델 공간에서 광원을 향하는 벡터를 계산한다. float3 L_model = omniPosAndInvRad.xyz - input.pos.xyz; // 정규화하고, 나중에 감쇠에 쓸 거리를 보관한다. float dist = length(L_model); float3 L_model_norm = L_model / dist; // 시선 벡터와 하프웨이 벡터를 계산한다. float3 V_model = normalize(viewPos - input.pos); float3 H_model = normalize(V_model + L_model_norm); // 세 번째 기저 벡터를 복원한다. float3 binormal = cross(input.normal, input.tangentDet.xyz) * input.tangentDet.w; // 조명 관련 벡터들을 접선 공간으로 회전시킨다. output.L.x = dot(L_model, input.tangentDet.xyz); output.L.y = dot(L_model, binormal); output.L.z = dot(L_model, input.normal); output.H.x = dot(H_model, input.tangentDet.xyz); output.H.y = dot(H_model, binormal); output.H.z = dot(H_model, input.normal); // 범위를 제한하지 않은 색과 감쇠 계수를 계산한다. float attenFactor = 1 - dist * omniPosAndInvRad.w; output.lightColor = omniColor * attenFactor; // 매핑 좌표를 수정 없이 전달한다. output.uv = input.uv; return output; }
Plain Text
복사
코드 10.21. 접선 공간에서 조명을 계산하는, 노멀 맵 물체의 무지향성 조명용 정점 셰이더.
대부분의 준비 작업을 정점 셰이더에서 했으므로 코드 10.22의 픽셀 셰이더는 꽤 짧습니다. 법선을 풀어 내고 보간된 L과 H를 정규화합니다. 그런 다음 다른 예제와 마찬가지로 블린–퐁 조명 방정식을 계산합니다.
// 정점 셰이더에서 온 보간된 입력 struct Input { float2 uv : TEXCOORD0; // 모든 맵의 텍스처 좌표 float3 L : TEXCOORD1; // 접선 공간에서 광원을 향하는 벡터 float3 H : TEXCOORD2; // 접선 공간 하프웨이 벡터 float3 lightColor: TEXCOORD3; // 광원 색과 감쇠 계수 }; // 전역 상수 RGB와 불투명도 uniform float4 constantColor; // 일정한 환경광 색 uniform float3 ambientLightColor; // 재질의 광택도(퐁 지수) uniform float specExponent; // 확산·정반사·노멀 맵 샘플러 sampler2D diffuseMap; sampler2D specularMap; sampler2D normalMap; // 픽셀 셰이더 본체 float4 main(Input input) : COLOR { // 텍셀을 가져와 재질 색을 구한다. float4 matDiff = tex2D(diffuseMap, input.uv); float4 matSpec = tex2D(specularMap, input.uv); // 접선 공간 법선을 디코딩한다. float3 N = tex2D(normalMap, input.uv).rgb * 2 - 1; // 보간된 조명 벡터들을 정규화한다. float3 L = normalize(input.L); float3 H = normalize(input.H); // 확산·정반사 계수를 계산한다. float diffFactor = max(dot(N, L), 0); float specFactor = pow(max(dot(N, H), 0), specExponent); // 광원 색과 감쇠의 범위를 제한한다. float3 lightColor = max(input.lightColor, 0); // 실효 조명 색을 계산한다. float3 diffColor = lightColor * diffFactor + ambientLightColor; float3 specColor = lightColor * specFactor; // 색을 합한다. float4 result = matDiff; // 확산 맵의 RGB와 불투명도 result.rgb *= diffColor; // 확산광+환경광을 곱한다. result.rgb += matSpec.rgb * specColor; // 맵 알파를 무시하고 정반사를 더한다. // 상수를 곱한 뒤 출력한다. return result * constantColor; }
Plain Text
복사
코드 10.22. 접선 공간에서 조명을 계산하는, 노멀 맵 물체의 무지향성 조명용 픽셀 셰이더.
💡
HLSL 구현에서 확인할 조건
코드 10.22는 노멀 맵에서 디코딩한 N을 다시 정규화하지 않습니다. 양자화와 텍스처 필터링 뒤에는 길이가 1이 아닐 수 있으므로 실제 계산에서는 디코딩 후 정규화가 필요할 수 있습니다. 영벡터·광원과 표면 위치 일치·서로 반대인 시선과 광원 방향 등 정규화의 경계 조건도 다루어야 합니다.

10.11.5 스키닝된 메시

이제 골격 렌더링 예제를 보겠습니다. 스키닝은 전부 정점 셰이더에서 수행하므로 픽셀 셰이더는 따로 제시할 필요가 없습니다. 여기의 정점 셰이더는 앞서 제시한 픽셀 셰이더와 함께 사용할 수 있습니다. 특별한 일이 아닙니다. 스키닝된 형상과 스키닝하지 않은 형상은 보통 같은 픽셀 셰이더를 공유할 수 있습니다.
두 가지 예를 들겠습니다. 첫 번째인 코드 10.23은 무지향성 광원과 환경광 조명 구성을 픽셀별로 계산합니다. 새로 등장하는 스키닝에 집중하도록 모든 조명을 픽셀 셰이더인 코드 10.14에서 계산합니다.
// 메시 입력 struct Input { float4 pos : POSITION; // 바인딩 포즈의 모델 공간 위치 float3 normal : NORMAL; // 같은 포즈의 모델 공간 정점 법선 byte4 bones : BLENDINDICES; // 본 인덱스. 사용하지 않는 항목은 0. float4 weight : BLENDWEIGHT; // 혼합 가중치. 사용하지 않는 항목은 0. float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 }; // 정점 셰이더 출력 struct Output { float4 clipPos : POSITION; // 래스터화용 클립 공간 위치 float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 float3 normal : TEXCOORD1; // 모델 공간 정점 법선 float3 modelPos : TEXCOORD2; // 조명 계산용 모델 공간 위치 }; // 모델→클립 변환 행렬 uniform float4x4 modelToClip; // 임의로 본의 최대 수를 정한다. #define MAX_BONES 40 // 각 본의 '바인딩 포즈→현재 포즈' 행렬 배열. // 4×3 행렬이지만 맨 오른쪽 열이 [0,0,0,1]인 4×4 행렬로 해석한다. // 기본 저장 방식이 열 우선(column major)이라고 가정한다. // 즉 각 열은 4차원 레지스터에 저장되므로 행렬마다 레지스터 세 개가 필요하다. uniform float4x3 boneMatrix[MAX_BONES]; // 정점 셰이더 본체 Output main(Input input) { Output output; // 혼합 행렬을 만든다. 대부분의 정점이 더 적은 본을 쓰더라도 // 항상 네 본을 혼합한다. 조건문으로 불필요한 계산을 건너뛰는 것과, // 모든 계산을 수행하는 것 중 어느 쪽이 더 빠른지는 하드웨어에 달려 있다. // 후자는 어셈블러가 명령 지연을 숨기도록 쉽게 스케줄링할 수 있다. float4x3 blendedMat = boneMatrix[input.bones.x] * input.weight.x + boneMatrix[input.bones.y] * input.weight.y + boneMatrix[input.bones.z] * input.weight.z + boneMatrix[input.bones.w] * input.weight.w; // 스키닝으로 위치와 법선을 바인딩 포즈에서 현재 포즈로 변환한다. // 행렬 곱의 차원은 [1×3] = [1×4] × [4×3]이다. output.modelPos = mul(input.pos, blendedMat); output.normal = mul(float4(input.normal, 0), blendedMat); output.normal = normalize(output.normal); // 정점 위치를 클립 공간으로 변환한다. output.clipPos = mul(float4(output.modelPos, 1), modelToClip); // UV를 그대로 전달한다. output.uv = input.uv; return output; }
Plain Text
복사
코드 10.23. 스키닝된 기하 형상을 위한 정점 셰이더.
여기서는 정점들을 정점 셰이더 상수 배열로 선언했습니다. 이 모든 행렬을 하드웨어에 전송하는 것이 큰 성능 병목이 될 수 있습니다. 일부 플랫폼에는 보조 ‘정점’ 스트림을 인덱싱하는 등의 더 효율적인 방법이 있습니다.
다음으로 스키닝된 메시에 노멀 매핑을 적용하는 방법을 보겠습니다. 코드 10.24의 정점 셰이더는 코드 10.22의 픽셀 셰이더와 함께 사용할 수 있습니다.
// 메시 입력 struct Input { float4 pos : POSITION; // 바인딩 포즈의 모델 공간 위치 float3 normal : NORMAL; // 모델 공간 정점 법선 float4 tangentDet: TANGENT; // 모델 공간 접선, w에는 행렬식 byte4 bones : BLENDINDICES; // 본 인덱스. 사용하지 않는 항목은 0. float4 weight : BLENDWEIGHT; // 혼합 가중치. 사용하지 않는 항목은 0. float2 uv : TEXCOORD0; // 확산·정반사 맵의 텍스처 좌표 }; // 정점 셰이더 출력 struct Output { float4 pos : POSITION; // 클립 공간 위치 float2 uv : TEXCOORD0; // 모든 맵의 텍스처 좌표 float3 L : TEXCOORD1; // 접선 공간에서 광원을 향하는 벡터 float3 H : TEXCOORD2; // 접선 공간 하프웨이 벡터 float3 lightColor: TEXCOORD3; // 광원 색과 감쇠 계수 }; // 모델→클립 변환 행렬 uniform float4x4 modelToClip; // 각 본의 '바인딩 포즈→현재 포즈' 행렬 배열 #define MAX_BONES 40 uniform float4x3 boneMatrix[MAX_BONES]; // 모델 공간 무지향성 광원 위치. w에는 감쇠 반지름의 역수. uniform float4 omniPosAndInvRad; // 감쇠 전 무지향성 광원 색 uniform float3 omniColor; // 모델 공간 시점 위치 uniform float3 viewPos; // 정점 셰이더 본체 Output main(Input input) { Output output; // 혼합 행렬을 만든다. float4x3 blendedMat = boneMatrix[input.bones.x] * input.weight.x + boneMatrix[input.bones.y] * input.weight.y + boneMatrix[input.bones.z] * input.weight.z + boneMatrix[input.bones.w] * input.weight.w; // 스키닝하여 현재 포즈의 모델 공간 값을 구한다. float3 pos = mul(input.pos, blendedMat); float3 normal = normalize(mul(float4(input.normal, 0), blendedMat)); float3 tangent = normalize(mul(float4(input.tangentDet.xyz, 0), blendedMat)); // 정점 위치를 클립 공간으로 변환한다. output.pos = mul(float4(pos, 1), modelToClip); // 모델 공간에서 광원을 향하는 벡터를 계산한다. float3 L_model = omniPosAndInvRad.xyz - pos; // 정규화하고, 나중에 감쇠에 쓸 거리를 보관한다. float dist = length(L_model); float3 L_model_norm = L_model / dist; // 시선 벡터와 하프웨이 벡터를 계산한다. float3 V_model = normalize(viewPos - pos); float3 H_model = normalize(V_model + L_model_norm); // 세 번째 기저 벡터를 복원한다. float3 binormal = cross(normal, tangent) * input.tangentDet.w; // 조명 관련 벡터들을 접선 공간으로 회전시킨다. output.L.x = dot(L_model, tangent); output.L.y = dot(L_model, binormal); output.L.z = dot(L_model, normal); output.H.x = dot(H_model, tangent); output.H.y = dot(H_model, binormal); output.H.z = dot(H_model, normal); // 범위를 제한하지 않은 색과 감쇠 계수를 계산한다. float attenFactor = 1 - dist * omniPosAndInvRad.w; output.lightColor = omniColor * attenFactor; // 매핑 좌표를 수정 없이 전달한다. output.uv = input.uv; return output; }
Plain Text
복사
코드 10.24. 스키닝과 노멀 매핑을 사용하는 기하 형상의 정점 셰이더.
💡
HLSL 구현에서 확인할 조건
코드 10.23 뒤의 ‘정점들을 셰이더 상수 배열로 선언’은 실제로는 본 행렬들을 배열로 선언했다는 뜻입니다.
💡
HLSL 구현에서 확인할 조건
byte4는 본문에서 사용하는 표기이지만 이 예제 안에 형식 정의는 없습니다. 사용 환경의 입력 선언과 호환되는 정수/벡터 형식 및 본 인덱스 전달 형식을 정해야 합니다. MAX_BONES, 행렬 저장 순서, 셰이더 상수 제한도 대상 환경에 맞아야 합니다.
💡
HLSL 구현에서 확인할 조건
법선과 접선에 스키닝 혼합 행렬을 적용하는 방식은 앞 절에서 설명한 근사입니다. 변환에 비균일 크기 변화가 있거나 혼합 뒤 직교성이 흐트러지는 경우를 고려해야 합니다.