물리 개체의 PID제어

강좌번역 2018. 10. 17. 21:53 by 빠재

원문: PID Control of Physics Bodies

이 기사에서는 멈춰 있는 상태이거나 이동하는 상태인가에 상관없이 물리 개체가 부드럽게 회전하도록 하는 데 대한 접근법을 다룹니다. 여기서는 cocos2d-x와 Box2D를 사용했지만 기본 원칙 자체는 어떤 물리 개체라도(심지어 3D에서 2D 평면 위의 회전을 하려고 한다면) 적용 가능합니다. 이 접근법은 비례-적분-미분(Proportional-Integral-Derivative, PID) 제어 루프를 사용하여 물체의 각도에 잘 제어가 되고 예측 가능한 방식으로 토크를 적용하는 것입니다. 동영상에서는 이 접근법을 이용한 두 가지 방법이 나오는데, 하나는 보고 있는 방향으로만 움직이는 미사일이고 다른 하나는 게임 캐릭터 같은 “개체”가 보는 방향과는 상관없이 움직이는 것입니다. 아래 동영상에서 그 행동을 보여줍니다. 무슨 말인지 이해해기 위해 동영상을 다 볼 필요는 없지만 볼만한 것들이 많습니다…

바라보기

여러분의 게임이 2D이건 3D이건 상관없이 종종 객체를 다른 방향을 보도록 돌려야 할 필요가 있을 겁니다. 그것은 캐릭터가 움직이는 도중의 걷는 방향이 될 수도 있고, 구부려 앉은 상태에서 총을 쏘는 방향이 될 수도 있고, 미사일이 날아가는 방향이 될 수도 있고, 자동차가 달리는 방향이 될 수도 있고, 그 외에도 많습니다. 이런 것들은 “캐릭터 컨트롤러”가 하는 작업인데, 캐릭터 컨트롤러는 이렇게 캐릭터가 겪어야 할 기본적인 움직임들(찾기, 돌기, 도착하기 등등)을 담당하는 코드입니다. 물리 엔진을 사용한 게임을 만들게 되면 더욱 사실적이 되고 게임의 재미가 증가하기 때문에 게임 플레이 경험이 극적으로 개선될 수 있습니다. 물체의 충돌, 부서짐, 회전, 튕겨내기, 움직임이 더욱 사실적으로 표현됩니다. 더욱 사실적으로 움직인다는 것은 대개 여러분이 생각하는 것처럼 보는 방향대로 움직이는 것이 아닙니다. 대부분의 관심은 “캐릭터가 0.5초만에 왼쪽을 보게 돌아야 한다”와 같은 것입니다. 물리적인 관점에서, 0.5초만에 왼쪽으로 90도만큼 돌게 토크를 준다는 것을 의미할까요? 정확한 지점에서 멈추기를 원하실 겁니다. 각운동량과 같은 것들은 나중에 반대 방향 토크를 주기 전까지는 계속 회전할 것이지만 그런 것들은 별로 생각하고 싶지 않을 겁니다. 정말로 딱 정확한 그 시점에 반대 토크를 주어 회전을 멈추는 것을 원하지도 않을 겁니다. Box2D에서는 여러분이 물체의 위치와 각도를 설정할 수 있게 해 줍니다. 하지만 여러분이 매 프레임마다 물리 개체의 위치와 방향을 설정한다면 (제 경험상) 물리 엔진의 충돌을 방해할 수 있습니다. 가장 중요한 것은, 이건 물리 엔진 위에서 돌아간다는 것입니다. 예상한 대로 물체가 움직이도록 사용해야 하는 것입니다. 우리의 목표는 회전 토크를 주어 물체의 보는 방향을 바꾸는 해결책을 만들어 내는 것입니다. 참고로 PID 제어는 많은 다른 용도로도 사용할 수 있습니다. 이 예제는 단지 제가 그 중 고른 하나일 뿐입니다. 만약 “어떻게 움직이는가”라는 문제에서 “어떻게 방향을 트는가”라는 문제를 분리해 낸다면 이 회전방법을 방향 제어가 필요한 다른 종류의 움직이는 물체에 대해서도 적용할 수 있습니다. 이 기사에서는 목표를 향해 움직이는 미사일을 고려해 보겠습니다.

