본문 바로가기
[Unity]

[Unity] 동적 Mesh로 포물선(Parabola) 그리기

by 김기승 2023. 12. 30.

이전 글에서 Mesh 클래스로 원하는 대로 도형을 생성할 수 있었다.

https://giseung.tistory.com/54

 

[Unity] Mesh를 생성하여 도형 만들기

3D 게임을 만들기 위해서는 다양한 모델링을 활용한다. 이 모델링들은 Mesh 데이터를 통해 형상화하게 되는데, 유니티에서는 Mesh 클래스를 생성해서 원하는 도형을 만들 수 있다. 이번 글에서는 Me

giseung.tistory.com

이를 활용하여, 포물선을 그리는 방법을 소개하겠다.

세부 내용은 뒤로 미루고 결과부터 보여주겠다.


결과.gif


스크립트

using UnityEngine;
using System.Collections.Generic;

public class MeshParabola : MonoBehaviour
{
    [Header("Component")]
    [Tooltip("시작 위치")]
    public Transform from;
    [Tooltip("종료 위치")]
    public Transform to;

    [Header("Draw")]
    [Tooltip("면의 개수. 클수록 부드러운 선이 된다.")]
    [Min(1)] public int face = 20;
    [Tooltip("선의 높이")]
    public float height = 0.15f;
    [Tooltip("거리에 따라 높이를 조정한다.")]
    public bool heightByDistance = true;
    [Tooltip("선의 두께")]
    public float thickness = 0.05f;
    [Tooltip("Off : 양면 렌더링, Front : 뒷면 렌더링, Rear : 앞면 렌더링, Both : 렌더링 안함.")]
    public Cull cull = Cull.Off;
    [Tooltip("사용할 쉐이더")]
    public string shader = "Unlit/Transparent";

    [Header("Texture")]
    [Tooltip("머티리얼에 적용할 텍스쳐")]
    public Texture texture;
    [Tooltip("텍스쳐 반복 횟수")]
    public float tiling = 1f;
    [Tooltip("흐르는 속도")]
    public float flowSpeed = 4f;

    // Component
    private MeshRenderer _meshRenderer;
    private MeshFilter _meshFilter;

    // Mesh
    private Mesh _mesh;
    private List<Vector3> _vertices;
    private List<Vector2> _uvs;
    private List<int> _triangles;

    // Texture
    private float _flow;

    public enum Cull
    {
        Off = 0, //Cull 하지 않는 상태. 양면 렌더링.
        Front = 1, //Front를 Cull하는 상태. 뒷면 렌더링.
        Back = 2, //Back을 Cull하는 상태. 앞면 렌더링.
        Both = 3 //모두 Cull하는 상태. 렌더링 안함.
    }

    //__________________________________________________________________________ Initialize
    private void Awake()
    {
        initializeMesh();
        initializeComponent();
    }
    private void initializeMesh()
    {
        _mesh = new Mesh(); //새로운 Mesh 생성

        _vertices = new List<Vector3>(); //정점 데이터를 저장할 List
        _uvs = new List<Vector2>(); //UV 데이터를 저장할 List
        _triangles = new List<int>(); //Triangle 데이터를 저장할 List
    }
    [ContextMenu("Initialize Component")]
    private void initializeComponent()
    {
        _meshRenderer = GetComponentInChildren<MeshRenderer>(true); //MeshRenderer 찾아보고,
        if (_meshRenderer == null) _meshRenderer = gameObject.AddComponent<MeshRenderer>(); //없으면 추가
        _meshRenderer.material = new Material(Shader.Find(shader)); //쉐이더 적용한 머티리얼 생성하고,
        _meshRenderer.material.mainTexture = texture; //텍스쳐도 지정

        _meshFilter = GetComponentInChildren<MeshFilter>(true); //MeshFilter 찾아보고,
        if (_meshFilter == null) _meshFilter = gameObject.AddComponent<MeshFilter>(); //없으면 추가
        _meshFilter.mesh = _mesh; //위에서 생성한 Mesh 지정
    }

