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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
) q4 o! ]  R2 Z# ?: M& k: b0 M8 W) e8 M& U
在这篇日志里面,你可以获得这些信息:
6 O5 \! U; i& I2 w1 X% ?) J
6 z) h. K0 C/ ]; D; O) g) V1 人物动画的框架
$ K& s3 S& W# \+ X- N8 l# S5 }2 骨骼动画及蒙皮技术0 Z0 `4 t, B! K% \2 G% T
3 doom 3和quake 4中模型和动画格式md5及原理7 A/ A/ j; u% h/ b9 r' v+ G
4 可能的扩展: I  L8 H+ u8 F, z

8 n; w; d! U( W! i4 \- h: e. m+ d$ z
先来看一下人物动画的几种方法:$ r# G- P+ _9 _

: F5 t  o+ P0 F5 M) N2 E$ d一、简单关键祯的动画
, V( |: Z" d& M5 R/ j1 ~& Z6 F& [- H5 X" X+ T! i% F, U/ \
  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。' H! x% I" X3 W' T: J1 P- @8 r  \0 A

7 v: R3 V) b$ k' n+ O二、简单的骨骼动画及蒙皮技术
6 V: c1 p$ F6 h$ |4 Q7 s9 e4 f
$ ?+ G# T  H1 @  W1 Q  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。2 C* r0 ?' d* V4 i$ m1 g* V

