找回密码
 注册
查看: 5722|回复: 0

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。) F& x0 R) ^. v  {; c' z2 x2 Q

8 E( s+ f. F! G, k$ O. m3 s在这篇日志里面,你可以获得这些信息:: D1 O# i4 g+ q7 w: j2 C

2 d* |6 o, X' k1 人物动画的框架
' H' r6 G1 W6 d+ T3 }2 骨骼动画及蒙皮技术  {6 z9 _) _+ c# G7 S& ^8 a6 g
3 doom 3和quake 4中模型和动画格式md5及原理0 l& M0 v& c+ E2 `! g
4 可能的扩展: D8 f& R6 y- Q) Q! T8 U  i

) ^9 i" U" _. [' k: d, |  G- d2 U
先来看一下人物动画的几种方法:/ j5 u2 l/ |! z/ V

0 C1 |0 a# v; g" P' R1 o# ?, \一、简单关键祯的动画
0 v; b; x: r8 s- Q- ]  \4 G
, m9 j0 }" Q; [  q2 a8 ]  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。* `2 Y& r  D# w& c8 Z* k

: G+ S0 S, c5 G二、简单的骨骼动画及蒙皮技术
7 \; U+ u: ~* H0 K  ]4 F6 K% W0 x* O8 R7 k6 o" B1 `
  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
& T$ W4 i; V7 {, t
. V2 c  ]7 H* p  \# r/ q7 X三、改进的蒙皮方法和基于物理的骨骼动画, Q& x/ ~& U2 ^) `# H0 k

, x$ Y& h+ J0 E. F  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
& d$ h# M' O, K2 L' h8 E- P
& X7 B& d% X, R+ P; u$ s% Z; x  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。3 M2 X4 d8 u0 z5 r
( z- \7 z$ g# j. A" E# I

+ g: A* m, V/ |0 c3 T  e( P5 V! o* L3 X
基本的蒙皮原理
+ p- d3 A3 ]3 E7 l$ g/ o% k# i/ E* v8 ^
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:4 j" z6 E8 u* U

( A& O. S; Q: P0 QJoint: 用来记录骨骼的关节的信息;7 f6 w8 \7 p6 }: z
Weight: 用来记录顶点相对于关节的权值;
3 o+ u& V+ Q6 |) \/ dVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
' {: c! [; h/ O2 E5 W
  t/ ?& T1 Q2 L4 ?) ]: Q现在就来解释一下这三者之间的关系:
& ^/ [) g3 m9 k5 M0 q) U
# E+ `5 y4 q; w- f9 \6 r  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。, G$ }6 G4 P% W$ }3 B* X0 _" Z9 \
$ R6 Y  r2 B& s
  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
/ V& r" y, _, T2 I6 G) _% ^$ v, _- t* d0 C; B" m
  了解这些基本的概念,下面就来介绍人物动画系统的框架。9 k1 v, g3 O; m+ N4 z0 v9 g4 _* Y" C
, Q3 x1 ?2 C2 a- W
; e1 Q( _' ^- a( N# m( u
骨骼蒙皮基本框架/ m* u5 }# p4 T

" [+ B" G$ a- d$ y1 v基本的类型:
& U' i# U5 G, F/ \/ g
/ w, r8 z% U9 k: s& f4 q5 A3 }关节信息:+ |3 M5 a( i3 d: g- a4 M5 N
* u- m* `' i5 [# Z% z# i' i
typedef struct _CharJoint0 a6 N0 i7 G' d3 {% U
{
' \0 E8 ]) M/ w+ p& i3 x    Vector3 pos;. I, W, }1 A3 `" P4 x6 j0 c' l1 p
   Vector4 startPoint;
% c9 l$ I1 x( s) t1 E0 T+ ^' F, o    int parentID;. w9 D5 L( d* U: F; F; s# p4 r
   char name[32];
8 S) ?, b  }' m! a0 ?! ^! ]! Y} CharJoint;! R7 w4 P; b: ?& @: R
( i: M) c1 \  V- _: d0 U) I
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
% h/ f  |4 W# Y- U# G4 U5 z2 l7 Q! c; ]$ _8 d1 Z- ?
权值信息:6 l, x. _4 j, N# U  f
1 K/ A5 [- _5 ~4 ^& k9 E& }
typedef struct _CharWeight  T% H% A6 @) `
{3 `1 M1 u# ]  X& j$ ?* O
   Vector3 pos;/ b% [$ ^# T* T# z! \) P  g, s
   int jointID;" ]- p( U  b! J' b* L
   float bias;
4 K* f& ], A& ?5 b} CharWeight;6 X& v- i8 ]) @* v' B
7 t% R) A# X- Q) @
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;; ?8 U+ W* w; O/ z, ~
: r. z: ~: U- Z3 w/ S

