Unity 레이캐스트로 Hitbox·Hurtbox 판정을 프레임 단위로 검증하는 방법

Unity C#에서 공격 Hitbox와 피격 Hurtbox의 레이캐스트 결과를 프레임별로 수집·비교하는 검증 프레임워크를 설계한다. 누락 타격, 중복 피격, 애니메이션 동기화 오류를 재현 가능한 로그와 테스트로 빠르게 찾는다.

✓ 복사 완료!
챕터게임 수학 & 물리
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity 레이캐스트로 Hitbox·Hurtbox 판정을 프레임 단위로 검증하는 방법

핵심 요약

Hitbox·Hurtbox 판정은 Physics.RaycastNonAlloc 결과를 공격 프레임마다 수집하고 기대한 피격 대상·충돌 순서·중복 여부와 비교하면 검증할 수 있다. 판정 입력과 결과를 FixedUpdate 기준 프레임 번호, 공격 인스턴스 ID, 레이어 마스크와 함께 기록하면 애니메이션 전환이나 프레임 드롭으로 생기는 누락 타격을 재현 가능하게 추적할 수 있다.

Hitbox와 Hurtbox 판정은 왜 프레임 단위로 검증해야 할까?

액션 게임의 공격 판정은 보통 공격자의 Hitbox와 피격자의 Hurtbox가 같은 물리 스텝에서 겹치는지 확인하는 문제다. 하지만 실제 버그는 단순한 충돌 여부보다 시간축에서 발생한다.

  • 공격 시작 프레임보다 한 스텝 일찍 레이캐스트가 실행된다.
  • 빠르게 이동한 대상이 두 물리 프레임 사이를 지나가며 판정에서 빠진다.
  • 한 공격이 여러 프레임 동안 유지되면서 같은 대상에게 중복 피해를 준다.
  • 애니메이션 이벤트는 Update에서 발생하고 판정은 FixedUpdate에서 수행되어 기준 프레임이 어긋난다.

따라서 ‘이번 공격이 맞았는가’만 기록하면 원인을 알기 어렵다. 언제 어떤 공격이 어떤 방향과 거리로 어떤 Hurtbox를 탐지했는지를 프레임별 표본으로 남겨야 한다.

판정의 이동 구간은 다음처럼 모델링할 수 있다.

d=pcurrent−ppreviousd = p_{current} - p_{previous}

여기서 p_previous와 p_current은 각 물리 스텝의 공격 기준점이며 레이캐스트 방향은 d.normalized, 길이는 d.magnitude다. 공격 범위 반경까지 반영하려면 레이 대신 SphereCastNonAlloc을 선택하는 편이 더 안전하다.

레이캐스트 Hitbox 검증 프레임워크의 구성 요소

프레임워크는 판정 실행부를 대체하기보다 기존 판정 코드가 남기는 관측 데이터를 표준화하는 방식으로 설계하는 것이 좋다.

구성 요소책임반드시 기록할 값
AttackInstance한 번의 공격 생명주기 식별attackId, 시작/종료 물리 프레임
HitQuery레이캐스트 입력 표현원점, 방향, 거리, 레이어 마스크
HitSample프레임별 판정 결과fixedFrame, 감지한 collider, 거리
HitLedger중복 피격과 기대 결과 검증이미 피격한 대상 ID, 오류 목록
ValidationReporter로그·테스트 출력공격별 요약, 실패 사유

공격 기준점의 이전 위치와 현재 위치 사이를 레이캐스트로 검사하고 Hurtbox 결과를 프레임 로그에 저장하는 Unity 판정 흐름

프레임 번호는 Time.frameCount가 아니라 물리 스텝으로 기록한다

Time.frameCount는 렌더링 프레임 기준이다. 물리 판정을 FixedUpdate에서 처리한다면 자체 카운터를 증가시키거나 Time.fixedTime을 이용해 물리 프레임을 식별해야 한다. 아래 예제는 테스트와 로그에서 안정적으로 사용할 수 있도록 카운터를 둔다.

using UnityEngine;

public sealed class PhysicsFrameClock : MonoBehaviour
{
    public static int FixedFrame { get; private set; }

    private void FixedUpdate()
    {
        FixedFrame++;
    }
}

PhysicsFrameClock.FixedFrame은 플레이 세션마다 0부터 시작한다. 네트워크 리플레이처럼 세션을 넘는 식별자가 필요하다면 매치 ID 또는 시드와 함께 기록한다.

Unity C#에서 프레임별 레이캐스트 결과를 어떻게 수집할까?

다음 구현은 공격 궤적의 이전 지점과 현재 지점 사이를 Physics.RaycastNonAlloc으로 검사한다. 할당을 피하기 위해 결과 배열을 재사용하고 Hurtbox 컴포넌트로 충돌체의 게임플레이 ID를 얻는다.

1. Hurtbox에 안정적인 대상 ID를 부여한다

Collider.GetInstanceID()는 실행 중에는 유용하지만 저장된 리플레이의 영속 ID로 적합하지 않다. 캐릭터 단위의 안정적인 ID를 Hurtbox에서 제공한다.

using UnityEngine;

