자료조사를 해보니, 아웃라인 셰이더를 구현하는 방법은 크게 3가지 정도가 있었다.
구현이 간단하고 자료도 많은 2Pass 방법을 사용해보기로 했다!
2Pass라고 불리는 이유는, 셰이더에 Pass를 하나 더 줘서 2개의 오브젝트를 그리기 떄문이다. (원본 메시, 외곽선 메시)
원본 정점들의 스케일을 키우고, 뒷면만 그리도록 설정했다.
(Graph Settings
에서 Render Face
를 Back
으로 설정)
이 방법은 구현이 너무너무너무 간단하지만, 그 만큼 큰 문제가 있었다.
왼쪽의 큐브처럼 로컬좌표의 원점이 메시의 중심점과 같은 경우에는 문제가 없지만, 다른 경우에는 원점에서 먼 정점일 수록 스케일 값을 크게 받아서 원본 메시와의 정점 위치가 어긋나버린다.
현재 프로젝트에 사용중인 대부분의 오브젝트는 로컬좌표의 원점이 메시의 중심점과 다르기 때문에 대신에 다른 방법을 찾아야했다.
현재 정점을 법선 방향으로 이동시키도록 수정했다.
이렇게 하니, 로컬좌표의 중심이 어디던 상관없어졌지만, 아웃라인 메시가 이어지지 않아서 아웃라인처럼 안보인다.
이 현상은 큐브를 예로 들면, 한 쪽 모서리 정점 당 3개의 법선 벡터를 갖고있기 때문에 발생한 현상이다.
버텍스 셰이더에서 정점을 하나는 위(y)로만, 하나는 오른쪽(x)으로만, 하나는 앞(z)으로만 이동시켰기 때문에, 이렇게 이동한 정점들은 서로 연결이 되지 않는다.
따라서, 같은 정점끼리는 하나의 노말을 사용하도록 바꿔줘야한다.
위의 현상을 해결하기 위한 방법이 Smooth Normal
이다.
하나의 정점에 있는 여러 법선들을 평균을 내서 하나의 법선으로 합친다.
단, 이 기능을 원본 메시에도 적용하면, 라이팅 쪽에서 이상하게 보일 수 있기 때문에 외곽선 메시에만 적용해야할 것 같다.
public static Vector3[] CalcaultateSmoothNormal(this Mesh mesh)
{
Dictionary<Vector3, List<int>> indicesDict = new();
for (int i = 0; i < mesh.vertexCount; i++)
{
if (!indicesDict.ContainsKey(mesh.vertices[i]))
indicesDict.Add(mesh.vertices[i], new());
indicesDict[mesh.vertices[i]].Add(i);
}
Vector3[] normals = mesh.normals;
foreach (var indices in indicesDict.Values)
{
Vector3 normal = Vector3.zero;
foreach (var index in indices)
normal += mesh.normals[index];
normal /= indices.Count;
foreach (var index in indices)
normals[index] = normal;
}
return normals;
}
하나로 합쳐진 법선벡터의 배열을 반환해주는 확장 메서드를 작성했다.
private void Initialize()
{
_outlineObject = new($"[Outline] {gameObject.name}");
_outlineObject.transform.SetParent(transform);
_outlineObject.transform.localPosition = Vector3.zero;
_outlineObject.transform.localRotation = Quaternion.identity;
_outlineObject.transform.localScale = Vector3.one;
var outlineMesh = Instantiate(GetComponent<MeshFilter>().sharedMesh);
outlineMesh.normals = outlineMesh.CalcaultateSmoothNormal();
_outlineObject.AddComponent<MeshFilter>().sharedMesh = outlineMesh;
var outlineRenderer = _outlineObject.AddComponent<MeshRenderer>();
outlineRenderer.material = Managers.Resource.GetCache<Material>("OutlineMaterial.mat");
}
MeshFilter
와 MeshRenderer
를 가지고 있는 게임오브젝트에 추가하면 스무스 노말을 계산한 뒤 아웃라인을 그려주는 오브젝트를 자식 객체로 생성해주는 컴포넌트를 작성했다.
원본 객체의 Mesh를 Instantiate해서, SmoothNormal을 적용한 다음 아웃라인 객체의 Mesh로 넣어줬다.
아웃라인 머티리얼은 어드레서블에 등록해놓고, 리소스 매니저를 이용해 할당받았다.
꽤나 그럴싸한 아웃라인 셰이더가 됐다 !
구현을 하다보니 스무스 노말을 계산해서 적용한 새로운 메시를 할당해야하는 구조가 됐다.
따라서 최적화를 생각하지 않을 수가 없다.
지금 생각나는 최적화 방법은 세 가지 정도 있다.
2번과 3번 방법을 짬뽕해서 스무스 노말의 계산은 JobSystem을 이용하고, 한 번 계산된 아웃라인 메시를 캐싱해뒀다가 쓰는 방법으로 최적화를 할 것 같다.
ex) Dictionary<originMesh, outlineMesh>
https://blog.naver.com/mnpshino/221495979665
https://lightbakery.tistory.com/34
https://ssan.tistory.com/17
https://roomdev-diary.tistory.com/9