# x2 ^2 t- O& k# q5 x# k+ K2 {顶点信息:+ \; y3 |- J( R2 f

3 Y# F9 ~+ x7 `" E: @typedef struct _CharVert
  d, Z+ U' V5 u# S* e' U{4 O# t6 r7 I/ y- u& S
   float u, v;
! A* F7 H6 f( S9 b( T2 s3 l    int startWeight;
4 q. o% X& b: O5 j    int weightCount;5 S: M5 ^- l! s# s- r/ {  S: E! g0 b
} CharVert;7 l# G% @9 M3 d5 u
# f9 A, j% I1 a* a! Q# X6 i* }
  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。1 ]& W# d) G$ a# |) \
- n2 d1 V9 R& s6 F. d6 z' `

8 f1 t% u: V/ m: |: d9 _" G6 B, g大概还涉及到这样一些类:
8 s  [5 {- V9 G5 T2 O. H
7 d4 `1 ^3 O# f, B' g6 UCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;) J, s6 Q( ]3 A
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;" y4 @' R, q3 S+ ~" E) M& J
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);0 l" d1 T4 g4 m4 R/ X
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;7 R( K* Q8 W8 Q8 f, t: {
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);& F( e# ]2 A1 a# ^# r9 ~) u

& T' X1 N: O- U) @& D& ]9 X
" j( R  B- c. }4 n* s解决关键问题7 r  D1 \2 l0 a7 z4 I1 e7 J
0 ?6 i" |* R6 u
  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。. f0 I9 G0 s+ D

3 e, z, y3 O* |关键祯混合:( u3 g) s* u, p

  T  ?! I9 X5 E" @7 [  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
/ i( j1 E' z, S) x% y4 o) m1 y
) Q/ B- g7 G5 m& m  际应用中还有其他的混合形式,后面再来介绍。
6 m, s8 h# L: j3 c% W2 ?0 d7 e& x6 Z, t& \/ y9 M

$ g* d7 [& s+ L' M% z1 C& L计算实际顶点:4 M3 q3 A: Q6 Q; A

- T, h: U, ]* c( U7 H2 K% t% y2 s我们看一下软件的(用CPU做蒙皮)Blend过程:; @  R. x2 Z$ j3 J6 ~, A
* y5 ]7 K3 o, q; e( U
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )$ E; p# ^6 F# j3 T9 X& D
{
+ Q( ]( X4 Q2 i$ q6 l    if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
& S( ]6 r% V  t7 k$ C! o        return;" e: [4 e* U. o8 I

0 q, P4 \9 F+ F% K8 r5 h$ ~2 f    CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
/ |+ u+ z1 o. S. {1 T    int numVerts = inputMesh.GetNumVerts();. E4 G+ T) ^8 u2 g% ^1 v
   int numTris = inputMesh.GetNumTris();+ C) U/ l' F* V& N4 R! q
4 I9 s/ d/ |; [" M; B! E
   for ( int i = 0; i < numVerts; i++ ); _, H! G0 J9 ?3 R  A
   {4 ~  n/ d  \( e4 E# M
       const CharVert *pVert = inputMesh.GetVertAt( i );
+ R8 ?% [5 ^4 O/ s0 ~* K        pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
6 P6 Z% b% J* j. R3 G, `8 \) Y% G# Z5 W. {! J$ i- C
       /* u v initial */
, X* ^( R$ G6 b9 z$ ^! d7 x        pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;
: u) s( P2 A+ C4 H        pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;+ I5 r3 g1 u2 `; f) o8 X, u
+ V, s$ a. b. J- P
       for ( int j = 0; j < pVert->weightCount; j++ )
! m1 W; K# P0 h1 d) S1 X        {
) W0 Z4 @( I% c& D            const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );1 X& [( W) L& b: u$ n+ O
           int index = pWeight->jointID;: b% [2 ~1 q) @( p& n! H' e
           const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
# \7 ?! c" O3 r; ^0 j* `            vec3_t wv;
, n  b  P" L& c  r! q: p0 E            Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );4 ]) G3 H5 F. A6 z! V5 ]7 i% S
           pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;* F! S; i8 z. y% B) f
           pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
' \% i' a$ J! ~0 V0 k+ }            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;/ p- u. \0 b" G4 z: b
       }