public sealed class Hurtbox : MonoBehaviour
{
    [SerializeField] private int actorId;
    [SerializeField] private bool damageable = true;

    public int ActorId => actorId;
    public bool Damageable => damageable;
}

2. 판정 입력과 결과를 구조체로 수집한다

판정의 성공 여부만 남기지 말고 입력값도 함께 보관한다. 그래야 같은 프레임의 실패를 에디터 Gizmo나 자동 테스트에서 재생성할 수 있다.

using UnityEngine;

public readonly struct HitSample
{
    public readonly int FixedFrame;
    public readonly int AttackId;
    public readonly int TargetActorId;
    public readonly Vector3 Origin;
    public readonly Vector3 Direction;
    public readonly float Distance;
    public readonly bool IsDuplicate;

    public HitSample(
        int fixedFrame,
        int attackId,
        int targetActorId,
        Vector3 origin,
        Vector3 direction,
        float distance,
        bool isDuplicate)
    {
        FixedFrame = fixedFrame;
        AttackId = attackId;
        TargetActorId = targetActorId;
        Origin = origin;
        Direction = direction;
        Distance = distance;
        IsDuplicate = isDuplicate;
    }
}

3. 공격 인스턴스에서 검사와 중복 방지를 함께 수행한다

HashSet<int>은 공격 하나가 같은 대상에게 한 번만 피해를 주게 한다. 단, 다단 히트 공격이라면 ‘공격 전체에서 한 번’ 대신 ‘대상별 마지막 피격 프레임’ 규칙으로 바꿔야 한다.

using System;
using System.Collections.Generic;
using UnityEngine;

public sealed class RaycastHitValidator
{
    private readonly RaycastHit[] hits = new RaycastHit[16];
    private readonly HashSet<int> hitActors = new();
    private readonly List<HitSample> samples = new();

    private readonly int attackId;
    private readonly int hurtboxMask;
    private Vector3 previousPosition;
    private bool hasPreviousPosition;

    public IReadOnlyList<HitSample> Samples => samples;

    public RaycastHitValidator(int attackId, int hurtboxMask)
    {
        this.attackId = attackId;
        this.hurtboxMask = hurtboxMask;
    }

    public void Sample(Vector3 currentPosition, Action<Hurtbox> onFirstHit)
    {
        if (!hasPreviousPosition)
        {
            previousPosition = currentPosition;
            hasPreviousPosition = true;
            return;
        }

        Vector3 delta = currentPosition - previousPosition;
        float distance = delta.magnitude;
        if (distance <= 0.0001f)
        {
            return;
        }

        Vector3 direction = delta / distance;
        int count = Physics.RaycastNonAlloc(
            previousPosition,
            direction,
            hits,
            distance,
            hurtboxMask,
            QueryTriggerInteraction.Collide);

        for (int i = 0; i < count; i++)
        {
            Hurtbox hurtbox = hits[i].collider.GetComponentInParent<Hurtbox>();
            if (hurtbox == null || !hurtbox.Damageable)
            {
                continue;
            }

            bool duplicate = !hitActors.Add(hurtbox.ActorId);
            samples.Add(new HitSample(
                PhysicsFrameClock.FixedFrame,
                attackId,
                hurtbox.ActorId,
                previousPosition,
                direction,
                hits[i].distance,
                duplicate));

            if (!duplicate)
            {
                onFirstHit?.Invoke(hurtbox);
            }
        }

        previousPosition = currentPosition;
    }
}

RaycastNonAlloc은 반환된 결과의 정렬을 보장하지 않는다. 관통 공격에서 가까운 대상부터 처리해야 한다면 반환 범위만 대상으로 거리순 정렬하거나 대상별 결과를 수집한 뒤 명시적으로 정렬한다. 결과 배열 용량을 초과한 경우도 검증 실패로 기록해야 한다. 반환값이 배열 길이와 같다면 ‘정확히 16개’가 아니라 ‘16개 이상일 수 있음’으로 해석한다.

누락 타격과 중복 피격을 검증하는 절차

다음 순서를 공격 단위 검증 규약으로 정하면 수동 재현에 의존하지 않아도 된다.

  1. 공격 계약을 정의한다. 예를 들어 공격 ID 42는 물리 프레임 120~126에서 대상 7을 정확히 한 번 맞혀야 한다고 정의한다.
  2. 프레임 표본을 수집한다. 원점, 방향, 거리, 레이어, 대상 ID, 중복 여부를 매 FixedUpdate에 기록한다.
  3. 기대값과 비교한다. 대상이 한 번도 감지되지 않으면 누락 타격, 두 번째 표본이 IsDuplicate == false이면 중복 피격, 종료 프레임 밖의 표본이면 윈도우 오류로 분류한다.
using System.Linq;

