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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
. X( j" y4 x5 D+ K+ }9 D. V
' o) k3 H! N/ H- v- d在这篇日志里面,你可以获得这些信息:7 r& Z/ B- W: ^! J; ]! }5 @

+ T, P9 N# k7 J/ P4 x) B+ L1 人物动画的框架
+ e0 J3 [5 D7 N$ J5 i- N. @2 骨骼动画及蒙皮技术
5 E4 _4 a2 y' {! e7 r/ L3 doom 3和quake 4中模型和动画格式md5及原理
$ g; s1 i( B$ H9 F5 `  n5 @, U- k4 可能的扩展
/ z& {- E) }% q1 N7 ]2 u  i0 k+ }3 E/ I6 E7 m  E
( F5 a4 x8 m2 ?8 u! H: T# u) S
先来看一下人物动画的几种方法:$ g/ I# }7 }# R9 G8 o8 x
. S/ O5 W" B0 D  K$ k3 K
一、简单关键祯的动画' U. Y* d! O/ [/ X& t" T/ a0 o

& G. E. D. m' z, h  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。" M, h3 y7 y9 M' Q
3 V8 s, Z6 I9 B1 `
二、简单的骨骼动画及蒙皮技术
2 L0 h# P6 K, e6 @) n3 P' z  W# _( U& v& p# [) C+ n
  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。/ g7 L5 X+ n: z1 [' {7 n- N/ Z

; |# E* F/ Z2 ]. }# P三、改进的蒙皮方法和基于物理的骨骼动画, R4 k; S4 E5 b/ s9 p0 z( E* W1 p

6 h1 L0 ]! f$ z( Y  `  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
/ U4 Y4 r+ Q# j; X& ?1 _' q( }( Y: v! [: s: _0 D+ P9 s( O' _
  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
+ ~$ c3 V+ J8 t3 j% M9 @- [5 f' b& B# m. b/ k& s. t

7 P/ S$ H$ y6 }3 e. ^- q5 g. [: }
基本的蒙皮原理
- G) j% ]1 b3 K; X* ^
- k( E- h' v& d& h拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:% E; m. G$ R, k7 n7 ^: F- `2 `
1 j) K9 X% Y/ Q5 O
Joint: 用来记录骨骼的关节的信息;
. Q3 N. f/ o1 ^) j' PWeight: 用来记录顶点相对于关节的权值;! s: J' Q4 E+ ]7 w
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
3 R" u/ H. ?) R  `. S0 n
- s9 s4 P* |( a; r# \2 ]现在就来解释一下这三者之间的关系:
" f! {. R0 |3 L" F# B& q  ?4 G: S, @- v9 b9 u! f
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。9 M4 W) h' p; }
- ^& i( B7 x4 H% n
  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
, ^  d: S7 `4 c" w- @; C6 e7 g4 c7 R% |( }* o+ A
  了解这些基本的概念,下面就来介绍人物动画系统的框架。9 |1 h$ s+ ]* p

3 a, ?0 Z4 Q5 F2 t: b# O: s, @, m0 a; M% g" t
骨骼蒙皮基本框架
+ z2 @4 ~$ t1 I: z* U" F7 H- k* T9 q8 B! m( h/ h( F% s( B; \
基本的类型:
# _# B1 R) o# O) c& p6 p. _% }  W7 S  Y: u) C( Y% k6 ]5 H+ n
关节信息:0 {5 H0 H  F. C" M8 O6 Q
$ H2 F3 Q- J( E2 W* Q% F
typedef struct _CharJoint
+ j6 P( Q: U+ @: I{8 y* G3 p0 J) ]  Q4 Z" i# P) o& M
   Vector3 pos;
( ~8 _  O" o0 k  K8 `# o    Vector4 startPoint;
; x/ z/ ~/ d2 t5 W7 b* \1 ]' r    int parentID;! X( R4 i/ S& R. }" x
   char name[32];2 x7 @( N$ f+ L& ]
} CharJoint;
& u' S9 G+ R6 c, D& J& |1 k/ q1 E# ~7 k/ P8 ~$ X; T5 h. s9 x
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;: _; l+ f& y( H, @# z

, [: `: t' m. N# C" v7 Q权值信息:) M, s$ {9 d3 n1 ~

3 E! ^3 ]! d) z& y/ K! m  }typedef struct _CharWeight2 _( @+ _& r6 \  J2 @
{
/ d3 r% [  U, }    Vector3 pos;) z5 G4 A- Z" ?/ f
   int jointID;
$ a; a# E" Z1 ^    float bias;8 T: s. V. l$ S& U* W; I
} CharWeight;* [' ]: {, K0 I" f
; U3 P- \* E9 ~4 h# M
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;8 C' k# K# E% ~8 o: r/ O
/ u/ E, W% ^: A% C& c

; S' L/ K6 [8 \4 n顶点信息:2 t% B* C3 N& j. Y- K
, i  r4 f4 e+ O1 T& U/ K" q
typedef struct _CharVert' F* _' x( l/ J) R0 h% T' h' _
{& ~. |4 ~- L- j
   float u, v;" @+ R% U; V) G# v  }+ i3 E# Z
   int startWeight;0 i- [# {; A1 i9 J9 D$ ^
   int weightCount;- U8 A) `6 P; O* R
} CharVert;
0 b8 A" K  S7 N: g( U. m/ ?: V. T7 M4 f2 m/ K
  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。8 q3 g9 B$ k) X8 [* K* G
! V9 X6 c8 ]: z, l
% h  m6 H% c  I+ z% j* ]( n$ z
大概还涉及到这样一些类:' g, q, y! p. ]# u0 f! d2 I  k9 d

9 v6 d3 z, d3 a: d" ?. tCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;& ~) O( n/ S/ J  q* j! g6 M) ?
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
. y. {3 I$ G) u% z. lCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
3 _# C/ x0 ?4 V0 C9 vCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;8 I1 M* F( g! Z
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);+ O" ?% k* y9 r2 \0 v( }& g) L7 X
+ {7 p7 b: y+ Q
3 X- R% ^0 J; {6 w. R7 u
解决关键问题2 I0 @9 C  J( E5 I( |- M- z
3 [- l4 \0 N9 q* ]# z! F& c5 `
  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。. C0 H5 j/ V3 L; `, P

+ i2 n4 T4 D  l6 @& k; \5 E$ C% i, R关键祯混合:
$ _, w: c+ U0 v( ?9 ?$ K( {4 B4 `: u( ]6 _% O: x
  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
+ x4 I* Z4 {3 ^/ ^% t! s+ h
0 R& h, A6 l3 X" O  际应用中还有其他的混合形式,后面再来介绍。
# r- g$ B  F; K1 Q
1 w. G; i5 ~, c
' b- L# z! K, T" q: j1 p$ f计算实际顶点:
/ I& h0 O3 i2 x
  A8 d3 H/ d( ]# A" Q, a5 P+ l- S我们看一下软件的(用CPU做蒙皮)Blend过程:( G6 ~. ]& e5 r7 z
; Y6 E" Z- V3 M, j- t  l
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )6 L- M( e! O: _6 g% W8 p
{3 O% b, N) h# [/ t1 T( ~! g/ p- V
   if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )  G, t, r# Y/ W) \2 s# @
       return;
4 Y% l* c3 i. n: C4 ]% G* m8 }! \- s$ H2 L0 x
   CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
0 e9 J0 K% R' J) Z& X    int numVerts = inputMesh.GetNumVerts();
: _' R6 B1 A9 T) |7 o5 Q5 l    int numTris = inputMesh.GetNumTris();
) D2 D) Q7 R: E$ \: r4 l8 E
) Y3 s: O: Z  T  Y9 }    for ( int i = 0; i < numVerts; i++ )
+ B% q7 y# y+ ^/ y    {
: u* \1 G) q5 w$ N        const CharVert *pVert = inputMesh.GetVertAt( i );
8 c" p# g7 {" ~0 m0 q, k        pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;5 D4 x% _! z( O/ B7 U

; P4 ^9 o5 x; v) j# ~        /* u v initial */
9 X1 U' M- A3 q% n# T/ @( |2 g" _# C8 n6 z        pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;: F3 m$ V) B! H/ i4 L7 R
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
0 K7 g7 X: w! l  P) l' r* |' N2 v$ r
       for ( int j = 0; j < pVert->weightCount; j++ )
