[UE5] 추출 슈터 4-4. 탄이 어디에 박히는가

게시:     수정

카테고리:

태그: , ,

📌 EmploymentProj 4단계 GAS 이관 네 번째 글입니다. 👾 깃허브 · 📚 시리즈 목차 · ← 4-3. 발사와 재장전을 어빌리티로


이 편에서 하는 것

무기 판정의 나머지를 정리한다. 세 가지인데 서로 물려 있다.

탄 분포를 커브로 뺀다
  → 튜닝하려면 탄착군이 보여야 한다 → 탄흔 데칼을 붙인다
      → 탄흔이 안 찍힌다 → 버그 세 개가 드러난다
          → 그 과정에서 PhysicalMaterial을 다시 보게 된다
              → 부위 대미지를 태그로 옮긴다
                  → 기존 부위 배율이 작동한 적이 없었다는 걸 알게 된다

앞의 둘은 GAS와 직접 관련이 없다. 그런데 “코드에 있던 값을 에셋으로 뺀다”는 이 시리즈의 주제가 가장 순수하게 드러나는 부분이라 같이 적는다.


1. 균일 난수는 왜 도넛이 되는가

3단계까지 스프레드는 이랬다.

// AEPWeapon::Fire
for (int32 i = 0; i < Count; i++)
{
    const float R     = FMath::FRand();          // 반경비율 0~1 균등
    const float Theta = R * HalfAngle;
    const float Phi   = FMath::FRand() * TWO_PI; // 각도 0~2π 균등
    OutPellets.Add(/* 구면 좌표 → 방향 벡터 */);
}
반경 r인 원에서 "고리 하나"의 넓이는 r에 비례해서 커진다.
  r=0.1 근처 고리 : 좁다
  r=0.9 근처 고리 : 9배 넓다

그런데 FRand()는 0.1도 0.9도 같은 확률로 뽑는다.
→ 넓은 바깥 고리와 좁은 중심 고리에 같은 수의 탄이 떨어진다
→ 눈으로는 "바깥에 몰린다"로 보인다

정확히는 면적 균등이 아니라 반경 균등이라 시각적 분포가 의도와 다르다. 게임에서 원하는 건 보통 중심 집중인데 기본 구현은 그 반대에 가깝다.

그런데 이걸 √r 보정으로 끝내지 않았다. 면적 균등도 정답이 아니기 때문이다. 무기마다 원하는 탄착군이 다르다. 소총은 중심 집중, 산탄총은 약간 퍼짐, 특수 무기는 도넛형일 수도 있다. 분포 자체를 데이터로 만들어야 한다.


2. 커브가 PDF, 룩업 테이블이 CDF

디자이너에게 “X축은 반경비율, Y축은 상대 확률”인 커브를 그리게 한다.

원하는 분포 커브 형태
중심 집중 (기본) X=0에서 Y=1.0, X=1에서 Y=0. 우하향
균등 Y=1 상수
도넛형 X가 0.3~0.7인 구간에서 Y가 높음

Y값의 절댓값은 무관하다. 정규화하므로 Y=2와 Y=200이 같은 결과를 낸다. 디자이너가 스케일을 신경 쓸 필요가 없다.

// EPWeapon.h
private:
    static constexpr int32 CDFTableSize = 256;
    TArray<float> SpreadCDFTable;
    void  BuildSpreadCDFTable();   // BeginPlay에서 1회
    float SampleSpread() const;    // Fire()에서 매 펠릿
[BeginPlay]  커브(PDF) → 사다리꼴 적분 → 누적합(CDF) → 정규화 → 256칸 테이블
[Fire()]     균등 난수 U(0~1) → 테이블에서 이진 탐색 → 반경비율 R 반환

역변환 샘플링을 그림으로 그리면 이렇다.

