[언리얼엔진] 4. VR 레버 구현

2026. 4. 8. 10:42·Unreal Engine 프로젝트/VR 공포게임

결과물

레버와 상호작용 시 손잡이에 손 메시가 부착이 되면서 Motion Controller의 위치에 따라 레버를 내리고 올릴 수 있게 구현했습니다. (컴퓨터 성능 탓에 렉이 걸리네요 ..)

 


 

구현 내용

1. ALever.h / .cpp

Tick 기반으로 동작을 합니다. 그랩 상태 / 리턴 상태 / 락 상태 3가지로 구분을 했는데요. 그랩 상태에서는 Motion Controller의 위치에 따라 레버의 위치가 보간되도록 했습니다. 리턴 상태는 그랩 상태에서 레버를 끝까지 당기지 못 했을 때 전이되는 상태입니다. 리턴 상태에서는 레버가 원래의 위치로 보간되도록 구현했습니다. 락 상태는 레버를 끝까지 당겼을 때 전이되는 상태로써, LockDuration 동안 그랩이 불가하고, LockDuration이 지나면 원래의 위치로 빠르게 보간됩니다. 
추가로 Haptic Feedback을 적용해봤습니다. 그랩 상태에서 레버의 위치에 따라 진동의 빈도, 세기가 점차 세게 전달이 되도록 구현했습니다. 레버를 끝까지 당긴 경우 0.3초간 강한 진동이 전달되면서 레버를 끝까지 내린 느낌을 줍니다.

ALever.h

더보기
#pragma once

#include "CoreMinimal.h"
#include "Actor/Grabbable/VRGrabbableActor.h"
#include "Lever.generated.h"

class AVRHand;

UCLASS()
class VIRTUALREALITY_API ALever : public AVRGrabbableActor
{
	GENERATED_BODY()

	
// Lifecycle Section	
public:
	ALever();
	virtual void BeginPlay() override;
	virtual void Tick(float DeltaTime) override;


// IGrabbable Interface	
public:
	virtual void OnGrab(USkeletalMeshComponent* InComponent, const FVector& GrabLocation) override;
	virtual void OnRelease(USkeletalMeshComponent* InComponent) override;

	
// Member Function	
private:
	void UpdateLeverAngle(float DeltaTime);

	
// Component Section	
protected:
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|컴포넌트")
	TObjectPtr<USkeletalMeshComponent> LeverMesh;
	
	
// Grab Variable Section	
protected:	
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	float MinAngle = -60.f;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	float MaxAngle = 60.f;

	/** 최대 각도에 도달하기 위해 컨트롤러를 이동해야 하는 거리(cm)입니다. */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	float MappingRange = 20.f;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	float ControlInterpSpeed = 8.f;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	float LockDuration = 5.f;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	float ReturnInterpSpeed = 10.f;
	
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|소켓")
	FName GrabSocketName = FName(TEXT("GrabSocket"));

	
// Haptic Feedback Section	
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	float PullHapticFrequency = 0.4f;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	float PullHapticMaxAmplitude = 0.4f;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	float MinAngleHapticFrequency = 1.0f;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	float MinAngleHapticAmplitude = 1.0f;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
	float MinAngleHapticDuration = 0.3f;
	
	
// Cached Section	
private:
	UPROPERTY()
	TObjectPtr<AVRHand> CachedHand;

	float GrabStartControllerZ = 0.f;
	float GrabStartAngle = 0.f;
	float CurrentAngle = 0.f;
	float LockTimer = 0.f;
	
	
// Flag Section	
	uint8 bReachedMinAngle : 1 = false;
	uint8 bIsLocked : 1 = false;
	uint8 bIsReturning : 1 = false;


// Getter, Setter Section	
public:
	FORCEINLINE float GetCurrentAngle() const { return CurrentAngle; }

};

ALever.cpp

더보기
#include "Lever.h"
#include "VirtualReality.h"
#include "Component/VRHapticComponent.h"
#include "Components/BoxComponent.h"
#include "Player/VRHand.h"

