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

유저에 의해서 한번만 생성되어져서 
지속적으로 게임 속에서 사용되어져야 하는 텍스쳐가 있다면 어떻게 구현해야 할까요?
예를 들어서 문신과 같은 유사한 기능을 구현할 때, 어떻게 해야할까요?

문신의 경우 유저에 의해서 선택되어지고, 
이후에는 지속적으로 문신이 적용된 상태로 텍스쳐링이 되어야 할 것입니다.
이를 적용할 수 있는 방법은 몇 가지가 있습니다. 

- 멀티 텍스쳐링
- 픽셀 쉐이더 상에서 혼합
- 텍스쳐에 락(Lock)을 걸어서 직접 연산


사실 위의 방법들은 실시간으로 텍스쳐 데이터를 생성해서,
텍스쳐링을 하는 것입니다.
이러한 작업을 매프레임 실행하는 것은 당연히 범죄이겠지요.^^

그래서 이를 단 한번만 수행하기 위해서
렌더타겟을 텍스쳐로 설정해서 각각의 텍스쳐를 파이프라인에 연결해서
픽셀쉐이더에서 연산하는 방법이 사용되기도 합니다.
물론 더 좋은 방법이 있으시면, 언제든지 알려주시기 바랍니다..^^

저도 물론 이렇게 해서 문제를 해결했었는데,
문제가 하나 있었습니다.
렌더타겟을 지원하지 않는 그래픽카드의 경우가 바로 그것입니다.
쉐이더를 지원하지 않는 그래픽카드라고도 할 수 있습니다. ^^
최근에는 이런 오래된 그래픽카드 유저가 거의 없지만,
해외에 나가면 또 얘기가 다릅니다.

또 하나의 미세한 문제가 있는데,
렌더타겟은 압축 포맷이 아니라는 것입니다.
즉, 일반적으로 사용되는 RGBA 포맷입니다.
결국 렌더타겟은 DDS 파일보다 메모리 점유가 클 수 밖에 없습니다.

제가 이 문제를 고민했던 시기가 2007~2008년도 사이였으니
좀 오래된 얘기이기도 합니다.
어쩌면 이제는 필요없는 것일수도.......

이렇게 GPU 활용에 제약이 있다는 얘기인데,
이렇게 되면 CPU를 활용할 수 밖에 없습니다.
일반적인 이미지 포맷( JPG, PNG... ) 등의 경우라면,
아래와 같은 방법으로 텍스쳐 이미지 픽셀의 정보를 얻어올 수 있습니다.

 
단순히 이미지의 높이와 폭만큼 접근하면서
반복적으로 이미지 픽셀 데이터에 접근하는 로직을 구성하면 됩니다.

그런데, DDS 파일 포맷은 이런 식으로 접근하면 안됩니다.
이 DDS에서 이미지 픽셀을 다루는 방법이 DirectX API 에는 존재하지 않습니다.
정확히 모든 이미지 포맷에 대해서 픽셀을 제어하는 DirectX API는 존재하지 않습니다.
즉, 이것은 개발자의 몫입니다.

DDS의 경우에는 압축 포맷입니다.
즉, DDS 파일은 이미지 픽셀 데이터가 실제 데이터와 1:1로 매칭되지 않습니다.

혼란을 피하기 위해서 용어를 명확히 하자면
DDS는 파일 포맷이고, DXT는 이미지의 픽셀 포맷입니다.
그런데, 다들 아시듯이 DXT는 1~5번까지의 압축 방법들이 있습니다.
DXT 는 결국 압축되어진 이미지 블럭에서 해당 이미지 픽셀을 추출할 수 있어야
GetPixel(...), SetPixel( ... ) 을 구현할 수가 있습니다.

DDS와 DXT에 대한 상세한 설명은 인터넷에 많은 자료들이 있으니,
저는 다루지 않겠습니다.^^

DXT 픽셀 포맷에서 이미지 픽셀 데이터를 추출하는 작업은
좀 처럼 쉬운 문제는 아닙니다.( 적어도 제게는 아주 어려운 작업이였습니다~ )
직접 데이터를 추출하셔도 되지만,
저는 라이브러리를 사용하라고 얘기드리고 싶습니다.
그 중에 하나가 오늘 소개드릴 squish 라이브러리입니다.

http://code.google.com/p/libsquish/

다운로드를 받으셔서,
헤더파일과 라이브러리를 프로젝트에 연결시켜주기만 하시면 됩니다.
그리고 아래에 한줄만 선언해 주시기 바랍니다.
#include <squish.h>

아래는 실제 사용 예입니다.



위의 예처럼 squish::DecompressImage( ... ) 만 해주시면,
RGBA 데이터가 자동적으로 풀려서 저장됩니다.
이 두 DDS 텍스쳐를 서로 혼합하는 것은 다음과 같이 하면 될 것입니다.



편의상 DXT1 만 사용해 보았습니다.
squish 라이브러리는 DXT 1, 3, 5 번 압축만 현재 지원을 하고 있습니다.
최적화도 굉장히 잘 되어 있다고 합니다.
( 홈페이지에 나와 있어서....ㅎㅎ )

그리고 보통 이미지 작업을 위해서 DevIL 많이들 사용하시는데,
DevIL 의 내부에서도 DXT 부분은 이 squish 라이브러리를 사용합니다.
( 옵션으로 squish와 Nvidia 모듈을 설정할 수 있습니다.^^ )






반응형
,
Posted by 알 수 없는 사용자

원래 2편에서는 '이해 관계자에게 먹히는' 예산계획에 대해서 쓰려고 했으나, 셧다운제 매출 300억 뉴스가 터지면서 '옳타구나! 이거다' 하고 바로 소재를 바꿔보았습니다.

