找回密码
 注册
搜索
查看: 5666|回复: 0

自己写游戏引擎(05) —— 人物动画系统

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。# j$ a# |/ v. v" E( f) I, j8 ^3 {5 o
8 Q# ?7 R' y$ Y* m8 t. P! W5 Z
在这篇日志里面,你可以获得这些信息:
) I5 d. y( V' a) z
% }: j& l- m: [6 \8 C6 ]4 V2 Z5 V1 人物动画的框架. j! e9 I& g6 G+ L& H6 V
2 骨骼动画及蒙皮技术
% |0 v* @3 E/ I# J3 Y; A3 doom 3和quake 4中模型和动画格式md5及原理
$ `- I* _- \0 N1 e3 ?8 p! P& V4 可能的扩展
% B# W7 |3 K- I) m
( M: W1 s2 M+ x9 l& i# g1 s- ?6 @3 H3 H9 Q, z- Q) v
先来看一下人物动画的几种方法:7 d. r  D: T! W: c" U4 j

) [) u# a- C0 ?& J: {1 A) x一、简单关键祯的动画
* }+ A5 K' |6 x, n2 u3 F- ^
/ [8 a; ^9 Q. d, v  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。
$ u- Z: L" |0 Y7 R7 z: l' b, c: D, \5 B1 v6 y, Z" x, B- M8 }# ]( G
二、简单的骨骼动画及蒙皮技术
5 t: U5 [* Y: S" x" l
2 V8 j% G% @4 E3 l% C6 `0 s& Q% \  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
# F4 p' i3 _/ ]; U& s( s$ J6 `, U5 Y; T" y% ?1 X
三、改进的蒙皮方法和基于物理的骨骼动画
* \1 `  g4 j. k7 ~  a+ M7 M+ Q
  ?! |. G, ]1 U1 T. l! m' L( d  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;1 k! l  V1 D8 p' n; m2 Y3 J4 W

5 z$ N4 ]7 E& G  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
0 T# B4 E2 N- n2 S4 [
4 I1 p" h9 [/ W0 F/ r
3 [* d1 q" ^8 ]
( K" N  a" H, Y5 n  s0 f& B8 h% J基本的蒙皮原理
# T$ k/ h: q. X9 |; H+ P
/ G  ]1 S" c) w. r6 M拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
$ t1 R* Z$ A" A1 Q
/ k/ o+ `% b6 ]+ r6 S: |Joint: 用来记录骨骼的关节的信息;2 }6 x. f% `/ h' \' b! o+ M
Weight: 用来记录顶点相对于关节的权值;
  V! T7 l9 Y  EVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
* N6 ^" Y& F2 W! |
% r) r' Y7 |7 w5 b& z1 a# [& Z现在就来解释一下这三者之间的关系:
; a! e$ T4 p+ I- H8 b
+ J* y( N. m: m5 i4 ?  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。: [7 n6 T( T1 M& ]8 Z  _

7 l6 M8 h. K6 V" L. l  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。$ u1 E* [0 ?* I5 K$ y/ R, P

; {! O- Z1 Z+ V4 p6 ]6 l  了解这些基本的概念,下面就来介绍人物动画系统的框架。
3 f: r$ i+ D8 t5 M. m" C0 n4 X- D  [- V. X+ E' u

! c* ^! W9 x1 d2 O) A2 t8 L$ ^8 A3 w骨骼蒙皮基本框架6 D: V; @8 _" |7 ~1 Y; a

1 l5 T8 L) l. \" Q0 U) F9 t基本的类型:' ?1 o* _. G/ ^$ O6 R+ ?
, ^$ J# w, L' J! V  G7 g* Q  W
关节信息:
$ o- r- f- U1 O/ }! ^
: d% I" Z6 |+ x8 a6 u) Y: I) }typedef struct _CharJoint
& X, ^4 d+ W( s5 Q6 Y+ O6 h{% H( q" W1 q+ P; T5 x
   Vector3 pos;1 h7 d: z- k1 F, l8 ]* b
   Vector4 startPoint;
6 b+ j: y+ [! N    int parentID;# e" F3 k0 N6 o4 t( n% g
   char name[32];0 b7 Y8 X$ W  [4 a8 e' R3 w% \9 A
} CharJoint;
3 g, U4 T, o" X( o7 {8 X& ?) S0 k) G. Z# u* x: z
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;6 Y% a  J2 z% B- i

- O1 m9 B3 N& B, w权值信息:
9 w+ N  [  ^- {, Z4 l3 U5 q! x6 X5 R& e% }  Z, V4 P: y2 [% |: P- ~
typedef struct _CharWeight
9 `  Z0 N- t& i% |9 K9 Y7 P3 H: k{
% v3 w# p  H) ]6 r2 m    Vector3 pos;9 E9 i; I, Y/ q# u  t, V% g8 ^
   int jointID;( N4 N1 a, `8 V% \% R2 {5 Q1 J
   float bias;
, `9 z* o1 G9 f' t} CharWeight;
- M8 _3 @0 v8 Q6 A4 k" P* i
% \6 {! H1 K7 C7 [/ o% L1 l% R其中,pos为偏移量,jiontID为对应的joint,bias偏向值;; ]( L! q7 g8 E* j5 m3 ?" A

  g& c- f& s) I. A9 X" c
3 l2 ?+ S$ [* q; F5 V' }$ ]顶点信息:# f- o) t" E8 E2 D  t

* n" B& t$ j! i) H) Dtypedef struct _CharVert3 `# A; ^% M2 i) M9 m# v
{( X* @/ g; c  G3 v6 s: P9 T
   float u, v;! ~" N7 j+ L/ u- ~# d3 {$ d
   int startWeight;" @: C& L( u. j* ?; u
   int weightCount;
! m$ V6 l1 y( Q4 T0 [& i} CharVert;
! E% Q- L! G( N  Z, |
* n" L+ f$ _- T  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。
. g  o. C8 j6 _" V. z/ B) G% w" `7 [3 P  I. e
% p8 S, w/ w( @) m8 g# Y1 Y  w  f
大概还涉及到这样一些类:' m3 n& ?' c, J2 M# m

3 l$ J$ [$ j7 ?CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
' m, B$ W8 H& z2 S2 Z( OCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
1 ]: m$ p9 D- [) f, P7 o9 @: }CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
* y( S; K( b9 K4 NCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
0 K/ M/ C$ D1 v# j% rCharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);/ n( c* I; l- g

* G. M' o% u9 |* u3 z' r" O
0 C; U2 A! ^! ?1 ~解决关键问题
1 n9 n# f9 z2 X8 D4 C. e0 M& p3 b0 Y  ^( `4 F9 u
  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
+ ]! C5 i. y- C/ k2 L" O* H4 H; l+ p0 s2 `  a
关键祯混合:
* M8 U" m2 r% R0 K2 W
3 S* M/ t" y; {, I6 ~8 o  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);' c! k; h4 R" D7 n7 [, a

* g8 t  m; b/ z9 u: t0 p3 x  际应用中还有其他的混合形式,后面再来介绍。
' m  _, G% b, \9 n, H: ?3 ?) L" F/ Y7 t  q8 ^  B

1 e9 N' L& s4 [9 p; u计算实际顶点:
- L) y( }$ g  Y* Q$ Z$ n% Y: @( X% R, [: [
我们看一下软件的(用CPU做蒙皮)Blend过程:) C  P8 l; i  j# k$ S

% ?- P3 f# C( K% v6 ?6 r3 ~0 v$ s; lvoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
6 s5 p& b! m( l% w9 V+ A{  `6 q: j+ k9 Y7 O- n$ g
   if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
2 c, j3 ^3 ]5 l        return;6 {% w" B! F- r# q; Z  v1 K. T
! i5 i; V! j* `! `
   CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
2 S  d$ F% _2 k8 e    int numVerts = inputMesh.GetNumVerts();/ R0 v1 C; s5 t7 X2 l3 s
   int numTris = inputMesh.GetNumTris();! x3 n9 x6 ?2 g! V' j& c3 D" S
3 J# a1 X  o0 L0 ~! d
   for ( int i = 0; i < numVerts; i++ )4 l4 @5 {/ ~3 m; r. \2 z: {
   {
$ j6 C  S* e* s; V6 k  t% N( s2 |* @        const CharVert *pVert = inputMesh.GetVertAt( i );
/ U7 d# p' e+ ?2 k% q: x& F        pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;; `6 c4 P1 q6 g7 Y  L

1 V4 C( q9 }" D; y        /* u v initial */
6 M; q  o5 B# x$ v# b$ `; ], W        pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;8 _. F- h, [( ~4 h8 _
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;& k- l3 ^; K! M. k" B+ _7 v2 K9 X& ?
) B6 D" O" C: A
       for ( int j = 0; j < pVert->weightCount; j++ )
4 z. u3 P0 j7 K        {& |4 Z( r( F- C
           const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
9 Y: b9 ~# f' S            int index = pWeight->jointID;
# m. e9 a& G( @            const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );0 [; F* h2 [$ z  }3 x2 H
           vec3_t wv;
. Z5 l' {  [2 O6 D- Q            Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );6 I2 c. O4 p6 g
           pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;5 F6 m# Z( W0 p) m* Z
           pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
  {4 m( U7 [. i" M* \" h( X4 a            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
- U4 c2 F/ M- G- A5 n, i        }5 E1 U; @" }+ y7 W$ h
   }
- C7 S8 o, s0 H: _, i) F/ n# M9 r; l
   outputMesh.UnlockVertexBuffer();
4 b3 d2 Z, k2 O5 g  I  K9 X" q9 I) W7 V* C  s5 c
   CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();) @0 [4 _0 a7 u7 j

6 d, e: U# a( T, l  R  d    for ( int i = 0; i < numTris; i++ )
( {! m5 u$ A( W1 b    {6 }7 L$ ]- x  \" Q9 O  n. c4 Q+ k
       const CharTri *pTri = inputMesh.GetTriAt( i );) B# H3 X2 ]9 g5 b9 K/ B
       pOutTri->index[0] = pTri->index[0];
0 h4 O" K9 t2 \7 e, K' d7 O! v        pOutTri->index[1] = pTri->index[1];8 c" K6 p) z* b. d/ w4 c7 ^
       pOutTri->index[2] = pTri->index[2];" Z) n6 C+ D) C, r# ?* N
   }: t- ~; D5 d+ o6 h3 k  h

& \) c7 A( U. a# u/ u9 H    outputMesh.UnlockIndexBuffer();9 p- t9 B/ G* ~
}; T: W2 X' v& q% y( v0 y8 u- O

. t/ P+ c) G8 V3 J9 ~* V" v6 b其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
8 _5 d* p4 N7 r' E) `. r( }
5 B  N) ^- Q) w$ ~* z
1 f6 T  Z2 f9 ^7 Y$ c& f关于md5anim文件
8 o: j5 p) T$ ~. |% l* s6 g
6 S/ `7 I/ k6 ^" f  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。) V( n7 D! b% \2 Z
4 \0 C4 C& s. L

, C# ^2 s  g* h可能的扩展9 H( W- V8 O  E  ?7 ?

( _& f. [7 y2 q8 o/ p1 J一、复杂动作的混合
) W3 `7 ^* _) g1 j; w/ i( ~' b3 h! z+ Z$ V# j$ t" k4 Y
  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。; N4 L$ g/ f! n/ j7 [& l
- m8 F9 H6 V8 U1 a
二、基于物理的动画
% c# W$ z& d5 k) M; P
1 ~. u  L$ ?4 ^# C  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;) L& s$ W! e; p6 b: g- ~
6 ]' h3 O1 P  G9 b4 K& b
三、基于GPU的蒙皮
) p: J+ h% g3 f' F
/ ?6 c( U* t7 d( D2 R! B  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。8 H2 }% b0 R( f+ [! s
2 z/ m8 y6 C4 @% ~7 f1 A
四、非常流行的“换装”系统# B$ y: L$ V! K$ \2 d
) N3 m4 D; Q0 T0 N+ L
  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

Archiver|手机版|小黑屋|宁德市腾云网络科技有限公司 ( 闽ICP备2022007940号-5|闽公网安备 35092202000206号 )

GMT+8, 2026-8-13 22:41 , Processed in 0.017407 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表