오늘은 C프로젝트에서 Shader를 이용하여 오브젝트가 물 background 위에 있을 때 잠겨있는 듯한 모습을 연출하려고 한다.

 

유니티에서 Shader를 만드는 방법은 크게 두 가지 방식이 있다.

첫 번째론 셰이더 언어를 이용하여 직접 코드를 작성하여 만드는 방식이고,

두 번째 방법으론 유니티에서 제공하는 비주얼 툴인 Shader Graph를 이용하여 만드는 방식이다.

나는 두 가지 방법 중 후자를 선택하여 만들어보려고 한다.

 

시작에 앞서 나는 유니티 프로젝트를 진행하면서 한 번도 셰이더를 적용하여 결과물을 만들어본 적이 없다...

그렇다보니 막히는 부분이 너무 많았다... ㅠ

그렇기 때문에 먼저 깊게까진 아니더라도 Rendering Pipeline에 대해서 공부를 해보았다.

그 이후 한 예제를 들어 셰이더를 만드는 방법에 대한 유튜브 영상도 참고해 보았다.

 

아래의 개발 과정은 셰이더에 대한 기본적인 지식이 있다는 가정하에 설명이 되므로

혹시 보는 과정 중 설명이 부족한 부분이 있다면 직접 찾아서 궁금증을 해결하자!

 

이제 본격적으로 직접 셰이더 그래프를 이용하여 만들어 볼 시간이다!

그러기 위해선 먼저 셰이더 그래프를 먼저 생성해 준다.

셰이더 그래프를 만드는 종류는 여러 가지가 있는데 이 중 현재 필요한 형태의 셰이더 그래프를 생성해 주면 된다.

기본적으로 이 프로젝트는 지금 당장은 아니지만 URP를 적용하여

Lighting에 대한 처리를 하기 위해 "Sprite Lit Shader Graph"로 생성했다.

 

우선 아이디어가 필요했다.

어떤 방식을 이용하여 물에 잠겨있는 듯한 연출을 표현할지...

처음 든 생각은 float형으로 depth 필드를 만든 이후 depth의 크기만큼 texture 일부분의 투명도는 0으로 해주었다.

우선 Texture의 특정 y값을 기준으로 아랫부분은 투명도를 0으로 해주기 위해 UV 좌표에서 y를 받아왔다.

적용할 머테리얼에서도 특정 y값을 조절할 수 있게 하기 위해

float형으로 _Depth라는 프로퍼티를 만들어 해당 값과 y를 빼주었다.

이후 step 노드를 통해 예쁘게 다듬어준다.

 

이렇게 나온 결과와 Main Texture의 알파값을 곱해준다면 특정 y값을 기준으로 아랫부분은 투명도가 0이 될 것이고 윗부분은 정상적으로 렌더링 되는 결과가 나올 것이다.

완성된 그래프를 저장한 이후 새로운 머테리얼을 만들어 방금 전에 만들어준 셰이더 그래프를 드래그 앤 드롭하여 적용시켜 준다.

만들어진 머테리얼을 오브젝트에 적용하여 결과를 확인하면 위와 같이 잘 적용되는 모습을 볼 수 있다.

하지만 물 위에 있는 듯한 느낌이 들긴 하지만 약간 어색함이 남아있다. (배경은 아직 없어 임시로 적용...)

 

아무래도 이 어색함은 잠겨있는 부분이 잔상처럼 보이지 않기 때문에 어색해 보이는 느낌이 드는 것 같아

texture의 일부 투명도를 0으로 줄였지만 조금 올려 뒤에 있는 물 배경과 형체가 자연스럽게 보이도록 수정해 보았다.

기존 그래프에서 일부를 추가하여 수정해 주었다.

우선 아랫부분과 윗부분을 나누어 아랫부분만 투명도를 조절할 수 있도록 만들어주었고

두 결과 값을 더하여 Main Texture의 알파값과 곱해주면 된다.

완성된 그래프를 저장한 뒤 다시 오브젝트를 확인해 보면 잘 적용된 모습을 볼 수 있다.

확실히 이전 방식보단 자연스러운 느낌이 남아있다.

지금 적용한 방식도 동적인 느낌이 안나다 보니 어색한 느낌이 여전히 남아있는 거 같다.

하지만 연출적인 부분에서 완전히 정해진 부분이 없어 정해지는 대로 더 나은 방식으로 자연스럽게 적용시켜보자 한다.

 

현재는 이 방식까지 밖에 생각 못했다 보니 혹시 다른 좋은 의견이 있으시다면 댓글 남겨주시면 감사하겠습니다!

