|
|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
8 h' s; m# K# i' ^" g& h. w7 O7 K0 A6 B
在这篇日志里面,你可以获得这些信息:
" P: K; Q; n& S% Q6 d4 v7 e* K2 n6 W& H' i Q3 d5 i7 }
1 人物动画的框架* t' y2 [9 ~! q
2 骨骼动画及蒙皮技术! \5 c# ?6 z: g+ `7 _2 }" f$ S) _
3 doom 3和quake 4中模型和动画格式md5及原理2 K: u3 }: [: z* `9 m
4 可能的扩展, h# l9 h* W% J% r Y- J
. p& s3 x1 t6 B7 R6 D# u( ^2 a
; M( ]3 B# ~' u9 ~先来看一下人物动画的几种方法:
8 Q) a$ d8 O0 F7 V/ r( ~% L. D. {7 j& V; F; \% p* ]
一、简单关键祯的动画
: q2 O9 A# n- x. \* u! l
8 e. Y% y9 ]/ O8 [! _ x4 B. D& a 像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。
4 [- z+ }" c6 s1 W
" Y8 e/ F( j# x) _( ]4 s0 Q二、简单的骨骼动画及蒙皮技术6 `, U; s$ u; v- |4 q: y% s
$ j9 v* q: n/ C3 {- @
现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
% V5 e' Q3 \. r4 d0 W! y1 D
5 V, l# ^* S9 Y) r& c" U4 D0 Y三、改进的蒙皮方法和基于物理的骨骼动画' o0 }4 Y# B6 ?0 h6 z! ~; T
% J) L, v$ h) k
改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
7 ^" y! g* x4 z% B$ K( @. O1 t* _' Y5 s8 e* F. ?& }2 `
基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
}/ T3 g& K+ V) c' P! L! f, m" o" U5 B
0 F, x" k( u& l$ C9 i7 f; a0 f
9 ^; {* c9 v4 Q+ j: [* Z1 I基本的蒙皮原理
, G: z2 }( `+ ^
. h' _/ p! }# c2 ^9 H4 o w拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
) }, q+ h' L3 w( |5 v/ C
9 y S( G( q. \, W% C& a, FJoint: 用来记录骨骼的关节的信息;( T0 T1 e% }1 |2 O6 q- Q! E
Weight: 用来记录顶点相对于关节的权值;- G9 {. I: H" L" Y
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
8 B# D, {: |: j6 Y: u/ _9 r- a
) K3 J M; `* k+ d" s现在就来解释一下这三者之间的关系:
& M1 d# _* m. W2 m9 _7 g v I4 U0 M5 u, u5 r& `1 j/ r* @
Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。: o0 g1 C _0 Q. Y7 G
7 {* R5 b: ?6 Q4 F4 h 很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
9 H6 U1 n) I) |2 O* s, V2 x; I8 |, P! A
了解这些基本的概念,下面就来介绍人物动画系统的框架。
! k. T, W9 e" [1 Y; W) J/ o v# i+ y) u: H* H
" Y4 C' ^$ q, Y( r骨骼蒙皮基本框架+ P. {6 E9 l. n9 G# E0 j, i8 Z; P
; ~) {$ }# m* f: z, W3 a
基本的类型:
9 i1 l1 b* `% Y( ]3 z: l5 o! G& |- x# C
关节信息:
0 N; q, P P" A3 {6 v) Q; D2 S+ Z2 l9 C. V0 b) p' F
typedef struct _CharJoint
, V& _& [* R u$ }( K- L{4 A! {; n5 |" e3 |3 u6 F
Vector3 pos;
) L% l3 q' X' v" n1 [5 s5 Q& Z Vector4 startPoint;$ P* a2 _$ I. Q: W i3 L
int parentID;( }9 {+ A$ e- y1 ?
char name[32];
3 h8 Y* w6 i0 x/ L) ]} CharJoint;! N0 O) @6 M6 r$ z, f& w& B ]
6 f1 Q9 R+ H. B; w其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
7 H: ]5 ^6 e/ \% K1 x, D( e/ l: n4 b: V# q
权值信息:
, ~; y) G$ m n8 R8 _2 {9 o2 _3 Y6 }4 M) N% Y& T; H% e
typedef struct _CharWeight/ _0 c% }6 l9 n3 d2 W
{
7 [7 x* e+ w8 l Vector3 pos;( x) t- U; M3 c. @7 \$ _
int jointID;5 Q" W: h' j, z6 L7 W4 w
float bias;8 \5 r; [' x/ Y! ]# L' ?
} CharWeight;6 a+ a6 ? h, [' e# s; e) V" k
* a7 T4 J. [# `1 R E其中,pos为偏移量,jiontID为对应的joint,bias偏向值;9 e, `& U5 }) S* \ Y
& H, a8 g& \9 `2 h3 g, E$ W
2 Y2 {: K% }9 e/ k顶点信息:& E" u7 I2 c1 v
& T& o# E0 p' D5 n9 Y
typedef struct _CharVert
4 H. b7 r8 i" E m$ d; w{7 w, Q8 s' T k' M8 @% W
float u, v;# d1 K) V! T: d2 j9 c9 U
int startWeight;
0 V( b5 S7 G, r( O! ~8 J int weightCount;
1 V. B7 I4 ~" z1 m$ D/ s} CharVert;) m% A# J+ ^4 M' q5 S
0 t: u) m6 h3 R* r1 S7 l 其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。* x, s4 `8 w: o: z- s1 \$ v/ Q
' ?- G( o* `; W" a9 e6 @
+ d; V' R6 [' N* A+ V+ s* Q# j大概还涉及到这样一些类:
: ]6 d% G1 @) d0 J* n
3 K, T" h7 p. B9 H+ JCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
5 z, f6 k. M+ w' }- ~" gCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
0 j1 e3 R, w5 u% ? E/ TCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
6 q. n% z- C( N+ ]7 a S3 E0 gCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;0 F$ h' f7 h! C, h/ L& T9 G6 S; ]3 u
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);: c8 d8 d6 v( A' W! _
- k6 F4 }3 }4 A9 F' P
6 @+ A, g. X8 |& R解决关键问题9 Y% q+ H# ?; l& L& [
4 Z: v* c. Q* T2 x, V
刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
+ m: V, j, t. B2 Q9 Z
; ~+ T: z3 L5 @( \5 X" }关键祯混合:3 z! G+ v; ~( x: G
1 V, Z9 J6 i C' }+ t( g
简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);( ^7 c2 `5 f/ n# W
' e- ?' m& [; ^9 L& f* C 际应用中还有其他的混合形式,后面再来介绍。. D V8 T% _; E2 Q2 Y' @
4 }' j* o) X1 k( x- c
5 H7 i1 i7 i/ s2 Z9 J计算实际顶点:2 d2 X0 L5 Q" l3 O: E! F
+ u. F! n5 ~+ }$ x4 C. u$ x( R
我们看一下软件的(用CPU做蒙皮)Blend过程:7 A: @, H3 F1 C' H2 `# Q
$ Q: } {5 x- E* }/ }: h& W6 }
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk ). g [9 ^$ u2 q' B$ d3 o/ w1 l0 b9 f
{4 m/ c R2 u" C7 N1 T' U
if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
+ \, f' k0 h f9 B& u9 Q% u return;
# p' T+ ~$ u9 w) K' W. @/ Y: R; i# L6 c0 S
CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
3 k4 |) @( z7 S; [0 i- p0 L int numVerts = inputMesh.GetNumVerts();
3 M1 c$ D" R K0 `, { int numTris = inputMesh.GetNumTris();
( ^2 x& ~$ f# [, C; s) }0 Y& e4 b/ e4 [$ N& J
for ( int i = 0; i < numVerts; i++ )0 {% b9 O0 H6 C5 [
{/ u# I$ C. m7 \* h& \' @4 Y
const CharVert *pVert = inputMesh.GetVertAt( i );6 M9 T+ x, J5 ?6 t
pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;4 p0 k) y" l4 y* f& L
# m9 v* |- n# A: C( S$ t
/* u v initial */
0 |8 ^" ?% M9 J$ ] pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;
" l5 a1 W% O* w pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;9 O$ q2 M& p5 T' G& R9 [& [
4 F& L M% r/ w1 B for ( int j = 0; j < pVert->weightCount; j++ )
0 \. v7 H) ~* [; Q( |; D0 h8 @ {
# m$ I6 q& }! {3 u% q5 L8 U const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
3 M! V3 J) z" z) O int index = pWeight->jointID;
1 b0 E {' W$ q! X const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
4 O9 f. E( B. U* i2 \ vec3_t wv;- e: [% w I2 a
Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
$ q+ m% s6 ]/ {" K pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
" C, C9 R2 K* F1 X+ k* f4 T pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
0 m% i9 D' C9 m7 m# m; r. \: w pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;; \, N' o, g/ Z5 o7 K# C. r3 }" k
}
' B' C3 y/ S' S' s6 ~% z }% H0 l- Q1 V3 x% _( f
8 y1 v1 C& M1 s4 J6 i outputMesh.UnlockVertexBuffer();; G6 O9 u( k6 M
D$ W% V1 v2 f: \/ u+ \ C
CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
" v$ [$ ?+ N" b# H
' z x) Y6 q& W3 y( I+ V; W for ( int i = 0; i < numTris; i++ )
& `0 m/ u0 e2 U8 {8 ]& j {; F1 B9 }- r" N7 `* r8 E
const CharTri *pTri = inputMesh.GetTriAt( i );3 c1 p2 H# X: x# t' i# b. W! t& [8 }/ W
pOutTri->index[0] = pTri->index[0];
% h" {3 `. y9 f- X0 @# H' _ pOutTri->index[1] = pTri->index[1];2 W; [3 e F5 |
pOutTri->index[2] = pTri->index[2];( z9 @ H' o) s. c
}
! \0 o5 \3 t5 K0 H n( D, ?) X9 ~ v' h" ?
outputMesh.UnlockIndexBuffer();
. b8 S! h8 a) d# e) I1 @}
& q4 r& t7 z1 _) j4 @2 i( k( y3 I1 E( k
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
/ u8 Z) [. M3 p1 x) U% t( G
2 c- ^" N6 b6 w. b
% \2 o6 W* G3 B% `$ B# U6 f. P关于md5anim文件
! c0 w3 T B5 Q: B8 Q; s
' a- `9 ]0 ]( M7 R0 c Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。# Q* s/ ]4 a! F9 \' d# l
) d% P" G2 L0 @* e) [7 h9 J$ ^9 ~% r. x7 V: _
可能的扩展
% Z: }$ [* x3 v& O1 k1 V! l$ Y
* L% [' D* E1 o8 d% I4 m" m9 Z8 R一、复杂动作的混合
3 e" z' M1 Y7 Y
8 B- k) L# H. V5 W/ @9 z8 Y 有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。' k# E! Q& Y2 s; |: q- o6 K7 W R! F
+ D7 z) |2 u* c: [3 G1 e
二、基于物理的动画3 h: ?4 g. R4 b# j& Z$ S; i0 F
8 n% |8 |0 I3 F4 I% M: z 这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;) U+ v+ Y' A B7 ]! Y. v
5 d8 S6 L: h4 Y, D8 F5 P* g
三、基于GPU的蒙皮/ x4 g3 F- L0 k2 M* B' C0 Q
% V3 A) Y- }& F4 F0 Q; W 原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
& h8 r ^- d4 J# ~" [* C% H8 j
% U! `9 I& v! }" `+ `四、非常流行的“换装”系统: a0 ?2 W# p$ [: L2 b2 X" p! L( @
" H% `5 R( F9 m! ` 这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|