본문 바로가기

유니티/Catlikecoding

[메시 기초] 둥근 큐브 렌더링 (Catlikecoding)

반응형

해당 글은 CatlikeCoding의 튜토리얼을 실습하며 번역해놓은 글입니다. 번역 과정에서 제가 내용을 덧붙이거나 삭제한 부분이 존재합니다. 원본의 링크는 글 하단에 있습니다.

 

 원본 글에 적혀있지 않은 내용을 추가할 때에는 (작성자 추가: ..내용)과 같은 형태로 작성하겠습니다.

 

요약

- 큐브에 둥근 모서리를 추가합니다.

- 법선을 정의합니다

- 서브 메쉬를 사용합니다

- 사용자 지정 셰이더를 만듭니다.

- 프리미티브 콜라이더를 결합합니다.

 

1. 큐브 합성하기 

 평범한 큐브는 그렇게 특별하지 않습니다. 둥근 큐브로 만들어 봅시다. 둥근 큐브로 만들어봅시다! 스크립트 파일과 클래스 이름을 RoundedCube로 변경합니다. Unity는 컴포넌트를 계속 추적하지만 연결이 끊어지면 스크립트를 다시 드래그해서 추가해주면 됩니다.

 

 또한 큐브가 얼마나 둥글게 되는지도 제어해야 하므로 이를 위해 'Roundness'필드를 public 가시성으로 추가합니다. 이 값은 1에서 큐브의 가장 작은 치수의 절반 사이의 값으로 설정해야 합니다.

 

 Unity가 노멀을 다시 계산하도록 할 수도 있지만 이번에는 직접 계산해보겠습니다. 인접한 삼각형의 평균을 구하는 대신 큐브에 적용할 roundness를 계산할 것이므로 이 접근방식이 더 효율적일 것입니다. 이제 법선 배열 필드를 추가합니다. 또한 이를 통해 노멀에 대한 기즈모를 그릴 수 있으므로 우리의 법선이 제대로 적용되었는지 확인할 수 있습니다.

 

    private void OnDrawGizmos () 
    {
        if (vertices == null) return;

        Gizmos.color = Color.black;
        for (int i = 0; i < vertices.Length; i++)
        {
            Gizmos.color = Color.black;
            Gizmos.DrawSphere(vertices[i], 0.1f);
            Gizmos.color = Color.yellow;
            Gizmos.DrawRay(vertices[i], normals[i]);
        }
    }

 

