코승호딩의 메모장

[Light-1] 본문

DirectX12/DirectX12 응용

[Light-1]

코승호딩 2023. 10. 10. 23:08

이번 글에서는 3D 세상을 더 사실적으로 표현할 수 있는 빛을 추가한다. 또한 이번 구현에서 중요한 점은 빛 정보를 쉐이더로 넘겨줄 때, 오브젝트 당 계속 데이터를 복사해 주는 것이 아니라 씬의 렌더 함수에서 프레임 당 한 번씩만 복사를 해주는 것이다. 일단은 프레임 당 상수 버퍼에 빛의 정보만 넣어줄 것이지만 더 나아가 뷰, 프로젝션 변환도 프레임 당 한 번씩만 업데이트를 할 것이다. 또한 프레임 당 상수 버퍼는 테이블이 아닌 루트 서술자를 이용하여 보내기로 한다.


Light Info

우선 빛을 위해 어떠한 정보가 필요한지 살펴보자. 

 

enum class LIGHT_TYPE : uint8
{
	DIRECTIONAL_LIGHT,
	POINT_LIGHT,
	SPOT_LIGHT,
};

빛은 우선 Directional, Point, Spot 세 가지로 구분될 수 있다.

 

struct LightColor
{
	Vec4	diffuse;
	Vec4	ambient;
	Vec4	specular;
};

그리고 빛의 색을 정하는 난반사, 환경광, 정반사가 존재한다. 사실 정반사의 경우 프레넬 효과에 따라 머티리얼의 물리적 속성에 따라 변경되어야 하는데 일단은 간단하게 구현하기 위해서 빛에 모든 정보를 넣는다. 아직 머티리얼은 사용하지 않는다.

 

struct LightInfo
{
	LightColor	color;
	Vec4		position;
	Vec4		direction;
	int32		lightType;
	float		range;
	float		angle;
	int32		padding;
};

이제 빛 하나에 들어있는 모든 정보를 정의할 수 있다. 추가적으로 위치, 방향, 타입, 범위, 각도 등이 있다. 패딩의 경우는 다들 알다시피 상수 버퍼를 넘길 때, 16바이트 경계를 넘으면 안 되기 때문에 넣어준 것이다.

 

struct PassConstants
{
	uint32		lightCount;
	Vec3		padding;
	LightInfo	lights[50];
};

최대 50개의 빛을 넣을 수 있도록 프레임 당 상수 버퍼 구조체에 추가한다. 이 후에 행렬 관련 정보가 더 추가될 것이다.


Light 클래스

class Light : public Component
{
public:
	virtual void FinalUpdate() override;

public:
	//... Set 관련 함수들
    
private:
	LightInfo _lightInfo = {};
};

게임 오브젝트에 빛이라는 컴포넌트를 붙일 것이기 때문에 컴포넌트를 상속받고 내부적으로 빛 관련 정보를 들고 있도록 한다.

 

void Light::FinalUpdate()
{
	_lightInfo.position = GetTransform()->GetWorldPosition();
}

빛의 업데이트 부분에서는 빛의 위치 정보만 넣어주도록 한다. 만약 추후에 손전등이나 움직이는 빛과 같이 구현하기 위해서는 빛의 방향 및 색상도 함께 업데이트를 해줘야 할 것이다.


class FrameResource
{
	//...
public:
	sptr<ConstantBuffer> PassCB;
	sptr<ConstantBuffer> ObjectCB;
	sptr<ConstantBuffer> MaterialCB;
};

FrameResource::FrameResource(ComPtr<ID3D12Device> device)
{
	PassCB = std::make_shared<ConstantBuffer>();
	PassCB->Init(CBV_REGISTER::b0, sizeof(PassConstants), 1);
	//...
}

이제 오브젝트, 머티리얼 당 상수 버퍼 뿐만 아니라 패스 당 상수 버퍼도 추가해 준다.

 

이제부터 상수 버퍼에 빛의 정보를 복사하고 이 상수 버퍼 서술자를 테이블과 별개로 사용할 수 있도록 루트 시그니처와 서술자 테이블 그리고 레지스터 등을 살짝 조절할 필요가 있다.