ALever::ALever()
{
	PrimaryActorTick.bCanEverTick = true;

	LeverMesh = CreateDefaultSubobject<USkeletalMeshComponent>("LeverMesh");
	LeverMesh->SetupAttachment(Mesh);

	GrabRegion->SetupAttachment(LeverMesh);
	GrabRegion->SetRelativeLocation(FVector(0.0f, -14.0f, 0.0f));
	GrabRegion->SetBoxExtent(FVector(16.0f, 16.0f, 8.0f));

	GrabbableType = EGrabbableType::Lever;
}

void ALever::BeginPlay()
{
	Super::BeginPlay();
	
	CurrentAngle = GetActorRotation().Roll;
}

void ALever::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	// ----- Lock State -----
	if (bIsLocked)
	{
		LockTimer += DeltaTime;
		if (LockTimer >= LockDuration)
		{
			bIsLocked = false;
			bIsReturning = true;
		}
		return;
	}

	
	
	// ----- Returning State -----
	if (bIsReturning)
	{
		CurrentAngle = FMath::FInterpTo(CurrentAngle, MaxAngle, DeltaTime, ReturnInterpSpeed);
		LeverMesh->SetRelativeRotation(FRotator(0.f, 0.f, CurrentAngle));
		if (FMath::IsNearlyEqual(CurrentAngle, MaxAngle, 1.0f))
		{
			bIsReturning = false;
		}
		return;
	}

	
	
	// ----- Normal State -----
	if (bIsHeld)
	{
		// 레버를 잡고 있는 동안 컨트롤러 위치를 기반으로 각도를 업데이트합니다.
		UpdateLeverAngle(DeltaTime);
	}
	else
	{
		// 레버를 놓은 후 MinAngle에 도달하지 못했다면 MaxAngle로 복귀합니다.
		CurrentAngle = FMath::FInterpTo(CurrentAngle, MaxAngle, DeltaTime, ControlInterpSpeed);
		LeverMesh->SetRelativeRotation(FRotator(0.f, 0.f, CurrentAngle));
	}
}

void ALever::OnGrab(USkeletalMeshComponent* InComponent, const FVector& GrabLocation)
{
	Super::OnGrab(InComponent, GrabLocation);

	// Grab 시점의 컨트롤러 위치와 레버 각도를 캐시합니다.
	CachedHand = Cast<AVRHand>(InComponent->GetOwner());
	if (CachedHand)
	{
		GrabStartControllerZ = CachedHand->GetMotionControllerLocation().Z;
	}
	GrabStartAngle = CurrentAngle;
	bIsHeld = true;
	bReachedMinAngle = false;

	// 손 컴포넌트를 레버의 GrabSocket에 부착합니다.
	InComponent->SetAllBodiesSimulatePhysics(false);
	InComponent->AttachToComponent(LeverMesh, FAttachmentTransformRules::SnapToTargetNotIncludingScale, GrabSocketName);
}

void ALever::OnRelease(USkeletalMeshComponent* InComponent)
{
	Super::OnRelease(InComponent);

	bIsHeld = false;

	// 햅틱을 즉시 중지합니다.
	if (CachedHand)
	{
		CachedHand->GetHapticComponent()->StopHaptic();
	}

	CachedHand = nullptr;

	// 손 컴포넌트를 레버에서 분리합니다.
	InComponent->DetachFromComponent(FDetachmentTransformRules::KeepWorldTransform);
	InComponent->SetAllBodiesSimulatePhysics(true);
}