안에 또 다른 큐브가 숨어 있습니다.

 

 그렇다면 둥근 큐브의 꼭지점을 어떻게 배치하고 법선을 계산할까요? 이를 위한 전용 방법을 만들어 봅시다. 그렇다면 정점을 어떻게 배치할까요? 원래 큐브 안에 작은 큐브가 떠있다고 생각해봅시다. 이 두 큐브의 면 사이의 거리는 둥글기(Roundness)와 같습니다. 이 작은 큐브의 모서리에 구가 붙어있고 그 반지름이 둥글기와 같아서 내부 큐브가 단단히 고정되어 있다고 상상할 수 있습니다.

 

 둥근 큐브의 표면점은 안쪽 점에서 시작하여 법선을 따라 둥글기와 같은 양만큼 이동하여 찾을 수 있습니다. 이를 구하기 위해 안쪽 큐브의 점을 찾아야 합니다. 먼저 X좌표를 확인합니다. 둥글기보다 작으면 내부 큐브의 왼쪽에 있으며, 내부 X좌표는 단순히 둥글기의 값입니다. 그리고 큐브의 X크기에서 둥글기를 뺀 값을 초과하면 오른쪽에 있습니다. 다른 모든 경우에는 내부 큐브의 범위 내에 있으며 두 점 모두 동일한 X좌표를 공유합니다.

 

    private void CreateVertices()
    {
        ...
        for (int y = 0; y <= ySize; y++) 
        {
            for (int x = 0; x <= xSize; x++) 
                SetVertex(v++, x, y, 0);
            for (int z = 1; z <= zSize; z++) 
                SetVertex(v++, xSize, y, z);
            for (int x = xSize - 1; x >= 0; x--) 
                SetVertex(v++, x, y, zSize);
            for (int z = zSize - 1; z > 0; z--) 
                SetVertex(v++, 0, y, z);
        }
        
        for (int z = 1; z < zSize; z++) 
        {
            for (int x = 1; x < xSize; x++) 
                SetVertex(v++, x, ySize, z);
        }
        
        for (int z = 1; z < zSize; z++) 
        {
            for (int x = 1; x < xSize; x++) 
                SetVertex(v++, x, 0, z);
        }
        mesh.vertices = vertices;		
        mesh.normals = normals;
    }

    private void SetVertex(int i, int x, int y, int z)
    {		
        Vector3 inner = vertices[i] = new Vector3(x, y, z);

        if (x < roundness) {
            inner.x = roundness;
        }
        else if (x > xSize - roundness) {
            inner.x = xSize - roundness;
        }

        normals[i]  = (vertices[i] - inner).normalized;
        vertices[i] = inner + normals[i] * roundness;
    }

 

 지금까지 결과는 전혀 둥글게 보이지 않지만 이미 양수 및 음수 X방향에서 법선을 얻고 있습니다. Y와 Z좌표에 대해서도 동일한 검사를 수행합니다. 마침내 둥근 큐브가 완성되었습니다.

 

    private void SetVertex(int i, int x, int y, int z)
    {		
        Vector3 inner = vertices[i] = new Vector3(x, y, z);

        if (x < roundness) 
            inner.x = roundness;
        else if (x > xSize - roundness) 
            inner.x = xSize - roundness;
        if (y < roundness) 
            inner.y = roundness;
        else if (y > ySize - roundness)
            inner.y = ySize - roundness;
        if (z < roundness)
            inner.z = roundness;
        else if (z > zSize - roundness)
            inner.z = zSize - roundness;

        normals[i]  = (vertices[i] - inner).normalized;
        vertices[i] = inner + normals[i] * roundness;
    }

 

2. 메시 분할 

 중복되는 정점이 없는 단일 메시로 만든 멋진 둥근 정육면체가 있습니다. 이걸 어떻게 텍스처링할까요? 이를 위해서는 UV좌표가 필요하지만 매끄러운 래핑을 만들 방법이 없습니다. 그리고 이음새에는 중복 버텍스가 필요합니다.. 이를 위해 중복 정점을 사용하도록 변경할 수 있지만 다른 방법도 있습니다. 여러 개의 서브 메시를 사용하여 동일한 정점을 사용하는 별도의 트라이앵글 목록을 만들 수 있습니다. 이렇게 하면 각 트라이앵글 세트에 다른 메터리얼을 사용할 수 있습니다.

 

 메시를 세 쌍의 면으로 분할해 보겠습니다. 즉 세 개의 배열과 세 개의 삼각형 인덱스가 필요합니다. 이제 링 루프를 네 개의 세그먼트로 분할하여 Z와 X배열을 번갈아가며 쿼드를 생성해줍니다.

 

 탑과 바텀 면에는 간단하게 Y어레이를 사용합니다.

 

