Home Line Purge Effect
Post
Cancel

Line Purge Effect

젤다의 선 이펙트가 만들고 싶어졌습니다.

그래서 만들어봅니다.

LinePurge4.gif 1차. 디졸브를쓴건데 디졸브가 gif에서 얇아서 안보입니다…슬프네요

LinePurge5.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

위에 작성한 이펙트의 설정은 다음과 같습니다.

루트는 만약 지향성있는 이동이 필요할 때, 추가로 필요한 정보이므로

월드스페이스의 포지션 앵커를 잡아줍니다.

Untitled

성능도 soso합니다. (물론 잡 그룹이 텅텅 비어있다는 기준 하에요)

Untitled

끝!

This post is licensed under CC BY 4.0 by the author.
Contents