발단
유니티에서 사용한다고 들은 Tangent Curve 라는 것은 무엇을 의미하는 걸까?
유니티의 Curve는 Evaluate 함수가 네이티브 c++ 동적 라이브러리로 구현이 숨겨져 있기 때문에 유니티 소스코드 결제를 해서 보지 않으면(사실상 안 된다는 거지...) 어떻게 구성되어 있는지 알 수 없다.
그래서 공개된 정보를 토대로 어떤 식으로 동작하고 있을지 추측 및 직접 구현하여 사용하는 과정을 다루고자 한다.
배경 지식 함양
Curve
두 점이 있는데, 두 점을 연속적으로 잇고 싶을 때 사용한다. 컴퓨터는 정량적, 또는 이산적 데이터를 다루게 되면서 여러 한계가 생기게 된다.
예를 들어 이미지 파일을 확대할 경우 픽셀의 크기가 정해져 있기 때문에 화질이 깨질 수 밖에 없다. 그렇다면 서로 인접한 애들끼리의 관계가 수학적으로 정의될 경우, 확대를 하더라도 수식 연산을 통해 사이에 비어있는 부분을 알아낼 수 있다.
유니티의 Curve도 이러한 니즈를 위해 사용하게 된다. 보통 직관적으로 생각할 수 있는 건 Animation 키프레임 사이의 보간 처리이다.
게임의 FPS는 컴퓨터의 스펙에 따라 변동할 수 있고, 애니메이션 데이터를 매프레임 모두 저장할 경우 저장용량이 커지게 된다. 그렇기 때문에 키프레임과 다음 키프레임을 수학적 곡선을 이용해 보간 처리를 하도록 하면 프레임이 증가하더라도 애니메이션이 아티스트가 의도한 결과를 낼 수 있도록 만들 수 있다.
Curve의 구현
인터넷에 찾아보면 Bezier Curve를 대표로 해서, Hermite Spline이라던가 하는 애들이 보인다. 뭐 일단 두 점 사이를 연결할 때 접선 정보를 구한다던지, 아니면 사이의 점들 몇 개가 더 필요하다던지 하는 건 알겠는데, 문제는 점이 무한정 늘어나더라도 커버가 되는 일반적인 클래스를 어떻게 만들 것이냐 라는 문제를 해결하지 못하고 있었다.
그래서 일단 Curve 클래스를 구현하는 것 부터 시작하기로 한다. 물론 Curve라는 것이 매우 많은 성현(?)들께서 이미 열심히 만들어 주셨기 때문에 맨땅에 헤딩하기 보단 깃헙을 탐험해서 어떤 느낌으로 만들어 놓았는지 찾아보았다.
https://github.com/d3cr1pt0r/UnityBezierCurves/blob/master/Assets/Scripts/BezierCurve.cs
그러다 위 링크를 찾아서 보았고, 내가 커브라는 것을 너무 어렵게 생각하고 있었음을 알아차렸다. 단순히 두 점 사이를 어떤 데이터를 가지고 보간할 지가 결정이 된다면, 점이 몇 개가 있든 인접한 두 점에 대해서 계산하기만 하면 된다는 사실을 왜 모르고 있었을까...
즉, 3개의 점이 있을 경우, 인덱스 0,1,2 라고 3개의 점을 정의했을 때 0,1 사이를 보간하는 Curve 연산과 1,2 사이를 보간하는 Curve 연산을 해서 연결하면 0~1~2 이렇게 3개 점이 모두 이어진 곡선을 얻을 수 있다는 개념이다.
유니티의 Curve 구현
그래서 결국 유니티는 무슨 커브를 썼다는 것이냐! 이 질문에 대한 답은 원래라면 못 찾을 뻔 했으나, PostProcessing 시 사용하는 Color Curve의 Inspector를 그리는 부분이 공개되어 있었다.
경로는 Packages/com.unity.render-pipelines.core/Editor/InspectorCurveEditor.cs 이다.
Curve를 화면에 그리기 위한 OnCurveGUI 함수를 보면, 다음과 같은 코드가 있음을 확인할 수 있다.
var prevKey = keys[0];
for (int k = 1; k < length; k++)
{
var key = keys[k];
var pts = BezierSegment(prevKey, key);
if (float.IsInfinity(prevKey.outTangent) || float.IsInfinity(key.inTangent))
{
var s = HardSegment(prevKey, key);
Handles.DrawAAPolyLine(state.width, s[0], s[1], s[2]);
}
else Handles.DrawBezier(pts[0], pts[3], pts[1], pts[2], color, null, state.width);
prevKey = key;
}
즉, Tangent Curve니 뭐니 했었는데 유니티도 결국 BezierCurve를 이용해서 구현을 했다는 말씀이다. 물론 확인할 방도는 없다.
유니티는 그렇다면 어떻게 Tangent의 개념을 BezierCurve에 녹여냈을까? BezierSegment 함수를 살펴보자.
Vector3[] BezierSegment(Keyframe start, Keyframe end)
{
var segment = new Vector3[4];
segment[0] = CurveToCanvas(new Vector3(start.time, start.value));
segment[3] = CurveToCanvas(new Vector3(end.time, end.value));
float middle = start.time + ((end.time - start.time) * 0.333333f);
float middle2 = start.time + ((end.time - start.time) * 0.666666f);
segment[1] = CurveToCanvas(new Vector3(middle, ProjectTangent(start.time, start.value, start.outTangent, middle)));
segment[2] = CurveToCanvas(new Vector3(middle2, ProjectTangent(end.time, end.value, end.inTangent, middle2)));
return segment;
}
두 개의 점의 1/3, 2/3에 해당하는 가상의 Point 2개를 계산하고 있었다. 왼쪽 점은 outTangent, 오른쪽 점은 inTangent를 이용하여 가상의 점을 구해낸 것이다.
4개의 점을 이용해 곡선을 구성하기 때문에 BezierCurve 중에서도 3차 베지어 곡선인 CubicCurve를 이용하고 있음을 추측할 수 있었다.
Custom Curve 클래스
그러면 이제 유니티의 커브가 아니라 나만의 커브 클래스를 만들어 사용해보자.
먼저 정의해야 하는 것은 Curve를 구성하고 있는 점에 대한 정의이다. 점은 현재 time 값과 이에 해당하는 value 값으로 구성되어 있으며, 왼쪽 기울기인 inTangent 값과 오른쪽 기울기인 outTangent로 구성된다.
[Serializable]
public struct Point
{
public float time;
public float value;
public float left;
public float right;
}
그리고 Curve 클래스는 단순히 이 point를 저장하는 List나 Array를 가지고 있으면 된다. 물론 여기에 헬퍼 함수도 몇 개 더 붙여야 하는 건 당연하다.
[Serializable]
public class Curve
{
public List<Point> points = new List<Point>();
public Vector3 BezierCurveLinear(Vector3 p0, Vector3 p1, float t)
{
return (1.0f - t) * p0 + t * p1;
}
public Vector3 QuadraticCurve(Vector3 p0, Vector3 p1, Vector3 p2, float t)
{
float nt = 1.0f - t;
return Mathf.Pow(nt, 2) * p0 + 2.0f * nt * t * p1 + Mathf.Pow(t, 2) * p2;
}
public Vector3 CubicCurve(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t)
{
float nt = 1.0f - t;
return Mathf.Pow(nt, 3) * p0 + 3.0f * Mathf.Pow(nt, 2) * t * p1 + 3.0f * nt * Mathf.Pow(t, 2) * p2 +
Mathf.Pow(t, 3) * p3;
}
float ProjectTangent(float inPosition, float inValue, float inTangent, float projPosition)
{
return inValue + ((projPosition - inPosition) * inTangent);
}
Vector3[] BezierSegment(Point start, Point end)
{
var segment = new Vector3[4];
segment[0] = new Vector3(start.time, start.value);
segment[3] = new Vector3(end.time, end.value);
float middle = start.time + ((end.time - start.time) * 0.333333f);
float middle2 = start.time + ((end.time - start.time) * 0.666666f);
segment[1] = new Vector3(middle, ProjectTangent(start.time, start.value, start.right, middle));
segment[2] = new Vector3(middle2, ProjectTangent(end.time, end.value, end.left, middle2));
return segment;
}
}
가장 중요한 Evaluate 함수는 어떻게 만들어야 할까? 결국 이 부분은 블랙박스라서 내가 직접 생각해야 했다. 하지만 이제 커브가 어떻게 구성되어 있는지 알아버린 나에게 문제가 되진 않는다. 구하는 건 쉬운데 이걸 상수 시간으로 찾아낼 수 없다는 게 문제이다.
Evaluate 함수를 만들려면 다음 조건을 만족해야 한다.
- 입력으로 0.0~1.0 사이의 time이라는 정규화된 시간 값을 받는다.
- time 값을 이용해 time 값이 위치한 구간의 인덱스를 얻어낸다. <- 이 녀석이 핵심
- 해당하는 구간에 맞게 Curve 연산을 적용해서 value 값을 뱉어낸다.
제일 단순히 생각할 수 있는 건 for문을 돌려서 0번 인덱스부터 time이 얘보다 큰지 아닌지 검사하면 되는데, 당연하지만 이런 방식은 실제 성능도 그렇지만 뭔가... 내가 심리적으로 불편하기 때문에 조금이라도 연산을 줄여보겠다고 Binary Search를 쓰기로 하였다.
그래서 내가 만들어낸 Evaluate 함수의 구현은 다음과 같다.
public int FindIndex(float time)
{
int start = 0;
int end = points.Count - 1;
// Binary Search
while (end - start >= 0)
{
var mid = (start + end) / 2;
if (points[mid].time <= time)
{
if (mid + 1 < points.Count && points[mid + 1].time > time)
{
return mid;
}
start = mid + 1;
}
else
{
end = mid - 1;
}
}
return -1;
}
public float Evaluate(float time)
{
switch (points.Count)
{
case < 1:
return 0;
case 1:
return points[0].value;
}
time = Mathf.Clamp01(time);
int index = FindIndex(time);
Vector3[] segments = BezierSegment(points[index], points[index + 1]);
float t = (time - points[index].time) / (points[index + 1].time - points[index].time);
return CubicCurve(segments[0], segments[1], segments[2], segments[3], t).y;
}
이걸 ComputeShader에서 쓰는 미친 짓을 해보기도 하였는데, 반복문이 들어가니까 Runtime에서 이걸 그대로 쓰기엔 부담스럽다. Editor 상에서만 써야할 듯 하다.
Runtime에서 Curve 데이터를 사용해야 한다면?
유니티 URP의 PostProcessing에서 사용하는 ColorCurves 클래스를 보면 이에 대한 해답을 제시하고 있다.
요런식으로 TextureCurveParameter라는 녀석을 사용하고 있다.
그러면 저 TextureCurve라는 녀석이 어떻게 구성되어 있는가 확인해보면...
public Texture2D GetTexture()
{
if (m_Texture == null)
{
m_Texture = new Texture2D(k_Precision, 1, GetTextureFormat(), TextureCreationFlags.None);
m_Texture.name = "CurveTexture";
m_Texture.hideFlags = HideFlags.HideAndDontSave;
m_Texture.filterMode = FilterMode.Bilinear;
m_Texture.wrapMode = TextureWrapMode.Clamp;
m_IsTextureDirty = true;
}
if (m_IsTextureDirty)
{
var pixels = new Color[k_Precision];
for (int i = 0; i < pixels.Length; i++)
pixels[i].r = Evaluate(i * k_Step);
m_Texture.SetPixels(pixels);
m_Texture.Apply(false, false);
m_IsTextureDirty = false;
}
return m_Texture;
}
즉, 커브 값을 미리 Evaluate한 후에 128 x 1 (k_Precision 값이 128이다) 짜리 텍스처로 프리 베이크해서 사용하는 것이었다! 역시 저렇게 무거운 Evaluate 함수는 사용할 수 없지...
'TA' 카테고리의 다른 글
(작성중) [Unity] URP환경에서 GPU Instancing Tool 만들기 (0) | 2025.01.20 |
---|---|
[Unity] Custom Shadow System 제작하기 (1) - 커스텀 그림자 제작의 필요성 (0) | 2024.12.30 |
(작성중) [Unity] Irradiance Volume 만들기 (짭 Adaptive Probe Volume) (0) | 2024.12.29 |
[Unity] Blend State 프로퍼티로 관리할 경우 Blend 옵션 비활성화 (0) | 2024.06.18 |
그래픽 리소스 / 렌더링 최적화에 대한 생각 (0) | 2024.06.09 |