스킨 메쉬의 해부 - 1(남병탁)

다이렉트 엑스 SDK를 설치하면 예제 디렉토리안에 메쉬라는 폴더가 있다. 그안에 스킨드 메쉬라고 하는 예제를 살펴 보면 Tiny라는 캐릭터가(남자인지 여자인지 의문이간다.) 걸어가는 것을 볼 수 있다. 각 관절의 끊어짐도 없으며 자연스러운 움직임을 보여준다. 하지만 이 예제는 너무나 복잡하고 어렵게 코딩되어 있어 분석하기가 만만치 않다. 몇몇 인터넷에 돌아다니는 자료를 살펴 보아도 그리 자세하게 설명된 것은 없었다.


 목표
 스킨드 메쉬 예제를 분석해서 애니매이션이 되는 원리와 계층구조 애니매이션, 스키닝에 대해 알아보고 스킨드 메쉬의 예제와 같게 소프트웨어 애니매이션을 구축한다. 이때 별도의 파일 포맷을 만들어 데이터를 추출하고 각각의 행렬 등 여러 전반에 걸친 것들을 살펴본다.


 전반적인 지식
 먼저 계층구조에 대해 알아보자.
 스킨드 메쉬 예제는 계층구조로 이루어져 있다. 계층구조라 함은 부모 노드가 변할 때 자식의 노드도 함께 따라 변하는 것이다. 그래서 계층구조는 각각의 변환 행렬을 가지고 있어야 함은 물론 부모의 행렬까지 변환된 최종 누적 행렬도 가지고 있어야 한다. 만약 어깨와 팔, 팔뚝, 손으로 이루어진 계층 구조가 있다면 손의 어미는 팔뚝이고 팔뚝의 어미는 팔이 된다. 만약 팔에 어떤 변환이 가해진다면 팔뚝과 손에까지 그 영향을 미친다는 말이다. 이것을 구현하는 것은 간단하다. 자신의 변환 행렬에다 부모의 행렬을 곱하는 것이다. 이것을 손에 대해서 써보면
손의 변환행렬 ×팔뚝 변환행렬 ×팔 변환행렬 ×어깨 변환행렬
이 된다. 모든 계층구조는 이런 식으로 구성이 된다.
 이것을 뼈대라고도 하는데 스킨드 메쉬에서는 프레임이라고 부르고 있다. 이러한 프래임은 메쉬에 영향을 주도록 되어 있는데 메쉬는 프레임(뼈대) 공간에 있지 않다. 이것은 나도 조금 이해하기가 어려웠는데 다음의 그림을 보자.
이 그림은 오프셋 행렬의 역할을 보여주고 있다. 누적된 행렬(부모의 행렬을 자신에 곱한)은 메쉬공간에서의 변환을 기술하지만 뼈대공간으로 변환되지 않은 것이다. 뼈대의 포즈를 고려한 변환을 위해 스킨드 메쉬예제에서는 오프셋 행렬이라는 것을 두고 있다.
그래서 고려해야할 행렬은 두가지 이다.
뼈의 오프셋 행렬 ×누적 행렬


사용자 삽입 이미지






뼈의 오프셋 행렬은 엑스파일에 저장되어 있다. 누적 행렬정보와 뼈 오프셋 행렬을 세팅한뒤 정점을 변환시켜 보면 맨 처음 자세가 출력이 되는 것을 볼 수 있다.





사용자 삽입 이미지


사용자 삽입 이미지


 다음은 스키닝에 대해 알아보자.
정점은 뼈대에 영향을 받는다.(뼈대는 프래임을 말한다) 뼈대가 회전하면 정점도 회전하게된다. 하지만 문제가 조금 있다. 관절부위에 있는 정점은 과연 어느 뼈대에 영향을 받아야 하나? 만약 한 뼈대에 영향을 받는다면 위의 그림과 같은 현상이 벌어지고 말 것이다. 관절은 깨져 보이고 눈에 보기에 그리 좋지 않을 것이다. 하지만 밑의 그림을 보면 관절주위의 정점들이 각 관절의 영향을 고루 받고 있다. 각 정점들은 뼈A 와 뼈B의 영향을 둘 다 받고 있다. 그래서 관절의 움직임은 깨지지 않고 자연스럽게 늘어났다 줄어들었다 할 수 있다. 이것이 스키닝의 개념이다. 다시 말해서 정점은 두 개 이상의 행렬에 의해 변환되며 그 정도는 가중치라는 수로 표현된다는 것이다. 예를 들면 팔뚝에 0.3, 손목에 0.7 이런 식으로 말이다. 이러한 뼈대에 대한 가중치는 모두 합하면 1.0이 되어야 하며 한 정점에 영향을 미치는 뼈대의 개수는 정하기 나름이다. Tiny.x 파일에서는 네 개다.


 다음은 애니매이션에 대해 알아보자.
애니매이션은 끊어짐이 없는 움직임을 말한다. 하지만 각각의 시간마다 정점들을 모두 저장할 수 있는 시스템은 아니기에 적당한 시간마다 포즈를 저장하고 우리는 그것을 중간중간 사용해서 공백 시간에 적절한 포즈를 만들어 내어야 한다. 스킨드 메쉬 예제에서는 각각의 시간과 그에 따른 변환 행렬 정보를 주는데 그 시간 사이에 우리의 애니매이션이 수행되고 있다면 어떻게 해야하겠는가? 우리의 시간과 앞뒤의 포즈를 고려해 적당한 포즈를 만들어 내어야 할 것이다. 이것을 보간이라고 하는데 여러 가지 방법이 있으나 생략하겠다. 여기서는 두 가지의 포즈를 계산한 후 시간이 어느쪽에 치우치는지를 판단해 퍼센테이지로 두 포즈를 섞어 주는 방법을 택했다.


파일추출
 엑스파일을 살펴보면 계층구조로 이루어졌음을 알 수 있다. 각각의 프래임은 자신의 변환 행렬을 담고 있고 이름도 담고 있다. 어떤 프래임은 메쉬를 저장하고 있기도 하다. 그 외에 특이한 템플릿을 발견 할 수 있는데 스킨 웨이트라는 것이다. 이것은 말 그대로 스킨 가중치라는 의미인데 프래임별로 저장되어 있다. 여기에는 가중치의 개수와 인덱스배열, 가중치 배열이 저장되어 있다. 이것은 위에서 살펴본 스키닝에 사용되는 것들이다. 가중치는 0.0~1.0 사이의 값이며 인덱스 배열은 이 뼈대(프래임이름을 통해 알 수 있다.)가 영향을 미치는 정점의 인덱스이다. 0번 정점에 0.5만큼의 영향을 미친다. 이런식으로 해석하면 되겠다. 밑으로 내려가면 또다른 템플릿이 등장하는데 모두 애니매이션에 관한 것들이다. 이것들 역시 각 프래임의 이름을 담고 있으며 특정시각에서 그 프래임(뼈대)의 변환 행렬을 담고 있다.


 이상의 데이터들을 사용해 스킨드 메쉬의 예제와 비슷하게 동작하는 예제를 작성해보도록 하자.


 먼저 계층구조를 추출하기 위해 처음엔 엑스파일 인터페이스를 이용해보고자 했으나 너무나 까다롭고 인터넷에서 여러 예제들을 구해 살펴보았지만 제대로 실행되는 것이 없어서 무식한 방법을 쓰기로 했다. 스킨드 메쉬 예제를 수정해 미리 구성되어 있는 프래임 계층구조를 파일에 출력하기로 했다.
스킨드 메쉬 예제의 메인 클래스에 멤버함수를 추가했다.


void CMyD3DApplication::Convert()
{
 FILE* fp;
사용할 파일을 연다.
 char filename[200];
 sprintf(filename,"%s.vsb",g_strFilename);
g_strFilename라는 변수에 열고자하는 파일 이름이 들어 있다. 파일이름 끝에 ".vsb"라는 것을 붙여 저장되게 하였다.
 fp = fopen(filename,"wb");
파일을 여는 구문이다.
 fprintf(fp,"VSB Ver 1.0\n");
파일에 버전을 표기했다. 왜했는지...
 NumFrame = 0;
프래임 숫자를 저장하기 위해 초기화 하였다.
 CalcuNumFrame(m_pFrameRoot);
이것은 프래임의 개수를 세는 함수이다.
 fprintf(fp,"%d\n",NumFrame);
프래임의 개수를 파일에 저장한다.
     LoadFrame(m_pFrameRoot,fp);
프래임 계층구조를 파일에 저장한다.
 fclose(fp);
}