아시다시피, 이번에 발표된 선택적 셧다운제에 따르면 셧다운제가 회사의 연매출액 규모에 따라 다음과 같이 적용됩니다.

  • 매출액 300억 이상인 업체의 게임
    - 16세 미만은 자정~새벽 6시까지 이용제한 + 게임 이용시 본인인증 및 보호자 동의 필요

  • 매출액 50억 ~ 300억 미만인 업체의 게임
    - 게임 이용시 본인인증 및 보호자 동의 필요 (셧다운제는 적용 안됨)

  • 매출액 50억 미만인 업체의 게임
    - 아무 제약 없이 서비스


일단 규제안의 정당성이나 합리성의 문제점은 제 포스팅의 주제와는 거리가 멀고 다른 분들이 이미 신나게 언급하실테니 굳이 나열하지 않겠습니다. 오늘의 주제는 '회계의 관점'에서 매출액 규모에 따른 셧다운제 제한을 어떻게 피해갈 수 있는지를 살펴보면서, 회계처리에 숨겨진 합리적 거짓말에 대해서 알아볼까 합니다.



매출액이 300억이 넘어가는데 어쩌면 좋죠?

사장님, 연매출액이 300억이 넘어가서 셧다운제 적용 때문에 고민이시라구요? 일단 저부터 채용을... 여기 매출액을 귀신같이 줄이는 비법을 소개해드립니다.

방법1: 게임별로 회사를 분리한다
아주 간단한 방법입니다. 만약 여러분이 연매출이 300억이 넘는 게임업체의 사장이고 회사의 게임별 매출액이 다음과 같다면 이렇게 하면 됩니다. 그냥 자회사를 만들어서 각 자회사별 매출이 300억이 넘지 않도록 게임을 분배해주면 됩니다.

그런데 아마도 바보가 아닌 이상(주어는 없음) 이정도의 꼼수에는 금방 대책이 나올 겁니다. 게다가 상장된 기업이나 외부 감사를 받는 규모의 비상장 기업은 한국채택 국제회계기준(K-IFRS)을 따라야 하므로, 자회사로 분사해도 결국 모기업의 연결재무제표에 자회사 및 계열사 매출까지 잡히므로 이걸로 걸고 넘어지면 난감해지죠.



방법2: 매출액을 쪼갠다!!!
일견 방법1과 유사한 것 같지만 사실은 완전히 다릅니다. 예를 들어보죠. 'A"라는 퍼블리셔가 어떤 게임 하나를 국내 개발사"B"와 퍼블리싱 계약을 통해 서비스하여 400억의 연매출을 올리고 있다고 가정해 봅시다. 그럼 100억을 낮춰야 셧다운제 적용이 안되겠죠? 요걸 어떻게 낮출 수 있을까요?

일단 빌링과 결제 관련 고객지원 업무를 분리해서 제3의 회사 "C사"에게 넘깁니다. 이때 A사가 C사의 지분을 50% 이상 보유하거나 이사진을 장악하여 C사의 실질적인 지배력을 갖추면 곤란합니다. (왜냐면 국제회계 기준의 적용을 받는 기업의 경우에는 지분법회계 규정에 따라 자회사 또는 영향력을 행사할 수 있는 관계회사의 경우 문제가 좀 복잡해집니다) 그러면 이 게임에서 유저가 결제한 금액이 A사로 바로 들어오는 게 아니라 C사로 먼저 가게 합니다. C사의 성격은 그냥 A사 대신 빌링사와 매출을 정산하고 결제 이슈에 대한 고객지원 업무를 대행할 뿐입니다. 즉 C사는 '게임사'가 아닙니다. 게임의 서비스권은 여전히 A사의 소유죠.

그리고 C사는 빌링사로부터 받은 매출액에서 일정액의 수수료를 취합니다. 그리고 나머지 금액을 A사에 다 주는 것이 아니라, A사와 B사의 퍼블리싱 계약에 따른 수익분배율에 맞게 나누어주면 됩니다. 만약 이 수익분배율이 5:5 이고, C사의 수수료가 매출액의 5% 라면, 3개 회사의 연간 매출액은 이렇게 달라집니다.

원래는 이랬던 것이        
A사 매출액 400억 (개발사에게 로열티를 지급해야 하므로 실제 수익은 200억 이하)
B사 매출액 200억 (A사로부터 받은 로열티 200억)

이렇게 바뀝니다            
A사 매출액 190억 (C사 수수료 20억 제외한 380억의 절반)
B사 매출액 190억 (위와 같음)
C사 매출액 20억 (매출액 400억의 5% 수수료 수입)

자 이렇게 되면 퍼블리셔인 A사는 매출액이 절반 이하로 줄었죠. 하지만 손해를 본 걸까요? A사의 원래 매출액은 400억이었지만 사실 이 금액이 모두 이익인 것은 아닙니다. 400억을 벌어서 개발사인 B사에 로열티를 줘야하고, 또 서비스 인프라, 각종 인건비 및 판관비 등의 비용이 발생하므로 실제 이익은 훨씬 적죠.

그러니까 이 방법은 어차피 A사가 지출해야 할 돈을 아예 받지 않아 매출 규모를 줄이고 대신 덜받는 만큼의 매출을 다른 회사로 돌리는 것입니다. 만약 C사가 게임의 서버 및 회선까지 구축하고 수수료를 더 받아 간다면 A사는 매출규모를 더 낮추고도 이익은 그대로 유지할 수 있을 것입니다. 그리고 B사는 원래보다 10억 정도 매출이 감소하는데, 이것은 C사로부터 받는 금액 비율을 A사와 조율함으로써 간단히 해결할 수 있습니다.