오늘은 프로젝트 C에서 자동 공격을 하는 오브젝트를 만들어보려고 한다.

 

해당 오브젝트는 기본적으로 이전 포스팅을 통해 작업한 상태 패턴을 기반으로 동작한다.

https://lms0408.tistory.com/14

 

유니티 프로젝트 개발 일지 - 1 (상태 패턴)

오늘은 프로젝트 C에서 오브젝트의 상태 패턴을 구현해 볼 것이다. 이전 개인 프로젝트에서 상태 패턴을 구현할 땐,Player와 Montser 오브젝트가 있을 때 각기 다른 상태를 정의하고 같은 오브젝트

lms0408.tistory.com

상태는 총 3가지로 Idle(적을 탐색하는 상태), Attack, Dead를 가진다.

오브젝트가 Attack 상태일 때 공격을 할 수 있도록 코드를 작성해보고자 한다.

 

우선 무기의 기초적인 뼈대를 작성해 볼 것이다.

public interface IWeapon
{
    public bool IsAtk { get; }
    public void ReadyAttack(float attackingTime);
    public bool UpdateAttack(Vector2 pos, Vector2 dir, float attackingTime);
}

IsAtk 프로퍼티는 무기의 공격이 준비되어 공격이 가능한 상태를 알리고,

ReadyAttack 메서드는 특정 상태일 때 매 프레임마다 호출되어 공격이 가능하게끔 준비해 주는 메서드,

UpdateAttack도 특정 상태일 때 매 프레임마다 호출되어 공격을 진행해 주는 메서드로 구현할 예정이다.

 

UpdateAttack 메서드가 bool 타입 반환형으로 선언한 이유는

해당 메서드를 호출하는 오브젝트가 가지고 있는 무기가 공격을 했다는 신호를 받아 애니메이션 클립을 재생해주기 위함이다.

 

이젠 선언한 인터페이스를 바탕으로 Weapon 클래스를 구현해보려고 한다.

[System.Serializable]
public abstract class Weapon : IWeapon
{
    [SerializeField] private float atk; // 공격력

    [SerializeField] private int maxAtkCount; // 공격 횟수
    private int atkableCount; // 현재 공격 가능한 횟수

    [SerializeField] private bool isAtk;
    [SerializeField] private float atkElapsed; // 공격 경과 시간

    [SerializeField] private float coolTime = 0.5f; // 쿨타임
    [SerializeField] private float coolTimeElapsed; // 쿨타임 경과 시간
    
    public void ReadyAttack(float attackingTime)
    {
        if ((coolTimeElapsed += Time.deltaTime) < coolTime) return;

        isAtk = true;
        atkElapsed = attackingTime;
    }
    
    public bool UpdateAttack(Vector2 pos, Vector2 dir, float attckingTime)
    {
        if ((atkElapsed += Time.deltaTime) < attckingTime) return false;

        if (--atkableCount < 0)
        {
            coolTimeElapsed = 0f;
            atkableCount = atkCount;
            isAtk = false;
            return false;
        }
    
        atkElapsed = 0f;
        Attack(pos, dir);

        return true;
    }
    
    protected abstract void Attack(Vector2 pos, Vector2 dir);
}

ReadyAttack 메서드는 Idle 상태에서 호출된다.

오브젝트들은 타겟과의 거리가 공격 범위내에 존재해야 그 자리에서 공격을 할 수 있어

이동과 같은 특수한 행동 상태 중에는 호출하지 않는다.

 

내부 구현은 매우 간단하게 되어있다.

호출이 진행됨에 따라 쿨타임 경과 시간을 업데이트해주면서 공격 준비를 시켜준다.

쿨타임이 완료되었을 땐 해당 메서드를 호출한 오브젝트가 공격 상태가 되도록

isAtk true로 변경해준다.

 

ReadyAttack을 통해 오브젝트가 Attack 상태가 되었다면 UpdateAttack 메서드를 호출하여

공격을 진행해 준다.

UpdateAttack 메서드는 atkableCount(공격 가능한 횟수)가 모두 소진되기 전까지 매 프레임 호출되게 된다.

 

매개변수 attackingTime은 한 번의 공격이 진행되는 시간이며

나는 기본값으로 공격 애니메이션 클립의 Length를 대입해 줄 것이다.

(+ ReadyAttack 메서드에서 보면 공격 경과시간을 attackingTime을 대입해 주면서 공격 상태를 맞이하게 되는데

그 이유는 공격 상태가 되었을 때 곧바로 공격을 하기 위함이다.)

 

공격 진행 시간이 초과된 이후

공격 가능한 횟수가 남아있다면 다시 공격을 진행해 준다.

