|
|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
/ N W- @- T7 M+ q4 Y: P) d8 N h* k8 X1 C8 x/ D* s
在这篇日志里面,你可以获得这些信息:
$ ^* ^7 b2 T8 E; }( P8 g3 }0 f- ^. N' u
1 人物动画的框架
) R' W6 ~7 i# S3 ]5 f1 b! W2 骨骼动画及蒙皮技术. B8 |1 D& M* |
3 doom 3和quake 4中模型和动画格式md5及原理
5 K) X7 j+ B$ I x: ?: h2 `4 可能的扩展
+ S( E9 Q& k. b* l' }% P6 W9 G O4 w
_1 Y9 l- C: x6 J& I; h先来看一下人物动画的几种方法:8 `" }3 H) y! \
; [4 b& \: N& e- K
一、简单关键祯的动画: z* g* |, L" _& B5 s" p+ V# z
2 H+ [& W8 s( ^/ u& T
像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。
4 i- W/ Y. E: s. y) S( m2 a( ^! I' T& J- m- j4 |; U% v2 X; z
二、简单的骨骼动画及蒙皮技术
, X9 v8 r" X$ E P( h/ I
4 Q% m0 k- f8 p5 Q8 m0 c 现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
4 i1 y% p0 N8 [: p; ~! h: Q) G5 U I" g2 d5 K2 ]+ _( ]
三、改进的蒙皮方法和基于物理的骨骼动画
% X, o9 T0 O, T2 G' o/ I: j0 g r
# v2 m% h9 _8 Q4 L [% y 改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;/ B/ B; }9 u9 Y) @1 N7 t
r5 Z+ j, ~ P4 ~- X% R1 N" U 基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
- H) p, d( U b) i, {# x
: u6 M d$ H# U) {5 W
T* [+ Z' |0 O4 q1 s* `8 R7 z8 N% A) P
基本的蒙皮原理
) Q5 k6 z( P! g/ x! {" g
, J# O: i: K( w9 \! R5 v拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
) ~# ?' S( @6 m3 o$ I4 \8 Y
) O F) B' F6 S( YJoint: 用来记录骨骼的关节的信息;
) T: o" B: ]4 pWeight: 用来记录顶点相对于关节的权值;
# s; S4 L" D) R7 _, @% qVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;( F+ t: ^) \6 C; A
, f# \. Y! Z9 J- w) s( P1 B
现在就来解释一下这三者之间的关系:, Q$ X$ w. ~% K2 \5 {0 v9 Q) v
7 u5 e! j) k( m2 m; ?5 D
Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
2 V* S3 U O6 L1 h9 \$ \# J0 j* t4 H7 G
很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
6 b5 f K( f `$ @2 m. v6 M9 s( H2 j3 R, f
了解这些基本的概念,下面就来介绍人物动画系统的框架。
% l0 y p# p! p9 j( ~0 Y9 H: z) \% t) U) u# V+ U6 C( n
b/ A+ }; b! U; `. I: ?/ \6 c骨骼蒙皮基本框架
! z( j5 }3 R6 _! \
0 h% R8 C! }2 g4 m5 d基本的类型:
% B, S/ |- {% n N& y& Q2 @
$ l- o3 y- a* v! c3 u* v' G关节信息:' K9 \" I) F: E( J0 k
: `4 [; m) M$ L. Z' B6 z4 Ttypedef struct _CharJoint" v3 C* l8 g5 w' Y
{
g5 |& B) D+ z* U4 ?; h Vector3 pos;
6 g' L$ J% P D) b7 T" q Vector4 startPoint;
' @ |% K3 c% W# t' _# j int parentID;$ ~, D7 g, |& O
char name[32];
9 z% y4 a8 L) m" n6 q} CharJoint;! T7 M, ]6 U Z ~6 x' u. U* u
: s q) c: y. h9 l7 r1 W其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;1 _) v, r8 y* G+ r# {
* G/ g+ w% c/ e( U/ p
权值信息:0 ?! ^) p9 ^. a/ Q: ?2 p
' f; }# a. y9 O$ mtypedef struct _CharWeight& Z" Y* t' \& M. m$ e; r1 J
{
) `8 G" |/ y- N7 v/ @ Vector3 pos;2 G# j5 R N# G2 Z
int jointID;: T. y" s# s; p( o+ x
float bias;8 J$ w9 i9 D" j8 d
} CharWeight;) K' Q/ b1 X3 i' |
' I; c, X2 W9 _# @其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
8 ^* m: p3 x1 h+ s# J* G
8 }5 _% q4 k) w; z
) t# ^: `. x0 }% d7 |" D顶点信息:& A' F0 C: Q1 g# W& p$ S V9 B
' n+ c* R( Y& D
typedef struct _CharVert0 x/ _& M, W6 p) c" z) e8 L# \
{
' B2 C5 ^4 ~9 d5 p& v float u, v;+ l0 ?% v' T1 `$ g. O( r
int startWeight;# j& R3 w- M2 Y& {: }2 v
int weightCount;) t; ]' V& z1 F h
} CharVert;
1 U: r/ } _# m2 f" l2 e
+ J+ U5 X- G. N1 o5 ?0 i 其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。
5 Y8 D; z% \" O5 v4 M I+ e0 l
/ x8 P* Y4 D6 v9 n* ^/ Z' o: c9 s) E% b! x& q
大概还涉及到这样一些类:
* L7 Y! E9 u0 N/ i7 v+ s% }3 X! w& P; w) I' z
CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
1 k% b: v' u9 OCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
: h( [8 L: G) ]) M4 zCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);3 M8 y7 A8 O- b/ Y/ ^# n* w
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
$ B6 s0 \& b) o. a3 g* G. \; r( }" oCharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
, v# i D! [' z
) n) C# h. d5 B: @( b
O, C- q0 b8 `5 @" X6 Y: i$ ?# \解决关键问题
$ j4 {. ^' s9 M( p9 o$ D! S2 p: t# Y* F$ ?# M
刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。3 n* l4 {0 ]" ^9 V3 ~( {
/ m5 y8 c, K6 e3 z* ]关键祯混合:
# @; H9 i! G7 k7 E) O$ H8 ?) [+ J: m7 o2 q4 }
简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);5 | B+ d* j/ ~3 Y
4 h( k- X' q+ w
际应用中还有其他的混合形式,后面再来介绍。- A q1 W" o( W/ x) B
/ g- V$ `/ `6 g. A
3 s5 K- |. a$ \, x) }% R+ B计算实际顶点:
8 O: C% w! [) w
3 E$ H, R8 e! k) A) ^% i我们看一下软件的(用CPU做蒙皮)Blend过程:$ A* ^9 f: {# k! @# R G4 c# [7 o( C
: } w8 L, W) f/ O' I; Nvoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk ); A, I! F: K1 t# e& i- S
{
% S$ [/ S. l4 n4 q- ~ if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
$ e- q8 X1 l5 ~8 r, W# K) v) [ return;# `& z* H" j* F" I
+ r9 U/ s' a. v9 R% A, i m Y CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
9 J$ `5 {( p/ i# L2 r/ L int numVerts = inputMesh.GetNumVerts();5 Q& E/ w4 w! _3 _# W3 d. ~
int numTris = inputMesh.GetNumTris();& S5 d4 a5 e5 B2 H4 s
* y/ u) ~2 C0 L3 N! P; T! ?8 t: o
for ( int i = 0; i < numVerts; i++ )
- Y0 S* B0 o7 Y6 J& Y+ W3 Y6 B R {8 z4 L$ p$ k! E/ e- O; p( @0 k
const CharVert *pVert = inputMesh.GetVertAt( i );) Y: A, P; X; c# e) ~' w& l3 m5 c
pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;* j% i4 n: ^4 x F' D! M
% {4 C b% i5 [; U$ S
/* u v initial */
( ^6 w: m3 ?2 y& ~ pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;9 x/ Y% E. S# W0 d6 Y% M: j
pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
) y, L% H1 }- u. R- b M6 x- Q
* v2 X# e# W+ O5 R' i for ( int j = 0; j < pVert->weightCount; j++ )( {) `7 c& H2 E5 b8 K9 N
{2 ?6 f6 p _- w" H+ Q7 j2 |3 j
const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );- A K% Z! i* E' j* t: @5 V
int index = pWeight->jointID;% }8 I5 |- G* `1 L6 U r+ a: t
const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );; }% m7 E/ d; }: U
vec3_t wv;
, h' [+ [. O* x' Z( h B7 ` Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );- k) P% t4 g; s+ l4 z. B2 ^
pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
+ J5 D# I, a8 f$ j; X% S pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;+ @5 n. [) C* _
pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;' f9 D; n9 q9 p7 r/ j9 N- i6 t
}9 k; |! E/ D2 ^
}! c- K+ Z; j( ^2 r J9 K
# |; \0 l) i4 E& [ outputMesh.UnlockVertexBuffer();
1 t- A+ l8 `. f' ~: X/ U% V% T0 f }' `2 U4 } p0 R: F
CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
8 E+ t1 [$ ]# }' `) A. o4 d* P' d
1 _$ K( ^5 A3 Y/ W2 F: F for ( int i = 0; i < numTris; i++ )1 m, v& w3 w; y) G! t$ {
{! n6 |/ ^, }! I4 M5 Z9 \
const CharTri *pTri = inputMesh.GetTriAt( i );
* K& N8 J% P: Q1 z% c- k Y1 m pOutTri->index[0] = pTri->index[0];" |8 k) s% Z* F. L; i3 l! K$ R
pOutTri->index[1] = pTri->index[1];
, v+ F# A' f, c/ ^+ \. \' Y pOutTri->index[2] = pTri->index[2];
1 K1 C. e! Y; W }
, f. g% R+ |+ \# a) Z9 F0 H2 A" f
- |# \7 ~) j7 C/ t `. ? outputMesh.UnlockIndexBuffer();% t7 a& G. _' j' v1 u
}
9 T" s5 N A+ i L5 A' G
* p; z& U% J/ _& \' n其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。1 A' f3 I9 q' P% p& c- {' x) o. |5 Y
$ ]- r1 v. s V- e( u9 m: p: t4 w' ^1 Q) q
关于md5anim文件0 `" p1 b2 C( @
3 d( ?# a% D) a' {# f8 e
Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
" z$ n# h4 k- p. i p/ ~6 ~
8 p) m# T; i! a7 U, p8 z, |
# U/ \- z$ x( r8 l; L# K可能的扩展
7 F' U/ p9 j3 Q" }
7 E1 {+ I0 M2 R6 s. Y一、复杂动作的混合8 R7 o2 U3 E& t) W) O( G( O" k
' I. j! a2 X1 m+ T
有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。 p6 B$ q- T# X V3 b5 H2 B# a; c
' ^; O7 K1 |- O5 T* q+ R( G二、基于物理的动画. ?! E* G. ]: `$ @4 d: h
x6 h5 A7 G$ Z' {
这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
+ x2 X: p2 O& D. K; H2 }2 s+ s: j# P7 p- m( x, u
三、基于GPU的蒙皮6 Y6 N0 Q w$ ]: {4 {, l1 h
' n% A0 t! x/ s) U
原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
% }8 u1 R$ p1 e# v, H, Y
4 v* R+ n3 {- ]3 e四、非常流行的“换装”系统
1 i- z! s( j; E( K) u% j3 t7 J
- C" `8 q8 l% U a2 l% Y8 r 这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|