|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
7 v+ N( j+ G5 e0 |7 S" Y4 }& c# {! G' G7 H* V+ K* W$ E& o
在这篇日志里面,你可以获得这些信息:
7 g7 f$ ?3 S. I) e$ W- {. G h& P
2 F& {/ @" J+ V, _8 O4 C, j1 人物动画的框架! h/ |' a2 [- O! ^ i( m% V
2 骨骼动画及蒙皮技术
" }' ~7 U+ S: Y! }# u9 }9 n3 doom 3和quake 4中模型和动画格式md5及原理
9 k3 i: a; Q% Z9 {( b; [: _$ n4 可能的扩展
& Z, `3 c" K+ P( q* u! E; T, {- v7 t! B Z
}- f% B# ~% [4 H: m+ w% B
先来看一下人物动画的几种方法:
( c0 L4 K4 @/ W: w K- E+ p+ ^
5 J# i2 h) Y c! F2 C8 J一、简单关键祯的动画" B+ P4 D0 j8 i. U) E
6 S- x/ K* _3 C' a* h% l3 l s 像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。
+ Z/ s9 \7 d4 ~! ]( N, J
9 |0 z5 [. x, _' j K二、简单的骨骼动画及蒙皮技术
5 V- F& ?# H7 ]/ W9 {, d0 M& w
' }8 B- i$ }5 Q. P2 c, C# ^+ H 现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。! a) x8 @- d6 P
( m& o; h& T D$ D, b
三、改进的蒙皮方法和基于物理的骨骼动画 l/ x/ u: P% u
' L: V3 _8 J6 ~6 f7 E 改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
! ]2 k# b: n4 Q1 }' t( L# S& K4 ?0 e2 p, B5 {7 g* T3 O
基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
# p2 X* y# I* [* G
. W4 A5 T+ }- p& v: R& s: I) p$ X- Q R# a* ^: Q( V& U
# L( t) J2 i U5 B7 P- v4 A; C# x
基本的蒙皮原理. o/ i; {, R2 `; v: y3 J
" _& c e! v+ I9 t4 P& h拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
+ v; S0 S) P* _' N( ^* Z3 _1 D3 W; z$ s: v) \' J" Z& u
Joint: 用来记录骨骼的关节的信息;' @) B; B3 Q0 K' z, N+ z, ?
Weight: 用来记录顶点相对于关节的权值;6 W2 S% m; S2 U* i n) K
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;( [& \2 c' u3 [. y
! R1 J8 d! f2 z; Q6 ^现在就来解释一下这三者之间的关系:
# ]; c) i3 P8 }( \9 J# U |! u0 z: C7 T
Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。+ y" Z: }2 W8 |8 V6 f: }
+ n3 m0 @( S5 C, Y2 w6 k8 k! C
很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
+ T4 F$ x5 W( S2 p: |
w$ S1 V6 S' l- J) [9 ^& @0 N% ]; N 了解这些基本的概念,下面就来介绍人物动画系统的框架。
* F$ M9 H0 Y' e; r1 ]* ?$ h w: k- e: K% i) H4 _
5 y+ ^ Y9 J3 ^( P4 u3 ~+ C. Q骨骼蒙皮基本框架: R% ^2 C% S& C J. D
+ @* ?2 r( A; M- O" q1 t8 T! Z7 }基本的类型:+ v7 X1 {6 _6 b* f" Q
# O0 P, f |. J P1 B- ^: V: J) C关节信息:
% c9 c5 V7 T7 x$ _
5 H; l4 `8 v/ m* a* v& t+ utypedef struct _CharJoint
7 z3 \. g2 F% `, p! s3 x{& J/ n' `9 w, J+ m" z
Vector3 pos;
7 X( M" M4 i* J# h% ` Vector4 startPoint;9 g$ \% q3 q. ]& a' d4 j7 P
int parentID;; p5 G E4 z4 T
char name[32];# V: D" _ f/ Z0 G7 f, @" P* Y
} CharJoint;8 `) Z, V) ~0 y3 I
' _+ f6 h: ]/ K$ z' T5 k( w其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
( x. Y' I; S5 Z' o6 T! Q M A2 E/ K9 U" ?
权值信息:
% m2 @. q `5 ~* v$ H" I5 E- z& U! d' R( H- j% _. K
typedef struct _CharWeight
M7 Q% r& S5 c{5 y5 n) f) u& q5 x r8 \4 `
Vector3 pos;
/ ]! u' P5 }- k5 } int jointID;" ?8 s9 T/ j: ]6 l, K# j& i6 _/ D
float bias;
$ K: a. X2 G( F4 l# e: V4 b} CharWeight;
& ?' e8 \+ \1 S+ j2 [* |1 h, A9 s0 x3 n
其中,pos为偏移量,jiontID为对应的joint,bias偏向值; j3 k4 o0 z! D/ y
. i' A6 g' | X$ w6 p2 q3 L+ b
2 i' b! \3 B3 y+ x! ?) s; z顶点信息:
9 {" l: o4 b4 c2 a& K0 V3 i1 U. ?1 d- S) x, L4 U7 f
typedef struct _CharVert) z6 l! X8 I2 \4 b8 h
{
) R, k5 M0 Q, C# d- J' Z float u, v;! \6 K0 H' g1 G/ ^& G
int startWeight; F! M: y7 F& S; e! g/ P9 i
int weightCount;7 C1 z( @, A' ?
} CharVert;
9 K8 U" E% `: [& R$ A! C. c& K8 Y9 d0 F; K
其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。3 U6 y* v+ H9 i1 j
5 a. A# u, p' P6 f# e1 ~$ A4 }- G( \5 c A) H6 ?. {
大概还涉及到这样一些类:6 j6 T( w4 \- R: i9 a% F; ^( r
6 t! y3 {: d( {. ACharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
0 H- v/ W: I8 _3 w. b; ~( ~CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
* _" i4 [4 w. u: ZCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);7 H8 l- v" M0 F" o6 Q C
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;+ c. H+ |4 c( B+ N/ {
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
. ?; _' d. H/ e! ^2 \7 m. B2 ^
$ p) ^' ~9 T/ ~9 A6 h4 e( N. w
2 j+ x5 E% q+ j5 f+ \解决关键问题' \. R9 a2 e0 w, Z5 V2 K
' \) u' e& m0 d& N: x( L 刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
% e1 b3 y- ^3 p9 q3 }, [; O! j( i1 N+ r7 S& t
关键祯混合:
! N: u: C( t( q, a/ _% |) F6 D. I1 S' u* _4 L) a- }& j% 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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);4 W4 d# o# y; C5 J$ n5 Y
- M: ]; j( ?$ Z7 C. f* h
际应用中还有其他的混合形式,后面再来介绍。, H" V4 U, ~# G ~
7 W1 o! K; k B# x6 D8 v
/ {/ K( B# u- N" i4 C B
计算实际顶点:; a& T6 _& V1 @+ Z
4 U% u& M+ R. F我们看一下软件的(用CPU做蒙皮)Blend过程:
$ ~. H' X+ }8 [7 K4 Q
& V, L% Y" v% z% o3 t: |void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
# v$ f' x- x! `8 M6 j( C% W{9 E' e/ g- ~1 X5 @) M
if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
. K. h$ f% @/ x( ? return;& e3 z! W5 S( K# e! I
+ O$ `7 K/ V6 t0 U- V; W
CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();! o; m9 y. g+ i$ r
int numVerts = inputMesh.GetNumVerts();0 B. d8 E4 t" b8 ^1 E1 b. }
int numTris = inputMesh.GetNumTris();/ H0 _4 Y1 F+ j% i9 j9 A
6 N6 e/ I" m+ T$ Z
for ( int i = 0; i < numVerts; i++ ), i {) ` s9 P0 o8 H3 k
{/ Z7 F" G5 e$ ] y% p$ ~
const CharVert *pVert = inputMesh.GetVertAt( i );0 z5 R, }" O8 f* g' N
pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;: k9 u( L, a: Z* w L- o
, }; F( u/ U% Q& a: Y! z /* u v initial */
+ ]' j. a6 A& e* u pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;9 s: {4 Q3 T7 a% a: W
pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
4 ^& h6 ]' F: p5 s8 L
6 e. i. c' t6 H9 K( l- ]! @ for ( int j = 0; j < pVert->weightCount; j++ )& ?9 s* u [; b2 y' f) x& O; k
{
1 X0 _7 \' d' N# i const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
) W! t4 P5 a8 y3 q( v int index = pWeight->jointID;
W2 e6 Z* G2 Y" S6 i) l/ Z const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
8 m4 L* @& ?+ o0 Y! d vec3_t wv;
# ]) p4 t0 M& E" ^. i( u; V5 l Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
1 b+ N9 @5 w; i# w* U( n pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias; n0 u3 f- F" z, o; b' q
pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;, C# U! G" j/ R5 V, u8 _% T/ P+ d
pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
% l$ [6 r4 z) U' ~ }
( x' F7 x- E# Y. C& i* s6 i+ H9 A }' [7 e, N G4 y3 B. b
& m$ F# D8 s6 _8 ^& ?* Z outputMesh.UnlockVertexBuffer();$ I: g' K% F; n+ T+ M( R$ k1 d
1 e7 @3 a* k' d! E! Q8 n+ W
CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
" L% o, n# V4 G4 ]/ g0 b& ]$ B( v5 k
for ( int i = 0; i < numTris; i++ )
4 O- @; M- _3 L) H& \5 @: k6 k {9 u/ P9 [ I2 b i
const CharTri *pTri = inputMesh.GetTriAt( i );$ e1 ^6 Z0 o6 c# K
pOutTri->index[0] = pTri->index[0];
6 H+ x- b, M; X2 O pOutTri->index[1] = pTri->index[1];! D% z- d2 I3 O# A7 V9 l+ k( H
pOutTri->index[2] = pTri->index[2];
: |* p1 F' n' ^" y }
, O( B; t. P2 C6 b5 V; f( J7 P$ t
outputMesh.UnlockIndexBuffer();8 T* n% H* [# I7 ~! [- d6 d/ {! |
}: [( g: l0 J* p. Y2 F/ l2 }' b
9 M4 \/ ?8 L5 M ^% Z( e i( o3 x; ~
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。* O. L! |: C' J2 s
% [. e. I# }- J
- W" I) v* `* E1 p0 \$ S关于md5anim文件
2 B4 P. b% u8 i8 f9 t+ n" q
2 {, j7 n7 `: t& @+ t5 o Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。. e! c( w; T* k
1 E# [# ^2 F$ ^& i' Z9 c
9 J1 T- J* F/ Y$ Q3 C( @+ j可能的扩展
: k, I) c/ b* c: [8 [% A
: T; x7 t5 ^- P( L. u# c2 P一、复杂动作的混合# \* `. H* M7 B' m) v# y* Q
4 x/ e X T4 b4 Y+ {
有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。* `- e* Y& ]/ g0 S3 N2 t0 {9 ~
, G7 b ~( x/ a# p" |( W; @9 Y
二、基于物理的动画
# t; {) r0 T0 j' i$ j2 G5 }! ~
+ Y7 H0 a4 r. [& f1 D# o8 `( f& m 这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
" ]* N* W7 X8 m; R$ W! d) X1 N
三、基于GPU的蒙皮( U" [: ]5 N# z* L. q
# M7 H5 q' B7 g" n! A+ A
原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
% n2 b! X5 O/ r' W' h) }, l, z- l5 ] i5 O- Z
四、非常流行的“换装”系统
6 P( \9 w* \6 n: ~0 A7 `
7 U9 ^6 I4 _* G) n. V( B9 F: [ 这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|