뭐 이런 목적을 위해서 그렇게 하는 건 아니지만 이와 유사한 방법으로 서비스되는 게임이 실제로 존재하며, 법적으로도, 회계적으로도 아무 하자가 없습니다.

이렇듯, 회계라는 것은 쉽게 눈에 띄지 않는 꼼수를 넣어 '합법적, 합리적인' 조작이 개입될  여지가 있습니다. 특히 외부감사를 받을 의무가 없는 비상장 중소기업의 회계자료는 액면 그대로 신뢰해서는 안됩니다. 자료만 봐서는 드러나지 않는 분식(粉飾: 회계처리상의 조작행위)이 어디에 감춰져 있는지 모르니까요.



합법적인 회계에도 합리적인 거짓말이 숨어있다

앞서 사례를 들어 설명했듯이 회계에는 분식회계와 같은 불법적인 거짓말만 존재하는 것이 아니라 합법적이지만 합리적인 거짓말도 숨어 있습니다. 이번엔 셧다운제와는 관계없는 다른 예를 하나 들어보죠.

신생 게임 퍼블리셔 D사는 신규 게임의 론칭을 위해 2012년 1월 1일자로 서버 장비를 2억원 어치 구매했습니다. 이 경우 회계상으로는 서버장비라는 고정자산이 2억원 증가하고, 2억원의 현금이 지출된 것으로 처리됩니다. 즉 장부상으로는 현금자산 -2억원, 고정자산 +2억원이죠.

그러면 2012년 12월 31일을 기준으로 이 서버 장비는 회계상 어떻게 기재되어 있을까요? 2억 주고 산거니까 1년이 지나도 그대로 2억 일까요? 물론 아닙니다. 아마 대부분 이런 말을 들어본적이 있을 겁니다. 회계에는 '감가상각'이라는 개념이 있죠. 이 감가상각이란 놈은;

고정자산의 가치감소를 산정하여 그 액수를 고정자산의 금액에서 공제함과 동시에 비용으로 계상(計上)하는 절차


라고 뇌입어 백과사전에 써있네요. 단지 한 문장이지만, 감가상각의 핵심이 잘 설명된 표현입니다. 이 설명대로, 감가상각이란 자산의 가치가 얼마나 감소했는지를 산정하고, 그 금액을 비용으로 인정하는 절차입니다.

기업이 보유한 자산 가운데 상당수는 '감가상각'을 통해서 기간별로 비용처리해야 합니다. 보통 자동차, 기계류, 컴퓨터 등과 같은 고정자산은 구입직후부터 중고품이 되어 시간이 흐를수록 가치가 떨어지기 때문에 회계상으로 이 자산을 얼마나 오래 이용할 수 있을지를 정하고(그 기간을 '내용연수'라고 합니다) 매년 일정한 규칙(감가상각방법)에 따라 그 가치가 감소한 것으로 간주합니다. 그리고 이 방식에 따라 감소시킨 금액만큼을 '감가상각비'라는 비용으로 회계처리하는 거죠. 그러니까 감가상각비라는 건 실제로는 돈이 지출되지는 않았지만 비용으로 인정하여 회계처리하는 것입니다.

아니, 돈이 실제로 지출된 게 아닌데 비용으로 지출처리 한다니, 회계에서는 이런 거짓말을 해도 되느냐? 라는 궁금증을 가지실 분들이 있을 겁니다. 뭐 회계 공부를 전혀 안해보셨다면 당연히 할 수 있는 의심이죠.

사실 감가상각과 더불어, 회계의 시스템을 정확하게 이해하기 위해서는 우선 아주 근본적인 부분부터 짚고 넘어가야 합니다. 왜 모든 재무회계든, 세무회계든, 관리회계든 1년 단위로 회계를 결산할까요? 물론 재무회계에서는 분기나 반기 단위 결산도 하긴 하지만 그것은 어디까지나 중간보고적 성격이고, 기본적으로는 1년 단위 결산이 중심이자 필수입니다. 도대체 왜 그럴까요?

일단 '주식회사'의 태생을 생각해봅시다. 주식회사란, 주주들에게 주식을 매각하여 획득한 자본을 가지고 있습니다. 그래서 주주, 즉 주식 보유자는 자신이 가지고 있는 주식 소유율만큼 그 회사의 소유권을 가지고 있는 셈이죠. 그래서 매년 결산후에 이익이 나면 주주들에게 이익에 따른 배당금을 지급해야 겠죠? 그런데 이익을 언제 어떻게 계산하느냐에 따라서 배당금의 액수가 달라지고 또 받을 사람이 달라지게 됩니다. 만약 회계결산을 회사마다 임의대로 아무때나 해도 된다면, 상당히 비합리적이겠죠. 그래서 1년마다 한번씩 결산을 하게 정한 것입니다.

이것은 세무의 관점에서도 마찬가지입니다. 국세청은 기업으로부터 법인세를 받아서 정부의 예산을 확보해줘야 하는데, 정해진 회계결산일이 없다면 언제 얼마의 세금을 부과할지를 판단하기가 곤란하겠죠. 그래서 세무회계 역시 1년에 한 번씩 결산하여 회사의 이익금을 기준으로 법인세를 부과하는 것입니다. 국가예산도 1년 단위로 수립하고 결산하니까요.

