Typing diary

Verlet알고리즘으로 PhysicsBone 구현하기! 본문

그래픽스, 게임 수학

Verlet알고리즘으로 PhysicsBone 구현하기!

Jcon 2025. 7. 9. 01:29

게임에서 물리 효과는 유저가 게임과 상호작용하는 과정에서 게임을 더욱 생동감 있고, 사실적으로 느끼게 해주는 중요한 요소이다. Physics Bone은 게임에서 의상, 헤어와 같은 물체의 물리 효과를 실시간으로 표현하기 위한 효과 중 하나이다. 스키닝된 메쉬 본에 대해서만 연산을 진행하기 때문에 모든 버텍스에 물리 연산을 진행하는 Vertex Physics보다 연산이 매우 가볍고, 아티스트가 버텍스에 Weight를 어떻게 할당하냐에 따라서 디테일한 연출도 가능하기 때문에 게임에서 널리 사용되고 있다.

 

이번 포스팅에선 PhysicsBone을 구현하는 방법 중 하나인 Verlet알고리즘을 사용하여 PhysicsBone을 구현하는 방법에 대해서 포스팅할 예정이다.


 

 Verlet 알고리즘?

Verlet 알고리즘은 고전역학의 운동 방정식을 단순하고 안정적으로 수치적으로 계산하기 위한 방법으로, 특히 속도를 직접적으로 다루지 않으면서도 위치를 계산할 수 있다는 점에서 많은 물리 엔진에서 사용되고 있고, 에너지 보존 특성이 뛰어나 장기 시뮬레이션에 적합하다.

 

기본적으로 많이 알고있는 오일러 방식은 다음과 같다.

v(t) = v(t-Δt) + a(t)*Δt
x(t+Δt) = x(t) + v(t)*Δt

 

시간 변화량 Δt를 기반으로 속도와 위치를 순차적으로 업데이트하는 계산 방식이다 .

 

반면, Verlet 알고리즘은 다음과 같은 수식을 사용하는데,

x(t+Δt) = 2∗x(t) − x(t−Δt) + a(t)∗Δt^2

특이한 점이 있다면 식에서 속도를 전혀 사용하지 않고 있다는 점이다!

그렇다면 속도 정보를 명시적으로 사용하지 않고도, 어떻게 다음 위치를 예측할 수 있을까?

위 식을 약간 변형하면 다음과 같이 볼수 있다.

x(t+Δt) = x(t) + x(t) − x(t−Δt) + a(t)∗Δt^2

여기서 x(t) − x(t−Δt) 이 부분을 보면 (현재 위치 - 이전 위치), 즉 변위량을 내포하고 있는것을 알 수 있다. 이 변위량은 한 프레임간 위치 변화량을 나타내기 때문에,  v(t)*Δt와 동일한 의미를 지니게 된다. 

 

Verlet 알고리즘은 위치 기반 계산에 초점을 두고 있기 때문에, 위치 제약이나 관절 제어가 중요한 Physics Bone과 같은 본 기반 물리 표현에 특히 적합하다.

 


 구현

 

항상 구현을 할땐 쉬운것 부터 차근차근 나아가면 어려운 문제도 쉽게 접근할 수 있다!

먼저 Verlet 알고리즘을 시뮬레이션 하는 단일 객체, VerletObject 클래스부터 구현하였다.

 

using UnityEngine;

public class VerletObject : MonoBehaviour
{
    //물리 계산을 위한 데이터
    public class VerletParticle
    {
        public Transform Target;
        public Vector3 CurrentPos;
        public Vector3 PreviousPos;
        public Vector3 Acceleration;

        public VerletParticle(Transform transform)
        {
            CurrentPos = transform.position;
            PreviousPos = transform.position;
            Acceleration = Vector3.zero;
            Target = transform;
        }
    }

    private VerletParticle _verletParticle;
    private void Start()
    {
        _verletParticle = new(transform);
    }

    private void Update()
    {
        UpdatePosition(Time.deltaTime);
    }

    private void UpdatePosition(float dt)
    {
        _verletParticle.CurrentPos = _verletParticle.Target.position;

        Vector3 temp = _verletParticle.CurrentPos;

        //verlet 알고리즘        
        Vector3 velocity = _verletParticle.CurrentPos - _verletParticle.PreviousPos;
        _verletParticle.CurrentPos += velocity + _verletParticle.Acceleration * dt * dt;

        //위치 반영
        _verletParticle.Target.position = _verletParticle.CurrentPos;
        _verletParticle.PreviousPos = temp;

        //가속도 초기화
        _verletParticle.Acceleration = Vector3.zero;
    }

}

 

오브젝트에 적용해서 플레이 후 Scene화면에서 살짝 밀어보면, 변화된 위치가 속도가 되어 앞으로 계속 나아가는걸 볼 수 있다.

