눈팅하는 게임개발자 블로그
OpenGL Lighting 3-5 Light casters 본문
원본 사이트
learnopengl.com/Lighting/Light-casters
번역 사이트
heinleinsgame.tistory.com/19?category=757483
Lighting Casters
지금까지 사용한 모든 lighting은 공간에 하나의 점으로 나타나는 하나의 광원으로 인한 것이었다.
이는 멋진 결과가 나타나긴 하지만 현실에서는 여러가지 유형의 빛들이 존재한다.
오브젝트에 빛을 cast하는 광원을 light caster라고 한다.
이번에는 여러 유형의 light caster들을 다룬다.
다른 광원을 시뮬레이션하는 법을 배우는 것은 환경을 구성함에 있어서 좋은 도구가 될 수 있다.
Directional Light
광원이 멀리 있을 때 광원으로부터 오는 광선은 서로서로에 대해 거의 평행하다.
마치 모든 광선들이 동일한 방향으로부터 오는 것처럼 보이기도 한다.
오브젝트의 위치나 viewer(카메라)의 위치에 상관없이.
이처럼 광원이 무한히 멀리 있다고 가정할 때 모든 광선들이 동일한 방향을 가지기 때문에
해당 광원을 directional light라고 부른다.
directional light의 대표적인 예는 현실세계의 태양이다.
우리의 위치로부터 무한정 멀리 떨어져 있지는 않지만 사실상 무한정 멀리 떨어져 있는 것으로
간주하고 lighting 계산을 해도 문제가 없을 정도로 멀리 떨어져 있다.
태양에서 오는 모든 광선들은 다음 이미지와 같이 서로 평행하다고 할 수 있다.
모든 광선들이 평행하기 때문에 각 오브젝트들이 광원의 위치와 어떠한 관계가 있는지에 대해서는 생각하지 않는다.
빛의 방향은 scene에 존재하는 각각의 오브젝트에 모두 동일하기 때문이다.
빛의 방향 벡터가 동일하게 유지되기 때문에 lighting 계산이 scene에 존재하는 각 오브젝트들에게 비슷하게 적용된다.
빛에 대한 위치 벡터 대신에 방향 벡터를 정의하면 이러한 directional light를 만들 수 있다.
shader 계산은 대부분 동일하지만 이번에는 빛의 position 벡터를 사용하여 계산된 lightDir 벡터 대신
빛의 direction 벡터를 직접적으로 사용한다.
struct Light {
// vec3 position; // Directional light를 사용할 때는 더 이상 필요하지 않습니다.
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
...
void main()
{
vec3 lightDir = normalize(-light.direction);
...
}
먼저 light.direction 벡터의 부호를 바꾸어두었다는 점을 알아야 한다.
지금까지 사용했던 lighting 계산들은 fragment로부터 광원으로 향하는 빛의 방향을 요구한다.
하지만 일반적으로 directional light를 광원으로부터 fragment로 향하는 방향으로 나타내는 것을 선호한다.
그러므로 빛의 방향 벡터의 부호를 바꾸어 반대 방향을 가지도록 해야한다.
이제 이 방향 벡터는 광원을 향하고 있다.
또한 입력받은 벡터를 확실하게 정규화한다.
최종 lightDir 벡터는 diffuse, specular 계산에 사용된다.
Directional light가 모든 여러 오브젝트들에 동일한 효과를 가진다는 것을 명확히 보여주기 위해 좌표시스템 에서
사용했던 컨테이너 파티 scene을 재사용한다.
먼저 10개의 다른 컨테이너 위치를 정의하고 적절한 local-to-world 변환을 가지고 있는 각자 다른 model 행렬을
생성한다.
for(unsigned int i = 0; i < 10; i++)
{
glm::mat4 model;
model = glm::translate(model, cubePositions[i]);
float angle = 20.0f * i;
model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
lightingShader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 36);
}
또한 광원의 방향을 실제로 지정한다는 것을 잊지 않아야 한다.
(현재는 방향을 광원으로부터의 방향으로서 정의한다, 이 빛의 방향은 위에서 아래쪽으로 향하는 것을 알 수 있다.)
lightingShader.setVec3("light.direction", -0.2f, -1.0f, -0.3f);
지금까지 빛의 위치 벡터와 방향 벡터를 vec3 타입으로 사용해왔지만 일부 사람들은
모든 벡터들을 vec4타입으로 정의하려는 경향이 있다.
위치 벡터를 vec4타입의 벡터로 정의할 때 w 요소를 1.0으로 설정하여
이동과 projection이 올바르게 작동하도록 하는 것이 중요하다.
하지만 방향 벡터를 vec4 타입의 벡터로 정의할 때는 이동 변환이 영향을 미치지 않아야 하기 때문에
w 요소를 0.0으로 정의할 필요가 있다.
예를 들면 연산에서 다음과 같이 사용할 수 있다.
if(lightVector.w == 0.0) // note: 부동소수점 오류를 조심하세요.
// Directional light 계산 수행
else if(lightVector.w == 1.0)
// 빛의 위치를 사용하는 light 계산 수행
이제 응용 프로그램을 컴파일하고 scene을 날아다녀 보면 모든 오브젝트에 빛을 발하는 태양 같은 것이 존재하는 것처럼
보일 것이다. diffuse, specular 컴포넌트들 모두 하늘 어딘가에 광원이 있는 것처럼 동작하는 것을 볼 수 있다.
결과는 다음과 같다.
Point Lights
Directional light들은 전체 scene을 밝히는 전반적인 빛에 사용되는 것이 좋다(태양과 같은)
하지만 일반적으로 scene 전체에 산란되는 여러 point light들도 사용한다.
point light는 world의 어딘가에 주어진 위치를 갖는 광원이다.
모든 방향으로 빛을 밝히고 거리에 따라 광선은 점점 희미해진다. 전구나 횃불을 생각하면 쉽다.
이전에서는 간단한 point light들을 가지고 작업을 해왔다.
현재는 주어진 빛의 위치로부터의 모든 방향으로 빛을 산란하는 광원을 가지고 있다.
하지만 정의했던 광원은 점점 희미해지지 않는 광선을 시뮬레이션한 것이므로
마치 광원이 매우 강한 것처럼 보인다.
대부분의 3D 시뮬레이션들에서 scene의 전체를 밝히는 것이 아니라 광원과 가까이 있는
특정 영역만을 밝히는 광원을 시뮬레이션한다.
이전까지의 lighting scene에 10개의 컨테이너들을 추가했었다면 램프 바로 앞의 컨테이너와
멀리 떨어진 컨테이너가 동일한 세기로 빛의 영향을 받았던 것을 알 수 있을 것이다.
거리에 따라 빛이 약해지는 것에 대한 공식을 사용하지 않았기 때문이다.
광원과 가까이 있는 컨테이너들과 비교해서 멀리 떨어진 컨테이너는 약간의 빛만 받도록 해야 한다.
Attenuation(감쇠)
광선이 지나가는 거리에 따라 빛의 세기를 줄이는 것을 일반적으로 attenuation이라고 부른다.
거리에 따라 빛의 세기를 줄이는 방법 중 하나는 간단히 1차 방정식을 사용하는 것이다.
이러한 방정식은 빛의 세기를 연속적으로 줄일 수 있으므로
멀리 있는 오브젝트는 적은 빛을 받을 수 있게 만들 수 있다.
하지만 이러한 1차 함수는 속임수처럼 보여질 수 있다.
현실에서 빛들은 바로 옆에 있으면 아주 밝지만 시작지점에서 광원의 밝기는 아주 빠르게 감소되고
남아있는 빛의 세기는 점점 더 느리게 감소된다. 따라서 빛의 밝기를 줄이는 데에 다른 공식이 필요하다.
운좋게도 일부 똑똑한 사람들이 이미 이것을 알아냈다. 다음 공식은 광원과 fragment 사이의 거리를
기반으로 하는 attenuation 값을 계산한다. 나중에 이 값을 빛의 세기 벡터에 곱하게 될 것이다.
(1)Fatt=1.0Kc+Kl∗d+Kq∗d2
여기에서 d는 fragment에서 광원까지의 거리를 나타낸다. 그 다음 attenuation 값을 계산하기 위해
3가지의(설정 가능한)항들을 정의한다.
Kc - 이 상수항은 일반적으로 1.0을 유지한다. 최종 결과의 분모를 1보다 작게 만들지 않도록 하기 위해 존재한다.
그렇지 않으면 특정 거리에서 빛의 세기를 증폭시켜 원하는 효과를 낼 수 없기 때문이다.
Kl - 이 1차항은 거리 값과 곱해지며 1차원 방법으로 세기를 감소시킨다.
Kq - 이 2차항은 거리의 사분면과 곱해지고 2차원 적으로 광원의 세기를 감소시킨다.
거리가 가까울 때 이 2차항은 1차항에 비해 덜 중요해질 것이고 거리가 멀때는 1차항보다 중요해질 것이다.
이 2차항 때문에 2차항이 1차항을 능가할 정도로 거리가 충분히 커질 때까지 빛의 세기는 주로 1차원적인 방법으로
감소되며 빠르게 감소될 것이다. 최종적인 효과는 빛이 가까운 범위 내에 있을 때 상당히 밝고
거리에 따라 빠르게 어두워지며 결국에는 점점 느린 속도로 어두워지게 되는 효과이다.
다음 그래프는 100 크기의 거리에서 이러한 attenuation이 가지는 효과를 보여준다.
이 빛은 거리가 작을 때 높은 세기를 가지고 거리가 커질수록 세기가 상당히 많이 줄어들고 느리게 0으로 다가간다.
이 것이 정확히 구현해야 하는 것이다.
Choosing the right valuse
하지만 이 3개의 항에 어떠한 값을 설정해야 하는지 모른다.
올바른 값을 설정하는 것은 많은 요소에 따라 다르다.
환경, 빛이 영향을 미칠 거리, 빛의 유형 등과 같은 요소들이 있다.
대부분의 경우에 적당한 정도로 조정하는 것은 단순히 경험의 문제이다.
다음 표는 특정한 반지름(거리)를 커버하는 일종의 현실적인 광원을 시뮬레이션하기 위해
가질 수 있는 이 항들의 값을 보여준다.
첫 번째 열은 주어진 항들을 사용하여 빛이 커버할 수 있는 거리를 지정한다.
이 값들은 대부분의 빛에서의 좋은 시작점이 될 수 있다.
Distance | Constant | Linear | Quadratic |
7 | 1.0 | 0.7 | 1.8 |
13 | 1.0 | 0.35 | 0.44 |
20 | 1.0 | 0.22 | 0.20 |
32 | 1.0 | 0.14 | 0.07 |
50 | 1.0 | 0.09 | 0.032 |
65 | 1.0 | 0.07 | 0.017 |
100 | 1.0 | 0.045 | 0.0075 |
160 | 1.0 | 0.027 | 0.0028 |
200 | 1.0 | 0.022 | 0.0019 |
325 | 1.0 | 0.014 | 0.0007 |
600 | 1.0 | 0.007 | 0.0002 |
3250 | 1.0 | 0.0014 | 0.000007 |
위의 테이블로 알 수 있듯이 상수항 Kc는 모든 경우에서 1.0을 유지한다.
1차항 Kl은 일반적으로 큰 거리를 커버하기 위해서는 아주 작은 값을 가지고 2차항Kq도 작아진다.
이 값들로 실험을 하여 구현되는 빛에 있어서 어떤 효과가 나타나는지를 알아야 한다.
현재 환경에서 거리는 32~100이 일반적으로 대 부분의 빛에 대해 충분하다.
Implementing Attenuation
Attenuation을 구현하기 위해 fragment에 추가적인 3개의 값이 필요하다.
공식에서 constant, linear, quadratic 항들이다.
이들을 전에 정의했었던 light struct 안에 저장해 놓는 것이 좋다.
Directional Light 섹션에서 했던 것이 아닌 이 전에 했던 것처럼 lightDir을 계산한다.
struct Light {
vec3 position;
vec3 ambient;
vec3 diffuse;
vec3 specular;
float constant;
float linear;
float quadratic;
};
그런 다음 OpenGL에서 항들을 설정한다.
현재 빛이 50만큼의 거리를 커버하기를 원하므로 표의 적절한 constant, linear, quadratic 항들을 사용한다.
lightingShader.setFloat("light.constant", 1.0f);
lightingShader.setFloat("light.linear", 0.09f);
lightingShader.setFloat("light.quadratic", 0.032f);
Fragment Shader에서 attenuation을 구현하는 것은 비교적 간단하다.
공식을 기반으로 간단히 attenuation 값을 계산하고 이 것을 ambient, diffuse, specular 컴포넌트에 곱하기만 해주면 된다.
공식에 필요한 광원까지의 거리가 필요하다. 벡터의 길이를 계산하는 방법을 기억해야 한다.
fragment와 광원 사이의 거리를 얻음으로써 distance(거리) 항을 얻을 수 있다.
GLSL의 length 함수를 사용하여 벡터의 길이를 얻을 수 있다.
float distance = length(light.position - FragPos);
float attenuation = 1.0 / (light.constant + light.linear * distance +
light.quadratic * (distance * distance));
다음 이 attenuation 값을 ambient, diffuse, specular 컬러에 곱하여 lighting 계산에 포함시킨다.
ambient *= attenuation;
diffuse *= attenuation;
specular *= attenuation;
결과는 다음과 같다.
이제 광원과 가장 가까운 컨테이너가 가장 밝게 비치는 것을 볼 수 있다.
멀리 떨어져 있는 컨테이너들은 거의 빛을 받지 못한다.
따라서 point light는 lighting 계산에 적용되는 위치와 attenuation을 설정할 수 있는 광원이다.
Spotlight
이제 마지막 유형의 light를 다룬다. spotlight라고 하며 환경의 어딘가에 위치한 광원이다.
모든 방향으로 광선을 쏘지 않고 특정 방향으로만 쏜다.
결과적으로 spotlight 방향의 특정 반지름 내부에 있는 오브젝트만 밝아지고 나머지 모두는 어두워진다.
spotlight의 좋은 예는 가로등과 손전등이다.
OpenGL의 spotlight는 world-space에서의 위치, 방향, cutoff 각으로 나타내진다.
각 fragment에 대해 spotlight의 cutoff 방향 사이(원뿔 내부)에 있는지 계산하며, 만약 그렇다면
그에 맞춰서 fragment를 밝힌다.
다음 이미지는 spotlight가 어떻게 동작하는지에 대한 개념을 보여준다.
LightDir : fragment에서 광원까지의 방향을 나타내는 벡터
SpotDir : spotlight가 겨누고 있는 방향
Phi ϕ: spotlight의 반지름을 지정하는 cutoff각이다. 이 각 외부에 있는 모든 것들은 spotlight에 의한 빛을 받지 못한다.
Theta θ: LightDir 벡터와 SpotDir 벡터 사이의 각도이다. spotlight의 내부에 있기 때문에 Theta의 값은 Phi의 값보다 작아야 한다.
그래서 기본적으로 현재 필요한 것은 LightDir 벡터와 SpotDir 벡터를 내적(두 벡터의 내적은 두 벡터 사이의 각에 대한 cosine 값을 리턴한다)
하여 이를 cutoff 각 Phi와 비교하는 것이다.
이제 여기서 생각해보면 손전등과 비슷한 것을 만드려고 한다는 것을 이해할 수 있다.
Flashlight
Flashlight는 viewer의 위치에 있고 일반적으로 플레이어의 관점을 향해 똑바로 겨누고 있는 spotlight이다.
기본적으로 flashlight는 일반적인 spotlight이지만 위치와 방향이 플레이어의 위치와 방향에 따라 계속해서 업데이트
된다는 점이 다르다.
그래서 fragment shader를 위해 필요한 값들은 spotlight의 위치 벡터(빛의 방향을 계산하기 위해),
방향 벡터, cutoff 각이다. 이 값들을 light struct에 저장한다.
struct Light {
vec3 position;
vec3 direction;
float cutOff;
...
};
그 다음 적절한 값들을 shader에 넘겨준다.
lightingShader.setVec3("light.position", camera.Position);
lightingShader.setVec3("light.direction", camera.Front);
lightingShader.setFloat("light.cutOff", glm::cos(glm::radians(12.5f)));
Cutoff값을 각도로 설정하지 않고 각에 대한 cosine 값을 계산하고 이 값을 fragment shader로 전달하도록 설정한 것을
볼 수 있다.
이에 대한 이유는 fragment shader에서 LightDir과 SpotDir 벡터의 내적을 계산하고 있는데 내적은 각이 아닌
cosine 값을 반환하므로 cosine 값과 각을 직접적으로 비교할 수 없기 때문이다.
각을 구하기 위해서는 내적의 결과에 대해 cosine의 역함수를 사용하여야 하는데 이는 비용이 많이 드는 연산이다.
그래서 약간의 성능 향상을 위해 주어진 cutoff각에 대한 cosine 값을 계산하고 이 결과를 fragment shader에 전달한다.
두 각 모두 이제 cosine으로 표현되었기 때문에 비용이 많이 드는 연산을 할 필요 없이 직접적으로 비교를 할 수 있다.
이제 남은 것은 theta값을 계산하고 이를 cutoff 값과 비교하여 spotlight의 내부에 있는지 외부에 있는지 판단하는 것이다.
float theta = dot(lightDir, normalize(-light.direction));
if(theta > light.cutOff)
{
// Lighting 계산 수행
}
else // 아니면, ambient light를 사용하므로 spotlight 외부에 있더라도 완전히 어둡지는 않습니다.
color = vec4(light.ambient * vec3(texture(material.diffuse, TexCoords)), 1.0);
먼저 lightDir벡터와 부호를 바꾼 direction 벡터(광원으로부터 나오는 것이 아닌 광원을 향하는 벡터가 필요하기 때문)의
내적을 계산한다. 관련된 모든 벡터들을 정규화했는지 확인한다.
if문의 조건식이 반대로 되어있는데(theta 값이 cutoff보다 작아야 하는데 반대로 되어있다.), 이 값이 cosine 값으로 나타내어졌다는 것을 유념해야 한다. 각이 0이면 cosine 값이 1.0으로 나타나고 90도면 cosine 값은 0.0으로 나타난다.
cosine 그래프는 다음과 같다.
해당 그래프에서 cosine 값이 1.0에 가까울수록 각도가 작다는 것을 알 수 있다.
이제 왜 theta가 cutoff값보다 커야하는 지 생각해보면, cutoff 값은 현재 cosine 12.5로 설정되어 있고,
이는 0.9978과 동일하므로 cosine theta의 값이 0.9979와 1.0 사이에 있어야 spotlight 내부에
fragment가 존재한다는 사실을 의미한다.
응용 프로그램을 실행하면 spotlight의 원뿔 내부에 있는 fragment들에만 빛을 비추는 spotlight를 볼 수 있다.
결과는 다음과 같다.
하지만 여전히 뭔가 아쉬운 부분이 있다.
spotlight가 명백한 외곽선을 가지고 있는 것처럼 보이기 때문이다.
fragment가 spotlight의 원뿔의 외곽선에 닿아있는 곳마다 멋지게 희미해지는 것이 아니라
완전히 빛이 닫혀버린다.
현실적인 spotlight는 외곽선에서 점차적으로 빛을 감소시키고는 한다.
부드러운 외곽선
부드러운 외곽선을 가진 spotlight를 생성하기 위해 inner(내부) 원뿔과 outer(외부)원뿔을 가지는
spotlight를 시뮬레이션해야 한다.
내부 원뿔은 이전에 정의했었던 원뿔로 설정하고 외부 원뿔로 갈수록 점점 빛이 어두워지는 효과를 적용해야 한다.
외부 원뿔을 생성하기 위해 간단히 spotlight의 방향 벡터와 외부 원뿔의 벡터 사이의 각에 대한 cosine 값을 정의하면 된다.
다음 fragment가 내부 원뿔과 외부 원뿔 사이에 있으면 빛의 세기 값을 0.0~1.0 사이로 계산한다.
fragment가 내부 원뿔 안에 존재한다면 빛의 세기는 1.0과 같고 외부 원뿔의 바깥에 존재한다면 0.0과 같다.
다음 공식을 사용하여 이러한 값들을 계산할 수 있다.
여기에서 ϵ (epsilon)은 내부 원뿔 (ϕ)과 외부 원뿔 (γ) 사이의 차이가 된다. (ϵ=ϕ−γ). 최종 결과인 I 값은 현재 fragment의 spotlight 빛의 세기가 된다.
이 공식이 실제로 어떻게 동작하는지 시각화하는 것은 약간 어렵다.
많은 샘플 값들을 알아보자.
이 테이블에서 알 수 있다 시피 기본적으로 외부 cosine과 내부 cosine 사이를 theta값을 기반으로 보간하고 있다.
이제 빛의 세기 값을 가지고 있지만 이 값은 spotlight 외부에 있으면 음수 값을 가지고
내부 원뿔에 있을 때는 1.0보다 큰 값을 가지게 된다.
이 값을 적절하게 고정시킨다면 fragment shader에 if-else 구문이 필요없어질 것이다.
그 다음 계산된 세기 값을 light 컴포넌트들에 곱해줄 수 있다.
float theta = dot(lightDir, normalize(-light.direction));
float epsilon = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);
...
// Ambient에는 영향을 미치지 않으므로 항상 작은 빛을 가지고 있습니다.
diffuse *= intensity;
specular *= intensity;
...
첫 번째 파라미터를 0.0~1.0 사이의 값으로 clamp하는 clamp 함수를 사용해야 한다.
이 함수가 값이 0~1 사이에서 벗어나지 않도록 해준다.
outerCutOff값을 Light struct에 추가해야 한다.
그리고 응용 프로그램에서 이것의 uniform 값을 설정한다.
결과는 다음과 같다.
'공부한거 > OpenGL' 카테고리의 다른 글
OpenGL Lighting 3-6 Multiple Lights (0) | 2020.12.06 |
---|---|
OpenGL Advanced 5-6 Cubemaps (0) | 2020.11.26 |
OpenGL Lighting 3-4 Lighting maps (0) | 2020.10.22 |
OpenGL Lighting 3-3 Materials (0) | 2020.10.22 |
OpenGL Lighting 3-2 Basic Lighting (0) | 2020.10.11 |