그리고 mesh.triangles에 3개의 서브메시를 생성하여 할당해줍니다.

 

    private void CreateTriangles()
    {		
        int quads = (xSize * ySize + xSize * zSize + ySize * zSize) * 2;
        int ring  = (xSize + zSize) * 2;
        int tZ    = 0, tX = 0, tY = 0, v = 0;
        
        int[] trianglesZ = new int[(xSize * ySize) * 12];
        int[] trianglesX = new int[(ySize * zSize) * 12];
        int[] trianglesY = new int[(xSize * zSize) * 12];

        for (int y = 0; y < ySize; y++, v++)
        {
            for (int q = 0; q < xSize; q++, v++)
                tZ = SetQuad(trianglesZ, tZ, v, v + 1, v + ring, v + ring + 1);
            for (int q = 0; q < zSize; q++, v++)
                tX = SetQuad(trianglesX, tX, v, v + 1, v + ring, v + ring + 1);
            for (int q = 0; q < xSize; q++, v++)
                tZ = SetQuad(trianglesZ, tZ, v, v + 1, v + ring, v + ring + 1);
            for (int q = 0; q < zSize - 1; q++, v++)
                tX = SetQuad(trianglesX, tX, v, v + 1, v + ring, v + ring + 1);
            tX = SetQuad(trianglesX, tX, v, v - ring + 1, v + ring, v + 1);
        }

        tY = CreateTopFace(trianglesY, tY, ring);
        tY = CreateBottomFace(trianglesY, tY, ring);

		mesh.subMeshCount = 3;
		mesh.SetTriangles(trianglesZ, 0);
		mesh.SetTriangles(trianglesX, 1);
		mesh.SetTriangles(trianglesY, 2);
    }

 

 우리의 메시는 세 조각으로 잘리고 첫 번째 메시만 실제 렌더링되고 있습니다. 메시 렌더러에 서브 메시당 하나씩 추가 메터리얼을 추가해서 할당해야 합니다. 이것이 바로 메터리얼 배열이 있는 이유입니다.

 

3. 그리드 렌더링

 이제 면을 구분할 수 있지만 텍스처 좌표는 아직 없습니다. 큐브 전체에 격자 패턴을 표시하여 개별 사분면을 볼 수 있도록 하려고 한다고 가정해보겠습니다. 어떻게 할 수 있을까요? UV좌표를 메쉬에 저장하는 대신 커스텀 셰이더를 사용하여 텍스쳐를 적용하는 방법을 알아보겠습니다. 다음은 새로 만든 셰이더입니다.

 

Shader "Custom/Rounded Cube Grid" {
	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 200
		
		CGPROGRAM
		#pragma surface surf Standard fullforwardshadows
		#pragma target 3.0

		sampler2D _MainTex;

		struct Input {
			float2 uv_MainTex;
		};

		half _Glossiness;
		half _Metallic;
		fixed4 _Color;

		void surf (Input IN, inout SurfaceOutputStandard o) {
			fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}
		ENDCG
	} 
	FallBack "Diffuse"
}

 

 기본 surface 셰이더입니다. 중요한 점은 메인 텍스쳐의 좌표를 예상하는 입력 구조를 정의한다는 것입니다. 이 좌표는 각 프래그먼트가 렌더될 때 호출되는 surf 함수에서 사용됩니다. 우리는 이러한 좌표가 없으므로 uv_MainTex를 다른 것으로 대체해야 합니다.

 

		struct Input {
			float2 cubeUV;
		};
        
        ....

		void surf (Input IN, inout SurfaceOutputStandard o) {
			fixed4 c = tex2D(_MainTex, IN.cubeUV) * _Color;
			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}

 