PDF (커브가 그리는 것)          CDF (적분한 것)
  ↑                              ↑ 1.0
  │╲                             │      ╱───
  │ ╲                            │   ╱
  │  ╲___                        │╱
  └──────→ r                     └──────→ r
                                  ↑
            균등 난수 U를 Y축에서 찍고 ─┘ 대응하는 r을 읽으면
            그 r은 PDF를 따르는 분포가 된다

기울기가 가파른 구간, 즉 PDF가 높은 구간이 Y축을 더 많이 차지하므로 더 자주 뽑힌다. 이게 역변환 샘플링의 전부다.

256칸으로 구운 이유는 매 발사마다 적분하면 낭비라서다. 미리 만들어두고 이진 탐색만 한다. 8회 비교면 끝나니 산탄총 10펠릿이어도 무시할 비용이다.

커브가 없으면 FMath::FRand()로 폴백한다. 기존 무기 에셋이 그대로 동작하니 마이그레이션 비용이 0이다.


3. 층화 샘플링, 각도가 뭉치는 문제

CDF는 반경만 제어한다. 각도는 여전히 독립 난수였다.

const float Phi = FMath::FRand() * TWO_PI;   // 펠릿마다 독립

산탄총 5발이 우연히 전부 오른쪽 위 사분면에 뽑힐 수 있다. 확률적으로는 정상인데 플레이어는 “총이 고장났다”고 느낀다. 난수의 정당성과 체감은 다르다.

원을 펠릿 수만큼 나누고, 각 펠릿은 자기 섹터 안에서만 뽑게 했다.

const float SectorSize = TWO_PI / Count;   // 루프 밖

for (int32 i = 0; i < Count; i++)
{
    const float R     = SampleSpread();
    const float Theta = R * HalfAngle;
    const float Phi   = (i * SectorSize) + FMath::FRand() * SectorSize;

    OutPellets.Add(
        AimDir  * FMath::Cos(Theta)
        + Up    * FMath::Sin(Theta) * FMath::Cos(Phi)
        + Right * FMath::Sin(Theta) * FMath::Sin(Phi));
}

섹터 안에서는 여전히 랜덤이라는 게 중요하다. 완전 균등 배치(Phi = i * SectorSize)로 하면 매번 똑같은 별 모양이 나와서 부자연스럽다.

PelletCount = 1이면 SectorSize = 2π라 기존 동작과 완전히 같다. 단발 무기에 영향이 없다는 걸 확인하고 넣었다.


4. 탄흔은 무기 BP가 책임진다

탄착군을 눈으로 봐야 커브를 튜닝할 수 있다. 그래서 데칼을 붙였다.

[서버] HandleHitscanFire
  → ConfirmHitscan (캐릭터 히트 + 환경 히트 모두 수집)
  → 캐릭터만 데미지 적용
  → Multicast_PlayImpactEffect (모든 히트를 배열로 1회)

[클라] PlayLocalImpactEffect
  → ImpactFX (Niagara) + ImpactSFX
  → EquippedWeapon->BP_PlayImpactEffect   ← 무기 BP가 구현
      → Spawn Decal at Location
// EPWeapon.h
UFUNCTION(BlueprintImplementableEvent)
void BP_PlayImpactEffect(const FVector& ImpactPoint, const FVector& ImpactNormal, uint8 SurfaceType);

데칼 머티리얼을 C++ UPROPERTY로 두지 않았다. 무기마다 탄흔 모양, 크기, 수명이 다르다. BlueprintImplementableEvent로 열어두면 무기 BP에서 노드 몇 개로 끝난다. C++에 두면 무기별 차이를 다시 코드로 분기해야 한다.

머티리얼 설정이 막히기 쉬운 부분이다.

  • 머티리얼 도메인: Deferred Decal
  • 블렌드 모드: DBuffer Translucent Color
  • DBuffer를 쓰면 Project Settings의 Rendering에서 DBuffer Decals를 켜야 한다
  • 텍스처는 알파 채널이 있는 탄흔 PNG. 사각형 텍스처를 그냥 쓰면 벽에 네모가 찍힌다