프래임의 개수를 찾는 재귀 함수


void CMyD3DApplication::CalcuNumFrame(LPD3DXFRAME pFrame)
{
 NumFrame++;
 if (pFrame->pFrameSibling != NULL)
    {
        CalcuNumFrame(pFrame->pFrameSibling);
형제 프래임이 있으면 형제프래임을 탐색한다.
    }

    if (pFrame->pFrameFirstChild != NULL)
    {
자식 프래임이 있으면 자식 프래임을 탐색하면서 카운트를 올린다.
  CalcuNumFrame(pFrame->pFrameFirstChild);
    }
}



 위의 함수는 재귀함수이다. 자신을 호줄하는 함수라는 뜻인데 D3DXFRAME이라는 구조체는 자신의 형제 프래임과 자신의 자식프래임의 포인터를 가지고 있다. 그래서 최상위의 프래임에서 시작하여 재귀적으로 모든 하위 프래임까지 검색을 하는 것이다. 계층구조에서는 이러한 형식의 함수를 많이 사용하는데 이번 예제에서도 이러한 함수를 많이 사용하니 꼭 이해하고 넘어갔으면 한다.

프래임을 파일에 기록하는 함수이다. Convert()라는 함수에서 LoadFrame(m_pFrameRoot,fp);라고 실행을 했던 함수이다. 이것 또한 재귀함수의 형태이다. m_pFrameRoot라는 것은 최상위 프래임을 말한다. 최상위부터 차례대로 검색해 나가는 것이다.



void CMyD3DApplication::LoadFrame(LPD3DXFRAME pFrame,FILE* fp)
{ 프래임 헤더를 기록
 fprintf(fp,"Frame\n");
 프래임의 이름을 기록
 fprintf(fp,"%s\n",pFrame->Name);
 Matrix헤더를 기록
 fprintf(fp,"Matrix\n");
 매트릭스를 읽어서 차례로 기록한다.
 fprintf(fp,"%f %f %f %f\n",
  pFrame->TransformationMatrix._11,
  pFrame->TransformationMatrix._12,
  pFrame->TransformationMatrix._13,
  pFrame->TransformationMatrix._14);
 fprintf(fp,"%f %f %f %f\n",
  pFrame->TransformationMatrix._21,
  pFrame->TransformationMatrix._22,
  pFrame->TransformationMatrix._23,
  pFrame->TransformationMatrix._24);
 fprintf(fp,"%f %f %f %f\n",
  pFrame->TransformationMatrix._31,
  pFrame->TransformationMatrix._32,
  pFrame->TransformationMatrix._33,
  pFrame->TransformationMatrix._34);
 fprintf(fp,"%f %f %f %f\n",
  pFrame->TransformationMatrix._41,
  pFrame->TransformationMatrix._42,
  pFrame->TransformationMatrix._43,
  pFrame->TransformationMatrix._44);

 if (pFrame->pFrameFirstChild != NULL)
        {
  만약 자식이 있으면 자식의 이름을 기록해둔다.
  fprintf(fp,"Child %s\n",pFrame->pFrameFirstChild->Name);
        }

    if (pFrame->pFrameSibling != NULL)
    {  만약 형제 프래임이 있으면 이름을 기록해 둔다.
  fprintf(fp,"Sibling %s\n",pFrame->pFrameSibling->Name);
    }
 
메쉬를 읽는 부분이다.
 if(pFrame->pMeshContainer !=NULL)
 {
  메쉬를 저장할 구조체이다.
  struct CHVERTEX
  {
   float x,y,z,nx,ny,nz,tu,tv;
  };
  DWORD FVF = D3DFVF_XYZ | D3DFVF_NORMAL | D3DFVF_TEX1;
  메쉬 컨테이너의 포인터를 얻는다.
  LPD3DXMESH pMesh;
  D3DXMESHCONTAINER_DERIVED* pMeshContainer = (D3DXMESHCONTAINER_DERIVED*)pFrame->pMeshContainer;
메쉬를 우리가 저장하기에 적합한 형태로 복사한다.  pMeshContainer->pOrigMesh->CloneMeshFVF(D3DXMESH_MANAGED,FVF,m_pd3dDevice,&pMesh);
 
  CHVERTEX* pVertex;
메쉬에서 정점의 개수를 얻는다.
  DWORD NumVertex = pMesh->GetNumVertices();
메쉬 헤더임을 표시
  fprintf(fp,"Mesh\n");
버텍스임을 표시
  fprintf(fp,"Vertex\n");
정점의 개수를 기록
  fprintf(fp,"%d\n",NumVertex);
메쉬의 정점 버퍼를 잠그고 기록한다.
  pMesh->LockVertexBuffer(D3DLOCK_READONLY, (LPVOID*)&pVertex);
  for(int i=0;i<NumVertex;i++)
  {
   fprintf(fp,"%f %f %f %f %f %f %f %f\n",
    pVertex[i].x,
    pVertex[i].y,
    pVertex[i].z,
    pVertex[i].nx,
    pVertex[i].ny,
    pVertex[i].nz,
    pVertex[i].tu,
    pVertex[i].tv);

  }
  pMesh->UnlockVertexBuffer();
메쉬에서 삼각형의 개수를 저장  
  DWORD NumFaces = pMesh->GetNumFaces();
인덱스 헤더임을 기록
  fprintf(fp,"Index\n");
삼각형 개수 기록
  fprintf(fp,"%d\n",NumFaces);
인덱스버퍼 잠금
  WORD* pIndices=NULL;
  pMesh->LockIndexBuffer(D3DLOCK_READONLY, (LPVOID*)&pIndices);
각각의 인덱스 기록
  for(i=0;i<NumFaces;i++)
  {
   fprintf(fp,"%d %d %d\n",pIndices[i*3+0],pIndices[i*3+1],pIndices[i*3+2]);
  }
  pMesh->UnlockIndexBuffer();
  pMesh->Release();
 }
 재귀함수의 형태이다. 자식과 형제 프래임에 대해 동일한 방식으로 기록한다.  
 if (pFrame->pFrameFirstChild != NULL)
    {
        LoadFrame(pFrame->pFrameFirstChild,fp);
    }

    if (pFrame->pFrameSibling != NULL)
    {
        LoadFrame(pFrame->pFrameSibling,fp);
    }   
}



다음은 메시지 프로시져라는 스킨드 메쉬안의 함수에 추가한 코드이다.


if( TRUE == GetOpenFileName( &ofn ) )
{
                _tcscpy( m_strInitialDir, m_strMeshFilename );
                TCHAR* pLastSlash =  _tcsrchr( m_strInitialDir, _T('\\') );
                if( pLastSlash )
                    *pLastSlash = 0;
                SetCurrentDirectory( m_strInitialDir );

                // Destroy and recreate everything
                InvalidateDeviceObjects();
                DeleteDeviceObjects();
                InitDeviceObjects();
                RestoreDeviceObjects();
                Convert();   ◀◀요놈이 추가한 것.
}



 Convert함수에서 파일 이름을 사용하는데 파일이름은 전역변수로 선언했다. 파일 이름이 저장되어 있도록 ofn 구조체를 세팅해야 한다. 예제에서 잠깐만 살펴보면 알 정도로 쉬우니 넘어가겠다.
