1. 개요
게임에서 빛은 매우 중요합니다.
빛을 이용하면 다양한 분위기도 표현이 가능하고, 다양한 연출도 가능하게 합니다.
그렇다면 DX에서는 이 Point Light를 어떻게 구현할까요?
우선 개발 완료 영상부터 보시겠습니다!
2. 필요한 값 / 값 전달 과정
우선 영상에 나온 대로 광원에 대한 값들이 필요합니다.
1. Intensity : 빛의 세기
2. Radius : 빛이 영향을 주는 반경
3. Falloff : 거리가 멀어질수록 빛이 약해지는 정도
4. Color : 빛의 색
이러한 값들을 바탕으로 클래스를 하나 만들어 주었습니다. (Point Light의 이름을 fireball로 하였습니다)
클래스는 각 값과 Getter, Setter로 구성되어 있습니다.
#pragma once
#include "PrimitiveComponent.h"
class UFireBallComponent :public USceneComponent
{
DECLARE_CLASS(UFireBallComponent, USceneComponent)
public:
UFireBallComponent();
virtual ~UFireBallComponent() = default;
void InitializeComponent() override;
// Getter 함수들
float GetIntensity() const;
float GetRadius() const;
float GetRadiusFallOff() const;
FVector4 GetColor() const;
// Setter 함수들
void SetIntensity(float NewIntensity);
void SetRadius(float NewRadius);
void SetRadiusFallOff(float NewRadiusFallOff);
void SetColor(const FVector4& NewColor);
private:
float Intensity;
float Radius;
float RadiusFallOff;
FVector4 Color;
};
결국은 이 값들을 쉐이더에 넘겨주어야 쉐이더에서 빛을 계산할 수 있습니다.
위의 struct는 넘어갈 Point Light의 데이터들입니다.
우선 개발의 편의와 constant buffer의 크기 제한을 고려해 Light의 수는 8개로 제한 하였습니다.
또한 constant buffer에 전달하는 것이기에 16 bytes를 맞추기 위하여 padding이 들어가 있습니다.
struct FFireBallData
{
FVector Position; // FireBall 위치
float Radius; // 반경
FVector4 Color; // RGB 색상 + Alpha
float Intensity; // 강도
float RadiusFallOff; // 감쇠 계수
float Padding[2]; // 16바이트 정렬
};
struct FLighting
{
static const int MAX_FIREBALLS = 8; // 최대 지원 FireBall 수
int FireBallCount; // 활성화된 FireBall 개수
float Padding[3]; // 16바이트 정렬
FFireBallData FireBalls[MAX_FIREBALLS]; // FireBall 배열
};
void FRenderer::UpdateLightBuffer(ULevel* CurrentLevel) const
{
if (!LightingBuffer || !CurrentLevel) return;
D3D11_MAPPED_SUBRESOURCE mappedResource;
Graphics->DeviceContext->Map(LightingBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedResource);
if (FLighting* constants = static_cast<FLighting*>(mappedResource.pData))
{
constants->FireBallCount = FireBalls.Num();
for (int i = 0; i < constants->FireBallCount; i++)
{
UFireBallComponent* FireBall = FireBalls[i];
constants->FireBalls[i].Position = FireBall->GetWorldLocation();
constants->FireBalls[i].Radius = FireBall->GetRadius();
constants->FireBalls[i].Intensity = FireBall->GetIntensity();
constants->FireBalls[i].RadiusFallOff = FireBall->GetRadiusFallOff();
constants->FireBalls[i].Color = FireBall->GetColor();
}
// 나머지 슬롯 초기화
for (int i = constants->FireBallCount; i < constants->MAX_FIREBALLS; i++)
{
constants->FireBalls[i].Radius = 0.0f;
}
}
Graphics->DeviceContext->Unmap(LightingBuffer, 0);
}
3. 고민의 과정
사실 처음에는 하나의 Spot Light만 생각하였습니다.
1. Shader에 Spot Light의 값들을 넘겨줍니다.
2. Pixel Shader에서 Spot Light의 Radius와 Position 값을 이용해서 현재 이 픽셀이 light의 영향안에 있는지 확인합니다.
3. 넘어온 값들로 적절히 Lighting 계산을 해줍니다.
이 과정까지는 머릿속에 어느 정도 정리된 상태였습니다.
그러나 문제가 되는 부분은 여러 개의 광원이 있다면 어떻게 처리할까요?
지금까지는 배열이 아닌 값들만 constant buffer에 넘겨주었기에
배열을 넘겨주는 방법이 있는지 고민하였습니다.
아래처럼 하면 struct 배열을 받을 수 있고 shader에서 사용하는 것이 가능합니다.
cbuffer LightingConstants : register(b5)
{
// FireBall 정보
int FireBallCount; // 활성화된 FireBall 개수
float3 Padding0; // 16바이트 정렬용 패딩
struct FireBallInfo
{
float3 Position; // FireBall 위치
float Radius; // 반경
float4 Color; // RGB 색상 + Alpha
float Intensity; // 강도
float RadiusFallOff; // 감쇠 계수
float2 Padding; // 16바이트 정렬용 패딩
} FireBalls[8];
};
그리고 위의 배열의 수만큼 반복문을 돌게 됩니다.
그런데! 여기서 질문!!
만약에 Lighting이 없으면 어떡하죠?
이 부분은 걱정할 필요가 없습니다.
애초에 constant buffer에 넘겨주는 값 중에 현재 level에 소환된 point light 수가 같이 넘어가게 됩니다.
그러므로 그 수가 0이면 point light 연산을 하지 않습니다.
그렇게 배열로 point light의 정보를 넘기고 shader에서 처리 하기로 하였습니다.
아래는 Lighting이 적용될 static mesh의 pixel shader 코드 일부입니다.
PS_OUTPUT mainPS(PS_INPUT input)
{
PS_OUTPUT output;
float3 N = normalize(input.normal);
if (IsLit == 1) // 조명이 적용되는 경우
{
// FireBall 조명 계산 (원본 코드 유지)
for (int i = 0; i < FireBallCount; i++)
{
// 반경이 0인 FireBall은 건너뛰기 (비활성화된 슬롯)
if (FireBalls[i].Radius <= 0.0f)
continue;
// FireBall과의 거리 계산
float3 lightVec = FireBalls[i].Position - input.worldPos;
float distance = length(lightVec);
// 반경 내에 있는 경우에만 조명 적용
if (distance < FireBalls[i].Radius)
{
// 정규화된 거리 (0~1)
float normalizedDist = distance / FireBalls[i].Radius;
// 감쇠 계산
float attenuation = 1.0 - pow(normalizedDist, FireBalls[i].RadiusFallOff);
// 표면 각도 계산
float3 L = normalize(lightVec);
float NdotL = max(0.0f, dot(N, L));
// FireBall 조명 기여도 계산
float3 fireBallLight = FireBalls[i].Color.rgb * attenuation * NdotL * FireBalls[i].Intensity;
// 최종 색상에 FireBall 조명 추가
finalColor += fireBallLight * color;
}
}
// 색상 클램핑 (HDR 효과를 원한다면 이 부분을 조정)
finalColor = saturate(finalColor + ambient);
output.color = float4(finalColor, Material.TransparencyScalar);
return output;
}
diffuse, ambient 같은 값들은 일단 무시하고 point light가 어떻게 계산되는지부터 살펴봅시다.
우선 pixel shader에 대해 이해하고 있어야 합니다.
pixel shader는 각 픽셀에 대해 색을 결정해 주는 shader입니다.
하나하나의 픽셀의 색을 적절히 계산해 주어서 빛을 받는 듯한 효과를 내는 것이 목적입니다.
그렇다면 코드를 분석 해봅시다.
14번째 줄 : Radius가 없을 때 빛 계산을 할 필요가 없으으로 return
17번째 줄 : input.worldPos는 static mesh의 픽셀위치이므로 point light와의 - 연산을 통해서 빛의 방향을 계산합니다.
18번째 줄 : 거리 계산
20번째 줄 : radius 반경안에 있는 물체만 계산
핵심적인 부분은 바로 30번째 줄이다. 바로 내적 개념이 등장하기 때문입니다.
static mesh 표면의 법선 벡터인 N과 빛의 방향벡터인 L을 내적한 값을 빛의 세기를 계산할 때 사용합니다.
그림을 참고해보면 A벡터에서 B벡터를 뺀곳이 바로 17번째 줄입니다.
그리고 내적을 통해서 그 크기를 계산합니다.
내적의 성질에 따라서 각도가 크면 클수록 (빛이 면과 수직에 있을수록) 내적값이 커지고 빛은 강해집니다.
반대로 각도가 작으면 작을수록 빛이 약해집니다.
4. 더 자세히 알아보는 빛 계산
위에서 언급한 내용을 좀 더 자세히 설명해보도록 하겠습니다.
초록색: 육면체 윗면의 법선 벡터
파란색: 계산하려는 pixel의 위치 벡터 input.WorldPos
빨간색: 광원의 위치 벡터 Fireballs[i].Position
우리는 input.WorldPos에서 Point Light로 향하는 방향 벡터를 구해야만 합니다.
그래야 내적을 통해 빛 세기 계산기 가능하기 때문입니다.
Point Light로 향하는 방향벡터는 2번 그림처럼 빨간색- 파란색으로 구할 수 있습니다.
그렇게 되면 내적 계산을 통해서 빛의 세기를 계산할 수 있게 됩니다.
5. Point Light 벡터 방향이 반대면?
잠시 의문을 가졌던 부분이 있습니다.
그 부분은 바로 Point Light Vector의 방향이 현재 내가 계산하는 곳을 향하는 것이 아니라
Point Light 방향을 향하고 있다는 점입니다.
그래서 이렇게 반대로 되면 어떻게 되는지에 대해 고민을 해보았습니다.
결론적으로 이렇게 하게 되면 두 벡터의 각도가 둔각이 나오게 됩니다.
둔각이 나온다는 것은 내적의 값이 음수가 나온다는 것입니다. (그림4 참고)
각이 둔각이면 빛이 정의 되지 않음을 의미 합니다.
간단히 생각해서 광원이 육면체의 윗면보다 밑으로 내려가면 당연하게도 윗면은 빛의 영향을 받지 않아서 어두컴컴 할 것입니다.
결론적으로 광원 벡터를 구할때 방향은 현재 계산하려고하는 픽셀에서 광원을 향하는 방향이어야만 합니다.
6. 꼭 cosθ 써야하나?
마지막 의문입니다.
결론적으로 빛의 세기를 계산할때 내적을 사용하게 되는데
내적의 계산식을 보면 a,b벡터는 각각 normalize 되어서 크기가 1이기에
내적의 크기는 cosθ에 의에 좌우된다.
그렇다면 cosθ가 아니라 선형적이며 위아래로 진동하는 그래프를 이용해서 빛 계산을 해주면 안되는 것인가?
그림 7을 보면 초록색은 cosθ 그래프이고 빨간색은 cosθ 그래프의 미분계수가 0인점을 지나는 선형적인 그래프입니다.
즉 저 빨간색 그래프의 값을 이용해서 빛 계산을 해주면 안될까라는 의문입니다.
결론부터 말하면 매우 부자연스러운 연출이 나올것으로 예상됩니다.
그 이유는 지금부터 설명하겠습니다.
7. cosθ 써야하는 이유
아래에는 지름이 4인 광원이 하나 있습니다.
이 광원이 표면에 대해서 수직한 위치에서 빛을 쏘개 되면
그 표면에 생기게 되는 빛의 지름은 광원의 지름과 동일할것입니다.
그렇다면 30도 각도로 비스듬하게 들어오는 광원에 대한 빛의 길이는 얼마가 될까요?
주황색은 전부 평행하고 광원까지의 방향벡터입니다.
빨간색은 우리가 구해야 하는 길이입니다.
남색은 30도 입니다. 왜냐하면 빛이 30도로 들어온다고 가정했고
주황색끼리 전부 평행하기에 남색은 동위각으로 30도입니다.
하늘색은 60도입니다. 초록색 법선벡터가 지면과 이루는 각도가 90도 이므로
90도 - 30도(남색) = 60도(하늘색)이기 때문입니다.
해당 부분을 삼각형으로 만든다면?
검은색은 주황벡터와 수직입니다.
자주색은 동위각이므로 30도이고
보라색은 수직으로 내렸음으로 90도
초록색은 삼각형 내각의 합으로 180-120하여 60도입니다.
그렇다면 검은색 부분은 광원과 똑같이 길이가 4입니다.
아래의 삼각형은 특수각이므로 빨간색 부분은 길이가 8이 나오게 됩니다.
자 길고 길었습니다.
결론적으로 30도 비스듬하게 광원이 들어오면 빛을 받는 면적이 원래의 2배가 됩니다.
즉 빛의 면적은 1/cosθ와 비례합니다.
그러므로 자연스러운 빛 계산을 위해서는 cosθ를 써야합니다.
위에서 언급했던 선형그래프로는 정확한 빛을 표현할 수 없습니다.
'DirectX11' 카테고리의 다른 글
[DX11] Normal Map (0) | 2025.04.16 |
---|---|
[DX11] Light Culling (0) | 2025.04.12 |
[DX11] Obj 파일 Parsing후 Pipe line Binding (2) | 2025.04.07 |
[DX11] 안개 구현: Render Path 분리 (Full Quad Rendering) (0) | 2025.04.05 |
[DX11] PIE Mode, Shallow/Deep Copy (깊은 복사/ 얕은 복사) (0) | 2025.04.03 |