일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- C++
- 동적 색인화
- Frustum Culling
- direct3d
- Dynamic Indexing
- 입방체 매핑
- Deferred Rendering
- 직교 투영
- DirectX
- 큐브 매핑
- 게임 클래스
- effective C++
- Direct3D12
- TCP/IP
- light
- FrameResource
- gitlab
- InputManager
- 장치 초기화
- 노멀 맵핑
- 조명 처리
- 디퍼드 렌더링
- 게임 디자인 패턴
- DirectX12
- 게임 프로그래밍
- 네트워크 게임 프로그래밍
- 절두체 컬링
- 네트워크
- Render Target
- gitscm
- Today
- Total
코승호딩의 메모장
[Light-2] 본문
이번 글에서는 이전 조명 처리에서 아쉬웠던 부분을 보강하고자 한다. 목표는 패스 당 상수 버퍼에 카메라 정보를 넣어줄 것이고 머티리얼에서 거칠기, 프레넬, 분산 반사율 등을 실제로 사용하여 조명을 구현하고자 한다.
PassConstants
이전까지는 ObjectConstants에 조명 작업에 필요한 view, viewproj, proj 정보 등을 넘겨 주었다. 그러나 이 카메라 정보들은 오브젝트 당이 아닌 한 프레임 당 업데이트가 일어나는 정보이다. 따라서 ObjectConstants와 PassConstants를 수정한다.
struct ObjectConstants
{
Matrix matWorld;
};
struct PassConstants
{
Matrix view = Matrix::Identity;
Matrix proj = Matrix::Identity;
Matrix viewProj = Matrix::Identity;
Vec4 eyePosW = { 0.f, 0.f, 0.f, 0.f };
float nearZ = 0.f;
float farZ = 0.f;
float totalTime = 0.f;
float deltaTime = 0.f;
Vec4 ambientLight = { 0.f, 0.f, 0.f, 1.f };
uint32 lightCount;
Vec3 padding;
LightInfo lights[50];
};
이제 오브젝트 당 상수 버퍼에는 그저 월드 변환 행렬만 들어있을 뿐이다. 그리고 패스 당 상수 버퍼에 온갖 필요한 정보들을 넣어주기로 한다. 추가로 이전에는 주변광(ambientLight)가 모든 조명마다 존재하였다. 또한 주변광임에도 불구하고 거리가 멀어질수록 어두워지다 아예 사라지는 것을 볼 수 있다. 때문에 주변광을 씬마다 하나씩만 가지고 있도록 변경한다. 그러면 프레임 당 한 번씩만 씬의 주변광을 업데이트하면 될 것이다.
class Camera : public Component
{
private:
friend class Scene;
Matrix _matView = {};
Matrix _matProjection = {};
//...
};
그리고 카메라에서 정적 변수였던 S_MatView와 S_MatProjection를 삭제했다. 어차피 카메라 상수 버퍼 정보는 딱 하나의 메인 카메라에 의해서만 업데이트가 일어나므로 씬이 메인 카메라를 가지고 있으니 씬에서 메인 카메라에 friend로 접근하여 내부 변수를 사용하도록 만들면 된다.
void Scene::PushPassData()
{
PassConstants passConstants = {};
passConstants.view = _mainCamera->GetCamera()->_matView;
passConstants.proj = _mainCamera->GetCamera()->_matProjection;
passConstants.ambientLight = _ambientLight;
//... PassData
//... LightInfo
CB(CONSTANT_BUFFER_TYPE::PASS)->PushPassData(&passConstants, sizeof(passConstants));
}
이제 씬에서는 렌더 전 프레임 당 한 번만 일어나는 패스 당 상수 버퍼 업데이트를 할 때, 자신이 가지고 있는 메인 카메라 정보를 기반으로 패스 당 상수 버퍼 정보를 업데이트 해준다. 당연히 상수 버퍼가 바뀌었으니 쉐이더 정보도 바뀌어야 한다.
LightInfo
이전까지는 LightInfo에 들어가는 잡다한 변수들이 많았다. LightColor라는 색상을 지정하는 구조체 또한 살펴보면 diffuse, ambient, specular를 가지고 있었는데, 사실 ambient는 앞서 말했듯이 씬에서 하나만 갖고 있도록 하는 것이 합당하고 specular는 머티리얼의 프레넬 효과에 따라서 변경되어야 하는 값이지 빛에서 변경되어야 하는 값이 아니다.
struct LightInfo
{
Vec3 strength = { 0.5f, 0.5f, 0.5f };
float fallOffStart = 1.0f; // point/spot light only
Vec3 direction = { 0.0f, -1.0f, 0.0f }; // directional/spot light only
float fallOffEnd = 10.0f; // point/spot light only
Vec3 position = { 0.0f, 0.0f, 0.0f }; // point/spot light only
float spotPower = 64.0f; // spot light only
int32 lightType;
Vec3 padding;
};
이제 LightInfo 변수들을 간추려 보면 다음과 같다. 공통으로 사용하는 strength(이전 diffuse에 해당)와 lightType을 제외하면 각자 사용해야 하는 변수들이 다르다. 세부 내용은 차차 알아볼 것이다.
struct MaterialConstants
{
Vec4 DiffuseAlbedo = { 1.0f, 1.0f, 1.0f, 1.0f };
Vec3 FresnelR0 = { 0.01f, 0.01f, 0.01f };
float Roughness = 0.25f;
Matrix MatTransform = MathHelper::Identity4x4();
};
이전부터 정의는 되었으며 상수버퍼로도 넘겨왔지만 전혀 사용을 하지 않았던 머티리얼 상수 버퍼이다. 이 정보들 또한 어떤 의미를 갖고 있는지 차차 알아보도록 하자. MatTransform은 텍스쳐 변환을 수행하기 위함이다. 예를 들어 텍스쳐를 이동하고 싶거나 확대하고 싶거나 축소하고 싶을 때 변환 정보를 넘겨주면 된다.
Light HLSL
각 상수 버퍼를 수정하였으므로 알맞게 구조체 정보를 수정한 상태이다. 이전에는 조명 처리를 뷰 스페이스에서 진행했다. 그러나 이번 구현에서는 이해하기 쉽게 월드 스페이스에서 진행하도록 한다.
struct VS_IN
{
float3 posL : POSITION;
float3 normalL : NORMAL;
float2 uv : TEXCOORD;
};
struct VS_OUT
{
float4 posH : SV_Position;
float3 posW : POSITION;
float3 normalW : NORMAL;
float2 uv : TEXCOORD;
};
우선 입력 정점과 출력 정점을 다음과 같이 수정하였다. 뒤에 붙은 L은 Local, W는 World, H는 Homogeneous 좌표계이다.
VS_OUT VS_Main(VS_IN vin)
{
VS_OUT vout = (VS_OUT)0.f;
// 세계 공간으로 변환
float4 posW = mul(float4(vin.posL, 1.0f), gObjConstants.world);
vout.posW = posW.xyz;
// 비균등 비례가 없을 경우 법선 변환 있다면 역전치 행렬을 사용
vout.normalW = mul(vin.normalL, (float3x3)gObjConstants.world);
// 동차 절단 공간으로 변환
vout.posH = mul(posW, gPassConstants.viewProj);
float4 uv = mul(float4(vin.uv, 0.f, 1.f), gMaterialConstants.matTransform);
vout.uv = uv.xy;
return vout;
}
정점 쉐이더에서는 로컬 좌표계를 월드 좌표계로 변환하고 출력 정점의 SV_Position에는 동차 절단 공간으로 변환한 좌표를 넘겨준다. 조명 처리는 픽셀 셰이더에서 일어나므로 픽셀 셰이더에서는 posW 값을 사용하면 된다.
float4 PS_Main(VS_OUT pin) : SV_Target
{
float4 diffuseAlbedo = gDiffuseMap.Sample(gsamAnisotropicWrap, pin.uv) * gMaterialConstants.diffuseAlbedo;
// 보간 과정에서 단위 벡터가 안될 수 있으므로 노말라이즈를 한다.
pin.normalW = normalize(pin.normalW);
// 조명되는 점에서 눈으로의 벡터
float3 toEyeW = gPassConstants.eyePosW.xyz - pin.posW;
float distToEye = length(toEyeW);
toEyeW /= distToEye; // 노말라이즈
// 주변광
float4 ambient = gPassConstants.ambientLight * diffuseAlbedo;
// 광택 : 거칠수록 광택이 떨어짐
const float shininess = 1.0f - gMaterialConstants.roughness;
// 조명을 입힐 최종 머티리얼
float3 shadowFactor = 1.0f;
Material mat = { diffuseAlbedo, gMaterialConstants.fresnelR0, shininess };
float4 directLight = ComputeLighting(gPassConstants.lights, mat, pin.posW, pin.normalW, toEyeW, shadowFactor);
float4 resColor = ambient + directLight;
// 분산 재질에서 알파를 가져온다.
resColor.a = diffuseAlbedo.a;
return resColor;
}
픽셀 쉐이더에서는 조명 처리를 위해 필요한 변수들을 계산하고 저장해둔 다음 인자로 넘겨준다. 광원에서 나오는 빛에 의해 영향을 주는 조명은 직접 조명이다. 그리고 모든 오브젝트에 똑같이 밝기를 주는 주변광은 간접 조명에 해당한다. 따라서 간접 조명 값에 직접 조명을 계산하여 더해주면 끝이다. 이전에는 빛이 각 주변광을 값을 가지고 있었고 거리에 따라 계산을 해줬기에 거리가 멀어질수록 오브젝트가 주변광의 색을 띄우는게 아니라 아예 사라져버렸다.
float4 ComputeLighting(LightInfo gLights[MaxLights], Material mat, float3 pos, float3 normal, float3 toEye, float3 shadowFactor)
{
float3 result = 0.0f;
for (int i = 0; i < gPassConstants.lightCount; ++i)
{
if (gLights[i].lightType == 0)
result += shadowFactor[i] * ComputeDirectionalLight(gLights[i], mat, normal, toEye);
if (gLights[i].lightType == 1)
result += ComputePointLight(gLights[i], mat, pos, normal, toEye);
if (gLights[i].lightType == 2)
result += ComputeSpotLight(gLights[i], mat, pos, normal, toEye);
}
return float4(result, 0.f);
}
패스 당 상수 버퍼에 있는 모든 라이트들과 각 정보들을 인자로 넘겨 받은 이 함수에서는 조명의 타입에 따라 각 조명 계산을 수행하고 결과 값에 모두 더해준다. 따라서 조명이 동시에 비치면 더 밝은 효과를 띄게 된다.
float3 ComputeDirectionalLight(LightInfo L, Material mat, float3 normal, float3 toEye)
{
float3 lightVec = -L.direction;
float ndotl = max(dot(lightVec, normal), 0.0f);
float3 lightStrength = L.strength * ndotl;
return BlinnPhong(lightStrength, lightVec, normal, toEye, mat);
}
Directional Light에서는 이전과 같다. 빛의 세기에 각도에 따른 값을 곱해줌으로써 뒤에 있는 부분은 빛이 도달하지 않도록 한다. 모든 계산을 끝내고 BlinnPhong 함수를 호출하는데 이는 좀 이따가 알아보도록 하자.
float3 ComputePointLight(LightInfo L, Material mat, float3 pos, float3 normal, float3 toEye)
{
float3 lightVec = L.position - pos;
float d = length(lightVec);
if(d > L.fallOffEnd)
return 0.0f;
lightVec /= d;
float ndotl = max(dot(lightVec, normal), 0.0f);
float3 lightStrength = L.strength * ndotl;
float att = CalcAttenuation(d, L.fallOffStart, L.fallOffEnd);
lightStrength *= att;
return BlinnPhong(lightStrength, lightVec, normal, toEye, mat);
}
Point Light에서는 이전과 같지만 하나 추가된 점은 CalcAttenuation이라는 함수를 사용했다는 점이다. 이 함수는 빛의 세기를 거리에 따라 약해지게 만드는 선형 감쇠 함수이다. 이 함수는 saturate((falloffEnd-d) / (falloffEnd - falloffStart)) 으로 되어 있는데 자세히 보면 거리 d가 falloffStart에 도달하기 전까지는 1.0을 유지하다가 falloffStart부터 falloffEnd까지 점차 0으로 감소하게 된다. 이 값이 주변광에 역시 영향을 미치지 않도록 해야 한다. 주변광은 항상 보여야 하기 때문이다.
float3 ComputeSpotLight(LightInfo L, Material mat, float3 pos, float3 normal, float3 toEye)
{
// Equal to Point Light method
float spotFactor = pow(max(dot(-lightVec, L.direction), 0.0f), L.spotPower);
lightStrength *= spotFactor;
return BlinnPhong(lightStrength, lightVec, normal, toEye, mat);
}
Spot Light에서는 앞부분은 Point Light와 완전히 일치하지만 뒤에 한 가지가 추가된다. 바로 spotFactor라는 요소인데 이 값을 살펴보면 cos에 제곱을 해준다는 것을 볼 수 있다.
왼쪽 그림은 y = cos(x)^2에 해당하고 오른쪽 그림은 y = cos(x)^8에 해당한다. 따라서 spotPower 값이 커질수록 빛이 도달하는 범위가 점점 줄어드는 것이다. 8정도면 약 45도 각도 정도를 보여준다.
float3 BlinnPhong(float3 lightStrength, float3 lightVec, float3 normal, float3 toEye, Material mat)
{
const float m = mat.shininess * 256.0f;
float3 halfVec = normalize(toEye + lightVec);
float roughnessFactor = (m + 8.0f)*pow(max(dot(halfVec, normal), 0.0f), m) / 8.0f;
float3 fresnelFactor = SchlickFresnel(mat.fresnelR0, halfVec, lightVec);
float3 specAlbedo = fresnelFactor*roughnessFactor;
specAlbedo = specAlbedo / (specAlbedo + 1.0f);
return (mat.diffuseAlbedo.rgb + specAlbedo) * lightStrength;
}
이 함수는 프레넬 반사와 표면 거칠기를 결합하는 함수이다.
다음은 여러 재질의 슐릭 근사 그래프이다. 완전한 프레넬 방정식은 상당히 복잡하기 때문에 대신 슐릭 근사가 흔하게 사용된다. 세타가 90도에 가까워 질수록 반사광의 양도 증가한다. 예를 들어 연못에서 자신의 발 아래를 내려다보면 세타가 0에 가까운 값이기 때문에 반사광의 양도 작아진다. 그러나 먼 수평선을 보면 세타가 90에 가까워져서 반사광의 양도 증가한다. 간단히 프레넬 효과를 말하자면, 반사광의 양은 법선과 빛 벡터 사이의 각도와 재질(Rf(0))에 의존한다. 따라서 물과 같이 매질 값 Rf(fresnelR0)가 매우 작다면 반사광의 양이 매우 적고 금과 같이 매질 값이 높다면 반사광의 양이 높다.
다음은 표면 거칠기에 의한 빛의 반영 반사를 모형화하는 함수이다. 그냥 m에 따라 반영 돌출부가 넓어지거나 좁아지는 점을 확인하면 된다. m이 작다면 표면이 거칠어서 빛 에너지가 좀 더 넓게 퍼지므로 반영 돌출부가 넓어진다. 결국 에너지가 분산되므로 반영 하이라이트가 어두워진다. 반대로 m이 크다면 매끄러워져서 반영 돌출부가 좁아져 하이라이트가 밝아진다.
따라서 BlinnPhong 함수에서는 거칠기와 재질값(fresnelR0)에 의해서 반사광의 양이 결정된다고 볼 수 있다. 때문에 마지막에 분산 반사율에 반영 반사율을 더하고 이전 조명 타입에 따라 계산해준 빛의 세기를 곱해주면 계산이 끝난다.
shared_ptr<Material> material = make_shared<Material>();
material->SetFresnel(Vec3(0.1f, 0.1f, 0.1f));
material->SetRoughness(0.01f);
이제 구 모형에 프레넬 값과 거칠기를 적용해보자. 그렇다면 위 머티리얼은 프레넬 값이 작기 때문에 반사광의 양이 적으면서 거칠기가 매우 매끈하다는 것을 알 수 있다.
{
shared_ptr<GameObject> light = make_shared<GameObject>();
light->GetLight()->SetLightType(LIGHT_TYPE::DIRECTIONAL_LIGHT);
light->GetLight()->SetLightDirection(Vec3(0.f, -1.f, 0.f));
light->GetLight()->SetLightStrenth(Vec3(0.0f, 0.0f, 0.5f));
//...
}
{
shared_ptr<GameObject> light = make_shared<GameObject>();
light->GetLight()->SetLightType(LIGHT_TYPE::POINT_LIGHT);
light->GetLight()->SetLightStrenth(Vec3(0.7f, 0.7f, 0.7f));
light->GetLight()->SetFallOff(1.f, 500.f);
//...
}
{
shared_ptr<GameObject> light = make_shared<GameObject>();
light->GetLight()->SetLightType(LIGHT_TYPE::SPOT_LIGHT);
light->GetLight()->SetLightDirection(Vec3(0.f, -1.f, 0.f));
light->GetLight()->SetLightStrenth(Vec3(1.0f, 0.0f, 1.0f));
light->GetLight()->SetFallOff(1.f, 500.f);
//...
}
다음과 같이 이제 세 종류의 조명을 모두 생성해준다. Directional Light는 Direction을 아래를 향하는, 파란색을 띄게 하도록 하였으며 Point Light는 카메라를 따라다니는 흰색의 밝은 조명과 Spot Light는 아래를 향하는 마젠타 색을 띄는 조명이다. 이제 실행을 해보면 다음과 같이 잘 나온다.
거칠기가 낮아서 구가 매끈한 모습이며 스포트 라이트를 받고 있어 한 쪽이 마젠타 색인 것을 알 수 있다. 그리고 윗 부분은 디렉셔널 조명의 파란색을 받기 때문에 파랗고 카메라가 움직일때 포인트 라이트의 위치가 바뀌기 때문에 점점 어두워지지만 주변광에 해당하는 만큼만 어두워짐을 볼 수 있다.
조명을 두 글에 거쳐 쓰게 되었는데 이렇게 공을 들인 이유는 게임에서 조명만 잘 쓰면 그냥 구와 큐브만 놓더라도 그럴듯 해보이기 때문이다. 확실히 이전 퀄리티보다 높아졌고 코드도 간결해졌다. 이렇게 책과 강의를 동시에 보면서 진행하니 서로의 장점을 따로 빼서 내껄로 만들 수 있기 때문에 굉장히 도움이 많이 되었다. 강의에서는 코드를 커플링을 없애는 쪽에 가깝고 책에서는 다양한 내용에 대한 구체적인 설명을 들을 수 있다. 확실히 퀄리티가 좋아진 조명을 보니 다른 메쉬도 사용하고 싶어졌고 게임을 더욱 발전해나가고 싶었으며 그럴만한 용기가 생겼다.
'DirectX12 > DirectX12 응용' 카테고리의 다른 글
[Frustum Culling] (0) | 2023.10.21 |
---|---|
[CubeMap] (1) | 2023.10.16 |
[Normal Mapping] (0) | 2023.10.12 |
[Light-1] (1) | 2023.10.10 |
[Resources] (1) | 2023.10.10 |
[Camera] (1) | 2023.10.09 |
[SceneManager] (1) | 2023.10.08 |