이로서 프래임 계층구조와 메쉬 정보까지 출력하는 파일 변환기를 만들었다. 좀 어설프지만 우리가 필요로 하는 정보를 가져왔으니 그걸로 만족하자.


 다음은 스키닝 데이터와 애니매이션 데이터를 추출해야 하는데 스킨 인포라는 인터페이스와 애니매이션 컨트롤러라는 인터페이스가 버티고 서 있었다. 무언가 정보를 얻기 위해 SDK문서를 참고해서 역시 스킨드 메쉬 예제에서 추출을 시도 했지만 잘 되지가 않았다. 정확한 값고 메서드를 사용했지만 왜 안되는지 도무지 알 수가 없었다. 난관에 부딫혔다. 아직 해야할 것이 많은데 ....
 이번엔 좀더 무식한 방법을 쓰기로 했다. 요즘 인터넷을 돌아다니다 보면 3디 맥스에서 익스포트가 가능한 ASE파일을 가지고 모델을 로딩하고 출력하는 예제를 많이 찾아볼 수 있다. 거기서 힌트를 얻었다. 엑스파일을 열어보니 프래임 계층구조는 굉장히 복잡해 보였지만 스킨 웨이트 템플릿이나 애니매이션 셋 템플릿은 그리 복잡하지 않게 구성되어 있었다. 거기다 텍스트 파일이니 도전하지 못할 이유도 없었다. 그래서 과감히 코딩에 들어갔다.


 MFC 로 코딩을 했다. MFC가 조금 어려운 감은 있으나 의외로 코딩하기가 편리한 점도 있어 자주는 아니지만 가끔 쓴다. 윈도우에서 컨트롤을 다룰 일이 많거나 해서 윈32로는 복잡해서 할 수 없을 때 쓴다. 별거 없다. 버튼 만들고 핸들러 작성하면 끝이다.
APP 위자드를 실행해 MFC에서 다이얼로그를 생성한다. 그리고 적당히 컨트롤을 올려 놓은뒤 코딩을 시작하면 된다.
먼저 파일을 선택하는 핸들러 이다. 프로그램을 실행해보면 "찾기"라고 적힌 버튼을 눌렀을 때 실행되는 핸들러 이다.


void CParserDlg::OnButtonSource()
{
 TCHAR szFilters[] =
    _T ("X files (*.*)|*.*|All files (*.*)|*.*||");
 CString filename;
 CFileDialog dlg (TRUE, _T ("X Files"), _T ("*.x"),
    OFN_FILEMUSTEXIST | OFN_HIDEREADONLY, szFilters);
    if (dlg.DoModal () == IDOK)
 {
  filename = dlg.GetPathName ();
  CEdit* pEdit=(CEdit*)AfxGetMainWnd()->GetDlgItem(IDC_EDIT_SOURCE);
  pEdit->SetWindowText((LPCTSTR)filename);
엑스파일이라는 클래스에 이름을 저장합니다. 얻은 이름을 가지고 변환을 하게 됩니다.
  m_XFile.SetOnlyName((LPCSTR)dlg.GetFileName());
 }
}


이함수는 오른쪽에 변환이라고 크게 쓰여진 버튼을 눌렀을 때 실행되는 함수이다. 아까 찾기 버튼을 눌러 선택한 파일의 이름을 가지고 변환을 시작하게 된다.


void CParserDlg::OnButtonConvert()
{
에디트 컨트롤로부터 저장해 놓은 파일 이름을 얻어온다.
 CString filename;
 CEdit* pEdit=(CEdit*)AfxGetMainWnd()->GetDlgItem(IDC_EDIT_SOURCE);
 pEdit->GetWindowText(filename);
 if(m_XFile.ReadFile(filename))
 {
  AfxMessageBox("파일 읽기 성공!!!");
 }
 if(m_XFile.ReadAni(filename))
 {
  AfxMessageBox("파일 읽기 성공!!!");
 }
}


위에서 사용되는 두 함수 ReadFile, ReadAni 함수에 대해 알아보자.
이 함수는 스킨 데이터를 읽어서 파일에 저장하는 함수 이다.


BOOL CXFile::ReadFile(CString filename)
{
 if(!filename)
  return FALSE;
엑스파일을 연다.
 FILE* fp=fopen(filename,"rb+");
우리가 저장할 파일이다. 역시 파일이름 끝에 ".bon"을 붙였다.
 FILE* fpDest;
 char Name[200];
 sprintf(Name,"%s.bon",OnlyName);
저장할 파일을 쓰기모드로 연다.
 fpDest = fopen(Name,"wt");
엑스파일에서 한줄씩 읽어서 저장하고 파싱할 때 사용되는 문자열들
 char line[200];
 char buffer[200];
파일에서 한줄을 읽고
 fgets(buffer,200,fp);
line 에 읽은 내용을 문자열(%s) 형식으로 읽는다.
 scanf(buffer,"%s",line);
우리가 필요한 것은 스킨 웨이트 템플릿의 내용이므로 스킨 웨이트 템플릿이 나올 때까지  일을 계속 읽어 나간다.
 while( !feof(fp) )
 {
  memset(&buffer,0,sizeof(buffer));
  memset(&line,0,sizeof(line));

  fgets(buffer,200,fp);
  sscanf(buffer,"%s",line);
문자열 비교함수이다. 템플릿을 만났다면 if문 안이 실행된다.
  if( !strcmp( "SkinWeights", line) )
  {
버퍼의 내용을 초기화 한다.
   memset(&buffer,0,sizeof(buffer));
   memset(&line,0,sizeof(line));
   fgets(buffer,200,fp);
다음의 구문은  " 나 ; , 등이 들어간 것을 없애 주어야 sscanf함수가 제대로 작동하기 때문에 이러한 문자를 공백 처리 해주는 과정이다.
     예 : "Bip01_L_UpperArm";
            41;
   for(DWORD i=0;i<200;i++)
   {
    if(buffer[i] == '"')
     buffer[i] = ' ';
    if(buffer[i] == ';')
     buffer[i] = ' ';
    if(buffer[i] == ',')
     buffer[i] = ' ';
   }
   sscanf(buffer,"%s",line);
   fprintf(fpDest,"%s\n",line);//뼈대 이름
   memset(&buffer,0,sizeof(buffer));
   memset(&line,0,sizeof(line));
   fgets(buffer,200,fp);
   for(i=0;i<200;i++)
   {
    if(buffer[i] == '"')
     buffer[i] = ' ';
    if(buffer[i] == ';')
     buffer[i] = ' ';
    if(buffer[i] == ',')
     buffer[i] = ' ';
   }
   DWORD NumInfl;
   sscanf(buffer,"%d",&NumInfl);
   fprintf(fpDest,"%d\n",NumInfl);//가중치 개수
가중치 개수만큼 인덱스 배열과 가중치 배열이 있기 때문에 for문을 사용해 읽어 들이면 된다.
   for(i=0;i<NumInfl;i++)
   {
    memset(&buffer,0,sizeof(buffer));
    memset(&line,0,sizeof(line));
    fgets(buffer,200,fp);
    for(int j=0;j<200;j++)
    {
     if(buffer[j] == '"')
      buffer[j] = ' ';
     if(buffer[j] == ';')
      buffer[j] = ' ';
     if(buffer[j] == ',')
      buffer[j] = ' ';
    }
    DWORD temp;
    sscanf(buffer,"%d",&temp);
    fprintf(fpDest,"%d\n",temp);

   }
   for(i=0;i<NumInfl;i++)
   {
    memset(&buffer,0,sizeof(buffer));
    memset(&line,0,sizeof(line));
    fgets(buffer,200,fp);
    for(int j=0;j<200;j++)
    {
     if(buffer[j] == '"')
      buffer[j] = ' ';
     if(buffer[j] == ';')
      buffer[j] = ' ';
     if(buffer[j] == ',')
      buffer[j] = ' ';
    }
    float temp;
    sscanf(buffer,"%f",&temp);
    fprintf(fpDest,"%f\n",temp);

   }
   memset(&buffer,0,sizeof(buffer));
   memset(&line,0,sizeof(line));
다음은 뼈대의 오프셋 매트릭스을 읽어들여야 한다. vsb 파일과 달리 읽어 들여서 한줄에 모두 출력하도록 했다.
   fgets(buffer,200,fp);
   for(int j=0;j<200;j++)
   {
    if(buffer[j] == '"')
     buffer[j] = ' ';
    if(buffer[j] == ';')
     buffer[j] = ' ';
    if(buffer[j] == ',')
     buffer[j] = ' ';
   }
   float _11,_12,_13,_14;
   float _21,_22,_23,_24;
   float _31,_32,_33,_34;
   float _41,_42,_43,_44;
   sscanf(buffer,"%f%f%f%f%f%f%f%f%f%f%f%f%f%f%f%f",
    &_11,&_12,&_13,&_14,
    &_21,&_22,&_23,&_24,
    &_31,&_32,&_33,&_34,
    &_41,&_42,&_43,&_44);
   fprintf(fpDest,"%f %f %f %f %f %f %f %f %f %f %f %f %f %f %f %f\n",
    _11,_12,_13,_14,
    _21,_22,_23,_24,
    _31,_32,_33,_34,
    _41,_42,_43,_44);
  }
메쉬의 헤더 부분이다. 별로 중요한 부분은 없으나 정보는 저장하기로 한다. 보통 맨처음 나오므로 맨 앞부분에 출력 된다. 파일을 살펴보라.
  if(!strcmp( "XSkinMeshHeader", line))
  {
   memset(&buffer,0,sizeof(buffer));
   memset(&line,0,sizeof(line));
   fgets(buffer,200,fp);
   for(int j=0;j<200;j++)
   {
    if(buffer[j] == '"')
     buffer[j] = ' ';
    if(buffer[j] == ';')
     buffer[j] = ' ';
    if(buffer[j] == ',')
     buffer[j] = ' ';
   }
   fprintf(fpDest,"SkinInfo\n");
   int temp;
   sscanf(buffer,"%d",&temp);
   fprintf(fpDest,"%d\n",temp);
   memset(&buffer,0,sizeof(buffer));
   memset(&line,0,sizeof(line));
   fgets(buffer,200,fp);
   for( j=0;j<200;j++)
   {
    if(buffer[j] == '"')
     buffer[j] = ' ';
    if(buffer[j] == ';')
     buffer[j] = ' ';
    if(buffer[j] == ',')
     buffer[j] = ' ';
   }
   sscanf(buffer,"%d",&temp);
   fprintf(fpDest,"%d\n",temp);
   memset(&buffer,0,sizeof(buffer));
   memset(&line,0,sizeof(line));
   fgets(buffer,200,fp);
   for( j=0;j<200;j++)
   {
    if(buffer[j] == '"')
     buffer[j] = ' ';
    if(buffer[j] == ';')
     buffer[j] = ' ';
    if(buffer[j] == ',')
     buffer[j] = ' ';
   }
   sscanf(buffer,"%d",&temp);
   fprintf(fpDest,"%d\n",temp);
  }
 }


 fclose(fp);
 fclose(fpDest);
 return TRUE;
}


