Sparta Unreal 부트캠프 128일차

정찬호·2026년 6월 8일

[TIL] UE5 StateTree Evaluator 실행 구간 분석과 포위 서브시스템 노이즈 설계 개선

Today I Learned | Unreal Engine 5 | StateTree | AI | C++ | 7th-Team2


1부. StateTree Evaluator의 실행 구간을 직접 파악한 과정

문제 발생 경위

적의 공전 목적지(ChaseLocation)가 매 프레임 흔들려 이동이 불안정한 문제를 분석하던 중,
RetrieveEnemyTargetEvaluator.cpp를 직접 읽으면서 실행 구간이 두 덩어리로 나뉜다는 것을
확인했습니다.

Evaluator 실행 구간 구조

void FRetrieveEnemyTargetEvaluator::Tick(...) const
{
    // ── 구간 A: 매 프레임 ──────────────────────────────────
    // TargetLocation, DistanceToTarget, ChaseLocation 갱신
    // GetSlotLocation 호출 ← 이 안에 FRandRange가 있었음

    InstanceData.AccumulatedTime += DeltaTime;
    if (InstanceData.AccumulatedTime < TickInterval)
    {
        return;   // ← 0.2초가 안 됐으면 여기서 반환
    }
    InstanceData.AccumulatedTime = 0.f;

    // ── 구간 B: 0.2초마다 ──────────────────────────────────
    // Perception 조회, 토큰 요청, bAttackable 계산
}

초기에는 "Tick 전체가 0.2초마다 실행된다"고 생각했는데,
AccumulatedTime 게이트 앞에 위치한 코드들이 매 프레임 실행된다는 것을
코드를 직접 읽어 확인했습니다.

발견된 버그: FRandRange 매 프레임 호출

EncirclementSubsystem::GetSlotLocationbUseOuterRadius일 때 호출마다 새 난수를 뽑고 있었습니다.

// Before — 매 호출(= 매 프레임)마다 새 난수
if (bUseOuterRadius)
{
    TargetRadius += FMath::FRandRange(MinNoise, MaxNoise);
}

기본값 MinNoise=-100, MaxNoise=+10 기준으로 목적지가 매 프레임 200~310 사이를 진동했습니다.
VInterpTo(speed=7.0)가 있어도 입력이 매 프레임 ±110 범위로 점프하므로 수렴이 불가능한 상태였습니다.

처음에는 VInterpTo가 진동을 충분히 완화할 것이라고 생각했지만, 직접 수치를 계산해 보니
(speed=7.0, dt=0.016일 때 프레임당 이동 비율 ≈ 10.6%) 입력 자체가 매 프레임 점프하는 이상
보간이 의미가 없다는 것을 알 수 있었습니다.

핵심: StateTree Evaluator에서 AccumulatedTime 게이트의 위치가 어디냐에 따라
매 프레임 실행되는 코드와 0.2초마다 실행되는 코드가 완전히 달라집니다.


2부. 포위 서브시스템 노이즈 아키텍처 개선

설계 의도와 어긋난 구현

"슬롯마다 목적지를 조금씩 다르게 해서 단조로운 공전을 방지한다"는 의도였는데,
노이즈가 슬롯 배정 시 확정되지 않고 조회할 때마다 새로 뽑히고 있었습니다.
조회 함수(GetSlotLocation)에 난수 생성 로직이 들어간 것이 원인이었습니다.

해결 방향: 상태는 한 곳에서 1회 확정

FRing 구조체에 슬롯별 노이즈를 저장하는 배열을 추가하고,
슬롯 배정(RequestSlot) 시 1회만 난수를 생성해 고정했습니다.

// EncirclementSubsystem.h — FRing 구조체에 SlotNoises 추가
struct FRing
{
    TArray<TWeakObjectPtr<AActor>> Slots;
    TArray<TWeakObjectPtr<AActor>> AttackTokens;
    TArray<float> SlotNoises;   // 슬롯별 노이즈 오프셋 (배정 시 1회 확정)
};
// GetSlotLocation — FRandRange 제거, 저장된 값 읽기
if (bUseOuterRadius)
{
    if (const FRing* Ring = Rings.Find(Target))
    {
        if (Ring->SlotNoises.IsValidIndex(SlotIndex))
            TargetRadius += Ring->SlotNoises[SlotIndex];
    }
}
// Evaluator.cpp — RequestSlot 직후 1회만 설정
SlotIndex = EncSubsystem->RequestSlot(InstanceData.TargetPlayer, Pawn);
if (SlotIndex != INDEX_NONE)
{
    const float Noise = FMath::FRandRange(StrafeMinNoise, StrafeMaxNoise);
    EncSubsystem->FixSlotNoise(InstanceData.TargetPlayer, SlotIndex, Noise);
}

변경 전후 비교

구분BeforeAfter
노이즈 생성 위치GetSlotLocation (조회마다)RequestSlot 직후 1회
노이즈 저장 위치없음 (매번 새로 계산)FRing::SlotNoises
GetSlotLocation 성격부작용 있는 조회순수 읽기 전용
ChaseLocation 안정성매 프레임 ±110 진동슬롯 배정 후 고정

추가로 발견된 문제: 토큰 반경 급변

bHasToken이 반경(InnerRadius=100 / OuterRadius=300)을 결정하는 구조에서,
토큰 반납 후 0.2초 이내 재취득 사이클이 반복되면서 목적지가 200단위로 급변하는 문제도 확인했습니다.

초기 수정 시도(hold timer)에서는 bHasToken=true를 일정 시간 유지해 급변을 방지하려 했으나,
이로 인해 공격 불가 상태에서 InnerRadius에 갇히는 부작용이 발생했습니다.

근본 해결책은 반경 결정 기준을 bHasToken에서 bAttackable로 분리하는 것이었습니다.

// Before — 토큰 보유 여부로 반경 결정
const bool bUseOuterRadius = !InstanceData.bHasToken;

// After — 공격 가능 여부로 반경 결정
const bool bUseOuterRadius = !InstanceData.bAttackable;

이 변경으로 토큰은 오직 공격 허가(bAttackable) 판단에만 사용되고,
반경은 "공격 가능한 상태인가"라는 더 넓은 조건으로 결정됩니다.
공격 후 쿨다운 중에는 bAttackable=false → OuterRadius로 자연스럽게 이동하는 흐름이 됩니다.


핵심 요약

  • StateTree Evaluator는 AccumulatedTime 게이트를 기준으로 매 프레임 섹션과 주기 섹션이 나뉩니다.
  • 공유 서브시스템의 조회 함수에 랜덤 로직이 있으면 호출 빈도만큼 난수가 발생합니다. 조회 함수는 순수 읽기 전용으로 유지해야 합니다.
  • 상태(State)는 한 곳에서 1회 확정하고, 소유권이 명확한 구조체(FRing)가 보유하도록 설계하면 여러 호출처가 일관된 값을 읽을 수 있습니다.
  • 첫 번째 수정(hold timer)이 새 버그를 만들었습니다. "무엇을 제어하는가"(반경 vs 공격 허가)를 분리하는 것이 올바른 설계였습니다.

Sparta Study 7th Team2 Final Project — 2026-06-08

profile
게임 개발 지망생입니다.

0개의 댓글