젤다의 선 이펙트가 만들고 싶어졌습니다.
그래서 만들어봅니다.
1차. 디졸브를쓴건데 디졸브가 gif에서 얇아서 안보입니다…슬프네요
그래서 수정했습니다. 중심포지션고려과 알파도 조금
본 포스트는 CK 2024 졸업작품 AzureField 작업물이 일부 포함되어 있습니다.
구현
이번 구현은 다음과 같은 작업환경에서 이루어집니다
- lineRenderer 사용
- Job 사용
- 커스텀 PlayerLoop 사용
일단 사용되기 위한 디펜던시인 PlayerLoop는 아래에서 간단하게 설정한 코드가 있습니다.
https://gist.github.com/ashuatz/f2e84fbaf44db9aa5e3b41879cb9f320
다만 이번에는 해당 내용을 설정하지않고 전체적으로 작성해 보겠습니다.
코드는 크게 세 부분으로 구성되어있습니다
- 플레이어 루프 관련
- Job 관련
- Mono관련
따라서 관리 용이성을 위해 세 Partial class로 구성합니다.
디펜던시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System.Collections;
using System.Collections.Generic;
using Unity.Collections;
using UnityEngine;
// 테스트용 버튼을 위해 사용합니다.
using Sirenix.OdinInspector;
//기타
using System;
using System.Diagnostics.CodeAnalysis;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine.LowLevel;
using ReadOnly = Unity.Collections.ReadOnlyAttribute;
플레이어 루프 관련 코드
정말 간단합니다.
플레이어 루프를 가져와서 earlyUpdateList에 delgate만 추가해줍니다.
동일 코드에서 여러번 등록하기엔 번거로우므로, 정적함수를 통해 이벤트를 발생하도록 처리합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public partial class LinePurge : MonoBehaviour
{
private static event Action OnEarlyUpdate;
[RuntimeInitializeOnLoadMethod]
private static void InitializePlayerLoop()
{
PlayerLoopSystem playerLoop = PlayerLoop.GetCurrentPlayerLoop();
for (int i = 0; i < playerLoop.subSystemList.Length; i++)
{
var subsystem = playerLoop.subSystemList[i];
if (subsystem.type == typeof(UnityEngine.PlayerLoop.EarlyUpdate))
{
var earlyUpdateList = new List<PlayerLoopSystem>(subsystem.subSystemList);
var customEarlyUpdate = new PlayerLoopSystem
{
type = typeof(LinePurge),
updateDelegate = EarlyUpdate
};
earlyUpdateList.Insert(0, customEarlyUpdate);
subsystem.subSystemList = earlyUpdateList.ToArray();
playerLoop.subSystemList[i] = subsystem;
break;
}
}
PlayerLoop.SetPlayerLoop(playerLoop);
}
private static void EarlyUpdate()
{
OnEarlyUpdate?.Invoke();
}
}
Job 관련 코드
머리와 몸통이 분리된 코드입니다.
Job특성상 vi가 0일때, 4001번을 참조하게되면 분할에따라 정상적으로 작동하지 않을 수 있지만,
소량의 데이터(33개) 정도에서는 문제가 없으므로 +1 ~ -2범위정도는 괜찮습니다.
버텍스당 연산은 크게 두가지 부분으로 읽을 수 있는데,
- 머리부분의 이동코드
- 노이즈 혹은 랜덤을 사용해 벡터 생성후 벡터를 쿼터니언으로 변환
- 쿼터니언곱으로 다음 이동 방향 회전 후 결정
- 몸통부분의 따라가는 코드
- 간단하게 2차 보간으로 각짐을 줄여줍니다.
로 읽을 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public partial class LinePurge : MonoBehaviour
{
private JobHandle handle;
private UpdateLinePositionJob updateJob;
private struct UpdateLinePositionJob : IJobParallelFor
{
[ReadOnly] public NativeArray<Vector3> defaultLineLocalPosition;
[ReadOnly] public Vector3 massCenterLocalPosition;
[ReadOnly] public float time;
[ReadOnly] public float normalizedRuntime;
public NativeArray<Vector3> vertices;
private const float RunSpeed = 0.5f;
public void Execute(int vi)
{
var pos = defaultLineLocalPosition[vi];
var targetpos = defaultLineLocalPosition[Mathf.Max(0, vi - 1)];
var runtimeSpeedFactor = -((1 - (2 * normalizedRuntime)) * (1 - (2 * normalizedRuntime))) + 1f;
if (vi == 0) //head
{
var rand = new Random();
var targetDirection = pos - defaultLineLocalPosition[vi + 1];
var noiseVector = new Vector3((float)rand.NextDouble() - 0.5f, (float)rand.NextDouble() - 0.5f, (float)rand.NextDouble() - 0.5f);
var rotation = Quaternion.Euler(noiseVector * 270 * runtimeSpeedFactor);
var forward = Vector3.Lerp(rotation * targetDirection.normalized, (pos - massCenterLocalPosition).normalized, 0.2f);
var nextPos = pos + forward * 0.5f * RunSpeed * runtimeSpeedFactor * 1.2f;
vertices[vi] = nextPos;
}
else //body
{
var targetpos2 = defaultLineLocalPosition[Mathf.Max(0, vi - 2)];
var cubicInterpolation = Vector3.Lerp(Vector3.Lerp(pos, targetpos, 0.5f), Vector3.Lerp(targetpos, targetpos2, 0.5f), 0.5f);
var diff = cubicInterpolation - pos;
var diffVector = diff.normalized * 0.5f;
var nextPos = pos + (diffVector) * RunSpeed * runtimeSpeedFactor;
vertices[vi] = nextPos;
}
}
}
public void JobSchedule()
{
handle.Complete();
//get data
lineRenderer.GetPositions(positionList);
//makeJob;
updateJob = new UpdateLinePositionJob
{
defaultLineLocalPosition = positionList,
massCenterLocalPosition = transform.InverseTransformPoint(Root.transform.position),
time = Time.time,
normalizedRuntime = curve.Evaluate(t / runtime),
vertices = new NativeArray<Vector3>(positionList.ToArray(), Allocator.Persistent)
};
//schedule
handle = updateJob.Schedule(lineRenderer.positionCount, 64);
JobHandle.ScheduleBatchedJobs();
}
public void JobComplete()
{
//isComplete is not working
handle.Complete();
//update data
lineRenderer.SetPositions(updateJob.vertices);
updateJob.vertices.Dispose();
}
}
Mono코드
이제 일반적으로 사용할 코드입니다.
Early Update에서 Job과 Color, width를 설정하며,
LateUpdate에서 Complete를 합니다. (더이상 늦을 순 없으니까요)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
public partial class LinePurge : MonoBehaviour
{
[SerializeField]
private Transform Root;
[SerializeField]
private LineRenderer lineRenderer;
[SerializeField]
private float runtime = 2f;
[SerializeField]
private Gradient gradient;
[SerializeField]
private AnimationCurve curve;
#if UNITY_EDITOR
[Sirenix.OdinInspector.Button]
private void RunTest()
{
foreach (var actor in LinkedActor)
{
actor.gameObject.SetActive(true);
actor.Purge();
}
}
#endif
[Header("Linked")]
[SerializeField]
private List<LinePurge> LinkedActor = new List<LinePurge>();
private Vector3[] defaultPositionList;
private NativeArray<Vector3> positionList;
private bool isScheduled;
private float t = 0;
private void Awake()
{
defaultPositionList = new Vector3[lineRenderer.positionCount];
lineRenderer.GetPositions(defaultPositionList);
positionList = new NativeArray<Vector3>(defaultPositionList, Allocator.Persistent);
isScheduled = false;
}
private void Purge()
{
if (lineRenderer == null)
return;
isScheduled = true;
t = 0;
OnEarlyUpdate += UpdatePurge;
}
private void UpdatePurge()
{
if (runtime < t)
{
CompletePurge();
return;
}
//position job
JobSchedule();
t += Time.deltaTime;
var normalizedTime = t / runtime;
//color
lineRenderer.startColor = gradient.Evaluate(normalizedTime);
var endColor = gradient.Evaluate(normalizedTime);
endColor.a *= 0.7f;
lineRenderer.endColor = endColor;
//width
lineRenderer.widthMultiplier = Mathf.Lerp(0.1f, 0f, normalizedTime);
}
private void LateUpdate()
{
if (!isScheduled)
return;
JobComplete();
}
private void CompletePurge()
{
t = 0;
isScheduled = false;
OnEarlyUpdate -= UpdatePurge;
gameObject.SetActive(false);
lineRenderer.startColor = gradient.Evaluate(0);
lineRenderer.endColor = gradient.Evaluate(0);
lineRenderer.widthMultiplier = Mathf.Lerp(0.1f, 0f, 0);
positionList.CopyFrom(defaultPositionList);
lineRenderer.SetPositions(defaultPositionList);
}
private void OnDisable()
{
CompletePurge();
}
private void OnDestroy()
{
OnEarlyUpdate -= UpdatePurge;
positionList.Dispose();
}
}
inspector Parameter Values & Profile Data
위에 작성한 이펙트의 설정은 다음과 같습니다.
루트는 만약 지향성있는 이동이 필요할 때, 추가로 필요한 정보이므로
월드스페이스의 포지션 앵커를 잡아줍니다.
성능도 soso합니다. (물론 잡 그룹이 텅텅 비어있다는 기준 하에요)
끝!