목표
계속 전진만 하는 오브젝트가 스스로 장애물을 회피하기 위한 방법입니다.
꼼꼼하게 주변을 살펴 장애물을 피하기보단 적당히 살펴보고 빠르게 다음에 갈 방향을 결정하기 위해,
단순히 장애물을 피하여 움직이는 방향을 정하는 방법입니다.
- 유니티의 ray casting 을 활용하여 전방 및 주변 장애물을 감지한다.
- 움직일 오브젝트의 정면 방향부터 시야각까지 적당한 간격으로 광선을 쏜다.
- 장애물이 검출되지 않은 광선 방향으로 오브젝트를 전진한다.
- 만약 모든 광선에서 장애물이 검출되었다면 장애물까지의 거리가 가장 긴 광선 방향으로 이동한다.
구현
Ray Casting
유니티는 Physics.Raycast()
을 이용하여 쉽게 ray casting을 사용할 수 있습니다.
Physics.Raycast()
함수의 시그니처를 먼저 살펴봅시다.
public static bool Raycast(
Vector3 origin,
Vector3 direction,
float maxDistance = Mathf.Infinity,
int layerMask = DefaultRaycastLayers,
QueryTriggerInteraction queryTriggerInteraction = QueryTriggerInteraction.UseGlobal);
public static bool Raycast(
Vector3 origin,
Vector3 direction,
out RaycastHit hitInfo,
float maxDistance,
int layerMask,
QueryTriggerInteraction queryTriggerInteraction);
- `origin`: 광선의 시작점(광원)
- `direction` : 광선의 방향
- `hitInfo` : 광선에 검출된 collider가 있을때의 관련 정보
`RaycastHit`의 자세한 정보는 Unity - Scripting API: RaycastHit (unity3d.com) 참조 - `maxDistance` : 광선의 최대 길이
- `layerMask` : 주어진 레이어마스크에 해당하는 오브젝트만 검출
`origin` 은 움직일 물체의 눈 위치,
`direction`은 장애물을 확인할 방향,
`layerMask`는 장애물의 layer를 설정해주면 되겠네요
하지만 `Physics.Raycast()`를 사용하면 문제가 발생할 수 있습니다.
분명 raycasting으로 장애물이 검출되지 않지만 이대로 전진하다간 몸통박치기 당해버립니다.
이런 경우 사용할 수 있는 것이 `Physics.SphereCast()` 입니다.
광선을 쏘아 장애물을 검출하듯이, 구를 쏘아서 구와 부딫히는 장애물을 검출합니다.
public static bool SphereCast(
Vector3 origin,
float radius,
Vector3 direction,
out RaycastHit hitInfo,
float maxDistance = Mathf.Infinity,
int layerMask = DefaultRaycastLayers,
QueryTriggerInteraction queryTriggerInteraction = QueryTriggerInteraction.UseGlobal);
사용법은 `Physics.Raycast()`와 비슷하지만, 구의 반지름을 넘겨주어 사용합니다.
광선 생성
광선을 적당한 간격마다 쏘아서 전방의 장애물이 없는 방향을 확인해야 합니다.
직진을 최우선으로 확인하고, 점차 각을 벌려가며 장애물을 확인합니다.
2차원
본래 목표는 3차원이지만, 바로 3차원 공간을 구현하는 것은 어려워 2차원에서 구현해보겠습니다.
중심각 `FOV`인 호 위의 점들을 `numPoints` 개수로 샘플링 합니다.
xz 평면과 평행한 광선들만 고려해보겠습니다.
for (int i=0; i<numPoints; i++)
{
float theta = FOV / 180f * Mathf.PI * i / (numPoints - 1f);
float z = Mathf.Cos(theta);
float x = Mathf.Sin(theta) * Mathf.Pow(-1, i % 2);
Vector3 dir = new Vector3(x, 0, z);
dirs[i] = dir;
}
`numPoints` 개수 만큼 광선을 전방 FOV 범위 내에서 추출합니다.
z축 방향은 직진 방향이고, x 값을 좌우로 벌리며 각을 넓힙니다.
흰색은 가장 처음, 어두워 질수록 늦게 생성된 광선
3차원
2차원은 원 위의 점을 추출 했다면, 3차원은 구 위의 점을 추출해야 합니다.
그러기 위해 `z = 0`으로 고정한 채로 x, y의 좌표만을 먼저 결정해보겠습니다.
for (int i=0; i<numPoints; i++)
{
float r = i / (numPoints - 1.0f);
float theta = 2 * Mathf.PI * turnFraction * i;
float x = r * Mathf.Cos(theta);
float y = r * Mathf.Sin(theta);
dirs[i] = new Vector3(x, y, 0);
}
반지름 `r` 은 0 ~ 1 사이의 값, `theta` 는 `turnFraction`에 의해 결정되는 값입니다.
반지름 1인 원을 중심에서 외각까지 theta 만큼 돌아가며 일정한 간격으로 채웁니다.
x와 y 값은 극좌표계로 표현한 $(r, \theta)$를 직교 좌표계$(x, y)$로 변환한 식입니다.
`turnFraction`의 값을 조정하여 다양한 분포를 확인할 수 있습니다.
이때 추천되는 값은 황금 비율 입니다.
사실 turnFraction = 1.618과 turnFraction = 0.618과 같은 모습을 보인
이제 이 점들을 구 위로 옮기는 방법을 찾아야 합니다.
극 좌표계를 직교 좌표계로 변경했듯이,
구면 좌표계를 직교 좌표계로 변환하듯 코드를 작성할 수 있습니다.
for (int i=0; i<numPoints; i++)
{
float t = i / (numPoints - 1.0f);
float theta = Mathf.Acos(1 - 2*t); // 1 - 2*t는 arccos 정의역(1 ~ -1), theta => (0 ~ pi), 감소함수
float azimuth = 2 * Mathf.PI * i * turnFraction; // 방위각
float x = Mathf.Sin(theta) * Mathf.Cos(azimuth);
float y = Mathf.Sin(theta) * Mathf.Sin(azimuth);
float z = Mathf.Cos(theta);
dirs[i] = new Vector3(x, y, z);
}
여기서 theta를 구하는 방법이 독특합니다.
위의 사진에서 확인 할 수 있듯이 점들이 원점에 몰려 분포해 있는데, 이를 해결하기 위해 arccos 함수를 사용했습니다.
구면 좌표계에서 $\theta$는 0 ~ $\pi$ 의 값을 가지는데 이때 $\theta = 0$과 $\theta = \pi$ 주변의 점들이 arccos 값에 의해 조정됩니다. 좀 더 고른 분포를 얻을 수 있습니다.
원하는 FOV 안에서 샘플링하기 위해선 theta의 값을 조절해줍니다.
float thetaRange = Mathf.Cos(Mathf.Deg2Rad * FOV);
for (int i=0; i<numPoints; i++)
{
float t = i / (numPoints - 1.0f);
float theta = Mathf.Acos(1 - (1 - thetaRange) * t);
float azimuth = 2 * Mathf.PI * i * 1.618f;
float x = Mathf.Sin(theta) * Mathf.Cos(azimuth);
float y = Mathf.Sin(theta) * Mathf.Sin(azimuth);
float z = Mathf.Cos(theta);
dirs[i] = new Vector3(x, y, z);
}
이제 3D 공간에도 장애물이 없는 방향을 찾기 위한 광선들의 준비가 끝났습니다.
회피 및 이동
이제 ray casting을 이용해 장애물을 검출할 수 있으므로, 장애물이 없는 방향으로 회전시켜주면 됩니다.
이동은 장애물 없는 방향 탐색 -> 회전 -> 전진 의 반복입니다.
장애물 없는 방향 탐색
먼저 장애물 없는 방향 탐색 부터 구현해보겠습니다. 이는 다음으로 회전할 방향이라고도 할 수 있겠네요.
위에서 광선들을 미리 구했습니다. 심지어 광선들의 순서도 고려하여 만들어 두었기 때문에, 순서 그대로 활용할 수 있습니다.
- 순서대로 ray casting 검사를 진행하다가 장애물이 없는 ray가 있다면 해당 광선의 방향이 회전할 방향이 됩니다.
- 만약 모든 ray에서 장애물이 검출된다면 장애물이 가장 멀리서 검출된 광선의 방향이 회전할 방향이 됩니다.
이때 광선의 `origin`을 단순히 오브젝트의 위치로 하기보단 자식 오브젝트로 tip을 만들어 오브젝트의 앞부분, 실제 눈이 되는 부분의 위치하도록 만들어 광선의 `origin`으로 설정했습니다.
Vector3 detectPos = detectTransform.position; // detectTransform = this.transform.GetChild(0).transform;
Vector3 moveDir = this.transform.forward;
RaycastHit hit;
float maxDist = 0f;
for (int i = 0; i < numPoints; i++)
{
Color color = Color.green;
Vector3 dir = transform.TransformDirection(dirs[i]);
if (Physics.SphereCast(detectPos, rayRadius, dir, out hit, this.distance, obstacleLayer))
{
color = UnityEngine.Color.red;
if (hit.distance > maxDist)
{
maxDist = hit.distance;
moveDir = dir;
}
}
else
{
moveDir = dir;
break;
}
}
회전
이제 `moveDir`에는 다음으로 움직일 방향이 저장되어 있습니다.
`transform.rotation = Quaternion.LookRotation(moveDir, Vector3.up)`으로 회전을 구현할 수도 있지만, 이렇게 회전시킨다면 한 프레임만에 회전이 완료되며 오브젝트의 움직임이 부자연스러워 집니다.
일정한 속도로 적당히 회전하는것이 자연스러운 움직임을 보여줄 것입니다.
moveDir = Vector3.Lerp(this.transform.forward, moveDir, Time.deltaTime * turnSpeed);
moveDir.Normalize();
오브젝트의 직진방향과 `moveDir`을 선형보간하여 이를 구현할 수 있습니다.
`turnSpeed`를 조절하여 회전 속도를 조절할 수 있습니다.
전진
transform.rotation = Quaternion.LookRotation(moveDir, Vector3.up);
transform.position += transform.forward * Time.deltaTime * speed;
최종 `moveDir`로 회전한 후 , `speed`와 `Time.deltaTime`으로 조절된 만큼 앞으로 움직입니다.
결과
참고
https://www.youtube.com/watch?v=bqtqltqcQhw&pp=ygUFYm9pZHM%3D
'Unity' 카테고리의 다른 글
[Unity] 3D 공간에 점찍기 (Geometry shader, Procedural Mesh) (1) | 2024.02.09 |
---|