|
|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。6 w- n# R$ v. O( ]9 \* B: M. i
/ @8 |/ s* |; C( ?+ o在这篇日志里面,你可以获得这些信息:. U+ P* k' n# {+ Y; K
^5 ~% ]: l3 n9 \9 k
1 人物动画的框架
, L, G, Z8 |( Y% U& ?; O2 骨骼动画及蒙皮技术$ l3 M& r! m& d8 Z: j
3 doom 3和quake 4中模型和动画格式md5及原理
; B N T3 a3 U& {% w2 R4 可能的扩展
H* |/ ^, j: T+ h2 u$ ^; T. S; P8 d) I0 I, E
( T! j1 c9 W! ^" U1 |0 M0 ^4 {9 x* R- S先来看一下人物动画的几种方法:+ }+ x+ x& |$ E/ z
+ Q! R+ M: J2 f4 L9 _$ Y2 v
一、简单关键祯的动画9 i1 N( _4 Z: T, l1 T. O
0 D( M9 v* C) N5 u) n; h% w* j' z 像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。! T9 E3 F2 k) y. y
4 H% ~4 {# v$ k5 `5 J
二、简单的骨骼动画及蒙皮技术
( X- Z) G7 c; G) b$ Y
3 j/ Z: k& h [1 a6 G, ]8 @7 m4 q$ j 现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。& m$ @. y m- b! ^
) J; Y* h3 J4 @( Q/ ]. U
三、改进的蒙皮方法和基于物理的骨骼动画
, X$ W4 d3 w9 V) d9 B; ~
( D: Z' T; T7 }4 S' C 改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;$ y5 z: ~+ O9 I L8 [6 ]2 M8 D/ q
[: A4 ` q0 P' I5 i: X 基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
- b4 X( g7 ]0 t6 v. X$ H% Z. l: s; l+ x; a+ b1 K6 g
+ b6 C+ P, t7 ]- R
# |- k/ w3 m+ W" m9 h基本的蒙皮原理
1 i6 D9 u @* ?! t, m3 J& T
" q+ }0 R2 J& J& x, s! W拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:( b5 ^( q2 j2 N
2 U$ w' Y) ~ c2 X+ |6 k. @Joint: 用来记录骨骼的关节的信息;
6 `# s" M" P/ @+ Q5 w5 KWeight: 用来记录顶点相对于关节的权值;* \/ @8 C" {$ j/ I2 t
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;- B1 c* p1 R* s5 `1 j6 u& e, J
5 ?. l. n5 b. Z& t) Q现在就来解释一下这三者之间的关系:
& l* x/ f# e- Q0 {0 [; \7 U+ o- m) f- P
Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
0 c9 X; n$ r+ q* s1 J7 ~, q5 C) a$ k& S8 ~' J
很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
! _9 R ] v) i2 u- S! o
7 c9 \5 n# `* E 了解这些基本的概念,下面就来介绍人物动画系统的框架。, Y# a& p( T+ `8 h# l
, ^/ |* p0 S n+ M. y: B9 L* U' P
7 j" e) I/ n- ~8 u骨骼蒙皮基本框架+ W& k4 e7 |( z* \3 \
" f* i; W2 r8 K4 ^: H7 r9 A基本的类型:/ u6 X! U& c' O2 M; C
1 m3 H) K8 s5 D7 o关节信息:, \( m; {! {( F/ b' Z' y
$ F2 A! K$ Z. n/ n
typedef struct _CharJoint
+ t) y- {* N0 O' h{
: }' z Q( R7 [ Vector3 pos;/ g. G6 y7 S1 w# p
Vector4 startPoint;/ f- |7 N5 s1 ~$ q# ^
int parentID;
3 U' u4 n) ^+ E0 B' n char name[32];
3 w+ Q! N2 }. Y0 G7 F* |; }} CharJoint;
+ r( R, E' f" [) n- s3 M
* L# C2 V ~2 l; M! {其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
O! S- z* Q1 J3 x0 {) {
. p$ V3 I2 ^. ?& {! |6 [, l, n权值信息:
( j& Y8 m* {. N6 Y: y0 B7 f0 O. S/ b$ L7 F# u/ s9 b
typedef struct _CharWeight3 \8 R. O( @5 |" d5 U
{4 v6 o$ M' \/ q9 @! y8 l |- o+ b, q
Vector3 pos;
; B4 i- S J7 Y( v int jointID;' L/ W5 S. F. q: v$ V6 n$ _
float bias;
+ Y# t1 p2 k0 i# G! y( R2 }' ^; e} CharWeight;
/ }+ C7 p/ n N- S# M; ^! K1 ]
O5 g' P% ]1 P& E! ~其中,pos为偏移量,jiontID为对应的joint,bias偏向值;+ X. I$ t2 A- e
( ~9 r! m: O5 }* P
/ U. Q1 h% f/ N* L9 ?2 c* n顶点信息:
# ]7 C/ O' a: I! [0 m% X* D
% ~! q+ c/ Q! I. R; k9 Ctypedef struct _CharVert0 K( }# m' o6 y& l; s
{
l- ^' T5 P1 r4 v0 R float u, v;
) M; O& z/ B# W. l3 z int startWeight;
# A4 M& v; S g$ U/ s int weightCount;2 `7 f. b" p3 b) G
} CharVert;3 z( X- a; @# o5 C+ A5 v
) f" T# {) B: _# e: q
其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。$ F$ A" F: N$ y! e; U
; m ?3 C9 s2 t% j+ u
1 q: p. g( b2 D6 V Y大概还涉及到这样一些类:3 Y( O# P4 `& z) O$ g
8 W3 @) z7 `% U# l( vCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
4 F9 v9 X& s" X* QCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;# j3 s/ E8 ?/ ~, i# c) @0 O! @5 h
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
. s" \3 W0 }$ I; Y7 s5 Z% Y8 p0 MCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;& q( Y! U: {9 n* f N
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
; y% z2 A9 l6 O7 j
8 _* }) N1 R! y
4 {, W; j. k& Z- z6 u解决关键问题
H% ^. s, q' u- N; M) E* n/ d# s) J8 s Y3 w
刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。# z$ j# k" _4 V* r1 d9 T
# \ |, s! s5 X5 @: {- J. R关键祯混合:4 ~* q3 Q% T3 n4 Q
3 O! T; G9 p' m
简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
8 W5 s8 z, w' [0 ?
7 g% g% y" O" y* h 际应用中还有其他的混合形式,后面再来介绍。
! R9 K0 Z$ m7 [3 Y+ t/ n" U+ f) h
+ F- m$ W* ^# B0 Y$ D# a# _4 N- ]
4 ]- b2 q5 t5 H! E9 x计算实际顶点:
y: B: T; ^2 C, D: ~
* t' [/ u t e ?3 }0 M" p# J我们看一下软件的(用CPU做蒙皮)Blend过程:
$ }6 y1 S; D& J; `+ h# r; e; h4 J! z5 Z6 W) f; }: s) O; J
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
5 Z0 `1 O b1 f; n# a{2 K v; c! b, i1 T
if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
+ [! o# c$ m X% W5 Q" w return;
" X( b$ F2 h; h( o1 S) f4 @: ^7 O5 G9 d" i0 X6 S- z
CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
$ z; G3 l) s( {* @7 ?6 z2 j int numVerts = inputMesh.GetNumVerts();
, ]6 U: s1 r3 s9 U H& x5 U int numTris = inputMesh.GetNumTris();' J3 a/ |) h! h* v Q$ M
) m. j, j! ?9 r& c( S& A for ( int i = 0; i < numVerts; i++ )
* u# _. ~2 r# } {
# m F9 K/ T q8 l; S+ F# s const CharVert *pVert = inputMesh.GetVertAt( i );
; t' Y# W9 g3 L: Q" t* V pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
, O/ D/ X0 d! `& L3 {6 Z" w# ^# l+ N- @$ U! g$ x8 g( N
/* u v initial */
! p! V6 p! u& v6 c$ k pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;* V _( c% ~4 `. N* m. V$ J
pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;/ d3 z3 F Z3 q& I
?9 I/ ]7 E& `* A( I) S
for ( int j = 0; j < pVert->weightCount; j++ ) A" |' f; j+ p: A C& z
{/ t8 E1 g U, J" f
const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
8 }6 |* |: J; J/ u3 j8 W int index = pWeight->jointID;6 B$ v; m3 c' o7 }8 U1 X
const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );* J% { d* L, g. ^
vec3_t wv;
; Y- n8 U2 _# F6 ? Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
3 G1 d, d0 C. Z* D0 ~7 O$ D0 r1 { pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
. `8 e! \4 U: q ]3 ?8 q* K; G pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;: p/ I- n0 D4 H6 [3 U
pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;- ^ U4 a( D e" g C
}' r1 Z$ H) i0 d! \4 r" @( c
}$ d8 N5 E% W! f, ~! k
% @2 `8 X0 g8 `
outputMesh.UnlockVertexBuffer();3 U! F* K* u! v& o* H
f" b8 `. U8 t" G/ |8 F, H+ E
CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();$ s1 [7 o7 |7 k, @4 P# F
. l p* x+ q* \6 H o
for ( int i = 0; i < numTris; i++ )0 r" c4 E9 V6 x) B( x9 _; e$ b
{
/ Z% @% k! @2 p! g const CharTri *pTri = inputMesh.GetTriAt( i );4 a; U2 ]' W4 C, L6 Y- ~$ n' L
pOutTri->index[0] = pTri->index[0];
+ z: b3 k1 k3 X7 R- h pOutTri->index[1] = pTri->index[1];
! b" c$ \% V5 r( v! L) V- u pOutTri->index[2] = pTri->index[2];# }& U9 V" `% a/ U8 o
}" U4 s" f. p. q: J) u
. u: x4 B0 `* H* @: ~( @
outputMesh.UnlockIndexBuffer();
4 d" S: V( s3 ?" K- o+ B}8 P8 o" m2 F- `; s
7 C# q1 X6 b) M其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。) ?/ u! F; G! g L) I: v
( ?6 z- D O3 ~
5 `3 o/ a/ ]3 J% w" Q! g# T4 g关于md5anim文件
+ ^- S" [$ \* s' ~- m% a
7 E, O5 n5 Z' j% m Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
7 E9 ?: V" S; N& U2 H2 \5 H
2 E! Y0 W) e. M# q
, E4 s/ R( K! [( ^可能的扩展% r' S, B1 `9 j* z5 M. t9 n
% w% o/ U% R4 J& M" {- v
一、复杂动作的混合
1 x, e# ?4 q8 w- P1 L3 {4 T* @
$ u6 R8 } E" ?) A8 |" w1 ~) M, S/ D 有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
3 U" U% o( f, a* q+ A M3 M
+ l& u* o: y/ D二、基于物理的动画9 C) z& Y* d; p2 a
$ F0 u& z, J2 R5 T$ n/ d& K9 _7 j 这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;( E7 S4 v R$ U( t) A. X; _8 P
( T& ? x6 B% T& f: \8 p三、基于GPU的蒙皮' j$ m+ A9 Z, \) U9 f
/ m' a+ Z/ I0 x% I3 T- m' l# P7 d 原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
0 E( ^8 v% e! R9 s6 \+ _2 n7 h" s" j# h2 E
四、非常流行的“换装”系统
( _+ o' w1 @, {1 r
/ C" ]9 u [' r/ o: p7 N4 E 这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|