UV는 버텍스별로 정의되므로 버텍스별로 호출되는 함수를 추가해야 합니다. 셰이더가 작동하는지 확인하려면 버텍스 위치의 XY좌표를 UV로 직접 사용하는 것부터 시작합니다.

 

		#pragma surface surf Standard fullforwardshadows vertex:vert
        
        ....
		
		void vert (inout appdata_full v, out Input o) {
			UNITY_INITIALIZE_OUTPUT(Input, o);
			o.cubeUV = v.vertex.xy;
		}

 

 이것은 Z면에 대해서는 합리적으로 작동하지만 다른 면은 엉망입니다. 다른 버텍스 좌표를 사용해야 합니다. 셰이더의 열거형 프로퍼티를 추가하여 어떤 면인지 선택할 수 있는 키워드를 추가할 수 있습니다.

 

	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
		[KeywordEnum(X, Y, Z)] _Faces ("Faces", Float) = 0
	}

 

 어떤 옵션을 선택하느냐에 따라 Unity는 메터리얼에 대한 커스텀 셰이더 키워드를 활성화합니다. 셰이더에 지원하고자 하는 각 키워드에 대한 버전을 생성하도록 지시해야 합니다.

 

		CGPROGRAM
		#pragma shader_feature _FACES_X _FACES_Y _FACES_Z
		#pragma surface surf Standard fullforwardshadows vertex:vert
		#pragma target 3.0

 

 어떤 키워드가 정의되어 있는지 확인할 수 있으므로 각 옵션에 대해 다른 코드를 작성할 수 있습니다.

 

		void vert (inout appdata_full v, out Input o) {
			UNITY_INITIALIZE_OUTPUT(Input, o);
			#if defined(_FACES_X)
				o.cubeUV = v.vertex.yz;
			#elif defined(_FACES_Y)
				o.cubeUV = v.vertex.xz;
			#elif defined(_FACES_Z)
				o.cubeUV = v.vertex.xy;
			#endif
		}

 

 이제 괜찮아보이지만 텍스쳐가 실제 쿼드에 맞지 않습니다. 더 큰 문제는 월드 스페이스 버텍스 위치를 사용하기 때문에 큐브를 움직이거나 회전하면 상황이 이상해질 수 있습니다.

 

 큐브가 둥글게 되기 전의 원래 큐브의 정점 위치가 필요합니다. 이를 어떻게든 메쉬에 저장할 수 있다면 셰이더에 전달할 수 있습니다. 버텍스 컬러를 사용하지 않으므로 버텍스 컬러 채널을 이 용도로 사용할 수 있습니다.

 

    private Color32[] cubeUV;

    private void CreateVertices()
    {
    	...
        vertices = new Vector3[cornerVertices + edgeVertices + faceVertices];
        normals  = new Vector3[vertices.Length];
        cubeUV   = new Color32[vertices.Length];
        
        ...
        
        mesh.vertices = vertices;		
        mesh.normals  = normals;
        mesh.colors32 = cubeUV;
    }

    private void SetVertex(int i, int x, int y, int z)
    {	
    	...

        normals[i]  = (vertices[i] - inner).normalized;
        vertices[i] = inner + normals[i] * roundness;
        cubeUV[i]   = new Color32((byte)x, (byte)y, (byte)z, 0);
    }

 

 버텍스 컬러 컴포넌트는 단일 바이트로 저장되기 때문에 여기서는 일반적인 Color유형 대신 Color32를 사용해야 합니다. 전체 색상은 4바이트로, 하나의 실수와 같은 크기입니다.

 

 일반 컬러를 사용하셨다면 Unity는 0-1 floats에서 0-255 바이트로 변환하여 해당 범위를 벗어나는 모든 값을 잘라냅니다. 바이트로 변환하면 최대 255까지 값을 이용하여 큐브 크기를 처리할 수 있으므로 예제를 구현하기에 충분합니다.

 

 셰이더에서는 이제 정점의 포지션 대신 버텍스 컬러를 사용할 수 있습니다. 셰이더는 버텍스 컬러 채널을 0-1범위의 값으로 해석하므로 255를 곱하여 0-255 의 범위로 값을 변환해야 합니다.

 

		void vert (inout appdata_full v, out Input o) {
			UNITY_INITIALIZE_OUTPUT(Input, o);
			#if defined(_FACES_X)
				o.cubeUV = v.color.yz * 255;
			#elif defined(_FACES_Y)
				o.cubeUV = v.color.xz * 255;
			#elif defined(_FACES_Z)
				o.cubeUV = v.color.xy * 255;
			#endif
		}

 

