목표
유니티에서는 Debug.DrawLine() 을 통해 쉽게 선을 그려 볼 수 있습니다.
하지만 위치에 대한 정보를 바탕으로 점을 찍어 보려니 그 방법을 쉽게 찾을 수 없었습니다.
3D 공간에서 필요한 위치에 점을 찍는 방법을 만들어 둔다면 나중에 유용하게 활용할 수 있을 것이라 생각했습니다.
예를 들면 함수의 그래프 개형을 그려본다거나, 물체의 자취를 찍어본다거나 등에서 활용해 볼 수 있겠죠.
- mesh의 vertices에 각 점들의 위치 정보를 담는다.
- geometry shader 를 이용하여 점들의 크기와 모양을 변경할 수 있도록 한다.
- procedural mesh를 통해 원하는 위치 정보를 담을 수 있도록 한다.
MeshTopology.Points 변경
가장 먼저 점을 찍을 수 있는 방법으로 든 생각은 topology를 point로 변경하는 것이었습니다.
나중에 procedural mesh를 통해 원하는 mesh 데이터를 저장할 수 있으므로
우선 원, 큐브 등의 이미 만들어져 있는 오브젝트의 topology를 변경해보겠습니다.
MeshFilter meshFilter = GetComponent<MeshFilter>();
meshFilter.mesh.SetIndices(meshFilter.mesh.GetIndices(0), MeshTopology.Points, 0);
MeshFilter.mesh.SetIndices() 의 2번째 인자로 MeshTopology.Points를 넘겨 메쉬를 점으로 만듭니다.
점의 크기가 작고, 화면에 크기가 고정되어 있습니다. 때에 따라 이런 경우가 유용할 수 있겠지만 지금 제가 원하는 것이 아닙니다.
점의 크기를 키우기 위한 방법이 필요합니다.
Shader 작성
파티클을 만들듯이 메쉬를 통해 위치 정보가 들어왔고, 점의 크기를 키워주기 위해서는 사각형 쿼드를 새로 그려주어야 합니다.
쿼드를 만들기 위한 새로운 정점를 생성하는 역할은 geometry shader에서 수행합니다.
출처: 그래픽 파이프라인 - UWP applications | Microsoft Learn
geometry shader는 vertext shader와 fragment shader(pixel shader) 사이에서 수행됩니다.
vertex shader에서 받아온 정보를 geometry shader에서 가공하는데, 정점을 없앨 수도있고 더 많은 정점을 만들어 낼 수도 있습니다.
가공된 정점 데이터들은 레스터라이저 단계를 거쳐 fragment shader로 넘어갑니다.
데이터 정의
struct appdata // vertex input
{
float4 pos : POSITION;
};
struct v2g // vertex to geometry
{
float4 pos : POSITION;
};
struct g2f // geometry to fragment
{
float4 position : SV_POSITION;
float2 uv : TEXCOORD;
};
float _Scale; // 그려질 점의 크기
기존에는 vertex shader 에서 바로 fragment shader로 넘어갔지만
이번에는 중간에 geometry shader 단계가 껴있으므로 이에 맞게 각각 셰이더의 입출력 데이터를 정의합니다.
vertex shader에는 위치 정보만이 필요하므로 v2g 까지는 위치 정보만 담습니다.
원 모양의 점으로 렌더링하기 위해 geometry shader에서 uv 좌표도 생성하여 fragment shader로 보냅니다.
Vertex Shader
우선 위치 정보가 담긴 정점 데이터를 vertex shader에서 처리합니다.
모델 좌표계에서 정의된 정점 데이터이므로 좌표계 변환을 해주어야 합니다.
이때 View 좌표계 까지만 변환합니다.
geometry shader에서 만들어진 쿼드가 빌보드로 항상 카메라를 바라봐야 하기 때문에
새로운 정점을 생성하기 전에 view 좌표계로 변환 후 카메라 방향과 수직인 사각형 정점 정보를 생성하도록 합니다.
또 projection 변환은 하지 않았는데, 그려질 점의 크기가 원근법에 의해 멀수록 작아지게 만들기 위함입니다.
view에서 동일한 크기의 쿼드가 만들어진 후 projection 변환을 통해 원근감을 얻게 될 것입니다.
vertex shader에서 geometry shader로 넘어갈 정보는 변환된 위치 정보 뿐입니다.
v2g vert (appdata v)
{
v2g o;
float4 positionOS = v.pos;
float4 positionWS = mul(UNITY_MATRIX_M, float4(positionOS.xyz, 1));
float4 positionVS = mul(UNITY_MATRIX_V, positionWS);
// view 좌표계까지만 변환한다.
// float4 positionCS = mul(UNITY_MATRIX_P, positionVS);
// o.pos = UnityObjectToClipPos(v.pos);
o.pos = positionVS;
return o;
}
Geometry shader
vertex shader에서 넘어온 데이터를 기준으로 사각형의 쿼드를 생성해야 합니다.
프로퍼티로 사각형의 크기를 받아 그 크기만큼의 사각형을 만듭니다.
쿼드를 생성되었다면, projection 행렬을 곱해 변환 해줍니다.
clip space로 최종 변환된 위치 정보를 다음 단계로 넘겨주어야 하는데, 사각형을 그대로 넘기는 것이 아닌 2개의 삼각형으로 나누어 보내줍니다.
이때 fragment shader에서 작업하기 위한 uv 좌표 정보도 추가하여 넘겨줍니다.
[maxvertexcount(6)]
void geom (point v2g input[1], inout TriangleStream<g2f> outputStream)
{
g2f output[4] =
{
(g2f)0, (g2f)0, (g2f)0, (g2f)0
};
v2g v = input[0];
float scale = _Scale;
// View Space
output[0].position = v.pos + float4(-scale, scale, 0.f, 0.f);
output[1].position = v.pos + float4(scale, scale, 0.f, 0.f);
output[2].position = v.pos + float4(scale, -scale, 0.f, 0.f);
output[3].position = v.pos + float4(-scale, -scale, 0.f, 0.f);
// Projection Space
output[0].position = mul(UNITY_MATRIX_P, output[0].position);
output[1].position = mul(UNITY_MATRIX_P, output[1].position);
output[2].position = mul(UNITY_MATRIX_P, output[2].position);
output[3].position = mul(UNITY_MATRIX_P, output[3].position);
output[0].uv = float2(0.f, 0.f);
output[1].uv = float2(1.f, 0.f);
output[2].uv = float2(1.f, 1.f);
output[3].uv = float2(0.f, 1.f);
outputStream.Append(output[0]);
outputStream.Append(output[1]);
outputStream.Append(output[2]);
outputStream.RestartStrip();
outputStream.Append(output[0]);
outputStream.Append(output[2]);
outputStream.Append(output[3]);
outputStream.RestartStrip();
}
geometry shader는 익숙하지 않기 때문에 하나하나 살펴봅시다.
[maxvertexcount(6)]
생성 혹은 삭제를 통해 geometry shader에서 나올 수 있는 최대 정점의 개수를 나타냅니다.
우리는 두개의 삼각형을 나타내기 때문에 최대 6개의 정점이 필요합니다.
void geom (point v2g input[1], inout TriangleStream<g2f> outputStream)
굉장히 생소한 부분입니다.
우선 `point v2g input[1]` 부터 봅시다.
v2g 타입의 데이터를 point로 간주하여 input 이라는 이름으로 1개의 길이를 받습니다.
다시말하면 하나의 point를 그리기 위한 v2g 타입의 input 데이터 1개를 입력으로 받는 것입니다.
만약 vertex shader에서 넘어온 데이터가 point가 아니라 일반적인 삼각형이었다면 `triangle v2g input[3]` 이렇게 받을 수 있겠죠.
다음으로 `inout TriangleStream<g2f> outputStream` 입니다.
geometry shader의 반환형은 void 입니다. 그렇기에 가공한 데이터를 다음 단계로 넘겨주기 위한 다른 방법이 필요한데, 그것이 inout 입니다.
우리가 레스터라이저, fragment shader 단계로 넘길 데이터는 point가 아닌 2개의 삼각형입니다.
3개의 정점이 어떻게 삼각형을 이루는지 표현하기 위해 TriangleStream 객체가 필요합니다.
각 정점에 projection 행렬을 곱한 뒤
삼각형을 이루는 3개의 정점을 TriangleStream에 append 한 후
RestartStrip() 을 호출하여 새로운 삼각형이 시작됨을 나타냅니다.
조금 더 자세한 설명은 Geometry-Shader Object - Win32 apps | Microsoft Learn 에서 확인할 수 있습니다.
Fragment Shader
복잡한 것 없이 색상을 결정합니다.
하지만 geometry shader에서 만든 쿼드를 그대로 칠한다면 사각형이 렌더링 되어 보여집니다.
우리가 원했던건 원이기 때문에 geometry shader에서 넘겨준 uv 정보를 이용하여 원을 만들어 줍니다.
fixed4 frag (g2f input) : SV_Target
{
if ( length(input.uv - float2(0.5f, 0.5f)) > 0.5 )
discard;
return _Color;
}
uv 정보가 (0, 0) ~ (1, 1) 의 사각형 좌표를 가지고 있기 때문에 중심(0.5, 0.5) 좌표로 부터 0.5 이상 떨어진 픽셀들은 버립니다.
즉, 사각형 크기에 맞는 원을 그리게 됩니다.
Procedural Mesh
이제 cube, sphere, capsule 처럼 이미 만들어진 정점 데이터 대신 직접 만들어 vertex shader에 넘길 수 있도록 procedural mesh를 이용해봅시다.
유니티에서 정점 데이터는 MeshFilter.mesh가 가지고 있습니다.
정점의 좌표는 vertices 배열로 관리하고 인덱스는 triangles, indices 배열로 관리합니다.
삼각형 메쉬를 만든다면 순서에 맞게 triangles에 인덱스를 넣어야 하지만, 우리는 단순히 point만 만들기 때문에 indices에 인덱스를 넣습니다. ( + triangles 는 항상 vertices.Length * 3 이어야 한다.)
void SetMeshData(int numPoints)
{
vertices = new Vector3[numPoints];
// 각 점들의 위치 정보 생성 후 vertices 에 저장
for (int i=0; i<numPoints; ++i)
{
vertices[i] = new Vector3(x, y, z);
}
indices = new int[numPoints];
// indices는 단순하게 생성된 순서대로 저장
for (int i = 0; i < numPoints; ++i)
{
indices[i] = i;
}
}
void CreateProceduralMeshPoint()
{
mesh.Clear();
mesh.vertices = vertices;
// mesh.triangles = indices;
// 삼각형을 만들 때 mesh.triagles의 배열 길이는 vertices.Length * 3 이어야 한다.
mesh.SetIndices(indices, MeshTopology.Points, 0);
}
vertices와 indices에 값을 저장하는 `SetMeshData()`,
값이 저장된 vertices와 indices를 MeshFilter.mesh에 저장하는 `CreateProceduralMeshPoint()`를 만들고 정의합니다.
만약 삼각형을 만든다면 indices 부분이 저렇게 단순하지 않고 시계방향 순서에 맞게 저장해야 합니다.
메쉬데이터를 업데이트 할 때 두 함수를 순서대로 호출합니다.
예시코드
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class polygonPoint : MonoBehaviour
{
public float size = 1.0f;
public int numPoints = 3;
public float turnFraction;
public float angle = 180f;
Mesh mesh;
Vector3[] vertices;
int[] indices;
private void OnValidate()
{
if (mesh == null)
return;
if (size > 0 && numPoints >= 1)
{
SetMeshData(size, numPoints);
CreateProceduralMeshPoint();
}
}
void Start()
{
mesh = GetComponent<MeshFilter>().mesh;
SetMeshData(size, numPoints);
CreateProceduralMeshPoint();
}
void SetMeshData(float size, int numPoints)
{
vertices = new Vector3[numPoints];
float thetaRange = Mathf.Cos(angle * Mathf.Deg2Rad);
for (int i = 0; i < numPoints; ++i)
{
float t = numPoints == 1 ? 0 : i / (numPoints - 1f);
float inclination = Mathf.Acos(1 - (1 - thetaRange) * t);
float azimuth = 2 * Mathf.PI * i * turnFraction;
float x = size * Mathf.Sin(inclination) * Mathf.Cos(azimuth);
float y = size * Mathf.Sin(inclination) * Mathf.Sin(azimuth);
float z = size * Mathf.Cos(inclination);
vertices[i] = new Vector3(x, y, z);
}
indices = new int[numPoints];
for (int i = 0; i < numPoints; ++i)
{
indices[i] = i;
}
}
void CreateProceduralMeshPoint()
{
mesh.Clear();
mesh.vertices = vertices;
mesh.SetIndices(indices, MeshTopology.Points, 0);
}
}
구현 결과
참고
CatDarkGames. Game Dev Story :: 유니티 빌보드 쉐이더 구현 & 분석 (tistory.com)
(Unity Shader) 00 Geometry shader (walll4542.wixsite.com)
Geometry-Shader Object - Win32 apps | Microsoft Learn
[Unity3D] Intro to Geometry Shader – Jingyu Liu (wordpress.com)
유니티 - 절차적 메시로 삼각형 그리기 (Make Triangle with Procedural Mesh) (tistory.com)
[Unity 쉐이더 입문] 폴리곤을 점으로 표현하기 (tistory.com)
'Unity' 카테고리의 다른 글
[Unity] 장애물 회피 (Obstacle Avoidance) (0) | 2024.03.01 |
---|