즉, 재무회계든 세무회계든, 큰 틀에서 중요한 것은 회계기간 1년 동안 그 회사가 얼마의 이익을 남겼는지를 판단하는 것입니다. 그런데 만약 감가상각이란 개념이 없다면 어떻게 될까요? 앞서 살펴본 D사의 경우 2012년에 2억을 들여서 서버장비를 구축해서 그냥 2억을 그해에 비용처리 해야겠죠? 자 그러면 D사가 그해에 10억을 벌어서 인건비 등 일반적인 비용으로 7억을 지출했다면, 서버 구매비 2억까지 합쳐서 9억을 비용으로 쓴 셈이 됩니다. 그럼 다른 수입이나 지출이 없다고 가정할 때 이 회사의  경상이익은 1억원이 됩니다. 여기서 주주 배당금 지급하고 법인세 내고 나면 당기순이익(적자인 경우엔 당기순손실)이 남습니다.

그러면 그 다음해는 어떻게 될까요? 똑같이 10억원을 벌고 다른 비용으로 7억을 썼다고 하면... 이미 2012년에 서버구매비를 전액 비용으로 처리했으니까 2013년의 경상이익은 3억원이 됩니다. 어라? 돈은 똑같이 벌고 똑같이 지출했는데, 이익은 전년의 3배가 되네요?

그런데, 2012년 1월에 구매한 서버장비는 일회성 소모품이 아닙니다. 한번 구매하면 계속 사용하면서 특별히 고장이나거나 하지 않으면 보통 몇년은 사용할 수 있고, 또 그 사용기간 동안에 발생한 매출에 기여를 하는 것입니다. 그러니까 구매하자마자 바로 전액 비용으로 처리하는 것은 '정확한 원가 및 이익을 측정하는 관점'에서는 불합리합니다. - 매년 정확한 원가 및 이익을 측정해야 하는 이유는 바로 주주의 불이익을 막고 세금을 정확하게 납부해야 하기 때문이구요.

보유하고 있는 자산의 가치를 매년 결산 때마다 규정에 따라 차감하고, 이때 감소시킨 금액만큼을 해당 기간의 비용, 즉 감가상각비로 처리하는 것은 바로 위와 같은 이유 때문입니다. 그리고 이런게 바로 합리적인 거짓말이죠. 돈은 이미 썼지만, 이런 이유 때문에 비용처리는 기간별로 나눠서 하는 것입니다.

감가상각을 하는 방법에는 뭐 정액법, 정률법, 생산량비례법 등이 있는데, 가장 간단한 정액법만 설명해드리자면, 취득한 자산의 내용연수를 정한 다음, 취득원가(자산의 취득에 들어간 모든 부대비용 포함)를 기준으로 매년 같은 금액을 감가상각비로 처리하는 방법입니다. 위와 같이 서버장비를 2억원에 구매하고나서, 내용연수를 5년으로 정했다면, 매년 1/5씩 감가상각이 되는 것이죠. 그러니까 2012년 연말결산시에 취득원가 2억원의 1/5인 4천만원이 그해 감가상각비로 처리되고, 다음해 결산때 또 4천만원... 이렇게 해서 5년 동안 매년 1/5씩 정액으로 감가상각하는 방식이 정액법입니다.

그러면 이 감가상각비를 적용할 경우 D사의 이익은 이렇게 변경됩니다.
2012년 매출 10억 - 기타비용 7억 - 감가상각비 4천만원 = 2억 6천만원 이익
2013년 매출 10억 - 기타비용 7억 - 감가상각비 4천만원 = 2억 6천만원 이익

자 이렇게 감가상각을 적용하고 나니까 같은 수입, 같은 지출일 경우 이익이 같아졌습니다. 합리적인 회계처리가 된 셈이죠.

그런데, 여기에도 합법적인 꼼수가 들어갈 여지가 있습니다. 감가상각을 할 때 그 자산의 유효기간, 즉 내용연수라고 하는 것은 법에서 정한 내용연수를 기준으로 일정 범위 내에서 각 회사가 선택할 수 있습니다. 그래서 어떤 자산을 실제로는 더 오래 사용하더라도 내용연수를 허용범위 내에서 짧게 잡을 수도 있고, 길게 잡을 수도 있죠.

그러면 내용연수를 정할 때 회계상 어떤 꼼수가 있는지 봅시다. 보통 돈을 잘 벌어서 이익을 충분히 내는 게임사의 경우, 컴퓨터 등 각종 장비와 소프트웨어 라이선스 같은 자산의 내용연수를 3년으로 잡습니다. 실제로 더 일찍 새 제품으로 교체하든 더 오래 쓰든 그건 상관없습니다. 회계상으로는 3년으로 잡는다는 뜻입니다. - 다시 말하지만 취득한 자산의 내용연수를 3년으로 설정한다는 말은, 그 자산의 취득금액을 회계상으로는 3년동안 나누어서 비용처리 한다는 의미입니다 - 반면에 신생 스타트업이나 아직 이익을 잘 못내는 회사들은 보통 5~6년 정도로 길게 잡습니다. 왜 그럴까요?

[추가]
포스팅 후 확인 결과, 기업회계기준에서는 자산의 내용연수를 기업이 자율적으로 산정할 수 있으나, 법인세법에서는 시행령에서 자산의 종류별로 내용연수의 상한, 하한을 정하고 있습니다. (PC와 같은 비품류는 4~6년 범위내에서 내용연수를 설정하도록 제한)

필자는 (주)넥슨네트웍스 등 일부 기업에서 공시한 재무제표에서 PC, 비품 및 소프트웨어의 내용연수를 3년으로 잡고 감가상각하는 사례를 토대로 예를 들었으나 이 기업들이 세무회계상으로는 어떻게 처리하는지는 해당 기업의 관계자가 아니므로 정확히 알 수는 없습니다. 따라서 본 예시는 그냥 감가상각비의 개념을 이해하는 용도로만 보시기 바랍니다.

