본문 바로가기
그래픽스

다중광원과 그림자 구현하기 (Multiple Light and Shadow) - 포인트라이트

by greenherb 2021. 8. 5.

이전 포스팅에서 방향성 광원에대한 빛계산및 그림자처리를했습니다.

이번에는 포인트 라이트와 스폿라이트에 대한 광원 및 그림자 구현과 이 3가지의 광원들의 그림자와 빛계산을 합치는 법을 올려보도록 하겠습니다.

기본적으로 참고했던 튜토리얼은 DirectX11 튜토리얼글중 

http://www.rastertek.com/dx11tut30.html 

 

Tutorial 30: Multiple Point Lights

Tutorial 30: Multiple Point Lights This tutorial will cover how to implement multiple point lights in DirectX 11 using HLSL and C++. Most of the tutorials I have used directional light since it simpler to understand and debug visually. However point lights

www.rastertek.com

https://learnopengl.com/Lighting/Multiple-lights

 

LearnOpenGL - Multiple lights

Multiple lights Lighting/Multiple-lights In the previous chapters we learned a lot about lighting in OpenGL. We learned about Phong shading, materials, lighting maps and different types of light casters. In this chapter we're going to combine all the previ

learnopengl.com

각각의 튜토리얼을 참고하면서 구현했습니다.

방향성광원에대한 처리는 그림자를 Cascade Shadow Map으로 처리했으므로  첫번째로는 포인트 라이트에대한 빛계산및 그림자구현 방법을 논의하도록 하겠습니다.

기본적으로 포인트라이트의 빛연산방법과 그림자매핑방법을 알고있다고 가정하며 읽으실때 모르실경우 omnidirectional shadow Mapping, pointlight shadowmapping on geometry shader

위 키워드로 검색하시면 그림자매핑에 관한 개념을 찾으실 수 있습니다.

쉐이딩 전 쉐도우 패스 단계에서 포인트라이트 에대한 그림자맵을 만들때 준비해야할 정보가있습니다. 큐브맵텍스쳐를 이용하여 포인트라이트의 위치기준으로 각 6방면에 렌더링을 진행해야하므로 포인트라이트 기준 6방면에대한 시야투영행렬이 필요합니다.

6개의 행렬을 담을 배열은 순서대로 X,-X,Y,-Y,Z,-Z 방향에대한 행렬을 의미하며 큐브맵텍스쳐의 각 Face 순서는 OpenGL이나 DirectX 나 둘다 똑같습니다.

다만 여기서 주의해야할 점은 Opengl,DirectX 와같은 3d api는 좌표계에 종속되지 않는다는것입니다. 하지만 예외가 한 가지 있는데요 바로 큐브맵에 렌더링할때는 DirectX 는 왼손좌표계 OpenGL은 오른손좌표계에 해당하는 행렬로 렌더링해야한다는것입니다. 이것을 지키지않고 한가지 좌표계로만 렌더링하게되면 큐브에 렌더링되는 순서가 뒤죽박죽 되어버립니다(정확히는 오른손이던 왼손이던 좌표계에 해당하는 행렬로 큐브 정점을 변환하고 그리고 그렇게 들어온 큐브의 정점들을 큐브맵 텍스쳐에 맞게 각  API마다 변환하는 과정이있습니다. 이떄 사용되는 공식이 해당 큐브의 정점공간이 gl은 오른손 dx는 왼손으로 가정하기떄문에 변환될때 맞지않는 좌표계행렬을 넣어주면 이상한 결과가 나오게됩니다.)

6개의 행렬이 준비되었다면 해당 행렬을 기하셰이더에 전달하여 한번의 DrawCall로 6방면 렌더링을 진행하면됩니다.(이방법은 인터넷에 자세히 설명되어있습니다. 위 Opengl 튜토리얼 사이트에 설명되어있습니다.)

다만 저는 장면에 1개의 포인트라이트가아닌 최대 4개의 포인트라이트를 설정하고싶습니다. 이경우에는 TextureCube 변수를 4개 선언하고 각각 변수마다 바인딩 포인트를 지정해야 할까요?

그럴경우 gl은 버전마다 다렉은 장치마다 바인딩포인트가 차이가 나므로 4개를 할당하는것은 다소 낭비라고 생각합니다. 고로 TextureCubeArray 를 사용하여 하나의 변수에 4개의 큐브맵텍스쳐를 담도록 하겠습니다.

