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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。4 S' G7 a7 V+ o& Q8 o! o

. m& O. Y  {, l0 e& B/ ~在这篇日志里面,你可以获得这些信息:# ?' T: B$ e$ y* G

% t  E! u. C3 Y, j& Y* r1 人物动画的框架
* o% p+ X4 u2 Q7 P) j) y6 h8 ]/ ?2 M7 Z% o2 骨骼动画及蒙皮技术
4 x/ A9 @$ {* l, Z( v3 doom 3和quake 4中模型和动画格式md5及原理8 N* F+ |: V+ t4 r
4 可能的扩展
  Q  N6 ^( ^6 c! N
8 G0 I3 ~0 m/ a. _% d% s8 A, g7 U, E7 i# [1 V+ m* B, z
先来看一下人物动画的几种方法:, l- A# |" D) s; u, L1 m
+ E; @' |3 J3 r/ F2 Z& D4 D
一、简单关键祯的动画! U+ q% o$ f+ e( o& S  w! t

9 C2 G2 A" A2 F+ m# u  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。
; f* h0 z7 a' b: D& d4 R, g7 H( U/ q* H5 |# ]4 C
二、简单的骨骼动画及蒙皮技术; {1 q! n# c8 X8 I* a* w0 Z
6 u- }( j. b; f! T
  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
  X5 w& c& s* R; h' ?1 Y
) s8 a: J% j$ l. _* j  G. h  |三、改进的蒙皮方法和基于物理的骨骼动画; h1 c% B* z+ a& f5 G" H6 A

8 \# \! R0 A2 }5 M' b  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;1 r+ h' D6 x& v% |, i4 l" M

8 Y: P* L# K& y& j. ^  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
" A5 V) D' v7 C# M6 ?% a
% u2 s1 J  d6 k
" [7 x0 Z6 t+ g
% _1 W. k- s4 {$ r8 \0 E8 s, ~; ?基本的蒙皮原理
3 ~, q% ?8 B) @: D0 A1 B' Z( ^4 t" K
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
% a9 X5 D& l' E" [% g4 \1 v" _
# J$ a8 j! X# }Joint: 用来记录骨骼的关节的信息;4 w2 d0 ~% s; a" F6 d
Weight: 用来记录顶点相对于关节的权值;) I. ?& J7 w2 H- A3 K% K; h- K# L
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
# f2 D7 [7 a, u: ^: ~5 L/ x3 K; Z2 z9 g0 E, J8 ?
现在就来解释一下这三者之间的关系:9 O* T, B. `% i3 r' {8 @
7 U5 z; {! Y4 E/ s
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
. c8 ?- ]) x% |  H, n$ F5 N/ V
7 k5 j/ m. K8 K1 M  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
* }% w/ f" r+ _/ X
/ ]' G. e# p0 a2 ?# F+ v* Z  a  了解这些基本的概念,下面就来介绍人物动画系统的框架。
7 ~0 t! r/ S: B) F3 C: y# m3 o# C$ A: ^& i9 K7 h7 w7 j

, R; D  `1 N# k1 X2 m骨骼蒙皮基本框架
; r" ~5 P7 ^: |/ |( y. B2 _
) q4 z. u% w; X! s. M基本的类型:
# o+ _( t5 j6 k1 r% M0 e6 F0 s2 a$ A( j9 z8 u
关节信息:  P5 j2 p0 R7 V% o! e1 u3 M2 M
/ }2 f$ m! v7 i# F8 K# P
typedef struct _CharJoint- p$ M) U7 m! S# Z' A, D
{
8 X+ |9 ?3 s; b+ r    Vector3 pos;5 P' W2 C6 c$ |1 H
   Vector4 startPoint;2 e5 w3 i7 ~8 ?5 n
   int parentID;
+ j* k' d% e; V2 X    char name[32];& O. R3 m% Q1 v3 N$ [3 O/ Z
} CharJoint;+ R8 I) }( Y) E* E9 ~
4 z* k$ n6 J  j2 E
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;) }5 _6 d9 u! X" c; e6 [' [