겪은 문제 1: 샷건 10발을 쐈는데 탄흔이 2개

증상은 하나인데 원인이 셋이었다.

원인 ① Unreliable Multicast 드롭

Multicast_PlayImpactEffect를 히트마다 호출하고 있었다. 10발이면 한 프레임에 10회 Multicast다.

Multicast RPC는 Unreliable이라 대역폭이 몰리면 조용히 버려진다.

// Before: void Multicast_PlayImpactEffect(FVector Point, FVector Normal);  ← 10회 호출
// After : void Multicast_PlayImpactEffect(const TArray<FVector_NetQuantize>& Points,
//                                         const TArray<FVector_NetQuantize>& Normals);

시그니처를 배열로 바꿔 1회 호출로 전부 전달했다.

코스메틱을 Unreliable로 보내는 건 맞다. 그런데 호출 횟수 자체를 줄여야 한다. “Unreliable이니까 몇 개 빠져도 된다”와 “10개 중 8개가 빠진다”는 다른 이야기다.

원인 ② Add가 캐릭터 블록 안에 있었다

// HandleHitscanFire
if (HitChar)
{
    /* 데미지 적용 */
    ImpactPoints.Add(Hit.ImpactPoint);   // ← 벽 히트는 수집되지 않는다
}

벽을 쏜 히트가 애초에 배열에 안 들어갔다. if (HitChar) 블록 밖으로 옮겼다.

원인 ③ ConfirmHitscan이 환경 히트를 버리고 있었다

제일 근본적인 원인이다. 3단계에서 SSR을 캐릭터 판정 전용으로 설계했기 때문에 벽 히트를 아예 반환하지 않았다.

// EPServerSideRewindComponent.cpp. ConfirmHitscan
AEPCharacter* HitChar = Cast<AEPCharacter>(Hit.GetActor());
if (HitChar && CandidateSet.Contains(HitChar))
{
    OutConfirmedHits.Add(Hit);
}
else if (!HitChar)          // 추가. 환경 히트, 이펙트 재생 목적
{
    OutConfirmedHits.Add(Hit);
}

🚩 else if (!HitChar) 조건이 중요하다. else로 뭉뚱그리면 “후보군이 아닌 캐릭터”까지 통과해버려서 3단계에서 만든 후보 필터가 무력화된다.

한 시스템의 출력 계약을 나중에 넓힐 때는 원래 계약이 왜 좁았는지를 먼저 확인해야 한다. CandidateSet.Contains() 필터는 리와인드하지 않은 캐릭터에 맞는 걸 막는 3단계의 안전장치였다. 그걸 유지한 채로 환경만 추가해야 했다.


5. 부위 대미지를 태그로

여기서부터 다시 GAS 이야기다. 3단계까지 배율 계산이 두 갈래였다.

// ① 본 이름 문자열로 배율 조회
float UEPCombatComponent::GetBoneMultiplier(const FName& BoneName) const;
// UEPWeaponDefinition::BoneDamageMultiplierMap : TMap<FName, float>

// ② PhysicalMaterial의 bool 플래그로 약점 판정
static float UEPCombatComponent::GetMaterialMultiplier(const UPhysicalMaterial* PM);
// UEPPhysicalMaterial::bIsWeakSpot / WeakSpotMultiplier

// 최종
const float FinalDamage = BaseDamage * GetBoneMultiplier(Hit.BoneName)
                                     * GetMaterialMultiplier(Hit.PhysMaterial.Get());

문제는 이랬다.

문제 설명
bool은 2단계뿐 “약점이냐 아니냐”. 머리 2.5배 / 팔다리 0.75배 같은 3단계 이상을 표현할 수 없다
배율의 소유자가 애매하다 BoneDamageMultiplierMap은 무기에 있는데 키가 캐릭터의 본 이름이다
본 이름은 스켈레톤 종속 "thigh_l"은 UE5 마네킹 규약이다. 다른 스켈레톤을 쓰는 적은 그대로 못 쓴다

