|
|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
. N+ _$ ~% y' O% X: d* o/ @% x( w9 q9 m" T: p( W
在这篇日志里面,你可以获得这些信息:$ b+ N" H/ ^/ q2 c3 K: F
- C8 O) |$ J- V$ A; H- _/ U) ]1 人物动画的框架
5 p' _" z/ Z$ f1 h2 骨骼动画及蒙皮技术
& e* `7 ~! P# x8 q7 [3 doom 3和quake 4中模型和动画格式md5及原理
) k! r% v) |; h: [( Z* M0 j9 J4 可能的扩展
4 R9 H1 [, X: j0 b- L, f, ]3 l7 [& L- j& Y4 _& O
4 J9 W9 d4 C- n. q, X先来看一下人物动画的几种方法:- j- Y7 c G# q' C( @( G9 [; `' B6 d
2 h. T4 F0 z0 B" f2 z% A& I$ @
一、简单关键祯的动画7 t5 X% h! _9 a% s- G, `6 r3 B
( R: i3 G' E" M1 B8 y
像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。# X" S" O: F& G: S, j
) d% }& n% U, D, L1 W5 f二、简单的骨骼动画及蒙皮技术; r# _1 Q" }) p" e1 o# z0 g
% Z4 B: `) K& a% C7 d. q
现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
" H: ?7 d$ j" Q1 U8 B9 \8 b6 z$ A
三、改进的蒙皮方法和基于物理的骨骼动画
: C9 F- S) r' n' I3 M
$ {3 o' A$ c( Q' L0 _( R) ]- { 改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;7 H* M2 @7 Y( _
) n% L0 _5 k0 n! x% A8 Q# Y
基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
3 Y/ _+ M+ n) \ G+ s+ {3 Z- r4 m9 {' D+ o7 p2 r* g3 e2 a
- A* k* ?/ S9 a- F% k+ s. s H! h$ h
6 o; E$ N. `1 s" `& A/ T: Q
基本的蒙皮原理8 c1 c. N2 E L" k9 o
7 S! Q; O# i& Z拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
v4 B# T" x' [# Y! w; ^! f- W+ _ T" I" c
Joint: 用来记录骨骼的关节的信息;0 {" c9 k9 Y1 J6 N
Weight: 用来记录顶点相对于关节的权值;4 l2 \, I. w9 z( e( m
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;; u8 E8 Q6 b( |8 g- b
2 r0 v8 D: x/ c, d% a
现在就来解释一下这三者之间的关系:
; J2 }# [3 d) ~- c, N7 J1 a/ U5 j2 O, K! N! r
Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。* p$ w Z$ F# k
& w7 f! ~* L5 L! p' S 很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
' c$ [6 T& |! H$ e" Y* K/ s" q# [8 N' h5 Y5 m; [8 t# x- R
了解这些基本的概念,下面就来介绍人物动画系统的框架。
: n6 V& \. g7 }- C7 [8 \; Y, C) G
v4 k) k/ G/ L4 v/ I( Y: h; B* m( B% n
骨骼蒙皮基本框架( h9 V9 B$ z/ @; W$ O1 Q! _) ]/ l
9 U+ r/ A g) E0 a# c$ X; b+ q* X# q. I# h基本的类型:
) J J+ C7 p1 R
, W1 Q8 H+ J+ m+ q0 S. x关节信息:
9 |' M- G- L* G X0 P
$ U6 R. s9 C- F6 b* Q4 Ktypedef struct _CharJoint9 |4 ?# V+ G7 r! a0 F6 U7 g$ Z8 ]; S1 S
{
$ O9 S& p! }( J3 `/ V0 Z/ |; T Vector3 pos;
5 |. D) z% ?: Y$ Z( j' F8 b$ ^ Vector4 startPoint;6 I* S/ q" q5 j" B5 Q
int parentID;" b$ s, o+ d7 r) D2 D7 j Q. ^
char name[32];3 I' v/ {% m W# \
} CharJoint;
2 u+ @) |+ d+ v) C
/ L2 ]5 l2 ^' b其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;$ b$ ?0 ` K7 j( T5 e/ s( ~
' T: [- y" b4 v8 I! d
权值信息:
( D. I6 G0 R; i0 c7 m6 O+ i- b) I& I- c1 G" y" C5 y$ _# Q* S! ~
typedef struct _CharWeight+ _+ p: e4 `9 K
{
/ M! z# Y5 }+ {$ y) v0 k; E. _) W. P Vector3 pos;/ {" ?8 v) }: P5 G, C# `+ |
int jointID;2 B. @- j3 a2 }7 B" D% t8 h& ~$ v5 {
float bias;
( g5 ?6 i& \) f6 R} CharWeight;2 C* M6 `+ u: r7 J" n7 L
' g: T9 K% w5 u' ]) E! V% m3 p其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
; Y$ T5 F! y/ `9 j2 ?! }* ]) F% j8 o5 n. G. N9 F+ `
, w6 P+ Y2 i6 d顶点信息:
: C$ E0 D( E7 p! N
) |- L. e m. p! K& Y7 `: otypedef struct _CharVert; q3 N& ^0 @/ k, J5 U
{
4 X) e( ^& _+ K8 N+ H float u, v;
. K3 a; K K. Y2 ~. X7 |- D$ W4 H! M int startWeight;! P. z% F+ Z; i! n( X2 O# U4 C% }: M
int weightCount;3 }( n+ T, t; D5 m
} CharVert;
: L: J. @! r1 S! v* L% I
7 ?, [- l* C, l" S 其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。4 H4 b, g% o8 ^! E' F; ~
% _5 G; x/ M' @1 ?& w, l, m# k. {
大概还涉及到这样一些类:' R3 r( o, a- x: \) S
( S" M8 J3 G, H( Z# s. ?; c9 hCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
, r9 e) e2 A, |' Y0 fCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;7 ?: O1 H8 X! |7 F9 T7 B- D
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
9 B' q+ F, }2 W0 wCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;! @6 h! Z+ C, @9 T: N' t
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);7 w- O& I9 \4 N. W
- f1 X4 X9 c3 ~) t, L/ q2 w4 T" H$ q; z
解决关键问题
( M( `% W( b' ?' j
0 {" e9 z/ B' w 刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
+ x* t# D- [& R3 Q- o. U+ a) k
" o. k1 Z7 S; z" i( t关键祯混合:
' Z2 o. U: d3 P2 c6 e1 @ ]8 `+ D6 i0 E- `5 M5 Y
简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);( I" e5 |' I2 |
4 y( D( G6 l& E
际应用中还有其他的混合形式,后面再来介绍。- d8 |! }5 D0 Y+ ~/ m
1 B5 N: Q! I+ r6 F7 j
( N1 ]2 D) l. J( m" ^
计算实际顶点:- ~) t, h9 v4 y J. j$ w5 |
. ^- i8 }: D9 ]( _我们看一下软件的(用CPU做蒙皮)Blend过程:+ y6 M% p6 _& S/ d1 g" {/ X
' Z6 v7 \- ?/ k5 K9 E: uvoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )* ?- @ _9 a( `5 K
{
# E- B5 W* q, W( _ if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
/ d& N0 g2 u: x$ Z ^. r" q return;! J) @% J O7 c e! N
3 v' N/ p5 H6 S' |( H6 R/ i& t9 P CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
" _% g) J. U' Y- d; z7 b6 z! V int numVerts = inputMesh.GetNumVerts();8 D; O3 i5 g+ j9 ^, G' g+ Y4 ^
int numTris = inputMesh.GetNumTris();
+ Q, d1 `9 u( f9 Z, N# r4 o
6 r# G7 u/ L! y' m* b3 @. V1 I for ( int i = 0; i < numVerts; i++ )
. V% B0 V& i/ W J4 V {
# r8 }4 K z, z; o- m3 Y const CharVert *pVert = inputMesh.GetVertAt( i );8 G% q: i: Y/ A7 a
pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
1 F7 X! h5 \4 x+ n+ G5 {6 Q( M K% v" o/ F" l/ b
/* u v initial */
9 i* r/ a: l- Z w* e pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;
# V6 v o/ M2 | pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;2 q4 p3 x+ V- y6 w/ U+ p, ?% v
: a5 n4 ]( |: G/ f9 o- h for ( int j = 0; j < pVert->weightCount; j++ )
3 N1 W8 {1 a' Y" I; D5 z1 u {+ b4 C" z: I0 g& L t4 l. p
const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );) I# ~8 ^3 V3 i, t" _# S
int index = pWeight->jointID;
& I/ w# d5 v! l) l const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
g, G R- b% n- }, y vec3_t wv;
0 P3 U# Y; e5 r$ k( B/ e Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
0 a3 Y1 z* C6 ]4 \# P pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;/ ]% p1 r# r5 ]7 \$ j% Q, Z# p
pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;/ u0 r- i w0 T3 x; d
pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
3 E: w- ] ]1 W }
, r6 p9 @/ ] f- L }
( T( f# [" m8 W* I/ U
, m' f* c p6 [1 ]1 Q0 m3 V) J outputMesh.UnlockVertexBuffer();0 E* n& {# M$ H' Y9 y& S" k+ z' m
8 W& C! s: v1 [; C9 K+ D CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
* K6 E, v3 O( j& W* r" |8 }& n' T
% k- t* n g/ I- a+ d for ( int i = 0; i < numTris; i++ )
3 e: ]& @5 r% } {% y) F- c/ l* U# n1 l/ o/ Z2 X6 f
const CharTri *pTri = inputMesh.GetTriAt( i );
& w& _: Z6 T" q pOutTri->index[0] = pTri->index[0]; h1 c- s1 H4 C$ G8 {( u
pOutTri->index[1] = pTri->index[1];
6 e) U' U8 x, C; u4 V# \ pOutTri->index[2] = pTri->index[2];
# @# b( [. Q. Y }
' f# ~: ~: \! n0 a/ V. P6 N, ?7 M W
/ d0 N. L/ G) C% J. l7 T outputMesh.UnlockIndexBuffer();
. q& P) e; l0 S) z+ @, T! b}
6 E5 z8 I4 G; ?2 x, N. Z! Z6 [ w% N' R0 S n) ^( x
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。( D' j% B8 d9 A- o9 ]" D
9 d7 X2 n3 e" z9 p" Q# g3 C8 l1 I: j1 u# O) u
关于md5anim文件! ]8 u2 c- z/ B! ?
2 X( E+ d' X; \- Y; Y
Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。. \: d- b8 |2 @2 _( y& G
" ~( E) k$ M- L
5 t5 n5 g; i# ^3 ^, R7 _" Z5 A可能的扩展5 c1 r. ^9 E s5 M* ?
8 r2 O; \0 j3 D& d- q8 _3 n一、复杂动作的混合# M9 [# n1 B4 T2 Z5 A7 Z- L+ }
% b7 `6 a6 [/ ^6 c
有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
# W% s: C: x. H! X' T5 P, q, e
5 }7 A, i' B- v# Z/ n# O( ~7 ]* d二、基于物理的动画
6 ~7 l7 q: @ y" V9 t
% W1 h, e S( R+ R( v 这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
# I ]( n! g! k9 t8 @- \4 h* e0 T% A. J4 O& K0 d
三、基于GPU的蒙皮
5 M# {4 y% L+ ^; A% j& Z6 v& Z' G9 t" ~
原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
1 n! X* P% Z3 U& E' a+ E0 K5 {3 @- \# }6 Z$ Z, Y: D
四、非常流行的“换装”系统 P* O( K0 ^% D! h, S5 b' |5 ?
$ r: }. ~0 c9 H! M, G 这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|