카테고리 없음

내일배움캠프_Unreal/TIL - Day 144 (260626)

하임 2026. 6. 27. 03:11

무기 히트판정 — 메시 바운드 자동 트레이스


1. 문제: 트레이스 지오메트리가 "소켓 2개 + 행 단일 설정"에 묶여 있었음

기존 공격 판정은 무기 메시의 소켓 두 개(start/end)를 보간해 그 선을 구체로 스윕하는 방식이었음. 두 가지 한계가 있었다.

  • 소켓 노가다: 무기마다 날 시작/끝 소켓을 수동으로 박아야 함. 메시 바뀌면 다시.
  • 메시 1개만 판정: GetWeaponMeshForTrace가 "두 소켓을 다 가진 메시 1개"만 반환 → 검+방패처럼 별도 메시 컴포넌트가 여럿이면 방패는 판정에서 누락.
  • 설정이 행(Row) 단위 단일 세트 → 파트별로 다른 트레이스 형상을 줄 수 없음.

2. 해결: 메시 바운드에서 트레이스를 자동으로 뽑는다

핵심 아이디어

로컬 바운딩박스의 최장축을 '날'로 간주한다. 시작·끝·스윕 반경을 메시 크기에서 자동 계산 → 소켓이 없어도 트레이스가 나온다.

// URetrieveWeaponTraceLibrary::BuildBoundsTrace (요지)
const FBoxSphereBounds LocalBounds = Mesh->CalcBounds(FTransform::Identity); // 컴포넌트 로컬 공간
const FVector Extent = LocalBounds.BoxExtent;   // 절반 크기

int32 LongAxis = 0;                              // 최장축 = 날 방향
if (Extent.Y > Extent[LongAxis]) LongAxis = 1;
if (Extent.Z > Extent[LongAxis]) LongAxis = 2;

// 반경 = 나머지 두 축 절반 중 큰 값 × 배율 (단면 = 날 두께)
float CrossHalf = /* LongAxis 제외한 두 축의 max */;
const float Radius = FMath::Max(1.f, CrossHalf * RadiusScale);

// 날 축 ±(절반길이 + 패딩)을 시작/끝점으로, 컴포넌트 트랜스폼으로 월드 변환
FVector AxisDir; AxisDir[LongAxis] = 1.f;
const FVector WorldStart = CompXform.TransformPosition(Center - AxisDir * HalfSpan);
const FVector WorldEnd   = CompXform.TransformPosition(Center + AxisDir * HalfSpan);

포인트: CalcBounds(FTransform::Identity)로 부르면 컴포넌트 로컬 공간 바운드가 나옴 → 최장축 판정을 회전과 무관하게 할 수 있고, 마지막에 컴포넌트 트랜스폼으로 한 번에 월드로 보냄.

배율(RadiusScale)·패딩(LengthPadding)은 에디터 노출 → 빌드 없이 판정 두께를 튜닝.


3. 지오메트리 산출을 상태 없는 라이브러리로 추출

기존 GA_Attack::BuildTracePoints 안에 박혀 있던 보간 로직을, 상태 없는 UBlueprintFunctionLibrary로 빼냄. 어빌리티는 "어떤 모드냐"만 분기하고, 점 계산은 라이브러리가 담당.

// 상태 없음 — 한 번 만들면 #3 점프슬램 등 다른 판정도 같은 지오메트리를 재사용 가능
struct FWeaponTraceSegment { TArray<FVector> Points; float Radius; };

static bool BuildSocketTrace(const UMeshComponent*, FName Start, FName End, ...); // 레거시 경로 추출
static bool BuildBoundsTrace(const UMeshComponent*, float RadiusScale, ...);      // 신규 (검)
static bool BuildBoundsSphere(const UMeshComponent*, float RadiusScale, ...);     // 신규 (둥근 방패)

