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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
$ Q( f& D7 ^% F% z. o2 R
! K. |! O& I  ^, ^( y) k3 `( a/ q在这篇日志里面,你可以获得这些信息:
2 G5 _. c6 T1 V& H2 }0 Z0 O8 }" e) v$ h2 ~/ L* ?& O4 {
1 人物动画的框架
: f0 i2 B: `  Y* [6 T+ ?2 骨骼动画及蒙皮技术
7 L8 v  R$ |: J3 doom 3和quake 4中模型和动画格式md5及原理
2 T# Q" B2 j! E- y' h# Y# ?4 可能的扩展
) @  t) X. M1 M1 n  o
; K& j! J2 l' f* D: c
: J& T7 u2 e6 o$ X% Y, S* L先来看一下人物动画的几种方法:9 v3 V1 Y" L' B5 K
' h6 i+ ^2 I) Q: i7 a6 Y( ^4 I$ p
一、简单关键祯的动画' Q+ ?( I6 x0 W
0 Q5 m1 F' n% B* i5 u9 c
  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。, f$ A: ]& ~+ S) X' C! `
2 U8 D# K7 l4 t# o( q
二、简单的骨骼动画及蒙皮技术
. A+ y* O) J/ q/ ]; w& H4 C  t! }) E. z( K0 Z) `
  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
& ]! d( h- |2 f
$ r3 R$ {% Y; |! O三、改进的蒙皮方法和基于物理的骨骼动画/ G& Z, u# P3 R! F, P
. B# i  T( h% y' L. _$ d6 W1 T0 k
  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
: A- k1 W( H' p5 I7 G! I
: a  o! D& b4 t  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
2 U3 q3 q" p# p# f& I& n) F$ j4 W3 ]8 [
$ r" a% j0 j7 V

* G5 N8 f% q: l基本的蒙皮原理/ l. ?# b/ p  j  v
) g. x6 u1 P8 q; }
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:5 k9 h6 X, C. z" L4 }) c

- M4 n$ S4 L' b' K! ~9 v, bJoint: 用来记录骨骼的关节的信息;
- n3 C8 B3 S1 j, t. x) x; ^2 TWeight: 用来记录顶点相对于关节的权值;- C6 A* O$ [3 R5 Q( ?+ b
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
0 R  @+ l4 L! V1 D0 d, J' c# }4 t$ a5 b/ E( b$ k6 M9 x
现在就来解释一下这三者之间的关系:
8 @6 u% ^0 u; d, k  o: U
" @8 o& L1 r- b  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。. ?- o/ i/ V) O5 o: F. c
8 O- ~6 m) y# H% h3 e, w2 x
  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。% P/ Y% o5 T4 v

* o& a# O2 Y% Y7 U6 P  了解这些基本的概念,下面就来介绍人物动画系统的框架。
( y+ k3 \5 a9 z$ k, s: p: i" i3 g/ Q2 n5 M

; V. M/ l' x4 _5 q" n! F骨骼蒙皮基本框架
1 @4 r; J) J- U
7 |) Z2 H  W) Y2 ~- a4 A基本的类型:
, A) m# l8 [5 i, X$ A5 z9 N: A4 Q1 M. ~( p& C% ^
关节信息:
: v9 o  q3 e& e. g9 s  }7 z' @; W3 c
typedef struct _CharJoint, I8 r0 I4 t2 T2 S
{
- `" f( a0 r+ a, i    Vector3 pos;
' a( C+ {  ?5 Q, {, g    Vector4 startPoint;% a- g* e. w" i& g/ ?" |3 O( a
   int parentID;2 A7 r& v. G* ^& o' b4 u
   char name[32];8 h1 z6 Z2 L) n" A
} CharJoint;
* g  X: k: \. W
& P' i; N$ v3 X其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;& i; l+ x( ^8 j! R

