|
|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
/ b. l* K& ^% c; k7 v: D, e- E5 {* m0 C! W
在这篇日志里面,你可以获得这些信息:
, U! b( w* O# z7 k1 m* d9 H m( g4 [3 R" S: w
1 人物动画的框架
( u1 |. g7 M8 U9 P) g o2 骨骼动画及蒙皮技术+ o2 L0 Y. E8 `* u; V( y3 x- H
3 doom 3和quake 4中模型和动画格式md5及原理
8 f- s7 h' J: }& m" s% L" A' I4 可能的扩展
6 `8 Z1 G% g/ T! H K/ x H) ~
) D) U3 v. j1 ]" l5 g( z# E1 }
k! O _. p/ C" b先来看一下人物动画的几种方法:
0 A& w0 i- Y, H% A; Y6 k5 n
# }# t1 j4 r( D3 F2 C一、简单关键祯的动画
0 |3 I/ m4 X5 m0 D5 k* z& s. [5 T+ i
像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。
: q- E% \* k& g/ m
7 m; u6 [% ^9 {( a二、简单的骨骼动画及蒙皮技术
6 s" G5 N) K$ D1 S7 X4 |2 R7 n' ~6 W* E& r) _7 u
现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。6 I; i6 K" z9 L, o ]' M/ e4 z6 Q& @
% D/ u9 C' }: ?三、改进的蒙皮方法和基于物理的骨骼动画) S* T3 {) |/ `, R' e
' W6 R7 o# W9 h9 {, c 改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
x+ K9 F* h- o" ?" u! e6 W
0 i4 |+ z* s& h2 n 基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。7 t6 B' `) p) e
0 p( O' u7 n0 {% x- J5 d2 h- Q7 }# ~; }+ f6 U3 X9 k/ E3 a$ X" L
" f% P1 n/ A! f& ]3 k% j2 u
基本的蒙皮原理
" z; Q' Z1 n! [2 Y. P& b
2 n$ M4 h2 h4 b拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
! F( [/ A5 @% ~. ^ \
' o- Q" M& V, l* Y f( }Joint: 用来记录骨骼的关节的信息;6 h9 [0 d1 e1 }4 ?0 B- N d! I
Weight: 用来记录顶点相对于关节的权值;
! n! l6 h% A# J" n6 o) R% |2 kVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;/ z& x0 P) C4 B' ^; j9 s. F& t# f
, k* S3 Z P+ U现在就来解释一下这三者之间的关系:
$ e* D! e, @% y7 j7 y8 {8 t+ B/ @5 D3 {) Z( i. D! {
Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
1 ]) V/ T# q. X0 H% p- d& d& o8 }9 y7 R2 Z' a; S' [
很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。* R) x( ^# |4 P9 k0 b5 G
. d: @5 V; q) ^% t
了解这些基本的概念,下面就来介绍人物动画系统的框架。
/ `! A( ?' A* N* N o% _
+ S! A7 G$ t& l7 T+ h
& V( K- V: |/ @' W, o骨骼蒙皮基本框架1 w# |: ]0 f* ^# d. q0 k8 i7 P! S
) p& y/ z( {8 q9 S8 j- u1 l- q
基本的类型:0 g% i+ Z8 }, \2 E7 ^) q% k9 [
2 S0 @0 b2 E$ R关节信息:6 s1 {( h X" V( q# j
8 [0 I9 K* ~, C5 u) f$ p
typedef struct _CharJoint
# f# Z% ^% e' O$ V9 L" G: s{
2 ~; y1 C) v# D Vector3 pos;. b, @! j7 g* o( p% F3 H! L
Vector4 startPoint;
, a: G! O. i% j& {. n int parentID;
; a2 l' Y1 S& A* V char name[32];- H4 e% m% L& H+ K4 v U+ _3 \
} CharJoint;
& H( O/ N1 k( s V9 h; v4 r! _3 o
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
' y, L$ F# V2 U- l+ Z4 @$ T+ y4 j4 k- i9 O9 B3 a$ ^
权值信息:" t3 D- p$ ]* I& F" q2 a& N. T
9 w$ ]* j: }3 u, A0 D
typedef struct _CharWeight
2 D) i. | i, [" r9 p2 q8 o2 J* \{, i1 f* k; }- }; J n2 r; Z
Vector3 pos;
+ J& J/ d& v( i4 W int jointID;
+ Y0 o1 X* c% t8 y* f float bias;2 O$ V4 Z( l' {0 \
} CharWeight;
. |; Q7 v8 V: A1 M. Q; X8 t) Q- l
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;, \! C! I6 V* g( j6 u
( J* C2 M1 m1 |2 H
9 S2 g( {9 Z! K. o" P! M0 f顶点信息:
$ s9 j B: }% `3 t5 Z, G8 ]# c7 Z, ?+ Y+ k4 x
typedef struct _CharVert3 C* G! e6 }; i4 g% u% X, d
{
/ t. C+ H) C) N& |2 S* x; K float u, v;
1 V8 z ?* N$ J& x2 p3 Q int startWeight;% A1 V6 Q; b4 E6 [9 E
int weightCount;
; H3 D6 q1 ` m" V4 _0 @' G! R} CharVert;% s9 `0 m" U# a7 J, ]
" T. m) Y7 H o$ f
其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。( {( O/ h, c2 t; ^/ O) T
' K! D" k% g( K- A$ s
6 u9 c" J0 `2 y1 k& b) x i- L; s大概还涉及到这样一些类:
0 [4 w6 S" C: G0 {, {$ b, ?' Y
7 ^: ? R! l7 U$ b0 x" }: A bCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
- j; _, \0 U; I ]8 h; P" VCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
& g3 a4 f" u& bCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
) }! t; n6 `8 Q) c- MCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
$ F2 p6 c* W4 MCharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
( {: H* _/ ^% F6 O1 \' v8 K! [8 l, \7 y8 I
! q1 c N6 y4 F6 ]. J解决关键问题) V3 n$ T# I2 I7 m+ d( Q6 [
- ^# l1 D. b" `& H8 K" {
刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。; V# }, N2 O( {% ]
% K9 W, G! W5 C9 z) |1 ~关键祯混合:
. b% F9 A4 b, M# g" @# r4 O4 x2 I. i$ O" _9 c v) o3 k% I
简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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 V+ K! n% E0 u6 m6 j
1 d' v4 H5 Q+ O 际应用中还有其他的混合形式,后面再来介绍。! r6 ~# u9 C. A
6 J: O6 f: E$ I& o. Q$ o, c% M @( S
7 I3 _$ D4 g1 t) U& \, L1 d/ r计算实际顶点:1 z6 Y* Z, Z: T+ ^: K
5 X7 u8 ~* n t& G, |. ^0 `我们看一下软件的(用CPU做蒙皮)Blend过程:, ^3 A% Z" A8 {* |# S
# a7 p g! N, _4 o: z3 tvoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
5 n/ s% p# Z+ ^0 O8 k{1 \# L: w/ S- ]! d |' a$ Q
if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
: a* o% `) v3 f/ u( V8 ?9 D' i return;
" r0 g. [3 \: p! g# Z
! x# U% M0 Q9 B CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
9 u! i! Z0 S/ c7 d4 Y int numVerts = inputMesh.GetNumVerts();
0 A4 u! f4 x2 e$ K- s; Q int numTris = inputMesh.GetNumTris();
* X% v- R6 v4 _" W7 h/ ]+ ^4 d0 C# U* S s9 p3 Q
for ( int i = 0; i < numVerts; i++ )0 X6 x' q }8 Y% I5 ]% k" p, T" J
{
! \5 {2 G ~2 ]! j. Z& p3 G$ ` const CharVert *pVert = inputMesh.GetVertAt( i );$ X3 G6 e4 S- `& g: E# W
pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;0 x5 D. H' \7 j `, M3 D
1 ~' V% v' E, n |( }" v /* u v initial */
! K& U! v- K+ C, o pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;
6 W b o: O" ^2 p" l6 h" x pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;1 I6 ]1 e" p, |4 J' c1 a) k# @
9 ?* y' M+ H7 [& M1 ]
for ( int j = 0; j < pVert->weightCount; j++ )
[+ c# m C# ^( r4 i8 U9 U f {
- q+ E. r1 |1 e const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
+ m: c4 A5 B( Y int index = pWeight->jointID;
9 Q2 q$ @% ~. T! [( R( v const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );) T/ J r% I [ U( U6 d9 @
vec3_t wv;
. v/ U* {6 S* x* ~) j* Z Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
$ t) s8 n; A! ~6 W pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
0 `+ _" C- e( j4 ?* z+ h8 _ pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
. z/ F, t* v- _ m& y pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
* h+ {6 B h- d8 @+ | }
+ d3 ?3 k4 D& T- F/ [2 _# o } l- r/ _3 d2 \
) l- a6 X: p6 L+ U# o
outputMesh.UnlockVertexBuffer();
* q# l+ h, z0 G' U* n! e. o( X* U3 W& f
CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
% @+ B; e' I# E: f4 c* ^9 Q- k% _- s' z9 \
for ( int i = 0; i < numTris; i++ )
" @0 C9 q- o& p1 N8 `! E8 s+ j {1 o6 d1 c- S7 A- Y9 ~, o p
const CharTri *pTri = inputMesh.GetTriAt( i );
; |2 D0 p9 a% I pOutTri->index[0] = pTri->index[0];
0 p' ?& Y" b1 [+ I+ ]9 S9 r pOutTri->index[1] = pTri->index[1];; J8 J4 }% @- r) A
pOutTri->index[2] = pTri->index[2];
i1 y3 X8 X8 t+ t- D }, M. {! ^' U: E% N& ~) w1 X5 h
: T; }* \4 z! W1 E3 u9 z5 o+ _8 n3 Q
outputMesh.UnlockIndexBuffer();+ s! |2 q# \: M- D/ q7 O
}- @$ J, M& X6 e" G+ I$ x
7 W, B/ P d% k% v其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。( N) s6 K# R% D4 C8 g
. [+ Q4 p4 P1 ~9 C) ^: _' T H- g' L7 V
关于md5anim文件
: Z+ A6 `1 k. h) _9 }
- T% ^. _9 l6 I( |6 ^" ~0 k2 d, n1 x Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。# r1 Z0 w3 m% A+ O
) F b7 I" k2 B# }4 B' S* r |
- @3 A& N9 N3 ?/ `# a. G1 s D. s可能的扩展7 p7 p( ^$ c0 B# t3 r3 s
& B: N! w" O5 c# }9 S1 I4 K/ N+ _' m
一、复杂动作的混合) w5 G8 E0 O V: i% Y. ~+ k w" R
- F$ g a" G5 l/ \! [ b% G
有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。2 I$ Q9 c8 B* q2 |
4 t0 k ?7 |& v1 u3 P8 c6 [二、基于物理的动画
% S. b! L" }/ C* b# @6 t9 _4 l, |, o" Y" J% F2 K
这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
l6 ?: ]) J* P
5 H- G: }' U& {* @! G, H) T三、基于GPU的蒙皮( `- p! W4 t0 z& r, U2 A: {' O- M
3 L$ @$ K% x& o, G9 C! ` 原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
& I$ c% p" }( v7 Z: P* D; F; @$ {1 u( T
四、非常流行的“换装”系统
5 e( n8 k& o% i1 \$ i) k+ z8 y4 s- w7 g; J% B
这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|