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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
; I4 w* B! g8 G9 {0 ?1 w% w
/ O. C/ o! N# e4 R* ^在这篇日志里面,你可以获得这些信息:
. ^: d# G$ |/ h( l2 `! P
+ T4 E$ Y+ A) Z1 人物动画的框架
9 z- E: d1 n) T/ I2 骨骼动画及蒙皮技术# C: `7 V" Z( _9 j2 M7 O
3 doom 3和quake 4中模型和动画格式md5及原理
1 }) U  E  a" y- H; n! g4 可能的扩展" [) r0 z7 @( L. x; G# c

/ ]5 R3 k( @1 b3 D- X
9 s! `5 U( f4 b2 l( E先来看一下人物动画的几种方法:
" A& N  s. c+ w% ^9 Z: Q8 N/ u2 r% q
一、简单关键祯的动画
, n  \1 I( c. \" U, r. {, C; [4 H* b6 P! [6 F- J- A0 M
  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。; {# k6 t8 i- u0 c" k1 ^
- h" Q; H5 K2 l
二、简单的骨骼动画及蒙皮技术
3 C( t5 D2 u+ f  u8 v( h2 N
6 I: H1 {% _2 c9 z1 y, T5 X  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。8 m( M& r& W3 b5 g' [2 [, `2 m2 D

  m! T; G3 |1 A" z+ L* j$ R* U三、改进的蒙皮方法和基于物理的骨骼动画
/ r) E+ H, k, t+ S- W1 o# C4 T+ O4 O: B# y$ X. o9 F7 p, [& p
  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
+ m, z7 G! o" o) h( e
5 `; R$ o, k: n% v  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
. \8 o1 q( _7 W; I1 i
) P1 k/ K3 q5 s# a- s5 q
3 `7 w" j2 B# m# P: {2 S
1 ?' K+ U" w, i2 j% {- s' @基本的蒙皮原理$ d0 F' D' n: }+ o' B

/ Q- @* Q- X/ C# F' S* V拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:& m' |) p# @# [+ w# f2 ?9 E( N

+ n$ X0 V; h$ M0 n2 A3 KJoint: 用来记录骨骼的关节的信息;
9 y" u; I* w5 q( ]: MWeight: 用来记录顶点相对于关节的权值;: m0 i* J& \* _2 ?2 y3 \, g
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;/ U" `; z" y* ]* t. P

