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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。4 D3 x8 q8 q4 c0 F7 `: R, y: W9 h

* ~/ c* ]- J! J" `* S3 `) Y在这篇日志里面,你可以获得这些信息:3 t5 U+ a! B4 r% J! ?+ l
8 U7 @5 C: {+ M6 W
1 人物动画的框架
/ X# g3 m* Q/ W# l* D5 l; t2 骨骼动画及蒙皮技术
& q3 O0 P+ Z; }1 @: `8 D' D3 doom 3和quake 4中模型和动画格式md5及原理" x4 D6 q1 |+ E# i# l6 a
4 可能的扩展2 w0 b, J/ d6 [' K1 R

# v6 ]8 g, F' b# y2 t( S+ s6 |; M- A) x* q4 ^
先来看一下人物动画的几种方法:1 P1 |: i3 K# n
. h9 ~/ C$ l/ i: o9 X1 I( z
一、简单关键祯的动画& l' u! S$ R$ u( D: S
8 Z4 ^* Y$ G: u% |- z5 a0 {
  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。: m$ t2 D% N) p" t: T' s: s

7 }4 n2 C6 p: O$ r" P+ S二、简单的骨骼动画及蒙皮技术
$ X" V7 N3 T4 n% f# q3 {
; j( X% u( R( W8 N; ~5 j- {  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
3 e- F; a& s4 _# w
1 o6 F. J/ x) N8 g% |/ e  d% ]三、改进的蒙皮方法和基于物理的骨骼动画
" I" O" A5 O1 l( r$ e( a% t  f& d( [* p5 I
  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
* |! ]% T3 q+ }" q( J  s. V) a
& X% I. `; u% ]/ t, a- g  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。( m% a8 o3 G: o' @. H/ s

  n1 s7 [- }+ I! [' j/ |9 W9 ]+ Q
! a! O1 R7 i$ _" F
基本的蒙皮原理
& O# I$ P/ E1 j& b2 n% A
1 M! p  p: w" J. t1 e- M% m拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
$ |6 I& k& @% A8 {# r, K( Y7 e9 m3 @/ S; E" q8 T
Joint: 用来记录骨骼的关节的信息;
1 z  {+ m0 O2 X1 R" XWeight: 用来记录顶点相对于关节的权值;& W; Z  o$ n2 A
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;, U1 D9 V& U1 K4 O: u, w  C
, M  `1 u7 m8 @# r" F
现在就来解释一下这三者之间的关系:
* h2 k* H! o0 T; g: w+ _2 ?& }  P$ }1 n) j/ S
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。3 G1 d: y1 P7 _
7 s" v+ Y+ p; v; M, I2 t
  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