- ?8 h! w: m. K( L        {
6 Z' p- m+ |, h" R0 c            const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );# {* E& \; h5 m
           int index = pWeight->jointID;8 q" n: Q6 F" h, `
           const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );7 S+ c# a2 E, s9 t9 n
           vec3_t wv;8 z2 r; X- f. D  u/ E
           Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
& T7 a5 Z# U! i            pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
+ F. \2 |- _6 d6 m' F            pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;8 t  x: s3 f3 I, l
           pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
9 d6 J+ z0 C' f# t% ]        }
5 t9 |* ?. ~+ o6 k. |/ M: A    }
7 r0 Z( Q" C/ L1 N
& d' P+ k  p5 K" i    outputMesh.UnlockVertexBuffer();
6 T* f* W+ s' a4 k5 J& D
6 h- F9 {! |7 g) \. H3 b0 t    CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
* s. q( J5 d$ O. Z5 R9 w. j9 y( \8 p7 X, U! q
   for ( int i = 0; i < numTris; i++ )
) M2 K9 G" s5 j7 g' I8 V; D    {
  H5 e1 C2 Z9 _- o# G6 p: Q        const CharTri *pTri = inputMesh.GetTriAt( i );) }8 _9 _' b. O! @1 j
       pOutTri->index[0] = pTri->index[0];
0 V/ t2 J' w7 D# l0 p5 x# F* a        pOutTri->index[1] = pTri->index[1];
( C3 n& J$ n1 L5 P        pOutTri->index[2] = pTri->index[2];
' Y; ~9 j5 F6 b1 P    }, i' `+ J- A$ E7 J. i

. F# H0 i( l1 ^- d0 m3 K) z# e    outputMesh.UnlockIndexBuffer();* R8 X5 r/ _$ ]2 ]/ T
}
! f$ e6 D$ Z3 D) y
9 {: ]: _% c- e4 B! j7 F* F! T其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。2 P  L3 m) @5 u% _) ?# O
7 ?0 G  ?- Q( g) q) K: @  f
1 T9 [; a2 V  t2 G2 N3 r/ d) |
关于md5anim文件2 k0 Z9 W9 g5 m7 ~2 J# ]: l
7 C1 D4 Q  g1 |- ]( T2 S
  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
/ R5 N) r: P0 _/ X" g2 k
* T) x$ Q/ x$ K5 I
5 d4 y, `: u% o9 A. D. V! {可能的扩展
* V7 ]5 M  a( I" U7 t0 F
% g, L; B6 R9 P- e2 n* n7 C. o一、复杂动作的混合
) u, |: q7 M( H/ F  w/ r: |
  n6 U  p/ V  V. \  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。/ ^. Q3 I) T9 _4 X

5 M$ e+ `  H% ^2 D- l$ |6 ]二、基于物理的动画
$ W' J* }5 x, H0 E4 l3 H4 ~9 b# D1 ?' ~; X  g* ~/ w
  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;# L- \0 P4 _& U5 _
/ O# R: A; a& v$ o! v
三、基于GPU的蒙皮
3 Y" o7 I) r' }, ?  c; U/ q1 h) d! Z
  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
8 P0 L0 ^9 u/ |' Z1 Y: z7 H
7 C2 [0 u2 t0 K8 T5 h3 z# |四、非常流行的“换装”系统
$ G" Y1 i7 O  h3 @& r* O& k& d3 {$ T2 q/ [
  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2026-6-18 12:06 , Processed in 0.022113 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

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