이로서 프래임(뼈대)의 가중치와 가중치가 적용되는 정점의 인덱스를 파일로 출력했다. 각각 이름이 있으므로 뼈대를 검색해 적용할 수 있을 것이다.



다음은 애니매이션을 로드하는 함수이다.
이번 함수도 꽤나 길다. 하지만 잘 살펴보면 별 거 없다. (사실 좀 고생해서 만들었지만)
BOOL CXFile::ReadAni(CString filename)
{
앞의 함수와 같다. 저장할 파일과 읽어 들일 파일을 설정한다. 이번엔 파일이름 끝에 ".anm"을 붙여 저장했다.
 if(!filename)
  return FALSE;
 FILE* fp=fopen(filename,"rb+");
 FILE* fpDest;
 char Name[200];
 sprintf(Name,"%s.anm",OnlyName);
 fpDest = fopen(Name,"wt");
 

 char line[200];
 char buffer[200];
 fgets(buffer,200,fp);
 scanf(buffer,"%s",line);
 while( !feof(fp) )
 {
  memset(&buffer,0,sizeof(buffer));
  memset(&line,0,sizeof(line));

  fgets(buffer,200,fp);
  sscanf(buffer,"%s",line);
애니매이션 키 템플릿을 발견했을때....
  if(!strcmp( "AnimationKey", line))
  {
   fgets(buffer,200,fp);
   memset(&buffer,0,sizeof(buffer));
   memset(&line,0,sizeof(line));
   fgets(buffer,200,fp);//애니매이션 키의 개수
   for(int j=0;j<200;j++)//가공
   {
    if(buffer[j] == '"')
     buffer[j] = ' ';
    if(buffer[j] == ';')
     buffer[j] = ' ';
    if(buffer[j] == ',')
     buffer[j] = ' ';
   }
   int temp;
애니매이션 키가 저장되는 부분에 헤더를 넣는다. 애니매이션 키는 시간과 행렬로 구성되어 있다. 이것이 여러개 있는 것이다. 이것 역시 개수를 주므로 개수만큼 for문을 돌리면 된다.
   fprintf(fpDest,"AnimationKey\n");//애니매이션키 색션 표시
   sscanf(buffer,"%d",&temp);
   fprintf(fpDest,"%d\n",temp);//애니매이션 키의 개수 저장
   for(j=0;j<temp;j++)
   {
    memset(&buffer,0,sizeof(buffer));
    memset(&buffer,0,sizeof(buffer));
    fgets(buffer,200,fp);
    for(int j=0;j<200;j++)//가공
    {
     if(buffer[j] == '"')
      buffer[j] = ' ';
     if(buffer[j] == ';')
      buffer[j] = ' ';
     if(buffer[j] == ',')
      buffer[j] = ' ';
    }
    float _11,_12,_13,_14;
    float _21,_22,_23,_24;
    float _31,_32,_33,_34;
    float _41,_42,_43,_44;
    int time,key;
    sscanf(buffer,"%d%d%f%f%f%f%f%f%f%f%f%f%f%f%f%f%f%f",
     &time,&key,&_11,&_12,&_13,&_14,
     &_21,&_22,&_23,&_24,
     &_31,&_32,&_33,&_34,
     &_41,&_42,&_43,&_44);
    fprintf(fpDest,"%d %f %f %f %f %f %f %f %f %f %f %f %f %f %f %f %f\n",
     time,_11,_12,_13,_14,
     _21,_22,_23,_24,
     _31,_32,_33,_34,
     _41,_42,_43,_44);
     
   }
   fgets(buffer,200,fp);//한줄 넘어간다.파일 보면 알 수 있음
   memset(&buffer,0,sizeof(buffer));
   memset(&line,0,sizeof(line));
   fgets(buffer,200,fp);//프래임 이름과 정확히 일치 한다.
   for(j=0;j<200;j++)//가공
   {
    if(buffer[j] == '"')
     buffer[j] = ' ';
    if(buffer[j] == ';')
     buffer[j] = ' ';
    if(buffer[j] == ',')
     buffer[j] = ' ';
    if(buffer[j] == '{')
     buffer[j] = ' ';
    if(buffer[j] == '}')
     buffer[j] = ' ';
   }
   sscanf(buffer,"%s",line);
   fprintf(fpDest,"%s\n",line);


  }
 

 }


 fclose(fp);
 fclose(fpDest);
 return TRUE;
}

이로서 파일의 추출은 끝이 났다. 우리가 만든 컨버터가 제대로 작동하는지 살펴보자. 다음에는 이 파일들을 가지고 애니매이션을 직접 해 보겠다.




스킨 메쉬의 해부 - 2(남병탁)


 시작하며
 이번에는 저번에 추출한 데이터를 가지고 실제로 애니매이션을 구하는 작업을 해보겠다. 사실 나도 무수한 시행 착오를 격었다. 이 행렬 저 행렬 모두 곱해서 정점을 변환해보기도 했고, 그만두기도 할까 생각했지만 일단 시작한 이상 끝을 보자는 생각에 여기까지 온 것 같다. 비록 허접하고 암것도 모르는 사람이지만 무언가 다른 사람에 도움이 될 수 있는 소중한 자료가 되었으면 좋겠다.
 

 계산을 어떻게 하지? 데이터 구조는?
 우리가 추출한 데이터를 가지고 어떻게 애니매이션을 구현할까? 스킨드 메쉬는 도대체 무엇을 감추고 있는 걸까? 일단 우리가 추출한 데이터를 살펴 보자.
 우리가 먼저 추출한 정보는 vsb 파일에 저장해 놓은 프래임 정보이다. 프래임은 자신만의 변환 행렬을 가지고 있고 또한 자식의 이름과 깊이가 똑같은 형제 프래임의 이름을 가지고 있다. 이것을 가지고 계층구조를 만들면 되겠고, 메쉬 정보는 그대로 읽어 들여서 사용하면 될 것 같다. 프래임은 내 멋대로 본이라고 부르겠다. 그냥 내맘대로....
 다음으로 스킨 정보를 가지고 있다.(bon파일에 저장되어 있음) 이것은 프래임의 이름과 영향을 받는 정점의 인덱스, 그리고 가중치 배열을 가지고 있다. 어차피 프래임이 가지고 있어도 상관 없다고 생각되어 프래임에 포함시키도록 하고 애니매이션 키 들은 어떻게 해야 할까?
 애니매이션 키도 역시 프래임이름을 가지고 있고 각각의 시간 정보와 함께 행렬을 가지고 있다.(anm 파일에 저장되어 있음) 그래서 이것 또한 프래임에 포함 시키기로 했다.
