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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
3 L+ @+ _% J4 a# u+ u8 X, T' \$ A4 r( p6 `. d" f' w( x
在这篇日志里面,你可以获得这些信息:7 i* v3 t/ J2 {; `/ x5 E
7 e' }$ D: Z/ }# |# y
1 人物动画的框架' x" v! Q' L0 W" X4 z
2 骨骼动画及蒙皮技术8 H! X+ ]  P: q8 V
3 doom 3和quake 4中模型和动画格式md5及原理2 a2 N+ Q9 D( c1 s3 ^( e5 |
4 可能的扩展
# G; B6 I% s' N* A7 e. A9 J
( o, L: a# Y$ }( L/ i4 v1 w* i
* O! J9 ?' y( p- a* g先来看一下人物动画的几种方法:) @/ T9 J% K, U/ C2 u

$ J% Z. a7 v* I+ v" M一、简单关键祯的动画
: j/ Z: ?( k  k) \3 c
0 A6 {# J9 M5 T  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。
* g5 Q( \# K! J0 s) z" g3 ^! ^% q4 J" t9 j! i' C
二、简单的骨骼动画及蒙皮技术
5 p$ X7 X8 M3 ]8 X5 X. a' @- s/ z- O  g9 w  u9 Y0 a
  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
& ^6 k7 B& T% r' @/ Q7 i5 P& [1 _% F+ ~
三、改进的蒙皮方法和基于物理的骨骼动画/ e6 D: w: n; `  i1 b7 t& m
6 E; ]5 Q, Q6 x6 I* d) Z3 D" |
  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;" D9 S% y8 R2 T3 Z9 {6 v0 G

