|
|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。: n7 t2 c6 V! p. k
4 v, L' O+ Q8 O4 b, B5 c6 b5 d在这篇日志里面,你可以获得这些信息:! M( n4 ?5 V6 ^7 B$ z
+ ?5 o) @; d* E8 \7 @- Y8 }
1 人物动画的框架
- Z2 w" p6 U4 \: l; X: w2 g2 骨骼动画及蒙皮技术5 ^. V" c+ _! @! a* e
3 doom 3和quake 4中模型和动画格式md5及原理5 [* U+ v& o9 G( l0 b/ s
4 可能的扩展' |+ a D* R0 S
5 d9 ^, b& k9 ?7 ^" U: o2 G9 S8 l
先来看一下人物动画的几种方法:0 L" K8 x9 q- Y/ h
" l% ^ Q. r( y) `; ]* z7 q q% o
一、简单关键祯的动画; [" E+ t: u6 O4 i- [* B
/ t8 \* \: [2 R- d; O
像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。1 E3 n# U: x" I! N; D: O$ }
+ |4 C& P) c: {6 a二、简单的骨骼动画及蒙皮技术$ I2 B1 W% u \
& i9 c5 p. X2 o+ } 现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。) b1 ^7 o1 |6 x- t
0 u5 _* A5 e0 x$ v! ~' M# |) Y0 d8 o三、改进的蒙皮方法和基于物理的骨骼动画
5 n$ m% R' Q* V/ B" O! I I% }
9 g( z8 |# P; E5 T* K ^/ b8 F 改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
; U% ~; |+ ~4 z: \6 Y7 }6 K
6 j8 K2 d6 \: U# g9 ^5 ~ 基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
4 a3 x( l; V4 ?8 i; c- X9 c3 t" k/ p3 o) G3 ~( }, C1 o5 o
$ e6 S, I. e: p) {7 u' P) u- x* R
3 J! y2 ]& @9 ?" G9 C% a% M5 J& ?基本的蒙皮原理
- y7 J8 @4 l& P) D6 q8 [0 q8 B
$ ^& v; ]' b; ]' N. N; T拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:8 U" M" c* b" r1 W, \" @8 a7 l
3 v1 n) F# A: m8 p6 C
Joint: 用来记录骨骼的关节的信息;( f. d( _& r$ Y/ o. s7 X0 X
Weight: 用来记录顶点相对于关节的权值;
" L% a% T; Y& [+ nVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
- u1 L/ r) U2 k4 M8 Y- X% E6 g3 m8 Z2 @# o! K8 F
现在就来解释一下这三者之间的关系:
( C! {2 P8 | o+ [9 ^5 i5 J4 c% l! @# Q( y# K
Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
* p# [1 d: o! ]5 }# E& K. j; g: m! O' F2 R/ R8 {% y L) I) n v
很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。2 L6 B6 O! Z5 C9 z
1 l: q6 ^, G, Y( W8 ~" c4 w
了解这些基本的概念,下面就来介绍人物动画系统的框架。. M4 a& [) C$ }2 g
* ]+ j( Z7 \" L! C9 t0 Y
3 ]" l4 F; g5 p+ a: ?0 J; e骨骼蒙皮基本框架& [1 R* T$ t" k; l/ _
0 z; Y) Y q# _6 ^2 ^/ f
基本的类型:
4 R* H7 g1 @( f# U# j! V$ P) M( S. \
关节信息:
E- a h3 x% }) B
5 M" [8 {/ b& Q6 Jtypedef struct _CharJoint8 b; ]: Q# v- d. t
{
& C9 V4 |3 Y% ?* r& E3 t Vector3 pos;3 D: B+ A e, N( X1 c
Vector4 startPoint;: f7 t, z4 J0 N. W# ^ E' v
int parentID;( _4 `; h N+ P6 W0 @" f6 A
char name[32];" i2 M/ L5 \% R* J* U5 i3 [! Z
} CharJoint;
! [* F/ M( J% K }% Z0 j
4 U3 B b0 f; ~% b% L+ m# E4 g3 f& ?# t0 j其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
) c% D3 O3 x! H; ^, [! k
8 m; Y3 I0 n$ Q$ s权值信息:: z0 Q* E# ?! w) c
0 J8 \8 q# x# Q4 G
typedef struct _CharWeight
& b3 @; k: ]! {2 V9 k{
! g" H! p" n0 I Vector3 pos;% e! _) O( E# ~% u [# P
int jointID;( i: B5 Y+ X. M" G
float bias;1 ~8 q' | o: Z) |# {* U
} CharWeight;
8 a) F2 S9 k" A. p' d3 M4 u! d2 k; X3 x Y, Z2 C
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;: s. c4 C6 _: K9 `( v
6 |+ ^! }3 o# U" Q' ]
! y! D# U' X' ^3 `顶点信息:
6 {/ g! L2 C$ q! {
. @/ r/ R3 r+ b1 g! F! Qtypedef struct _CharVert$ }5 }' ~4 S) A8 M$ r3 A1 }6 M5 U0 P- N
{
5 ]2 ^. ? g/ B* B) w9 Z' s float u, v;6 |+ _$ ~1 Z* f+ V7 ]# m& v
int startWeight;
5 X0 g+ G/ [5 e0 [- y/ l int weightCount;
3 \8 v3 \$ Z4 l0 Y) U- v} CharVert;: U+ R( Y4 l3 C* v; d0 S0 U! Q( o
* v* m# C4 b# q 其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。# b) D9 c7 o% }' R! d1 Z# u/ ^0 g
2 F: S4 c( r% e I* E) N
# ^9 K9 H( j# Q C* ?( V大概还涉及到这样一些类:
; _8 |9 b4 }! x) i# V0 t
4 t7 I$ F7 P4 t3 A6 _CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;; [3 o. \6 |$ ] y9 ]- G5 K
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;+ X0 C$ [7 t" h$ P
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
$ p6 G: [5 k8 @5 B+ \# _5 oCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
* D4 J! }/ d4 H( i6 h3 {& KCharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);( M$ F' j" l. \$ R7 G1 K
' U6 T: c1 a* u7 l
: C) ]0 T$ g$ X# o
解决关键问题
# {( e2 K; [5 X5 R1 H1 G9 n0 c# w/ E. Y Y- q- J1 \/ v a
刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
: N7 I+ Q4 g3 a. H0 |0 S; G! L' A9 P6 u3 b. B/ Q) p. J
关键祯混合:
: l3 [. t% f* B% b! R9 ^
2 [8 l: o3 V6 `. _ u0 C 简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为40,而我只有标识为20和50的两个关键祯,于是:40-20 = 20,50-40 = 10;而20:10 = 2:1;所以,我们现在的状态离关键祯20的差异,以及离关键祯50的差异,这两个差异的比,就是2:1;好了,所以现在很自然地,我们取倒数,1:2;于是,我们做混合的时候,用“1份”关键祯20的骨骼,和“2份”50关键祯的骨骼,然后相加两者的结果(也就是“混合”过程)最后,除以3,得到最终的“1份”关键祯为40的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
, q( g- i' I; J7 Q
* i' j0 D" a8 z; l0 z 际应用中还有其他的混合形式,后面再来介绍。5 B) p! H" X) G& @0 o/ N7 i
c/ r+ K. k; [" [, b- ~6 |! d
. x# ], J5 p" w
计算实际顶点: N7 E2 a8 G/ l4 P
8 p9 ~9 k0 w5 y
我们看一下软件的(用CPU做蒙皮)Blend过程:/ F$ a/ ?4 ~4 k$ F3 ^
3 I/ q" b0 B) ?, Evoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
7 P; g3 b( R( s. E" h% ^" X{; Q' c9 n: M0 f1 Y& o G) x4 ]
if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )1 p/ _, j+ N* H8 [2 j
return;2 R% d; x! Q; _7 o p; w) B
5 |0 K; i/ z# V# e% F. c+ S
CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();/ z. S& Y) d d2 P4 a& k9 p1 z; M
int numVerts = inputMesh.GetNumVerts();# r7 X8 i/ V) e9 [( h" W
int numTris = inputMesh.GetNumTris();0 V; o" Q8 Q7 e
" a8 Q* `) \0 ^ for ( int i = 0; i < numVerts; i++ )
8 l/ W9 j; j6 q3 f- | {# m+ w7 U& h: S/ r% j( m
const CharVert *pVert = inputMesh.GetVertAt( i );' C0 ]$ S: q ]: ~5 n
pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
8 P: r& ?& a7 X* m/ n0 ]4 j- o5 w5 R6 y$ j
/* u v initial */
1 J& e5 Q) k1 \+ m3 I& { pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;# ~( L" s( D7 @% Y: C
pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;% F* r* {3 i+ w8 N( }
2 d4 K2 Z8 T7 J- @& W0 M for ( int j = 0; j < pVert->weightCount; j++ )( c7 V" h7 ]0 I9 D* i
{0 p7 o( j! c6 d U$ \, b' d
const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );9 K$ T: a. d& ~1 ?
int index = pWeight->jointID;
: O- [% U# g, Y. X const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
! L7 \# V5 c: s2 F0 L vec3_t wv;
0 I/ W0 z# s, f, [, s5 u Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
' n% X/ n- h) a1 E |- G3 [5 c1 k pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
7 ]6 D1 a, Q# o+ i) r pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;2 B7 B) G8 R4 z1 _: v' {) q# T
pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;/ `3 y2 H$ o, H' n
}
! M, P R3 j* M! |* l3 r }. P8 a* G# }7 H O! X0 A4 }
* I$ N+ T$ `3 x2 b outputMesh.UnlockVertexBuffer();
* k/ ~2 M! v# A# \# U7 g/ r2 i
+ N! B! E4 {* U) }8 N CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();. h* y4 [: R% I* i j
& N9 d% B3 v' I( C5 B+ z
for ( int i = 0; i < numTris; i++ )" B+ ], [; q# ?
{
+ e9 J* [ S z! c const CharTri *pTri = inputMesh.GetTriAt( i );
$ T; J. B4 Y' d1 Z5 v* F pOutTri->index[0] = pTri->index[0];
* v/ R5 D8 {9 m8 b" [- U pOutTri->index[1] = pTri->index[1];- X7 U4 ~( R( @; F3 J3 N8 ]' _6 k
pOutTri->index[2] = pTri->index[2];7 L$ {2 |, U; c1 c/ Q
}# d) O. z. w! L7 d2 B1 j U
2 O& F; `8 y, D8 e' l* M1 s2 W3 w
outputMesh.UnlockIndexBuffer();) ~- |. \1 S+ L* A* K$ f) F
}
9 }9 a2 x4 F _( G* R- d; Z1 @, b( P4 o: m. z1 Q, ?/ ^* [2 b6 C9 l
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。. D7 U( o; c: x8 a
! n9 a- H% y) D- v0 z- ~6 P s' P! p0 q/ t; n' Y a0 ~
关于md5anim文件
. ?3 e+ ^" e8 a7 P4 [5 _; V
$ |6 _7 d5 ?0 Z5 b9 g Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。- u. R8 F0 C2 u1 D
7 t! x0 {5 g0 b; q
. ]) O9 L& N" B$ i5 w( k( i
可能的扩展. n8 J; e2 G- t A6 T: t# N
) q' e* \; u4 G4 J" F( w一、复杂动作的混合" d; j% L6 J8 ^" f
! G4 c# n7 y* R) e 有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。% N5 c' q( a: Z: I* |! ~$ A7 x
# Z- }7 x; J2 z- [二、基于物理的动画
( v2 u6 n M8 ~ e' S# V; M/ a4 q- O- o0 Z
这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
/ T& N0 B5 m9 g& U2 c$ U3 R! [
3 v6 _+ p% Q# x0 Y6 Q8 V- W% S三、基于GPU的蒙皮
7 m# T! @5 V- Y7 {+ O: k |* s3 U+ c" _/ J0 U
原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。+ h& [/ G* O8 u
" W6 p' k2 P# H* R5 L
四、非常流行的“换装”系统
9 Q& `% F- h% ^
4 s& k% r6 o/ O1 p. [ 这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|