public static class AttackAssertions
{
    public static string ValidateSingleHit(
        IReadOnlyList<HitSample> samples,
        int expectedActorId,
        int firstFrame,
        int lastFrame)
    {
        var targetSamples = samples
            .Where(x => x.TargetActorId == expectedActorId)
            .ToList();

        if (targetSamples.Count == 0)
        {
            return "누락 타격: 기대한 Hurtbox를 감지하지 못했습니다.";
        }

        if (targetSamples.Any(x => x.FixedFrame < firstFrame || x.FixedFrame > lastFrame))
        {
            return "판정 윈도우 오류: 공격 활성 프레임 밖에서 감지되었습니다.";
        }

        if (targetSamples.Count(x => !x.IsDuplicate) != 1)
        {
            return "중복 피격 오류: 동일 공격의 유효 피해가 한 번을 초과했습니다.";
        }

        return null;
    }
}

null을 반환하면 해당 계약은 통과다. Unity Test Framework의 Play Mode 테스트에서는 정해진 위치와 Physics.Simulate로 물리 스텝을 진행해 같은 계약을 반복 검증할 수 있다.

Raycast, SphereCast, OverlapBox 중 무엇을 선택해야 할까?

방식적합한 상황장점주의점
RaycastNonAlloc검, 총알, 좁은 궤적비용이 낮고 이동 경로를 명확히 추적두께가 없어 가장자리 누락 가능
SphereCastNonAlloc근접 무기, 넓은 검격반경으로 공격 두께를 반영반경이 크면 의도하지 않은 대상 탐지
OverlapBoxNonAlloc지속형 범위 공격박스 영역을 직관적으로 표현이전 프레임에서의 관통 누락을 별도 처리해야 함

근접 무기의 스윙처럼 무기 끝점이 크게 이동하는 경우에는 레이 하나보다 여러 샘플 지점을 쓰거나 SphereCastNonAlloc이 적합하다. 검사 반경을 r, 한 물리 스텝의 이동 거리를 d로 두면 연속적인 스윕 볼륨은 대략 캡슐 형태가 된다.

Vsweep≈Capsule(pprevious,pcurrent,r)V_{sweep} \approx \text{Capsule}(p_{previous}, p_{current}, r)

이때 하나의 레이는 캡슐의 중심선만 검사하므로 시각적 무기 폭과 판정 폭이 달라질 수 있다.

판정 로그를 디버그 도구로 연결하는 방법

수집한 HitSample은 콘솔에만 출력하지 말고 공격 종료 시 오류 중심으로 요약한다. 정상 공격까지 매 프레임 출력하면 로그가 빠르게 오염된다.

using UnityEngine;

public static class HitValidationReporter
{
    public static void ReportFailure(string reason, IReadOnlyList<HitSample> samples)
    {
        Debug.LogError($"[HitValidation] {reason} samples={samples.Count}");

        foreach (HitSample sample in samples)
        {
            Color color = sample.IsDuplicate ? Color.yellow : Color.red;
            Debug.DrawRay(sample.Origin, sample.Direction * sample.Distance, color, 3f);
        }
    }
}

Unity Scene 뷰에서 프레임별 레이캐스트 경로와 중복 피격 표본을 색상으로 구분해 확인하는 디버그 예시

개발 빌드에서는 최근 N개 공격만 보관하는 링 버퍼를 권장한다. 전체 전투 로그를 무한히 쌓는 방식은 메모리와 분석 비용을 함께 키운다. 또한 DEVELOPMENT_BUILD 또는 전용 컴파일 심볼로 검증 수집을 감싸면 출시 빌드에서 불필요한 기록 비용을 제거할 수 있다.

자주 묻는 질문 (FAQ)

Update에서 공격 애니메이션을 바꾸고 FixedUpdate에서 판정해도 될까?

가능하지만 공격 활성화 요청을 큐에 넣고 실제 Hitbox 활성 프레임은 FixedUpdate에서 확정해야 한다. 애니메이션 이벤트가 발생한 렌더 프레임과 물리 판정 프레임을 같은 값으로 취급하면 오프바이원 오류가 생긴다.

RaycastNonAlloc이면 GC가 완전히 사라질까?

레이캐스트 결과 배열 할당은 피할 수 있지만 람다 캡처, LINQ, 문자열 보간 로그는 별도 할당을 만들 수 있다. 런타임 수집부에서는 재사용 리스트와 조건부 로그를 사용하고 분석 단계에서만 LINQ를 쓰는 편이 안전하다.

같은 적을 다단 히트 공격으로 여러 번 맞히려면 어떻게 해야 할까?

HashSet<int>만으로는 부족하다. Dictionary<int, int>에 대상별 마지막 유효 피격 프레임을 저장하고 현재 프레임과의 차이가 쿨다운 프레임 이상일 때만 유효 피해로 판정한다.

정리

프레임 단위 Hitbox·Hurtbox 검증의 핵심은 레이캐스트 결과를 단순한 성공 플래그가 아닌 재현 가능한 물리 표본으로 취급하는 데 있다. 공격 ID, 물리 프레임, 스윕 입력, 대상 ID, 중복 상태를 함께 기록하고 계약 기반으로 비교하면 누락 타격과 중복 피격을 빠르게 분류할 수 있다. 먼저 좁은 공격에는 RaycastNonAlloc으로 시작하고 시각적 폭 때문에 누락이 확인될 때 SphereCastNonAlloc 또는 다중 샘플 지점으로 확장하자.