* W6 l- \& `) I$ W% e权值信息:
5 H# H# G. @/ K& d& r2 K2 y" S
4 j7 ]4 h3 ~! Ftypedef struct _CharWeight
% P% C# r) X. o4 R{
& _8 a5 y  [0 f/ j0 J* V& p1 _1 [    Vector3 pos;
8 D, g2 q: c# V% J0 r    int jointID;
9 B( l- Q+ z" W6 v* p    float bias;+ e( z. `( Q6 N, n0 P8 V% w+ t: [8 x
} CharWeight;& F& ?# I  E( h" K6 X% p7 m
. W3 ]& z$ r7 G, d- _
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
+ |; Y" E9 m' M; }$ n  E( k; a, _
* A% J( y: M5 ], Z" G, E1 P
$ y) D! J% G7 v' S5 `顶点信息:
% @7 X  f9 U5 F! h! M8 {" V! N8 M" t- Q0 |% p: V. P
typedef struct _CharVert
; c' j$ J' T# c# p2 d$ B: q) w{
3 u" {5 b8 ~% q" C  g$ {    float u, v;
; Q; L: B* y( d    int startWeight;. C+ b; ?9 o, \. l1 t
   int weightCount;' O0 Z! Y- D0 @+ i" l" Q* o
} CharVert;! Y: X. m8 K0 ]
0 C6 l  z. H$ M, f
  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。
  z: v* m& y$ t% O6 f  o
' H( V. z. v9 q2 ~. o; ^8 W3 w2 ]0 M
( _2 M) |6 N! V0 _% l8 A2 U+ J' D大概还涉及到这样一些类:. w2 v5 G0 t7 D' {! q

9 J/ A" D; I' k! DCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
6 x8 e3 d1 Y; l, u4 Q6 A: Y( e+ VCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
+ k* m! y9 `. \CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);- m; o" E7 F3 M! J, o
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;' V3 ~$ q/ {2 a7 {5 o
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
9 N! @6 ?% ]- _  }- y) o, q, \5 S4 u$ k- j& d: K" m% D
( `6 |2 M% e; l9 n
解决关键问题
6 z0 N0 j' k$ w4 H
, r4 y6 ?1 G# F5 F) Z$ E1 U, t& U  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。2 I! C8 I) x6 t( e9 M! K1 `- X
1 Z/ ^& g3 E( B% a6 K' b+ ~
关键祯混合:
8 e: I. [0 A6 s2 Y0 T3 S
" J' y8 S! {- P  c& c4 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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
1 B2 X4 W* a6 {6 l+ E+ \0 v; d4 N+ c) j- M$ A9 {; _6 c6 h
  际应用中还有其他的混合形式,后面再来介绍。
$ z4 g: D% i+ I3 C- ^  s; i( H! Y' v( j+ @; [

$ g' m1 B* R# q9 M" s" }3 p计算实际顶点:9 \) ^/ V! n  E6 ?$ J

' _- }' t% T* O/ z* W2 n" F7 m4 D8 a我们看一下软件的(用CPU做蒙皮)Blend过程:
* W( l( G9 j% u$ X, {
1 u: Q0 t* n3 u- Evoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
. w4 f3 g7 q2 ]. X% S" K{
. K" _/ I. O0 `3 v: f" q    if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
2 O( }+ R" _7 x# R7 p( N* [        return;
$ q) g$ k; h8 w$ J6 a& A/ ?, c9 b' F% I
   CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();$ |3 W8 Y% T* z4 Z+ u% l
   int numVerts = inputMesh.GetNumVerts();
5 A% j( r; h, N9 S8 k0 Z    int numTris = inputMesh.GetNumTris();
7 y9 }: m! l$ @4 @7 E# L/ \: }$ j
6 ~- W. Y5 _9 {- ~    for ( int i = 0; i < numVerts; i++ )+ {2 C2 T, F) c' V
   {( d% V- |8 e! z4 h* |% b, ^* I
       const CharVert *pVert = inputMesh.GetVertAt( i );
; P: C( U! y8 g- ?) X        pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
8 k, Z, E2 Q, \0 U1 Q% ]' [! C- _  X0 r
       /* u v initial */
2 ^% j! k) i4 `. Z1 |$ U5 |2 b        pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;1 n. c) {& @* e* M
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
& @7 e: o% Z6 m- {# |: F9 z3 G" g" a1 ^# n# \
       for ( int j = 0; j < pVert->weightCount; j++ )
. t* ?1 k; P, `  C2 u4 d- T+ {3 R        {6 _' ^+ `% H0 X( I  E0 p
           const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
2 z) _5 u1 y( y- U# B" K7 c  c            int index = pWeight->jointID;
- N( r! R/ B. y, }            const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
0 r' M: m2 P( H2 K, j            vec3_t wv;
4 L1 p# e" }- e* S) \' j+ Z7 I            Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
* \* v0 F; s- g8 m1 Z6 m9 \0 w            pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;, _. I! `* p$ D
           pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;0 X! Q5 w; ]% J( U* h" U
           pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;2 n2 t/ \9 N) q0 N0 I
       }  U  L; J1 g& U
   }1 x# X: D* m' G$ z( S- F
' R0 p; r% B0 |; n9 a  \
   outputMesh.UnlockVertexBuffer();" i' s, a  L  ?9 G' M
& Q7 R4 d/ {$ ?( J# \5 {
   CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
" x* v$ a( L! o+ C) c6 U' e) x" b6 [( I0 X& Y
   for ( int i = 0; i < numTris; i++ )
: G9 ]1 t8 }* ~: n    {; x, z3 P* I! e2 l6 ?3 E
       const CharTri *pTri = inputMesh.GetTriAt( i );% H) O* H( a0 F, U- `1 B
       pOutTri->index[0] = pTri->index[0];
: l# F1 X* R+ L4 C( u$ i2 l        pOutTri->index[1] = pTri->index[1];6 D; ]+ o. |! u8 t" E9 X' b
       pOutTri->index[2] = pTri->index[2];
! j! p$ q7 }: Q$ L6 Z  @1 Y    }
( Y: F* ?% A" G3 [4 N3 g" ?+ s; ~2 g" \# H4 q/ X7 Z/ n& R% [
   outputMesh.UnlockIndexBuffer();% H2 h3 w; t9 l4 m& s
}" a' S; s7 p! K: r

2 k. L- S* Q! d) y3 g0 c其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
' ]! X( K$ o, M* v0 ^( P- ]; e8 Z: A2 H! l! I) j
. N# I$ B) ]. U: A9 H5 M
关于md5anim文件' ]& @" G# `# M9 ^. ?1 d
0 T! Z1 K1 D2 T" G* h$ }; X
  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
# [9 |, A+ d' ~  ]; W& U+ ^; j% @. v- t

! Q1 A  K( r4 O1 R: K- H9 O可能的扩展
+ F4 h. Y  M. B9 R0 M) B4 v: T2 j6 x6 r- C5 Q
一、复杂动作的混合- t" ^& O# {$ P9 j, J
" x9 q9 b4 v4 U* a
  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。* Z5 |3 }, M' w: q

2 e. C& I2 _! N* Q8 ^/ q二、基于物理的动画0 b1 z( ?& q7 ^8 ^& S; r

. S1 |( j# h" u8 k, |/ O  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
5 K& e7 T0 c, r( \. T, y
% n5 n% ~/ X8 d7 o9 C# v& E" B三、基于GPU的蒙皮
5 W% `+ f8 b7 X( ~
% W8 X- Q, x4 T* ]  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。8 ~+ `4 R  y! e- i3 o! z( H

9 v2 @: e. g9 p! @四、非常流行的“换装”系统
! t. W$ b+ f! E! O. I7 v8 T6 i: v- t: H: ]6 ]  N9 I# W& w% j7 F
  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2025-8-9 02:59 , Processed in 0.034770 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

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