|
|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
2 q2 w! y! b' f4 h8 p. ?
2 x% X0 e) M- @$ c在这篇日志里面,你可以获得这些信息:
' U0 h% c& ? T$ \8 a, a2 g% m9 u% N5 K5 i' d' Y
1 人物动画的框架! c/ o! n+ |( D) A+ N! l! Q
2 骨骼动画及蒙皮技术
9 W7 T/ j. Y, h3 doom 3和quake 4中模型和动画格式md5及原理7 Z7 \( N% b$ B. t6 z6 F, ~
4 可能的扩展
/ {5 `! z0 R+ d% b" D8 @! |# _' R- ^/ a7 X9 j
; `( ~" }; z: j, L9 |先来看一下人物动画的几种方法:
/ x/ O. \ y. T6 v" `( F( F2 n( A' Q% z/ r
一、简单关键祯的动画 n3 j! X% n* q" c
* N. J. Y5 M7 z$ F6 A* J 像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。: Q, |' N( H( Z! | q( S1 y
3 C' Z J; B. v( r6 h) Y: D& R+ k
二、简单的骨骼动画及蒙皮技术
F+ \) I8 F# q G! @+ [" h: G4 d# d9 {- S6 J: O
现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
( ?! R" G( \& I$ B' V" {; J) h( Y9 N
* t) G' p6 E& M, Z三、改进的蒙皮方法和基于物理的骨骼动画
2 X: y- z" L. N( \
y* p- D2 r' k5 B; N4 D) y2 R& o) ] 改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
; A& ~/ n# Z) D x ] J7 X6 i, x. e: d/ z
基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
9 z9 @* K2 g M4 X- z, |0 h$ F. G) V! p' j/ \( H
$ E0 A; \& W' R/ L' g8 \7 I0 A) G# V# t' O- f9 K- e
基本的蒙皮原理+ c0 ?8 {+ X3 Y
5 B+ q( l: U4 I* i% b- C, s6 P
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
6 L% L9 J3 v8 \. Z. d/ t7 `5 Z6 h. v, n; k5 ]1 t; g4 V
Joint: 用来记录骨骼的关节的信息;: k9 m& B1 v, E k3 d! ^' j* o
Weight: 用来记录顶点相对于关节的权值;1 l$ ]/ e0 C0 [* f! k* w* f
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;4 j0 X4 y% q; g, e3 k% p
! Y/ P r# _$ Z3 W) f
现在就来解释一下这三者之间的关系:
* Z [8 h, T4 R9 @/ \& \3 C& F6 w
) M0 q( l. s1 D Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。( Q; `" H- f4 y0 O
- ?3 E1 }, }% Y/ \
很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
! c9 n& g. U$ f" ]* i7 c/ C7 U* H( O
2 C2 |& R1 u: R# ^3 a5 x, @1 ^& g 了解这些基本的概念,下面就来介绍人物动画系统的框架。5 {- p5 J$ I+ S4 ~9 G$ P
" j$ p0 v/ Q% i+ y* C
4 ~- u! b, f1 g5 G5 W( }& _骨骼蒙皮基本框架
- M$ e+ F& u% ?# U- d$ Q: }, r: b. q9 z6 K
基本的类型:
% Y, O$ T8 M. w+ _3 q- d% H$ `/ u, t) t' v2 I& X3 d2 ^( R w
关节信息:
# ^& u. k+ M' j+ ~1 ^; a5 F0 u5 v2 I( D: J% j
typedef struct _CharJoint
4 P# I1 F9 h, c' X2 F{
* b2 g( s* P9 p2 J+ J: s Vector3 pos;8 a! Q/ s, N+ z7 Z- t( Y. x# k
Vector4 startPoint;
) r+ o) J7 D, s7 c2 j/ R1 t int parentID;
+ Z/ z5 X8 c( B: | char name[32];' }& A7 B% f4 H! E, e# ^! ^
} CharJoint;: B4 F8 Z$ c, l7 [! b: T' Z
- N0 }8 v) k. S- E1 G& g
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
3 h; T" [* A+ e
5 X# p2 _* B# F% k* {) ~权值信息:% P9 M4 |# U& T* U) T( a T# w
+ K$ I1 b5 _! C, b9 _- Utypedef struct _CharWeight0 }5 r3 o' V F+ m% S
{: A) ^. \, Q) v* e3 k! P9 p
Vector3 pos;6 s/ @; x! m$ K; _
int jointID;3 u8 V* Z1 ]" B
float bias;
1 M% C. k6 L8 ~' T} CharWeight;9 r1 e% P1 M: I0 h8 [( E/ P
7 V9 s/ I: j4 N S9 F+ e5 e其中,pos为偏移量,jiontID为对应的joint,bias偏向值;% _' m: g: n! w4 L; J( Q1 H) |
: k" g' t8 _! v, j- R9 ^
# T5 q' d# A' t/ X" N8 G) A: a顶点信息:# z1 ~3 d6 U' i8 W7 |6 F
6 y, v2 q* V0 @
typedef struct _CharVert0 U) V8 B. Q: O+ V: v* u
{
4 A: ~+ Z- p5 D float u, v;
* }# t: B7 ^4 Q. R7 Y int startWeight;/ V% e @7 C% `& H: J' H. I- T
int weightCount;$ D# o$ e( H. {# }. o
} CharVert;* z* q, h: u! ^# z
/ h) O; c$ f1 Z6 o 其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。
/ @3 d# F# P. }* @9 i; F
& h. t# A0 ?$ ?0 s
Q% w1 Z* \: V6 @大概还涉及到这样一些类:, W6 G4 d1 n. @/ n6 ^' x7 z5 @
. A5 Q. _; P6 V4 g8 r9 ^
CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;9 Q# K6 `! _* z) d- n
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;- o0 u" c; S- O3 D/ }; h' g- r
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
* @" @4 h4 n( x+ x- PCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;8 t; S" [* X5 ]& T
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);, A' h/ R% B% y/ |. ?. a
1 \" \) X4 N- |+ a
+ w/ Y- p3 F) o( D1 H& J
解决关键问题
: T$ \9 I7 N) ]
o" V3 K3 n5 ?( \1 d& g 刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。) J/ k$ w" C: o- a% R. h$ o
\: Y* _2 o/ ^ A+ Y
关键祯混合:
& w$ U& h2 c' a; e: }7 `* w- p$ p: z9 \5 l
简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
# R, v6 k* u( W& }3 r) P
4 G& a7 `$ ` Y/ x0 g. I: i: }0 y 际应用中还有其他的混合形式,后面再来介绍。% T# O# k$ O! R* h% Z* w( v4 k, G1 ^
, Z% P, G4 U' c" U5 N/ e4 C/ F2 W8 b' s, i# ]* R: J& E
计算实际顶点:
' s( `! N' W v: R; m9 [8 l9 |9 b& u8 u) C# s; d& t
我们看一下软件的(用CPU做蒙皮)Blend过程:7 g. i I' K2 s' A/ C$ \9 {
7 q, k5 S* Z- j3 ]" G, p* @
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
. w- g- B# j2 r/ E. F{
, S6 _# @/ G; b* x L+ O( l if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
0 H) s0 L( c. F5 w return;3 ^ U" `# c8 k' ]
) u R1 A' P8 D/ p) D CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
" f h0 {* G' \4 {7 X( Z" k V int numVerts = inputMesh.GetNumVerts();% F3 c4 h% r, ~, Z) e4 I0 J- t$ d3 G
int numTris = inputMesh.GetNumTris();, I f" `7 u* A/ u) e. D
R o) B& ^( j) ?
for ( int i = 0; i < numVerts; i++ )
% P, h' C8 I6 ~9 i9 v { i) P& I& j: @
const CharVert *pVert = inputMesh.GetVertAt( i );; C' j# y2 [- B8 u2 y
pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
2 A% V5 `( t4 }; A3 Q3 R$ N) [' m: |
/* u v initial */
, n6 E$ z: F2 [) n pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;
/ n& J' M/ E4 P. `- O4 a3 K pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;3 e: u9 w% W* X3 Y/ Q
0 N( P7 o+ ^ l+ K
for ( int j = 0; j < pVert->weightCount; j++ )* G G- i9 j( N6 n9 z% h
{8 g, j. S: I5 V) D" r
const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
) Y4 C) }* x2 _# f. T4 ?6 B int index = pWeight->jointID;
. ^+ l: u0 D7 [ Z5 U% \4 y$ L const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );& t# o2 p; C: r' K8 s. v
vec3_t wv;
, w$ h3 s+ w, v$ y' M; f Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
* f* {; e6 _7 ~: b; t, { pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;4 `. g& m) ?* {. t @0 D6 N
pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
1 h+ w/ }& ~4 e pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
: E' q S# E+ R2 |! d }
4 }' y' G: `2 A/ {: q0 [. k }4 f f2 `0 k4 s' j
% ?+ Q) L b) e* S6 v) {! X outputMesh.UnlockVertexBuffer();! d. v$ K, T$ H/ W% ~& f
1 d; K) ^4 m, M6 t5 i# ] CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();! B: m! E3 |( n) f6 u$ K8 [6 A
/ o" W( g$ Z. B for ( int i = 0; i < numTris; i++ )
* c, [8 P: J" C# i {
; h! G* N: q) c& B$ ]/ M5 e9 C const CharTri *pTri = inputMesh.GetTriAt( i );
; v4 q. Z9 {! A! B0 Q, @- U, Y pOutTri->index[0] = pTri->index[0];; |4 J+ r- X; `5 E1 W n9 ~. b t+ ?
pOutTri->index[1] = pTri->index[1];
; `; F! |/ L) t' q pOutTri->index[2] = pTri->index[2];
* f3 t/ G9 c( t7 W& E9 h( i }; k3 Q( | c1 e2 y- G
: I/ Z y" m- T
outputMesh.UnlockIndexBuffer();
) @8 O0 A$ e/ K* Z$ N5 U I}( Y/ X3 N* n! t1 Y
5 m3 j/ a% ]8 g4 Y$ W- e4 C其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
! z8 o5 a. x7 f9 ^: C9 J. O! i, B3 T3 ]4 p5 y( u
9 L# s+ v" ^$ P; s$ c/ J3 G1 H9 y关于md5anim文件8 N4 M: Q" n+ K" i1 F( ~$ C7 O! x+ M
6 S* S0 I7 J8 a
Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。7 w( W9 ~- I, Z5 `' h! b
7 ]7 [; y9 i$ q6 U
6 _5 a+ {7 y4 f4 }3 V
可能的扩展0 W% ^3 J8 _8 |7 s: X
! I6 J+ w) j7 R) j% T一、复杂动作的混合
6 [3 i2 U9 n/ J8 _
# L% b! B, p, B: o O 有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
' w |3 k# g( E) w& y. K1 T6 l0 R
二、基于物理的动画0 P6 a8 \$ j3 }% D
. \2 ^2 u" q6 G( s* T 这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;# G5 _ T/ i" Z9 R
' R* V+ I" ]8 l
三、基于GPU的蒙皮
, p) b$ e2 X7 p. W$ l- B
! A7 J z+ |1 ]( d, @" N& \ 原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。- ?5 e8 `+ f D' f4 B
% z K) q8 ]; S8 O) h
四、非常流行的“换装”系统
$ m0 }2 e [4 i0 V# S; i6 N, d5 S1 a1 i
这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|