void ALever::UpdateLeverAngle(float DeltaTime)
{
	if (!CachedHand) return;

	// 컨트롤러가 Grab 시점 대비 얼마나 이동했는지 계산합니다.
	float DeltaZ = CachedHand->GetMotionControllerLocation().Z - GrabStartControllerZ;

	// Delta Z를 각도로 변환합니다. (아래로 내리면 음수 → MinAngle 방향)
	float DeltaAngle = (DeltaZ / MappingRange) * (MaxAngle - MinAngle) * 0.5f;
	float TargetAngle = FMath::Clamp(GrabStartAngle + DeltaAngle, MinAngle, MaxAngle);

	// InterpSpeed가 낮을수록 무거운 느낌을 줍니다.
	CurrentAngle = FMath::FInterpTo(CurrentAngle, TargetAngle, DeltaTime, ControlInterpSpeed);
	LeverMesh->SetRelativeRotation(FRotator(0.0f, 0.f, CurrentAngle));

	// 당기는 정도에 비례하여 연속 햅틱을 재생합니다. (0=MaxAngle, 1=MinAngle)
	float PullRatio = FMath::Clamp((CurrentAngle - MaxAngle) / (MinAngle - MaxAngle), 0.f, 1.f);
	CachedHand->GetHapticComponent()->PlayHaptic(PullHapticFrequency, PullRatio * PullHapticMaxAmplitude);

	// MinAngle에 처음 도달했을 때 잠금을 시작하고 강한 햅틱을 재생합니다.
	if (!bReachedMinAngle && FMath::IsNearlyEqual(CurrentAngle, MinAngle, 10.0f))
	{
		bReachedMinAngle = true;
		bIsLocked = true;
		LockTimer = 0.f;
		CachedHand->GetHapticComponent()->PlayHapticBurst(
			MinAngleHapticFrequency, MinAngleHapticAmplitude, MinAngleHapticDuration);
	}
}

 


 

2. UVRHapticComponent.h / .cpp

상호작용에 따라 Haptic Feedback을 전달하면서 몰입도를 높이고자 합니다. 따라서, VRHand는 VRHapticComponent를 소유함으로 진동 기능을 활용합니다.

UVRHapticComponent.h

더보기
#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GenericPlatform/GenericPlatformInputDeviceMapper.h"
#include "VRHapticComponent.generated.h"

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class VIRTUALREALITY_API UVRHapticComponent : public UActorComponent
{
	GENERATED_BODY()

	
// Lifecycle Section	
public:
	UVRHapticComponent();

	
// Member Function	
public:
	/** 어느 손에 햅틱을 재생할지 초기화합니다. VRHand의 BeginPlay에서 호출합니다. */
	void Initialize(EControllerHand InHandType);

	/** 연속 햅틱을 재생합니다. (매 Tick 호출 가능) */
	void PlayHaptic(float Frequency, float Amplitude);

	/** 지정한 시간 동안 햅틱을 재생한 뒤 자동으로 중지합니다. */
	void PlayHapticBurst(float Frequency, float Amplitude, float Duration);

	/** 햅틱을 즉시 중지합니다. */
	void StopHaptic();

	
// Variable Section	
private:
	EControllerHand HandType = EControllerHand::Right;
	FTimerHandle BurstTimerHandle;

};

UVRHapticComponent.cpp

더보기
#include "VRHapticComponent.h"
#include "VirtualReality.h"
#include "GameFramework/PlayerController.h"

UVRHapticComponent::UVRHapticComponent()
{
	PrimaryComponentTick.bCanEverTick = false;
}

void UVRHapticComponent::Initialize(EControllerHand InHandType)
{
	HandType = InHandType;
}

void UVRHapticComponent::PlayHaptic(float Frequency, float Amplitude)
{
	APlayerController* PC = GetWorld()->GetFirstPlayerController();
	if (!PC) return;

	PC->SetHapticsByValue(Frequency, Amplitude, HandType);
}

void UVRHapticComponent::PlayHapticBurst(float Frequency, float Amplitude, float Duration)
{
	APlayerController* PC = GetWorld()->GetFirstPlayerController();
	if (!PC) return;

	// 이전 버스트 타이머가 남아있다면 취소합니다.
	GetWorld()->GetTimerManager().ClearTimer(BurstTimerHandle);

	PC->SetHapticsByValue(Frequency, Amplitude, HandType);

	// Duration 이후 자동으로 햅틱을 중지합니다.
	GetWorld()->GetTimerManager().SetTimer(
		BurstTimerHandle, this, &UVRHapticComponent::StopHaptic, Duration, false);
}