앞으로 만들 클래스를 정리 해보면



CBBone : 계층 구조를 가진다. 이제부터 프래임을 본이라고 부르겠다. 자신의 이름과 형제 본, 자식 본의 포인터를 가지고 처음에 컨버팅 할때 프래임의 개수를 저장해 두었으므로 먼저 이 클래스의 배열을 생성하고 각각 클래스의 배열을 돌면서 이름을 이용해 검색을 해서 자식과 형제의 포인터를 링크해 주는 방식으로 계층 구조를 만들면 되겠다. 본에는 저장되는 게 좀 많은데 스킨 데이터(가중치와 인덱스배열), 애니매이션 키 배열 등이 저장된다. 모두다 본에 종속적으로 저장된 것이므로 통합적으로 관리하는게 좋을 것 같아 그렇게 했다. 스킨 메쉬 예제에서는 배열을 돌면서 포인터를 링크하고 여튼 넘 어렵게 해놓은 것 같다.

CMesh : 이 클래스는 메쉬를 관리한다. 다이렉트 엑스의 메쉬 인터페이스를 사용하지 않고 그냥 정점 배열을 사용했다. 나중에 이것은 변환 되어 DrawIndexedPrimitiveUP 함수를 사용해 그려지게 된다.

AnimationSet 구조체 : 이것은 애니매이션을 저장하게 된다. 하위에 Key라는 구조체의 배열을 가지게 되는데 예를 들어 걷기 애니매이션이면 걷기 애니매이션에 관한 시간과 행렬에 관한 배열이(Key) 저장되게 된다. 여러 애니매이션, 예를 들어 뛰기, 점프하기, 등이 있을 수 있어서 나중에 확장하기 위해 이 구조체는 본에서 200개의 포인터 배열을 가지게 했다.

Key 구조체 : 이것은 시각과 행렬로 구성된다.

CModel 클래스 : 이 클래스는 위의 모든 것을 통합적으로 관리한다. 파일을 읽어 들여 본 클래스의 계층구조를 구축하고 각 본마다 스킨 데이터와 애니매이션 데이터를 설정한다. 또한 누적행렬계산과 애니매이션의 계산, 렌더링 버퍼의 갱신 등 통합적인 모델을 관리하는 클래스이다.


코드 설명
각각의 클래스마다 헤더와 멤버함수 구현을 주욱 나열하지 않겠다. 별로 어려운 부분은 없지만 그래도 조금은 긴 내용이라 프로그램의 시작에서부터 렌더링까지 한 스텝을 따라가면서 설명을 하도록 하겠다.
 시작은 SDK 의 클립미러 예제를 사용했다. 불필요한 부분은 모두 지우고 프래임 워크만 사용했다. 맨처음 시작점은 OneTimeSceneInit 함수이다. CD3DMyApplication 클래스는 CModel 클래스를 멤버로 가지고 있다. 여기서 다음이 호출된다.
Model.LoadFile("aaa.vsb","aaa.bon","aaa.anm");
우리가 추출한 파일 이름을 가지고 있다. 이것은 실행 파일과 동일한 디렉토리에 존재해야한다. 디버깅시에는 읽어들이지 못하는 경우가 있는데 이것은 디버깅시에는 cpp파일이 있는 곳에서 프로그램이 실행되기 때문에 cpp파일과 같은 곳에 위의 세 파일을 복사해두면 에러없이 디버깅을 할 수 있다. 이제 저 함수를 살펴보자.