7 u) [2 v7 F8 K# q  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
$ l: z& k% ~7 s. N
; F  j7 t6 u% b) ]) J; g
4 B  v1 K$ B' r5 \  Q
3 m/ z5 ^" B, e( f' s4 \基本的蒙皮原理
9 n; {. ?; w' s6 A2 _- [% D# J0 \# }6 A* M4 O
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:! _- ]+ `2 G! Z7 I' R+ e: S
& J/ d% Y" `5 V' q# k) ]( |
Joint: 用来记录骨骼的关节的信息;
4 d9 e! y% j8 l: RWeight: 用来记录顶点相对于关节的权值;6 A7 s! W  P/ Y$ x3 \( l! r
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
' R0 W) U4 `" j) [$ ]  E0 ?  ?4 v4 D4 \
现在就来解释一下这三者之间的关系:
+ C0 ?, [0 G0 j. n& c2 B; e7 C' H; T8 U/ m' E
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
! F) _; y2 Y; f
) j0 g3 {7 V" z0 J/ W  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。2 K$ R, C- [! T3 x$ G5 d2 }
( F, U1 X; ~0 c. ~8 Q" y
  了解这些基本的概念,下面就来介绍人物动画系统的框架。2 M" }( g% j4 {& v3 l

  g/ {& i7 S" e" f# b
& J( q$ j9 N$ Z% O+ B) ~骨骼蒙皮基本框架
+ W$ ~( ?5 `+ V  x. w2 r& x4 e1 f8 D. [
基本的类型:
  ^8 H( Y% e0 d# L  s" E$ _# O5 d4 a, C7 X# X; I6 A/ k
关节信息:
/ E, e" V% v, y8 k: J+ n# M+ Y# m: n# M8 a
typedef struct _CharJoint' d- |/ H. M& w) ~, |2 w% U  s! e
{3 J; f0 V9 m* j) C8 b& r
   Vector3 pos;/ o. w* u* Z+ m+ u$ d! V9 s
   Vector4 startPoint;
8 L4 M0 \* o* ]7 ?( B    int parentID;
% f* e' m* a' o! u    char name[32];
7 B8 h* m4 @) w" |} CharJoint;
$ k7 @) y: v6 J3 \# w/ ^- `4 @
5 ?3 L; P, U* ?其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;; X% r, h, a& \  O6 s- L1 e& h$ e' c, K

+ q$ f6 r6 ~& \, e权值信息:. T; `5 f7 I- g1 p' Y+ L5 R
! {0 F" h9 e, |
typedef struct _CharWeight+ }& I) z; f* x( Q- }
{  q. @: w5 B/ c4 J0 w) K
   Vector3 pos;. n3 R: ]& S4 S: ]% W
   int jointID;5 A& @, {& X2 L! j) Q. E% m) i8 X
   float bias;; f, X; y8 d# f/ Z
} CharWeight;
! q+ ~! e3 C; O6 Y
$ v5 p0 f4 W, c' b4 v. _( @其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
5 u$ F. D" v2 o% ?' }
  j. v) j5 _+ T: P& i) G, I
  W, L4 P' T- D. Q5 c  l顶点信息:
* q- k, Y8 U, G6 W4 C
: a8 t- r4 c2 Q. {2 etypedef struct _CharVert
. V6 X7 x- t0 {# Y{) R  v7 s* Z$ w% A7 Z
   float u, v;* N% x/ x6 y4 ]# d1 l+ R1 C
   int startWeight;
. k6 O: ^% P( j7 F4 H    int weightCount;3 z# X. G  R: h
} CharVert;
9 U2 h$ \: c0 q+ l) E8 G) ]
2 l, _. G8 e' f' d0 s  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。
' Y/ Q! C9 _; o+ |8 Q% o; Q) x; Z6 p1 }

. A; o+ O. X$ T( x大概还涉及到这样一些类:6 O1 F4 M3 [6 ]. b* u0 k& W
# q( ?* J: B+ X9 D
CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
( L: R% v% s" ~$ P4 b" H; {CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
4 D- V' U- d. ?CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
3 \( `+ M  K* nCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;1 p) w5 J. n5 Q0 c
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);3 B* x% m6 m1 S! e
0 J' Q% s# ~, p5 b+ M
4 d. h7 \8 R( x2 V; X$ U5 h
解决关键问题
" j2 o+ m% ?. @) Z+ F0 O# b) d3 o) U7 L+ P) |2 x& m: x0 \
  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
# w$ z- N+ W8 a$ _) w  r$ [$ \7 t
关键祯混合:# `+ X- E5 i+ @7 C) a4 h
; Y, A& f, l. K! M: e2 N
  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
9 k8 r/ N1 Q/ S8 w$ S, w: m7 [5 e4 w6 E
  际应用中还有其他的混合形式,后面再来介绍。
7 X% C" y4 w) {" S5 |/ c2 G8 \
  A5 w! j' t3 r* a- L4 `% _/ b# E0 M2 g9 P# u0 N
计算实际顶点:) _) `8 M8 V1 D) ]! W! V, G

9 Y9 Q* G" \6 c* {3 I1 {* h, x2 f我们看一下软件的(用CPU做蒙皮)Blend过程:
4 v; N# e! ]4 m1 p# z3 \+ U
/ f4 f! A0 x( r: Uvoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
% N5 j4 k) b  O0 A{0 E' L9 J3 a4 x: x+ A% B. _' n
   if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )7 x/ p( W4 [8 z
       return;: N& n) p8 U* ?8 S7 v/ n

. p6 S% }3 G) F/ p    CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();" a! ^" D) G4 ]! Q. J
   int numVerts = inputMesh.GetNumVerts();
4 _+ e% m% q" x. k+ n' n    int numTris = inputMesh.GetNumTris();
+ N* O; a  i0 L2 ^' N$ Z) z0 [
$ C1 I) s) F: y# e    for ( int i = 0; i < numVerts; i++ )8 U# |/ X# f: I4 k- Q/ K
   {0 v" R' Q* N; Q) E4 ^. O1 k5 g
       const CharVert *pVert = inputMesh.GetVertAt( i );4 m/ _# ~" D! X4 ^
       pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;& _9 ]& _' Q$ [( C( b
  \8 B3 j7 \$ x; ~* T% U
       /* u v initial */
4 {+ Q% F+ W) o4 p        pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;/ [2 o" r7 l1 G/ U
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;' J2 h' }5 b6 ?% G4 J# u. [; U

8 e0 a# F0 X2 {0 t: |        for ( int j = 0; j < pVert->weightCount; j++ )
5 ^( n+ j: {0 x6 i  R  `6 }        {
) V& r  k* c" |1 H' Z2 d            const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
- P2 L. j8 A. l& O6 q            int index = pWeight->jointID;
+ d/ R( V+ T7 D            const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );. m4 X5 v  a# Y# L
           vec3_t wv;
8 Q' ~4 C7 [4 y  O8 I            Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );/ @) ]/ z0 g7 `. `( A; |  J
           pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;( z7 c1 `- W3 V; v
           pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
, P, @' _# P/ d: q( y            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
6 q$ z' R  a2 [/ r% n0 U- S6 b8 h        }
) b9 R2 C8 w1 e5 I: B3 w    }: @' A& [- Z% H2 y$ a
+ [' K2 [9 X& j
   outputMesh.UnlockVertexBuffer();
5 G* l  U+ Y$ I0 q! `; x
4 m6 l: C. ^3 m) {! ^    CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();2 r: |) ~! E: u0 u7 d

3 q" }& ?6 d, g    for ( int i = 0; i < numTris; i++ )
- h  S' t+ O  A4 e8 @5 S    {
6 c: @9 n* W- q8 I        const CharTri *pTri = inputMesh.GetTriAt( i );5 U: J4 F& i+ z  L0 V0 f) |; m
       pOutTri->index[0] = pTri->index[0];