그렇다면 준비해야할것은 4개의 큐브맵텍스쳐를 가진 즉 길이 4의 큐브맵배열이 필요합니다.

GL 의경우 GL_TEXTURE_CUBE_MAP_ARRAY 키워드를 사용하여 배열을 만듭니다. 이때 DSA 텍스쳐냐 그냥 텍스쳐냐에따라 다르지만 저는 TextureView를 사용하므로 DSA 텍스쳐기준으로 그리고 멀티샘플링이 없다고 가정하면glTextureStorage3D(renderId,miplevel,internalFormat,width,height,depth) 함수를 호출하여 만듭면됩니다. 위 함수의 마지막 depth 부분이 배열의 길이를 정해주는 곳이며 단순히 큐브맵 배열이므로 4를 넣으면안됩니다. 배열길이 x 큐브맵 텍스쳐 갯수(고정값 6) 으로 넣어주셔야합니다.
DX의 경우 CreateTexture2D 함수로 만듭니다 다만 D3D11_TEXTURE2D_DESC 텍스쳐 구조체에 들어갈 정보가 다른데요 여기서 ArraySize가 위에 GL 함수의 depth 값에 맞춰서 넣으시면됩니다. 우리는 이 큐브맵텍스쳐들을 깊이를 기록하는데 사용할것이므로 바인드 플래그에는 D3D11_BIND_DEPTH_STENCIL 를 넣으시고 텍스쳐큐브임을 알리는 MiscFlags에는 D3D11_RESOURCE_MISC_TEXTURECUBE 를 입력하시면됩니다.

이렇게하여 생성된 텍스쳐큐브맵을 GL에서는 프레임버퍼에 붙이고 DX에서는 렌더타겟지정시 렌더타겟은 비우고 깊이스텐실뷰만 넣어서 깊이텍스쳐만 붙여주도록합니다.

다만 붙이실때 주의할점은 gl 의경우 glFrameBufferTexture 함수를 이용하여 붙이실텐데. 이함수가 저한텐 여간 불편한게 아니었습니다. 위에서 만든 큐브맵배열의 첫번째 인덱스의 큐브맵만 필요하다고하면 위함수 상세명세서에서 명시하는 기능으로는 부분에 접근해서 붙일수가 없더라구요. (물론 제가 자세히 몰라서 그럴수도있습니다)

이경우 새로운 큐브맵텍스쳐를 만들어서 큐브맵배열에서 복사해오고 붙이는방법도있지만 너무 비효율적이라고 생각했습니다. 그래서 4.3이후로 지원하는 TextureView를 사용하는데 왜 이기능이 인터넷에서는 자세히 설명되어있지 않는지 모르겠습니다. (모든 내용이 Android 관련 내용 SurfaceView..)

TextureView는 다렉11의 텍스쳐의 ShaderView,DepthStencilView,RenderTargetView처럼 View에 해당하는 개념입니다. 좀 더 포괄적인 개념이긴 하지만요.

저는 TextureView 기능을 사용하여 원본 큐브맵배열의 정보를 가르키는 1개의 큐브맵뷰를 만들도록하겠습니다.(자세하게 만드는 내용은 gl 레퍼런스 페이지에있습니다 정말 간단합니다)

glTextureView(m_renderID, target, srcTexID, internalformat, startMip, numMip, startLayer, numlayer);

위 함수가 TextureView를 만드는 함수인데요 주의할점은 절대로 glCreateTexture함수로 만드시면 안됩니다. CreateTexture은 초기화도 진행해버리기때문에 glGenTexture함수를 호출하셔야합니다(이함수는 Id값만 예약합니다)

파라매터는 순서대로 id,타겟의경우 어떤 텍스쳐인지 (Texture2D냐 Array냐..) srcTexID는 원본 텍스쳐 id값입니다. 그다음은 내부포맷  시작할 밉수준, 밉갯수,시작할 레이어 ,레이어갯수

자세한 textureView 내용은 아래 링크의 TextureView 항목을 확인하시면 됩니다.

https://www.khronos.org/opengl/wiki/Texture_Storage#Texture_views

 

Texture Storage - OpenGL Wiki

The Texture Storage is the part of Texture objects that contains the actual pixel data stored in the texture. This article describes the layout of a texture's storage, the many ways of managing the allocation and pixel contents of a texture's storage. Anat