+ O5 t# _- S8 t$ Z% @三、改进的蒙皮方法和基于物理的骨骼动画! ~- C8 s  _' ]5 g# F
/ A  D. h, e  u
  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;% {( t; ]! X2 e( V& ?

2 n% m! w" A) ~  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。3 T: w9 ^; C4 L& C: D: r3 X

" }  c6 c  b; b+ m9 H
0 O! D9 |" X9 B* t; S6 I# o5 ]8 v' t
基本的蒙皮原理
- n9 m5 u7 x; K1 Q% |% W$ T& X
  F$ a! B8 K4 @: \拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:7 u) w. {9 Y5 A9 I& E6 ^! d
3 Z4 ^0 H; m7 m* R7 m
Joint: 用来记录骨骼的关节的信息;9 L! W2 S3 q' c
Weight: 用来记录顶点相对于关节的权值;
; f5 n, U% a: v9 O, X+ b8 w; ZVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;8 i, C% i' K9 ~
9 q8 r0 i* y: g- ]5 O
现在就来解释一下这三者之间的关系:
# P" s& B* x  n1 Q/ a; s" n& v: ]/ W6 |+ b5 G
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
! d/ M1 Z6 m, d. S; z# J+ I3 Y4 _! W
  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。: H) l0 Q4 X0 {  Y3 C
' Q% @% J+ c1 Q% t
  了解这些基本的概念,下面就来介绍人物动画系统的框架。( b. P  J( L$ _
4 P( n2 r# A& P3 c8 L, o
0 i+ M  |: Y* @* n9 B
骨骼蒙皮基本框架
. s$ Z# ]4 ]5 C! P- Q- F  d/ a5 t0 x, B
基本的类型:
  Z  T' G* a, [
+ d: `3 o' z. b9 H. `- M关节信息:0 c0 n' s! u" n. i6 `

1 ?- v! S$ L7 w. Ptypedef struct _CharJoint& z: r( X9 [& F
{  K. i3 e" [- G8 H
   Vector3 pos;
- j" r% h4 G3 t) V% ]8 M4 Q    Vector4 startPoint;
$ P: @" U6 N5 P# M* n$ f' |    int parentID;
$ a0 l; o! c, X- F& q7 f    char name[32];; v4 N6 z8 P+ Y* |- M! S) N8 e
} CharJoint;8 ]* t* A* n6 O0 o: O( n
1 G# t3 V5 k$ k. U7 J
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
7 ~" p; T" B. m. I  j$ ~
( f/ h- h3 S$ q1 G; \8 K权值信息:
5 T7 e: U7 g! Y+ B6 E" c
; l' i- }, Q/ J3 B7 P& S: u& @, |; Rtypedef struct _CharWeight
. @: }. V$ W& g) J7 i{# \* _; M: f: P7 k) i5 Z
   Vector3 pos;% n0 ~! |, O8 A& d! u3 v
   int jointID;
, P2 V/ A: Y3 v1 v. b, u" L    float bias;" y% c$ l2 V! T4 I9 {- ~# y
} CharWeight;
# o. R8 X0 ?' l: I; K7 v5 h7 O' j( _( L4 u, Z
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;5 k7 `- t9 h* u/ j% E% @2 {

! _- ]0 l4 t/ v
, t+ a% `5 j. j- t+ P4 Q. I2 v9 t顶点信息:
, y) n+ q9 m0 Q7 C% B3 k. B: ^+ Z
3 e7 L! }7 {- |: Q5 stypedef struct _CharVert
: ~- J! f3 l4 o4 E5 x{4 w# \+ f" q. g/ g) t4 x
   float u, v;' L0 t. S& c+ k9 B  B! U
   int startWeight;
* w# V; ?9 F4 v& I  A    int weightCount;
" F/ D$ F$ k5 q9 F5 `} CharVert;) M, [1 z! g6 f2 \6 Z* H

, [. h/ u7 H: ]+ ]" o  z  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。8 k2 n' G; b4 Z" u! W- Z

$ e$ D' L. H* T* |
7 X5 @9 j/ K' H1 j$ m/ ~% {大概还涉及到这样一些类:
3 }& h' o: J7 R( K# g, X4 N/ J2 J# U1 R! E7 ?2 v& K
CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
0 _, t+ W5 C% c4 _* Q. F! {9 nCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;! H$ M- X$ {8 p/ Z/ W* C
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
& t2 m  _7 q8 L2 }  m( ]6 B8 |CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
( }/ `$ H5 ]$ H9 MCharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);. K3 U4 x# ^7 {

" a- b. N2 \. T" \& p3 \8 u) c6 I8 ]2 X* A( T5 Q# `" r5 s* J& P
解决关键问题6 x0 F( _/ ^! p* e+ e

( o$ \- D; M5 H+ c  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。- R7 j  L! o$ Q" o5 p9 w* l8 I
# E' v& `- \1 f" h1 e' J
关键祯混合:$ Q; J/ l- t" F& F/ C

) m% }+ ^& c# t  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
, y2 i, ?* m* e! C4 E( ~$ f! a6 o3 G. I
  际应用中还有其他的混合形式,后面再来介绍。6 Q& Y: A, D: b7 s& G

2 o' x0 e& n5 F- b; P: \4 M. ]1 u- X0 K* I
计算实际顶点:" e+ }- f/ Y' ~" A9 ^2 Z7 ?& `
  v( v8 Z" s/ n; Z$ L# _
我们看一下软件的(用CPU做蒙皮)Blend过程:
! b( J7 `8 R" M; P$ @$ ~5 ~8 Q* C. l- I5 K
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
7 L, m, P- j4 I" C  ?8 I5 L{+ u( v/ }' ~6 w8 Q
   if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
$ w8 F) K6 }4 O. R, C) t        return;- t3 ]3 T& R+ c6 z- G

+ g0 ^8 x0 L; q    CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
4 k) l7 C+ l( b5 r9 o    int numVerts = inputMesh.GetNumVerts();+ b: ], z# D1 t" |8 }
   int numTris = inputMesh.GetNumTris();
