꽃미남 프로그래머 김포프가 창립한 탑 프로그래머 양성 교육 기관 POCU 아카데미 오픈!
절찬리에 수강생 모집 중!
프로그래밍 언어 입문서가 아닌 프로그래밍 기초 개념 입문서
문과생, 비전공자를 위한 프로그래밍 입문책입니다.
jobGuid 꽃미남 프로그래머 "Pope Kim"님의 이론이나 수학에 치우치지 않고 실무에 곧바로 쓸 수 있는 실용적인 셰이더 프로그래밍 입문서 #겁나친절 jobGuid "1판의내용"에 "새로바뀐북미게임업계분위기"와 "비자관련정보", "1판을 기반으로 북미취업에 성공하신 분들의 생생한 경험담"을 담았습니다.
Posted by rihwan

1.

1. Introduction

3D 캐릭터 에니메이션을 제작하는 데에는 지금까지 주로 2 가지 방법이 사용되고 있습니다. 하나는 Modeling Software, 예를 들면 3dsmax, Maya, Blender 등을 이용하여 에니메이션 제작하는 방법이고, 다른 한 가지는 Motion Capture System (MoCap)을 사용하여 에니메이션을 제작하는 방법입니다.


위는 3ds max 스크린 샷입니다. (원본 이미지: http://www.spatialview.com/product/3dsmax/)


Modeling Software의 장점은 매우 유연하다는데 있습니다. 거의 모든 부분을 컨트롤할 수 있습니다. 이런 유연성 중 하나로 다양한 형태의 에니메이션 (Biped, Bone, Mesh Deformation등)을 지원하고 있습니다. 하지만 몇 가지 단점도 가지고 있는데, 그 중 하나는 거의 모든 영역에 대해서 컨트롤할 수 있게 해주는만큼 배워야 하는 기능의 양도 많다는 거죠. 아무리 쉽게 만드려고 해도 사실 모델러 또는 에니메이터 분들은 엄청난 양의 기능 및 활용 방법에 대해서 배워야 합니다. 또한 그 정보를 2D 화면 위에 표현해낼 수 있어야 하지요. 또 다른 단점 중 하나는 에니메이션의 제작 시간이 오래 걸린다는데 있습니다. 실제 에니메이션을 제작하는 방법은 우선 가장 중요한 몇 개의 키 프레임 정보를 만들어낸 이후에 중간 중간의 프레임을 채워넣는 형태로 에니메이션을 만들어냅니다. 이 과정에서 하나 하나의 키 프레임을 만드는 작업은 매우 손이 많이 가며, 그에 따라 시간도 매우 오래 걸리게 됩니다. 이렇게 손으로 만들어낸 에니메이션 정보는 "과장된 표현"을 할 수 있어 게임을 제작할 때 장점이 됩니다. 타이밍 등의 정보를 바꾸어 과장되게 표현해냄으로서 게임의 재미를 좀 더 향상시킬 수 있게 되지요.


위 이미지는 모션 캡쳐 스크린 샷입니다(원본 이미지: http://freemotionfiles.blogspot.com/2009/05/motion-capture-technology.html)

위 Modeling Software의 단점인 배우는 시간, 에니메이션 제작 시간, 및 어려움을 해결하기 위해서 개발된 방법이 MoCap 입니다. 많은 분들이 알고 계시겠지만, MoCap은 사람 또는 동물의 몸의 중요한 위치에 센서를 부착한 이후에 Capturing Hardware를 이용하여 센서의 위치 정보와 축의 회전 정보를 얻어내고 기록합니다. 이렇게 얻은 정보는 확실히 손에 비하여 훨씬 자연스럽고 빠르게 에니메이션 정보를 만들어낼 수 있다는 장점이 있지만, Pre-processing, Post-processing등을 필요로 하며, "과장된 표현"을 해낼 수 없다는 단점이 있지요. Post-processing의 경우는 얻어낸 정보에 항상 Noise 등이 존재하며 매우 짧은 시간을 기준으로 데이터를 끊임없이 저장하기 때문에 엄청난 양의 비슷한 정보가 저장되기에 반드시 필요한 과정입니다.

지금까지 게임 내에서 MoCap을 적극적으로 사용한 경우는 많이 보지 못했습니다. 일부 댄스 게임의 경우는 사용하는 것으로 보이지만 확실하지는 않습니다. 잘 사용되지 못했던 이유 중 하나는 MoCap을 위해서 비싼 Hardware와 Software를 사야 하며, 또한 그 이후에도 Post-processing, 그리고 에니메이터 분들이 다듬는 작업 등이 필요로 하기 때문으로 보입니다.


2. Motion Database

하지만 몇 가지 검색해본 결과 얻어낸 정보로, 이미 Carnegie Mellon University (CMU)에서 엄청난 양의 MoCap Database를 공개하고 있습니다. 라이센스는 연구 목적으로는 완전 free 이외의 경우는 데이터 자체를 바로 수는 없습니다. 하지만 실제 판매되는 제품에 포함시킬 수는 있다고 되어 있네요. 부분은 조금 애매할 있으므로 사용을 하려고 하시는 경우에는 직접 문의 해보심이 좋을 합니다. 해당 데이터 베이스의 링크는 아래에 있습니다.

http://mocap.cs.cmu.edu/

직접 한번 어떤 데이터가 있는지 보고 싶으시면 아래의 링크에 가셔서 mpg와 animated를 클릭해보시면 어떤 에니메이션 데이터가 있는지 보실 수 있습니다.

http://mocap.cs.cmu.edu/search.php?subjectnumber=%&motion=%

어느 정도의 데이터가 있을까요? 약 2400개가 넘는 모션이 존재합니다. 2400개의 모션은 하나의 모션마다 약 1000개 이상의 키프레임이 존재합니다. 제가 모두 계산해본 결과 약 4,000,000개가 넘는 키 프레임이 전체 데이터 베이스 안에 존재합니다. 만약 저 데이터를 게임에 활용할 수 있다면 확실히 엄청난 양의 모션을 에니메이터 분들이 매번 손으로 다 만들지 않아도 되겠네요! 또한 게임 중간에 필요한 에니메이션을 찾아야 한다면 저 데이터베이스에서 검색해서 해당 에니메이션으로 치환할 수도 있겠네요. 물론 그만큼 작업은 해줘야 하겠지요.

위 데이터 베이스 안에 존재하는 파일 포맷은 사실 3dsmax와 Maya등에서 로딩하여 사용할 수 있습니다. 하지만 그렇게 하는 경우, 너무나 많은 키 프레임과 Noise들로 인해서 에니메이터 분들이 쉽게 컨트롤 하기 힘들게 되어 있습니다. 그에 반해 우리가 직접 로딩하여 사용할 수 있다면 필요한 부분만 쉽게 자르고 붙이고 다시 샘플링할 수 있다는 장점이 있지요.

밑에는 제가 직접 로딩하고 렌더링해본 결과 입니다. 데이터 베이스 안에는 뼈대 데이터만 존재합니다. 만약 스키닝을 해야 한다면 3dsmax나 Maya에서 Rigging (뼈대와 스킨을 연결하는 작업)을 따로 해줘야 합니다.



위 예제는 아주 일부만을 보여드린 것이며, 사실 데이터 베이스 안에는 걷기, 달리기, 점프, 앉기, 권투, 농구, 축구, 힙합 댄스, 발레 댄스 등등 엄청나게 다양한 모션이 존재하고 있습니다.

데이터 베이스에서 사용하고 있는 파일 포맷은 Acclaim File Format 입니다. 해당하는 포맷은 Acclaim이라는 게임 회사에서 처음 만들어낸 포맷으로 보이며, ASF (Acclaim Skeleton File), AMC (Acclaim Motion Capture Data)라는 2개의 다른 파일로 정의되어 있습니다. 모두 문자열 기반의 Text 파일이기에 눈으로 확인하기에도 편합니다.

데이터 베이스는 아래에서 한번에 다운로드가 가능합니다.
http://mocap.cs.cmu.edu:8080/allasfamc.zip

3. ASF/AMC (Acclaim Motion File Format) 파싱 하기

이제 ASF/AMC 파일을 파싱하도록 해보죠. ASF 파일은 뼈대에 대해 정의해놓은 파일입니다. 뼈대의 각각의 기본적인 위치 정보, 회전 정보, 그리고 뼈대의 연결 정보로 구성되어 있습니다. AMC 파일은 정확히 Motion의 키 프레임 정보만을 담고 있는데, ASF 파일과 연계되어 있습니다. 그래서 각 키 프레임의 회전과 위치 정보를 담고 있습니다. AMC를 로딩하기 위해서 ASF에 정의되어 있는 형태대로 로딩해줘야 하므로, 먼저 ASF를 제대로 로딩해야 AMC를 파싱해낼 수 있습니다.


http://research.cs.wisc.edu/graphics/Courses/cs-838-1999/Jeff/ASF-AMC.html

ASF 파일 안에 있는 중요한 정보로는 units, root, bonedata, hierarchy가 있습니다. 우선 간단히 1개의 ASF 파일을 전체를 훑어보고 소스 코드와 함께 맞춰보도록 하겠습니다. 아래는 ASF 파일 중 일부를 여기에 올려보았습니다.

# AST/ASF file generated using VICON BodyLanguage
# -----------------------------------------------
:version 1.10
:name VICON
:units
  mass 1.0
  length 0.45
  angle deg
:documentation
   .ast/.asf automatically generated from VICON data using
   VICON BodyBuilder and BodyLanguage model FoxedUp or BRILLIANT.MOD
:root
   order TX TY TZ RX RY RZ
   axis XYZ
   position 0 0 0 
   orientation 0 0 0
:bonedata
  begin
     id 1
     name lhipjoint
     direction 0.566809 -0.746272 0.349008
     length 2.40479
     axis 0 0 0  XYZ
  end
  begin
     id 2
     name lfemur
     direction 0.34202 -0.939693 0 
     length 7.1578 
     axis 0 0 20  XYZ
    dof rx ry rz
    limits (-160.0 20.0)
           (-70.0 70.0)
           (-60.0 70.0)
  end
:hierarchy
  begin
    root lhipjoint rhipjoint lowerback
    lhipjoint lfemur
    lfemur ltibia
    ltibia lfoot
    lfoot ltoes
    rhipjoint rfemur
    rfemur rtibia
    rtibia rfoot
    rfoot rtoes
    lowerback upperback
    upperback thorax
    thorax lowerneck lclavicle rclavicle
    lowerneck upperneck
    upperneck head
    lclavicle lhumerus
    lhumerus lradius
    lradius lwrist
    lwrist lhand lthumb
    lhand lfingers
    rclavicle rhumerus
    rhumerus rradius
    rradius rwrist
    rwrist rhand rthumb
    rhand rfingers
  end

하나씩 살펴보이죠. 가장 먼저 ASF 파일과 AMC 파일은 모두 줄 단위로 정보가 기록되어 있습니다. 그러므로 한 줄씩 읽어나가면 됩니다. 우선 #으로 시작되는 영역은 주석입니다. 그러므로 그냥 한 줄을 읽고 버리면 됩니다. ASF 파일 안에는 7 개의 다른 Block이 있는데 각각 :version, :name, :documentation, :units, :root, :bonedata, :hierarchy 입니다. 가장 초기에 나와 있는 :version, :name, :documentation은 사실 파싱할 필요가 없습니다. 현재 우리에게 필요없는 데이터이므로 읽고 버려도 됩니다. 이래 위 파일을 로딩하기 위하여 아래에 loadAcclaimSkeletonFile라는 파싱 함수를 정의해놓았습니다. 들어가는 인자는 ASF 파일의 이름 (전체 Path 포함)입니다. 해당하는 함수 안을 살펴보면 각 Block에 맞춰서 함수를 하나씩 정의하고 있습니다. parseAsfVersion, parseAsfName, parseAsfUnits, parseAsfDocumentation, parseAsfRoot, parseAsfBoneData, parseAsfHierarchy 라는 이름으로 총 7 개의 파싱 함수를 정의해놓았습니다.

#include <tchar.h>

#ifdef _UNICODE
namespace std
{
    typedef basic_string<wchar_t>            tstring;
    typedef basic_ifstream<wchar_t>            tifstream;
}
#else
namespace std
{
    typedef basic_string<char>                tstring;
    typedef basic_ifstream<char>            tifstream;
}
#endif

typedef std::vector<std::tstring> TStringArray;

void SkipEmptyChar(std::tistream& in_stream) {
    TCHAR ch = _T('\0');
    while (in_stream) {
        ch = in_stream.peek();
        if (_T(' ') == ch || _T('\t') == ch || _T('\r') == ch || _T('\n') == ch)
            in_stream.get();
        else
            break;
    }
}

bool Skeleton::loadAcclaimSkeletonFile(const std::tstring& sFilePath)
{
    std::map< std::tstring, TStringArray > hierarchy;
    std::tifstream fin;
    fin.open(sFilePath.c_str());
    if (fin.is_open()) {
        TCHAR ch; // 임시로 읽어들일 캐릭터 타입
        std::tstring sBuffer; // 임시로 읽어들일 문자열 버퍼
        bool bResult = true;

        while (fin && bResult) {
            SkipEmptyChar(fin);

            ch = fin.get();
            if (!fin || fin.eof())
                break;
            else if (ch <= 0)
                break;

            if (_T('#') == ch) {
                std::getline(fin, sBuffer);
                continue;
            }
            else if (_T(':') == ch) {
                fin >> sBuffer;
                if (_T("version") == sBuffer) {
                    if (!parseAsfVersion(fin))
                        bResult = false;
                }
                else if (_T("name") == sBuffer) {
                    if (!parseAsfName(fin))
                        bResult = false;
                }
                else if (_T("units") == sBuffer) {
                    if (!parseAsfUnits(fin))
                        bResult = false;
                }
                else if (_T("documentation") == sBuffer) {
                    if (!parseAsfDocumentation(fin))
                        bResult = false;
                }
                else if (_T("root") == sBuffer) {
                    if (!parseAsfRoot(fin))
                        bResult = false;
                }
                else if (_T("bonedata") == sBuffer) {
                    if (!parseAsfBoneData(fin))
                        bResult = false;
                }
                else if (_T("hierarchy") == sBuffer) {
                    if (!parseAsfHierarchy(fin, hierarchy))
                        bResult = false;
                }
                else
                    assert(false);
            }
            else
                assert(false);
        }

        fin.clear();
        fin.close();

        return bResult;
    }
    else {
        return false;
    }
}

로딩을 시작하기 전에 우선 사용하고 있는 구조체 정보를 알아야 하므로...

struct ASF_Joint
{
    unsigned int nJointIndex;
    std::tstring sJointName;
    ENUM_ROTATION_TYPE eRotOrder;

    double dPosition[3]; // Relative Position
    double dOrientation[3]; // Euler Angles

    std::tstring sAmcOrder[7]; // AMC Information Orders
    unsigned int nAmcOrderCount; // AMC Information Count

    double dLimits[7][2]; // Constraints. Just information

    ASF_Joint();
    ASF_Joint(const ASF_Joint& rhs);
    ASF_Joint& operator=(const ASF_Joint& rhs);
    void clear();
};

struct AMC_Keyframe
{
    unsigned int nKeyframeIndex;
    double dValues[7];

    AMC_Keyframe();
    AMC_Keyframe(const AMC_Keyframe& rhs);
    AMC_Keyframe& operator=(const AMC_Keyframe& rhs);
    void clear();
};

typedef std::vector<ASF_Joint> AsfJointArray;
typedef std::vector<AMC_Keyframe> AmcKeyframeArray;
typedef std::unordered_map<std::tstring, AmcKeyframeArray> NameAmcKeyframeArray;

따로 하나씩 살펴보지요. 우선 :units 입니다. :units는 ASF 파일에서 사용하고 있는 단위를 정의해놓고 있는 Block 입니다. mass는 질량 정보를, length는 전체 뼈대의 길이에 모두 곱해져야할 양를 그리고 angle은 앞으로 나올 회전 정보가 degree 단위로 되어 있는지 아니면 radian 단위로 되어 있는지를 보여주고 있습니다. length를 앞으로 나올 뼈대의 길이에 곱해주어야 정확한 meter 혹인 inch 값을 얻을 수 있습니다. 하지만 정확한 길이에 관심이 없다면 그냥 무시해도 관계는 없습니다.

:units
  mass 1.0
  length 0.45
  angle deg

로딩 함수는 다음과 같습니다. mass_, globalLengthMultiplier_, degreeAngle_은 모두 Skeleton 클래스 내부 변수입니다.

bool Skeleton::parseAsfUnits(std::tifstream& fin)
{
    std::tstring sBuffer;

    fin >> sBuffer;
    assert(_T("mass") == sBuffer);
    fin >> mass_;

    fin >> sBuffer;
    assert(_T("length") == sBuffer);
    fin >> globalLengthMultiplier_;

    fin >> sBuffer;
    assert(_T("angle") == sBuffer);
    fin >> sBuffer;

    std::transform(sBuffer.begin(), sBuffer.end(), sBuffer.begin(), ToUpper());

    if (_tcsncmp(sBuffer.c_str(), _T("DEG"), 3) == 0)
        degreeAngle_ = true;
    else
        degreeAngle_ = false;

    return true;
}

이제 :root를 살펴봐야 합니다. 이 정보부터는 앞으로 AMC와 연계되어 있으며 매우 세심히 다뤄주어야 합니다. 또한 일반적으로 :root는 사람의 뼈대 중에서 가장 중요한 위치인 pelvis (배꼽 아래, 엉덩이 살짝 위)입니다. 보통 사람 뼈대를 정의할 때 항상 Pelvis를 중심으로 정의하며, pelvis를 움직여서 전체 위치를 이동하고는 합니다.


:root
   order TX TY TZ RX RY RZ
   axis XYZ
   position 0 0 0 
   orientation 0 0 0

1. order는 AMC 파일 안에 어떤 순서로 데이터가 들어가 있는지를 설명해주고 있습니다. 이 정보가 있어야 AMC의 데이터를 읽을 수가 있지요.
2. axis는 Euler 회전의 순서를 담고 있습니다. XYZ 순서로 회전 행렬을 곱해주어야 정확한 회전 값을 얻을 수 있지요.
3. position은 가장 기본적인 위치 정보를 담고 있습니다.
4. orientation은 각각 X, Y, Z 축의 회전양 정보를 담고 있지요.

bool Skeleton::parseAsfRoot(std::tifstream& fin)
{
    ASF_Joint asf_joint;
    asf_joint.nJointIndex = 0;
    asf_joint.sJointName = _T("root");

    std::tstring sBuffer;

    int count = 0;
    while (count < 4)
    {
        fin >> sBuffer;
        if (_T("order") == sBuffer)
        {
            std::tstring sAmcOrder;
            std::getline(fin, sAmcOrder);

            parseAsfAmcOrder(sAmcOrder, asf_joint);
        }
        else if (_T("axis") == sBuffer)
        {
            std::tstring sAxis;
            fin >> sAxis;
            asf_joint.eRotOrder = GetEulerRotationTypeFromString(sAxis);
        }
        else if (_T("position") == sBuffer)
        {
            fin >> asf_joint.dPosition[0];
            fin >> asf_joint.dPosition[1];
            fin >> asf_joint.dPosition[2];
        }
        else if (_T("orientation") == sBuffer)
        {
            fin >> asf_joint.dOrientation[0];
            fin >> asf_joint.dOrientation[1];
            fin >> asf_joint.dOrientation[2];

            if (degreeAngle_)
            {
                asf_joint.dOrientation[0] = asf_joint.dOrientation[0] * 3.141592 / 180.0;
                asf_joint.dOrientation[1] = asf_joint.dOrientation[1] * 3.141592 / 180.0;
                asf_joint.dOrientation[2] = asf_joint.dOrientation[2] * 3.141592 / 180.0;
            }
        }
        else
        {
            assert(false);
            return false;
        }

        count += 1;
    }

    assert(joints_.empty());
    joints_.push_back(Joint(this));
    joints_.back().setJointIndex(static_cast<unsigned int>(joints_.size()) - 1);
    return joints_.back().createJointFromAsf(asf_joint);
}

void Skeleton::parseAsfAmcOrder(const std::tstring& sAmcOrder, ASF_Joint& asf_joint)
{
    unsigned int index = 0;
    for (size_t i = 0; i < sAmcOrder.size(); ++i)
    {
        if (index >= 7)
            break;

        if (sAmcOrder[i] == _T(' ')  || sAmcOrder[i] == _T('\t') ||
            sAmcOrder[i] == _T('\r') || sAmcOrder[i] == _T('\n'))
            continue;
        else if (sAmcOrder[i] == _T('r') || sAmcOrder[i] == _T('R'))
        {
            if (sAmcOrder[i+1] == _T('x') || sAmcOrder[i+1] == _T('X'))
            {
                i += 1;
                asf_joint.sAmcOrder[index] = _T("RX");
                index += 1;
                continue;
            }
            else if (sAmcOrder[i+1] == _T('y') || sAmcOrder[i+1] == _T('Y'))
            {
                i += 1;
                asf_joint.sAmcOrder[index] = _T("RY");
                index += 1;
                continue;
            }
            else if (sAmcOrder[i+1] == _T('z') || sAmcOrder[i+1] == _T('Z'))
            {
                i += 1;
                asf_joint.sAmcOrder[index] = _T("RZ");
                index += 1;
                continue;
            }
            else
                assert(false);
        }
        else if (sAmcOrder[i] == _T('t') || sAmcOrder[i] == _T('T'))
        {
            if (sAmcOrder[i+1] == _T('x') || sAmcOrder[i+1] == _T('X'))
            {
                i += 1;
                asf_joint.sAmcOrder[index] = _T("TX");
                index += 1;
                continue;
            }
            else if (sAmcOrder[i+1] == _T('y') || sAmcOrder[i+1] == _T('Y'))
            {
                i += 1;
                asf_joint.sAmcOrder[index] = _T("TY");
                index += 1;
                continue;
            }
            else if (sAmcOrder[i+1] == _T('z') || sAmcOrder[i+1] == _T('Z'))
            {
                i += 1;
                asf_joint.sAmcOrder[index] = _T("TZ");
                index += 1;
                continue;
            }
            else
                assert(false);
        }
        else if (sAmcOrder[i] == _T('l') || sAmcOrder[i] == _T('L'))
        {
            asf_joint.sAmcOrder[index] = _T("l");
            index += 1;
            continue;
        }
        else
            assert(false);
    }

    assert(0 == asf_joint.nAmcOrderCount);
    asf_joint.nAmcOrderCount = index;
}

위 함수에서는 :root에 들어있는 모든 정보를 로딩하고 있습니다. axis의 경우는 내부적인 enumeration 형태로 변환시켜주고 있습니다. 총 6개의 다른 Euler rotation이 존재할 수 있는데, XYZ, XZY, YXZ, YZX, ZXY, ZYX가 있으며, 각각 어떤 회전 행렬을 먼저 곱해줘야 하는지를 표현해주고 있지요.

또한 order의 경우는 parseAsfAmcOrder 함수를 이용하여 어떤 순서로 AMC의 키 프레임이 기록되어 있는지를 미리 저장해놓고 있습니다.

orientation의 경우는 degree인 경우 모두 radian을 기준으로 회전 값을 변환하여 저장해놓고 있습니다. radian으로 하면 나중에 sin, cos 함수를 사용하여 편리하기 때문이죠.

:bonedata
  begin
     id 1
     name lhipjoint
     direction 0.566809 -0.746272 0.349008
     length 2.40479
     axis 0 0 0  XYZ
  end
  begin
     id 2
     name lfemur
     direction 0.34202 -0.939693 0 
     length 7.1578 
     axis 0 0 20  XYZ
    dof rx ry rz
    limits (-160.0 20.0)
           (-70.0 70.0)
           (-60.0 70.0)
  end

bonedata는 매우 길어 일부만 여기에 올려봅니다. begin과 end 사이가 하나의 joint (bone)을 의미하고 있습니다.

1. id는 해당하는 뼈대의 인덱스인데, 1부터 시작하며 크게 의미가 없습니다.
2. name은 뼈대의 이름입니다. 이 이름을 잘 저장해놔야 나중에 hierarchy에서 저 이름을 가지고 tree structure를 만들어낼 수 있습니다.
3. length는 뼈대의 부모 joint로부터 해당하는 joint까지의 거리를 의미합니다.
4. direction은 뼈대의 부모로부터의 상대적인 방향 벡터입니다. 이 벡터와 length를 곱하고 또 :units에 존재했던 전체 length를 곱하게 되면 정확한 미터 혹은 센티미터 단위를 얻을 수 있습니다(이 단위는 상황에 따라 다르게 저장하는 것으로 보입니다)
5. axis는 부모로부터의 상대적인 회전 값입니다. axis의 제일 뒤에는 XYZ등의 문자열이 있는데 이 문자열은 어떤 순서로 Euler 회전을 처리해야 하는지 알려줍니다. 부모와 이 행렬을 곱하게 되면 해당하는 bone의 기본적인 3개의 축을 추출할 수 있습니다.
6. dof는 degree of freedom의 약자입니다. 여기에서는 rx, ry, rz라고 되어 있는데 이 값이 중요한 이유는 여기 나온 순서대로 amc 파일에 정의되어 있기 때문입니다. 해당하는 값을 파싱하여 Euler 회전 순서로 곱해야 정확한 euler rotation을 얻어낼 수 있습니다.
7. limits는 추가적인 정보입니다. 향후 직접 joint를 회전 처리할 때 정의된 회전 값 범위 내에서 회전해야 한다는 것인데, 이 값을 amc 파일에 적용하는 것은 아닙니다. 그냥 로딩해서 버려도 무방한 값입니다.

bool Skeleton::parseAsfBoneData(std::tifstream& fin)
{
    std::tstring sBuffer;
    while (fin)
    {
        SkipEmptyChar(fin);

        TCHAR ch = fin.peek();
        if (_T(':') == ch)
            break;
        else if (ch <= 0)
            break;
        else if (!fin || fin.eof())
            break;

        fin >> sBuffer;
        assert(_T("begin") == sBuffer);
        if (_T("begin") != sBuffer)
            return false;

        ASF_Joint asf_joint;

        double direction[3] = { 0.0, 0.0, 0.0 };
        double length = 0.0;

        while (fin)
        {
            fin >> sBuffer;
            if (!fin && fin.eof())
                break;
            else if (sBuffer == _T("end"))
                break;

            if (_T("id") == sBuffer)
            {
                assert(0xffffffff == asf_joint.nJointIndex);
                fin >> asf_joint.nJointIndex;
            }
            else if (_T("name") == sBuffer)
            {
                assert(asf_joint.sJointName.empty());
                fin >> asf_joint.sJointName;
                assert(!asf_joint.sJointName.empty());
            }
            else if (_T("direction") == sBuffer)
            {
                fin >> direction[0];
                fin >> direction[1];
                fin >> direction[2];
            }
            else if (_T("length") == sBuffer)
            {
                fin >> length;
            }
            else if (_T("axis") == sBuffer)
            {
                fin >> asf_joint.dOrientation[0];
                fin >> asf_joint.dOrientation[1];
                fin >> asf_joint.dOrientation[2];

                if (degreeAngle_)
                {
                    asf_joint.dOrientation[0] = asf_joint.dOrientation[0] * 3.141592 / 180.0;
                    asf_joint.dOrientation[1] = asf_joint.dOrientation[1] * 3.141592 / 180.0;
                    asf_joint.dOrientation[2] = asf_joint.dOrientation[2] * 3.141592 / 180.0;
                }

                std::tstring sAxisOrder;
                fin >> sAxisOrder;
                assert(sAxisOrder.size() == 3);

                asf_joint.eRotOrder = GetEulerRotationTypeFromString(sAxisOrder);
            }
            else if (_T("bodymass") == sBuffer)
            {
                double body_mass;
                fin >> body_mass;
            }
            else if (_T("cofmass") == sBuffer)
            {
                double cof_mass;
                fin >> cof_mass;
            }
            else if (_T("dof") == sBuffer)
            {
                std::tstring sAmcOrder;
                std::getline(fin, sAmcOrder);

                parseAsfAmcOrder(sAmcOrder, asf_joint);
            }
            else if (_T("limits") == sBuffer)
            {
                for (unsigned int i = 0; i < asf_joint.nAmcOrderCount; ++i)
                {
                    TCHAR ch = 0;
                    fin >> ch;
                    assert(ch == _T('('));

                    std::tstring sMin;
                    fin >> sMin;

                    if (_tcsncmp(sMin.c_str(), _T("-inf"), 4) == 0)
                        asf_joint.dLimits[i][0] = -std::numeric_limits<float>::max();
                    else if (_tcsncmp(sMin.c_str(), _T("inf"), 3) == 0)
                        asf_joint.dLimits[i][0] =  std::numeric_limits<float>::max();
                    else
                        asf_joint.dLimits[i][0] = static_cast<float>(_tstof(sMin.c_str()));

                    SkipSpaceChar(fin);

                    std::tstring sMax;
                    std::getline(fin, sMax, _T(')'));

                    if (_tcsncmp(sMax.c_str(), _T("-inf"), 4) == 0)
                        asf_joint.dLimits[i][1] = -std::numeric_limits<float>::max();
                    else if (_tcsncmp(sMax.c_str(), _T("inf"), 3) == 0)
                        asf_joint.dLimits[i][1] =  std::numeric_limits<float>::max();
                    else
                        asf_joint.dLimits[i][1] = static_cast<float>(_tstof(sMax.c_str()));

                    fin.get();

                    if (degreeAngle_)
                    {
                        asf_joint.dLimits[i][0] = asf_joint.dLimits[i][0] * 3.141592 / 180.0;
                        asf_joint.dLimits[i][1] = asf_joint.dLimits[i][1] * 3.141592 / 180.0;
                    }
                }
            }
            else
            {
                assert(false);
                return false;
            }
        }

        asf_joint.dPosition[0] = direction[0] * length * globalLengthMultiplier_;
        asf_joint.dPosition[1] = direction[1] * length * globalLengthMultiplier_;
        asf_joint.dPosition[2] = direction[2] * length * globalLengthMultiplier_;

        // Finished to load a joint information from asf file
        joints_.push_back(Joint(this));
        joints_.back().setJointIndex(static_cast<unsigned int>(joints_.size()) - 1);
        if (!joints_.back().createJointFromAsf(asf_joint))
            return false;
    }

    return true;
}

사실 :root와 :bonedata는 구조적으로 동일합니다. 주로 root는 이동값을 가지고 있지만, 뼈대는 길이의 변화가 거의 없으므로 이동 값을 가지지 않는다는 점이 다릅니다. 따라서 같은 구조체에 처리하는 편이 편리하여 같은 구조체로 처리하고 배열에 담아 처리하고 있습니다. joint_라는 이름으로 저장해놓고 있지요. 부모로부터의 뼈대의 거리 벡터를 Euler 회전 행렬에 의해서 회전시킨 후 화면에 그려주게 되면, 에니메이션을 할 수 있게 됩니다. 그러기 위해서는 부모와 자식 간의 연결 고리를 만드는 것은 매우 중요하고 hierarchy가 그 부모 자식 간의 관계를 보여주고 있습니다.


:hierarchy
  begin
    root lhipjoint rhipjoint lowerback
    lhipjoint lfemur
    lfemur ltibia
  end


bool Skeleton::parseAsfHierarchy(
    std::tifstream& fin,
    std::map< std::tstring, TStringArray >& hierarchy)
{
    std::tstring sBuffer;
    fin >> sBuffer;

    assert(_T("begin") == sBuffer);
    if (_T("begin") != sBuffer)
        return false;

    while (fin)
    {
        std::tstring sParent;
        fin >> sParent;

        if (_T("end") == sParent)
            break;

        auto it = hierarchy.find(sParent);
        if (it != hierarchy.end())
            assert(false);

        hierarchy[sParent] = TStringArray();
        TStringArray& children = hierarchy[sParent];

        while (fin)
        {
            SkipSpaceChar(fin);
            TCHAR ch = fin.peek();
            if (_T('\r') == ch || _T('\n') == ch)
                break;

            std::tstring child;
            fin >> child;
            if (!fin || fin.eof() || child.empty())
                break;

            children.push_back(child);
        }
    }

    return true;
}

위와 같이 std::map에 첫번째 key로 부모 뼈대의 이름을 넣어두고, value로 자식 뼈대들의 이름을 배열로 담아두게 되면 뼈대의 전체 tree 구조를 파싱해낼 수 있습니다. 해당하는 이름에 맞는 뼈대를 찾아 부모 자식을 연결시켜주기만 하면 되지요.

이제 남은 것은 AMC 파일을 파싱하는 일입니다. ASF 파일에 대해서 정확하게 파싱한 이후에는 AMC를 로딩하는 것은 상대적으로 매우 쉽습니다.

1
root 9.37216 17.8693 -17.3198 -2.01677 -7.59696 -3.23164
lowerback 2.30193 -0.395121 1.17299
upperback 0.0030495 -0.462657 2.70388
2
root 9.37285 17.8666 -17.3192 -2.06376 -7.58832 -3.1009
lowerback 2.29991 -0.349181 1.09181
upperback 0.0947876 -0.407398 2.60055

AMC 파일은 위와 같은 형태로 되어 있습니다. 즉 우선 키 프레임의 번호, 그리고 뼈대 이름 후에 각각의 회전 또는 이동 정보가 들어가 있지요.

bool Skeleton::loadAcclaimMotionFile(const std::tstring& sFilePath)
{
    if (joints_.empty())
        return false;

    std::tifstream fin;
    fin.open(sFilePath.c_str());
    if (fin.is_open())
    {
        unsigned int nKeyframeCount = 0;

        TCHAR ch;
        std::tstring sBuffer;
        bool bResult = true;

        while (fin && bResult)
        {
            ch = fin.peek();
            if (!fin || fin.eof())
                break;
            else if (ch <= 0)
                break;

            if (_T('#') == ch)
            {
                std::getline(fin, sBuffer);
                continue;
            }
            else if (_T(':') == ch)
            {
                std::getline(fin, sBuffer);
                continue;
            }
            else if (isdigit(ch))
            {
                unsigned int new_keyframe = 0xffffffff;
                fin >> new_keyframe;
                assert(new_keyframe == nKeyframeCount + 1);

                nKeyframeCount = new_keyframe;

                AmcKeyframeArray motion_sequence;
                motion_sequence.resize(joints_.size());
                for (size_t i = 0; i < motion_sequence.size(); ++i)
                {
                    motion_sequence[i].nKeyframeIndex = nKeyframeCount;
                }

                if (!parseAmcKeyframe(fin, motion_sequence))
                {
                    OutputDebugString(_T("Failed to parse amc keyframe\n"));
                    bResult = false;
                    break;
                }

                for (size_t i = 0; i < joints_.size(); ++i)
                {
                    joints_[i].insertAmcKeyframe(sFilePath, nKeyframeCount, motion_sequence[i]);
                }
            }
        }

        fin.clear();
        fin.close();

        return bResult;
    }
    else
        return false;
}

bool Skeleton::parseAmcKeyframe(std::tifstream& fin, AmcKeyframeArray& motion_sequence)
{
    SkipEmptyChar(fin);

    TCHAR ch = 0;
    std::tstring sLine;

    while (fin)
    {
        ch = fin.peek();

        if (!fin || fin.eof())
            break;
        else if (ch <= 0)
            break;

        if (isdigit(ch))
            break;
        else
        {
            std::getline(fin, sLine);
            std::tistringstream sin_stream(sLine);

            std::tstring sName;
            sin_stream >> sName;

            const ASF_Joint* pJoint = 0;
            for (size_t i = 0; i < joints_.size(); ++i)
            {
                if (joints_[i].getSrcJoint().sJointName == sName)
                {
                    pJoint = &joints_[i].getSrcJoint();
                    assert(pJoint->nJointIndex == static_cast<unsigned int>(i));
                    break;
                }
            }

            assert(pJoint);
            if (0 == pJoint)
                return false;

            assert(pJoint->nAmcOrderCount <= 7);
            for (unsigned int i = 0; i < pJoint->nAmcOrderCount; ++i)
            {
                double dValue = 0.0;
                sin_stream >> dValue;

                if (!sin_stream)
                    assert(false);

                if (pJoint->sAmcOrder[i] == _T("RX") ||
                    pJoint->sAmcOrder[i] == _T("RY") ||
                    pJoint->sAmcOrder[i] == _T("RZ"))
                {
                    if (degreeAngle_)
                        dValue = dValue * 3.141592 / 180.0;

                    motion_sequence[pJoint->nJointIndex].dValues[i] = dValue;
                }
                else
                    motion_sequence[pJoint->nJointIndex].dValues[i] = dValue;
            }
        }
    }

    return true;
}



ASF 파일에 정의되어 있던 order에 맞춰서 각 뼈대의 이동 또는 회전 정보를 로딩하게 되면 모든 AMC 로딩은 마무리 되게 됩니다. 매우 단순한 포맷이기 때문에 파싱하는데에는 별다른 문제가 없습니다.


파싱이 마무리된 이후에는 회전 또는 이동 정보를 원하는 형태로 가공해내야 합니다. 에니메이션을 위해서는 주로 벡터와 쿼터니온 형태로 이동과 회전 정보를 저장해두면 나중에 매우 편리하게 보간 처리를 할 수 있습니다. 아래의 함수를 써서, 회전 값과 오일러 회전 순서를 주면 쿼터니온 형태로 변환할 수 있지요.

Quaternionf MakeQuaternionfFromAxisAndAngle(
    const Vector3f& vAxis,
    const float fAngle)
{
    const float fCosValue = cos(fAngle * 0.5f);
    const float fSinValue = sin(fAngle * 0.5f);

    return Quaternionf(vAxis.x*fSinValue, vAxis.y*fSinValue, vAxis.z*fSinValue, fCosValue);
}

Quaternionf MakeQuaternionfFromEulerRadianAngles(
    const Vector3f& v,
    ENUM_ROTATION_TYPE eRotationOrder)
{
    const float fAngleX = v.x;
    const float fAngleY = v.y;
    const float fAngleZ = v.z;

    Quaternionf qRotX = MakeQuaternionfFromAxisAndAngle(Vector3f(1.0f, 0.0f, 0.0f), fAngleX);
    Quaternionf qRotY = MakeQuaternionfFromAxisAndAngle(Vector3f(0.0f, 1.0f, 0.0f), fAngleY);
    Quaternionf qRotZ = MakeQuaternionfFromAxisAndAngle(Vector3f(0.0f, 0.0f, 1.0f), fAngleZ);

    switch (eRotationOrder)
    {
    case ENUM_ROT_EULER_XYZ:
        return qRotZ * qRotY * qRotX;

    case ENUM_ROT_EULER_XZY:
        return qRotY * qRotZ * qRotX;

    case ENUM_ROT_EULER_YXZ:
        return qRotZ * qRotX * qRotY;

    case ENUM_ROT_EULER_YZX:
        return qRotX * qRotZ * qRotY;

    case ENUM_ROT_EULER_ZXY:
        return qRotY * qRotX * qRotZ;

    case ENUM_ROT_EULER_ZYX:
        return qRotX * qRotY * qRotZ;

    default:
        assert(false);
        return qRotZ * qRotY * qRotX;
    }
}

위 함수를 사용하여 모든 회전 값은 쿼터니온으로 변환시켰으며, 이동 값은 그대로 벡터 타입으로 변환하여 저장해놓았습니다.

또한 키 프레임의 번호, 각 AMC 파일 안에 기록되어 있는 1 - 20XX 번까지 주로 정의되어 있는 키 프레임 번호는 일정 시간에 한번씩 캡쳐된 정보입니다. 주로 초당 120 번씩 기계에서 캡쳐한다고 되어 있네요. 그러므로 키 프레임 번호는 해당하는 시간으로 변환할 수 있습니다. 일부 파일의 경우는 60 번씩 캡쳐하기도 합니다만, 일부 파일이므로 해당 파일이 발견되면 바꾸어주시면 됩니다.

float keyframe_time = static_cast<float>(keyframe_number) / 120.0f;

에니메이션을 하기 위해서는 매 프레임 현재 시간인 curTime을 주면 현재 시간을 사이에 끼고 있는 2개의 이동 값과, 2개의 회전 값을 일반적으로 추출해낼 수 있습니다.

4. Skeleton Animation

Skeleton Animation을 해내기 위하여 약간의 이해
가 필요합니다. 우리가 팔을 움직이는 경우를 생각해보지요. 만약 어깨를 위쪽으로 30도 만큼 회전시킨다고 하면 단순히 어깨만 회전 했음에도 불구하고 사실 그 아래의 손, 손목, 팔꿈치등이 모두 30도 만큼 회전하게 됩니다. 이건 손, 손목, 팔꿈치가 모두 어깨와 부모 자식 관계에 있기 때문에 당연한 것입니다. 아래의 이미지에서 보여주듯이 어깨가 움직이면 그 자식은 모두 그 영향을 받게 되지요.

(출처: http://flylib.com/books/en/4.422.1.44/1/)

그럼 이것을 어떻게 프로그램에서 표현해낼 수 있을까요? 부모 자식 관계는 tree 구조로 하여 자연스럽게 표현해낼 수 있습니다. 그럼 부모의 움직임이 자식의 움직임에 영향을 주기 위해서는 부모의 회전 및 이동 행렬을 자식에 곱해주어야 합니다. 손 위의 정점 v를 렌더링하기 위해서는

Local Transform (LT) := 해당 노드의 회전 & 이동 값을 이용하여 만들어진 행렬
최종 vertex position = v * 손의 LT * 손목의 LT * 팔꿈치의 LT * 어깨의 LT * ...

위와 같이 계속 곱해 올라가서 최상위 노드인 root (pelvis)까지 곱해져야 원하는 정점을 얻어낼 수 있지요. 위와 같이 계속 곱해 올라가는 과정은 재귀적으로 표현하여 단순화시킬 수 있습니다.

Global Transform (GT) := My Local Transform * Parent's Global Transform (부모가 있을 때)
최종 vertex position = v * Global Transform

위와 같이 재귀적으로 행렬을 연산하여 각 뼈대의 최종 행렬을 만들어낼 수 있습니다. 매 프레임 시간에 맞춰서 Local Transform을 만들어내고 tree를 따라서 Parent Global Transform을 곱해나가면 최종 행렬을 모두 만들어낼 수 있습니다.

(
이 부분은 다른 책들로부터도 도움을 받을 수 있습니다. 몇 개의 책들을 추천드리고자 합니다. 한국에서는 해골책이라고 불리우는 "IT EXPERT 3D 게임 프로그래밍"에서도 스키닝을 잘 설명해주고 있습니다. "Real-Time Rendering 제 2판"에서도 설명해주고 있습니다.)

5. MoCap Data로부터 Local Transform 계산하기

지금까지 우리는 모든 MoCap의 모든 데이터를 파싱하여 간단한 자료구조로 저장해놓았으며, 각 뼈대의 부모 자식 관계도 만들어 두었습니다. 또한 Local Transform을 가지고 있으면 최종 Global Transform을 만들어낼 수 있지요. 이제 남은 일은 시간에 맞춰서 Local Transform을 계산해내는 일입니다.

만약 현재 시간을 fCurTime이라고 한다면, fCurTime을 사이에 두고 있는 2개의 이동 값과 2개의 회전 값을 파싱해놓은 MoCap Data로부터 찾아낼 수 있습니다. 정확히 fCurTime의 이동과 회전이 필요하므로, 2개의 이동 값은 순수 선형 보간으로, 2개의 회전 값은 구형 보간으로 하여 현재 시간에 해당하는 이동 값과 회전 값을 추출해낼 수 있습니다.

// 이동 값 추출해내기. 선형 보간
float t = (pPosKeySecond->fTime - fCurTime) / (pPosKeySecond->fTime - pPosKeyFirst->fTime);

t = std::min(std::max(t, 0.0f), 1.0f);
Vector3f vCurPos = pPosKeyFirst->vPos + (pPosKeySecond->vPos - pPosKeyFirst->vPos) * t;

// 회전 값 추출해내기. 구형 보간
float t = (pRotKeySecond->fTime - fCurTime) / (pRotKeySecond->fTime - pRotKeyFirst->fTime);
t = std::min(std::max(t, 0.0f), 1.0f);
Quaternionf qCurRot = pRotKeyFirst->qRot.Slerp(pRotKeySecond->qRot, t);
Matrix4x4f mCurRotation = MakeRotationMatrix4x4fFromQuaternion(qCurRot);

여기까지는 쉽게 해낼 수 있습니다. 하지만 이동 값과 회전 값은 Local Transform을 의미하지 않습니다. 왜냐하면, MoCap에 기록된 정보는 최초에 정의된 센서의 축으로부터 상대적인 회전 행렬을 의미하고 있기 때문입니다. 즉 정확하게 Local Transform을 위해서는 몇 단계가 더 필요합니다.

Local Transform의 회전 정보를 만들기 위해서는, 각 뼈대의 초기 회전 정보를 알아야 합니다. 이 초기 회전 정보는 root의 orientation, 또는 bonedata의 axis 정보와 오일러 회전 순서 정보를 이용하여 만들어냅니다.

Quaternionf q = MakeQuaternionfFromEulerRadianAngles(vEuler, srcJoint_.eRotOrder);
Matrix4x4f mOriginalRotation = MakeRotationMatrix4x4fFromQuaternion(q);

위와 같이 초기 회전 행렬을 알고 있다면, Local Transform을 만들기 위해서 먼저 초기 행렬의 역행렬을 만들어야 합니다.

Local Transform's rotation matrix = Inverse(mOriginalRotation) * mCurRotation * mOriginalRotation;

위와 같이 해주어야 Local Transform의 회전을 만들어낼 수 있습니다. 이유는, MoCap에 정의된 정보가 센서의 초기 행렬에 대해서 상대적으로 어느 정도 회전 했는지를 기록해놓았으므로, 우선 초기 행렬을 없앤 후 현재 회전을 적용하고 다시 회전을 적용해야 결과적으로 최종적인 회전을 처리해낼 수 있습니다.

Local Transform의 회전 영역은 구했으므로, 이제 이동 영역을 구해야 합니다.
우선 현재의 뼈대의 부모로부터 상대적인 벡터를 v_relative_bone_length라고 해보지요.

// direction은 ASF 파일에 정의된 direction
// length는 ASF 파일에 정의된 length
// global_length는 ASF 파일의 units에 정의된 길이
v_relative_bone_length = direction * length * global_length;

// 바로 위에서 계산한 Local Transform's rotation matrix를 위 벡터에 곱해준 후 MoCap 이동 값을 더해줘야 최종 이동 값
v_result_position = (v_relative_bone_length * Local Transform's rotation matrix) + vCurPos;


이렇게 하여 Local Transform의 회전과 이동을 얻어내었으므로,

Local Transform =
( m11  m12  m13  m14 )

( m21  m22  m23  m24 )
( m31  m32  m33  m34 )
( m41  m42  m43  m44 )

위의 행렬 선언에서 m11, m12, m13, m21, m22, m23, m31, m32, m33의 영역은 회전에 관한 영역이므로 Local Transform's rotation matrix에서 복사해서 넣어주어야 합니다.

m41, m42, m43은 이동 영역이므로 v_result_position의 x, y, z 값을 넣어주어야 합니다.

위와 같이 하여 모든 에니메이션 행렬을 모두 만들어낼 수 있습니다.


6. Rendering

사실 렌더링은 매우 쉽습니다. 각 Global Transform 행렬에는 최종 이동 값이 있으므로, 해당하는 이동값을 추출해내고, 부모로부터 자식까지 선을 연결하고, 각 노드에 구(Sphere)나 박스를 그려주면 뼈대와 노드를 모두 볼 수 있습니다.

Vector3f vThis(0.0f, 0.0f, 0.0f);
vThis = vThis * Global Transform;

if (getParent())
{
    Vector3f vParent(0.0f, 0.0f, 0.0f);
    vParent = vParent * getParent()->getGlobalTransform();

    // Draw line from vThis to vParent
}

// Draw Box or Sphere on vThis


6. 결론


지금까지 ASF/AMC 파일을 로딩하고 또 렌더링할 수 있는 방법을 써보았습니다. 써놓고 보니, 몇 가지 제가 미리 가정하고 있었던 것이 있었네요. 우선 행렬과 그 행렬의 파이프라인에 대해서 어느 정도 가정을 해두었고, 보간(Interpolation)에 대해서도 미리 이해가 되어 있다는 가정을 하고 있었습니다. 소스 코드에 대해서도 파싱 부분을 제외하고 나머지 부분은 너무 길어서 여기에 올리지 못했습니다. 나중에 따로 요청해주시면 보내드리도록 하겠습니다.

댓글을 달아 주세요

  1. Favicon of https://gamedevforever.com ozlael 2012.01.06 23:36 신고  댓글주소  수정/삭제  댓글쓰기

    뭐..뭔지 모르지만 굉장하다!!!

  2. Favicon of https://gamedevforever.com cagetu 2012.01.10 16:03 신고  댓글주소  수정/삭제  댓글쓰기

    모션데이터를 사용해본 적이 없기는 하지만, 꼭 스크랩 해두었다가 필요할 때 보겠습니다. 재밌네요. ㅎㅎ

  3. Favicon of https://gamedevforever.com zinzza 2012.01.10 20:08 신고  댓글주소  수정/삭제  댓글쓰기

    이건 너무 굉장해서 드릴 말씀이 없네요-_-;

  4. Favicon of https://gamedevforever.com denoil 2012.01.11 10:26 신고  댓글주소  수정/삭제  댓글쓰기

    공부할때도 편하겠군요! 모델데이터 걱정이 없어질 수도 있다는 생각이 문득 들었습니다. 어서 한국으로 돌아와서 오프라인강의 해주세요 ㅋ

  5. sgpro 2012.01.11 11:42  댓글주소  수정/삭제  댓글쓰기

    고맙습니다 ^^

  6. sadpria 2012.01.11 15:51  댓글주소  수정/삭제  댓글쓰기

    좋은 내용 잘 읽었습니다.감사합니다.