/ [. L! ]' L! l8 P权值信息:
9 y) H2 \1 j2 D% A! O
' j: N  T# m0 r/ |1 J% c. C7 ltypedef struct _CharWeight
; m7 Y- ^" f4 _; {/ l+ z1 ^{" V2 t: N8 ~- A/ r0 U2 U
   Vector3 pos;
, ^) ^, T) y, J- @* B    int jointID;( O, J# I& _3 j8 R- W* ^
   float bias;
  C  P: `" E1 {/ F/ N3 P} CharWeight;
! D# x+ o* J0 ^6 \# [  N5 R) j
- d3 [% A) _9 t* R其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
4 B7 i) Y5 `& u  `) [- Y2 k/ G5 B4 _
$ G) Y1 d4 f+ }7 g% u- P
顶点信息:
$ V: J% I% X2 \9 C$ q' e) N
3 F+ i2 `' A' |8 l+ D. ctypedef struct _CharVert
! `' ?8 j8 N7 S( x6 a% f{% \3 `' Z" u7 `
   float u, v;( n2 l' W" A1 @7 Z$ D: I& L+ p# E
   int startWeight;
; `+ H2 k5 h  f& }" a8 z2 w  X    int weightCount;
" V5 w/ e4 v9 @' c$ F3 q9 z+ z( j} CharVert;& C; S8 o$ }' T4 p. D3 B/ J
( P% G, e$ Z* G
  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。
7 K: r) {, w2 v8 h7 y0 D* `0 Y! i! u9 b6 K& x
/ j; T, k+ m- e& c8 C
大概还涉及到这样一些类:6 v2 _3 u& I3 W

( S0 P& O, l' T% JCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;# ?' D; D$ o1 k9 w- k7 H
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
7 q7 A8 m; U# ^* g4 C$ x, JCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);8 |# F8 ]' |8 b8 j4 h- W+ v
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;) n* l! ]" @4 I3 G
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
2 X* r# `" z8 t5 \, V$ \8 A0 j5 e7 b( W5 k' x5 L

2 S% w1 a& f1 s. K% O% N解决关键问题" l. J  ~; r: B

( X  G& l& P" Z3 r# `1 b  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
" w  U6 g* y$ O, u5 `2 k" h
( C4 O$ g8 m) Q关键祯混合:) r9 w7 W+ S5 A8 X5 u
( N8 h6 F0 c0 P. [9 n7 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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);( ^1 N  ], T; f( K
' n. d5 W: k' p/ B5 w, T
  际应用中还有其他的混合形式,后面再来介绍。5 Q' W. C$ k/ @1 _

, [4 F% [$ z* T, a3 R  l! f) Q5 j# g5 x9 J- q
计算实际顶点:
: D$ q& s1 s6 x3 K; S1 m0 H  X5 k$ L: U; V8 U) r
我们看一下软件的(用CPU做蒙皮)Blend过程:  M5 [6 W9 H5 Z/ w6 k! T
" A" K, m$ l+ u/ [: {
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )! ]( I# _' p6 f: }1 w
{" E4 ~% i2 j/ _1 t. H+ ^, O3 [6 d
   if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )2 T& N1 w; i  ^1 E- U
       return;+ y& S: V. G- j6 e
( w6 D3 U3 J# w4 ~
   CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();2 e' _4 x* A  n- B& t- a
   int numVerts = inputMesh.GetNumVerts();
2 R  c1 M: j7 c4 Y    int numTris = inputMesh.GetNumTris();
0 A" a, ~! p' {& N0 T1 ^- Z
) H# D3 S" S3 @& ^' w0 B4 B1 N    for ( int i = 0; i < numVerts; i++ )
, }9 c5 c/ G9 G( m% t# S+ r# @3 b    {
6 {5 d) O. Y2 V+ c        const CharVert *pVert = inputMesh.GetVertAt( i );! ]2 `3 F" T9 n( n
       pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
( R% u. }4 s; ~2 f0 D9 s4 S8 N8 _+ d$ T$ L
       /* u v initial */1 |: `$ @" t$ g- u9 t1 }
       pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;# W( X# k/ }/ a. i# l; z" d& @
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;( Z$ D: i2 w6 x1 z
% v5 [4 M; D3 k6 O0 |
       for ( int j = 0; j < pVert->weightCount; j++ )
6 \5 i; t: [+ h+ X) m. S        {
* @) J( K! V! \# ~, P            const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
% x# {6 v3 h8 D" M% z2 L' p            int index = pWeight->jointID;! W: v+ \7 g7 a0 O% n
           const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );  l' ^; M+ u9 I" X" h3 k7 O
           vec3_t wv;* B1 z( Y# v# u) l& @$ m5 l& K( Q% s
           Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
/ f& t0 z& p, q5 d1 I            pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;3 N* E& W1 k$ m6 M; l
           pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
, @; d( h. E1 C, i. o/ |            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
0 v2 x; o+ N/ g# o; B+ J2 a        }
) d% y3 G7 t) p2 x    }0 W+ h( Q7 D8 G0 S+ }6 v; E# x
  a9 \% ?& \' H8 O. E
   outputMesh.UnlockVertexBuffer();
4 X8 H2 f+ P. T$ X% }8 U3 E7 G/ ~3 O) Y
   CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();6 m& c9 H" s- p

' w+ z' ^1 Q+ K7 R" `; B    for ( int i = 0; i < numTris; i++ )2 v, j2 Q; }9 _. M- i) I
   {) x& R: F7 D5 P5 c
       const CharTri *pTri = inputMesh.GetTriAt( i );+ c3 J( ?9 L' w9 h
       pOutTri->index[0] = pTri->index[0];! n! Z3 t+ u/ }3 Y
       pOutTri->index[1] = pTri->index[1];
) }+ }9 r# \- r$ G# v        pOutTri->index[2] = pTri->index[2];
3 j9 H% k: G) D3 n% s3 l* A* ^    }
, f, o) z% R- B/ g3 r- U* t; U% J4 J/ W9 r1 T9 |
   outputMesh.UnlockIndexBuffer();