두 번째가 핵심이다. 무기 데이터가 캐릭터의 뼈 이름을 알아야 하는 구조였다.

그런데 ①은 애초에 작동한 적이 없었다

이 편을 쓰려고 Before 코드를 다시 열었을 때 알았다.

// EPWeaponDefinition.h
// 부위별 대미지(GAS 이후 태그 기반으로 수정)
TMap<FName, float> BoneDamageMultiplierMap;   // ← UPROPERTY가 없다

UPROPERTY가 없으니 에디터에 뜨지 않고 DataAsset에 직렬화되지도 않는다. 그리고 저장소 전체를 뒤져도 이 맵에 값을 넣는 코드가 한 줄도 없다. 읽는 곳만 있다. 문서에만 “C++ 또는 DataAsset 로직으로 채운다”고 적혀 있고, 채우는 코드는 끝내 없었다.

GetBoneMultiplier는 항상 1.0을 반환하고 있었다.

내가 문제라고 적어뒀던 것 실제
“본 이름 배율과 약점 플래그가 같은 히트에 둘 다 곱해진다” 일어난 적 없다. 본 이름 쪽이 늘 1.0이라 곱해봐야 그대로다
“다른 스켈레톤을 넣으면 매칭이 전부 깨진다” 깨질 매칭 자체가 없었다. 설계상의 문제로는 유효하지만 겪은 문제는 아니다
3-3편에 적은 결과 로그 “팔 26.25 (35 × 0.75)” 소스에 0.75가 없다. 실제로는 35.0이었을 것이다

마지막 줄은 확인한 김에 정확히 적어둔다. 0.75가 나올 수 있는 경로는 ② 하나뿐인데, WeakSpotMultiplier의 기본값은 2.0이고 약점 배율에 0.75를 넣을 이유가 없다. 결과를 눈으로 안 보고 설계 표의 계획값을 그대로 옮겨 적었던 것이다.

헤드샷이 제대로 아팠던 건 순전히 UEPPhysicalMaterial의 약점 배율 덕이었다. 다른 경로가 그럴듯한 결과를 만들어주는 바람에 끝까지 몰랐다.

🐛 2단계에서 UEPItemInstance의 복제 등록을 빠뜨렸고, AEPWeapon::MaxAmmoGetLifetimeReplicatedProps에 등록하지 않았다. 이번이 세 번째다.

UE에서 UPROPERTY는 문법이 아니라 계약이다. 빠뜨리면 컴파일 에러가 나는 게 아니라 조용히 없던 일이 된다. 경고도 없고 크래시도 없다.

그래서 이 절의 진짜 교훈은 “본 이름에서 태그로”가 아니라 “안 되는 걸 몰랐다”에 가깝다.

책임을 두 에셋으로 나눈다

[PhysicalMaterial]  "이 부위는 머리다"          ← 캐릭터의 성질
        ↓ 태그로 전달
[WeaponDefinition]  "머리를 맞추면 2.5배다"      ← 무기의 성질
PM_Head    ─ MaterialTags: {HitZone.Head}
PM_Chest   ─ MaterialTags: {HitZone.Chest}
PM_Limbs   ─ MaterialTags: {HitZone.Limbs}
PM_Default ─ MaterialTags: {}                  → 1.0배 폴백
                    │
                    │  태그
                    ▼
DA_AK74    ─ TagDamageMultiplierMap: { Head:2.5, Chest:1.0, Limbs:0.75 }
DA_Sniper  ─ TagDamageMultiplierMap: { Head:4.0, Chest:1.0, Limbs:0.60 }
DA_Shotgun ─ TagDamageMultiplierMap: { Head:1.5, Chest:1.0, Limbs:0.90 }
// Before. 캐릭터의 본 이름이 키
TMap<FName, float> BoneDamageMultiplierMap;

// After. 추상화된 부위 태그가 키
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Weapon|Combat")
TMap<FGameplayTag, float> TagDamageMultiplierMap;

