결과물

보이시나요 .. 부드럽게 송출되는 CCTV의 화면이 .. 추가로 보이실지 모르겠지만, 좌측 상단에 프레임이 표시되는데요. VR말고 PC 상에서는 무려 230 FPS이 나온답니다.
(가운데 가로막고 있는 물체는 Hand 메시입니다. VR 장비를 착용하지 않은 상태라서 제어가 안 되는 상태라 그렇습니다.)
구현 내용
1. ACCTV.h / .cpp
SceneCaptureComponent를 활용하여 특정 장소의 모습을 실시간으로 캡쳐하려고 합니다. 다만, 실시간으로 캡쳐를 진행하는 만큼 렌더링의 비용이 발생하고, 이는 VR 환경에서 치명적으로 작용하기 때문에 SceneCaptureComponent의 최적화도 진행해주었습니다.
총 4개의 SceneCaptureComponent를 사용하여 4개의 장소를 캡쳐하고자 합니다. 플레이어의 카메라 + 4대의 CCTV이므로 렌더링 비용이 어마어마할 겁니다. 하지만, 현재 비추는 장소의 CCTV만 캡쳐를 진행하고, 나머지 CCTV는 캡쳐를 비활성화 하는 방식을 사용하여 최적화를 진행했습니다. 추가로, 따로 설정을 하지 않으면 SceneCaptureComponent가 Tick마다 캡쳐를 진행하기 때문에 타이머 방식을 활용해서 일정 시간마다 캡쳐를 진행하도록 구현했습니다.
ACCTV.h
더보기
#pragma once
#include "CoreMinimal.h"
#include "Components/SceneCaptureComponent2D.h"
#include "GameFramework/Actor.h"
#include "CCTV.generated.h"
class UCameraComponent;
class USceneCaptureComponent2D;
class UTextureRenderTarget2D;
UCLASS()
class VIRTUALREALITY_API ACCTV : public AActor
{
GENERATED_BODY()
public:
ACCTV();
virtual void BeginPlay() override;
// Member Function
public:
/** SceneCaptureComponent의 캡처 활성화 여부를 설정합니다. */
void SetCaptureEnabled(bool bEnabled);
private:
void CaptureScene();
// Component Section
protected:
/** 씬 캡쳐 컴포넌트가 어떤 장면을 캡쳐하는지 볼 수 있게 하는 카메라입니다. 이 카메라는 씬 캡처 컴포넌트와 동일한 위치에 배치되어야 합니다. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수")
TObjectPtr<UCameraComponent> FakeCameraComponent;
/** 씬 캡처 컴포넌트입니다. 이 컴포넌트가 장면을 캡처하여 텍스처로 렌더링합니다. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수")
TObjectPtr<USceneCaptureComponent2D> SceneCaptureComponent;
// Variable Section
protected:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|수치")
float CaptureInterval = 1.0f;
private:
FTimerHandle CaptureTimerHandle;
// Getter, Setter Section
public:
FORCEINLINE UTextureRenderTarget2D* GetRenderTarget() const { return SceneCaptureComponent ? SceneCaptureComponent->TextureTarget : nullptr; };
};
ACCTV.cpp
더보기
#include "CCTV.h"
#include "Camera/CameraComponent.h"
#include "Components/SceneCaptureComponent2D.h"
#include "Engine/TextureRenderTarget2D.h"
#include "VirtualReality.h"
ACCTV::ACCTV()
{
FakeCameraComponent = CreateDefaultSubobject<UCameraComponent>(TEXT("FakeCameraComponent"));
FakeCameraComponent->SetupAttachment(GetRootComponent());
SceneCaptureComponent = CreateDefaultSubobject<USceneCaptureComponent2D>(TEXT("SceneCaptureComponent"));
SceneCaptureComponent->SetupAttachment(FakeCameraComponent);
// 기본적으로 캡처를 비활성화하여 불필요한 렌더링 비용을 절약합니다.
SceneCaptureComponent->bCaptureEveryFrame = false;
SceneCaptureComponent->bCaptureOnMovement = false;
SceneCaptureComponent->ShowFlags.SetMotionBlur(false);
SceneCaptureComponent->ShowFlags.SetEyeAdaptation(false);
SceneCaptureComponent->ShowFlags.SetLocalExposure(false);
SceneCaptureComponent->ShowFlags.SetPostProcessMaterial(false);
SceneCaptureComponent->ShowFlags.SetToneCurve(false);
SceneCaptureComponent->ShowFlags.SetTonemapper(false);
SceneCaptureComponent->ShowFlags.SetBloom(false);
SceneCaptureComponent->ShowFlags.SetAmbientOcclusion(false);
SceneCaptureComponent->ShowFlags.SetDynamicShadows(false);
SceneCaptureComponent->ShowFlags.SetFog(false);
SceneCaptureComponent->ShowFlags.SetLensFlares(false);
SceneCaptureComponent->ShowFlags.SetAntiAliasing(false);
SceneCaptureComponent->ShowFlags.SetDepthOfField(false);
SceneCaptureComponent->LODDistanceFactor = 10.0f;
SceneCaptureComponent->SetActive(false);
}
void ACCTV::BeginPlay()
{
Super::BeginPlay();
CaptureScene();
}
void ACCTV::SetCaptureEnabled(bool bEnabled)
{
if (bEnabled)
{
GetWorldTimerManager().SetTimer(
CaptureTimerHandle,
this,
&ThisClass::CaptureScene,
CaptureInterval,
true,
true
);
}
else
{
GetWorldTimerManager().ClearTimer(CaptureTimerHandle);
}
LOG(TEXT("CCTV 캡처 상태가 [%s]로 변경되었습니다."), bEnabled ? TEXT("활성화") : TEXT("비활성화"));
}
void ACCTV::CaptureScene()
{
// 씬을 수동으로 캡처합니다.
SceneCaptureComponent->CaptureScene();
}
2. AMonitor.h / .cpp
CCTV가 캡쳐하는 장면을 렌더링하는 책임을 가지는 클래스입니다. Material Instance를 통해 화면에 송출하는 기능을 수행합니다.
AMonitor.h
더보기
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Monitor.generated.h"
class ACCTV;
class UPointLightComponent;
class UStaticMeshComponent;
class UMaterialInterface;
class UMaterialInstanceDynamic;
class UInputAction;
UCLASS()
class VIRTUALREALITY_API AMonitor : public AActor
{
GENERATED_BODY()
// Lifecycle Section
public:
AMonitor();
virtual void BeginPlay() override;
// Member Function
public:
UFUNCTION(BlueprintCallable)
void SwitchToCCTV(int32 Index);
private:
void SwitchToNextCCTV();
void SwitchToPrevCCTV();
void CollectCCTVs();
void SetActiveCCTV(int32 Index);
void DeactivateAllCCTVs();
// Component Section
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|컴포넌트")
TObjectPtr<USceneComponent> MonitorRoot;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|컴포넌트")
TObjectPtr<UStaticMeshComponent> MonitorMesh;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|컴포넌트")
TObjectPtr<UStaticMeshComponent> ScreenMesh;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|컴포넌트")
TObjectPtr<UPointLightComponent> ScreenLight;
// Variable Section
protected:
/** 렌더 타겟을 표시할 화면 머티리얼입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
TObjectPtr<UMaterialInterface> ScreenMaterial;
/** 렌더 타겟 텍스처를 연결할 머티리얼 파라미터 이름입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
FName RenderTargetParameterName = TEXT("RenderTarget");
private:
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> ScreenMaterialInstance;
TArray<TObjectPtr<ACCTV>> RegisteredCCTVs;
int32 ActiveCCTVIndex = -1;
// Getter, Setter Section
public:
FORCEINLINE int32 GetActiveCCTVIndex() const { return ActiveCCTVIndex; }\
};
AMonitor.cpp
더보기
#include "Monitor.h"
#include "Components/StaticMeshComponent.h"
#include "Materials/MaterialInstanceDynamic.h"
#include "Engine/TextureRenderTarget2D.h"
#include "Actor/CCTV.h"
#include "EngineUtils.h"
#include "VirtualReality.h"
#include "Components/PointLightComponent.h"
AMonitor::AMonitor()
{
MonitorRoot = CreateDefaultSubobject<USceneComponent>("MonitorRoot");
SetRootComponent(MonitorRoot);
MonitorMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MonitorMesh"));
MonitorMesh->SetupAttachment(MonitorRoot);
MonitorMesh->SetRelativeScale3D(FVector(1.0f, 2.5f, 2.5f));
ScreenMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ScreenMesh"));
ScreenMesh->SetupAttachment(MonitorMesh);
ScreenMesh->SetRelativeLocation(FVector(4.0f, 0.0f, 31.6f));
ScreenMesh->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
ScreenMesh->SetRelativeScale3D(FVector(0.6f, 0.001f, 0.14f));
ScreenLight = CreateDefaultSubobject<UPointLightComponent>(TEXT("ScreenLight"));
ScreenLight->SetupAttachment(ScreenMesh);
ScreenLight->SetIntensityUnits(ELightUnits::Unitless);
ScreenLight->SetVisibility(false);
static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_Monitor(TEXT("/Game/AtmosphericHouse/Meshes/Meshes_props/Electronics/SM_Computer_screen"));
if (SM_Monitor.Succeeded())
{
MonitorMesh->SetStaticMesh(SM_Monitor.Object);
}
}
void AMonitor::BeginPlay()
{
Super::BeginPlay();
// 화면용 다이나믹 머티리얼 인스턴스를 생성합니다.
if (ScreenMaterial)
{
ScreenMaterialInstance = UMaterialInstanceDynamic::Create(ScreenMaterial, this);
ScreenMesh->SetMaterial(0, ScreenMaterialInstance);
}
// 월드 내 모든 CCTV를 수집합니다.
CollectCCTVs();
}
void AMonitor::CollectCCTVs()
{
RegisteredCCTVs.Reset();
for (ACCTV* CCTV : TActorRange<ACCTV>(GetWorld()))
{
RegisteredCCTVs.Emplace(CCTV);
}
LOG(TEXT("CCTV %d개가 수집되었습니다."), RegisteredCCTVs.Num());
}
void AMonitor::SetActiveCCTV(int32 Index)
{
// 이전 활성 CCTV를 비활성화합니다.
if (RegisteredCCTVs.IsValidIndex(ActiveCCTVIndex))
{
RegisteredCCTVs[ActiveCCTVIndex]->SetCaptureEnabled(false);
}
// 새 CCTV를 활성화합니다.
ActiveCCTVIndex = Index;
RegisteredCCTVs[ActiveCCTVIndex]->SetCaptureEnabled(true);
LOG(TEXT("활성 CCTV가 인덱스 %d로 변경되었습니다."), ActiveCCTVIndex);
}
void AMonitor::DeactivateAllCCTVs()
{
for (TObjectPtr<ACCTV>& CCTV : RegisteredCCTVs)
{
if (CCTV)
{
CCTV->SetCaptureEnabled(false);
}
}
ActiveCCTVIndex = -1;
LOG(TEXT("모든 CCTV가 비활성화되었습니다."));
}
void AMonitor::SwitchToCCTV(int32 Index)
{
if (RegisteredCCTVs.IsEmpty()) return;
ScreenLight->SetVisibility(true);
// 인덱스를 CCTV 배열 범위 내에서 순환시킵니다.
const int32 ClampedIndex = (Index % RegisteredCCTVs.Num() + RegisteredCCTVs.Num()) % RegisteredCCTVs.Num();
// 활성 CCTV를 전환합니다.
SetActiveCCTV(ClampedIndex);
// 전환된 CCTV의 렌더 타겟을 화면 머티리얼에 적용합니다.
if (UTextureRenderTarget2D* RenderTarget = RegisteredCCTVs[ClampedIndex]->GetRenderTarget())
{
ScreenMaterialInstance->SetTextureParameterValue(RenderTargetParameterName, RenderTarget);
}
LOG(TEXT("모니터가 CCTV 인덱스 %d로 전환되었습니다."), ClampedIndex);
}
void AMonitor::SwitchToNextCCTV()
{
// 다음 인덱스로 전환합니다. SwitchToCCTV 내부에서 순환 처리됩니다.
SwitchToCCTV(ActiveCCTVIndex + 1);
}
void AMonitor::SwitchToPrevCCTV()
{
// 이전 인덱스로 전환합니다. SwitchToCCTV 내부에서 순환 처리됩니다.
SwitchToCCTV(ActiveCCTVIndex - 1);
}
3. Render Target Texture
SceneCaptureComponent가 캡쳐한 장면을 저장하기 위한 텍스처입니다. Size X, Size Y에 따라 캡쳐한 장면의 해상도가 좌우됩니다.


4. Monitor Material
이 VR 게임의 핵심이라고 할 수 있는 모니터의 머티리얼을 구현했습니다. Render Target Texture 기반 머티리얼로, SceneCaptureComponent가 캡쳐한 장면을 흑백 필터링 및 Lens Distortion 효과가 적용된 상태로 송출하는 기능을 수행합니다.


마무리
CCTV 로직을 직접 구현해보니, 역시 언리얼은 무엇보다 최적화가 중요하다는 사실을 새삼 깨닫게 되었네요. 유니티로 2D 게임 개발할 때에는 프레임 드랍 걱정은 전혀 없었는데, 언리얼은 언제나 프레임 걱정을 하면서 개발을 진행하는 것 같아요 ㅎㅎ 이게 또 언리얼만의 묘미가 아닐까 싶습니다 ! 덕분에 SceneCaptureComponent의 최적화 로직에 대한 스킬을 얻게 되었습니다 ! 나이수 !
'Unreal Engine 프로젝트 > VR 공포게임' 카테고리의 다른 글
| [언리얼엔진] 9. Floating Dust 파티클 구현 (0) | 2026.04.09 |
|---|---|
| [언리얼 엔진] 8. 채널 전환 버튼 구현 (0) | 2026.04.09 |
| [언리얼엔진] 6. VR Hand 애니메이션 제작 (0) | 2026.04.09 |
| [언리얼엔진] 5. VR 최적화 (0) | 2026.04.09 |
| [언리얼엔진] 4. VR 레버 구현 (2) | 2026.04.08 |