BOOL CModel::LoadFile(LPCSTR vsbName, LPCSTR bonName, LPCSTR anmName)
{
 if(!vsbName||!bonName||!anmName)
  return FALSE;
 //Vsb데이터 로딩
 FILE* fp = fopen(vsbName,"rb");
 if(!LoadVsb(fp))
  return FALSE;
 MakeLink();
 //Bon파일 로딩
 fclose(fp);
 fp = fopen(bonName,"rb");
 if(!LoadBon(fp))
  return FALSE;
 fclose(fp);
 //Anm 파일 로딩
 fp = fopen(anmName,"rb");
 if(!LoadAnm(fp))
  return FALSE;
 return TRUE;
}
별거 없다. 차례대로 호출 되는 함수를 살펴 보자.
먼저 LoadVsb 함수이다. 이번건 좀 길다. 하지만 우리가 추출한 데이터이므로 어려울껀 없다. 파싱하기 편하게 해 놓았다.
BOOL CModel::LoadVsb(FILE* fp)
{
 char buffer[200];
 char data[200];
 fgets(buffer,200,fp);
 sscanf(buffer,"%s",data);
왜 했는지 모르겠다. 그냥 버젼체크
 if(!stricmp(data,"VSB Ver 1.0"))
  return FALSE;
 fgets(buffer,200,fp);
본의 개수이다.
 sscanf(buffer,"%d",&NumBones);
본의 개수만큼 배열을 생성한다. 그래서 이 배열에 본 정보를 읽어들인다.
 m_pBoneRoot = new CBBone[NumBones];
 int BoneCount=0;
 fgets(buffer,200,fp);
 sscanf(buffer,"%s",data);
 while(!feof(fp))
 {    본 정보가 들어 있는 곳을 만났을때..
  if(!stricmp(data,"Frame"))
  {
   //이름 저장
   fgets(buffer,200,fp);
   sscanf(buffer,"%s",data);
   sprintf(m_pBoneRoot[BoneCount].Name,"%s",data);
   fgets(buffer,200,fp);
   sscanf(buffer,"%s",data);
행렬이라는 헤더가 나오지 않으면 잘못된 파일이다.
   if(stricmp(data,"Matrix"))
    return FALSE;
행렬을 저장하는 부분이다.
   fgets(buffer,200,fp);
   sscanf(buffer,"%f%f%f%f",
    &m_pBoneRoot[BoneCount].TM._11,
    &m_pBoneRoot[BoneCount].TM._12,
    &m_pBoneRoot[BoneCount].TM._13,
    &m_pBoneRoot[BoneCount].TM._14
    );
   fgets(buffer,200,fp);
   sscanf(buffer,"%f%f%f%f",
    &m_pBoneRoot[BoneCount].TM._21,
    &m_pBoneRoot[BoneCount].TM._22,
    &m_pBoneRoot[BoneCount].TM._23,
    &m_pBoneRoot[BoneCount].TM._24
    );
   fgets(buffer,200,fp);
   sscanf(buffer,"%f%f%f%f",
    &m_pBoneRoot[BoneCount].TM._31,
    &m_pBoneRoot[BoneCount].TM._32,
    &m_pBoneRoot[BoneCount].TM._33,
    &m_pBoneRoot[BoneCount].TM._34
    );
   fgets(buffer,200,fp);
   sscanf(buffer,"%f%f%f%f",
    &m_pBoneRoot[BoneCount].TM._41,
    &m_pBoneRoot[BoneCount].TM._42,
    &m_pBoneRoot[BoneCount].TM._43,
    &m_pBoneRoot[BoneCount].TM._44
    );
   //매트릭스 읽기 끝
   fgets(buffer,200,fp);
   sscanf(buffer,"%s",data);
자식 본이 있는지 본다. 자식이 있으면 이름을 저장한다.
   if(!stricmp(data,"Child"))
   {
    char data1[200];
    sscanf(buffer,"%s%s",data,data1);
    sprintf(m_pBoneRoot[BoneCount].ChildName,"%s",data1);
    fgets(buffer,200,fp);
    sscanf(buffer,"%s",data);
   }
형제 본도 마찬가지.
   if(!stricmp(data,"Sibling"))
   {
    char data1[200];
    sscanf(buffer,"%s%s",data,data1);
    sprintf(m_pBoneRoot[BoneCount].SiblingName,"%s",data1);
    fgets(buffer,200,fp);
    sscanf(buffer,"%s",data);
   }
   BoneCount++;
아까 만든 본 배열에 저장하기 위해 본 카운트를 늘려 준다.

  }
메쉬가 나올 경우다. 프래임에 메쉬가 저장되어 있으므로 우리 파일에도 프래임안에 메쉬가 갑자기 나온다. 당황하지 말고 파싱!!!
  if(!stricmp(data,"Mesh"))
  {
   fgets(buffer,200,fp);
   sscanf(buffer,"%s",data);
버텍스 헤더가 맞는지 확인
   if(stricmp(data,"Vertex"))
    return FALSE;
메쉬 클래스에 정점의 개수를 저장한다.
   fgets(buffer,200,fp);
   sscanf(buffer,"%d",&m_Mesh.NumVertices);
메쉬 클래스에 정점을 저장할 배열을 만든다. 또한 정점배열과 같은 크기에 배열을 하나 더 만든다. 이곳에는 변환된, 애니매이션된 정점이 들어가고 이것으로 렌더링하게 된다.
   m_Mesh.CreateVertexBuffer(m_Mesh.NumVertices);
정점 개수만큼 루프를 돌면서 정점 정보를 읽어준다.
   for(UINT i=0;i<m_Mesh.NumVertices;i++)
   {
   
    fgets(buffer,200,fp);
    sscanf(buffer,"%f%f%f%f%f%f%f%f",
     &m_Mesh.pVertex[i].x,
     &m_Mesh.pVertex[i].y,
     &m_Mesh.pVertex[i].z,
     &m_Mesh.pVertex[i].nx,
     &m_Mesh.pVertex[i].nx,
     &m_Mesh.pVertex[i].nx,
     &m_Mesh.pVertex[i].tu,
     &m_Mesh.pVertex[i].tv
     );
   }
   fgets(buffer,200,fp);
   sscanf(buffer,"%s",data);
  }
인덱스 데이터를 읽어주는 부분이다. 위와 별다른 점이 없다.
  if(!stricmp(data,"Index"))
  {
   fgets(buffer,200,fp);
   sscanf(buffer,"%d",&m_Mesh.NumFaces);
인덱스 데이터를 저장할 배열을 생성한다.
   m_Mesh.CreateIndexBuffer(m_Mesh.NumFaces*3);
   for(UINT i=0;i<m_Mesh.NumFaces;i++)
   {
    fgets(buffer,200,fp);
    sscanf(buffer,"%d%d%d",
     &m_Mesh.pIndex[i*3+0],
     &m_Mesh.pIndex[i*3+1],
     &m_Mesh.pIndex[i*3+2]
     );
   }
   fgets(buffer,200,fp);
   sscanf(buffer,"%s",data);
  }
 }
이 함수가 뼈대배열을 돌면서 자식과 형제 이름이 같은 뼈를 찾아 그 포인터를 대입해 계층구조를 구축해주는 함수이다. 이 형제와 자식의 포인터를 통해 하위까지 뼈대의 탐색이 가능하다.
 MakeLink();
 return TRUE;
}
이 함수는 스킨정보파일을 로드하는 함수 이다.
BOOL CModel::LoadBon(FILE* fp)
{
 char buffer[200];
 char data[200];
 fgets(buffer,200,fp);
 sscanf(buffer,"%s",data);
 if(stricmp(data,"SkinInfo"))
  return FALSE;
 fgets(buffer,200,fp);//영향 정점 개수
 fgets(buffer,200,fp);//영향 면 개수
 fgets(buffer,200,fp);//총 뼈대 개수
 int iBones;//저장되어 있는 스킨 데이터의 개수
 sscanf(buffer,"%d",&iBones);
 
 for(int i=0;i<iBones;i++)
 {
  fgets(buffer,200,fp);
  sscanf(buffer,"%s",data);
  int BoneNumber=5000;//이름과 같은 본을 찾아서 그곳에 데이터를 저장해야한다.
  for(int j =0;j<NumBones;j++)
  {
   if(!stricmp(m_pBoneRoot[j].Name,data))
    BoneNumber = j;
  }
  fgets(buffer,200,fp);
저장되어 있는 인덱스와 가중치 배열의 크기를 저장.
  sscanf(buffer,"%d",&m_pBoneRoot[BoneNumber].NumInfl);
배열의 크기만큼 인덱스와 가중치에 메모리를 할당한다.
  m_pBoneRoot[BoneNumber].pIndex = new WORD[m_pBoneRoot[BoneNumber].NumInfl];
  m_pBoneRoot[BoneNumber].pWeight = new FLOAT[m_pBoneRoot[BoneNumber].NumInfl];
모두 개수를 알고 있으므로 포문으로 돌면서 읽어 들인다.
  for( j=0;j<m_pBoneRoot[BoneNumber].NumInfl;j++)
  {
   fgets(buffer,200,fp);
   sscanf(buffer,"%d",&m_pBoneRoot[BoneNumber].pIndex[j]);
  }
  for( j=0;j<m_pBoneRoot[BoneNumber].NumInfl;j++)
  {
   fgets(buffer,200,fp);
   sscanf(buffer,"%f",&m_pBoneRoot[BoneNumber].pWeight[j]);
  }
  fgets(buffer,200,fp);
오프셋 매트릭스를 읽어들인다.
  sscanf(buffer,"%f%f%f%f%f%f%f%f%f%f%f%f%f%f%f%f",
   &m_pBoneRoot[BoneNumber].OM._11,
   &m_pBoneRoot[BoneNumber].OM._12,
   &m_pBoneRoot[BoneNumber].OM._13,
   &m_pBoneRoot[BoneNumber].OM._14,
   &m_pBoneRoot[BoneNumber].OM._21,
   &m_pBoneRoot[BoneNumber].OM._22,
   &m_pBoneRoot[BoneNumber].OM._23,
   &m_pBoneRoot[BoneNumber].OM._24,
   &m_pBoneRoot[BoneNumber].OM._31,
   &m_pBoneRoot[BoneNumber].OM._32,
   &m_pBoneRoot[BoneNumber].OM._33,
   &m_pBoneRoot[BoneNumber].OM._34,
   &m_pBoneRoot[BoneNumber].OM._41,
   &m_pBoneRoot[BoneNumber].OM._42,
   &m_pBoneRoot[BoneNumber].OM._43,
   &m_pBoneRoot[BoneNumber].OM._44
   );
 }
 return TRUE;
}

다음은 애니매이션 파일을 읽어들인다.
BOOL CModel::LoadAnm(FILE* fp)
{
 char buffer[200];
 AnimationSet* pAni;
 
 while(!feof(fp))
 {
  fgets(buffer,200,fp);
 
  //애니매이션 키의 개수
  //개수 만큼 읽어들인다.
  int NumKeys;
  fgets(buffer,200,fp);
  sscanf(buffer,"%d",&NumKeys);
먼저 임시로 변수에 애니매이션 셋을 할당한다.
      pAni = new AnimationSet;
키의 개수만큼 메모리를 할당한다.
  pAni->pKey = new Key[NumKeys];
  pAni->NumKey = NumKeys;
키의 개수만큼 루프를 돌면서 시간과 행렬을 읽어들인다.
  for(int i=0;i<NumKeys;i++)
  {
   fgets(buffer,200,fp);
   sscanf(buffer,"%d%f%f%f%f%f%f%f%f%f%f%f%f%f%f%f%f",
    &pAni->pKey[i].time,
    &pAni->pKey[i].Mat._11,
    &pAni->pKey[i].Mat._12,
    &pAni->pKey[i].Mat._13,
    &pAni->pKey[i].Mat._14,
    &pAni->pKey[i].Mat._21,
    &pAni->pKey[i].Mat._22,
    &pAni->pKey[i].Mat._23,
    &pAni->pKey[i].Mat._24,
    &pAni->pKey[i].Mat._31,
    &pAni->pKey[i].Mat._32,
    &pAni->pKey[i].Mat._33,
    &pAni->pKey[i].Mat._34,
    &pAni->pKey[i].Mat._41,
    &pAni->pKey[i].Mat._42,
    &pAni->pKey[i].Mat._43,
    &pAni->pKey[i].Mat._44
    );
  }
마지막에 이름이 저장되어 있으므로 이름으로 본을 찾은뒤 본에다 포인터를 대입해서 붙인다.
  char data[200];
  fgets(buffer,200,fp);
  sscanf(buffer,"%s",data);
  CBBone* pBone;
  FindBone(m_pBoneRoot,data,&pBone);
  pBone->pAniset[0] = pAni;
 
 }
 return TRUE;
}