이처럼, Verlet 알고리즘을 게임에 사용할때, 외부 반응에 대해 위치만 잘 적용시켜주면, 그것이 물리 시뮬레이션에 바로 적용되는 것이 Verlet 알고리즘의 장점이라고 할 수 있다.


 

우리가 만들건 PhysicsBone이기 때문에, VerletParticle 여러개를 이어서 bone 계층의 물리를 시뮬레이션 할 수 있게 수정하였다.

using System.Collections.Generic;
using UnityEngine;

//PhysicsBone으로 클래스 이름 변경 
public class PhysicsBone : MonoBehaviour
{
    //물리 계산을 위한 데이터
    public class VerletParticle
    {
        public Transform Target;
        public Vector3 CurrentPos;
        public Vector3 PreviousPos;
        public Vector3 Acceleration;

        //부모 본과의 위치를 유지하기 위해 부모 본과의 초기 거리를 저장한다.
        public float DistanceToParent;

        public VerletParticle(Transform transform, Transform parent)
        {
            CurrentPos = transform.position;
            PreviousPos = transform.position;
            Acceleration = Vector3.zero;
            Target = transform;

            DistanceToParent = (parent.position - transform.position).magnitude;
        }
    }

    //시뮬레이션을 위한 루트 본 설정 
    [SerializeField] private Transform _root;

    [Header("Params")]
    [SerializeField] private Vector3 _gravity = Vector3.zero;

    private List<VerletParticle> _bones = new(); //시뮬레이션을 위한 VerletParticle 저장

    private void Start()
    {
        if (_root == null)
            return;

        SetBoneRecursive(_root);

        //재귀적으로 transform의 자식을 찾으며 VerletParticle을 생성한다.
        void SetBoneRecursive(Transform parent)
        {
            if (parent.childCount == 0)
                return;
            Transform child = parent.GetChild(0);
            var bone = new VerletParticle(child,parent);
            _bones.Add(bone);
            SetBoneRecursive(child);
        }
    }

    private void Update()
    {
        UpdatePosition(Time.deltaTime);
    }

    private void UpdatePosition(float dt)
    {
        Vector3 parentPosition = _root.position;

        for (int i = 0; i < _bones.Count; i++)
        {
            var bone = _bones[i];

            //중력 적용 
            bone.Acceleration += _gravity;

            Vector3 temp = bone.CurrentPos;
            Vector3 velocity = (bone.CurrentPos - bone.PreviousPos);

            //verlet 
            bone.CurrentPos += velocity + bone.Acceleration * dt * dt;
            bone.PreviousPos = temp;

            //부모 본과 거리 유지 
            Vector3 dirFromParent = (bone.CurrentPos - parentPosition).normalized;
            bone.CurrentPos = dirFromParent * bone.DistanceToParent + parentPosition;
            bone.Target.position = bone.CurrentPos;

            bone.Acceleration = Vector3.zero;

            parentPosition = bone.CurrentPos;
        }
    }

    //본 사이 연결을 알 수 있게 DrawLine..
    private void OnDrawGizmos()
    {
        Vector3 parentPosition = _root.position;
        Gizmos.color = Color.yellow;
        for (int i = 0; i < _bones.Count; i++)
        {
            var bone = _bones[i];
            Gizmos.DrawLine(bone.CurrentPos, parentPosition);
            parentPosition = bone.CurrentPos;
        }
    }

}

첫번째 코드에서 바뀐점은 여러 VerletParticle을 연산하기 위해 VerletParticle를 List에 저장하고, 부모 본과 초기 거리를 유지하기 위한 코드가 추가된 점이다. 

부모 본과 초기 거리를 유지하기 위해 필요한 코드가, 단순히 CurrentPos를 거리 만큼 떨어뜨려 위치시킨 코드가 전부라는 점을 유념하자.

 

적당히 중력을 주고, 오브젝트를 배치하면..

오오.. 뭔가 진자 운동같은 효과를 볼 수 있다.


 

damping, stiffness 효과를 추가한 최종 코드는 다음과 같다.

using System;
using System.Collections.Generic;
using UnityEngine;
using static UnityEngine.GraphicsBuffer;

public class PhysicsBone : MonoBehaviour
{
    public class VerletParticle
    {
        public Transform Target;
        public Transform Parent;

        public Vector3 CurrentPos;
        public Vector3 PreviousPos;
        public Vector3 Acceleration;

        public Vector3 RestPosition; // 원래 위치 저장
        public Quaternion InitialLocalRotation; // 초기 로컬 회전값
        public Vector3 InitialDirection; // 부모로부터의 초기 방향
        public float DistanceToParent;