www.khronos.org

다렉의경우는 간단하게 텍스쳐에 대한 DepthStencilView를 만들면됩니다.

DX 깊이스텐실뷰는 1,2차원 텍스쳐만 지원합니다 CubeMap은 지원하지않습니다. 하지만 CubeMap도 사실은 텍스쳐배열이므로 Texture2DArray와 호환이 가능합니다. 고로 깊이 스텐실뷰를 만들때 viewDimension 은 Texture2DArray로 하고 시작배열위치를 정하고 배열개수는 6개로 지정하시면됩니다.

우리는 4길이의 큐브맵배열이므로 각 큐브맵의 시작위치는 0,6,12,18 이됩니다.

이렇게 준비된 뷰가 프레임버퍼나 렌더타겟함수를 이용하여 붙여지게된다면 이제 렌더링을 할차례입니다.

정점쉐이더에서는 모델트랜스폼을 이용하여 월드상으로 옮겨주고 기하세이더 코드만 잠깐 보여드리도록 하겠습니다.

#type geometry
cbuffer LightTransform : register (b1)
{
	matrix shadowMatrices[6];
}
struct GSIn
{
	float2 tc		: TexCoords;
	float4 position : SV_POSITION;
};
struct GSOutput
{
	float2 tc		: TexCoords;
	float4 FragPos  : FragPos;
	float4 pos		: SV_POSITION;
	uint RTIndex	: SV_RenderTargetArrayIndex;
};

[maxvertexcount(18)]
void GSMain(
	triangle GSIn input[3],
	inout TriangleStream< GSOutput > output
)
{
	for (int face = 0; face < 6; ++face)
	{
		GSOutput element;
		element.RTIndex = face;
		for (uint i = 0; i < 3; i++)
		{
			element.FragPos = input[i].position;
			element.pos = mul(shadowMatrices[face], input[i].position);
			element.tc = input[i].tc;
			output.Append(element);
		}
		output.RestartStrip();
	}
}

LightTransform 이 6면에 해당하는 빛행렬입니다. maxvertexcount(18)은 기하셰이더를 통해서 18개의 정점이 나간다는뜻이며 이전포스팅에서 언급한것처럼 각 6면에 각각렌더링을 수행합니다.

이렇게 그림자맵을 만들었다면 이제 쉐이딩 단계입니다.

첫번째로 포인트라이트의 쉐이더 코드입니다.

// Point Light Calc
	[unroll]
	for (int k = 0; k < 4; ++k)
	{
		float distance = length(pointLight[k].position - input.fragPos);
		if (pointLight[k].isActive)
		{
			float3 L = normalize(pointLight[k].position - input.fragPos);
			float4 fragToLight = float4(input.fragPos - pointLight[k].position, 0.0f);
			float attenuation = 1.0 / (distance * distance);
			float shadowMain = 1.0f;
			shadowMain = CalcPointShadowFactor(k, pointShadowMap, fragToLight, pointLight[k].farPlane, pointLight[k].nearPlane, isSoft);
			shadowMain = lerp(shadowMain, 1.0f, ratio);

			Lo += CalculateDirectLight(N, V, L, pointLight[k].diffuse, roughness, metallic, F0, albedo, attenuation,1.0f)*shadowMain;
		}
	}

여기서 중요시 봐야할것은 DirectLight함수는 무엇이든 될수있습니다. 여러분이사용하는 쉐이딩기법에따라 달라집니다. 기본적인정보는 빛의 방향과 빛감쇄수치 이며 이값을이용하여 쉐이딩한 색깔 Lo 가 나올것입니다 해당 색깔에 그림자계산을통해나온 그림자계수를 곱하여 (0.0f~1.0f 사이의값) 어두울지 밝을지를 정합니다.

CalcPointShadowFactor 함수는 그림자계수를 계산하는 함수로 k는 인덱스입니다 어떤 인덱스냐면 위에서 준비한 큐브맵배열 텍스쳐에 대한 인덱스입니다. 

