게임 개발/학습 일지

언리얼 C++ - 파티클과 사운드로 게임 효과 연출하기

이개 2026. 4. 30. 11:58

파티클 시각 효과 추가하기

언리얼에는 캐스케이드와 나이아가라 두 가지의 파티클 시스템이 있다. 나이아가라가 더 고급스러운 효과를 낼 수 있지만, 우선 캐스케이드로 실습을 진행한다.

 

 

BaseItem.h

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effect")
UParticleSystem* PickupParticle;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effect")
USoundBase* PickupSound;

UParticleSystem과 USoundBase를 추가해준다.

 

BaseItem.cpp

void ABaseItem::ActivateItem(AActor* Activator)
{
	// 파티클과 사운드 재생
	if (PickupParticle)
	{
		UGameplayStatics::SpawnEmitterAtLocation( // 파티클 생성
			GetWorld(), // 파티클을 생성할 월드
			PickupParticle, // 파티클 에셋
			GetActorLocation(), // 파티클 생성 위치
			GetActorRotation(), // 파티클 생성 시 각도
			true // 파티클 효과가 끝나면 메모리에서 자동제거
		);
	}

	if (PickupSound)
	{
		UGameplayStatics::PlaySoundAtLocation( // 사운드 재생
			GetWorld(), // 사운드를 생성할 월드
			PickupSound,	// 사운드 에셋
			GetActorLocation()	// 사운드 재생 위치
		);
	}
}

 

MineItem 블루프린트에서 파티클과 사운드를 지정해준다.
HealingItem 클래스에 파티클을 지정해준다.
BigCoin, SmallCoin 모두 사운드를 지정해준다.

 

지뢰의 오버랩 반경을 넓게 수정해준다.

 

각 아이템의 ActivateItem에서 부모 생성자를 호출해준다.