같은 금액의 자산인 경우 내용연수가 짧으면 매년 비용처리하는 감가상각비가 크겠죠? 감가상각비가 크다는 얘기는, 매년 '장부상 비용'으로 처리되는 금액이 증가한다는 얘기고 이 말은 '장부상 이익'이 감소하므로 '세금도 감소'한다는 말입니다. 자산을 구매할 때 이미 돈을 지불해버렸는데 비용은 이렇게 나누어서 계산하는 게 잘 이해가 가지 않을 수도 있을 겁니다. 좀 더 깊이 있게 알고 싶은 분은 회계관련 서적에서 '수익비용 대응의 원칙' 부분을 찾아보시기 바랍니다.

그럼 이번에는 신생회사나 아직 이익을 못내는 회사의 경우를 봅시다. 만약 E사라는 신생 게임사가 있는데, 이 회사가 초기에 각종 장비와 소프트웨어 등을 큰맘 먹고 4억 5천만원 어치 구매했다고 가정합시다. 그리고 회사의 대표가 회계지식이 없어서 그냥 이 자산들의 내용연수를 3년으로 잡고 회계처리를 했다고 생각해보죠. 그러면 이 4억 5천만원에 대한 비용처리는 딱 3년 동안에만 나눠서 해야 합니다. 정액법으로 계산한다면 매년 1억 5천만원씩을 감가상각비로 처리하는 것이죠.

그런데, 이 회사가 설립 2년차에 온라인 게임을 론칭해서 매년 매출액 15억, 각종 지출을 제외한 이익이 5억원이 나왔다고 가정해보죠. 여기에 감가상각비를 적용한 후의 경상이익을 계산하면 5억 - 1억 5천 = 3억 5천만원이 장부상 이익으로 인정됩니다. 그리고 이익이 났으니 법인세를 내야겠죠? 회사의 수중에 남은 이익은 5억이지만 1억 5천의 감가상각비를 제한 3억 5천을 기준으로 세금이 매겨집니다. (실제론 더 복잡하지만 개념 설명을 위해서 그냥 단순무식하게 계산합니다) 그런데 회사 설립 첫해에는 매출이 없어서 이익도 나지 않아 법인세를 낼 필요가 없는데도 감가상각비 1억 5천을 처리할 수 밖에 없었죠. 만약 이 감가상각비를 더 늦춰서 처리할 수 있다면 이 회사는 세금을 더 줄일 수 있을 것입니다.

만약 저 4억 5천원으로 구매한 자산의 내용연수를 5년으로 설정하고 정액법으로 감가상각을 한다고 생각해보죠. 그러면 매년 9천만원씩 비용으로 처리가 되겠죠? 만약 이렇게 회계처리했다면 E사는 이익이 발생한 4년 동안 총 4억 1천만원을 비용으로 인정받아 절세를 할 수 있는 것입니다. (첫해에는 이익이 발생하지 않아 법인세를 납부하지 않으므로 절세효과 없음)

따라서 감가상각을 해야 하는 고정자산을 취득할 때에는 회사의 재무상태와 향후 이익 전망에 따라 적절한 내용연수를 산정할 필요가 있습니다. 내용연수는 어느 정도 회사가 선택할 수 있는 여지가 있으므로 실제로 사용할 수 있는 기간에 구애받지 않고 회계적 관점에서 생각할 필요가 있고 여기에 합법적인 거짓말, 즉 꼼수가 숨어있는 것입니다.

* * * * * * * * * *

회계는 이렇듯 합법이든 불법이든 거짓말이 숨어있습니다. 이런 거짓말을 법과 규정의 테두리 안에서 적절히 사용한다면 회사의 입장에서는 더 많은 이익을 낼 수 있지만, 악용해서 회사의 실적을 속이는 용도로 왜곡하다보면 결국엔 회사가 망하게 되어 있습니다. 이런 속임수에 속지 않으려면 회계 지식과 감각을 갖춰야 합니다. 셧다운제를 적절하게 디스하기 위해서라도 알아두면 좋겠죠 :)



반응형
,
Posted by 알 수 없는 사용자
오늘은 아주 아주 짧고, 간단한 내용 하나 적어볼까 합니다.

스마트폰에 대해서, 열풍이 불길래, 아주 잠깐, 옛날 기억을 살려서, OpenGLES용 렌더러를 살짝 만들어본적이 있는데요. 그 때, 렌더링 파워가 어느 정도까지 될까? 라는 궁금증이 있었는데, 마침 "인피니트 블레이드"라는 게임이 출시되어서, "아~ 이 정도는 되는구나!"라고 생각했었죠.

특히, 스펙큘러가 반짝 반짝 하는 것을 보고, PC에서 하는 것 처럼 했을까? 라는 생각이 들었었는데, 좀 찾아보니까, 고정 테이블을 이용한 방법이 있더군요. 살짜쿵 소개해볼까 합니다. (실제로 UDK의 모바일 셰이더를 열어보니, 요게 있더군요.)

일반적인 Specular 계산
float BaseSpec = max(0, dot(normal, halfvector));
// SpecPower = 8, 16, 32, ...
float3 Specular = pow(BaseSpec, SpecPower); 
PC에서도 아깝지만, 스마트폰에서 pow(x, 32)를 하면 완전 아깝죠... (실제 테스트는 안해봤어요!)
pow(x, n)을 이미 테이블로 만들어 놓은 값을 참고해서, max(A * x + B)로 만들 수가 있습니다.
float BaseSpec = max(0, dot(normal, halfvector));
// N = 18, M = 2
#define SpecA 6.645
#define SpecB -5.645
float SpecularAmount = clamp( SpecA * BaseSpec + SpecB, 0, 1 );
float3 Specular = BaseSpec * SpecularAmount;
A와 B는 이미 만들어진, 아래 테이블을 참조하여, 결정하면 됩니다. 
(UDK를 보면, M=2일 때 보기가 좋다고 하네요...) 



