🕹️자체엔진/DirectX 11 개인공부

Domain Shader로 구현하는 GPU기반 Deferred Shading Sphere Lighting Volume 코드 리뷰 [[ HLSL

Mawile 2025. 4. 11.

사실 이 책은 예전에 보던 책인데, DirectX 재활하는중이기도 하고 복습하는 느낌으로 한번 더 보고있다.
그 중 Deferred Shading 부분의 Point Light룰 구현하는 부분이 있다.
이 책이 정말 흥미로운 점이 Deferred Shading에서 사용하는 Lighting Volume을 GPU로 구현한다는것이다.
정말 재미있다.
나는 CPU로도 당연히 구현을 해봤는데 개인적으로 Hull Shader와 Domain Shader를 사용하는 이 책의 방법이 매우 매력적이다.
 
오늘 포스팅에서 할 내용은 이 코드의 핵심 부분을 리뷰하고 실험하고 테스트 하는 그런 글이다.
아마 이 글은 나랑 같이 이 책을 읽고 있는 사람들을 위힌 글 아닐까?
 

코드 (Vertex Shader)

float4 PointLightVS() : SV_Position
{
    return float4(0.0, 0.0, 0.0, 1.0); 
}

정점 셰이더에서는 어떠한 정보도 넘겨주지 않고 있다.
당연하게도 이 책에서는 드로우콜을 할때 CPU메모리에서 어떠한 정점정보를 넘겨주지 않고 있다.
어차피 Hull Shader에서 정점 정보를 생성할것이기 때문이다.
 

Hull Shader

static const float3 HemilDir[2] = {
	float3(1.0, 1.0,1.0),
	float3(-1.0, 1.0, -1.0)
};

[domain("quad")]
[partitioning("integer")]
[outputtopology("triangle_ccw")]
[outputcontrolpoints(4)]
[patchconstantfunc("PointLightConstantHS")]
HS_OUTPUT PointLightHS(uint PatchID : SV_PrimitiveID)
{
	HS_OUTPUT Output;

	Output.HemiDir = HemilDir[PatchID];

	return Output;
}

Hull Shader에서는 총 2개의 패치 정보를 넘겨줄 생각이다.
우리가 만들 볼륨메시는 구가 되어야하는데 반구 2개를 합쳐서 구를 만들고 있다.
즉, 1개의 패치가 반구 한쪽을 담당한다는것을 볼 수 있다. HemiDir의 역할은 Domain Shader에서 설명하겠다.
 

Domain Shader

/////////////////////////////////////////////////////////////////////////////
// Domain Shader shader
/////////////////////////////////////////////////////////////////////////////
struct DS_OUTPUT
{
	float4 Position : SV_POSITION;
	float2 cpPos	: TEXCOORD0;
};

[domain("quad")]
DS_OUTPUT PointLightDS( HS_CONSTANT_DATA_OUTPUT input, float2 UV : SV_DomainLocation, const OutputPatch<HS_OUTPUT, 4> quad)
{
	// Transform the UV's into clip-space
	float2 posClipSpace = UV.xy * 2.0 - 1.0;

	// Find the absulate maximum distance from the center
	float2 posClipSpaceAbs = abs(posClipSpace.xy);
	float maxLen = max(posClipSpaceAbs.x, posClipSpaceAbs.y);

	// Generate the final position in clip-space
	float3 normDir = normalize(float3(posClipSpace.xy, (maxLen - 1.0)) * quad[0].HemiDir);
	float4 posLS = float4(normDir.xyz, 1.0);
	
	// Transform all the way to projected space
	DS_OUTPUT Output;
	Output.Position = mul( posLS, LightProjection );

	// Store the clip space position
	Output.cpPos = Output.Position.xy / Output.Position.w;

	return Output;
}

 
사실상 이 부분이 이 포스팅에서 설명하고자 한 핵심이다.
그러는 동시에 Sphere Lighting Volume을 생성하는 핵심파트이기도 하다.
 
우선

float2 posClipSpace = UV.xy * 2.0 - 1.0;

이 부분은 좌표계를 바꾸는 부분이다.
기존 Domain Shader에서 넘겨주는 SV_DomainLocation 이라는 SystemValue는 좌측 하단이 (0, 0)이고 우측 상단으로 갈수록 (1, 1)에 가까워지는 좌표계이다.
하지만 위와 같은 연산을 거치며, 일반적으로 우리가 수학좌표계에서 볼 수 있는[-1, 1] 범위의 정가운데가 원점인 좌표계로 변환된다.
 

float2 posClipSpaceAbs = abs(posClipSpace.xy);
float maxLen = max(posClipSpaceAbs.x, posClipSpaceAbs.y);

이부분은 xy의 값을 비교해서 가장자리로 갈수록 z값의 더 변화를 줌으로써 최종적으로 반구 모양이 될수 있도록 하는 코드이다.
실제로 xy값에 따른 maxLen의 값변화 그래프가 이런 양상을 띄고 있다.

Figure 1

 
가장자리로 갈수록 1에 더 가까워지는(더 구부러지는)걸 볼수가 있다.
 

float3 normDir = normalize(float3(posClipSpace.xy, (maxLen - 1.0)) * quad[0].HemiDir);
float4 posLS = float4(normDir.xyz, 1.0);
	
// Transform all the way to projected space
DS_OUTPUT Output;
Output.Position = mul( posLS, LightProjection );

이 코드는 이제 실제로 View 공간으로 변환하기 이전에 Hull Shader에서 넘겨주는 패치에 따른 반구의 최종 위치를 정하는 부분이다.
xy부분은 그대로 유지하는데 z값에 의문이 생길수 있다.
현재 maxLen의 범위는 [0, 1]인데 -1을 하게되면 [-1, 0]이 된다.
이렇게 정의역을 설정한 이유는 View 공간에서는 -Z방향이 앞부분이 되기 때문이다.
 
따라서 정의역을 [-1, 0]으로 설정한것이다.
그리고 HemiDir이 이제 패치에 따른 반구를 나누어 주는 부분이다.
 
위로 거슬러 올라가면 HemiDir의 내용이 이런식으로 되있다는것을 볼 수 있다.

static const float3 HemilDir[2] = {
	float3(1.0, 1.0,1.0),
	float3(-1.0, 1.0, -1.0)
};

난 개인적으로 이 부분이 작년에 공부했을때 이해가 안갔었는데 올해 들어서야 이해가 갔다.
 
일단 단순하게 생각했을때
xy부분은 달라질게 없고 그냥 z부분만 다르게해서

static const float3 HemilDir[2] = {
	float3(1.0, 1.0,1.0),
	float3(1.0, 1.0, -1.0)
};

이렇게 해도 되지 않을까? 생각했었는데 당연히 안된다.
이게 안됬던 이유는 normDir벡터가 View 공간에서 계산된 벡터라는것을 가정하지 않았기 때문이다.
 
실제 이 상태로 프로그램을 구동해보면 이런식으로 나오는걸 볼 수가 있다.

위 결과를 통해서 어떤 상황이 벌어졌는지 비로소 이해가 갔다.
한마디로 하나의 반구는 counter-clockWise로 렌더링이 되었는데 다른쪽 반구는 clockWise로 렌더링이 되면서 서로 맞물리지 않는것이다. 따라서 다른쪽 반구는 x방향(또는 y방향)도 반전시켜줌으로써 면이 컬링되는 부분을 역전시켜준것이다.
당연히 HemiDir = (1, -1, -1)로 줘도 작동한다.
 

// Transform all the way to projected space
DS_OUTPUT Output;
Output.Position = mul( posLS, LightProjection );

// Store the clip space position
Output.cpPos = Output.Position.xy / Output.Position.w;

이 코드는 이제 View행렬과 Projection행렬을 곱한 LightProjection행렬을 곱한것이다.
그러면 이제 Output.Position은 Clip좌표계로 변환이 완료되었지만 실제 빛 연산을 수행하기 위해서
w성분을 나눔으로써 NDC좌표계로 변환하면서 Domain Shader가 끝나게 된다.
 

결과

Result

 

마무리

오랜만에 기술글을 썼는데 다음에는 내가 이전에 올렸던 글중
https://mawile.tistory.com/374

특정 축을 기준으로 회전하는 알고리즘 아이디어

그냥 선형대수학 공부하고있는데 뜬금없이 생각나서 적어본다. 나중에 까먹을 가능성이 높아서 여기다 적어놓는다. 틀릴수도있는데 일단 되게 간단하고 직접 손으로 값대입해보면서 넣어봤는

mawile.tistory.com

이 글이 있다.
특정 축을 기준으로 회전하는 알고리즘을 구현해보고 실험해보는 글을 올릴 생각이다.
기본적으로 해당 글에 오류가 있는데 그 부분을 지적해보고 좋은 알고리즘을 공부해와서 그 알고리즘을 글로 쓸것이다.
아주 좋다 ㅎㅎ

댓글