void RootSignature::Init()
{
	CD3DX12_DESCRIPTOR_RANGE ranges[] =
	{
		CD3DX12_DESCRIPTOR_RANGE(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, CBV_REGISTER_COUNT - 1, 1), // b1~b4
		CD3DX12_DESCRIPTOR_RANGE(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, SRV_REGISTER_COUNT, 0), // t0~t4
	};

	CD3DX12_ROOT_PARAMETER rootParameter[2];
	rootParameter[0].InitAsConstantBufferView(static_cast<uint32>(CBV_REGISTER::b0)); // b0
	rootParameter[1].InitAsDescriptorTable(_countof(ranges), ranges); // b1~b4, t0~t4
	//...
	CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(2, rootParameter,
		(UINT)STATIC_SAMPLER_COUNT, staticSamplers.data(),
		D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);
}

빛의 정보를 프레임당 상수 버퍼로 넘길 것이며 b0 레지스터를 사용할 것이다. 따라서 서술자 테이블은 b1~b4, t0~t4 레지스터를 사용할 것이고 프레임당 상수 버퍼는 b0 레지스터를 사용할 것이기 때문에 서술자 테이블의 범위 부분을 수정한다. CBV 레지스터 하나를 줄이고 레지스터 공간 1부터 시작하도록 한다. 그리고 루트 파라미터가 이제 두 개니깐 첫 번째 루트 파라미터에 b0 레지스터를 사용할 수 있도록 설정한다. 참고로 마지막 루트 시그니처 디스크립션을 생성할 때 첫 번째 인자로 루트 파라미터의 개수를 수정해서 넣어줘야 한다. (나는 이 인자를 수정하지 않아서 삽질했다...)

 

void TableDescriptorHeap::Init(uint32 count)
{
	//...
	desc.NumDescriptors = count * (REGISTER_COUNT - 1); 
	//...    
	_groupSize = _handleSize * (REGISTER_COUNT - 1);
}

D3D12_CPU_DESCRIPTOR_HANDLE TableDescriptorHeap::GetCPUHandle(uint8 reg)
{
	//...
	handle.ptr += (reg - 1) * _handleSize;
	return handle;
}

그리고 서술자 테이블도 b0 레지스터를 사용하지 않을 것이기 때문에 레지스터 개수를 하나씩 빼준다. GetCPUHandle 부분에서는 레지스터 1번을 서술자 테이블이 가지고 있는 서술자 힙을 0번부터 사용할 것이기 때문에 다음과 같이 지정한다.

 

void SceneManager::Render()
{
	if (_activeScene)
		_activeScene->Render();
}

씬 매니저의 렌더 함수를 살짝 수정하여 씬 내부에서 렌더를 할 수 있도록 변경한다.

 

void Scene::Render()
{
	PushPassData();
	_mainCamera->GetCamera()->Render();
}

이제 렌더 함수를 씬으로 변경하였기 때문에 씬이 가지고 있는 메인 카메라를 렌더 하기 전 패스 당 상수 버퍼를 업데이트한다.

 

void Scene::PushPassData()
{
	PassConstants passConstants = {};
	
	for (auto& gameObject : _gameObjects)
	{
		if (gameObject->GetLight() == nullptr)
			continue;

		const LightInfo& lightInfo = gameObject->GetLight()->GetLightInfo();

		passConstants.lights[passConstants.lightCount] = lightInfo;
		passConstants.lightCount++;
	}

	CB(CONSTANT_BUFFER_TYPE::PASS)->PushPassData(&passConstants, sizeof(passConstants));
}

패스 당 상수 버퍼를 업데이트하는 부분이다. 게임 오브젝트에서 빛 정보를 찾아오고 빛 정보를 찾았다면 임시 패스 당 상수버퍼 구조체 객체에 저장하여 이를 업데이트해준다. 이후에 다른 정보가 더 추가될 예정이다.

 

struct ObjectConstants
{
	Matrix matWorld;
	Matrix matView;
	Matrix matProjection;
	Matrix matWV;
	Matrix matWVP;
};

