[DirectX11] 게임테크랩 자체 엔진 게임잼 - 토끼굴의 비밀
1. 개요
지금까지 매주 DX11와 ImGui를 이용해서 만든 자체엔진을 이용해서 게임잼을 진행했습니다.
자체엔진에 부족한 점이 많아서 개발하는 것이 쉽지 않았지만 결국 완성했습니다.
지금부터 그 과정을 알려드리도록 하겠습니다.
아래는 간단하게 작성한 기획서입니다.
2. 게임 소개
저희가 개발한 게임은 사진촬영과 탈출컨셉의 게임입니다.
토끼들은 인간을 지배하기 위해서 사악한 음모를 꾸미고 있고, 주인공은 토끼로 변장해서 토끼들의 굴에서 증거사진을 찍어서
탈출하는 스토리를 가지고 있습니다.
일러스트는 전부 AI를 통해 제작한 것입니다.
3. 구현 요소
3-1. 기본 화면
기본적으로 ImGui를 통한 UI가 구현되어있습니다.
좌하단의 UI는 카메라 쿨타임을 나타내는 UI이고 하단의 일러스트는 플레이어가 찍어야할 사진들입니다.
3-2. 카메라 흔들림
카메라 Shake효과를 천천히 줘서 카메라를 들었을때 자연스럽게 흔들리는 효과가 구현되어 있습니다.
3-3. DOF
중앙의 십자선을 기준으로 피사체와의 거리를 계산하여 DOF를 적용한 모습입니다.
3-4. 촬영
Post Processing으로 카메라의 셔터가 열렸다가 닫히는 효과를 구현하였습니다.
또한 거리와 각도를 기반으로 사진이 찍힌 물체를 구분하는 것도 가능합니다.
이를 바탕으로 플레이어가 찍어야할 피사체를 지정하는 것이 가능합니다.
아래는 CameraEffect RenderPass의 쉐이더로 넘어가는 Constant Buffer의 구조입니다.
Progress값을 카메라를 관리하는 클래스에서 매 Tick계산해주고 그를 Constant Buffer로 넘겨줍니다.
cbuffer ShutterConstants : register(b3) // 기존 b0, b1, b2 다음으로
{
float apertureProgress; // 0.0 (완전히 열림) ~ 1.0 (완전히 닫힘)
float aspectRatio; // 화면 가로/세로 비율 (셔터 모양 보정용)
float2 shutterPadding; // 16바이트 정렬용
};
아래는 셔터효과를 내는 Pixel Shader코드의 일부입니다.
if (apertureProgress > 0.001f) // 셔터가 조금이라도 닫혀있다면
{
float2 shutterUV = UV - float2(0.5f, 0.5f); // 셔터 중심을 화면 중앙으로
shutterUV.x *= aspectRatio; // 셔터 모양을 위한 화면 비율 보정 (cbuffer aspectRatio 사용)
float distFromShutterCenter = length(shutterUV);
float maxShutterRadius = 0.707f; // 셔터가 화면 모서리에 닿는 반지름 (대략)
float currentApertureRadius = maxShutterRadius * (1.0f - apertureProgress);
if (distFromShutterCenter > currentApertureRadius)
{
// 셔터 바깥쪽은 검은색 (또는 원하는 셔터 색상)
// 알파는 1로 하여 완전히 덮도록 함.
FinalColor = float4(0.0f, 0.0f, 0.0f, 1.0f);
}
}
3-5. Zoom In, Zoom out
FOV를 조절하여 피사체를 확대, 축소하는 기능을 추가했습니다.
이 값에 따라서 피사체가 카메라에 감지되는 거리역시 함께 변경 됩니다.
3-6. 이동
캐릭터는 상하좌우 이동하는 것이 가능하고 그에 따라 카메라 모델도 자연스럽게 움직입니다.
3-7. 토끼의 길찾기 (A*)
맵을 생성할때 장애물로 지정한 오브젝트를 인식해서 지형 정보를 담은 데이터를 생성하고, 그를 바탕으로 A* 알고리즘을 통해서
토끼들은 플레이어를 추격하게 됩니다.
4. 맡은 부분 자세한 설명
위의 gif를 보면 피사체를 인식하여서 촬영을 하면 아래 UI에 현재 찍은 사진이 저장되는 방식입니다.
원리에 대해서 간단히 설명해보도록 하겠습니다.
간단히 말하면 Render된 결과가 SRV에 매 프레임 저장되고 있는 상태이고 사진 촬영시 해당 SRV를 깊은 복사를 통해서 가져와서
UI에 띄워주는 방식입니다.
깊은 복사를 해야하는 이유는 사진이 찍히는 타이밍의 SRV만 필요하기 때문입니다.
만일 얕은 복사로 SRV를 가져올 경우 Rendering됨에 따라서 SRV가 계속해서 바뀌게 될것입니다.
따라서 촬영 순간의 화면을 안전하게 저장하기 위해 깊은 복사가 필요합니다.
아래는 사진 촬영 기능의 핵심 함수입니다.
사진을 찍고 적절한 사진인지 CheckSubject에서 검사하게 됩니다. (범위 안에 사물이 들어와있는지 확인)
void RabbitCamera::TakePicture()
{
if (!ValidateTakePicture())
{
return;
}
CanTakePicture = false;
TriggerShutterEffect();
auto HitComp = CheckSubject();
FVector OwnerLocation;
if (GetOwner())
{
OwnerLocation = GetOwner()->GetActorLocation();
}
OnPictureTaken.Execute(this, HitComp, OwnerLocation);
}
아래는 오브젝트를 확인하는 코드입니다.
우선 플레이어가 카메라로 보고 있는 방향 벡터와 플레이어의 위치값을 가져옵니다.
그리고 StaticMeshComponent를 순회하는데 PhotoType이 지정되어 있는 경우에만 아래의 로직을 처리하도록 하였습니다.
가져왔던 값을 바탕으로 해당 오브젝트가 유효한 거리에 있는지 그리고
플레이어가 바라보는 방향 벡터와 피사체 방향 벡터 간 내적을 통해 시야각 내 존재 여부를 판별합니다.
bPrintedOutOfFOV
bPrintedTooFar
이 두 변수는 각각 범위 밖으로 벗어났을때, 너무 멀때의 처리가 세세하게 필요할 때를 대비하여 만들어둔 변수입니다.
최종적으로 가장 가까이 있는 오브젝트의 Component를 반환합니다.
UPrimitiveComponent* RabbitCamera::CheckSubject()
{
auto Player = Cast<ARabbitPawn>(GetOwner());
FRotator Rot = Player->GetPlayerController()->GetControlRotation();
FVector PlayerForward = Rot.ToVector();
FVector PlayerPosition = Player->GetActorLocation();
float FOV = FMath::Cos(FMath::DegreesToRadians(30.f)); // 느슨한 시야각 (총 30도)
UStaticMeshComponent* HitComponent = nullptr;
float MinHitDistance = MaxRange;
bool bPrintedOutOfFOV = false;
bool bPrintedTooFar = false;
for (auto CurrentComponent : TObjectRange<UStaticMeshComponent>()) {
if (!CurrentComponent ||
CurrentComponent->GetPhotoType() <= EPhotoType::NONE ||
CurrentComponent->GetPhotoType() >= EPhotoType::END)
{
continue;
}
FVector ObjectLocation = CurrentComponent->GetOwner()->GetActorLocation();
FVector ToObject = ObjectLocation - PlayerPosition;
float DistanceToObject = ToObject.Length();
if (DistanceToObject > MaxRange)
{
if (!bPrintedTooFar)
{
bPrintedTooFar = true;
}
continue;
}
FVector ToObjectDir = ToObject.GetSafeNormal();
FVector PlayerDir = PlayerForward.GetSafeNormal();
float Dot = FVector::DotProduct(PlayerDir, ToObjectDir);
if (Dot >= FOV) {
if (DistanceToObject < MinHitDistance) {
MinHitDistance = DistanceToObject;
HitComponent = CurrentComponent;
}
}
else
{
if (!bPrintedOutOfFOV)
{
bPrintedOutOfFOV = true;
}
}
}
return HitComponent;
}
그 후에 이벤트를 발생시키게 되고 해당 이벤트를 구독하는 곳에서 적절한 사진인지 판단하여 사진을 저장합니다.
중복체크등의 로직을 구독하는 곳에서 처리합니다.
아래의 코드가 이벤트를 구독하는 곳입니다.
void ARabbitGameMode::JudgeCapturedPhoto(UPrimitiveComponent* CapturedComp, RabbitCamera* RabbitCam)
{
if (UStaticMeshComponent* Comp = Cast<UStaticMeshComponent>(CapturedComp))
{
EPhotoType CapturedType = Comp->GetPhotoType();
// 무효한 타입 무시
if (CapturedType <= EPhotoType::NONE || CapturedType >= EPhotoType::END)
{
return;
}
// 중복 방지
if (!CapturedPhotoTypes.Contains(CapturedType))
{
CapturedPhotoTypes.Add(CapturedType);
RabbitCam->StorePicture(CapturedType);
}
}
//... 생략
}
그렇게 저장하게 될때 현재 RenderTarget을 가져와서 깊은 복사를 한 결과를 return해 줍니다.
FRenderTargetRHI* RabbitCamera::CaptureFrame()
{
auto Source = GEngineLoop.GetLevelEditor()
->GetActiveViewportClient()
->GetViewportResource()
->GetRenderTarget(EResourceType::ERT_DepthOfField_Result);
return CopySource(Source);
}
5. 결론
개발 완료 영상
https://www.youtube.com/watch?v=8lha5jGehBo