본문 바로가기
그래픽스

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

by greenherb 2021. 8. 6.

https://learnopengl.com/Lighting/Light-casters

 

LearnOpenGL - Light casters

Light casters Lighting/Light-casters All the lighting we've used so far came from a single source that is a single point in space. It gives good results, but in the real world we have several types of light that each act different. A light source that cast

learnopengl.com

위 튜토리얼을 참고한 글입니다. 처음부분은 스폿라이트에 대해 튜토리얼 내용그대로 설명하겠습니다.

포인트라이트에 이어서 스폿라이트를 추가해보겠습니다.

스폿라이트의 빛계산은 포인트라이트와 비슷하지만 빛이 닿는 범위를 제한하여 만들어집니다.

일정각도안에있는 물체들에만 빛을쏘는 스폿라이트는 월드상의 위치, 빛의 방향, 빛의 범위를 결정하는 절단각도로 표현할 수 있는데요 아래의 이미지가 스폿라이트를 잘 나타내는 이미지입니다.

검은색선인 LightDir은 Fragment에서 빛의 위치로 향하는 방향벡터입니다. 그다음 초록색 Theta 는 LightDir과 현재 스폿라이트가 바라보는 방향사이의 각도를 의미합니다. 마지막으로 파란색인 Phi 는 절단각도로 이 범위를 벗어난 Fragment는 빛범위 밖에 존재하게 됩니다.

위 이미지로 기본적으로 스폿라이트 방향과 fragment에서 빛을 바라보는 방향사이의 각도가 Phi 보다 작으면 스폿라이트 범위에 존재한다는 것을 의미합니다. 이런정보와함께 쉐이더에서 스폿라이트 구조체 정보를 만들때 꼭 위치,방향,절단각도 정보를 넣어야한다는것을 알 수 있습니다.

float theta = dot(lightDir, normalize(-light.direction));
    
if(theta > light.cutOff) 
{       
  // do lighting calculations
}
else  // else, use ambient light so scene isn't completely dark outside the spotlight.
  color = vec4(light.ambient * vec3(texture(material.diffuse, TexCoords)), 1.0);

코드도 정말 간단합니다. theta 각은 스폿라이트 방향과 lightDir 벡터사이의 내적으로 구하게됩니다. 다만 주의하실점은 내적으로 구한각은 Cos 각도라는것을 잊으시면안됩니다. 이렇게 구한 theta각도가 우리가 준비한 cutoff 각도보다 클경우 빛연산을 수행합니다.

그렇다면 위 이미지에서 Theta는 Cutoff각도인 Phi 보다 작은것으로 보이는데 왜? 연산식에서는 클경우에 빛연산을 진행할까요? 그이유는 코사인그래프를 보면 알수있습니다. 코사인그래프는 0도에서 1 90도에서 0의 값을 가지게 됩니다. 즉 각도가 커질수록 작은값을 가지게 되는것이지요 위에서 dot 연산이 코사인각도가 나온다는것을 알 수 있습니다.

고로 lightCutOff는 실제 degree로는 Theta보다 큰값이 맞습니다 하지만 cos 각도에서는 90도에 가까운 값이므로 0에 가까운 값이 되게됩니다. 오히여 더작은 Theta의 cos각도에서는 1에 가까운 값이 나오게될것이구요 이런 사실때문에 조건문에서 부등호가 실제 이미지와다르게 반대를 나타나게 됩니다.

이렇게  구현한 스폿라이트는 가장자리가 아주 깔끔하게 잘려서 표현됩니다. 뭔가 어색해보일거에요 빛에는 음영,반영 구간이있습니다. 음영은 numbra, 반영은 penumbra 라고하며 반영은 빛의 경계선에보이는 희미한 구간을 의미합니다. 이 것을 구현하기위해 우리는 OuterCutoff라는 CutOff각도보다 큰 외부절단각을 새로 추가해서 절단각 사이의 보간을 수행 해야합니다.

cutoff 와 outercutoff 사이에는 보간

이미지를 간단히 그려봤습니다. 위에서 Theta는 Fragment와 스폿라이트사이에이루는 LightDir과 스폿라이트 방향벡터사이의 코사인 각도라는것을 알고있습니다. 만약 Theta 값이 내부 cutoff 안에있을경우는 Intensity 값은 1.0f로 만약 outercutoff 밖에있을경우는 Intensity 값은 0.0f cutoff와 outercuoff 값 사이에 있을경우는 Intensity 값이 0~1사이 값으로 보간을 진행하여 부드러운 경계선을 만들어주는것입니다.

보간은 간단한 보간식으로 구할 수 있습니다.

감마는 outercuoff의 값 여기서 Epsilon(E) 의 값은 cutoff 값을 outercutoff 로 뺀 값입니다. 기억하세요 cutoff 값이 outercutoff 값보다 실제로는 더 높은값을 가지게됩니다 고로 Theta값은 실제세계에서 각도가 커지면커질수록 작아지는값을 가지게됩니다 즉 theta 값이 cuoff의 값과 똑같아져 E의 값과 같아져 intensity 값은 1이나오게되며 만약 감마(r) 값과 같아지면 0이되어 intensity는 0이되게됩니다. 감마값보다 작아져 음수가 나올수있기때문에 우리는 산출되는 값이 범위를 0~1사이로 제한하는 연산을 해야합니다.

