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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。% W% ^! L# H1 i2 G( r" k5 s

; J7 i9 D. z$ x& B: a在这篇日志里面,你可以获得这些信息:
/ v$ c% ~1 `2 t' n! M& A
; R: x' r; g* w1 人物动画的框架- y3 N0 u" h- N% t- x
2 骨骼动画及蒙皮技术. U, _/ T/ G4 G
3 doom 3和quake 4中模型和动画格式md5及原理
. q5 `6 W8 I8 a2 ], H4 可能的扩展
. l$ a! Y  U! K  C: D
# ]# |: |0 f5 I1 Z
# R3 C, N, z: T  P1 Z& w% }先来看一下人物动画的几种方法:
7 Q6 e( ~% M% B7 z' u! p0 X# {
- t5 @8 V  u8 O# v. k7 ?+ H0 s+ {$ g# r一、简单关键祯的动画3 }9 ^" X5 L- [" j2 I; W9 U, W; h
: d/ `' M* u& F7 g  a
  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。9 _8 x  t; d* |& }

+ W# Q3 w0 ?& i二、简单的骨骼动画及蒙皮技术
2 I% {+ S# }' ~0 q0 x! J( @; U% a% f) Y) D( [
  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。4 P8 _) T- d$ ^# q

0 V. h  P' m/ l3 d7 m% n5 B/ z三、改进的蒙皮方法和基于物理的骨骼动画# d  \! B: r! N% {. f+ M4 d/ ?, \

3 }' ]" [  ~+ V- ^, q4 Z9 K. k) j4 X  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;* T4 @, n" X1 a5 q; k" C9 u
$ f8 c4 @1 R2 \2 Z) j
  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
7 |% E% h+ I3 q' ~* C; f2 {9 m
" e$ p, v/ z: H$ o. |/ i0 B8 G
  G5 k- X% t! j: I
/ o8 ]* d9 E8 @& J  g2 z基本的蒙皮原理
* S7 s; J) P1 d. R$ k# E- @/ D3 j" {' R0 ~6 e; e4 m8 H8 ^1 Y0 x5 {
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
# U! k5 Q( G9 P* Y! y9 A1 |- V5 _& ]- U$ _
Joint: 用来记录骨骼的关节的信息;) |! M+ L' m4 ~7 ], P: ]/ @
Weight: 用来记录顶点相对于关节的权值;! A& U. p* n7 v1 K- K# C
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;5 l3 c1 e. d1 v9 \

! E9 W: i# W& n. V& P6 h( r现在就来解释一下这三者之间的关系:
2 {8 e5 f: X) E$ T; a5 f- x4 s6 F( h. R
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。( F4 }9 Q  ?! C+ N

# P. b. [9 O* }2 G1 i) g, g3 S" K  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
5 b  H& y2 |1 t
! c7 F/ G$ z/ k2 Z  了解这些基本的概念,下面就来介绍人物动画系统的框架。0 s2 x4 i0 M0 E, w

+ z/ P1 K# T/ A8 {& l' p* C+ h; T9 f) C( q& E+ d
骨骼蒙皮基本框架9 ~8 p7 v, z: m& P7 p9 R7 j

- e2 T$ x/ n& y8 S8 ]0 m基本的类型:5 _, W6 [2 w. u
7 M  V7 y( [/ Y- @
关节信息:
4 D  T) U0 v6 e1 W! _. _9 |2 a# U0 {% I. a
typedef struct _CharJoint
$ `. e6 J# @. z{
, m' V0 R2 U1 O5 C! X+ {* j    Vector3 pos;% a4 [2 H9 h: Q4 l
   Vector4 startPoint;) w# K6 f, I& C% V% Z! D; |! P
   int parentID;