float CalcDepthInShadow(const in float3 fragPos,float far_plane,float near_plane)
{
	const float c1 = far_plane / (far_plane - near_plane);
	const float c0 = -near_plane * far_plane / (far_plane - near_plane);
	const float3 m = abs(fragPos).xyz;
	const float major = max(m.x, max(m.y, m.z));
	return (c1 * major + c0) / major;
}
float CalcPointShadowFactor(int index,TextureCubeArray cubeTex, float4 fragToLight, float far_plane, float near_plane, bool isSoft)
{
	float  currentDepth = CalcDepthInShadow(fragToLight.xyz, far_plane, near_plane);
	float width, height,element;
	cubeTex.GetDimensions(width, height,element);
	float textureSize = 1.0f / width;
	fragToLight = normalize(fragToLight);

	float bias = 0.01f;
	float shadow = 0.0f;
	float4 direction = float4(fragToLight + gridSamplingDisk[index] * (textureSize * 2),0.0f);
	direction.w = index;
	if (isSoft)
	{
		for (int i = 0; i < 20; ++i)
		{
			shadow += cubeTex.SampleCmpLevelZero(shadowSplr, direction, currentDepth - bias);
		}
		shadow /= 20.0f;
	}
	else
	{
		shadow = cubeTex.Sample(defaultSplr, direction).r;
		if (shadow < currentDepth - bias)
			shadow = 0.0f;
		else
			shadow = 1.0f;
	}
	return shadow;
}

여기서 중요한점이 나오게됩니다. 일반적인 튜토리얼 강좌에서는 포인트 라이트의 그림자맵을 만들때 빛기준에서 물체가 얼마나떨여졌는지 실제 Z값이아닌 실제 거리값을 기록합니다. 하지만 이렇게 기록한 Z값은 레스터라이저이후에 보정받는 SlopeDethBias 보정을 받지 못하게됩니다. 우리가 기록한 Z값은 빛공간 상의 Z값입니다. (픽셀쉐이더에서 아무런 행동을하지않음) 이러한값은 보정을 받을수있습니다 ( 후에 각도에따른 그림자 여드름에 대해 설명하겠습니다.)

CalcDepthInShadow 함수는 포인트라이트 투영행렬의 nearZ,farZ 를 사용하여 현재 FragPos에대한 빛공간의 Z값을 구합니다 (간단하게 설명하자면 fragPos의 메이저 벡터 (x,y,z축중 가장큰값이 빛의 공간기준 Z축이 됩니다) 를 이용하여 Z값을 구합니다. 투영행렬을 이용하여 월드벡터에 곱하는 과정을 보면 쉽게 알수있습니다.)

TextureCubemapArray는 float4의 방향벡터를 받는데요 float3는 방향 마지막 w 요소는 큐브맵배열의 어떤 큐브맵을 사용할지 정합니다.

glsl 에서는 PCF 를 사용할떄는 samplerCubeArrayShadow 사용하지않을때는 samplerCubeArray

shadow += texture(pointShadowMap,coords,currentDepth );} 코드에서 coords 변수는 Vec4로 vec3는 방향 마지막 w요소는 인덱스를 의미하며 마지막값을 비교할 깊이값입니다.

PCF를 사용하셔도되고 사용하지않을땐 SamplerComparisonState 이아닌 그냥 SamplerState를 사용하여 샘플링하시면됩니다. (GL의경우는 Texture와 샘플러가 한꺼번에 합쳐져있기때문에 PCF를 사용하지않으려면 텍스쳐바인딩을 2곳에서 해야합니다 --; 샘플러오브젝트는있지만 너무 불편한부분인것같습니다)

이렇게 하여 그림자계수까지 구하셨다면 저의 경우 PBR이므로 간접광이아닌 직접광부분에 그림자계수가 더해지게됩니다. 아래와같은 결과가 나옵니다.

 

(유튜브의 유명엔진채널에서 UI 강의를 잘보고있어요 ㅎㅎ;)

빨간색은 디렉셔널 라이트 초록색은 포인트라이트다

그림자가 겹치는 부분과 그림자가 2개생긴것을 볼수있습니다(저도 그림자가 2개 생기는지 곰곰히 생각해보지않았는데 강한 광원이 있다면 2개가 생길수있고 겹치는부분은 더 어두워집니다.)

중요한점은 Directional Light를 계산하면서 나온 Lo (그림자계산도 적용됨) 에 포인트라이트에대한 계산결과와 그림자 결과를 더해주는것입니다 그래서 쉐이더 코드에서 계속 Lo += (값) 코드가 나옵니다.

합성은 정말 생각보다 간단합니다 ^^

 

다음포스팅은 SpotLight 도 함께 넣어서 완전한 3가지 종류의 다중광원을 보여드리도록하겠습니다.