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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。- U& c/ b0 h$ y0 \

. Y- y% N) I$ k1 z  C' L: r7 c在这篇日志里面,你可以获得这些信息:
- E* N6 P$ }% v& N7 e
) K/ \* ?3 h& r6 W1 v1 人物动画的框架
' m8 H1 s0 N/ N: F# M( m0 u2 骨骼动画及蒙皮技术2 x9 i  K1 @2 F8 W4 J# x3 A: H
3 doom 3和quake 4中模型和动画格式md5及原理" f  d, s, G6 n
4 可能的扩展; v% m7 I7 o$ ]: X% e. g

8 l+ ^) P) K* ]+ i  t4 V
% ~6 }! Z% b' @9 w先来看一下人物动画的几种方法:
- G* }" [8 p" g, S6 _
8 T+ `8 `5 Y' r6 |. T+ k3 d. ^一、简单关键祯的动画6 j# p4 \  {3 B3 H

) \: I: K  }! p9 S: u0 Y  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。" N- m0 N! G. O
5 n8 y; Z4 O- |- B1 d
二、简单的骨骼动画及蒙皮技术
9 r4 u# h4 o: p
( J5 k+ p) Q$ {6 d4 v  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
7 I# {9 e% ~9 B1 Z% u7 F8 W. V: `* e; C6 {5 y" u
三、改进的蒙皮方法和基于物理的骨骼动画
2 X% W7 F- v$ S* L1 t$ f" l9 D3 z! y& X: j2 B
  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
0 Z7 ~2 h) A+ s) Q
8 B7 y  ~$ v# I5 Q6 p  n  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。5 r: b0 p" i+ P6 s