이제 이것으로 파일의 로딩은 모두 끝이 났다. 모든 데이터는 프로그램에 의해 불러들여져서 사용되기만을 기다리고 있다. 이것을 이용해서 빨리 걸어가는 우리의 Tiny 캐릭터를 만들어 보자.
데이터의 로딩이 끝난후 호출 되는 함수는 모델 클래스의 SetTime 함수이다. 이함수는 흘러간 시간을 인자로 받아 적절한 애니매이션 키 두 개를 찾아 뼈대에 등록하는 것이다. 이렇게 등록된 두 행렬은 서로 정점을 변환한 뒤에 시간에 따라 적절히 혼합되고, 가중치에 의해 뼈대의 영향을 받아 최종 변환되게 될 것이다.
void CModel::SetTime(float time,CBBone *pBone)
{처음 if 문은 메쉬가 저장된 프래임은 이름이 없기 때문에 파싱되면서 (null)이라는 이름이 들어가는 것을 발견했다. 메쉬가 들어간 프래임은 애니매이션 행렬이 없으므로 넘어가기 위해 다음과 같이 한 것이다. 또 재귀함수의 형태를 띄고 있는데 각각의 본을 돌면서 자식과 형제 본의 SetTime멤버 함수를 호출하고 있다.
 if(stricmp(pBone->Name,"(null)"))
 {  
  pBone->SetTime(time);
 }
 if (pBone->pSibling != NULL)
    {
        SetTime(time,pBone->pSibling);
    }

    if (pBone->pChild!= NULL)
    {
        SetTime(time,pBone->pChild);
    }
}
처음에 time_now 는 0에서 시작하고 렌더링 루프가 돌때마다 흐른시간(time)이 더해진다. 저장되어 있는 행렬이 100과 200 시간이고 현재 시간이 150이면 100과 200 시간의 행렬 두 개를 선택해서 두 행렬을 가지고 정점을 섞어 중간적인 정점을 만들어낸다.
void CBBone::SetTime(float time)
{
 time_now = time+time_now;
애니매이션 속도를 적당히 맞추었다. frame 변수는 애니매이션 키에 저장된 시간 정보와 비교되어 현재 시간에서의 적절한 애니매이션 행렬을 선택할 수 있게 해주는 역할을 한다.
 float frame = 30.0f*time_now/0.01f;
 
 int i=0;
애니매이션 키가 없는 본이면 리턴.
 if(pAniset[0]->pKey[0].time)
  return;
저장된 애니매이션 보다 현재시간이 더 많이 지나가버렸다면 다시 시간을 원점으로 돌리고 행렬을 맨처음 행렬로 세팅한다.
 if(frame > pAniset[0]->pKey[pAniset[0]->NumKey-1].time)
 {
  time_now = 0;
  blend_rate=1.0f;

  matAni1 = pAniset[0]->pKey[0].Mat;
  matAni2 = pAniset[0]->pKey[0].Mat;
  return;
 }
두 행렬을 선택하는 함수. 현재시간보다 큰 애니매이션 키를 찾아 그전 애니매이션 키와 함께 본에 마련된 버퍼에 저장한다.
 while(pAniset[0]->NumKey)
 {
  if(pAniset[0]->pKey[i].time > frame)
  {
   break;
  }
  i++;
 }
다음의 변수는 현재의 시간이 구한 두 애니매이션 키의 시각 사이에서 얼마만큼 한쪽으로 치우쳤는가를 나타낸다. 선택된 키의 time 가 100과 200일 때 현재 frame 가 100이면 100%,1.0이고 150이면 50%,0.5 이고 200 이면 0이 되는 것이다. 선형 보간에 쓰이는 것이다. 고등학교 수학시간에 많이 배웠다. 그래프에서 두 점을 알 때 중간의 어느 지점 엑스에서 와이값을 어떻게 구할까...라는 문제.. 엑스증가량 분에 와이 증가량을 구해서 답을 얻었었다... 지금 구하는 것은 엑스증가량이다. 나중에 나오지만 matAni1과 2에 의해 변환된 정점 두 개가 와이라고 생각하면 되겠다. 이것을 선형보간이라고 한다.
 blend_rate = (frame - pAniset[0]->pKey[i-1].time) / (pAniset[0]->pKey[i].time - pAniset[0]->pKey[i-1].time);

 matAni1 = pAniset[0]->pKey[i-1].Mat;
 matAni2 = pAniset[0]->pKey[i].Mat;
 
}
이제 행렬의 업데이트가 끝이 났다. 애니매이션이 적용된 두 행렬과 현재 시각을 고려한 블렌딩 인자도 설정이 끝났다. 이제 정점을 변환하는 일만 남았나?

잠시 짚어보고 넘어가자. 지금 우리의 프로그램은 어떠한 계산 과정을 거쳐야 하는지 다시 생각해보자.
 먼저 행렬의 순서이다.
본 오프셋 행렬 ×누적행렬 ←←
누적행렬을 계산해야 한다. 각각 본 마다 애니매이션 행렬을 두 개 구해 놨었다. 그 행렬을 차례로 누적시켜야 하는 것이다. 그리고 나서 이 행렬에 의해 정점을 변환 시킨다. 누적행렬이 두 개니까 정점도 두 개 변환시키고 그것을 아까 구한 블렌딩 인자에 의해 보간한다. 너무나 복잡하다. 일단 누적행렬부터 계산하자.
이것 역시 재귀함수이다. 똑같은 역할을 하는 UpdateAni2 함수가 있다. 이것은 두 번째 애니매이션 행렬을 누적시켜준다.
void CModel::UpdateAni(CBBone *pBone, LPD3DXMATRIX mat)
{
 if (mat != NULL)
 {
  if(stricmp(pBone->Name,"(null)"))
  {
FINALANITM이라는 변수에 누적행렬을 저장한다. 자신의 행렬에 부모의 행렬을 곱한다
   D3DXMatrixMultiply(&pBone->FINALANITM, &pBone->matAni1, mat);
  }
 }
        else
 {
mat 인자가 없으면 루트 본이므로 자신의 행렬을 누적행렬로 한다.
         pBone->FINALANITM = pBone->matAni1;
 }
자식과 형제 본을 돌아다닌다.
 if (pBone->pSibling != NULL)
        {
                UpdateAni(pBone->pSibling, mat);
        }

    if (pBone->pChild!= NULL)
    {
        UpdateAni(pBone->pChild, &pBone->FINALANITM);
    }
}