참고로 일단 오브젝트 당 상수 버퍼에 뷰, 프로젝션 행렬 등을 넣어준다. 나중에 패스 당 상수 버퍼로 옮길 예정이다. 이렇게 다양한 행렬을 넘기는 이유는 쉐이더 코드에서 WVP 행렬뿐만 아니라 다른 행렬도 자주 사용되기 때문이다.

 

void ConstantBuffer::PushPassData(void* buffer, uint32 size)
{
	assert(_elementSize == ((size + 255) & ~255));
	::memcpy(&_mappedBuffer[0], buffer, size);
	CMD_LIST->SetGraphicsRootConstantBufferView(0, GetGpuVirtualAddress(0));
}

그리고 상수 버퍼에서는 테이블이 아닌 루트 서술자에 데이터를 복사해 준다. 이제 사용할 준비가 완료되었다.


Light HLSL

struct LightColor
{
    float4      diffuse;
    float4      ambient;
    float4      specular;
};

struct LightInfo
{
    LightColor  color;
    float4	    position;
    float4	    direction; 
    int		    lightType;
    float	    range;
    float	    angle;
    int  	    padding;
};

struct PassConstants
{
    int		    lightCount;
    float3	    padding;
    LightInfo	    lights[50];
};

struct ObjectConstants
{
    row_major matrix gMatWorld;
    row_major matrix gMatView;
    row_major matrix gMatProjection;
    row_major matrix gMatWV;
    row_major matrix gMatWVP;
};

우선 상수 버퍼 내용이 바뀌었으므로 다음과 같이 업데이트하였다.

 

struct VS_IN
{
    float3 pos : POSITION;
    float2 uv : TEXCOORD;
    float3 normal : NORMAL;
};

struct VS_OUT
{
    float4 pos : SV_Position;
    float2 uv : TEXCOORD;
    float3 viewPos : POSITION;
    float3 viewNormal : NORMAL;
};

입력 정점과 출력 정점은 다음과 같다. 출력 정점에서 viewPos와 viewNormal은 빛 계산에서 필요하므로 추가해 준다.

 

VS_OUT VS_Main(VS_IN input)
{
    VS_OUT output = (VS_OUT)0;

    output.pos = mul(float4(input.pos, 1.f), gObjConstants.gMatWVP);
    output.uv = input.uv;

    output.viewPos = mul(float4(input.pos, 1.f), gObjConstants.gMatWV).xyz;
    output.viewNormal = normalize(mul(float4(input.normal, 0.f), gObjConstants.gMatWV).xyz);

    return output;
}

이번 강의 예제에서는 빛 계산을 뷰 스페이스에서 진행한다. 따라서 입력 정점에 gMatWV 즉 월드 변환과 뷰 변환을 통해 뷰 스페이스로 변경한다. 

 

float4 PS_Main(VS_OUT input) : SV_Target
{
    float4 color = gDiffuseMap.Sample(gsamAnisotropicWrap, input.uv);

    LightColor totalColor = (LightColor)0.f;

    for (int i = 0; i < gPassConstants.lightCount; ++i)
    {
         LightColor color = CalculateLightColor(i, input.viewNormal, input.viewPos);
         totalColor.diffuse += color.diffuse;
         totalColor.ambient += color.ambient;
         totalColor.specular += color.specular;
    }

    color.xyz = (totalColor.diffuse.xyz * color.xyz)
        + totalColor.ambient.xyz * color.xyz
        + totalColor.specular.xyz;

     return color;
}

빛을 계산하여 반환해 주는 CalculateLightColor 함수를 새로 생성해 주고 최종 색상에 각각 요소 값들을 더해준다. 그리고 텍스쳐 또는 머티리얼의 색상 값에 빛을 계산한 값을 곱해준다. 단 정반사는 기존 색상을 곱해주지 않는다. 정반사를 통해 출력되는 색상은 빛의 쨍한 부분이기 때문이다. 

 

