결과물

Meshy AI를 통해서 꽤 고퀄리티의 오브젝트들을 생성했습니다. 버튼과 레버인데요 ! Meshy AI의 적절한 프롬프트 사용법을 익히는 데까지 조금 고생했지만, Meshy AI의 진가를 알게 되었고 앞으로도 유용하게 활용할 것 같습니다.
추가로, 기존에는 위에서 아래로 내리는 레버 클래스를 구현했었는데요. 화면에 보이시는 것처럼 Y축 상으로 움직였을 때 작동하는 레버도 구현할 필요가 있어서 이참에 레버의 중간 클래스를 구현하여 중복 코드를 최소화 했습니다. 이제 디테일 패널에서 레버의 작동 축만 지정해주면, 추가적인 코드 작성 없이 원하는 레버를 구현할 수 있습니다.
구현 내용
1. ALeverBase.h / .cpp
레버들의 부모 클래스입니다. 지정한 작동 축을 토대로 Tick 기반 동작을 수행합니다. 좌우로 움직이는 레버의 경우 X축 상으로 움직이지만 회전은 Pitch를 기반으로 합니다. 앞뒤로 움직이는 레버의 경우 Y축 상으로 움직이지만 회전은 Roll을 기반으로 합니다. 여기서 중요한 사실이 하나 있습니다. 위 아래로 움직이는 레버의 경우 Z축 상으로 움직이지만 회전은 Yaw를 기반으로 하는 것이 아닌 **Roll**을 기반으로 합니다. 즉, Y축과 Z축의 회전 작동방식이 동일하다는 것을 알고 가야합니다.
ALeverBase.h
더보기
#pragma once
#include "CoreMinimal.h"
#include "Actor/Grabbable/VRGrabbableActor.h"
#include "LeverBase.generated.h"
/** 레버를 당기는 기준 축을 나타내는 열거형입니다. */
UENUM(BlueprintType)
enum class ELeverAxis : uint8
{
X UMETA(DisplayName = "X"),
Y UMETA(DisplayName = "Y"),
Z UMETA(DisplayName = "Z"),
};
UCLASS()
class VIRTUALREALITY_API ALeverBase : public AVRGrabbableActor
{
GENERATED_BODY()
// Lifecycle Section
public:
ALeverBase();
virtual void BeginPlay() override;
virtual void Tick(float DeltaTime) override;
// IGrabbable Interface
public:
virtual void DoGrab(USkeletalMeshComponent* InComponent) override;
virtual void DoRelease(USkeletalMeshComponent* InComponent) override;
// Member Function
protected:
void UpdateLeverAngle(float DeltaTime);
private:
float GetControllerAxisValue(const FVector& Location) const;
FRotator GetRotationForAngle(float Angle) const;
// Component Section
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|컴포넌트")
TObjectPtr<USkeletalMeshComponent> LeverMesh;
// Grab Variable Section
protected:
/** 레버의 시작 각도(Roll)입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
float StartAngle = -30.f;
/** 레버의 끝 각도(Roll)입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
float EndAngle = 60.f;
/** 최대 각도에 도달하기 위해 컨트롤러를 이동해야 하는 거리(cm)입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
float MappingRange = 20.f;
/** 레버를 잡고 있을 때의 보간 속도입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
float ControlInterpSpeed = 8.f;
/** EndAngle 도달 후 잠금 유지 시간(초)입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
float LockDuration = 5.f;
/** 잠금 해제 후 원위치로 복귀하는 보간 속도입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
float ReturnInterpSpeed = 10.f;
/** 레버를 당기는 기준 축입니다. */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수")
ELeverAxis LeverAxis = ELeverAxis::Y;
/** 손을 부착할 소켓 이름입니다. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "변수|소켓")
FName GrabSocketName = FName(TEXT("GrabSocket"));
protected:
float GrabStartControllerAxis = 0.f;
float GrabStartAngle = 0.f;
float CurrentAngle = 0.f;
float LockTimer = 0.f;
uint8 bReachedEndAngle : 1 = false;
uint8 bIsReturning : 1 = false;
};
ALeverBase.cpp
더보기
#include "LeverBase.h"
#include "VirtualReality.h"
#include "Component/VRHapticComponent.h"
#include "Player/VRHand.h"
ALeverBase::ALeverBase()
{
// Grab에 사용할 Mesh를 초기화합니다.
LeverMesh = CreateDefaultSubobject<USkeletalMeshComponent>("LeverMesh");
LeverMesh->SetupAttachment(Mesh);
}
void ALeverBase::BeginPlay()
{
Super::BeginPlay();
const FRotator RelRot = LeverMesh->GetRelativeRotation();
switch (LeverAxis)
{
case ELeverAxis::X: CurrentAngle = RelRot.Pitch; break;
default: CurrentAngle = RelRot.Roll; break; // Y, Z
}
}
void ALeverBase::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, StartAngle, DeltaTime, ReturnInterpSpeed);
LeverMesh->SetRelativeRotation(GetRotationForAngle(CurrentAngle));
if (FMath::IsNearlyEqual(CurrentAngle, StartAngle, 1.0f))
{
bIsReturning = false;
}
return;
}
// ----- Normal State -----
if (bIsHeld)
{
// 레버를 잡고 있는 동안 컨트롤러 위치를 기반으로 각도를 업데이트합니다.
UpdateLeverAngle(DeltaTime);
}
else
{
// 레버를 놓은 후 EndAngle 도달하지 못했다면 StartAngle 복귀합니다.
CurrentAngle = FMath::FInterpTo(CurrentAngle, StartAngle, DeltaTime, ControlInterpSpeed);
LeverMesh->SetRelativeRotation(GetRotationForAngle(CurrentAngle));
}
}
FRotator ALeverBase::GetRotationForAngle(float Angle) const
{
switch (LeverAxis)
{
case ELeverAxis::X: return FRotator(Angle, 0.f, 0.f); // Pitch
default: return FRotator(0.f, 0.f, Angle); // Roll (Y, Z)
}
}
float ALeverBase::GetControllerAxisValue(const FVector& Location) const
{
switch (LeverAxis)
{
case ELeverAxis::X: return Location.X;
case ELeverAxis::Z: return Location.Z;
default: return Location.Y;
}
}
void ALeverBase::DoGrab(USkeletalMeshComponent* InComponent)
{
Super::DoGrab(InComponent);
// Grab 시점의 컨트롤러 위치와 레버 각도를 캐시합니다.
if (CachedHand)
{
GrabStartControllerAxis = GetControllerAxisValue(CachedHand->GetMotionControllerLocation());
}
GrabStartAngle = CurrentAngle;
// 손 컴포넌트를 레버의 GrabSocket에 부착합니다.
InComponent->SetAllBodiesSimulatePhysics(false);
InComponent->AttachToComponent(LeverMesh, FAttachmentTransformRules::SnapToTargetNotIncludingScale, GrabSocketName);
}
void ALeverBase::DoRelease(USkeletalMeshComponent* InComponent)
{
Super::DoRelease(InComponent);
// 손 컴포넌트를 레버에서 분리합니다.
InComponent->DetachFromComponent(FDetachmentTransformRules::KeepWorldTransform);
InComponent->SetAllBodiesSimulatePhysics(true);
bReachedEndAngle = false;
}
void ALeverBase::UpdateLeverAngle(float DeltaTime)
{
if (!CachedHand) return;
// 컨트롤러가 Grab 시점 대비 얼마나 이동했는지 계산합니다.
float DeltaAxis = GetControllerAxisValue(CachedHand->GetMotionControllerLocation()) - GrabStartControllerAxis;
// Delta를 각도로 변환합니다. (당기면 양수 → EndAngle 방향)
float DeltaAngle = (DeltaAxis / MappingRange) * FMath::Abs(EndAngle - StartAngle) * 0.5f;
float TargetAngle = FMath::Clamp(GrabStartAngle + DeltaAngle, FMath::Min(StartAngle, EndAngle), FMath::Max(StartAngle, EndAngle));
// InterpSpeed가 낮을수록 무거운 느낌을 줍니다.
CurrentAngle = FMath::FInterpTo(CurrentAngle, TargetAngle, DeltaTime, ControlInterpSpeed);
LeverMesh->SetRelativeRotation(GetRotationForAngle(CurrentAngle));
// EndAngle에 처음 도달했을 때 잠금을 시작하고 강한 햅틱을 재생합니다.
if (!bReachedEndAngle && FMath::IsNearlyEqual(CurrentAngle, EndAngle, 10.0f))
{
bReachedEndAngle = true;
bIsLocked = true;
LockTimer = 0.f;
CachedHand->GetHapticComponent()->PlayHapticBurst(BurstHapticScale, BurstHapticDuration);
CachedHand->ReleaseObject();
}
}
2. 자식 레버 클래스
간단하게 작동 축만 지정해주면 됩니다. 매우 간단합니다. 생성자에서 작동 축을 지정해줍시다.
AEntityClearLever.cpp (Y축 기반)
#include "EntityClearLever.h"
#include "VirtualReality.h"
#include "Components/BoxComponent.h"
AEntityClearLever::AEntityClearLever()
{
PrimaryActorTick.bCanEverTick = true;
// 메시의 전체적인 스케일을 조정합니다.
Root->SetRelativeScale3D(FVector(0.15f, 0.15f, 0.15f));
// Grab에 사용할 Mesh를 초기화합니다.
LeverMesh->SetRelativeLocation(FVector(0.0f, -10.0f, -24.0f));
LeverMesh->SetRelativeRotation(FRotator(0.0f, 0.0f, -30.0f));
// Grab이 가능한 영역인 BoxCollision을 초기화합니다.
GrabRegion->SetupAttachment(LeverMesh);
GrabRegion->SetRelativeLocation(FVector(0.0f, 0.0f, 180.0f));
GrabRegion->SetRelativeRotation(FRotator(0.0f, 0.0f, 30.0f));
GrabRegion->SetBoxExtent(FVector(32.0f, 32.0f, 32.0f));
// Animation 처리를 위한 Grabbable 타입을 지정합니다.
GrabbableType = EGrabbableType::EntityClearLever;
// 레버의 작동 축을 결정합니다.
LeverAxis = ELeverAxis::Y;
}
ALever.cpp ( Z축 기반 )
#include "Lever.h"
#include "VirtualReality.h"
#include "Components/BoxComponent.h"
ALever::ALever()
{
PrimaryActorTick.bCanEverTick = true;
// Grab이 가능한 영역인 BoxCollision을 초기화합니다.
GrabRegion->SetupAttachment(LeverMesh);
GrabRegion->SetRelativeLocation(FVector(0.0f, -14.0f, 0.0f));
GrabRegion->SetBoxExtent(FVector(16.0f, 16.0f, 8.0f));
// Animation 처리를 위한 Grabbable 타입을 지정합니다.
GrabbableType = EGrabbableType::Lever;
// 레버의 작동 축을 결정합니다.
LeverAxis = ELeverAxis::Z;
}
3. UHapticComponent.h / .cpp
SetHapticsByValue 함수가 아닌 PlayHapticEffect 함수를 사용합니다. SetHapticsByValue는 Loop가 적용되지 않기 때문에 약 3초 동안 진동 발생 후 자동으로 진동이 꺼집니다. 하지만, 연속적으로 진동이 발생하는 기능이 필요하기 때문에 저는 PlayHapticEffect 함수로 변환했습니다.
UHapticComponent.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();
virtual void BeginPlay() override;
// Member Function
public:
/** 어느 손에 햅틱을 재생할지 초기화합니다. VRHand의 BeginPlay에서 호출합니다. */
void Initialize(EControllerHand InHandType);
/** 연속 햅틱을 재생합니다. (매 Tick 호출 가능) */
void PlayHaptic(const float InScale);
/** 지정한 시간 동안 햅틱을 재생한 뒤 자동으로 중지합니다. */
void PlayHapticBurst(const float InScale, const float Duration);
/** 연속 햅틱을 즉시 중지합니다. */
void StopHaptic();
private:
/** 버스트 중인 햅틱을 즉시 중지합니다. */
void StopBurstHaptic();
// Haptic Section
protected:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "변수|햅틱")
TObjectPtr<UHapticFeedbackEffect_Base> HapticEffect;
// Variable Section
private:
EControllerHand HandType = EControllerHand::Right;
FTimerHandle BurstTimerHandle;
uint8 bIsBursting : 1 = false;
UPROPERTY()
APlayerController* CachedPC;
};
UHapticComponent.cpp
더보기
#include "VRHapticComponent.h"
#include "VirtualReality.h"
#include "GameFramework/PlayerController.h"
UVRHapticComponent::UVRHapticComponent()
{
PrimaryComponentTick.bCanEverTick = false;
}
void UVRHapticComponent::BeginPlay()
{
Super::BeginPlay();
CachedPC = GetWorld()->GetFirstPlayerController();
}
void UVRHapticComponent::Initialize(EControllerHand InHandType)
{
HandType = InHandType;
}
void UVRHapticComponent::PlayHaptic(const float InScale)
{
CachedPC->PlayHapticEffect(HapticEffect, HandType, InScale, true);
}
void UVRHapticComponent::PlayHapticBurst(const float InScale, const float Duration)
{
bIsBursting = true;
// 이전 버스트 타이머가 남아있다면 취소합니다.
GetWorld()->GetTimerManager().ClearTimer(BurstTimerHandle);
CachedPC->PlayHapticEffect(HapticEffect, HandType, InScale, true);
// Duration 이후 자동으로 햅틱을 중지합니다.
GetWorld()->GetTimerManager().SetTimer(
BurstTimerHandle, this, &UVRHapticComponent::StopBurstHaptic, Duration, false);
}
void UVRHapticComponent::StopHaptic()
{
if (bIsBursting) return;
CachedPC->StopHapticEffect(HandType);
}
void UVRHapticComponent::StopBurstHaptic()
{
bIsBursting = false;
GetWorld()->GetTimerManager().ClearTimer(BurstTimerHandle);
CachedPC->StopHapticEffect(HandType);
}
마무리
오브젝트의 모델링을 어떻게 해야할지가 눈엣가시였는데 Meshy AI 덕분에 한시름 놓을 수 있게 됐네요 ! 앞으로도 Fab에서 모델링을 구매하는 방법말고 Meshy AI를 적극적으로 활용하는 방법을 써야겠습니다.
'Unreal Engine 프로젝트 > VR 공포게임' 카테고리의 다른 글
| [언리얼엔진] 12. 이벤트 출력 및 스캔 기능 구현 (0) | 2026.04.13 |
|---|---|
| [언리얼엔진] 11. 손전등 기능 구현 (0) | 2026.04.11 |
| [언리얼엔진] 9. Floating Dust 파티클 구현 (0) | 2026.04.09 |
| [언리얼 엔진] 8. 채널 전환 버튼 구현 (0) | 2026.04.09 |
| [언리얼엔진] 7. CCTV 기능 구현 (0) | 2026.04.09 |