# D* e' p2 D1 a; m/ q- c
4 H3 X1 K. ^( G% _* b8 r6 E2 d- }
. F- \  @# a0 T/ v基本的蒙皮原理
& q$ t! V( O* @# R* C+ P% ~$ m' N; x# o8 c
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:  r/ x: U7 M' e" I8 H

* [4 |- h7 I# ~: G0 t$ JJoint: 用来记录骨骼的关节的信息;
% T; d6 w* W3 y; V" F1 P' dWeight: 用来记录顶点相对于关节的权值;
( c0 b$ {" t  J2 h- e2 g1 i$ lVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
# p' A- U6 p3 [  L! y
% v/ i% B5 j7 _现在就来解释一下这三者之间的关系:
+ D! h* @( W: w5 A4 u- M4 R: ]/ A$ F4 m
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
$ j* z, }) ?3 T( K$ x, v8 b1 |! A% v2 m4 k0 u! B/ _
  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
: K" b% P. a: q3 R
  G  r: m) {: m* d' n% z  了解这些基本的概念,下面就来介绍人物动画系统的框架。* G. H/ Z0 d5 I
, l% |- B' Y; ]

: t1 `) f4 G, q. s9 v骨骼蒙皮基本框架
" n9 C3 ~$ Z1 V' C
2 Y3 C8 P3 X8 a" o0 p: h8 f; G基本的类型:
# |' j- m7 }: [$ m
7 n, P; c$ [6 y  |! \3 `关节信息:
2 ~" T3 Z3 K' H, N8 I0 `
8 E" G# h) p8 o. \* rtypedef struct _CharJoint+ H# @& j" `& Y& o# |
{9 T( Y. C5 \9 ^  P5 C. ?0 z
   Vector3 pos;
$ z5 P, n$ Q" C    Vector4 startPoint;( R: y; L: E7 M  H, ~+ g$ y
   int parentID;, m% [; s/ y! X0 Q" Q6 G0 ^5 k+ G# r
   char name[32];
& u( C& J) e! V3 {2 Y+ F2 a} CharJoint;
" q5 {" d' B' @) q+ _& t
5 N! y7 v0 ^2 j. Z! V0 p: E其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;, K- q" K; m: p9 B9 a
) t4 }# h* S0 M# `: A
权值信息:; I+ J- J8 Y! Q8 W
0 P$ E) w" j7 [" I7 b
typedef struct _CharWeight4 i( ^( D# {4 }4 W% ^8 H
{
2 F7 z9 b& M+ m8 Y; S- I5 }    Vector3 pos;
% i# E5 k7 _3 a' I2 M& x9 h    int jointID;
! V" D2 _+ }, r3 s, h; V    float bias;
$ b: [; W, \0 n5 P} CharWeight;
! ^: X4 ^6 l; L5 u. [
3 |& F3 f9 S( j0 ?' s6 q# Z其中,pos为偏移量,jiontID为对应的joint,bias偏向值;6 x  M1 ?! {9 I

2 Z) Q1 d) @: i' X; r7 B3 V/ \# k8 j6 s1 l) r
顶点信息:
/ P2 U+ Q2 q+ ~* ]
! G6 U% l" F5 S& Wtypedef struct _CharVert, v" F* R; e$ H& ?/ {& Z( F
{
( I7 I$ V5 D7 B$ \1 }+ |    float u, v;! ~! j  \8 R+ Z7 M- G, ~
   int startWeight;! R- u! {! m! u+ q( S
   int weightCount;
& [2 \  T, l! i' T' F} CharVert;
" @$ Y! R, N: l0 H$ R7 Z) L, o
) H, Z% @  s) k  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。# L+ c4 c" T6 C9 O; F

7 u' O" s: L& `8 i) f6 x) n! o. h+ C: c5 f
大概还涉及到这样一些类:
7 {1 b7 f+ [* ?. z; ?9 ]/ u: I$ @$ u
% ?! f8 _% l) Z4 D3 \, vCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
% f2 G9 T* J, i6 CCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
5 ~$ V! ~- i2 SCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);' T- h+ E; p7 R- Y. w  z6 m% b4 Z- ~
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;1 m3 ~  z3 p- O' d
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
3 S9 a  J" }# {. M0 M( k. E, b( \
$ E3 D) s: f# ~; H
解决关键问题
. \) k2 t' V% o# E6 r$ w/ y2 M# g- p7 s# \: h
  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。' A4 X. g2 Q' G; Q* ~( x6 `/ B
; s) V. K9 B9 a; {
关键祯混合:
/ Q" j( y7 T6 m) u5 G* c: q% u; j' G  c9 d! G2 n: B' F
  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
: t' m2 r& ^% u' D1 o% g3 Q6 ?8 W9 H/ f/ A8 t
  际应用中还有其他的混合形式,后面再来介绍。
9 G% k9 q/ m5 C/ F$ U3 E8 t& a% W! k* n6 m7 x% ]7 y
! c$ w/ m# w( H/ w- p  j* e
计算实际顶点:8 X" g. i" H5 l# F2 |5 b' x& T

+ h" T% D3 U" i7 W我们看一下软件的(用CPU做蒙皮)Blend过程:
4 i1 ^# W/ C9 H9 g3 k. L8 [- b7 Q
: L. Z2 X; R$ d6 C0 Rvoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )% @' L( m7 Y; p- X: U1 T9 o, {
{
# N( X; M! x1 C9 S$ L, W    if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
/ g( J, `) z  S; h) n' W' A0 R* ]        return;
4 @0 X1 Y% d+ S9 h' I/ N6 }0 ]) u$ m; y# i1 V- C+ p. O
   CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();% U1 C7 b+ l3 j0 c1 ]
   int numVerts = inputMesh.GetNumVerts();
# |/ ?+ S7 v! u5 C; ~/ x    int numTris = inputMesh.GetNumTris();& r& h% E% G+ h% s0 r/ ?, I

8 z4 e; U2 `  R2 v3 T% C    for ( int i = 0; i < numVerts; i++ )$ O4 v  w1 F/ ?/ d, y& _- C, a. T
   {+ V1 D  o# h" z/ B  S
       const CharVert *pVert = inputMesh.GetVertAt( i );! P5 c+ O: e8 G1 ?- a
       pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
. l2 S8 T5 s$ |0 t
; ^- @- Q3 _7 S. ?+ Y3 w! [        /* u v initial */, p7 x) L' ?. m* N
       pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;; E2 R) n4 P: n. V0 I# k% [; r
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
9 z" K4 ~5 D0 ~+ ~2 m6 C" V7 S, K, f, n: ?/ `$ T
       for ( int j = 0; j < pVert->weightCount; j++ ); k0 C) \; B( |2 e! W
       {: g  E' w/ X3 ?3 K7 g
           const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );6 c8 ]. ~( Q% a, U
           int index = pWeight->jointID;
1 [0 r# o3 O/ V4 M0 G            const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
- x9 n0 o0 `) |% H            vec3_t wv;. m) z$ q! R2 U" i+ _7 ~  Y& F
           Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );! i3 F) [1 A0 c
           pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
1 V5 V! T; Q1 U9 N1 t9 l) }            pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
, _8 l9 T* l% A0 H. Z            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;7 o+ _. `& O# A0 ]& w$ ~
       }
, g' m" o/ f6 d  J; i( t    }
, C. r* N/ I& X) O5 p4 E
. D! n1 [) P) v1 I0 l+ _& p* x    outputMesh.UnlockVertexBuffer();
, t3 C- l% C6 R3 y
! _. t  s/ g0 L/ b4 P  p    CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();' Y  x" A! U: H6 E# Z, v+ b