여기에 주어진 속도와 방향대로 이동하는 미사일이 있습니다. 속도의 방향은 x축을 기준으로 측정됩니다. 미사일이 “바라보는 방향”은 바로 코가 보는 방향이고 미사일은 오로지 앞으로만 움직입니다. 이제 다른 각도에 있는 목표를 향해 미사일을 돌려야 합니다. 미사일이 타겟을 타격하기 위해서는 타겟이 잘 조준되어 있어야 합니다. 만약 타겟이 움직이지 않는 물체였다면 간단히 타겟의 x축 기준 각도를 이용하면 됩니다.

피드백 제어 시스템 101

제어 시스템의 기본 아이디어는 “어떤 값으로 만들 것인지”와 “지금 값이 어떤지”의 차이를 구하여 시스템에 입력을 조절하여 시간에 따라 원하는 값으로 수렴하게 하는 것입니다. 이 위키피디아 항목을 보면:

제어 루프의 친숙한 예 중 하나는 온수 및 냉수 수도꼭지(밸브)를 조절하여 원하는 온도로 물을 유지하는 것입니다. 대개 온수와 냉수의 흐름을 섞게 됩니다. 어떤 사람이 물을 만져 물의 온도를 느끼거나 재 보려고 할 것입니다. 이 피드백을 통해 원하는 온도로 안정될 때까지 온수와 냉수 밸브를 조절하는 제어 행동을 합니다.

제어 시스템 이론에는 거대한 지식군이 있습니다. 다항식들, 극점, 영점, 시간 영역, 주파수 영역, 상태 공간 등등.. 처음 보는 사람을 주눅들게 만들 수 있습니다. 물론 이미 아는 사람도 주눅들게 할 수 있습니다! 물론 방향 제어에 있어서 더욱 “현대적인” 해결 방법도 있지만 일단은 PID 제어에 집중하겠습니다. PID 제어는 세 개의 파라미터만을 “조절”하면 되고 직관적으로 “느끼면” 된다는 장점이 있습니다.

PID 제어

바라봐야 할 방향과 현재 물체의 방향/속도의 차이인 “제어”해야 할 기본 변수를 먼저 보겠습니다.

$$ e(t) = desired - actual $$

여기에서 $e(t)$는 “에러”로, 이 에러를 0으로 만들어야 합니다. 물체에 힘을 주어 일정한 방향으로 돌고 $e(t)$가 0이 되게 움직이도록 합니다. 그렇게 하기 위해 함수 $f(.)$을 만들고 $e(t)$를 입력으로 넣고 그 결과를 이용하여 물체에 토크를 줍니다. 토크가 물체를 움직이게 합니다:

$$ torque(t) = I * f(e(t)), I \equiv AngularInertia $$

비례 피드백

가장 먼저 나오고 가장 명확한 선택은 $e(t)$값 그 자체를 사용하는 것입니다. 에러가 큰 경우, 큰 힘이 작용합니다. 에러가 작은 경우, 작은 힘이 작용합니다. 이런 식입니다:

$$ f(e(t)) = K_p * e(t) $$