이제 행렬의 누적도 끝이 났다. 오프셋 매트릭스도 아까 로드되었으며 두 개의 애니매이션 행렬도 UpdateAni 와 UpdateAni2 함수에 의해 누적되어 각각의 본들이 가지고 있게 되었다. 이제 정점을 변환할 시간이다.
pVertexBuffer 라는 것은 변환된 정점이 저장될 장소이다. 이 버퍼를 모두 0으로 지운다.
void CModel::ClearBuffer()
{
 memset(m_Mesh.pVertexBuffer,0,sizeof(CHVERTEX)*m_Mesh.NumVertices);
}
정점을 변환하는 함수 이다. 이것 역시 재귀 함수 이며 모든 뼈대마다 돌아다니게 된다.
void CModel::DrawModel(CBBone* pBone)
{
오프셋 매트릭스와 아까 누적시켜놓은 애니매이션 매트릭스를 곱한다.
 D3DXMATRIX matTemp,matTemp1;

        matTemp = pBone->OM * pBone->FINALANITM;
 matTemp1 = pBone->OM * pBone->FINALANITM1;
뼈에 만약에 가중치와 가중치 인덱스 배열이 있다면 정점을 변환한다.
 if(pBone->NumInfl)
 {
가중치의 개수만큼 루프를 돈다.
  for(int i=0;i<pBone->NumInfl;i++)
  {
   D3DXVECTOR3 pOut;//첫번째 버텍스
가중치 인덱스를 검색해 정점을 찾은후 계산해 둔 행렬에 의해 정점을 변환한다.
 D3DXVec3TransformCoord(&pOut,&D3DXVECTOR3(m_Mesh.pVertex[pBone->pIndex[i]].x,
     m_Mesh.pVertex[pBone->pIndex[i]].y,
     m_Mesh.pVertex[pBone->pIndex[i]].z),
     &matTemp);
   D3DXVECTOR3 pOut1;두번째 버텍스
가중치 인덱스를 검색해 정점을 찾은후 계산해 둔 행렬에 의해 정점을 변환한다. D3DXVec3TransformCoord(&pOut1,&D3DXVECTOR3(m_Mesh.pVertex[pBone->pIndex[i]].x,
     m_Mesh.pVertex[pBone->pIndex[i]].y,
     m_Mesh.pVertex[pBone->pIndex[i]].z),
     &matTemp1);
이게 아까 그 그래프 어쩌고 저쩌고 이야기한 그 것이다. 처음 정점에 두정점과의 차이에 얼마만큼 가까운지 퍼센트를 곱한다. 이것이 정점을 섞어주는 과정이다.
   D3DXVECTOR3 pFinal = pOut+(pOut1-pOut)*pBone->blend_rate;
이제 정점 버퍼에 계산한 정점을 더한다. 이때 가중치 만큼 곱해져서 정점버퍼에 더해지게 된다. 나중에 다른 본을 돌면서 똑같은 본이 가중치에 의해 곱해져서 더해지게 되는데 한정점에 대한 가중치들을 모두 계산했을 때 1.0이 되어야 한다. 이것은 엑스파일에 그렇게 저장되어 있으므로 걱정안하고 아래의 방식으로 해주면 스키닝이 적용된다고 보면 된다.
    m_Mesh.pVertexBuffer[pBone->pIndex[i]].x = m_Mesh.pVertexBuffer[pBone->pIndex[i]].x+pFinal.x*pBone->pWeight[i];
    m_Mesh.pVertexBuffer[pBone->pIndex[i]].y = m_Mesh.pVertexBuffer[pBone->pIndex[i]].y+pFinal.y*pBone->pWeight[i];
    m_Mesh.pVertexBuffer[pBone->pIndex[i]].z = m_Mesh.pVertexBuffer[pBone->pIndex[i]].z+pFinal.z*pBone->pWeight[i];
  }
 }
또 자식과 형제를 챙겨야죠....
 if (pBone->pSibling != NULL)
    {
        DrawModel(pBone->pSibling);
    }

    if (pBone->pChild!= NULL)
    {
        DrawModel(pBone->pChild);
    }
}


다음은 Render 함수의 일부분이다.
// Begin the scene
    if( SUCCEEDED( m_pd3dDevice->BeginScene() ) )
    {
        m_pFont->DrawText( 2,  0, D3DCOLOR_ARGB(255,255,255,0), m_strFrameStats );
  m_pFont->DrawText( 2, 20, D3DCOLOR_ARGB(255,255,255,0), m_strDeviceStats );

  m_pd3dDevice->SetFVF(Model.FVF);
  m_pd3dDevice->DrawIndexedPrimitiveUP(D3DPT_TRIANGLELIST ,
   0,
   Model.m_Mesh.NumVertices,
   Model.m_Mesh.NumFaces,
   Model.m_Mesh.pIndex,
   D3DFMT_INDEX16,
   Model.m_Mesh.pVertexBuffer,//우리가 변환시킨 정점들이 저장된                                                      버퍼
   sizeof(float)*8
   );


        // End the scene.
        m_pd3dDevice->EndScene();
    }




지금까지 한 내용을 정리 해보자.


1.뼈대의 계층구조 구성
2.뼈대에 스킨정보 구성
3.뼈대에 애니매이션 데이터 로드
4.시간정보를 통한 애니매이션 행렬 두 개를 세팅, 블랜딩 인자 설정
5.세팅된 두 애니매이션 행렬을 뼈대를 따라 누적
6.정점을 두행렬로 변환
7.변환된 두정점을 블랜딩인자를 이용해 시간에 알맞은 정점 생성
8.가중치를 적용해 뼈대를 따라 돌면서 정점 버퍼에 정점 저장
9.정점 버퍼를 렌더링



참고 :  이 프로그램은 메모리 누수가 발생한다. 동적으로 할당된 메모리를 신경쓰지 않고 결과에만 너무 집중한 나머지 나도 메모리 해제를 할 수 없을 정도가 되어 버렸다. 사실 제대로 된 코드를 짰으나 이상하게 손상된 블록이라고 메모리 해제가 되지 않았다. 누군가 이것을 좀 대신해 주었으면 좋겠다. 다음번에는 함수 하나만으로 애니매이션을 실행하고 정지하며, 두 애니매이션간의 블렌딩 까지 한번 만들어 보겠다.



이상으로 스킨드 메쉬를 나만의 방법으로 따라해 보았다. 사실 스킨드 메쉬 예제는 너무나 보기가 까다로왔다. 레퍼런스 카운트와 인터페이스의 멤버 함수, 숨겨진 데이터의 흐름 등 알아보기가 너무 어려웠다. 시간이 좀 걸리고 노가다 비슷한 작업이 많았지만 그래도 움직이는 Tiny를 보았을땐 정말 기뻤다. 아직은 걸음마 단계고, 그저 취미로 하는 프로그래밍이지만 정말 재미있고 신나는 일인 것 같다.

잡담 ... Cal3D 라이브러리를 봤는데 정말 대단했습니다. 간단하게 d3d 프래임워크에 얹어 보았는데 정말 부드럽고 사실적인 블랜딩은 정말 칭찬할 만 했습니다. 다음에는 그쪽도 좀 건드려 볼까 생각 중이구 ... 나름대로 생각이 있다면.. 철권과 같은 대전 액션 게임을 만들어 보는게 꿈입니다. 지금 나름대로 공부하고 있지만 대학교 3학년의 바쁨과 지나친 귀차니즘은 자꾸만 꿈을 멀게만 하는 군요. 이렇게 그냥 시간만 흘러서는 안되는데....



참고자료 : 어디서 구한 d3dx_skinedMesh.pdf
   기타 잡다한 자료.(인터넷) 다 알수 없음.





프로그램 디버깅

스킨메쉬예제를 사용한 파일 컨버터는 SDK에서 스킨 메쉬와 같은 위치에서 디버깅하시면 되고
다른 한개의 컨버터는 상관없습니다.
실행프로그램은 SDK의 클립미러 예제와 같은 곳에서 빌드하시면 될 겁니다.

SDK가 설치 되지 않았을 경우 오류가 나구 SDK버전이 맞지 않아도 오류가 나더군요.

실행파일은 잘 되던데 파서 들은 오류가 나서...ㅠ,.ㅠ
9.0b SDK 사용했구 비쥬얼C++6.0 윈도98에서 만들었습니다. w 키는 캐릭터를 작게 보이게, s 키는 크게,
1번은 와이어프레임, 2번은 솔리드 입니다.
메터리얼은 그냥 암때나 해서 이상해요...ㅠ,.ㅠ 그리고 제발...  메모리 누수.. 좀 잡아주세요.


글쓴이 남병탁
메일 : nampt68@sayclub.com
블로그 : nampt68  (네이버)


누가 퍼가실랑가 모르겠지만 그래도 퍼가실땐 이름은 지우지 말아 주세요.

퍼가실때 어디 퍼가시는지 꼬릿말 좀 부탁해요.



실행 파일과 소스는 이곳 자료실에 모두 올려 놨습니다.


본이 아니게 이곳을 빌어 자료를 공개함을 죄송스럽게 생각합니다.

신고
Posted by Real_G