' Y& m' I% a' r- c& R    for ( int i = 0; i < numTris; i++ )% J$ g/ [, T1 O4 t
   {
7 G" H, d% ^. {1 U* ?/ i        const CharTri *pTri = inputMesh.GetTriAt( i );
& y' ^: d. t# u" |( _9 q! N" f2 o( ~8 w        pOutTri->index[0] = pTri->index[0];
6 \1 {( m* C2 H* _, X" |2 H        pOutTri->index[1] = pTri->index[1];) D! |* m2 h# P& @' ?6 w
       pOutTri->index[2] = pTri->index[2];
6 N3 \6 j% Y8 l2 L/ }% _) U1 c) ^    }
! H3 e* H0 Q% z( A3 q4 S& n/ o& X, _9 m4 }% y: v
   outputMesh.UnlockIndexBuffer();8 R+ V/ l8 H- @# S; `3 j+ A# _4 c
}
* N/ m( q" @5 H
: F- y* `1 ^9 i/ m+ ]其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。. K# {0 I( T3 E4 a# e

2 O9 W8 g' p1 T$ x# ^% C$ R: u* ~
' R1 Z, S1 |. ^3 w! J# ]关于md5anim文件6 a& T% }% E# D

" y1 A# e& \- R6 b0 d  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。/ x2 b- T9 L! X% i/ z: M) {4 y

! n9 t, J. |5 u$ @" q# s% d$ p" w7 c) d7 ~! J
可能的扩展2 V9 Z2 N" \3 M7 [  z

1 [4 ~/ }9 l) @$ y. q( k! I一、复杂动作的混合" @7 w  Y- q7 Q5 H
' w+ \# \" I5 M2 Z1 G7 R
  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。" x" m5 @8 q- z1 B

9 q- C% `$ t* f" H6 W二、基于物理的动画
! s+ [; z: t0 W
, i1 b* W- [) ]$ ~8 E% L& k/ }  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
6 ]- |3 ~' w1 U8 r) }' k; k: |1 t+ i: @* Q
三、基于GPU的蒙皮
1 j: @, r( p+ I/ v0 b! E* W) p: h6 ^5 q* S; w6 B' q# f; {4 A2 ~
  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
8 @! N! c0 X$ P7 u5 a; G3 X; {/ t6 o
四、非常流行的“换装”系统
' t0 M9 }" J$ k; m7 t- \3 [
- g( ^3 t. e' A+ }& k* @5 D' V  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2026-8-13 21:29 , Processed in 0.017668 second(s), 14 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

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