그리고 이 비례 피드백만으로 토크를 적용해도 동작할 것입니다. 문제는 에러가 작은 경우, 교정 토크도 작아진다는 것입니다. 따라서 물체가 회전하여 $e(t)$가 작아지면(원하는 각도에 가까워지면) 감속 토크도 작아집니다. 따라서 물체는 오버슛을 하게 되고 원했던 각도 이상으로 꺾게 됩니다. 그리고 다시 반대로 돌아 결국 안정될 때까지 진동하게 됩니다. 만약 $K_p$가 너무 크지 않다면 진동은 점점 감쇠하는(지수적으로 감소) 사인 곡선을 만들고, 각 진동마다 크게 크기가 감소할 것입니다(안정된 솔루션). 물론 점점 크게 발산할 수도 있고(불안정한 솔루션), 그냥 타겟 지점 부근에서 무한히 진동할 수도 있습니다(약간 안정된 솔루션). 만약 $K_p$값을 줄여 빠르게 움직이지 않게 한다면, $e(t)$가 큰 경우에 움직일 만큼의 큰 힘을 가지지 못하게 됩니다. 순수 비례 에러는 bias값(Steady State Error)을 가지고 있어 계속 최종 출력이 입력과 다르도록 합니다. $K_p$의 함수로 만든 에러는 이런 형식입니다:

$$ SteadyStateError = \frac{desired}{[1 + constant*K_p]} $$

따라서 $K_p$ 값을 증가시키는 것은 bias를 작게 하지만(좋음) 더 많은 진동을 만들게 합니다(나쁨).

적분 피드백

다음으로 볼 용어는 적분으로, PID의 “I” 부분입니다.

$$ f(e(t)) = {K_p * e(t)} + \int_{-\infty}^{now}{K_i * e(t)} $$

각 타임 스텝마다 $e(t)$값이 일정하다면 적분항은 다음 문제를 해결합니다. - 목표로의 방향이 갑자기 조금만 바뀌게 되면, 각 타임 스텝마다 그 차이가 쌓여 회전 토크를 만들게 됩니다. - 방향에 bias가 있다면(Steady State Error와 같은), 이것들이 타임 스텝마다 쌓여 반대 작용을 일으킬 것입니다.

적분항은 출력으로 나오는 어떤 상수 오프셋에도 대항할 수 있습니다. 일단 영향이 작긴 하지만 시간이 지남에 따라 그 값이 누적되고(적분) 점점 커져, 시간이 갈수록 더 강하게 밀어붙입니다. 정확한 적분값을 계산할 필요는 없습니다. 시간을 $-\infty$에서부터 가져올 뿐 아니라 곧 있을 결정에는 오래 전의 에러가 적은 영향을 미쳐야 하기 때문에 별로 사용하고 싶지 않을지도 모릅니다. 최근 몇 번의 사이클에서의 $e(t)$값만을 더해 타임 스텝마다 더해 가는 방법이나(오일러 적분) 다른 수치적인 방법으로 짧은 기간동안만의 적분값을 추정할 수 있습니다. 이 코드베이스에서는 합성 심프슨 법칙 기법을 사용했습니다.

미분 피드백

대부분의 PID 제어기들은 “PI”까지만 합니다. 비례항에서 입력의 방향을 바꾸게 하는 출력을 만들고 적분항에서 bias나 비례항을 방해하던 다른 지속되는 외부 힘들을 해결해 줍니다. 하지만, 출력 응답에서 계속 진동이 생깁니다. 필요한 것은 물체가 목표 각도로 회전하는 속도를 줄이는 방법입니다. 비례항과 적분항은 그 방향으로 돌게 하는 일을 합니다. $e(t)$의 미분을 봄으로, 최근의 그 값을 측정하고 바뀌지 않는 경향으로 힘을 주게 합니다. 바로 비례항과 적분항의 반대 토크(counter-torque)가 되는 것입니다.

$$ f(e(t)) = K_p * e(t) + \int_{-\infty}^{now}{K_i * e(t)dt} + {K_d * \frac{de(t)}{dt}} $$