4 b% K" G- l$ E' Y+ u& G0 g& ~( ~6 ?# \6 E, O$ L, W
  了解这些基本的概念,下面就来介绍人物动画系统的框架。, T2 ^2 a1 |, R$ U2 W, Q

) ^# ?4 P; f0 t8 e1 O9 R& R2 j" r) m
骨骼蒙皮基本框架
' x2 N* b8 ~9 E* p- Y: v! Z" d& r" Y" X
基本的类型:* d' B; H% q) i& v, b+ C
9 R' X9 k% x* w" _9 f
关节信息:
. Y3 u  L( _( `" ~, B
& {6 j- P# ]. Atypedef struct _CharJoint' @% x0 O! G- Z- A( R8 e
{) }" n# Y; C; E: |7 F, {7 [/ N
   Vector3 pos;* l+ ~8 \7 Q# X+ ?. y& D8 }  s
   Vector4 startPoint;. W" q5 z* s7 r8 d3 a
   int parentID;5 W4 E& s. {9 X# {
   char name[32];9 [' E2 W% s, W4 t
} CharJoint;
. T4 ?6 G6 E( W6 d: ^; P& K& @. H: K5 L( J2 s0 N
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;4 w# ^- g) ]( X# _* K
9 _- M& e5 l( \/ C' A  {: |  f
权值信息:
) U* ]# h: G& {5 c4 ]8 a; O* ?
) ]; X" o: m( H( S0 ptypedef struct _CharWeight5 e: J9 \  p/ V) g1 T4 o7 \
{
' T5 Z. h5 o" a4 x0 b& a    Vector3 pos;
, G/ d  Z- P6 }  R" ~6 l( R    int jointID;, i1 f2 b( C5 t. e# F0 p
   float bias;
: y) \+ R" T$ }! [} CharWeight;
  Y7 e2 x/ c. U% w! U
; n6 t# i! E# Z, }2 Y1 Z其中,pos为偏移量,jiontID为对应的joint,bias偏向值;7 g) t' ]( x# B/ o1 A

9 k9 w- _7 |' Y% {! x3 a6 C- T& L+ E
顶点信息:
. t# U2 c) g. J' a* `9 T2 y  G, Z- E6 v& S) ?, U
typedef struct _CharVert! P, Q" r5 x: j# }3 `* H
{, B( t# U! ]' e; F2 j1 ]" m
   float u, v;& g, v6 s: e  ?9 [
   int startWeight;
; x5 G: u! y; f' g; \* z/ ^  }8 w0 B    int weightCount;
7 ]: [& {) _$ G! a: N8 Y( d} CharVert;# z0 a1 I  ]; e# q% m+ y# C

) e1 v$ P5 w( x1 @9 S  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。
* O; \& k6 H* {  ~; E+ _$ V$ t6 r5 r7 a- z3 U4 f) V

- {5 a1 z( w+ t大概还涉及到这样一些类:
$ D0 U. m5 ^4 ]  D1 T# ]5 m- {* M8 v0 o6 L5 y
CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;7 O- B4 b( z6 E
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;/ J, Z* k6 A: ?6 m
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);2 d/ c$ H1 w/ b9 P( w
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;' V. |1 y- `1 k, r$ X: K
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);9 ~8 \, R" ^1 h1 h

+ i& R  G" B5 D* O  o3 M3 |- A/ t
解决关键问题
4 s5 F" `8 ], Z4 e
; ~- `& L7 N, y7 |  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
- ?8 T* ]; p5 m( a) d" N4 b0 n2 G, Y( W: C  J% I
关键祯混合:
" A9 c% u! J. h1 n( B* m( j4 S3 W4 i  ]  W! _2 p
  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
- K; Y' u. e' `
/ o1 i0 U- c/ S, v  际应用中还有其他的混合形式,后面再来介绍。" G  h6 S- ~+ [
, D! Y/ k- I7 r) H

5 Q6 k  C  \' T! @/ Q8 a, R( E计算实际顶点:
9 O7 m: a, B# b8 J( Q1 W9 X" h4 C9 T9 }2 l; b) v) C
我们看一下软件的(用CPU做蒙皮)Blend过程:
$ Q7 P$ _/ ?% R0 ^* S6 H. A8 Q
$ f3 w; r5 f' l: `4 g5 V0 P9 ~void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
5 ^  A% _! ~+ G0 l" {. U{
- N- q8 z% t% w9 R. O, j! a3 N    if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )% L" u5 p" e% p' I' m" t9 F) y% E
       return;
' [/ [( A$ a+ u# r6 s4 Z) }1 l' `0 Q  I5 ~$ ?
   CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();9 Z, A/ p! P1 }9 p
   int numVerts = inputMesh.GetNumVerts();! k, ]2 K7 J+ n. y* @1 Z
   int numTris = inputMesh.GetNumTris();
5 R( X' ]: E$ Q" S, [/ w3 a9 ^4 M/ m1 u
   for ( int i = 0; i < numVerts; i++ )
; V' X$ f- g% k; E# s    {
6 g1 _+ e  {8 [' a        const CharVert *pVert = inputMesh.GetVertAt( i );' e$ ^' N" v  m; L9 J0 n. I0 G$ q
       pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
: }2 `! _/ Q5 i* ?
0 P- y- S& j& k+ s% ~1 N0 v        /* u v initial */
* D' D7 Z/ ~, M+ q        pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;. I/ M0 x& w6 ~; s3 q
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
4 n- |; K( ?" i% o
3 I- Y/ A6 p. s' o  f        for ( int j = 0; j < pVert->weightCount; j++ )2 d3 s: X: U& \* J
       {
3 b$ a% S% y) c8 S  G            const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );' P- _3 W  R3 u" `
           int index = pWeight->jointID;9 q" A, ~9 B) L* k" @2 H
           const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
7 k: z1 \* v& x            vec3_t wv;' B1 p, ?, ~) a  ?, h, B$ y
           Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );5 \1 A$ }) l% I; F0 _' H
           pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;3 W0 N9 K- N: ^4 q4 J1 U. `
           pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
5 L! S& i3 d" |. J9 `8 C6 D            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;% Q+ ]$ ?$ i8 F4 d, j
       }