1 \3 n4 ]7 V7 a  a. e" o    char name[32];
/ G0 K' h: [) q$ A. b3 a} CharJoint;
+ S# m; I7 ~: ~5 j% W7 n: Q: Y2 p9 g) F! ~3 n" R' p/ w2 {
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;/ X" d, a' \" T: D  L- U$ I
) Q+ ^, n! E* S0 P
权值信息:8 g  v: Z/ l7 U2 H$ R- L

  R% U. T9 [$ I% Htypedef struct _CharWeight: S" t5 P: i3 a5 {
{
. G' Y; T% F4 m7 |    Vector3 pos;& o3 |6 e2 e1 k" Z$ L
   int jointID;2 m/ a2 q. c: ?& L8 k
   float bias;9 X9 D: g' ~, I( L, f
} CharWeight;
0 T! M! x' G) S6 ^5 e8 R$ o. @9 b3 X, `0 Z0 x
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
8 o- J. t1 L; X$ U0 \; V1 F+ N* V3 s" o/ w$ F# r' o3 `& U
! b: _6 g4 x! U, ?  T/ @
顶点信息:1 F7 R# A, J0 O! g. x' L

( X# V& p  L1 o. A+ q5 }' ^$ t; }! |typedef struct _CharVert+ G3 J+ {+ x, K6 C0 {; u
{
& T0 D8 Z1 _+ x* R. ?# O    float u, v;& Z8 f  x( ]: f8 _* P5 V
   int startWeight;
* j% y9 K, J+ t% o, u3 \    int weightCount;5 P/ G" P6 D/ s
} CharVert;$ Q' }* m" i2 k" W; F" U; `" ^
5 t, R$ ?7 u6 z9 A
  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。7 V  `- V' T- R- q8 J, I
0 ?2 z$ b  f' l' E8 _6 |! l

4 F# u3 C& I1 v" L大概还涉及到这样一些类:! S" A: B  Q6 R6 t, k- C4 D  c

4 L5 c* L7 Q, t' UCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;5 L, I: b1 l: K' ?
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;4 n/ d( b% M; S* ]. V
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);7 m/ `9 v0 @3 R
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
  G# e" }! b2 h( UCharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);) [' H- k" p* B9 `7 z2 z

  v7 l( t% E' z4 H( W! [0 W/ ^. C
解决关键问题
& u# |2 P  b- v& t8 x0 `  Y  k) k2 k" W* r) M* @$ Y
  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。+ V8 k# G5 b9 [$ j& r& @! P

# U7 M, J  f! W0 C# @  E8 L关键祯混合:
0 T1 a, S6 p' u$ B- i& H& L: Y
+ u" \" ^* ?+ J4 |  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);" P7 J2 b1 [  R7 v& h) y5 Q& [4 n
/ c6 I6 x. J$ J0 T  c
  际应用中还有其他的混合形式,后面再来介绍。
% N2 n' A% @9 h) n+ z
# }- W1 h) c; D& N8 q* ~" |' V- d; ?4 `) e& f9 D5 n
计算实际顶点:
( S) y1 O4 {: c2 {, o
7 z: s% _; _5 e% U& u& x: c  U7 P我们看一下软件的(用CPU做蒙皮)Blend过程:1 t* B: ^& ^- K: }& y

, l; m3 Y$ c* c9 h5 e' N+ N5 r8 hvoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )% J7 Q) w# Q9 `4 {) v* _" T
{7 Q3 R, q4 `1 `" {. w
   if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )( V" K4 w' V4 e2 Z
       return;# o# s* }8 _; S. o
0 a0 E$ y' P8 K2 ~8 U8 T
   CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
8 V$ u9 t% I4 y. h6 d) `# [    int numVerts = inputMesh.GetNumVerts();
, m) Z% M7 C4 I9 w; n" U! \  ~: g    int numTris = inputMesh.GetNumTris();! L* o- K3 N# P' _/ B! S7 c
+ |  V4 I' [( B* Q8 s! }$ c
   for ( int i = 0; i < numVerts; i++ )
" e" a- [* P0 O2 X0 d    {+ O+ O$ |! w! ~% G% V
       const CharVert *pVert = inputMesh.GetVertAt( i );
9 X) M5 q# ]0 h        pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
- P) e' U2 X: x& K( e. k0 I1 n* |4 I' j
       /* u v initial */5 e) j( Y5 |. F6 n& B& s. O
       pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;2 r* F4 c+ H1 f; H) `4 q) R& b5 }1 e
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
% k0 G) L% y+ Y- f6 T. D1 O5 S
) {" q/ ^! B9 _3 s4 c) b        for ( int j = 0; j < pVert->weightCount; j++ )
; v7 h. N' _- f0 ?+ k+ W  C' t        {
. T1 i0 @3 u" R' T/ ^            const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );5 H; q1 _0 e3 O+ n' P; i; T
           int index = pWeight->jointID;
+ A  C2 _. O' V; s+ W6 [5 x            const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
/ s. \/ m& ?3 }: F; R2 l5 x            vec3_t wv;3 ^6 j. h% A% O
           Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );: E( o' U$ ~& `/ B5 R, |- z
           pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
3 C! Q) H& \6 T" l            pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
8 i8 u, V# ?  c$ l3 a            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
4 f: M1 Z: y$ c- Y8 r        }
+ J- G6 k$ [4 }+ o! H9 s% |5 f. y    }
1 K4 Q. W' I/ v3 Z6 u' t* G& c6 q. S' V% c: ~+ H, x( b
   outputMesh.UnlockVertexBuffer();