$e(t)$가 진동하는 경우를 생각해 보겠습니다. 마치 사인 함수처럼 동작할 것입니다. 그것을 미분하면 코사인 함수가 되고 그 함수의 최대치는 $sin(e(t)) = 0$일 때입니다. 다시 말하면, 미분이 가장 클 때가 바로 $e(t)$가 우리가 원하던 방향을 지나칠 때인 것입니다. 반대로 진동이 끝자락에 있다면, 바로 방향을 바꾸려고 할 때, 바뀌는 정도가 양수에서 음수, 또는 그 반대일 것이고, 미분값이 가장 작을 때입니다(최소). 그렇기 때문에 미분항은 물체가 우리가 원하는 지점을 지날 때 가장 강한 반대방향 토크를 작용시킴으로 진동 현상에 대응하고, “지나침”의 끝부분에서 가장 작습니다. 적분항과 비슷하게, 미분항도 수치적으로 추정할 수 있습니다. 지난 몇 번의 $e(t)$값의 변화량을 추려서 구할 수 있습니다(코드에 나옵니다).

미분항을 사용하는 것은 대부분의 현실 제어 시스템에서 좋은 생각이 아닙니다. 센서의 노이즈가 $e(t)$가 급격히 왔다갔다 하는 것처럼 보이게 할 수 있어서 미분항 역시 급격히 왔다갔다 할 수 있습니다. 하지만 우리의 경우에는, 수치적인 문제가 있지 않는 이상 사용해도 문제가 되지 않습니다.

클래스와 순서도

우리들은 소프트웨어 개발자이기 때문에, PID 제어기에 무슨 알고리즘을 사용하든 사용하기 편리한 패키지로 감싸 깔끔한 인터페이스를 가지고 다른 불필요한 것들을 숨기려고 할 겁니다. 방향을 트는 개체가 이 인터페이스를 “가지고” 있어야 합니다.

MovingEntityInterface는 “움직이는 개체”를 의미합니다. 이 데모의 경우, 움직이는 개체는 *미사일*과 같이 앞으로만 움직이는 개체가 될 수 있고 “캐릭터”와 같이 움직이는 동안 방향을 틀 수 있는 개체가 될 수도 있습니다. 내부적으로는 “추진력을 작용”하는 방법은 다르지만 방향을 제어하는 방법은 거의 동일합니다. 이는 개체 타입에 더 적합한 “찾기” 동작을 구현할 수 있게 해 줍니다. 인터페이스 자체는 범용적이기 때문에 MainScene 클래스가 개체의 타입에 상관없이 이 인스턴스를 보유하고 조작할 수 있습니다. PIDController 클래스도 이 인터페이스를 가지고 있습니다:

/********************************************************************
* File : PIDController.h
* Project: Interpolator
*
********************************************************************
* Created on 10/13/13 By Nonlinear Ideas Inc.
* Copyright (c) 2013 Nonlinear Ideas Inc. All rights reserved.
********************************************************************
* This software is provided 'as-is', without any express or implied
* warranty. In no event will the authors be held liable for any
* damages arising from the use of this software.
*
* Permission is granted to anyone to use this software for any
* purpose, including commercial applications, and to alter it and
* redistribute it freely, subject to the following restrictions:
*
* 1. The origin of this software must not be misrepresented; you must
* not claim that you wrote the original software. If you use this
* software in a product, an acknowledgment in the product
* documentation would be appreciated but is not required.
* 2. Altered source versions must be plainly marked as such, and
* must not be misrepresented as being the original software.
* 3. This notice may not be removed or altered from any source
* distribution.
*/
#ifndef __Interpolator__PIDController__
#define __Interpolator__PIDController__
#include "CommonSTL.h"
#include "MathUtilities.h"

/* This class is used to model a Proportional-
* Integral-Derivative (PID) Controller. This
* is a mathemtical/control system approach
* to driving the state of a measured value
* towards an expected value.
*
*/
class PIDController
{
private:
    double _dt;
    uint32 _maxHistory;
    double _kIntegral;
    double _kProportional;
    double _kDerivative;
    double _kPlant;
    vector _errors;
    vector _outputs;
    enum
    {
        MIN_SAMPLES = 3
    };