키 타입만 바뀐 게 아니다. UPROPERTY가 붙었다. 태그로 오면서 비로소 DataAsset에서 값이 보이고, 편집되고, 저장된다. 값이 보인다는 건 비어 있는 것도 보인다는 뜻이다.

이제 무기 데이터는 스켈레톤을 모른다. 캐릭터 모델이 바뀌어도, 적이 인간형이 아니어도 무기 에셋은 그대로다.

함수 두 개가 하나로

float UEPCombatComponent::GetTagDamageMultiplier(
    const UEPPhysicalMaterial* PM, const UEPWeaponDefinition* WeaponDef)
{
    if (!PM || !WeaponDef) return 1.f;

    for (const FGameplayTag& Tag : PM->MaterialTags)
        if (const float* Multiplier = WeaponDef->TagDamageMultiplierMap.Find(Tag))
            return *Multiplier;

    return 1.f;   // 태그 없음 또는 매칭 없음
}

폴백 설계가 중요하다. 태그가 없거나 매칭되지 않으면 조용히 1.0을 반환한다. 새 부위를 추가했는데 무기 맵에 등록하지 않으면 에러 대신 기본 데미지가 나간다. 크래시보다 낫고, 밸런스 이슈로는 금방 눈에 띈다.

복수 태그가 매칭될 때는 첫 번째만 반환한다. 지금은 PM 하나에 태그를 하나만 부여하니 문제가 없지만, 방어구 태그를 도입하면 곱연산으로 바꿔야 한다.

호출부는 4줄이 2줄이 됐다.

// Before
const float BaseDamage         = EquippedWeapon ? EquippedWeapon->GetDamage() : 0.f;
const float BoneMultiplier     = GetBoneMultiplier(Hit.BoneName);
const float MaterialMultiplier = GetMaterialMultiplier(Hit.PhysMaterial.Get());
const float FinalDamage        = BaseDamage * BoneMultiplier * MaterialMultiplier;

// After
const float BaseDamage        = EquippedWeapon ? EquippedWeapon->GetDamage() : 0.f;
const UEPPhysicalMaterial* PM = Cast<UEPPhysicalMaterial>(Hit.PhysMaterial.Get());
const float FinalDamage       = BaseDamage * GetTagDamageMultiplier(PM, EquippedWeapon->WeaponDef);

Hit.PhysMaterial이 채워지려면 3단계에서 켜둔 것이 필요하다.

Params.bReturnPhysicalMaterial = true;   // ConfirmHitscan의 FCollisionQueryParams

당시엔 약점 판정용이었는데 태그 시스템에서 그대로 재사용된다. 이 편에서 Physics Asset은 한 곳도 수정하지 않았다. 각 본에 PhysicalMaterial 에셋만 할당하면 끝이다.


겪은 문제 2: 마이그레이션에 중간 상태가 없다

bIsWeakSpot을 제거하면 GetMaterialMultiplier가 그 필드를 참조하니 즉시 컴파일 에러가 난다. 반대로 함수를 먼저 지우면 호출부가 깨진다.

  • 해결: PhysicalMaterial 수정과 함수 교체를 한 번에 빌드했다
  • 교훈: 필드와 그 필드를 읽는 함수를 동시에 교체하는 마이그레이션은 중간 상태가 존재하지 않는다. 단계를 나눌 수 없다는 걸 문서에 미리 적어두는 게 낫다

태그 이름을 통일한 이야기

처음에 TAG_HitZone_Limb(“HitZone.Limb”)로 선언했다가 Limbs로 통일했다. 단수와 복수가 섞이면 나중에 반드시 헷갈린다.

// Before
UE_DEFINE_GAMEPLAY_TAG(TAG_HitZone_Limb,  "HitZone.Limb")
// After
UE_DEFINE_GAMEPLAY_TAG(TAG_HitZone_Limbs, "HitZone.Limbs")