: {( f# Y& W! b- ]& y7 q
, Z+ }* V3 i. O( n$ z: Z5 Z" U    CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();9 Y1 {7 l. z) r$ a) q9 C

3 M( h5 [- w& a7 s- x    for ( int i = 0; i < numTris; i++ )1 i4 j" ]" T) n% \7 P$ j
   {3 Z$ D: T; [( U& q
       const CharTri *pTri = inputMesh.GetTriAt( i );
2 D" {* n" N+ ^, h- x        pOutTri->index[0] = pTri->index[0];6 U/ k0 S# P& x" O7 W2 l
       pOutTri->index[1] = pTri->index[1];; \6 ^# x  J/ `
       pOutTri->index[2] = pTri->index[2];1 a" O, B6 @5 M# J
   }
5 E, Z4 }" V/ x+ c% r1 Z; k3 q& L+ V7 Q+ r5 W5 W8 O
   outputMesh.UnlockIndexBuffer();
" ^  b9 f& \7 z}
- E6 O1 H1 I9 A: S
& v' q. u/ _1 `. _5 T其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。$ J: A( p& f; X! E
3 C4 v" d& ]' k2 r" C

1 c1 N. u9 z7 J' d关于md5anim文件; I; M' y; K: z$ T+ T' R
! F6 S; `' d- g
  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
- K. @& q. a8 A. @9 E/ Z/ u6 L. V4 P" e2 v8 r7 @

' k* g' O7 [9 S" y可能的扩展
0 x: G* l% j/ f* S7 M. }& c3 i6 ]9 l: E, r' R* ]( D8 i  R
一、复杂动作的混合# n$ ?. U0 F; ~: i0 x( a
$ k% I( _# W" u# X
  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。: D% ~0 N9 K* C

. h/ D  @% w  n2 ^) Y4 d$ s5 ]二、基于物理的动画1 X& r( [* l' b, K2 t& t
, V. R1 ?, |% Z; h% {, z. {- ]
  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;2 m+ A; d5 Q  F. G. N! x% k
& B, b6 ^* a7 `, k5 A
三、基于GPU的蒙皮, g4 s4 H) Z# Y) a. l+ O

: M! _8 Y. r* T5 k2 t, m2 W/ o  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。7 @) p- `8 G' a7 H- [- Z. @0 ^
9 f! _0 Q1 O- }( G! e* o% p5 R3 D* e
四、非常流行的“换装”系统
+ t! A( f+ v  X; Z; r
% `1 P! `, V6 Z! i: z, O2 P  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2025-6-19 14:30 , Processed in 0.016520 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

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