    /* Given two sample outputs and
    * the corresponding inputs, make
    * a linear pridiction a time step
    * into the future.
    */
    double SingleStepPredictor(
        double x0, double y0,
        double x1, double y1,
        double dt) const
    {
        /* Given y0 = m*x0 + b
        * y1 = m*x1 + b
        *
        * Sovle for m, b
        *
        * => m = (y1-y0)/(x1-x0)
        * b = y1-m*x1
        */
        assert(!MathUtilities::IsNearZero(x1-x0));
        double m = (y1-y0)/(x1-x0);
        double b = y1 - m*x1;
        double result = m*(x1 + dt) + b;
        return result;
    }

    /* This funciton is called whenever
    * a new input record is added.
    */
    void CalculateNextOutput()
    {
        if(_errors.size() < MIN_SAMPLES)
        { // We need a certain number of samples
            // before we can do ANYTHING at all.
            _outputs.push_back(0.0);
        }
        else
        { // Estimate each part.
            size_t errorSize = _errors.size();
            // Proportional
            double prop = _kProportional * _errors[errorSize-1];
            // Integral - Use Extended Simpson's Rule
            double integral = 0;
            for(uint32 idx = 1; idx < errorSize-1; idx+=2)
            {
                integral += 4*_errors[idx];
            }
            for(uint32 idx = 2; idx < errorSize-1; idx+=2)
            {
                integral += 2*_errors[idx];
            }
            integral += _errors[0];
            integral += _errors[errorSize-1];
            integral /= (3*_dt);
            integral *= _kIntegral;
            // Derivative
            double deriv = _kDerivative * (_errors[errorSize-1]-_errors[errorSize-2]) / _dt;
            // Total P+I+D
            double result = _kPlant * (prop + integral + deriv);
            _outputs.push_back(result);
        }
    }

public:
    void ResetHistory()
    {
        _errors.clear();
        _outputs.clear();
    }

    void ResetConstants()
    {
        _kIntegral = 0.0;
        _kDerivative = 0.0;
        _kProportional = 0.0;
        _kPlant = 1.0;
    }

    PIDController() :
        _dt(1.0/100),
        _maxHistory(7)
    {
        ResetConstants();
        ResetHistory();
    }

    void SetKIntegral(double kIntegral) { _kIntegral = kIntegral; }
    double GetKIntegral() { return _kIntegral; }
    void SetKProportional(double kProportional) { _kProportional = kProportional; }
    double GetKProportional() { return _kProportional; }
    void SetKDerivative(double kDerivative) { _kDerivative = kDerivative; }
    double GetKDerivative() { return _kDerivative; }
    void SetKPlant(double kPlant) { _kPlant = kPlant; }
    double GetKPlant() { return _kPlant; }
    void SetTimeStep(double dt) { _dt = dt; assert(_dt > 100*numeric_limits::epsilon());}
    double GetTimeStep() { return _dt; }
    void SetMaxHistory(uint32 maxHistory) { _maxHistory = maxHistory; assert(_maxHistory >= MIN_SAMPLES); }
    uint32 GetMaxHistory() { return _maxHistory; }
    void AddSample(double error)
    {
        _errors.push_back(error);
        while(_errors.size() > _maxHistory)
        { // If we got too big, remove the history.
            // NOTE: This is not terribly efficient. We
            // could keep all this in a fixed size array
            // and then do the math using the offset from
            // the beginning and module math. But this
            // gets complicated fast. KISS.
            _errors.erase(_errors.begin());
        }
        CalculateNextOutput();
    }
    double GetLastError() { size_t es = _errors.size(); if(es == 0) return 0.0; return _errors[es-1]; }
    double GetLastOutput() { size_t os = _outputs.size(); if(os == 0) return 0.0; return _outputs[os-1]; }
    virtual ~PIDController()
    {
    }
};

