[Unreal Engine] Custom LOD

이매·2026년 3월 4일

Unreal Physics

목록 보기
4/5
post-thumbnail

1. Simulation LOD 추가

지난번에 구현한 슬라임을 Unreal Insights를 통해 프로파일링한 결과, 현재 시뮬레이션은 CPU에서 직렬적으로 처리되고 있음을 알 수 있었다.
이 구조에서는 슬라임 개수가 증가할수록 Solver 연산 비용이 선형적으로 증가하며, 그에 비례해 프레임이 지속적으로 하락하는 문제가 발생한다.

즉, 액터 수 증가에 따라 연산량이 그대로 누적되는 구조이며, 병렬화나 가변 품질 제어 없이 모든 슬라임을 동일한 정밀도로 처리하고 있기 때문에 성능 확장이 어렵다는 한계가 있다.

이러한 문제를 해결하기 위해 플레이어의 대략적인 시야 및 카메라와의 거리를 기준으로 시뮬레이션 품질을 조절하는 간단한 LOD(Level of Detail) 시스템을 만들어보자.

Slime.h

UCLASS()
class XPBD_IMPLEMENT_API ASlime : public AActor
{
	GENERATED_BODY()
    
    ...
    
	int32 CalculateLOD() const;
    void RunPBD_LOD(float DeltaTime, int32 LOD);

slime.cpp

#include "DrawDebugHelpers.h"

...

int32 ASlime::CalculateLOD() const
{
	APlayerCameraManager* CamManager = UGameplayStatics::GetPlayerCameraManager(GetWorld(), 0);
	if (!CamManager) return 0;

	FVector CamLoc = CamManager->GetCameraLocation();
	
	// 카메라와의 거리 및 방향 계산
	float DistSq = FVector::DistSquared(CamLoc, GetActorLocation());
	FVector ToActor = (GetActorLocation() - CamLoc).GetSafeNormal();
	float Dot = FVector::DotProduct(CamManager->GetActorForwardVector(), ToActor);

	constexpr float LOD1DistSq = 1000.f * 1000.f;
	constexpr float LOD2DistSq = 4000.f * 4000.f;
	constexpr float LOD3DistSq = 10000.f * 10000.f;
	
	const FVector Forward = CamManager->GetActorForwardVector();

	constexpr float ViewDotThreshold = 0.2f;
	constexpr float DebugLength = 3000.f;

	const float AngleRad = FMath::Acos(ViewDotThreshold);
	
	// 카메라 시야각 & 거리 디버그 드로잉
	DrawDebugCone(
		GetWorld(),
		CamLoc,
		Forward,
		DebugLength,
		AngleRad,     
		AngleRad,  
		24,
		FColor::Green,
		false,
		0.f,
		0,
		2.f
	);
	
	DrawDebugSphere(
	GetWorld(),
	CamLoc,
	1000.f,
	32,
	FColor::Green,
	false,
	0.f,
	0,
	2.f
	);

	DrawDebugSphere(
		GetWorld(),
		CamLoc,
		4000.f,
		32,
		FColor::Yellow,
		false,
		0.f,
		0,
		2.f
	);

	DrawDebugSphere(
		GetWorld(),
		CamLoc,
		10000.f,
		32,
		FColor::Red,
		false,
		0.f,
		0,
		2.f
	);

	int32 LOD;
	
	// 거리 기반 LOD
	if (DistSq < LOD1DistSq)
	{
		LOD = 0;
	}
	else if (DistSq < LOD2DistSq)
	{
		LOD = 1;
	}
	else if (DistSq < LOD3DistSq)
	{
		LOD = 2;
	}
	else
	{
		LOD = 3;
	}
	
	// 뷰 안이면 LOD는 최대 1까지만 허용
	if (Dot > 0.2f)
	{
		LOD = FMath::Min(LOD, 1);
	}
	else
	{
		// 뷰 밖이면 한 단계 강등
		LOD = FMath::Clamp(LOD + 1, 0, 3);
	}
	
	return LOD;
}

void ASlime::RunPBD_LOD(float DeltaTime, int32 LOD)
{
	if (LOD == 3)	// LOD 3 : 시뮬레이션 제거
	{
		Destroy();
		return;
	}
	if (LOD == 2)	// LOD 2 : 시뮬레이션 정지, 위치 유지
	{
		for (FSlimeParticle& P : Particles)
		{
			P.Velocity = FVector::ZeroVector;
			P.PrevPosition = P.Position;
		}
		return;
	}
	if (LOD == 1)	// LOD 1 : 제약 조건 해결 횟수 감소
	{
		SolverIterations = 2;
	}
	if (LOD == 0)	// LOD 0 : Full Simulation
	{
		SolverIterations = 5;
	}
    
	// LOD 0 : 전체 시뮬레이션
	
	FVector Center = ComputeParticleCenter();
	
	// Actor를 중심 위치로 이동
	FVector ActorWorldPos = GetActorLocation();
	SetActorLocation(ActorWorldPos + Center);
	
	if (Particles.Num() == 0)
	{
		return;
	}
	
	// Particle들은 위치 유지 (로컬 위치 유지)
	for (FSlimeParticle& P : Particles)
	{
		P.Position -= Center;
	}
	
	/*
	 * for all Particles i : 
	 * v_i = v_i + g * dt		| 속도 업데이트
	 * p_i_prev = p_i			| 현재 위치 저장
	 * p_i = p_i + v_i * dt		| 위치 업데이트
	 */
	for (FSlimeParticle& P : Particles)
	{
		P.Velocity.Z += Gravity * DeltaTime;
		P.PrevPosition = P.Position;
		P.Position += P.Velocity * DeltaTime;
	}
	
	/*
	 * 제약 조건 해결 (SolverIterations 만큼 반복)
	 * for all Constraints c :
	 *	SolveConstraint(c)
	 */
	for (int32 Iter = 0; Iter < SolverIterations; Iter++)
	{
		// 거리 제약 조건 Solve
		SolveDistanceConstraints(Constraints, DeltaTime);
		
		// 부피 보존 제약 조건 Solve
		SolveVolumeConstraints(DeltaTime);	
		
		// 충돌 제약 조건 Solve
		SolveCollision(DeltaTime);
	}
	
	/*
	 * for all Particles i :
	 * v_i = (p_i - p_i_prev) / dt	| 속도 업데이트
	 */
	for (FSlimeParticle& P : Particles)
	{
		P.Velocity = (P.Position - P.PrevPosition) / DeltaTime;
	}
	
	DynamicMeshComp->GetDynamicMesh()->EditMesh(
	[this](FDynamicMesh3& Mesh)
	{
		for (int32 vid : Mesh.VertexIndicesItr())
		{
			Mesh.SetVertex(vid, static_cast<FVector3d>(Particles[vid].Position));
		}
	},
	EDynamicMeshChangeType::GeneralEdit,
	EDynamicMeshAttributeChangeFlags::VertexPositions
	);
}

void ASlime::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	
	int32 ActorLOD = CalculateLOD();
	