이게 끝입니다. ㅎㅎ 간단하죠!

이 처럼 지수 계산을 간단한 곱셈으로 줄일 수 있어서, 스펙큘러를 사용하면서도 최적화를 할 수 있습니다. (결국, mad_sat instruction 하나로 처리가 가능!)
(물론, 오차 범위나 결과의 차이는 고려해야 합니다.)

그럼 어떤 차이가 있는지 한번 볼까요? 
(개인 학습용으로 개발된 렌더러에서 붙여보았습니다.)

[pow(n, 32)의 경우]

[N=18, M=2 테이블을 이용함]


직접적인 비교는 어렵긴한데, 대충 어느 정도 차이가 나는지를 보실 수는 있을 것입니다. 너무 하이라이트가 강하게 나오는 듯 하니, M, N을 테이블에서 바꾸보면서 테스트 해보면 될 듯 합니다.

간략하게 글을 썼습니다. 원본글을 읽어보시면, 더 도움이 되실거에요. 

(http://www.gamasutra.com/view/feature/2972/a_noninteger_power_function_on_.php)

사실, PC로 개발할 때에는 이 정도 비용은 크게 신경을 쓰지 않습니다. 하지만, 최근 게임들이 다양한 PC 사양에서 돌아가게 만들도록 옵션들을 다양하게 제공합니다. 심지어는 라이팅을 완전히 하지 않는 경우도 있지요.
생각해보면, 지수 연산을 마구 쓰기시작한지는 얼마 되지 않았습니다. 리니지2 정도의 시절?만 하더라도, Pixel 라이팅 자체를 거의 사용하지 않았으니까요. (이 때는 GlossMap으로 후려쳤지요!) 그러니, 당연하다고 하기에
는 생각보다 꽤 비싼 연산입니다.

약간 하이엔드급(?) 스마트폰 개발을 한다거나, 저사양 PC를 타겟으로 개발을 해야 한다면, 테스트해보시기를 추천드립니다.

그럼 27일날 뵈어요~ 휘리릭~ ㅎㅎ

반응형
,
Posted by 알 수 없는 사용자

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)에 대해서도 미리 이해가 되어 있다는 가정을 하고 있었습니다. 소스 코드에 대해서도 파싱 부분을 제외하고 나머지 부분은 너무 길어서 여기에 올리지 못했습니다. 나중에 따로 요청해주시면 보내드리도록 하겠습니다.
반응형
,
Posted by 알 수 없는 사용자
일요일에 클라이언트 최적화 테스트하기에 대한 글을 작성해 올렸습니다.

해당 내용 중 잘못된 내용이 있어 수정과 함께 제 마음이 아픈 아프다 서비스(A/S)를 드립니다.

잘못된 내용 부분은 Fraps의 fps 기록과 스크린샷 촬영 기능을 같이 켜준다. 라는 부분인데 같이 안된다라는 제보가 있어 확인해보았습니다.

안되더군요. =_=;;;

우선 변명을 드리자면 해당 내용은 제 경험과 함께 웹에서 조사한 툴 조합 시 얻을 수 있는 결과물에 대해 나름 고민해서 만든 테스팅 메뉴얼 입니다. 그런데 솔직하게 얘기드리자면 제가 대부분 15분,30분 단위의 테스팅을 진행해서  이때까지 1시간 가량의 장시간 테스팅을 할 기회는 없었습니다. (MMORPG 계열 보다는 FPS와 같은 단시간 게임 테스팅을 주로했다고 생각해주시면 될 것 같습니다.)

그러다보니 보통은 스크린샷보다 테스터의 피드백 수집으로 리포트 작성이 되더군요. (15분 30분 정도는 상황에 대해 테스터들도 기억을 합니다.)

스크린샷에 대한 내용은 1시간 이상 테스트가 지속될 경우를 예상해 도움이 되고자 넣은 내용인데 FRAPS에서 2가지 기능을 같이 사용 못한다는 제약이 있다는 걸 사전에 파악하지 못하고 글을 작성해 잘못된 내용을 올린것 같습니다. 이 부분 해당 게시물을 보신 모든 분들께 사과 드립니다.

그럼 A/S 나갑니다.

생각보다 특정 시간마다 스크린샷을 찍어주는 캡쳐 프로그램이 정말 없더군요. 저는 주로 리포트 작성용으로 오픈 캡쳐를 사용하고 있습니다만 오픈 캡쳐도 해당 기능은 없었습니다. (요청 피드백을 보내봐야 겠습니다. ^^)

우선 찾아낸 캡쳐 프로그램으로 안카메라라는 캡쳐 프로그램이 있었습니다.



인터페이스 참 단순하더군요. 저 중앙창을 스크린샷 찍으려는 영역에 맞춰 창크기를 조절하면 캡쳐 준비 끝입니다.
옵션에서 ms 단위로 시간을 조절 할 수 있으며 상단 2번째 아이콘으로 시간 캡쳐를 시작 할 수 있습니다.

프랩스와 안카메라3를 같이 사용하면 1시간 이상 테스트시 상황 기록에 대한 이슈는 해결 할 수 있을 것으로 보입니다. :)

