본문 바로가기

이론

동적으로 섬 생성하기 - 2 (바이옴 분할의 시각화)

실제로 그려보기 (유니티 c# 사용)

물론 직접 그리진 않고 라이브러리의 도움을 받을것입니다.

저는 csDelaunay라는 라이브러리를 사용하려고 하는데,

쓰다 보니 불편한 점이 이만저만이 아니라 저장소를 포크하고 제 입맛대로 마개조했습니다.

이 저장소master 브랜치를 포크해주시면 되겠습니다.

아직 연재중인 글이기 때문에 아예 포크하셔서 앞으로 있을지 모르는 변경점을 업데이트 받으시는 것을 추천드립니다.

 

우선, 보로노이 다이어그램을 생성 후 시각화해봅시다.

보로노이 다이어그램을 이루는 다각형의 무게중심을 랜덤하게 생성해야합니다.

 

[SerializeField]
private Vector2Int size;

[SerializeField]
private int nodeAmount = 0;

우선 Map이라는 클래스를 만들고 두 필드를 선언해줍시다.

size필드는 시각화를 위한 텍스쳐의 크기이고,

nodeAmount필드는 무게중심의 수입니다.

인스펙터에서 수정 가능하도록 이런 식으로 선언해주세요.

private Voronoi GenerateVoronoi(Vector2Int size, int nodeAmount)
{
    var centroids = new List<Vector2>();

    // 무게 중심을 nodeAmount만큼 생성
    for (var i = 0; i < nodeAmount; ++i)
    {
        var x = Random.Range(0, size.x);
        var y = Random.Range(0, size.y);

        centroids.Add(new Vector2(x, y));
    }

    var Rect = new Rect(0f, 0f, size.x, size.y);
    var voronoi = new Voronoi(centroids, Rect);
    return voronoi;
}

랜덤한 좌표에 무게중심을 생성한 후,
Voronoi 인스턴스 생성자에 생성한 무게중심과 범위를 나타내는 Rect를 파라미터로써 넣어줍시다.
이러면 알아서 잘 보로노이 다이어그램을 생성해줍니다.

 

Voronoi 클래스는 csDelaunay 라이브러리의 클래스이므로,

스크립트에 using csDelaunay; 라인을 꼭 추가해주세요.

 

그럼 이제 시각화해봅시다.

먼저 생성된 무게중심이라도 찍어볼까요?

우선 저는 보로노이 다이어그램을 시각화하기 위한 함수들을 모아둔 클래스를

적당히 MapDrawer라는 이름으로 팠습니다.

이 클래스는 굳이 유니티에서 컴포넌트로써 사용하지 않고 전역함수들만 모아둘거라

Monobehaviour 상속은 빼주셔도 됩니다.

public static Sprite DrawVoronoiToSprite(Voronoi voronoi)
{
    // 텍스쳐 픽셀 하나하나의 색을 담고있는 배열입니다.
    // 애석하게도, Texture2D의 픽셀 정보는 1차원 배열입니다.
    var rect = voronoi.PlotBounds;
    var width = Mathf.RoundToInt(rect.width);
    var height = Mathf.RoundToInt(rect.height);
    var pixelColors = Enumerable.Repeat(Color.white, width * height).ToArray();
    var siteCoords = voronoi.SiteCoords();

    // 무게중심 그리기
    foreach (var coord in siteCoords)
    {
        var x = Mathf.RoundToInt(coord.x);
        var y = Mathf.RoundToInt(coord.y);

        var index = x * width + y;
        pixelColors[index] = Color.red;
    }

    // 모서리 그리기
    // . . .
    
    // 텍스쳐화 시키고 스프라이트로 만들기
    var size = new Vector2Int(width, height);
    return DrawSprite(size, pixelColors);
}

public static Sprite DrawSprite(Vector2Int size, Color[] colorDatas)
{
    var texture = new Texture2D(size.x, size.y);
    texture.filterMode = FilterMode.Point;
    texture.SetPixels(colorDatas);
    texture.Apply();

    var rect = new Rect(0, 0, size.x, size.y);
    var sprite = Sprite.Create(texture, rect, Vector2.one * 0.5f);
    return sprite;
}

인스펙터에서 설정한 size만큼 텍스쳐에 입힐 픽셀 컬러를 계산한 후

Texture2D를 생성해 계산한 픽셀 컬러를 넣어줍니다.

그 후 생성했던 텍스쳐를 토대로 스프라이트를 생성합니다.

 

[SerializeField]
private Vector2Int size;

[SerializeField]
private int nodeAmount = 0;

[SerializeField]
private SpriteRenderer voronoiMapRenderer = null;

private void Awake()
{
    var voronoi = GenerateVoronoi(size, nodeAmount);
    voronoiMapRenderer.sprite = MapDrawer.DrawVoronoiToSprite(voronoi);
}

다시 Map 클래스로 돌아가 인스펙터에서 적당히 sizenodeAmount값을 정하고,

voronoiMapRenderer 필드를 SpriteRenderer가 달린 게임오브젝트를 새로 생성해

인스펙터에서 대입시켜준 후 돌려봅시다.

 

드디어 랜덤하게 생성한 보로노이 다이어그램의 무게중심이 그려졌네요....

이젠 보로노이 다이어그램의 모서리를 시각화해봅시다.

무게중심 그리는 부분의 밑 라인에 "모서리 그리기"라는 주석을 남겨뒀던 곳에 이 코드가 들어갑니다.

 

// 모서리 그리기

// 먼저 모든 폴리곤의 정보를 얻어온다.
foreach (var site in voronoi.Sites)
{
    // 그 폴리곤의 모든 이웃 폴리곤을 얻어온다.
    var neighbors = site.NeighborSites();
    foreach (var neighbor in neighbors)
    {
        // 이웃한 폴리곤들에게서 겹치는 가장자리(edge)를 유도해낸다.
        var edge = voronoi.FindEdgeFromAdjacentPolygons(site, neighbor);

        // 뭔진 밑에서 설명해드리겠습니다.
        if (edge.ClippedVertices is null)
        {
            continue;
        }

        // 가장자리를 이루는 모서리 정점(vertex) 2개를 얻어온다.
        var corner1 = edge.ClippedVertices[LR.LEFT];
        var corner2 = edge.ClippedVertices[LR.RIGHT];

        // 1차 함수의 그래프를 그리듯이 텍스쳐에 가장자리 선분을 그린다.
        var targetPoint = corner1;
        var delta = 1 / (corner2 - corner1).magnitude;
        var lerpRatio = 0f;

        while ((int)targetPoint.x != (int)corner2.x ||
            (int)targetPoint.y != (int)corner2.y)
        {
            // 선형 보간을 통해 corner1과 corner2 사이를 lerpRatio만큼 나누는 점을 얻어온다.
            targetPoint = Vector2.Lerp(corner1, corner2, lerpRatio);
            lerpRatio += delta;

            // 텍스쳐의 좌표 영역은 (0 ~ size.x - 1)이지만,
            // 생성한 보로노이 다이어그램의 좌표 영역은 (0 ~ (float) size.x)이다.
            var x = Mathf.Clamp((int) targetPoint.x, 0, size.x - 1);
            var y = Mathf.Clamp((int) targetPoint.y, 0, size.y - 1);

            var index = x * size.x + y;
            pixelColors[index] = Color.black;
        }
    }
}

단번에 이해하기 힘든 코드지만, 천천히 설명해드리겠습니다.

그러나 내용을 이해하시려면 적어도 1편의 맨 밑에 있는 요약 정도는 읽어주세요.

 

먼저, 모든 가장자리의 정보를 얻기 위해 모든 폴리곤의 정보를 얻어와야 합니다.

인접한 폴리곤 2개끼리 서로 겹치는 1개의 가장자리를 얻어낼 수 있기 때문입니다.

각각의 폴리곤의 모든 인접 폴리곤 (이웃 폴리곤)을 얻어, 해당 폴리곤과 모든 인접 폴리곤 사이에서 가장자리를 유도해낼 수 있습니다. 이를 모든 폴리곤에 대해 수행한다면 모든 가장자리의 정보를 얻을 수 있을 것입니다.

 

구석에 있는 폴리곤의 가장자리는 무한히 뻗습니다.

 

가장자리의 정보를 얻은 후 유의해야 할 점은, 동그라미로 표시한 곳 처럼 보로노이 다이어그램의 구석에 있는 폴리곤의 가장자리 일부는 무한히 뻗는다는 점입니다. 그렇기 때문에 ClippedVertices 프로퍼티를 사용해주세요.

이 프로퍼티는 무한히 뻗은 가장자리를 사각형 범위로 제한한 것입니다.

이 프로퍼티가 null인 경우엔 폴리곤의 가장자리를 이루는 정점이 사각형 범위 바깥에 있다는 의미기 때문에,

해당 가장자리에 대한 선 긋는 공정은 건너뛰셔야 합니다.

 

그 후 1차 함수의 그래프를 그리듯이 텍스쳐에 선을 그려주시면 됩니다. (참고 자료)

 

어찌저찌 뽑긴 했는데 모양이 더럽다.

어찌저찌 시각화를 해봤지만 모양이 좀 불-편합니다.

폴리곤의 모양과 크기의 편차가 심하기 때문입니다.

이럴 때 조치할 수 있는 방법이 있습니다.

Lloyd's Relaxation(로이드의 알고리즘) 이라는 작업을 반복해서 돌리면 됩니다.

다행스럽게도, 이미 csDelaunay 라이브러리에서 이 작업을 지원합니다.

 

이 작업은 모든 폴리곤의 무게중심을 인자로 사용해 보로노이 다이어그램을 새로 생성하는데,

이 작업을 반복할수록 폴리곤의 모양과 크기의 편차가 줄어듭니다.

 

적당히 5번 돌려보겠습니다.

Map 클래스의 GenerateVoronoi 함수로 돌아가 파라미터 타입을 바꾸고,

호출하는 곳으로 돌아가 파라미터를 추가해줍시다.

// 필드 새로 선언
[SerializeField]
private int lloydIterationCount = 0;

// 파라미터 추가
private Voronoi GenerateVoronoi(Vector2Int size, int nodeAmount, int lloydIterationCount)

// 구현부 내부의 인스턴스 생성자에 파라미터 추가
var voronoi = new Voronoi(centroids, Rect, lloydIterationCount);

// GenerateVoronoi 호출부에 파라미터 추가
var voronoi = GenerateVoronoi(size, nodeAmount, lloydIterationCount);

로이드의 알고리즘 반복 횟수를 나타내는 인자를 하나 추가합시다.

인스펙터에서 반복 횟수를 조절하고 싶으시다면 알아서 클래스 필드로 빼시면 되겠습니다.

 

드라마틱한 변화!!!! 와!!!!

변화가 아주 드라마틱하네요... 이정도는 되어야 모양이 좀 이쁘장하다고 말할 수 있을 것 같습니다 ㅎㅎ