void UVRHapticComponent::StopHaptic()
{
	GetWorld()->GetTimerManager().ClearTimer(BurstTimerHandle);

	APlayerController* PC = GetWorld()->GetFirstPlayerController();
	if (!PC) return;

	PC->SetHapticsByValue(0.f, 0.f, HandType);
}

 


 

마무리

VR 플랫폼이 확실히 몰입도가 높긴 한 것 같아요. 하드웨어의 성능이 좋아져서 PC에서의 성능을 그대로 발휘하는 날이 온다면 점차 게임 산업도 VR 쪽으로 기울지 않을까 하는 생각도 들었네요. 물론 VR 멀미, 하드웨어 비용 등 고려할게 많긴 하지만, VR의 고점이 높은 건 확실한 것 같습니다.

'Unreal Engine 프로젝트 > VR 공포게임' 카테고리의 다른 글

[언리얼엔진] 6. VR Hand 애니메이션 제작  (0) 2026.04.09
[언리얼엔진] 5. VR 최적화  (0) 2026.04.09
[언리얼엔진] 3. VR Hand 물리 효과 구현  (0) 2026.04.04
[언리얼엔진] 2. VR 버튼 구현  (0) 2026.04.04
[언리얼엔진] 1. VR Grab/Release 기능 구현 (C++)  (0) 2026.04.04
'Unreal Engine 프로젝트/VR 공포게임' 카테고리의 다른 글
  • [언리얼엔진] 6. VR Hand 애니메이션 제작
  • [언리얼엔진] 5. VR 최적화
  • [언리얼엔진] 3. VR Hand 물리 효과 구현
  • [언리얼엔진] 2. VR 버튼 구현
Meoyoung's Development Logs
Meoyoung's Development Logs
내가 보려고 만든 블로그
  • Meoyoung's Development Logs
    이게뭐영
    Meoyoung's Development Logs
  • 전체
    오늘
    어제
    • 분류 전체보기 (289)
      • Unreal Engine 프로젝트 (36)
        • 더 퍼스트 버서커 : 카잔 (16)
        • VR 공포게임 (13)
        • Paper-ZD (7)
      • 언리얼 엔진 (72)
        • GAS (10)
        • 트러블슈팅 (27)
        • 캐릭터 (2)
        • VR (1)
        • Lighting (2)
        • 멀티스레드 (2)
        • Lyra (1)
      • C++ (31)
        • 문법 정리 (8)
        • [서적] Fundamental C++ 프로그래밍 .. (5)
        • [서적] 이것이 C++이다 (11)
        • [서적] Effective C++ (7)
      • 게임잼 (3)
      • 강의 (36)
        • [강의] 이득우의 언리얼 프로그래밍 Part1 (13)
        • [강의] 이득우의 언리얼 프로그래밍 Part2 (2)
        • [강의] 이득우의 언리얼 프로그래밍 Part3 (12)
        • [강의] 소울라이크 개발 A-Z (4)
        • [강의] Udemy-2D (5)
      • C# (1)
        • [서적] 이것이 C#이다 (1)
      • 코딩테스트 (26)
        • 프로그래머스 (6)
        • 알고리듬 (13)
        • 자료구조 (7)
      • 컴퓨터 과학 (27)
        • 운영체제 (11)
        • 데이터베이스 (0)
        • 디자인패턴 (0)
        • 자료구조 (5)
        • 네트워크 (0)
        • 컴퓨터구조 (11)
      • 면접준비 (0)
        • C++ (0)
        • 운영체제 (0)
        • 자료구조 (0)
      • 기타 (48)
        • [팀프로젝트] The Fourth Descenda.. (5)
        • GetOutOf (15)
        • [개인프로젝트] FPS 구현 맛보기 (5)
        • [서적] 인생 언리얼5 (4)
        • 스파르타코딩클럽 (15)
        • 객체지향프로그래밍 (2)
        • 컴퓨터회로 (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    선택정렬
    쉘정렬
    버블정렬
    경북게임잼
    자료구조
    참가후기
    셸정렬
    게임잼
    삽입정렬
    게임개발
    알고리즘
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Meoyoung's Development Logs
[언리얼엔진] 4. VR 레버 구현
상단으로

티스토리툴바