이득: ① 호출부가 얇아지고 ② 판정 형상이 한 곳에 모여 테스트/재사용이 쉬워짐(후속 #3 점프슬램이 검 바운드를 그대로 가져다 씀).


4. 무회귀 리팩토링 — 순수 가산(additive) opt-in

판정 코어를 건드리는 작업이라 회귀 가능성이 있었음. 그래서 기존 경로를 1도 안 바꾸고, 신규 경로만 옆에 추가하는 방식을 택함.

// GA_Attack::ApplyStepDamage
TArray<FRetrieveEquippedWeaponMesh> HitParts;
CachedWeaponComponent->GetHitVolumeMeshes(HitParts); // opt-in 한 파트만

if (HitParts.IsEmpty())
{
    BuildTracePoints(CurrentPoints);   // ← 레거시 소켓 경로 그대로
    if (CurrentPoints.IsEmpty()) return;
}
// ... HitParts 있으면 위 CurrentPoints는 비어 no-op, 아래 파트별 신규 경로가 돈다

신규 어태치먼트 필드는 전부 default=꺼짐.

상황 동작
어떤 파트도 opt-in 안 함 기존 동작 100% 동일 (레거시 소켓 경로)
검 어태치먼트만 bGeneratesHitVolume=true 검만 바운드 트레이스, 나머지 무영향

이렇게 하면 ① DT/에셋이 안 깨지고(과거 바이너리 머지 사고 교훈) ② 데이터를 켜기 전까지 무회귀가 보장됨 → "완전 대체"는 나중에 안전하게.


5. 데이터 모델 재구성 — 평면 배열 → 구조체 배열

검+방패를 파트별로 다루려면 "메시 포인터만" 갖고 있던 배열로는 부족. 메시 + 그 파트의 트레이스 설정을 묶은 구조체 배열로 재구성.

❌ Before

TArray<TObjectPtr<UMeshComponent>> EquippedWeaponMeshComponents; // 메시만, 설정은 행 단일

✅ After

struct FRetrieveEquippedWeaponMesh {
    TObjectPtr<UMeshComponent> Mesh;
    bool  bGeneratesHitVolume = false;   // 이 파트가 판정을 만드는가
    bool  bUseBoundsTrace     = false;   // 바운드 자동 / 소켓 폴백
    ERetrieveBoundsTraceShape BoundsTraceShape; // 검=스윕 / 방패=구체
    float BoundsRadiusScale, BoundsLengthPadding;
    FName TraceStartSocket, TraceEndSocket;      // 비우면 무기 행 값 상속
};
TArray<FRetrieveEquippedWeaponMesh> EquippedWeaponMeshComponents;

ApplyWeaponVisuals가 어태치먼트를 스폰할 때 데이터에서 이 설정을 복사해 둠 → 런타임 판정 시 파트별로 독립적으로 트레이스.


6. 형상 모드 enum — 검과 방패는 판정 모양이 다르다

길쭉한 검과 둥근 방패는 자연스러운 판정 형상이 다름. enum + EditCondition으로 분기 + 관련 필드만 디테일 패널에 노출.

UENUM(BlueprintType)
enum class ERetrieveBoundsTraceShape : uint8 {
    SweptLongAxis,  // 최장축을 따라 구체 스윕 (검 — 기본)
    SingleSphere,   // 바운드 중심에 구체 1개 (둥근 방패)
};

// 방패 모드 필드는 bUseBoundsTrace일 때만 노출
UPROPERTY(EditAnywhere, meta = (EditCondition = "bGeneratesHitVolume && bUseBoundsTrace"))
ERetrieveBoundsTraceShape BoundsTraceShape = ERetrieveBoundsTraceShape::SweptLongAxis;

7. 프레임 간 스윕 연속성(터널링 방지)을 파트별로 보관

빠른 스윙은 프레임 사이에 적을 통과(터널링) 할 수 있음. 그래서 직전 프레임 점들과 현재 점들을 이어 추가로 스윕함. 파트가 여럿이 되면서 "직전 점들"도 파트별 2차원 배열로 확장해야 했다.

TArray<TArray<FVector>> PreviousTracePointsPerPart; // 파트 인덱스 → 직전 프레임 점들

for (int32 i = 0; i + 1 < Seg.Points.Num(); ++i)
    SweepPart(Seg.Points[i], Seg.Points[i + 1]);     // ① 이번 프레임 점들 연결
if (PartPrev.Num() == Seg.Points.Num())
    for (int32 i = 0; i < Seg.Points.Num(); ++i)
        SweepPart(PartPrev[i], Seg.Points[i]);        // ② 직전→현재 프레임 연결 (터널링 방지)
PartPrev = Seg.Points;

단일 구체(둥근 방패)는 점이 1개라 0길이 스윕(오버랩) 으로 처리. 중복 히트 방지(HitActorsThisStep)는 파트 통합 유지 — 한 스텝에 한 적은 1회만.


8. 주의 노트 🐛

인프라를 깔았다고 데이터까지 켜면 안 됨

  • #6 인프라는 검+방패 둘 다 히트볼륨을 지원하지만, 방패를 휘두르는 공격(배시)은 아직 없음.
  • 방패 어태치먼트를 지금 bGeneratesHitVolume=true로 켜면 → 검 콤보 도중에도 방패 팔이 적을 때림(의도치 않은 히트).
  • 결정: #6은 인프라만. 방패 opt-in은 OFF 유지, 실제 활성화는 방패 배시 스킬 후속 작업에서. → "코드가 지원함"과 "데이터로 켬"을 분리.

메시 교체 시 OLD 메시 정리도 같이 손봐야 함

  • 배열 원소가 UMeshComponent* → 구조체로 바뀌면서, 무기 교체 시 OLD 메시를 PendingDestroy로 옮기는 루프도 .Mesh를 꺼내 쓰도록 전부 수정해야 했음(EquipWeapon/ClearWeaponVisuals/GetPrimaryEquippedWeaponMesh/GetWeaponMeshForTrace).
  • 교훈: 컨테이너 원소 타입을 바꾸면 그 컨테이너를 순회하던 모든 곳이 컴파일 단위로 드러남 → 컴파일러를 체크리스트로 활용.

비교 정리

구분 ❌ Before ✅ After
트레이스 점 산출 수동 소켓 2개 보간 메시 바운드 최장축 자동 (+소켓 폴백)
판정 대상 메시 두 소켓 가진 메시 1개 파트별 독립(검+방패 동시 지원)
설정 위치 무기 행 단일 세트 어태치먼트별 opt-in
지오메트리 로직 GA 내부에 박힘 상태 없는 URetrieveWeaponTraceLibrary
데이터 모델 TArray<UMeshComponent*> TArray<FRetrieveEquippedWeaponMesh>
기존 무기 영향 무회귀(opt-in 안 하면 100% 동일)

핵심 요약

  • 공격 판정 지오메트리는 메시 로컬 바운드의 최장축을 '날'로 보면 소켓 없이 자동 산출된다. CalcBounds(Identity)로 로컬 바운드를 얻고 컴포넌트 트랜스폼으로 월드 변환.
  • 판정 코어 리팩토링은 순수 가산 + opt-in default 꺼짐으로 — 켜기 전까지 무회귀가 보장돼 안전하다.
  • 지오메트리 계산은 상태 없는 라이브러리로 추출 → 다른 판정(점프슬램 등)이 재사용.
  • 빠른 스윙 터널링은 직전 프레임 점 ↔ 현재 점 스윕 연결로 막고, 멀티파트면 그 직전 점도 파트별로 보관해야 한다.
  • "코드가 지원함" ≠ "데이터로 켬". 인프라는 검+방패 다 되지만, 소비자(배시 스킬) 없는 방패는 OFF로 둔다.
  • 컨테이너 원소 타입 변경은 순회하던 모든 곳을 깨뜨린다 → 컴파일러를 마이그레이션 체크리스트로 쓴다.