LightColor CalculateLightColor(int lightIndex, float3 viewNormal, float3 viewPos)
{
    LightColor color = (LightColor)0.f;

    float3 viewLightDir = (float3)0.f;

    float diffuseRatio = 0.f;
    float specularRatio = 0.f;
    float distanceRatio = 1.f;

    // Directional Light
    viewLightDir = normalize(mul(float4(gPassConstants.lights[lightIndex].direction.xyz, 0.f), gObjConstants.gMatView).xyz);
    diffuseRatio = saturate(dot(-viewLightDir, viewNormal));

    float3 reflectionDir = normalize(viewLightDir + 2 * (saturate(dot(-viewLightDir, viewNormal)) * viewNormal));
    float3 eyeDir = normalize(viewPos);
    specularRatio = saturate(dot(-eyeDir, reflectionDir));
    specularRatio = pow(specularRatio, 2);

    color.diffuse = gPassConstants.lights[lightIndex].color.diffuse * diffuseRatio * distanceRatio;
    color.ambient = gPassConstants.lights[lightIndex].color.ambient * distanceRatio;
    color.specular = gPassConstants.lights[lightIndex].color.specular * specularRatio * distanceRatio;

    return color;
}

우선 Directional Light를 계산하는 부분이다. 간단하게 빛의 역방향과 해당 픽셀의 노말 값을 내적 한다. 두 요소 모두 단위 벡터이기 때문에 cos 값이 나오게 되고 각도에 따라 각도가 작아지면 diffuseRatio가 커지고 커지면 작아질 것이다. 환경광은 단지 distanceRatio만 곱해주게 되고 정반사는 specularRatio 즉, 정반사 비율만큼 곱해준다. Directional Light는 diffuseRatio에만 영향이 있다.

 

//...
// Point Light
float3 viewLightPos = mul(float4(gPassConstants.lights[lightIndex].position.xyz, 1.f), gObjConstants.gMatView).xyz;
viewLightDir = normalize(viewPos - viewLightPos);
diffuseRatio = saturate(dot(-viewLightDir, viewNormal));

float dist = distance(viewPos, viewLightPos);
if (gPassConstants.lights[lightIndex].range == 0.f)
    distanceRatio = 0.f;
else
    distanceRatio = saturate(1.f - pow(dist / gPassConstants.lights[lightIndex].range, 2));
///...

 Point Light는 해당 픽셀의 위치와 광원의 위치를 기반으로 계산이 된다. 우선 광원에서 픽셀로 나아가는 단위 벡터를 구한다. 그리고 이 단위 벡터에 마이너스를 해서 픽셀에서 광원으로 뻗어나가는 벡터와 노멀 벡터를 내적 하게 되면 해당 diffuseRatio의 비율을 구할 수 있다. 또한 거리에 따라서 빛의 세기를 조절하기 위해서 거리를 빛의 범위로 나눈다. 이렇게 되면 거리가 빛의 범위보다 작을 경우 분수가 되고 이를 제곱하여 더 작게 만들어주면 distanceRatio는 양수의 값이 될 것이다. 그러나 만약 범위가 더 큰 경우 1을 넘는 값이 되고 결국 distanceRatio는 0이 된다. 따라서 Point Light는 diffuseRatio와 distanceRatio에 영향이 있다. power을 해준 이유는 자연스럽게 만들기 위해서이다.

 

// Spot Light
float3 viewLightPos = mul(float4(gPassConstants.lights[lightIndex].position.xyz, 1.f), gObjConstants.gMatView).xyz;
viewLightDir = normalize(viewPos - viewLightPos);
diffuseRatio = saturate(dot(-viewLightDir, viewNormal));

if (gPassConstants.lights[lightIndex].range == 0.f)
    distanceRatio = 0.f;
else
{
    float halfAngle = gPassConstants.lights[lightIndex].angle / 2;

    float3 viewLightVec = viewPos - viewLightPos;
    float3 viewCenterLightDir = normalize(mul(float4(gPassConstants.lights[lightIndex].direction.xyz, 0.f), gObjConstants.gMatView).xyz);

    float centerDist = dot(viewLightVec, viewCenterLightDir);
    distanceRatio = saturate(1.f - centerDist / gPassConstants.lights[lightIndex].range);

    float lightAngle = acos(dot(normalize(viewLightVec), viewCenterLightDir));

    if (centerDist < 0.f || centerDist > gPassConstants.lights[lightIndex].range)
        distanceRatio = 0.f;
    else if (lightAngle > halfAngle) 
        distanceRatio = 0.f;
    else 
        distanceRatio = saturate(1.f - pow(centerDist / gPassConstants.lights[lightIndex].range, 2));
}

