오늘은 내적을 이용해 간단하게 시야각(FOV)이 있는 적을 구현해보도록 하겠다.
0️⃣목표
- 내적을 활용하여 시야각 시스템을 만들자!
- 시야각 안의 플레이어를 감지해 적을 움직여보자!
1️⃣내적으로 시야 판별을 하는 방법
내적(a, b) = |a||b|cosθ
내적은 우선 이렇게 정의된다. (|a|, |b|는 벡터다)
여기서 중요한점은 이 cosθ인데, 두 벡터 a, b를 정규화해 길이를 1로 만들게 된다면
1*1*cosθ이므로 원하는 각도를 알 수 있게된다.
이렇게 나온 cosθ는 특징이 3가지 존재하는데,
내적 결과 > 0 : 두 벡터는 같은 방향을 향하고 있다.
내적 결과 < 0 : 두 벡터는 다른 방향을 향하고 있다.
내적 결과 = 0 : 두 벡터는 서로 직교한다.
라는 특징이 있다.
이것을 알고난 후 다시 시야각으로 넘어와보자.
적의 전체 시야각이 180도의 부채꼴이라고 쳤을때
적이 보는 방향의 벡터를 f,
적과 목표까지의 벡터를 v 라고 했을 때
정규화 시킨 이 두 벡터를 내적하면 두 벡터 사이의 각도인 cosθ를 알 수 있다.
지금 시야각은 f를 기준으로 딱 절반 갈라진 상태이므로 이때의 cosθ는 시야각의 절반과 비교해야 한다.
이렇게 비교한 값이 내적보다 시야각/2가 더 작다면 범위 안에 있는것이고, 아니라면 벗어난것이라고 판단 가능한다.
2️⃣유니티로 구현해보자
방금까지 배운 내적의 시야판별을 이용해 시야각을 직접 코드로 구현해보자.
[Header("Target")]
[SerializeField] private Transform player;
[Header("View Settings")] [Min(0f)]
[SerializeField] private float viewDistance = 6f;
[Range(0f, 180f)]
[SerializeField] private float viewAngle = 90f;
[Header("Check")]
[Min(0.01f)] [SerializeField]
private float checkInterval = 0.05f;
private float _timer;
먼저 시작하기 위해 정보가 필요하다.
타겟을 비교해야 하므로 타겟의 위치정보가 필요하고,
시야각의 전체 각도와 부채꼴의 거리도 필요하다.
또한 아래는 솔직히 필요는 없지만, 업데이트에서 너무 많은 감지를 해버리면 성능이 떨어지기 때문에
몇 초에 한번씩 감지를 시도할건지를 정해놓으면 좋다. 또한 시간을 잴 필요가 생겼으므로 타이머 변수도 만들어주자.
private bool IsInFOV(Vector3 targetWorldPos)
{
Vector2 enemyPos = transform.position;
Vector2 toTarget = (Vector2)targetWorldPos - enemyPos;
//거리 체크
float distSq = toTarget.sqrMagnitude;
float viewDistSq = viewDistance * viewDistance;
if (distSq > viewDistSq) return false;
//플레이어와 너무 겹치지 않았는지 예외처리
if (distSq < 0.0001f) return true;
//내적으로 각도 체크
Vector2 forward = GetFacingDirection(); //적이 바라보는 방향
Vector2 dir = toTarget.normalized;
float dot = Vector2.Dot(forward, dir); // cos(theta)
float halfAngle = viewAngle * 0.5f;
float threshold = Mathf.Cos(halfAngle * Mathf.Deg2Rad);
return dot >= threshold;
}
private Vector2 GetFacingDirection()
{
//localScale.x가 음수면 왼쪽을 보고 있다고 가정
return transform.localScale.x >= 0f ? Vector2.right : Vector2.left;
}
다음으로 부채꼴 안에 있는지 없는지를 bool값으로 반환하는 메서드를 하나 만들어준다.
sqrMagnitude가 생소할 수 있는데 이것은 Magnitude의 계산과정에서 루트 계산을 빼고 계산한다는 의미다.
이렇게 계산하면 불필요한 sqrt계산을 뺄 수 있게된다.
private void Update()
{
if (player == null) return;
_timer += Time.deltaTime;
if (_timer < checkInterval) return;
_timer = 0f;
bool inFov = IsInFOV(player.position);
if (inFov)
{
Debug.Log($"플레이어 감지! Enemy: {name}");
}
else
{
Debug.Log($"플레이어 놓침. Enemy: {name}");
}
}
이제 거의 다 완성했다.
타이머를 계산해서 계산을 시킨 후 감지가 되었는지 아닌지만 판단하면 된다.
하지만 지금은 시각적으로 볼 수 없으니 기즈모로 시각적으로 보게 만들어보자.
private void OnDrawGizmos()
{
Gizmos.color = Color.red;
Vector3 pos = transform.position;
Gizmos.DrawWireSphere(pos, viewDistance);
Vector2 forward = GetFacingDirection();
float halfAngle = viewAngle * 0.5f;
Vector2 leftDir = Rotate2D(forward, +halfAngle);
Vector2 rightDir = Rotate2D(forward, -halfAngle);
Gizmos.DrawLine(pos, pos + (Vector3)(leftDir * viewDistance));
Gizmos.DrawLine(pos, pos + (Vector3)(rightDir * viewDistance));
int segments = 20;
Vector3 prev = pos + (Vector3)(rightDir * viewDistance);
for (int i = 1; i <= segments; i++)
{
float t = i / (float)segments;
float angle = Mathf.Lerp(-halfAngle, +halfAngle, t);
Vector2 d = Rotate2D(forward, angle);
Vector3 next = pos + (Vector3)(d * viewDistance);
Gizmos.DrawLine(prev, next);
prev = next;
}
}
private static Vector2 Rotate2D(Vector2 v, float degrees)
{
float rad = degrees * Mathf.Deg2Rad;
float cos = Mathf.Cos(rad);
float sin = Mathf.Sin(rad);
return new Vector2(
v.x * cos - v.y * sin,
v.x * sin + v.y * cos
).normalized;
}

이렇게 잘 작동하는 모습을 볼 수 있다.

또한 추가로 감지 시 방향으로 전진하는것도 조금 추가하면 따라오게도 만들 수 있다.