만약 공격 가능한 횟수가 남아있지 않다면 공격 준비 상태가 되도록 isAtkfalse로 변경해 주게 된다.

 

이 Weapon 클래스의 뼈대를 바탕으로 나는 근거리 무기와 원거리 무기로 나누어 상속을 해주었다.

현재는 원거리 무기를 테스트하고자 하니 간단하게 투사체를 발사하는 코드를 구현하면 다음과 같다.

[System.Serializable]
public class LongRangeWeapon : Weapon
{
    public Rigidbody2D pref;
    protected override void Attack(Vector2 pos, Vector2 dir)
    {
        var _obj = GameObject.Instantiate(pref, pos, 
            Quaternion.Euler(0f, 0f, Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg - 90f));
        _obj.velocity = dir * 5f * Time.fixedDeltaTime;
    }
}

 

이제 위의 Weapon 로직이 잘 돌아가는지 확인하기 위해 투사체를 발사하는 대포 오브젝트에 적용을 해보자.

public interface ICannonAction : IIdleAction, IAttackAction, IDeadAction { }

public class CannonController : MyObject, ICannonAction
{
    [SerializeField] private LongRangeWeapon myWeapon;
    [SerializeField] private Animator _animator;
    
    public Quaternion originRot;
    public Transform Target;
    
    // Something...
    
    public void Idle()
    {
        Quaternion _targetRot = Quaternion.identity;
        if (Target == null) _targetRot = originRot;
        else
        {
            // Cannon Position에서 타겟 Position까지의 벡터를 구해 각도를 변환
            var _dir = (weapon.Target.Position - (Vector2)weapon.Transform.position).normalized;
            var _axis = Mathf.Atan2(_dir.y, _dir.x) * Mathf.Rad2Deg;
            _targetRot = Quaternion.Euler(0f, 0f, _axis);

            myWeapon.ReadyAttack(1f);
        }
        
        var _rotSpeed = 10f;
        transform.rotation = Quaternion.Lerp(transform.rotation, _targetRot, Time.deltaTime * _rotSpeed);
    }
    
    public void Attack()
    {
        if (!myWeapon.UpdateAttack(transform.position, transform.up, 1f)) return;
        // Weapon이 공격을 했다면 애니메이션 재생
        _animator.SetTrigger("Attack");
    }
    
    // Something...
}

위 코드는 CannonController의 핵심적인 코드의 일부분이며, 위에서 설명한 설계대로 구현이 된 상태이다.

추가적인 부분은 타겟이 존재하지 않을땐 항상 처음에 셋팅한 rotation을 바라보고,

타겟이 존재하고 공격이 준비되고 있는 상태에선 타겟을 바라보도록 연산해보았다.

 

해당 스크립트 컴포넌트가 적용된 오브젝트를 적절하게 배치하여 테스트 해보면 문제없이 잘 동작하는 모습을 볼 수 있게 된다.

오늘은 프로젝트 C에서 오브젝트의 상태 패턴을 구현해 볼 것이다.

 

이전 개인 프로젝트에서 상태 패턴을 구현할 땐,Player와 Montser 오브젝트가 있을 때 각기 다른 상태를 정의하고 같은 오브젝트 끼리는 해당 상태 인스턴스를 공유한 방식이었다.같은 상태를 가지는 오브젝트가 개별로 상태 인스턴스를 가지고 있는건 메모리적으로 낭비가 되는 부분이기 때문에각 상태를 CashData로 저장하여 공유가 되도록 했다.

 

이번에는 이전 방식과 로직은 비슷하되 Scriptable Object를 활용하여 제작해보기로 결정했다.

 

스크립트는 크게 세 분류로 나누었으며,각 상태를 컨트롤 해주는 State Machine과 해당 상태로 변경할 수 있는 지에 대한 여부를 판단하는 Decision,해당 상태의 Action이 정의된 State로 나누었다.

public abstract class StateDecision : ScriptableObject
{
    public abstract bool Decision(MyObject obj);
}

Decision은 bool 반환형 메서드로 정의하여 해당 상태로 변경이 가능한지에 대한 여부를 판단할 수 있도록 정의해주었다.

public abstract class State : ScriptableObject
{
    public StateDecision decision;

    public virtual void Enter(MyObject obj) { }
    public virtual void Action(MyObject obj) { }
    public virtual void Exit(MyObject obj) { }
}

State는 StateDecision 필드 변수 하나와 메서드 3가지로 정의하였다.

decision은 앞서 정의한 StateDecision을 가지는 필드 변수이다. 각 상태에 대응되는 Decision을 대입할 것 이다.