마지막으로 Spot Light는 거리와 각도를 기반으로 계산된다. 우선 diffuseRatio는 앞서 구한 방식과 일정하다. 앞에서 Point Light에서 구한 distanceRatio와 비슷하게 이번에는 한 방향으로만 적용되므로 빛의 센터 디렉션을 구하고 이 벡터와 광원에서 픽셀까지의 벡터와 내적을 하는 것이다. 그리고 acos을 통해 둘의 각도도 구한다. 만약 각도가 설정한 각도 이상이면 그리지 않는다. 그리고 거리가 0보다 작거나 범위를 벗어난다면 이때도 그리지 않는다. 모든 경우를 통과했다면 그린다.


#pragma region Light
{
	shared_ptr<GameObject> light = make_shared<GameObject>();
	light->Init();
	light->AddComponent(make_shared<Transform>());
	light->AddComponent(make_shared<Light>());
	light->GetLight()->SetLightType(LIGHT_TYPE::POINT_LIGHT);
	light->GetLight()->SetDiffuse(Vec3(1.0f, 1.f, 1.f));
	light->GetLight()->SetLightDirection(Vec3(0.0f, -1.f, 0.f));
	light->GetLight()->SetAmbient(Vec3(0.1f, 0.1f, 0.1f));
	light->GetLight()->SetSpecular(Vec3(0.1f, 0.1f, 0.1f));
	light->GetLight()->SetLightRange(1000.f);
	light->GetLight()->SetLightAngle(45.f);
	scene->AddGameObject(light);
    
	sptr<TestLightMoveToCamera> moveLightScript = make_shared<TestLightMoveToCamera>();
	moveLightScript->SetGameObject(camera);
	light->AddComponent(moveLightScript);
}

이제 다음과 같이 빛 객체를 생성하여 컴포넌트를 붙여주면 완성이다. 그러나 그냥 하면 심심하므로 빛이 포인트 라이트일 경우 카메라를 따라다니는 빛 스크립트를 생성하여 붙여주도록 하자.

 

void TestLightMoveToCamera::LateUpdate()
{
	Vec3 targetPos = _target->GetTransform()->GetLocalPosition();
	GetTransform()->SetLocalPosition(targetPos);
}

간단히 자신이 물고 있는 타깃의 위치 정보를 받아와 자신에게 업데이트하면 끝이다.

 


다음은 포인트 라이트를 사용해서 뒤로 갈수록 어두워지는 상황을 보여준다. 그러나 여기서 아쉬운 점은 이번 구현에서는 머티리얼에 대한 내용이 없었다는 것이다. 머티리얼을 추가하여 거칠기, 굴절률, 디퓨즈 알베도 등을 넣는 것이 합당하다. 그래도 이번 구현에서는 조명 처리를 하였다는 점 외로 패스 당 상수 버퍼를 추가하였다는 점이 주목할만한 점이었던 것 같다. 굳이 프레임 당 한번만 계산하면 될 것을 여러번 계산하는 것은 낭비이기 때문이다. 조명 처리 같은 경우는 쉐이더 코드가 인터넷 상에 많기 때문에 이를 넘겨주기 위한 절차가 가장 중요하다고 생각한다. 다음 글에서는 책의 내용을 추가하여 머티리얼의 특성을 입혀 조명을 처리하도록 변경해보자.

'DirectX12 > DirectX12 응용' 카테고리의 다른 글

[CubeMap]  (1) 2023.10.16
[Normal Mapping]  (0) 2023.10.12
[Light-2]  (1) 2023.10.11
[Resources]  (1) 2023.10.10
[Camera]  (1) 2023.10.09
[SceneManager]  (1) 2023.10.08
[Component]  (0) 2023.10.07