4-1편에서 네이티브 태그를 고른 이유가 여기서 드러난다. 문자열이었다면 에셋에 박힌 옛 이름이 조용히 매칭 실패했을 것이다. 네이티브 선언이라 참조하는 코드가 전부 컴파일 에러로 잡혔다.


함정 정리

상황 원인 해결
샷건 탄흔이 일부만 찍힘 다중 Unreliable Multicast 드롭 배열 RPC 1회 호출
벽 탄흔이 전혀 안 찍힘 SSR이 환경 히트 미반환 else if (!HitChar) 추가
후보 아닌 캐릭터에 맞음 else로 뭉뚱그림 !HitChar 조건 명시
데칼이 안 보임 머티리얼 도메인이 Deferred Decal이 아님 도메인·블렌드 모드 확인
데칼이 네모로 찍힘 알파 없는 텍스처 알파 채널 있는 탄흔 PNG
커브를 넣었는데 분포가 그대로 BuildSpreadCDFTable() 미호출 BeginPlay 확인
PhysicalMaterial이 항상 null PhysAsset 본에 PM 에셋 미할당 각 본의 Simple Collision 확인
Hit.PhysMaterial이 비어 있음 트레이스 파라미터 누락 bReturnPhysicalMaterial = true
헤드샷인데 배율이 1.0 무기 맵에 태그 미등록 TagDamageMultiplierMap 확인. 조용히 폴백된다

결과

  • PelletCount를 5 이상으로 놓고 벽 사격 → 탄흔으로 탄착군 육안 확인
  • 중심 집중 커브 → 탄흔이 중심부에 밀집
  • 균등 커브 → 이전 FRand() 방식과 같은 분포
  • 층화 샘플링 적용 후 → 펠릿이 원주 전체에 분산. 한쪽 뭉침 없음
  • PelletCount = 1 → 기존 동작과 동일
  • HitZone.Head 피격 → 기본 대비 2.5배, HitZone.Limbs → 0.75배
  • PM이 할당되지 않은 부위 → 1.0배 폴백. 에러 없음
  • 무기를 바꿔 같은 부위 사격 → 다른 배율 적용

한계:

  • 반동에 따른 스프레드 증가가 없다. 연사할수록 HalfAngle이 커지는 동적 스프레드는 미구현이다
  • 조준 시 스프레드 감소도 아직 없다
  • 데칼 개수 제한이 없다. LifeSpan을 30초로 잡아 완화했을 뿐이라 풀링이 필요할 수 있다
  • 표면별 데칼 분기가 미구현이다. BP_PlayImpactEffectSurfaceType을 받지만 지금은 항상 0을 넘긴다. PhysicalMaterial을 태그로 다루기 시작했으니 이 값을 채울 근거는 생겼다
  • 부위별 방어구가 없다. 방향은 정해뒀다. GE Context에 HitZone 태그를 실어 보내고 PostGameplayEffectExecute에서 감산한다. 그러려면 배율 적용 지점을 HandleHitscanFire에서 PostGEExecute로 옮겨야 한다. 4-2편에서 만든 메타 어트리뷰트 구조가 이걸 위한 것이다
  • 관통은 미지원이다

다음 편

스킬 세 개를 만든다. 지속 형태가 전부 달라서 공통 골격이 보였다.

그리고 이 시리즈에서 제일 오래 잡은 버그가 나온다. 시전 게이지는 끝까지 차는데 힐이 안 되고, 쿨다운도 안 돈다. 그것도 확률적으로.


참고

  • DOCS/Notes/04/04_GAS_05_Spread.md. CDF와 층화 샘플링
  • DOCS/Notes/04/04_GAS_05_WeaponDecals_STATUS.md. 버그 3종 기록
  • DOCS/Notes/04/04_GAS_06_HitZoneDamage.md. 태그 기반 부위 대미지
  • 3-1편과 3-3편. Physics Asset 구성과, 작동하지 않았던 원래의 부위 배율

DevLog 카테고리 내 다른 글 보러가기

댓글남기기