4 B: T0 T# F2 A$ ~( E$ _    }
4 B: o6 [) N( ~3 P8 [5 p& {) x0 d$ Z+ o
   outputMesh.UnlockVertexBuffer();
; H+ i1 o+ R4 Z7 D4 ~* b" Y4 M8 W/ c" p( G5 m
   CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
2 Y4 `7 u& M4 k7 G
  w, s! ?# K  u. g6 W- y. k. i/ n    for ( int i = 0; i < numTris; i++ )
+ k+ L0 n3 o! ]2 L/ q. k! v    {' A( ^# a3 a4 b3 D3 c* s
       const CharTri *pTri = inputMesh.GetTriAt( i );
4 g8 t" G: ~! Z% g  O% B! u        pOutTri->index[0] = pTri->index[0];
1 b3 j& E' C* u  e5 Q* u8 E* I7 y        pOutTri->index[1] = pTri->index[1];
$ t7 p" I5 [1 M9 l+ W! ?7 E; Z- X        pOutTri->index[2] = pTri->index[2];
* d5 z* X; }: D    }
$ ~) Y4 _9 P# K+ r0 d; J: t' X% @4 y. I, S
   outputMesh.UnlockIndexBuffer();
. Y- r/ R# Q3 r3 q# @}, P9 c1 u' ^5 N8 {8 v
, Q) y- m* J+ q8 }. f$ v6 h! L& e
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
" t8 G0 Z4 u+ k& `8 M# M
5 E) q9 Z- w" h3 C
6 Y- \3 ?0 {1 W3 w5 G关于md5anim文件$ T9 h: k& E. Q2 [

  b9 z" S$ t* |8 m7 s* t! s1 M5 d  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
1 l- E; C! l" J/ R
3 J' J9 X1 O1 Z! K
5 q! e5 p& V& D5 q' |  h可能的扩展9 o  _' N0 M7 K- _# V6 o

! a4 O8 e# ^4 A- l1 \* C8 t一、复杂动作的混合
' C# L1 W' h7 n1 {
$ Z7 c! T& s# R7 l  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。. ]3 V0 {9 }; J- F3 J
6 b+ ?9 ?1 p3 X! Q3 K! \2 H; s
二、基于物理的动画5 p6 Q" y5 Y# K/ v+ N0 t

1 F  Y4 {/ z' D- R/ T5 \  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
* U9 E4 T1 [; B7 ?: Y+ b! I6 l! ]' C" t4 K7 B3 t" k) X  K& W: s+ ~! s' Q
三、基于GPU的蒙皮
. p8 R+ b( a; U8 ^, v2 m
; _3 f, b, O2 j1 Y" S/ V  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。+ b7 X# \; @' v3 c

9 p: o+ F  |6 j" o3 s1 Q; x四、非常流行的“换装”系统, ?# r' W) P  x9 S
; Y+ Z$ M( |; q. Q8 s0 [
  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2025-12-15 14:31 , Processed in 0.020110 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

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