본문 바로가기

C# 프로그래밍/예제 코드

[C#] 물고기 군집 시스템 (Fish school system)

반응형

물고기 군집 시스템

공모전을 준비하던 와중 수족관을 만들게 되었다. 

다만 일반적인 수족관과의 차이점이라면 물고기가 떼를 이루어 군집을 형성한다는 점.

 

처음엔 유튜브 채널 중 Sebastian Lague의 'Boids'를 참고하려 했으나, 이내 포기하고 말았다.

Sebastian Lague는 compute shader를 사용했고, 이는 모바일 빌드가 어렵기 때문.

 

따라서 직접 물고기 군집 시스템을 만들기로 하였고 한 편의 논문을 참고하였다.

 

해당 논문 자료는 https://academic.oup.com/beheco/article/16/1/178/206370 에서 확인할 수 있다.

 

Fuzzy Logic을 통해 확률분포로 군집 시스템을 제작할까 했지만 위 논문처럼 Individual Based Model이 더 '진짜처럼' 행동할 것이라 기대되어 해당 논물을 참고한다.

 

 

기본 행동 구조

논문에 따르면 물고기 떼는 3가지 행동양식을 띤다.

Repulsion, Attraction, and Aligning 으로써 반발, 끌림, 정렬이 그것이다.

 

우선 물고기 떼는 측면 정렬 형태를 주로 이룬다고 한다. 그 이유는 후면으로는 blind area가 존재하여 시야각에 나타나지 않기 때문이다. 따라서 군집은 주로 blind area인 후면 60도를 제외한 측면으로 정렬하게 된다.

 

행동양식을 간단히 요약하면 다음과 같다.

 

서로에 반발이 동작하는 동안 각각의 물고기들은 인접한 물고기에 반하여 회전한다.

또한 그에 맞게 끌림이 작용하는데, 이것으로 인접한 물고기들끼리 같은 방향을 바라보게 한다.

나머지 정렬에 의한 각속도는 인접한 물고기가 같은 방향으로 진행할 경우 두 물고기의 해당 지점에 대한 각도 차이로써 물고기 떼를 정렬하게 만든다.

 

전체 반응은 이것들의 합으로 결정되며 이 행동양식들은 인접한 물고기의 거리에 비례한다. (가까울수록 변화에 따른 각속도 변화가 커지기 때문)


ωtij=wr(dtij)ωr+wa(dtij)ωa+wp(dtij)ωp

그렇다면 이것을 어떻게 유니티로 어떻게 제작을 할 수 있을까?


변수 설정

우선 물고기 객체마다 Update나 코루틴을 실행하여 각각 오브젝트가 모여 군집 시스템을 형성하는 경우가 있다.

또한 전체 행동 양식을 하나의 스크립트에서 정해준 후 물고기는 트랜스폼 각도 변화나 위치 변화만 스스로 판단 내리게 하는 경우가 있을 것이다.

 

둘 다 각각의 장단점이 있으리라 판단이 되지만, 현재 레벨에서는 전체 행동 양식을 하나의 스크립트가 모두 정해주고 나머지 부분에 대해서만 각 객체가 행동하는 게 불필요한 연산을 줄일 수 있으리라 판단되어 후자를 선택하였다.

 

변수를 정하기 위해서 편의상 각 행동 양식의 힘을 척력, 인력, 조정력(?)이라 하겠다.

 

3가지 힘은 모두 blind area를 제외한 나머지 영역에서  이루어지며

척력은 가장 가까운 물고기로부터, 인력은 가장 멀리 있는 물고기로부터, 정렬력은 모든 물고기의 평균으로 이루어진다.(논문 참고) 

 

따라서 스크립트를 통해 하나의 물고기 오브젝트가 무리 안에서 가장 가까운 물고기와 가장 멀리 떨어진 물고기 그리고 물고기 떼의 평균 위치의 물고기를 모두 검출해내야 하지만, 이는 현실에 가까운 시뮬레이션을 위한 코딩일 뿐 나는 최대한 비슷하게 제작하면 되기 때문에 논문과는 조금 다르게 제작하고자 한다.


코드 스크립팅

첫 번째로 척력의 경우 가장 인접한 물고기로부터 계산되어야 하기 때문에 가장 가까운 물고기를 찾는 것으로 스크립팅을 한다.

Overlapshere를 통해서 찾고자 하는 물고기의 오브젝트를 모두 검출하고(transform이 변화하는 오브젝트의 경우 OnTriggerStay보다 연산이 적다) 가장 가까운 물고기를 찾아낸다. 만약 가장 가까운 물고기가 기준으로 삼은 거리보다 가깝다면 해당 물고기의 회전을 반대로 하게 하는 것이다. (Vector3 타깃벡터 = 해당 물고기 - 가장 가까운 물고기)

이러한 연산이 자주 쓰이기 때문에 RotateTransform이라는 함수를 만들어 사용하도록 하였다.

private Quaternion RotateTransform(Transform target, Transform basis, float factor = 1f)
    {
        Quaternion rot = Quaternion.LookRotation(target.position - basis.position);
        return Quaternion.Slerp(transform.rotation, rot, factor * Time.deltaTime);
    }

여기서 세 번째 인자인 factor는 척력, 인력, 정렬력 등의 힘의 세기이다. 이 힘에 따라 물고기의 정렬방식이 달라질 것이다.

 

두 번째로 인력은 가장 가까운 물고기가 일정 거리 이상으로 멀어지면 해당 물고기가 가장 가까운 물고기 방향으로 회전하도록 한다. 이것은 가장 멀리 떨어진 물고기와 거리를 유지해야 한다는 논문과 차이점이 있다. 가장 멀리 떨어진 물고기를 찾아내기 위해서는 추가적인 계산이 필요하고 검출에 필요한 Sphere의 크기도 커질 수밖에 없다. 크기가 커지면 당연히 더 많은 물고기가 콜라이더에 들어가므로 불필요한 계산이 너무 많아지게 되는 것이다. 따라서 물고기 떼가 최소한의 응집상태를 유지하게 하기 위해서 가장 인접한 물고기 오브젝트만을 검출하여 인력으로 사용한다.

 

이를 합쳐서 스크립트로 나타내면 다음과 같다.

private void Heading()
    {
        Collider[] cols = Physics.OverlapSphere(transform.position, m_detectingDistance, followingLayer);
        float shortestDist = Mathf.Infinity;
        Collider shortestCol = null;

        for(int i = 0; i < cols.Length; i++)
        {
            if (cols[i].gameObject != gameObject)
            {
                float currentDist = Vector3.Distance(transform.position, cols[i].transform.position);
                if (shortestDist > currentDist)
                {
                    shortestDist = currentDist;
                    shortestCol = cols[i];
                }
            }
        }
        transform.position += transform.forward * Time.deltaTime * movementSpeed;
        transform.rotation = RotateTransform(aligningObject.transform, transform, aligningFactor);

        if (shortestCol != null)
        {
            Quaternion totalRotation = transform.rotation;
            if (shortestDist < repulsionDistance)
            {
                totalRotation = RotateTransform(transform, shortestCol.transform, repulsionFactor);
            }
            else if (shortestDist > attractionDistance)
            {
                totalRotation = RotateTransform(shortestCol.transform, transform, attractionFactor);
            }
            transform.rotation = totalRotation;
        }
    }

여기서 나는 물고기의 속력에 변화를 주지 않았다. 항상 일정한 movementSpeed로 움직이며 물고기의 방향만 인력과 척력, 정렬력에 의해 변화할 뿐이다.

 

이제 물고기 떼는 각자 일정거리를 유지하며 뭉쳐다닐 수 있게 되었다.

 

나머지 세 번째인 정렬력은 본래 물고기 떼의 중심에 위치한 물고기의 회전각을 통해 나머지 물고기들에게 영향을 주는 방식으로 해야 하지만 이것도 일부 수정하기로 한다. 

 

수정한 방식은 이러하다. 우선 빈 GameObject에 물고기의 움직임을 제어하는 스크립트를 넣는다.

물고기의 움직임은 앞서 언급한 것과 마찬가지로 transform.forward방향으로 직진만 하며 장애물(수족관 벽, 충돌을 피해야 할 오브젝트)에 가까이 다가가면 회피하도록 설정한다.

 

따라서 앞에 장애물이 있는지에 대한 판단을 하는 스크립트를 다음과 같이 작성하였다.

private bool IsRaycastedAll(int castAngle, int castCount = 1)
    {
        bool isCasted = false;
        float longestDist = 0;
        Collider longestCol = null;
        Vector3 longestPoint = Vector3.zero;

        for (int i = 0; i < castCount; i++)
        {
            float rad = (360f / castCount) * i * Mathf.PI / 180;
            float forwardRad = Mathf.Cos(castAngle * Mathf.PI / 180);
            float verticalRad = Mathf.Sin(castAngle * Mathf.PI / 180);

            Vector2 dir = new Vector2(Mathf.Cos(rad), Mathf.Sin(rad));
            Ray ray = new Ray();
            ray.origin = transform.position;
            ray.direction = verticalRad * transform.right * dir.x + verticalRad * transform.up * dir.y + forwardRad * transform.forward;
            
            if(Physics.Raycast(ray, out hitInfo, 50f, layer))
            {
                //Debug.DrawLine(transform.position, hitInfo.point, Color.red, Time.deltaTime);
                float dist = Vector3.Distance(transform.position, hitInfo.point);
                if (longestDist < dist)
                {
                    longestDist = dist;
                    longestCol = hitInfo.collider;
                    isCasted = true;
                    longestPoint = hitInfo.point;
                }
                
            }
        }

        if (longestCol != null)
        {
            //Debug.DrawLine(transform.position, longestPoint, Color.blue, Time.deltaTime);
            Quaternion rot = Quaternion.LookRotation(longestPoint - transform.position);
            transform.rotation = Quaternion.Slerp(transform.rotation, rot, 5f * Time.deltaTime);
        }
        return isCasted;
    }

여기서 castAngle은 ray를 쏘는 방향이며 castCount는 ray의 개수이다.  rayCount만큼의 ray를 쏴서 가장 거리가 멀리 떨어진 방향으로 물고기는 방향으로(회피하는 방향이기 때문) 회전하면 된다. 그렇다면 ray는 언제 쏘면 될까?

 

아래는 ray를 쏘는 기준이다.

private void IsObstacleDetected()
    {
        Ray ray = new Ray();
        RaycastHit info;
        ray.origin = transform.position;
        ray.direction = transform.forward;
        float dist = Mathf.Infinity;

        //if (Physics.SphereCast(ray, 1f * modi, out info, 3f * modi, layer))
        if (Physics.Raycast(ray, out info, 3f * modi, layer))
        {
            dist = Vector3.Distance(transform.position, info.point);
            if (dist > 5 * modi)
                IsRaycastedAll(15, 8);
            else if (dist > 4 * modi)
                IsRaycastedAll(30, 8);
            else if (dist > 3f * modi)
                IsRaycastedAll(45, 8);
            else if (dist > 2 * modi)
                IsRaycastedAll(60, 8);
            else if (dist > 1f * modi)
                IsRaycastedAll(90, 8);
            else
                IsRaycastedAll(120, 8);
        }
    }

장애물을 회피하기 위해 ray를 매번 쏠 필요는 없다. 앞에 장애물이 있다면 그때 쏘면 된다. 따라서 장애물을 판단하기 위한 ray를 앞서 미리 쏜다. 이때 SphereCast를 통해서 구체를 쏘면 좀 더 효율적이라 판단된다.(직선을 검출하는 ray보다 크기가 커서 보다 효율적으로 검출해 낼 수 있다.)

 

장애물과 거리가 가까우면 가까울수록 더 넓은 각도로 ray를 발사하여 빠른 회피가 가능하도록 한다.

 

이 과정은 Update에서 프레임 단위로 실행하였다.

 

그리고 이렇게 만들어진 스크립트를 GameObject에 넣고 나머지 물고기 떼들이 이 GameObject를 향해 바라보도록 한다.

 

이렇게 하여 인력, 척력, 정렬력이 모두 갖추어졌으나 세 힘의 크기와 순서가 물고기 군집의 형태를 좌우한다. 정렬력이 너무 커버리면 물고기들이 겹치며 일렬로 움직이게 되고 인력이나 척력이 너무 강하면 물고기가 정렬되지 않고 무리를 나누어 움직인다. 따라서 기본적으로는 정렬을 하며 움직이되 물고기끼리의 거리가 특정값보다 너무 가깝거나 멀면 척력과 인력을 작용하여 거리를 유지하도록 하였다.

반응형