( a+ L( @7 o. A  h: M) P    }8 ~3 D, Z8 j% m% n" p
9 U6 |8 _4 X% w0 w. _5 I
   outputMesh.UnlockVertexBuffer();
% i6 r2 W: E8 ~0 a0 K
- E  o* q$ Z4 k. S  w4 e4 n1 F    CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
* t5 F$ D1 @, b# ]& G; R
. q' {& k( E+ ]: L    for ( int i = 0; i < numTris; i++ )
; w, B7 R9 t' u* D% e  I& R    {
9 i& G: @4 Z) J        const CharTri *pTri = inputMesh.GetTriAt( i );3 o6 _* A0 H1 E5 h! B( @$ o
       pOutTri->index[0] = pTri->index[0];
4 i3 X8 L! i2 j& i+ `7 A1 ^        pOutTri->index[1] = pTri->index[1];
# d! @+ N( n) l/ s) ~% T) b2 J        pOutTri->index[2] = pTri->index[2];
" e; l' l6 J9 C" r    }: u( y0 ]" n# s  h2 Y
4 _: i! N+ {) p4 k% X% e# {2 W# Q* q
   outputMesh.UnlockIndexBuffer();; g* A; C# V( m: L" n
}! D! k& y+ z, `2 P; J9 ^" O* o0 q

" e( s0 Y+ P) U5 a其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
6 t2 v$ g0 B( l: W1 G% `) ^$ K8 J3 n0 ?1 V1 T: p6 I5 @& e# O. c- n* t

! L' p7 u2 m+ E, q关于md5anim文件
/ _6 F/ M% G. C  P7 j
( j: C; F" \2 {: Z, |6 s1 _" }5 w  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
' z- K. d) ?) u! K* I! v
  J/ [, Q* |8 g4 G$ I
! n6 l5 T2 E. y可能的扩展  G7 q, M' @* ?" S3 x9 F, o
9 Y" s! D" }7 P" F5 U5 g5 [1 S2 A
一、复杂动作的混合4 `+ p7 f  w6 M! H  o1 y% X

- }& @; c! x7 Q0 ~* ~, h  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。- n* m& s0 o* ]
' {! Q' E( t! U
二、基于物理的动画
  Z9 c  w" x' D3 {5 z3 B( A& t2 h9 l3 J2 h$ j
  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
) U+ o$ t! |2 j( L
- {% |0 I9 Z9 D2 p. M4 E三、基于GPU的蒙皮* ]5 I3 z% g4 z. T+ F: h: L
7 M9 G4 L' |: P3 H
  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。8 S4 k8 f* d" Z2 I$ i/ O
/ M- ^7 ?% g# L: Q
四、非常流行的“换装”系统
  @- s% \( S; |* a1 m6 ~2 G2 ?3 X" b  v- y% ~
  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2026-10-2 09:50 , Processed in 0.017996 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2026 Discuz! Team.

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