프랩스에서 동시 기능을 지원해주는게 가장 깔끔할텐데 저도 아쉬움이 많이 남네요 :(

그럼 도움 되었기를 바라며 이만 글 줄이겠습니다.
감사합니다.

 
반응형
,
Posted by 알 수 없는 사용자
// 비즈니스 카테고리가 비어있는 것이 마음에 걸려서 원래는 개인 블로그에서 연재하려고 했던 내용을 여기서 먼저 연재합니다^^

흔히 회계라고 하면 게임 개발과는 전혀 관련이 없고 경영지원 부서나 사업 관련 부서에서나 신경쓰는 일로 알고 있을 것입니다. 저 역시 한 2년 전만 해도 '돈 계산'은 취향과도 맞지 않았고 딱히 공부한 적도 없었습니다. 개인적으로는 돈 계산을 너무 철저히 하면 사람이 쪼잔하고 치사해진다고 생각했었으니까요.

하지만 2010년 1월 조직개편 때 갑작스럽게 개발에서 사업쪽 업무로 전환되면서 '돈 계산'을 해야만 하는 '갑작스런 상황변화'가 일어났습니다. 게다가 회사 산하의 조그마한 자회사의 관리 업무까지 할당되는 바람에 본격적으로 '회계'의 영역에 들어서게 되었습니다. 제가 앞으로 연재할 <게임회사의 회계 이야기>는 이 때부터 지금까지 약 2년 동안 경험한 회계 관련 업무 경험과 그 업무를 능력을 얻기 위해 읽은 수십 권의 회계 및 재무 관련 서적을 통해 얻은 지식과 노하우를 정리한 것입니다.


누구에게나 회계는 있다

여러분 지금 기획자든 프로그래머든 GM이나 QA든 무엇이든 간에, 이 바닥에서 짬밥을 먹어 연차가 쌓이다보면 낙오되지 않는한 직급이 점점 올라가거나 아니면 회사를 나와서 창업을 하게 됩니다. 그래서 신입사원 → 파트장(대리급) → 팀장(과장/차장급) → 실장(차장/부장급) → 경영진(이사/본부장) 순으로 위치가 변화하죠.

물론 회사의 규모나 경영방침에 따라서 좀 차이가 있겠지만, 대개는 직급이 올라갈 수록 현장 실무보다는 '관리' 업무의 비중이 커지게 됩니다. 아시다시피 관리는 영어로 management 고, 이 단어는 회사를 운영한다는 의미인 '경영'이라는 뜻도 가지고 있죠. 즉, 팀장이 되면 팀을 관리/경영 해야 하고, 본부장이 되면 자신의 본부를 관리/경영해야 하는 것입니다. 이것은 '운명의 데스티니'이기 때문에 누구도 거스를 수 없습니다.

몇몇 회사들은 팀 단위로 예산을 할당받아 쓰기도 하고 프로젝트 단위로 예산을 받아서 쓰기도 합니다. 이런 경우 여러분이 팀장 또는 PM이라면 당연히 예산계획을 수립하고 보고를 올려 결정권자의 승인을 받아서 자금을 활용해야 합니다. 그리고 기간별로 예산실적도 보고해야겠죠.  게다가 요즘에는 앱 개발이나 소규모 웹게임 개발사를 창업하는 경우도 많습니다. 여러분이 만약 창업주라면 당연히 회사 자금의 관리를 위해 예산계획이나 자금운용 계획을 짜야 하고 외부 투자를 유치하거나 사업자금 대출 등을 받아야 한다면 회계의 중요성은 더욱 커집니다. 누구도 자금운용 계획조차 제대로 세우지 못하는 사람에게 돈을 맡기고 싶어하진 않으니까요

따라서 여러분의 생각이나 희망사항과는 관계 없이, 결국 언젠가는 팀이나 회사, 혹은 프로젝트의 자금을 관리하거나 적어도 예산 계획 및 실적을 관리하는 일을 할 수 밖에 없다는 점을 먼저 이해해야 합니다. 그런건 나에게 어울리지도 않고 하고 싶지도 않았던 제가 지금 2년 넘게 그 일을 하고 있듯이 말이죠.



일단 알아둘 회계 상식

우선 회계라는 용어의 의미부터 살펴봅시다. 회계(會計, accounting)란,

"특정의 경제적 실체에 관하여 이해관계를 가진 사람들에게 합리적인 경제적 의사결정을 하는 데 유용한 재무적 정보를 제공하기 위한 일련의 과정 또는 체계"

라고 뇌입어 백과사전에 써있군요... 아, 쫌 머리가 아파옵니다. 좀 쉽게 말하면, 우리 회사가 돈을 어디서 끌어와서 어디에 쓰고 어떻게 벌는지에 대한 정보를 그게 필요한 놈들분들에게 제공하기 위한 일이 바로 회계입니다.

그런데 여기서 일단 핵심은, 이해관계를 가진 사람들(관련자)입니다. 회계에는 크게 세 가지 종류가 있는데, 그렇게 나뉘는 가장 큰 이유는 바로 이해관계자가 누구냐에 따라 회계처리의 목적과 결과물이 달라지기 때문입니다. 회계는 이해관계자에 따라 다음과 같이 분류됩니다.

재무회계 financial accounting
이해관계자: 주주, 투자자, 채권자 등 주로 외부인이며, 회사에 투자 또는 자금을 제공한 후에 회수 또는 배당이익을 추구하는 사람들

세무회계 tax accouting
이해관계자: po국세청wer (-_-)

관리회계 managerial accounting
이해관계자: 회사내부의 경영진, 관리자 등

일단 재무회계는 흔히 말하는 재무제표, 즉 재무상태표(예전명칭 대차대조표), 손익계산서와 같은 결과물을 작성하여 주주, 투자자, 채권자 등 외부의 이해관계자에게 '정확한 재무정보를 제공하기 위한 회계'입니다. 그래서 상장회사의 경우는 매년 지정된 기간 내에 재무재표를 작성하여 회계법인으로부터 외부감사를 받은 후에 공시해야 합니다. 이러한 재무제표의 작성은 보통 회계 또는 경영관련 전공자가 담당하며, 관련부서가 아니면 직접 경험해볼 일은 거의 없습니다.

세무회계는 말 그대로 국세청과 같은 세금 징수 기관에 세무신고를 하기 위한 회계를 말합니다. 재무회계와의 차이점은 법인세법이나 부가가치세법 등 세금 관련 법규에 따른 소득 및 비용처리 신고를 위한 회계이기 때문에 해당 법에 따른 조정과 증빙이 필요하다는 것입니다. 이 업무 역시 회사에서 회계나 재무 관련 부서에서 일하지 않는 한 접할 일은 거의 없을 것입니다. 아주 작은 회사의 경우에는 보통 세무사에게 맡기죠.

마지막으로 관리회계는, 앞선 재무회계나 세무회계와는 달리 회사의 외부인이 아닌 회사 내부에서 사용되는 회계입니다. 즉 사장이나 CFO(재무책임자), 부서장 등의 경영진에게 예산계획 보고, 예산실적 보고 등을 해서 경영진이 판단과 결정을 할 수 있는 정보를 제공하기 위한 회계를 말합니다. 회사 내부만을 위한 회계인 만큼 어떤 법규에 구애받지 않고 각 회사의 사정에 맞게 자율적인 양식과 형태로 이뤄집니다. 그리고 이 관리회계 업무는 회계 전공자나 관련 부서가 아니더라도 회사 정책에 따라서 팀장이나 PM과 같은 직무를 맡으면 접할 기회가 생깁니다.

이렇게 회계 3종 세트에 대해서 간략하게 설명을 해보았습니다. 하지만 아직 감이 그 차이점이 잘 와닿지 않을테니, 예를 들어볼까 합니다. 여러분이 만약 연매출 100억원인 회사를 운영한다고 가정합시다. 그런데 그 해에 접대비를 10억원이나 썼다고 가정해 봅시다. (접대비라고 하면 흔히 룸살롱을 연상하는 분들이 많겠지만, 사실 외부 업체를 만나서 법인카드로 커피 한잔을 마셔도 접대비로 비용 처리 합니다. 그리고 외부 업체에 명절 선물을 사서 보내도 역시 접대비로 처리합니다)

이 경우 재무회계에서는 손익계산서의 판매관리비 항목에 접대비 10억원이라고 쓰면 됩니다. 10억원을 무엇에 썼는지 그 증빙만 확실히 갖추고 있으면 재무회계상으로는 아무런 하자가 없습니다. 다만, 그 비용이 동종업계 평균이나 과거에 비해 크게 달라졌다면 그 이유에 대해서 설명할 필요는 있습니다. 회사 외부인들은 그 이유를 설명하지 않으면 알 방법이 거의 없으니까요.

하지만 세무회계에서는 문제가 조금 다릅니다. 만약 여러분이 위와 같이 매출액의 10%나 되는 금액을 접대비로 썼다고 세무소에 신고하면 바로 태클이 들어올 것입니다. 세금 징수자의 입장에서는 이렇게 과도한 비용처리는 이익을 낮게 신고해서 납세액을 줄이려는 의도가 있다고 의심을 하기 때문이죠. (비용이 증가하면 이익이 줄고 그만큼 과세 대상액이 감소하므로)

그래서 세법에서는 진짜 접대비로 사용했건 아니건 간에 접대비와 같은 명목의 지출은 일정 제한을 둬서 그 이상의 지출은 비용으로 인정하지 않습니다. 아주 간단하게 예를 들어 매출액 100억인 회사는 접대비를 5억원 까지만 비용으로 인정한다고 세법에서 제한하면, 나머지 5억원에 대해서는 비용으로 인정하지 않고 이익이 난 것으로 간주하고 세금을 책정한다는 뜻입니다. 그러므로 세무회계 담당자에게는 관련 법규에 맞는 세금 신고는 물론, 절세방법을 찾는 것이 중요한 업무능력입니다.

반면에 관리회계에서는 접대비 10억원을 어떤 목적으로 썼고 그 비용을 써서 얻은 효과가 무엇인가를 경영자에게 전달하는 것이 포인트입니다. 만약 그 보고를 받고 경영자가 다음에는 접대비를 10% 감축하라고 지시하면, 9억원을 가지고 1년 동안 어떻게 써야할지 예산계획을 짜야겠죠.

// 짤방이 없으면 지루한듯 하니 쉬어가시라고 적절한 예시 짤방 삽입
// 다들 아시겠지만 TIG 원사운드님 카툰을 합성하였습니다.

* * * * * * * * * * *

이와 같이 회계에는 크게 세 가지가 있습니다. 하지만 재무회계나 세무회계는 관련부서가 아니면 해당 업무를 직접적으로 수행할 일이 거의 없으므로, 본 연재 포스팅에서는 관리회계에 대해서 주로 이야기 할 예정입니다. (사실 재무나 세무회계는 전공자가 아니라서 저도 잘 모르...)  이어지는 2편에서는 제가 회사에서 실제로 사용하는 예산계획, 보고 문서양식을 통해서 '이해관계자에게 먹히는' 예산을 계획하는 요령에 대해서 이야기 해 볼까합니다.

반응형
,