4. 콜라이더 추가

 큐브는 던질 수 있을 때 재미있으며, 이를 위해서는 물리학과 콜라이더가 필요합니다. 안타깝게도 메시 콜라이더는 블록 콜라이더의 폴리곤 수 제한에 금방 부딪히기 때문에 실제로 작동하지 않습니다. 하지만 걱정하지 마세요. 프리미티브 콜라이더를 사용하여 완벽히 둥근 큐브를 만들 수 있습니다. 이를 위한 메서드를 추가해 보겠습니다.

 

    private void Generate()
    {
        GetComponent<MeshFilter>().mesh = mesh = new Mesh();
        mesh.name = "Procedural Cube";
        CreateVertices();
        CreateTriangles();
        CreateColliders();
    }
    
    private void CreateColliders () { }

 

 생성된 메시에 박스 콜라이더를 추가해보면 Unity는 메시의 바운딩 박스와 일치하도록 콜라이덜들 배치하고 스케일을 조정할 수 있을 만큼 똑똑하다는 것을 알 수 있습니다. 이제 둥근 큐브의 각 평평한 면과 일치하도록 콜라이더를 추가하고 콜라이더의 스케일을 조정하겠습니다. 세 개의 면 쌍 모두에 대해 이 작업을 수행해야 하므로 결국 세 개의 교차하는 블록이 생깁니다.

 

(작성자 추가: 법선 렌더링이 불필요해보여서 제거하였습니다)

    private void CreateColliders () 
    {
        AddBoxCollider(xSize, ySize - roundness * 2, zSize - roundness * 2);
        AddBoxCollider(xSize - roundness * 2, ySize, zSize - roundness * 2);
        AddBoxCollider(xSize - roundness * 2, ySize - roundness * 2, zSize);
    }
	
    private void AddBoxCollider (float x, float y, float z) {
        BoxCollider c = gameObject.AddComponent<BoxCollider>();
        c.size = new Vector3(x, y, z);
    }

 

 캡슐을 사용하여 가장자리와 모서리를 채울 수 있씁니다. 올바른 방향을 지정하고 각 엣지의 중앙에 배치해야 합니다. 엣지당 캡슐이 하나 필요하므로 총 12개가 필요합니다. 배치를 더 쉽게 하기 위해 최소, 절반, 최대 벡터를 만들겠습니다.

 

    private void CreateColliders () 
    {
        AddBoxCollider(xSize, ySize - roundness * 2, zSize - roundness * 2);
        AddBoxCollider(xSize - roundness * 2, ySize, zSize - roundness * 2);
        AddBoxCollider(xSize - roundness * 2, ySize - roundness * 2, zSize);

        Vector3 min  = Vector3.one * roundness;
        Vector3 half = new Vector3(xSize, ySize, zSize) * 0.5f; 
        Vector3 max  = new Vector3(xSize, ySize, zSize) - min;

        AddCapsuleCollider(0, half.x, min.y, min.z);
        AddCapsuleCollider(0, half.x, min.y, max.z);
        AddCapsuleCollider(0, half.x, max.y, min.z);
        AddCapsuleCollider(0, half.x, max.y, max.z);
		
        AddCapsuleCollider(1, min.x, half.y, min.z);
        AddCapsuleCollider(1, min.x, half.y, max.z);
        AddCapsuleCollider(1, max.x, half.y, min.z);
        AddCapsuleCollider(1, max.x, half.y, max.z);
		
        AddCapsuleCollider(2, min.x, min.y, half.z);
        AddCapsuleCollider(2, min.x, max.y, half.z);
        AddCapsuleCollider(2, max.x, min.y, half.z);
        AddCapsuleCollider(2, max.x, max.y, half.z);
    }
    private void AddCapsuleCollider (int direction, float x, float y, float z) 
    {
        var c = gameObject.AddComponent<CapsuleCollider>();
        c.center    = new Vector3(x, y, z);
        c.direction = direction;
        c.radius    = roundness;
        c.height    = c.center[direction] * 2f;
    }

 

 

 

 

Rounded Cube, a Unity C# Tutorial

A Unity C# scripting tutorial in which we'll create a rounded cube.

catlikecoding.com

반응형