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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
& R; p" n& Y7 \: d! o
8 J! v4 R; e7 Z0 a( G& h1 g在这篇日志里面,你可以获得这些信息:
$ X& Z& E' i4 g4 z5 X
: W. T" ~. O( c" D1 人物动画的框架! n& A( m: Y! m+ O5 L2 F3 e
2 骨骼动画及蒙皮技术
2 d  ~1 T& b9 H' w# f: q3 doom 3和quake 4中模型和动画格式md5及原理( P& [5 @" M7 P2 G6 L; X9 Z9 F
4 可能的扩展
: o/ I, T9 T% B1 G  D. _0 r
0 ~; ~6 v+ K6 a, o( ^7 ]" p
: l! K6 ]6 e4 n8 L  l先来看一下人物动画的几种方法:" w+ Q, N# O1 k7 Y
( w8 R* {: v! I2 T
一、简单关键祯的动画" u# c$ E+ V* J9 i
4 Z7 x: j% `" B6 F$ ?. w) Y9 J: f
  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。/ Y! I- }) G) y0 {, }+ D

' \: s+ e: M" B% u0 S* d/ a+ n* A二、简单的骨骼动画及蒙皮技术
; B' \5 m8 s- ?
6 s, g' e. L6 i) O" r3 I  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
! J6 n: r6 m/ P- ]7 S2 H' F# c: d) P) w6 D/ m4 j
三、改进的蒙皮方法和基于物理的骨骼动画0 b7 g: \6 y7 }( G; A" T
. A6 `. Y& w0 `# c& B3 n8 f
  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;. @! `8 a* B$ O
! V6 n  J9 Y* R4 `, e( e6 r  T
  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
4 F6 P; S4 X8 w" K! q3 n' o1 f& Q, h6 Z& u  Y7 L/ z2 c: {! ]
, [: h1 E8 }  S3 _3 E% t" J6 E
* z( F% J8 J# R6 w) N- W
基本的蒙皮原理
/ Y8 [8 x& P# }) o, `" `- h3 l; L; ~$ a4 p8 p) U3 M! O" j9 b4 K1 ^
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
  t+ W3 ]3 Z! b- u: `3 j/ }  K' t! F3 T! _5 h) h% i( P, y+ J: h$ R
Joint: 用来记录骨骼的关节的信息;
: d. q: f& @1 ~. Y3 C7 ~  v5 rWeight: 用来记录顶点相对于关节的权值;- \* W8 \# o7 j7 N
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
! W' Z1 z1 [5 {& M
7 |! y1 J# Q) F0 |! F现在就来解释一下这三者之间的关系:6 u! m0 j" p5 r& C  r  ^+ Z  i9 m

# H: ^3 d3 L" x3 D0 [* e. m  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
; |% H8 M/ r  C5 G/ X9 G
( I8 f4 x3 P3 V0 F  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
* m6 l& [6 W( a" q
: Z3 M5 y, A4 C* B- ^+ _3 c% p' |  了解这些基本的概念,下面就来介绍人物动画系统的框架。
7 E* a: i, e0 ]  o' g3 ?; W! ?0 E. L2 M% H' u( o  x# ?

4 X0 [3 R2 x7 M" F! N; d4 N9 m" C& Y9 M骨骼蒙皮基本框架. j2 |) |  G! k, C: \

3 }/ |8 A0 a0 @% P基本的类型:+ B7 ~1 C4 i6 h& k9 p- z

4 Y/ f. O$ {$ F+ ^, x6 y1 b关节信息:" P) p9 ~0 g- K7 z- ~

$ [( L& `. m6 N( g; L7 ptypedef struct _CharJoint; V. J- h$ `8 n: f
{1 A( D! }+ s% g% O, }5 o
   Vector3 pos;
" l3 o, s" i+ F. b    Vector4 startPoint;
. F1 M& F' w: d) C+ r    int parentID;' a6 t2 v& l5 Z6 a
   char name[32];" P1 C4 V4 v/ l& e! |
} CharJoint;
1 {. V3 ]% s+ G' S
. q7 p0 `- L7 P! `" x2 Q! M, L8 _其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;0 l' h/ I) w4 C2 l. _1 |

! ^# W4 V: a: J! ^3 W权值信息:
2 S1 Y- f1 d4 r+ q
, Q- |6 X% O, [0 z$ A3 t0 \  n( ptypedef struct _CharWeight
) A- a2 |! ^% i. J$ m{
$ j3 w/ k* y; i: p+ F% E# G5 k# B" |    Vector3 pos;
$ v& S% p$ o  W  C4 j" I6 _& y# [    int jointID;( [/ y& P) F. j* m" Y) ~
   float bias;
, c7 z) @2 I2 v& F4 a$ K+ z3 i} CharWeight;  Y& \1 t; s4 u( J1 j+ g( g
" r7 f3 E, X2 ^  [7 V4 f6 J1 }
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
, H/ K* j* D: U+ `. y8 k2 B% W. s2 U* Z, k
' g8 A; H1 K6 r8 i7 N8 Z
顶点信息:. s. |7 t7 v' a) E0 r

. e) h3 F9 s& |typedef struct _CharVert7 A9 k8 \" }& }* y; c) M9 N
{$ a3 q: [8 [2 d5 q/ w0 x8 f( o
   float u, v;5 g* h" M: X6 q2 g9 D6 m" u
   int startWeight;  [8 v/ b6 t- m% p2 _8 v
   int weightCount;
( r- p% V2 W/ J! F4 C5 v} CharVert;7 M; K% y4 C9 B# ?! s

4 H4 Y4 C1 x) i- G  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。' L( X1 @) ~4 `( x6 D

8 I& R+ K" n# m1 x/ \8 p4 G( F) h; o' s7 _' Z' V
大概还涉及到这样一些类:
6 J- r2 e" s7 p4 h3 h. S& B5 k! [4 p4 b; A+ f  {5 l
CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;3 {4 j$ F. j- f% q) I( @/ e$ W
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
6 z7 j7 ~; P) U5 Z6 `* {$ UCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);( w8 `( o( j$ E' W9 X. j
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;7 f% W7 y2 y. g0 [. [1 c8 N8 U
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
% X  W9 a/ j* M4 A6 @. A4 U4 z
9 Q: ~+ s3 o$ U5 |) ~7 z+ M) R9 G: N. E, {% _
解决关键问题
+ _2 }* t. x: Q3 V1 q- |$ _
! E) U7 J6 S1 o3 t* ^4 L5 Z" l  W  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。7 z3 a* T4 u& I* B( O6 m

- x' B, y5 o* `关键祯混合:) n- B3 E, B- P4 h  c

' n9 s9 K! {" ~( M  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);+ U) R/ W- x6 `
( e0 q: U* s3 W
  际应用中还有其他的混合形式,后面再来介绍。
- L1 E: z5 c8 _8 X
$ m8 m8 M8 t) j* z% r3 O$ N0 Z  j) S3 S! _  X+ M
计算实际顶点:% k  j0 X# _9 _6 H" o
3 Y9 K9 g: r+ o( n! e
我们看一下软件的(用CPU做蒙皮)Blend过程:
- E3 j3 f( X" }  J' N2 Q+ E$ a( ^+ y! c/ r' w2 P
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )4 U. {# O6 q' e2 j
{
2 X! h( U# ]# \: ^* p4 |    if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )* s( H1 u; e# k- r7 d% s
       return;4 F  x$ Y0 D3 ~" u8 f" ?

. ]4 ]4 h1 ^5 z' Y" v' E5 p- n    CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();
) `: C4 O0 v6 ~    int numVerts = inputMesh.GetNumVerts();
) L# b0 _% r! R0 h/ _, J! G    int numTris = inputMesh.GetNumTris();8 o1 w8 Y1 |7 m. N

& R) g; r( F# s9 l9 f. P, o  l2 Y    for ( int i = 0; i < numVerts; i++ )+ p2 |* d4 j  \! s
   {
/ q4 q( B& r) o& |% A) O        const CharVert *pVert = inputMesh.GetVertAt( i );8 E. _" M- w! x( O! I, a
       pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;& ^* x  l% \% ^/ B* \- W/ u
) o! y/ C- O) X  u+ n2 b0 x
       /* u v initial */. T8 v4 g$ E5 x8 @' G; d
       pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;4 m0 O7 m3 g* t. k6 E4 F: W
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
- f& f! K- `( \  P. i: s
5 l! z  C- e0 [7 w( [/ b        for ( int j = 0; j < pVert->weightCount; j++ )$ g1 D1 N. {% ^0 J5 P8 k, K
       {" t) b) ]& u8 \
           const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );& f# c% j2 O; d; K$ x% E
           int index = pWeight->jointID;
0 q: }) e) V0 s9 y; O9 \4 b            const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
0 |% s; |  Y4 N            vec3_t wv;
0 i$ z/ |$ r  f# S8 H% O3 Q! r; i/ F            Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );! [8 b7 O' I6 ^) f9 b( @
           pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;) `, a* G5 Z2 T2 i
           pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
1 p6 L2 Z; I' H) X6 j            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
4 m1 s1 r7 \4 ?1 q' M1 _" X# A        }8 n+ U6 S  B6 }+ _8 C* [
   }
  S, G" G7 B, Z: V6 @3 e) u; S: S. @' T
   outputMesh.UnlockVertexBuffer();
3 M% r8 T2 g$ @' p/ Z0 `: z
# y  V" O- ^5 G3 d    CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
5 H( l0 a1 |% h& y/ J" d# T( h; A+ v) |4 A8 F7 V
   for ( int i = 0; i < numTris; i++ )2 ~! I) h) C' W4 K9 ~
   {9 W7 @4 d- D7 h4 U& X% r/ c
       const CharTri *pTri = inputMesh.GetTriAt( i );' W1 z7 N: y! k/ T
       pOutTri->index[0] = pTri->index[0];
) f5 O) r; x( f0 {! I        pOutTri->index[1] = pTri->index[1];+ V9 B+ w; ]; }
       pOutTri->index[2] = pTri->index[2];2 b) e5 {' n0 ]. g3 @
   }
* j( }# W* S5 G# I9 t1 x! K7 `; o
   outputMesh.UnlockIndexBuffer();
+ I8 ?# F, R( o! H" E" N}
& C, l. i: S, x8 c, e; L- p5 V1 V' s# [, j$ y
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。: v6 W5 h+ q8 x2 p3 `

4 L5 ~! H& e7 T3 j" ]2 d8 p9 R7 c' ]0 S
关于md5anim文件
; a+ c* B+ P9 {2 m" f; q& C  E& a' m' ]  e* O. ~1 X
  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
4 ]( i( i3 K/ _8 M  I" }, M4 R" b5 r) q. ~2 j0 ]

. I' W& W7 W3 g) N4 W8 y, j可能的扩展
( d* f" f3 `7 }7 Q) T% y) o& a3 b4 Q5 r" @6 P" _* e8 n' L
一、复杂动作的混合
6 x" D- I; o. M6 N% W7 G, P1 G# t
* t: C6 J: g# p3 z- N8 E' D  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
! O7 C$ X7 l; S+ F) N; }# x& @; D' i9 O6 k- ~. \5 G' Z
二、基于物理的动画7 `0 y" P' k# y
* D- O/ p0 e. ?# ^* Z% D8 L
  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;& x" F) k4 e3 w' ^5 s3 C$ a7 c
' S1 V( {/ \/ R/ M7 l
三、基于GPU的蒙皮
9 X" Q( y7 g) r4 p3 G+ w7 [; u9 c, G. F6 E2 P& H
  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。  a6 O7 W8 d: r1 `! w$ }
: t8 H* f( y- o
四、非常流行的“换装”系统/ I/ q7 N; t  G& o

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

本版积分规则

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

GMT+8, 2025-11-15 01:43 , Processed in 0.018612 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

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