void AMineItem::ActivateItem(AActor* Activator)
{
	Super::ActivateItem(Activator);
	// 게임 월드 -> 타이머 매니저
	// 5초 뒤를 계산하는 타이머 핸들러
	GetWorld()->GetTimerManager().SetTimer(ExplosionTimeHandle, this, &AMineItem::Explode, 5.0f, false); // 타이머

 

void AHealingItem::ActivateItem(AActor* Activator)
{
	Super::ActivateItem(Activator);
	if (Activator && Activator->ActorHasTag("Player"))
	{
		if (ASpartaCharacter* PlayerCharacter = Cast<ASpartaCharacter>(Activator)) // 캐릭터 타입 확인
		{
			PlayerCharacter->AddHealth(HealAmount);
		}
	
		// GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT("Player Gained %d HP!"), HealAmount));
		DestroyItem();
	}
}

 

void ACoinItem::ActivateItem(AActor* Activator)
{
	Super::ActivateItem(Activator);
	// 액터가 존재하며 플레이어인지 확인
	if (Activator && Activator->ActorHasTag("Player"))
	{
		if (UWorld* World = GetWorld())
		{
			if (ASpartaGameState* GameState = World->GetGameState<ASpartaGameState>())
			{
				GameState->AddScore(PointValue);
				GameState->OnCoinCollected();
			}
		}
		// GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT("Player Gained %d points!"), PointValue));
		DestroyItem();
	}
}

아이템이 사라져도 파티클 효과가 화면에 남아있다.

 

 

폭발 사운드와 파티클 추가하기

MineItem.h에 파티클과 사운드 변수를 추가한다.

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "BaseItem.h"
#include "MineItem.generated.h"

/**
 * 
 */
UCLASS()
class SPARTAPROJECT_API AMineItem : public ABaseItem
{
	GENERATED_BODY()

public:
	AMineItem();
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|Component")
	USphereComponent* ExplosionCollision;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
	float ExplosionDelay; // 폭발 지연시간
	// 콜리전 컴포넌트를 이용할 것이므로 삭제 
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
	float ExplosionRadius; // 폭발 반경
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
	int32 ExplosionDamage; // 폭발 대미지
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effect")
	UParticleSystem* ExplosionParticle; // 파티클 변수
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effect")
	USoundBase* ExplosionSound; // 사운드 변수


	// 폭발 시간을 조정하는 타이머 핸들
	FTimerHandle ExplosionTimeHandle;

	virtual void ActivateItem(AActor* Activator) override;

	void Explode(); // 폭발하는 함수
};

 

#include "MineItem.h"
#include "Kismet/GameplayStatics.h"
#include "Components/SphereComponent.h"
#include "Particles/ParticleSystemComponent.h"

void AMineItem::Explode()
{
	UParticleSystemComponent* Particle = nullptr; // 초기화 안 해주면 에러남

	// 폭발 파티클 생성
	if (ExplosionParticle)
	{
		Particle = UGameplayStatics::SpawnEmitterAtLocation( // 월드에 파티클 스폰
			GetWorld(),
			ExplosionParticle,
			GetActorLocation(),
			GetActorRotation(),
			false // true면 파티클의 라이프 사이클 이후 자동으로 사라지지만, Loop 파티클이나 3초 뒤에 사라지는 파티클같은 경우는 조정할 수 없다.
		);
	}

	// 폭발 사운드 생성
	if (ExplosionSound)
	{
		UGameplayStatics::PlaySoundAtLocation(
			GetWorld(),
			ExplosionSound,
			GetActorLocation()
		);
	}

	TArray<AActor*> OverlappingActors;
	ExplosionCollision->GetOverlappingActors(OverlappingActors);

	for (AActor* Actor : OverlappingActors)
	{
		if (Actor && Actor->ActorHasTag("Player")) // 액터인지와 플레이어 태그 확인
		{
			// 데미지 구현
			UGameplayStatics::ApplyDamage(Actor, ExplosionDamage, nullptr, this, UDamageType::StaticClass());
			// GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT("Player damaged %d by MineItem"), ExplosionDamage));
		}
	}

	DestroyItem();

	if (Particle)
	{
		FTimerHandle DestoryParticleTimerHandle;
		GetWorld()->GetTimerManager().SetTimer(
			DestoryParticleTimerHandle,
			[Particle]() {Particle->DestroyComponent();},
			2.0f,
			false
		);
	}
}

 

BaseItem.cpp

void ABaseItem::ActivateItem(AActor* Activator)
{
	UParticleSystemComponent* Particle = nullptr;

	// 파티클과 사운드 재생
	if (PickupParticle)
	{
		Particle = UGameplayStatics::SpawnEmitterAtLocation( // 파티클 생성
			GetWorld(), // 파티클을 생성할 월드
			PickupParticle, // 파티클 에셋
			GetActorLocation(), // 파티클 생성 위치
			GetActorRotation(), // 파티클 생성 시 각도
			true // 파티클 효과가 끝나면 메모리에서 자동제거
		);
	}

	if (PickupSound)
	{
		UGameplayStatics::PlaySoundAtLocation( // 사운드 재생
			GetWorld(), // 사운드를 생성할 월드
			PickupSound,	// 사운드 에셋
			GetActorLocation()	// 사운드 재생 위치
		);
	}

	// 파티클은 2초 뒤에 사라진다.
	if (Particle)
	{
		FTimerHandle DestoryParticleTimerHandle;
		GetWorld()->GetTimerManager().SetTimer(
			DestoryParticleTimerHandle,
			[Particle]() {Particle->DestroyComponent(); },
			2.0f,
			false
		);
	}
}

 

폭발 효과와 소리를 지정해준다.

 

지뢰의 상태를 구분하기 위해 MineItem.h에 bHasExploded라는 bool 변수를 추가한다.

bool bHasExploded;

 

cpp에 분기를 넣어준다.

void AMineItem::ActivateItem(AActor* Activator)
{
	if (bHasExploded) return;

	Super::ActivateItem(Activator);
	// 게임 월드 -> 타이머 매니저
	// 5초 뒤를 계산하는 타이머 핸들러
	GetWorld()->GetTimerManager().SetTimer(ExplosionTimeHandle, this, &AMineItem::Explode, 5.0f, false); // 타이머


	// Explode로 이동
	//TArray<AActor*> OverlappingActors;
	//ExplosionCollision->GetOverlappingActors(OverlappingActors);

	//for (AActor* Actor : OverlappingActors)
	//{
	//	if (Actor && Actor->ActorHasTag("Player")) // 액터인지와 플레이어 태그 확인
	//	{
	//		// 데미지 구현
	//		GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT("Player damaged %f by MineItem"), ExplosionDamage));
	//	}
	//}

	//DestroyItem();

	bHasExploded = true;
}

 

 

 

USoundBase

USoundeBase는 언리얼 엔진의 모든 사운드 에셋이 공유하는 기본 클래스다. SoundWave와 SoundCue 모두 SoundBase를 상속받는다.

 

SoundWave는 간단한 SFX를 재생하는데 사용한다. SoundeCue는 동적 사운드 생성으로 거리에 따른 효과 등 좀더 복잡한 후처리를 해줄 수 있다.

 

 

트러블 슈팅

1. 마지막까지 코드를 작성하고 나니 레벨이 바뀔 때 파티클이 남아있으면 크래시가 나면서 언리얼 엔진이 멈췄다.

 

“레벨 전환 + 파티클 + 타이머” 충돌이 일어난 것이다.

 

if (Particle)
{

    FTimerHandle DestoryParticleTimerHandle;
    GetWorld()->GetTimerManager().SetTimer(
        DestoryParticleTimerHandle,
        [Particle]() {Particle->DestroyComponent(); },
        2.0f,
        false
    );
}

 

제미나이의 설명

언리얼 엔진에서 흔히 발생하는 '댕글링 포인터(Dangling Pointer)' 혹은 '유효하지 않은 월드 참조' 문제입니다. 레벨 전환이라는 특수한 상황과 람다(Lambda) 캡처의 특성이 맞물려 발생하는 이 현상을 구조적으로 분석해 보겠습니다.


1. 문제의 근본 원인: 지연 실행과 생존 주기(Lifecycle)의 불일치

이 에러의 핵심은 Particle 컴포넌트의 수명보다 람다 함수의 실행 시점이 더 길게 설정되어 있다는 점입니다.

작동 원리 단계별 분석

  1. 람다 캡처 ([Particle]): SetTimer가 호출될 때, Particle 포인터 변수의 주소값이 람다 내부로 복사됩니다.
  2. 레벨 전환 (Level Transition): 2초가 지나기 전 레벨이 바뀌면, 언리얼 엔진은 현재 월드의 모든 액터와 컴포넌트를 소멸(Destroy)시킵니다. 이때 Particle 객체도 메모리에서 해제되거나 무효화됩니다.
  3. 타이머 실행: 2초 뒤, 타이머 시스템은 등록된 람다를 실행하려 합니다.
  4. 유효하지 않은 참조: 람다 내부의 Particle은 이미 파괴된 객체의 메모리 주소를 가리키고 있습니다. 이 상태에서 ->DestroyComponent()를 호출하면 Access Violation(부조적 메모리 접근) 에러가 발생합니다.

2. 구조적 결함 및 간극 식별 (Reframing)

질문하신 코드에는 세 가지 잠재적 위험 요소가 숨어 있습니다.

  • 가정의 오류: "2초 뒤에도 월드와 컴포넌트가 온전할 것"이라는 가정하에 코드가 작성되었습니다.
  • 캡처 방식의 한계: 일반 포인터로 캡처하면 해당 객체가 가비지 컬렉션(GC)에 의해 사라졌는지 알 방법이 없습니다.
  • 월드 소멸 처리 미비: GetWorld()를 통해 타이머를 걸었지만, 월드 자체가 사라질 때 해당 타이머 핸들이 안전하게 정리되지 않을 수 있습니다.

3. 해결을 위한 복수 경로 및 리스크 관리

방법 A: 약포인터(TWeakObjectPtr) 사용 (권장)

객체가 살아있는지 확인하고 실행하는 가장 안전한 방법입니다.

if (Particle)
{
    // 약포인터로 변환하여 캡처
    TWeakObjectPtr<UPrimitiveComponent> ParticlePtr(Particle);

    FTimerHandle DestroyParticleTimerHandle;
    GetWorld()->GetTimerManager().SetTimer(
        DestroyParticleTimerHandle,
        [ParticlePtr]() {
            // 실행 시점에 객체가 여전히 유효한지 체크
            if (ParticlePtr.IsValid())
            {
                ParticlePtr->DestroyComponent();
            }
        },
        2.0f,
        false
    );
}

1. TWeakObjectPtr가 해결사인 이유: "안부 확인"

가장 큰 차이는 객체를 붙잡고 있느냐, 아니면 지켜보고 있느냐의 차이입니다.

  • 일반 포인터 ([Particle]): 포인터는 단순히 메모리 주소(예: 0x123)를 복사합니다. 레벨이 넘어가서 해당 주소의 실제 데이터가 삭제되어도, 포인터는 여전히 0x123을 가리킵니다. 이를 댕글링 포인터라고 하며, 여기에 ->Destroy()를 시도하면 크래시가 납니다.
  • 약포인터 ([WeakParticle]): 언리얼의 가비지 컬렉터(GC)와 통신하는 특수 스마트 포인터입니다. 객체가 메모리에서 해제되면 내부적으로 자동으로 nullptr 처리가 됩니다.
    • if (WeakParticle.IsValid()) 단계에서 "너 아직 살아있니?"라고 엔진에 물어보는 과정이 추가된 것입니다. 레벨 전환으로 이미 파괴되었다면 false가 되어 함수를 그냥 종료하므로 에러가 나지 않습니다.

2. IsValid()의 핵심 역할: "메모리 주소 vs 객체의 수명"

단순히 포인터가 nullptr인지 확인하는 것과 IsValid()를 사용하는 것은 큰 차이가 있습니다.

  • 일반 if (Pointer): "이 변수에 메모리 주소값이 들어 있는가?"만 확인합니다.
  • IsValid(): "이 메모리 주소에 있는 객체가 실제로 유효(Active)하며, 삭제 대기 중(Pending Kill)이 아닌가?"를 확인합니다.

작동 단계 (내부 로직)

  1. 포인터 체크: 변수가 비어있는지(nullptr) 먼저 봅니다.
  2. 플래그 체크: 객체가 존재하더라도, 언리얼 엔진이 Destroy()를 호출하여 "이제 곧 삭제할 것"이라고 표시해둔 상태(RF_BeginDestroyed 등)인지 확인합니다.
  3. 결과 반환: 주소도 있고 삭제 대기 상태도 아닐 때만 true를 반환합니다.

만약 UObject가 아닌 일반 C++ 클래스를 다룬다면 IsValid()를 쓸 수 없고 nullptr 체크만 가능합니다. 하지만 언리얼의 모든 컴포넌트(UActorComponent)와 액터(AActor)는 UObject를 상속받으므로, 반드시 IsValid()나 TWeakObjectPtr를 사용하는 습관을 들이는 것이 좋습니다

 

 

결론: 비동기 실행(Timer, Delegate) 시점에는 반드시 대상의 생존을 재확인해야 한다.

 

 

 

 

2. 메뉴에서 Start Game을 누르지 않아도 LevelDuration이 지나면 게임이 자동으로 시작되는 현상

SpartaGameState.cpp에서 StartLevel() 호출을 없애준다.

void ASpartaGameState::BeginPlay()
{	
	Super::BeginPlay();
	// UpdateHUD();
	// StartLevel(); // 레벨 시작
	GetWorldTimerManager().SetTimer(
		HUDUpdateTimerHandle,
		this,
		&ASpartaGameState::UpdateHUD,
		0.1f,
		true
	);
}

 

 

그리고 플레이어 컨트롤러의 BeginPlay를 아래와 같이 수정해준다.

void ASpartaPlayerController::BeginPlay()
{
	Super::BeginPlay();

	// 1. 입력 매핑 설정 (이 부분은 유지)
	if (ULocalPlayer* LocalPlayer = GetLocalPlayer())
	{
		if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
			LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>())
		{
			if (InputMappingContext)
			{
				Subsystem->AddMappingContext(InputMappingContext, 0);
			}
		}
	}

	// 2. 일시정지 해제 (이전 레벨의 영향 제거)
	SetPause(false);

	// 3. 맵 이름에 따른 분기 (중복 제거)
	FString CurrentMapName = GetWorld()->GetMapName();

	if (CurrentMapName.Contains("MenuLevel"))
	{
		// 메뉴 맵일 때
		ShowMainMenu(false);
	}
	else
	{
		// 게임 플레이 맵일 때 (BasicLevel, Level2 등 모두 포함)

		// 마우스 커서 숨기고 게임 입력 모드로 변경
		bShowMouseCursor = false;
		SetInputMode(FInputModeGameOnly());

		// GameState를 가져와서 게임 시작!
		ASpartaGameState* SpartaGameState = GetWorld() ? GetWorld()->GetGameState<ASpartaGameState>() : nullptr;
		if (SpartaGameState)
		{
			// 여기서 HUD 생성(ShowGameHUD)과 타이머가 동시에 시작.
			SpartaGameState->StartLevel();
		}
	}
}