	RunPBD_LOD(DeltaTime, ActorLOD);
}

현재는 정확한 카메라의 View Frustum이나 장애물에 의한 Blocking 상황을 고려하지 않고 그냥 CameraView의 각도를 넓게 판정해서 보고 있는 방향의 대부분은 시뮬레이션이 되도록 했다.

아니면 시야각의 경우, 간편하게 최근 렌더링 됐는지 판단해서 처리할 수도 있다.

	if (WasRecentlyRendered(0.2f))	// 화면에 보이는지 여부 체크
	{
		// 화면에 보이면 최소 LOD 1 이상으로 강등하지 않음
		LOD = FMath::Min(LOD, 1);
	}
	else
	{
		// 화면에 안 보이면 한 단계 강등
		LOD = FMath::Clamp(LOD + 1, 0, 3);
	}

초록색 원을 벗어나고 플레이어의 시야각의 바깥에 위치하면 PBD 시뮬레이션이 완전히 정지되도록 하였다.

가까이 있고 화면에 또렷하게 보이는 슬라임은 기존과 동일한 Fully Simulation을 유지한다. 반대로, 멀리 있거나 화면 밖에 있는 슬라임은 Iteration 수를 줄여 연산량을 낮춘다.

이렇게 하면 플레이어가 크게 인지하지 못하는 영역에서는 계산 비용을 자연스럽게 줄이고, 실제로 시선이 머무는 영역에 연산 자원을 집중할 수 있다.



2. Unreal Insight에서 CPU Stat 측정

CPU 구간 성능을 측정하기 위해 먼저 아래 헤더를 포함한다.

#include "ProfilingDebugging/CpuProfilerTrace.h"

그 다음, 성능을 측정하고 싶은 함수 내부에 TRACE_CPUPROFILER_EVENT_SCOPE 매크로를 추가한다.

void ASlime::SolveDistanceConstraints(TArray<FDistanceConstraint>& Constraint, float DeltaTime)
{
	TRACE_CPUPROFILER_EVENT_SCOPE(Slime_SolveDistance);
    
    ...
    
}

void ASlime::SolveVolumeConstraints(float DeltaTime)
{
	TRACE_CPUPROFILER_EVENT_SCOPE(Slime_SolveVolume);
    
    ...
    
}

이 매크로는 해당 코드 블록의 실행 시간을 CPU 트레이스에 기록한다.
함수가 호출될 때마다 해당 구간이 하나의 이벤트로 수집되며, Unreal Insights에서 타이밍 정보를 확인할 수 있다.

트레이스를 시작한 뒤 Unreal Insights를 열어 CPU Timing 탭을 확인하면, 위에서 지정한 이름으로 구간이 표시된다.
이를 통해 각 함수가 프레임 내에서 얼마만큼의 시간을 소비하는지 정밀하게 분석할 수 있다.

profile
언리얼 엔진 주니어(신입) 개발자 | 소설 쓰는 취준 개발자

0개의 댓글