- \3 ~6 _$ q8 L* e6 \
+ A7 Q. c  k9 o    for ( int i = 0; i < numVerts; i++ )  r' ~/ s' T% s. d3 {: f" K
   {+ k" }6 ^4 X2 \( j6 M4 `  T& l: I
       const CharVert *pVert = inputMesh.GetVertAt( i );
# Z: d0 C# E  w        pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;5 ?) l7 t+ S2 _: G) {1 a
% l# K1 k( z+ A0 \# U5 d" P: K$ A* z
       /* u v initial */
" T( I' b5 B, B5 j& Q5 m* a- }        pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;! W% u0 f4 P5 x. }8 B. a
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
- W+ L+ c5 W$ M" V2 C6 n& F# Z9 _, l
       for ( int j = 0; j < pVert->weightCount; j++ ), s3 O8 d1 |9 w) A% z
       {0 d3 ?, r; d, D1 c" {
           const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
' S0 G* R. o& b/ T5 F: U            int index = pWeight->jointID;  O8 f" S' }, A* j
           const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );, U  X: A8 s5 V5 \! k6 {
           vec3_t wv;
8 C! A* r" p" x            Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
- J& Y& n5 m, b( I            pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
2 @6 }$ H, I% b( C7 z3 _9 N7 n6 _            pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
" H8 ]6 o1 f- r. h            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;+ I  h) h1 X$ V3 U0 G
       }
/ ~8 y6 Y, j/ b! V- h, F    }8 N0 C& J, V2 z+ e. A+ j3 d1 U; d
/ N3 Q$ t# k% q
   outputMesh.UnlockVertexBuffer();
2 q0 }6 C5 Y# \% \8 p4 N" D2 i* x& T1 |' A2 Z" s$ m; T
   CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();7 k3 v! t+ }) L$ o/ S, w9 e
1 C+ q5 ~6 P$ d& _
   for ( int i = 0; i < numTris; i++ )
! f# ?: |( ^8 Q" S3 _: _    {
5 E* {9 @+ |1 u3 H3 k        const CharTri *pTri = inputMesh.GetTriAt( i );
' l# `* E# E+ W2 `: q: V        pOutTri->index[0] = pTri->index[0];
2 ]; `1 U; O5 R) c        pOutTri->index[1] = pTri->index[1];
4 X3 p* j( ~) u$ M        pOutTri->index[2] = pTri->index[2];
. f, b! k3 B" S0 o/ s    }
( k, G/ @2 a2 h1 N$ b8 i0 v7 E" h3 ^# @2 Q" s
   outputMesh.UnlockIndexBuffer();3 Y, d5 l, y9 p7 P+ R4 r8 l
}
- w$ n0 R" w( w' x8 X+ w" h+ p8 h* \) T7 @* R
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
" Q  M8 V/ r$ b" b
8 Z# C& w' `1 U; Z9 t3 g1 m
( }2 A) L! N# W1 L- A" K关于md5anim文件
, C' [! D$ G" e% ?5 w6 m
9 l2 K, [% \  U& O  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。4 |+ X* P( g% }$ [
( Q2 M0 z5 U; y( e7 s

2 s6 d" g8 G9 A  c: X& V+ f4 h) I可能的扩展! j2 p6 a. ?1 l  d$ t  K  ?- d
; T& m0 R& J: }7 ~0 ?- t
一、复杂动作的混合
) H0 X$ `9 m' x+ \( I2 @8 r0 Q& L" F+ l
  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。* V! Q$ L/ D: m$ B# ^' n( _# D
# s$ B" @7 Q6 h- f$ z7 h
二、基于物理的动画
, w& G5 u" ^( q( X  x% R" L2 |, W' J6 d% m5 h- k
  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;* E* \1 S) R$ ]* a, v" c2 _

, f1 G5 }0 l& L2 L/ @三、基于GPU的蒙皮
; A( b+ i  [: q, @  ]: u
$ }( K2 @- g* |; o  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。  o4 I; `, x' B" k2 ~; Q

" |$ W  x6 i2 `7 U  b: c4 b四、非常流行的“换装”系统
# q8 {0 P/ I* j. \4 D3 p5 `* @# ]) |
  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2026-8-13 20:17 , Processed in 0.020980 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

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