    //__________________________________________________________________________ Draw
    private void Update()
    {
        draw();
    }
    private void draw()
    {
        if (from != null && to != null) //From과 To 모두 있어야한다.
        {
            float division = 1f / face; //각 면의 t(보간비율)를 구하기 위한 값
            float currV = 0f; //현재까지의 V값. UV중 V값이다.
            float distance = Vector3.Distance(from.position, to.position); //From과 To의 거리

            for (int i = 0; i < face; ++i) //면의 개수만큼 반복
            {
                float startT = division * i; //시작 t
                float endT = startT + division; //끝 t
                Vector3 adjFromPos = from.position - transform.position; //현재 오브젝트가 원점이 아닐 수 있으므로 조정
                Vector3 adjToPos = to.position - transform.position; //현재 오브젝트가 원점이 아닐 수 있으므로 조정

                GetPoints(adjFromPos, adjToPos, height, startT, thickness, out Vector3 startL, out Vector3 startR); //시작 지점의 양쪽 포인트를 구한다.
                GetPoints(adjFromPos, adjToPos, height, endT, thickness, out Vector3 endL, out Vector3 endR); //끝 지점의 양쪽 포인트를 구한다.

                float startV = currV + _flow; //시작 지점의 V. Flow 값을 더해준다.
                float endV = startV + distance * tiling / face / thickness; //끝 지점의 V. V값에 영향을 주는 요소들을 계산한다.

                stackMeshSquare(
                    new Vector3[] { startL, endL, endR, startR },
                    new Vector2[] { new Vector2(0f, startV), new Vector2(0f, endV), new Vector2(1f, endV), new Vector2(1f, startV) }); //면(사각형) 하나를 쌓는다.
                currV = endV - _flow; //다음 V값을 넘겨준다.
            }
        }

        calculateFlow();
        applyMesh();
    }
    private void calculateFlow()
    {
        _flow -= flowSpeed * Time.deltaTime; //Flow 속도를 적용한다.
        _flow = _flow > 1f ? _flow % 1f : _flow < 0f ? 1f - _flow + Mathf.Ceil(_flow) : _flow; //항상 0과 1사이의 값을 가지도록 한다.
    }

    //__________________________________________________________________________ Mesh
    private void stackMeshSquare(Vector3[] vertices, Vector2[] uvs)
    {
        int vertLen = _vertices.Count; //이전 정점의 개수를 가져온다.
        if (Cull.Front != (cull & Cull.Front)) //비트 연산으로 Front가 포함되지 않으면 시계방향으로 사각형을 쌓는다.
        {
            _triangles.Add(vertLen);
            _triangles.Add(vertLen + 1);
            _triangles.Add(vertLen + 2);

            _triangles.Add(vertLen + 2);
            _triangles.Add(vertLen + 3);
            _triangles.Add(vertLen);
        }
        if (Cull.Back != (cull & Cull.Back)) //비트 연산으로 Back이 포함되지 않으면 반시계방향으로 사각형을 쌓는다.
        {
            _triangles.Add(vertLen);
            _triangles.Add(vertLen + 3);
            _triangles.Add(vertLen + 2);

            _triangles.Add(vertLen + 2);
            _triangles.Add(vertLen + 1);
            _triangles.Add(vertLen);
        }

        for (int i = 0, l = vertices.Length; i < l; ++i) //정점을 추가한다.
            _vertices.Add(vertices[i]);

        for (int i = 0, l = uvs.Length; i < l; ++i) //UV를 추가한다.
            _uvs.Add(uvs[i]);
    }
    private void applyMesh()
    {
        if (_mesh == null) return;

        // 지금까지 쌓은 정점, UV, 사각형 데이터를 적용한다.
        if(_mesh.vertices.Length > _vertices.Count) //새로운 정점의 개수가 더 적으면
        {
            _mesh.triangles = _triangles.ToArray(); //Triangle 먼저 적용한다.
            _mesh.vertices = _vertices.ToArray();
            _mesh.uv = _uvs.ToArray();
        }
        else //새로운 정점의 개수가 더 많거나 같으면
        {
            _mesh.vertices = _vertices.ToArray(); //정점 & UV 먼저 적용한다.
            _mesh.uv = _uvs.ToArray();
            _mesh.triangles = _triangles.ToArray();
        }
        _mesh.RecalculateNormals(); //노멀 재계산
        _mesh.bounds = new Bounds(Vector3.zero, Vector3.one * float.MaxValue); //Bound를 최대로 지정하여 카메라에 컬링되지 않도록 한다.

        _vertices.Clear(); //정점 데이터 초기화
        _uvs.Clear(); //UV 데이터 초기화
        _triangles.Clear(); //삼각형 데이터 초기화
    }