( H+ G4 h! c: l现在就来解释一下这三者之间的关系:
- M* |9 [0 c3 p8 C' e, R4 G$ ]
. ]; c  X8 i* K3 s  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
: d6 }: X9 P( d0 V0 c
5 I1 t0 V1 t6 ^0 P  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
$ S, q) V: H9 |( t' X+ |$ D& x3 T# E- z/ r' E" F
  了解这些基本的概念,下面就来介绍人物动画系统的框架。
0 D3 H  d/ J* e1 X$ l7 K- z. {' B5 A2 j4 d

# S* u( r+ }1 O6 M骨骼蒙皮基本框架$ f7 w% E- y* P3 q+ N' v

, G  ?# |1 U  H8 C' N) p2 X5 m; a基本的类型:
: [. W+ v1 g8 u- `! U! p7 ^( J1 J' e* @2 A7 b
关节信息:# K5 s: K" d) k: Z+ S

  s: p  Z* H2 k; _: c: H  U! [typedef struct _CharJoint* K9 I. ^$ S1 J* ]; }
{1 `3 H; L- L8 {, ]
   Vector3 pos;' g4 I: Z/ O4 I+ T8 L% b" n
   Vector4 startPoint;2 v9 F  ?2 s9 T
   int parentID;
. h  ^' ]- [( x9 @# S3 Q    char name[32];, ]7 C% M* f, T7 P+ z2 u
} CharJoint;6 m- D+ t  T! q7 p$ P, [& N
% D  A$ a8 ]+ l; S$ h
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;. T1 \0 c5 e8 g: c6 k

7 J) ~4 I  ~* [( [6 l/ y9 G权值信息:- g* C/ O2 L% R  c+ T" c1 [

' J8 c2 V( o& utypedef struct _CharWeight
, N4 t( X: }2 j& @  r. ~. f- A% d* @{
3 d6 z( w% r# \: @+ _& A! I( X    Vector3 pos;
7 g- N* \8 w* l    int jointID;& A' d/ Q4 u/ ^4 U$ T
   float bias;
$ C  Y2 ]. I' J& I6 `- Y) P8 n# p} CharWeight;; A2 o7 i$ ~& X% k% T
; {8 J6 }+ g# F, V4 m: S( y
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
" w& N9 C$ P$ e0 {0 O) a' Z0 b+ \& `) O/ |$ _4 g5 x9 w; `

* F$ [4 G" _) z  f5 o9 m- H顶点信息:
( m  I+ g2 G) h/ H: _3 H+ r0 Q- p4 K* Q/ w  q3 x; ?* P* a( s/ G
typedef struct _CharVert
6 z) E4 C. C" K, j% u% t{/ W6 i+ i* I( g  M; r/ A! S0 f; F
   float u, v;5 y8 ]1 C. {8 ]4 p$ o4 a
   int startWeight;
  r2 j/ B+ _+ w" r" ]5 D$ s: V1 `    int weightCount;' N) J  b* Y2 l' s
} CharVert;% O! ]- V1 i  p; q& t9 ?

6 a! v! w, Z% W! x+ O8 u! ]9 q  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。
& ?# A3 Z7 X1 l% n* Q" ?) P8 P; V- c( `6 W3 U6 q' N4 \, C, A

6 V: q6 c2 ]) e! c大概还涉及到这样一些类:) N4 P. V. C: m; _" X! N

% Z9 @% R9 _3 ~( h; R% A0 ?1 ECharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
/ m4 a# r& F% U: k# a) jCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
% I* L7 g& D/ rCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
) q: Q6 t+ s8 ~% rCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
! [3 @% {! ~/ y9 _( K) E8 t0 yCharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);/ n* F" V, [% u7 `9 t: D0 H; m# h' ?
& d5 J9 Q* \9 R7 f$ g9 h

- b4 B4 z0 f2 a( Z解决关键问题; ~! T5 x% ]$ D5 g: ]& F
5 H! ~% I& ?% _. k1 r
  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
8 |! n( o, I$ |- b' P, Z& q1 K% r+ L! u2 Z
关键祯混合:
1 I. l* x1 x5 D0 e8 ~9 q2 A" z8 c7 `
  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);5 b, F# ^" `% `/ Q/ f0 z  N, F
! {7 \* j$ }5 T6 `4 P! a
  际应用中还有其他的混合形式,后面再来介绍。
$ c. |* @) T1 V8 Y$ z! w6 _( [
; D7 e0 S0 B" ~; s/ p0 p  H, @! Q0 a+ [4 N
计算实际顶点:) t* O4 ?: t( c9 z% W/ [

/ m0 f3 y+ x/ H2 h# O1 V我们看一下软件的(用CPU做蒙皮)Blend过程:
6 @9 Y- N& G9 N- A; c4 c
: X8 M- ]5 a1 {. C7 cvoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )9 X+ a. Y) n. g: ^! h% I4 Q
{
9 A0 V* }& l3 G    if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )7 j% F( o' }5 Y; ?: o
       return;" H; i  B# F! E7 j( u, ]1 W7 ~
; J3 U$ C1 N& p$ K" }
   CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();% _5 S9 \3 o; m3 G3 b
   int numVerts = inputMesh.GetNumVerts();
2 ~2 t. c2 Y* v& R    int numTris = inputMesh.GetNumTris();8 G0 z' f. R" U
2 N' U( Y, b. \2 v) G& \
   for ( int i = 0; i < numVerts; i++ )( J& Z. R5 \0 c' h- [4 g
   {% w4 c/ q( U- }, }. [
       const CharVert *pVert = inputMesh.GetVertAt( i );# b/ M. r) N4 ?/ G! D" k0 W
       pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;+ A$ _7 f( b  B/ K4 c; @. O

& A( u+ R# m+ }) T6 U) D, I1 F        /* u v initial */6 R! i: ^! p  \
       pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;6 P9 L, s* t8 a. C
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
$ T+ ^1 U. l* d$ T( T7 d0 u) s: }; d. N2 ?/ r. u/ R
       for ( int j = 0; j < pVert->weightCount; j++ )
& q! p+ B; D. ?* B        {! u2 [/ @7 O& @: ^' t/ ]7 O  q: z
           const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
9 h  u8 @; j, _* Q+ g            int index = pWeight->jointID;. z3 Q/ }9 s3 |  A7 w) o1 [/ k
           const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );3 C0 D  F7 h9 a' s; r: ^1 Z
           vec3_t wv;
/ U3 E/ f7 V% I+ W3 @) v2 s, \& w* i            Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );9 N, b  T8 J5 b4 e. v3 r$ y3 x$ x
           pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
2 ]/ w0 I$ T6 z- ]9 i1 x            pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;. |0 q8 w$ v3 Q) |' ^6 T8 v
           pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
6 L6 d+ S6 }- l2 x        }
2 N6 I5 j. r9 ^/ Y: z+ t; a* {    }4 B0 o0 w+ b$ t4 t

9 ~5 H# v$ E8 z# z    outputMesh.UnlockVertexBuffer();
( E+ a1 _# {2 x6 h2 g  Y. Y: f' Z& M8 Y" j$ y6 O
   CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
7 V/ N# J8 N! T7 O& }
% E" |& K6 e# C) _% V1 ]& `! ^* c. i2 Q    for ( int i = 0; i < numTris; i++ )1 l3 F! A0 ^; r+ h9 A) k
   {
) W" W/ J" ]( @2 ]+ w# E        const CharTri *pTri = inputMesh.GetTriAt( i );
9 O/ R8 O: P7 l2 `        pOutTri->index[0] = pTri->index[0];
+ [  j9 j# _8 |8 T  M2 G) z        pOutTri->index[1] = pTri->index[1];
& q# {; j% _* q% ~  q4 b( ~        pOutTri->index[2] = pTri->index[2];4 h+ I0 X) E6 j6 V, n+ H' O8 p2 C
   }
7 r) P/ }& ~& s- p; c# n: m- T9 E! s3 }3 ~6 t6 {6 e5 D8 T' L7 b
   outputMesh.UnlockIndexBuffer();, O% i& z5 p8 _4 `7 e# ?/ m) B
}9 L/ T* f$ d: b. x% [

5 n" t# |! n6 l7 ^3 v其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
  B* |1 y5 I* m. ]* c8 n* h3 `) f! W4 Y- z% J

4 `  F) i  o/ Z关于md5anim文件
- i; {$ R- F4 X, _0 `* X& d- A2 M. ~: s0 i) p! H% V9 s
  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
6 T* t) Q( Q7 C6 n' o1 o& I6 B5 e+ R5 E& o* T

* [% X! p8 o& w; [! |/ M( S1 g  F可能的扩展
6 |8 i" g$ w9 ^5 O" I) y1 @/ R; g9 N( N! K) @! u" }
一、复杂动作的混合
; D* F6 S% }6 ~7 x8 _( v4 K( L2 X! }7 ^1 Q7 W
  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
/ I9 W& c7 X: }# a) V3 r! G7 b. x. P3 r2 K, V
二、基于物理的动画
3 w3 V  m) e. T$ `5 h8 e; _9 K/ @) q, {) D0 B  x( W3 i2 i- ~# X/ E
  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
5 U: `8 b4 Z# i+ }& |6 Y$ q
7 c9 }6 c0 }4 I0 y0 J三、基于GPU的蒙皮& l$ B$ g2 }' ]( W: [; F

4 m- I' p6 W6 z5 n  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
5 n  ]# E0 ~/ }5 ]. @1 D/ p6 k
& K' e6 S$ _/ J& U2 N5 o% h8 l四、非常流行的“换装”系统# c6 T5 K4 p' @! |2 H- F. _

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

本版积分规则

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

GMT+8, 2025-6-19 11:56 , Processed in 0.015262 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

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