if (spotLight[j].isActive)
	{
		float3 L = normalize(spotLight[j].position - input.fragPos);
		float distance = length(spotLight[j].position - input.fragPos);
		float4 lightSpacePos = mul(SpotLightMat[j], float4(input.fragPos, 1.0f));
		float attenuation = 1.0 / (distance * distance);
		
		float theta = dot(L, -spotLight[j].lightDirection);
		float epsilon = spotLight[j].cutOff - spotLight[j].outerCutOff;
		float intensity = clamp((theta - spotLight[j].outerCutOff) / epsilon, 0.0, 1.0);
		float shadowMain = 1.0f;
		shadowMain = CalcDirSpotShadowFactor(j, lightSpacePos, spotShadowMap, isSoft);
		shadowMain *= intensity;
		shadowMain = lerp(shadowMain, 1.0f, ratio),0.0,1.0;
		Lo += CalculateDirectLight(N, V, L, spotLight[j].diffuse, roughness, metallic, F0, albedo, attenuation, intensity) * shadowMain;
	}

실제 제가 사용하는 코드입니다. Shadow부분은 제외하고 계산식을 보시면됩니다. L은 fragment와 스폿라이트 사이의 방향벡터이며 theta는 그 방향벡터와 스폿라이트의 방향사이의 각도입니다. 이렇게 구해진 값들을 이용하여 앱실론 값을 구하여 성형보간을 수행하는데 이렇게 얻어진 선형보간된값은 hlsl,glsl 둘다 clamp 함수를 이용하여 0.0,1.0 을넘지않도록 할 수있습니다. 조건문이있는 cutoff만 있을때보다 더 나은것같습니다. anttenuation 값은 빛감쇄 값으로 실제 월드위치를 가지는 스폿라이트는 포인트라이트처럼 거리별 빛의 상쇄값이 있어야합니다.

이렇게 얻은 Intensity와 Attenuation 값을 실제 색상이 더해지는 부분에 더하시면됩니다.

이글에서 중요한점은 스폿라이트에 대한 그림자를 만들어야한다는것입니다. 제 엔진에서는 포워드 렌더패스에서는 최대 8개의 스폿라이트로 제한했으니 8개의 그림자맵을 담는 Texture2DArray가 필요할것입니다.

이전 Cascade Shadow Map 포스팅에서 설명한것처럼 8개의 사이즈를 가지는 텍스쳐배열을 만듭시다. 이제 스폿라이트의 방향에따라 시야투영행렬을 만들어야합니다. 

float angle = (comp.outerCutOff) * 2.0f;
if (RenderAPI::GetAPI() == RenderAPI::API::DirectX11)
	shadowProj = glm::perspectiveLH_ZO(glm::radians(angle), 1.0f, comp.near_plane, comp.far_plane);
else
	shadowProj = glm::perspectiveLH_NO(glm::radians(angle), 1.0f, comp.near_plane, comp.far_plane);

Spotlightdata.matrix = shadowProj * glm::lookAt(lightPos, lightPos + forward, upVector);

투영행렬을 만들때 시야각에 주의하셔야합니다. 스폿라이트의 빛영향을 받는 영역은 outerCutoff 범위 안에있는 단편들입니다. 저는 여기서 Degree 값의 outercutoff값을 사용합니다. (쉐이더에 전달하기전에 cos 함수로 값을 변환합니다)

곱하기 2를 한이유는 시야각이기때문입니다 만약 outerCutoff 각도가 30도라면 시야각은 60도입니다 이렇게 할경우 스폿라이트 기준에서 보이는영역은 실제 outerCutoff에 제한되는 범위만 보이게 될 것입니다.

그후 빛의 방향대로 바라보는 시야행렬을 만들고 서로 곱해주면 빛행렬은 완성입니다.

이렇게 만든 행렬과함께 텍스쳐배열을 프레임버퍼에 붙여서 그림자연산을하면됩니다. 주의하실점은 텍스쳐배열의 어떤 인덱스를 가르킬지만 조심스럽게 정해주시면됩니다. 깊이기록쉐이더는 정말간단해서 올리지 않겠습니다. 정점쉐이더에서 행렬곱후 픽셀쉐이더에선 아무것도 하지않습니다.

그렇게하여 나온 그림자맵으로 그림자연산을 하면됩니다 그후나온 그림자계수에 Intensity 값을 곱하는것을 잊지마세요! intensity 값을 곱해야 스폿라이트 경계선부분의 그림자도 자연스럽게 사라집니다.

intensity를 그림자계수에 적용하지않으면 저렇게 영역밖에 색이 입혀지게됩니다. 저부분은 그림자맵에서 밝은부분이기때문에 적은 그림자계수와함께 색이 입혀지게 되는것입니다. 

 

최종적으로 구한 스폿라이트 값도 최종색을 담은 벡터에 더해주시면 아래와같은 결과가 나오게됩니다(포인트,스팟,디렉셔널)

포인트라이트,스폿라이트의 범위를 나타내는 디버깅시 선을보여주는 효과도 넣으면 좋을것같습니다. 다음에는 모델로딩과 더불어 피킹후 외곽선효과를통해 피킹된것을 나타내고 동시에 디버깅선을 보여보도록하겠습니다.