    //__________________________________________________________________________ Formula
    public void GetPoints(Vector3 from, Vector3 to, float height, float t, float thickness, out Vector3 left, out Vector3 right) //포물선 공식으로 양쪽 포인트를 구하는 함수
    {
        Vector3 mid = GetPoint(from, to, height, t); //포물선 위치 저장
        Vector3 dir = Vector3.Scale(to - from, new Vector3(1f, 0f, 1f)); //From에서 To로 가는 방향벡터를 높이를 무시하고 구한다.
        Quaternion lookRot = Quaternion.LookRotation(dir); //방향 벡터를 바라보는 각도 저장
        left = mid + lookRot * Vector3.left * thickness * 0.5f; //왼쪽 포인트 반환
        right = mid + lookRot * Vector3.right * thickness * 0.5f; //오른쪽 포인트 반환
    }
    public Vector3 GetPoint(Vector3 from, Vector3 to, float height, float t) //포물선 공식 함수
    {
        Vector3 midPos = Vector3.Lerp(from, to, t); //중간 위치
        if(heightByDistance) height *= Vector3.Distance(from, to); //거리에 따라 높이를 조절
        float form = -4 * height * t * t + 4 * height * t; //포물선 공식
        return new Vector3(midPos.x, Mathf.Lerp(from.y, to.y, t) + form, midPos.z); //결과 위치 반환
    }
}

 

※ 대부분의 코드에 주석이 달려있어 분석하는데 유용할 것이다.

※ 이전 글에서 Mesh가 생성되는 원리를 이해하는 것을 권장한다.

 

포물선을 만드는 원리는 사각형을 여러개 이어주는 것이다.

필요한 사각형들을 stackMeshSquare 함수로 쌓고,

마지막에 applyMesh 함수로 반영해주는 것이다.

applyMesh 함수에서 새로운 정점의 개수를 판단하는 것은

Mesh 클래스 자체의 오류 때문이다.

만약, 새로운 정점의 개수가 더 적은 경우에 Vertices에 할당하면,

Triangles가 인덱스를 초과했다는 오류문구가 뜬다.

 

중요한 것은 매 프레임마다 UV를 변경해주는 것이다.

UV에 V값에 비례하는 것은 타일링, 두 지점의 거리이고,

반비례하는 것은 면의 개수, 선의 두께이다.

비례하는 것은 곱해주고, 반비례하는 것은 나눠주면,

항상 일정한 텍스쳐 모양이 나온다.

여기에 Flow 변수를 더해주면 흘러가는 형태를 표현할 수 있다.

 

stackMeshSquare 함수에서 사용한 비트 연산을 간단히 설명하겠다.

2진수로 Cull(Enum)을 표현하면 아래와 같다.

Off : 00(0)

Front : 01(1)

Back : 10(2)

Both : 11(3)

만약, Front이거나 Both이면 Front(01) And 연산후 01이므로 앞면을 그리지 않고,

Back이거나 Both이면 Back(10) And 연산후 10이므로 뒷면을 그리지 않는다.

Off이면 어떤 And 연산이든 00이므로 양면을 모두 그린다.


사용 방법

위 스크립트를 Inspector 창에서 보면 아래와 같다.

각 옵션에 대해 알아보자.

From, To : 시작 지점과 끝 지점을 나타낸다. 하나라도 없으면 아무것도 나타나지 않는다.

Face : 면의 개수이다. 클수록 부드러운 선이 된다.

Height : 선의 높이다.

Height By Distance : 거리에 따라 높이를 조절하는 옵션이다. 해제하면 높이가 유지된다.

Thickness : 선의 두께이다.

Cull : 컬링할 면을 선택할 수 있다.

Shader :  사용할 쉐이더이다. 쉐이더 경로를 적어주면 된다.

Texture : 사용할 텍스쳐이다.

Tiling : 텍스쳐 반복 정도이다. 클수록 촘촘해진다.

FlowSpeed : 흐르는 속도이다.


어찌보면 이 기능이 목표 지점을 가리키는 가이드라인으로 사용하기에도 좋을 것 같다.

추가로 이 글에서는 보여주지 못했지만 텍스쳐가 반투명이어도 된다.

설명이 잘못되었거나 질문이 있다면 언제든지 댓글을 달아주길 바란다.

댓글