|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。) z* k7 s9 Y9 O% C- z. P" A2 q, r0 S& o
: E/ f1 _$ U5 Y; X在这篇日志里面,你可以获得这些信息:
: d5 `5 }6 c# ^6 {8 S6 {2 T) h1 c0 ]) {& T
1 人物动画的框架
8 y6 @, E6 O0 k; `- }$ j2 骨骼动画及蒙皮技术
* I2 R$ w7 z3 M1 ?# }% f' R3 doom 3和quake 4中模型和动画格式md5及原理* }" J/ |# B8 m* C9 j, X; K
4 可能的扩展; s$ I/ E6 }* p6 ]$ B2 q
. G. P2 `# R- p, P6 {
5 p! G. X# A8 ~先来看一下人物动画的几种方法:+ ?; k$ D! y8 m, T2 o6 t, @! r7 B
) O7 |# i) O9 J \8 f7 E6 L W一、简单关键祯的动画
) C" j0 Y+ k" [( {+ \) H+ q% ]& j/ g$ ~& k1 G
像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。4 ]- g" T" k- f# i
/ _0 k) K: O/ ` M
二、简单的骨骼动画及蒙皮技术
f6 C0 r) P4 I' d' h1 ^. j( D9 b# G8 ~4 \! U
现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
* I2 k- [0 \$ F* D1 D8 B8 G* Z% I; @+ b$ M3 M4 r, i. X$ C
三、改进的蒙皮方法和基于物理的骨骼动画 l. ?( F, A# [/ { }* s
6 E0 I* {; {' Y% L 改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;+ v1 b9 Z& L% B+ H" X+ f: B* b
2 k7 l9 L- `: b1 P4 D
基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。) T) [1 a- _' b& ~* q Y
2 l5 g9 H' r1 F; C
6 d' I4 A, x4 p+ K9 H! s7 h# \1 V, W1 y+ {; @) m _3 \' u) ~
基本的蒙皮原理
2 f& T) \; f' x4 O7 f1 [0 P: c9 ? @; W: Y
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
. ~* `" |0 ~( \' z5 X
3 @4 N7 A- T( a0 F6 OJoint: 用来记录骨骼的关节的信息;
+ ^4 F" c- F9 q% D+ G H+ gWeight: 用来记录顶点相对于关节的权值;
2 T$ \$ \7 ]- y8 Q: }0 vVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;' j- Y& Z* l% B' f7 l: _! A7 M6 t
* [, w `0 n: X现在就来解释一下这三者之间的关系:
: U1 K3 ? ]0 a- e& u; X/ ^) E V% E
6 F' h4 R# k+ j" N3 U Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
9 ?' S' k7 V% T7 _
8 ~5 j* D7 U( x! l8 \ 很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
* n: b8 I2 E& E
' I2 x7 ~4 b9 o, b0 M0 ] 了解这些基本的概念,下面就来介绍人物动画系统的框架。
+ @' O4 X& N3 g: C3 l( i! c+ t' n& |( k) R {0 [) m# H2 I
3 s+ a/ `' p4 ~8 R0 ]! m/ u
骨骼蒙皮基本框架- f0 Z0 A- P( q$ _
( M' [0 b2 b2 v, i) Y7 @8 I3 w1 i基本的类型:
& _4 @+ t% r# K5 g. l
* Q' ]* n2 z b2 {" T关节信息:, E$ V& G1 `! `& n1 m
2 U# d( U. a: M, n# Rtypedef struct _CharJoint
* ]) Y. ` C8 s. S: D( J# M' i1 O) u# c8 T* Q{
4 i( Z0 a4 X/ x$ y2 ^ Vector3 pos;
/ W2 U9 W$ s. z+ Z" J/ x* ]) S Vector4 startPoint;
. P, ?! o G% Y8 r* I/ I int parentID;$ N- z' _* U0 _
char name[32];
9 S0 v# ^, U/ o% x* ]- ^} CharJoint;
+ i: i! E% h6 }, j% c$ c1 t7 l0 ^6 U) X: h
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;3 S% _* D8 C* h' @8 [0 z4 P
0 {1 S; q) ?1 v p @/ }- I权值信息:
1 Q6 ?$ U4 o* }% f0 j" e2 S
. m& ^3 @$ S+ I$ H+ n7 a2 m; Ltypedef struct _CharWeight+ x0 m# ]( t* m' W1 `" E) K7 a9 l3 b
{$ S3 X+ V) x! x
Vector3 pos;1 m. _/ ?$ T- U- A2 T7 a$ V
int jointID;0 j. u4 _# S6 P/ c# B7 V( k
float bias;" W8 @ j* g s/ J) |, p. h
} CharWeight;, n; a0 ]6 O# u0 K; c' C
' R7 x" _4 {* S6 J) b2 W X& C其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
. C+ S5 u6 Y! J& _9 C) S: \+ M& c' P# T8 |7 ]) W
0 G& H* @% D( h, q8 t _
顶点信息:" l7 w' p. v7 _! B) |
& O* E: |! d. T) ]* D: Z. ztypedef struct _CharVert$ T' T3 G1 S" X! e. }8 s
{
4 n$ |8 s/ a2 q. ~1 b+ ~. U float u, v;
3 A2 X2 @0 |7 C7 t8 H int startWeight;
) K# h) I+ F8 l; Q* {3 T int weightCount;9 Z# W: `9 R/ y: R* K
} CharVert;8 ^! T' E4 S% ~. u0 d. ?! @
# _# R' I6 U1 V 其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。 I4 o* x+ y4 X* w3 @' ~
% c3 X) g: ]- x/ F. }" E. Z# L1 X0 ~( `2 t% N @
大概还涉及到这样一些类:
3 P6 k( Z- ~$ v& J3 I8 C
( |, O. O: y# r; ]5 {CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;2 B( t0 x# l$ t& U) Q! {. z
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;, @' @: J( }$ s1 s& l
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
# Z. r4 i/ F- w8 D# u$ ^" S' j& cCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
$ k0 s9 c" h s+ D0 f# zCharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);3 w1 P, O1 p) f. b
( C+ |2 K5 H) d! r. @3 _& C
( `& W& y$ ^+ H1 L2 N, x解决关键问题5 j2 H3 ~: M) Y' H
# D0 q: o) f8 j8 q; j. O) _ 刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
' B; ^5 H% O T8 ~6 t( j k3 t( ^) \
关键祯混合:
/ o, k% I1 e% A. X2 f; X! I; r) K9 [6 |% [# t9 S: d
简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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% \) {. |, m0 |3 S1 O
A! c8 I9 u6 e T @ 际应用中还有其他的混合形式,后面再来介绍。
% k( u' o, g8 k, V& a" E6 C/ z6 r8 L' p; a7 M3 a# X% w
$ L% m6 ^8 W {$ @' H" D计算实际顶点:9 \1 h& o9 v6 @6 K
6 T! U4 \, j( r! ^. e) G# O我们看一下软件的(用CPU做蒙皮)Blend过程:7 g' a! @* w; G8 n) ]0 |
/ o7 o, B, u; ^/ o& i; Nvoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
$ P8 b( ]$ g9 J* B{% H7 \2 I* }6 C
if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )2 N+ U) T6 b6 o. C
return;
: X0 J4 j$ z, {4 v. z
( J9 ~& D, X2 E- q0 Z6 i" ~ CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
0 y6 ^6 c7 Y/ _3 \4 l+ W+ ~ int numVerts = inputMesh.GetNumVerts();
. r# E# S/ c) [8 J0 J- F% ^/ U int numTris = inputMesh.GetNumTris(); V$ O$ {! X0 |& n' P9 N
' Y v: s- j+ K G' D; G for ( int i = 0; i < numVerts; i++ ): N* }/ d X. D0 A: o( t8 i
{
0 M8 n% G3 z% O. p6 v; m6 y const CharVert *pVert = inputMesh.GetVertAt( i );4 ?- [ H: [$ e6 m* c% F- w. b
pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
& x7 d5 W7 y+ \$ |' Q( M9 j* O {
5 M# H8 U! {' q s2 ~ /* u v initial */+ H) v7 \/ y7 [4 T; m
pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;- @) {1 D8 H& P! A3 e y
pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
% I q6 o) l" w% V: p( t" W, e5 J$ w, W* m/ N( g |9 J
for ( int j = 0; j < pVert->weightCount; j++ )
( _) F3 ]% f F2 O {+ B6 ~1 t8 P( s3 w. e+ e y# H3 r
const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
0 t! V3 ?* i }( w; t int index = pWeight->jointID;
6 |2 Q' w6 k1 C9 ?0 S+ b' p W const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );2 g: T! ^, H* _* d2 c+ y
vec3_t wv;- w& i" ^+ {" g1 Z/ o( L) ^5 V0 z
Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
( F3 `4 T! {- f4 k8 u pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
% a2 O; I; u1 ~, a$ G9 ?5 J) b# t pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
3 |5 h4 ~. [' d8 a pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
6 Z- M3 W) c+ ]: b( F4 l. {% ` }: U' ? U# {$ n2 S% Y
}' G# h* d: B# n" i+ g
; p7 |. q: x, h outputMesh.UnlockVertexBuffer();
% {; _0 r+ R# W6 s8 b0 r2 K0 y# y$ m `: |
CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
3 s# o2 t; I7 s1 N; Q" _
, I {8 V9 E# w4 q; F for ( int i = 0; i < numTris; i++ )% Q. c0 h4 [4 v3 {+ _. S$ g! b
{) v. Y. _2 n) [" C1 L1 f: F+ p
const CharTri *pTri = inputMesh.GetTriAt( i );
# ^& V* p `4 e. x0 z7 z) M( ~ pOutTri->index[0] = pTri->index[0];' h" v4 L7 `/ n. g& p$ Z' ]) N
pOutTri->index[1] = pTri->index[1];
: \7 @4 [ L8 H }/ Q+ s; Q pOutTri->index[2] = pTri->index[2];
& B& ?9 C8 U0 c6 @ }: }' k9 T; T( Z& o3 d) j, Q
1 H5 \. J U2 R: h4 d& L: F3 O- f; X outputMesh.UnlockIndexBuffer();
3 }+ Z' W+ `& d: P: u$ h}
' R9 n: g7 T) R: s- X
- H- w% `' m! i$ K其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。1 _. h3 p8 o+ I( e2 k$ c
! H- W8 A: @5 B6 ^+ b
! F9 x/ B: U/ J0 G8 N关于md5anim文件
3 z N/ Q; V4 k% i
8 f- w0 T! g3 N8 n p Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。: b- R8 {! K j; a
! L) n8 e; f0 T# L6 p/ O6 M: X3 v- S6 s* }3 {0 v
可能的扩展+ k/ M w7 V6 M) L$ m6 s: b+ p4 t# V
; J9 x$ q! |0 S C, i9 v
一、复杂动作的混合
. t' {- D: }5 `; |" A: ^' U- h
! V& b% u9 \* f+ j. {) i5 y 有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
. `2 q j5 D7 g; o; r( X8 r3 F) k+ [% v/ T: W
二、基于物理的动画
% q4 x; T. U* `( z# |( h* m
) }- q$ M1 B4 p2 b 这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;% u8 m$ o. |+ \5 ]7 E
3 C+ u6 F5 e4 i3 K三、基于GPU的蒙皮! p0 i! W- P, P& d
) e, u$ b/ g8 c$ j7 t& R8 z
原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
% i% y6 b, K. t, M* K$ @0 D
) T& p1 T+ \% L: S$ d! Z+ C四、非常流行的“换装”系统/ u: x+ p' a; d' p. S" H( _0 z
# Q" v# @6 Y3 N3 q5 u, e, p) g
这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|