사용하기 아주 간단한 클래스입니다. 필요한 경우 SetKXXX류의 함수를 호출하여 설정할 수 있고, 적분항의 타임 스텝도 설정하고, 업데이트 사이클마다 AddSample(...) 에러와 함께 호출하면 됩니다. 이 클래스의 인스턴스를 가지고 있는 Missile 클래스에 있는 스텝 업데이트 함수(Update 함수)는 다음과 같습니다:

void ApplyTurnTorque()
{
    Vec2 toTarget = GetTargetPos() - GetBody()->GetPosition();
    float32 angleBodyRads = MathUtilities::AdjustAngle(GetBody()->GetAngle());
    if(GetBody()->GetLinearVelocity().LengthSquared() > 0)
    { // Body is moving
        Vec2 vel = GetBody()->GetLinearVelocity();
        angleBodyRads = MathUtilities::AdjustAngle(atan2f(vel.y,vel.x));
    }

    float32 angleTargetRads = MathUtilities::AdjustAngle(atan2f(toTarget.y, toTarget.x));
    float32 angleError = MathUtilities::AdjustAngle(angleBodyRads - angleTargetRads);
    _turnController.AddSample(angleError);

    // Negative Feedback
    float32 angAcc = -_turnController.GetLastOutput();

    // This is as much turn acceleration as this
    // "motor" can generate.
    if(angAcc > GetMaxAngularAcceleration())
        angAcc = GetMaxAngularAcceleration();

    if(angAcc < -GetMaxAngularAcceleration())
        angAcc = -GetMaxAngularAcceleration();

    float32 torque = angAcc * GetBody()->GetInertia();
    GetBody()->ApplyTorque(torque);
}

뉘앙스

동영상을 자세히 보셨다면 길찾기를 할 때 미사일과 캐릭터(코드에서는 MovingEntity)간에 명확한 차이가 있습니다. 미사일은 경로를 쉽게 이탈하는데, 특히 최대 회전율이 작아졌을 경우에 더욱 그러합니다. MovingEntity언제나 더 정확히 점들을 향해 가는데 이것은 객체가 현재 위치의 “벡터 피드백”과 목표 위치를 사용해 속도를 조절하기 때문입니다. 이것은 미사일보다는 고전적인 “찾기” 행동에 더 가깝습니다. 저는 또한 의도적으로 PID 제어기의 상수들을 어떻게 튜닝하는가에 대한 핵심 정보들을 조금 남겨 놓았습니다. 구글에 찾아보시면 PID 제어 루프를 튜닝하는 방법에 대한 엄청난 수의 글들이 있고 저는 이 기사가 다 끝난 뒤 여러분이 해 볼 수 있도록 뭔가를 남겨두어야 했습니다. 또한 _dt로 되어 있는 타임 스텝의 기본값이 0.01초로 되어 있습니다. 이 값도 조절해서 여러분이 실제로 필요하는 타임 스텝이 되게 할 수 있지만 수치 시뮬레이션에서 겪게 될 몇 가지 트레이드오프들이 있습니다(에러 반올림, 시스템 대역폭 등). 저는 연습으로 PIDController 클래스 같은 컨트롤러와 같은 상수값들로 여러 크기의 물리 개체를 변형 없이 적용해 보았는데 동작이 (매우)충분히 사실적이어서 더 나아가 미세 조정을 할 필요가 없었습니다. 이 소스코드는 cocos2d-x와 C++로 작성되었고, 깃허브 저장소에서 받을 수 있습니다. PIDController 클래스는 표준 라이브러리 이외에는 아무런 의존성이 없기 때문에 어느 시스템에나 적용할 수 있습니다.

'강좌번역' 카테고리의 다른 글

[번역] 톤 매핑  (2) 2022.02.11
[번역] 자동 노출  (0) 2022.02.11
(번역) Cassandra와 MongoDB 비교  (0) 2022.02.02
그래픽스 API 선택하기  (2) 2015.09.06
Nav