# u) W3 Z4 r6 j7 `% R        pOutTri->index[1] = pTri->index[1];
7 }, u; G' e7 |$ F" M& q4 a+ d- x. g        pOutTri->index[2] = pTri->index[2];
- T) M% V8 s9 x" T6 p    }/ b8 e  @$ ]0 N! E0 q+ ~! f/ R" `9 `( `

; F4 V8 r) j1 |" z+ o  D    outputMesh.UnlockIndexBuffer();! V# ^9 n$ z# @, b3 s$ A) ?2 i  O% v
}
# p- O, B+ Y1 I2 j& L9 s
; Z# r; ~/ v: j: X8 p  I0 T6 h其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。7 {' B% M  _. w! \; \" A

5 b; h* x# C3 Q( H" G8 k3 f( j. i) s, P5 h+ B1 _7 f5 N. k
关于md5anim文件, ^- S  w( |1 a- r! B6 p
4 p' J0 _$ z+ r" G# K0 D  Z0 y) n
  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。! X0 @' _+ |( D/ b, t0 I7 N
$ {9 x  _1 L; k3 D; z7 e
2 `! x4 r) O' z+ t1 Y$ h: R
可能的扩展
. e4 t* m8 N5 D  x$ ^$ w' y) O8 B
  A; m, |" q- M" Y9 w) G. d一、复杂动作的混合
* v9 Q$ k2 Z0 y5 W7 W7 G9 M" \3 P* l: U5 u: G6 E* B
  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
/ k5 p2 p5 Z& {3 y# o, W! s! }5 p( ?) _5 l3 I
二、基于物理的动画
! J( F% D; l& z# i: q  K" D& Y/ J- \. o- F7 ?+ ]8 Y" W/ `
  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;: \8 ^! H( V' {! T. D# A
& _( a0 B- J# j
三、基于GPU的蒙皮
. d/ E# y$ N9 O! q8 }; l
$ {% O6 e% `& [3 S2 W! D6 ]+ m  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
5 a: K' }: d1 ]0 J' [) A7 O9 v; P2 O, H0 I- Y1 l& l
四、非常流行的“换装”系统9 w' N' K$ H: a, C- A6 V  W
# C( ^0 j4 f$ |- H8 X
  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2026-6-18 10:21 , Processed in 0.077792 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

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