        public VerletParticle(Transform transform, Transform parent)
        {
            CurrentPos = transform.position;
            PreviousPos = transform.position;
            

            Acceleration = Vector3.zero;
            Target = transform;
            Parent = parent;

            RestPosition = transform.position;
            // 부모와의 거리 계산
            DistanceToParent = (transform.position - parent.position).magnitude;

            // 부모로부터의 초기 방향 저장
            InitialDirection = (transform.position - parent.position).normalized;

            // 초기 로컬 회전값 저장
            InitialLocalRotation = transform.localRotation;
        }
    }

    [SerializeField] private Transform _root;
    [Header("Params")]
    [Range(0f, 1f)][SerializeField] private float _damping = 0f;
    [SerializeField] private float _stiffness = 0f;
    [SerializeField] private Vector3 _gravity = Vector3.zero;

    private List<VerletParticle> _bones = new();
    private Vector3 _rootRestPosition; // 루트의 초기 위치

    void Start()
    {
        if (_root == null)
            _root = transform;

        _rootRestPosition = _root.position;
        SetBoneRecursive(_root);

        void SetBoneRecursive(Transform parent)
        {
            if (parent.childCount == 0)
                return;
            Transform child = parent.GetChild(0);
            var bone = new VerletParticle(child, parent);
            _bones.Add(bone);
            SetBoneRecursive(child);
        }
    }

    private void UpdatePosition(float dt)
    {
        Transform parentTransform = _root;
        Vector3 parentPosition = _root.position;
        Vector3 parentRestPosition = _rootRestPosition;

        for (int i = 0; i < _bones.Count; i++)
        {
            var bone = _bones[i];

            //중력 적용 
            bone.Acceleration += _gravity;

            //강성 적용 
            if (_stiffness > 0f)
            {
                Vector3 restOffset = bone.RestPosition - parentRestPosition;
                Vector3 targetRestPosition = parentPosition + restOffset;
                Vector3 stiffnessForce = (targetRestPosition - bone.CurrentPos) * _stiffness;
                bone.Acceleration += stiffnessForce;
            }

            Vector3 temp = bone.CurrentPos;
            Vector3 velocity = (bone.CurrentPos - bone.PreviousPos);

            //감쇠 적용 
            velocity *= (1 - _damping);

            //verlet 
            bone.CurrentPos += velocity + bone.Acceleration * dt * dt;
            bone.PreviousPos = temp;

            //부모 본과 거리 유지 
            Vector3 dirFromParent = (bone.CurrentPos - parentPosition).normalized;
            bone.CurrentPos = dirFromParent * bone.DistanceToParent + parentPosition;
            bone.Target.position = bone.CurrentPos;

            // 부모 본과 회전 유지
            Vector3 currentDirection = (bone.CurrentPos - parentTransform.position).normalized;
            Quaternion directionRotation = Quaternion.FromToRotation(bone.InitialDirection, currentDirection);
            Quaternion targetRotation = parentTransform.rotation * directionRotation * bone.InitialLocalRotation;
            bone.Target.rotation = targetRotation;

            bone.Acceleration = Vector3.zero;

            parentTransform = bone.Target;
            parentPosition = bone.CurrentPos;
            parentRestPosition = bone.RestPosition;
        }
    }

    //애니매이션 처리 이후 계산하기 위해 LateUpdate로 변경 
    private void LateUpdate()
    {
        UpdatePosition(Time.deltaTime);
    }

    private void OnDrawGizmos()
    {
        if (_root == null || _bones == null)
            return;

        Vector3 parentPosition = _root.position;
        Gizmos.color = Color.yellow;
        for (int i = 0; i < _bones.Count; i++)
        {
            var bone = _bones[i];
            Gizmos.DrawLine(bone.CurrentPos, parentPosition);
            parentPosition = bone.CurrentPos;
        }
    }
}

 

실제 캐릭터 메쉬에도 적용해보자!

 

적은 코드로 상당히 만족스러운 결과를 낸 것 같다!

여기서 조금만 더 응용하면, 충돌 처리부터 다양한 물리 파라미터를 추가할 수 있다. 

 


캐릭터의 애니매이션을 재생하면 메쉬가 뭉개지는 현상이 있다.. 아마 애니매이션에서 본의 위치와 회전을 매번 갱신해 주기 때문에 Start()함수에서 초기화 한 값들이 어긋나는 이유로 보인다.

 


참고 자료

https://www.youtube.com/watch?v=lS_qeBy3aQI&t=112s

'그래픽스, 게임 수학' 카테고리의 다른 글

텍스처 압축 포맷  (0) 2025.07.23
드로우 콜(Draw Call)  (1) 2025.06.04
Raytracing  (0) 2022.08.21
Backface Culling  (0) 2022.08.21
행렬식(determinant)  (0) 2022.08.21