Enter는 해당 상태로 변했을 때 초기화가 이루어지는 부분이고, Action은 해당 상태에서 매 프레임마다 Update되는 메서드이다.

Exit는 해당 상태가 종료되었을 때 한번 호출되는 메서드이다.

public class GeneralStateMachine : ScriptableObject
{
    [SerializeField] private List<State> states;

    public void TransState(MyObject obj, ref State curState)
    {
        foreach (var _state in states)
        {
            if (_state.decision == null || _state.decision.Decision(obj))
            {
                ChangeState(obj, ref curState, _state);
                break;
            }
        }
    }

    public void UpdateState(MyObject obj, State curState)
    {
        curState.Action(obj);
    }

    private void ChangeState(MyObject obj, ref State curState, State newState)
    {
        if (curState.Equals(newState)) return;

        curState.Exit(obj);
        newState.Enter(obj);
        curState = newState;
    }
}

StateMachine은 매 프레임마다 호출되어 상태를 바꾸어주는 TransState와 매 프레임마다 현재 상태의 Action을 호출해주는 UpdateState로 나누었다.

 

각 상태는 List를 통해 저장하며 순서가 젤 앞설수록 상태 전환의 우선 순위를 가질 수 있도록 설계했다.

 

현재 상태는 각 오브젝트 별로 가지고 있어(현재 상태는 고유의 값이므로) 매개변수를 통해 현재 상태를 전달해준다.

이때 현재 상태를 매개 변수로 전달할 때 값 형식으로 전달되기 때문에 반드시 ref 키워드를 붙여 참조 형식으로 전달해주어야 현재 상태의 값이 반영된다.

 

이렇게 뼈대는 완성이 되었고 이제 살만 붙이는 작업만 해준다면 멋진 상태 패턴을 만들 수 있다.

 

이전 방식에선 Controller에서 상태에 대한 행동을 정의하지 않고 상태 클래스 내에서 행동을 모두 정의했다.

Controller의 코드 다이어트가 되어 조금 더 깔끔해 보이지 않을까 하는 생각에 해당 방식으로 작성한 것이었다.

 

하지만 이번엔 조금 더 상태를 범용성있게 사용하기 위해 각 Controller에 상태에 대한 행동을 정의하고 해당 메서드를 각 상태에서 호출할 수 있게끔 설계하였고 먼저 캡슐화 작업을 해주었다.

#region State
public interface IIdleState
{
    public bool Idleable { get; }
}
public interface IMoveState
{
    public bool Moveable { get; }
}
public interface IAttackState
{
    public bool Attackable { get; }
}
public interface IHitState
{
    public bool Hitable { get; }
}
public interface IDeadState
{
    public bool IsDead { get; }
}
#endregion

#region Action
public interface IIdleAction
{
    public void Idle();
}
public interface IMoveAction
{
    public void Move();
}
public interface IAttackAction
{
    public void Attack();
}
public interface IHitAction
{
    public void Hit();
}
public interface IDeadAction
{
    public void Dead();
}
#endregion

캡슐화는 Decision에 필요한 것과 Action을 위해 필요한 것으로 나누어 정의했다.이후 필요한게 있다면 추가적으로 캡슐화를 할 예정이다.

 

이렇게 정의된 interface를 바탕으로 각 State와 Decision을 정의해주었고, 아래는 Move 상태에 대한 코드이다.

public class MoveDecision : StateDecision
{
    public override bool Decision(MyObject obj)
    {
        return (obj as IMoveState).Moveable;
    }
}

public class MoveState : State
{
    public override void Enter(MyObject obj)
    {
    }
    public override void Action(MyObject obj)
    {
        (obj as IMoveAction).Move();
    }
    public override void Exit(MyObject obj) 
    {
    }
}

매개 변수를 통해 전달 받은 오브젝트를 캐스팅하여 메서드를 호출하는 간단한 코드이다.

 

이렇게 정의된 스크립트를 바탕으로 Decision과 State를 만들어주었고, 해당 State에 대응되는 Decision을 대입해주었다.

다른 상태들 역시 필요하다면 이렇게 추가하여 관리할 수 있다.

 

이렇게 몬스터에 필요한 각 상태들을 정의하고 우선순위에 맞게 각 상태들을 List에 대입한 모습이다.

 

이 방식은 Editor상에서 좀 더 직관적으로 상태를 관리할 수 있다는 점이 아주 좋은 부분인 것 같다.

처음 시도인만큼 완벽한 설계 방식은 아니지만 문제없이 돌아간다는 점에서 큰 만족감을 가지게 되었다.

+ Recent posts