6 L7 R  z  e% x# ]  J6 u1 n}
. b) M# B( x7 k- T! D+ e
: j- Z3 A$ O; _# E其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。2 f" k& E* p% A4 K  ~' G; w
& N. B' N2 |' y0 \% y# ]' M, W2 G

8 a. M9 f/ o. \0 P2 s4 B: W关于md5anim文件
1 z: U" P$ c0 w
# R+ w% e6 x% s+ s: b+ q  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。4 u! l! r9 o% _# b

! h3 a, w$ {- p* o" e. l9 j) E, E. P: Q5 r- F! B' E
可能的扩展
8 i5 \% j- U; H' t$ x( {- ]( A, U2 H" q* R9 h$ g
一、复杂动作的混合
8 Z8 X/ z; x$ b7 t% U) N8 I2 O$ R) k0 ^$ k4 `
  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
9 f* ~+ z8 A- d/ L2 ]: e0 N7 C: h& L: L7 u
二、基于物理的动画4 ?/ j3 |+ V8 R

6 ?- L* G3 W7 c, M* ?1 M$ T1 |- E9 u  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;) U6 p7 E3 @2 |! c  X

  C2 w- I, [* E0 h+ y$ a三、基于GPU的蒙皮
4 ^- y4 P6 p+ I& Z
8 b9 U0 T) y3 Y  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
7 O/ u' B+ A, _9 a1 ]# h- b4 ?2 Q# K0 H
四、非常流行的“换装”系统% {5 Y. ~% Z$ I, p6 _0 M2 l4 L  M$ T

: F( h0 _. [0 g0 r4 r/ V8 s  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2025-11-14 23:43 , Processed in 0.020018 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

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